首页
社区
课程
招聘
[原创] 用 P-code 做 VMP 还原:一份实战向的 IR 入门
发表于: 4小时前 121

[原创] 用 P-code 做 VMP 还原:一份实战向的 IR 入门

4小时前
121

用 P-code 做 VMP 还原:一份实战向的 IR 入门

面向:会看 ARM64 汇编、想上手 VMP / 去混淆,但没接触过中间表示的同学。
环境:Python 3 + pypcode(Ghidra 反编译器的 Python 绑定)+ keystone。
注意: 模型以笔记底座写的东西,上下文是七神分析

0. 先说结论:P-code 能帮你干什么

先说人话。如果你正在还原一个 VMP,下面这几件事大概率正在恶心你:

  • handler 长得都差不多,语义却完全不同,一个个抠汇编抠到吐血
  • 寄存器被反复复用,x10 上一条是「源值」、下一条是「临时」,纯看汇编得自己脑内记账
  • 想知道「这个值从哪来、又流到哪去」,汇编里全是隐式的,追两步就断

P-code 解决的就是这三件事:

  1. 一条汇编变成若干条语义单一的微操作COPY / LOAD / INT_ADD / …),没有隐式副作用
  2. 每个值都有名字(varnode),寄存器重命名、内存别名、条件码这些脏东西全被显式化
  3. def-use 是现成的 —— 每个 varnode 谁定的、谁用的,一目了然

第三条最值钱:它本身就是一张 DFG(数据流图)的骨架。后面做污点追踪、锚点定位、语义还原,全建在它上面。

所以对 VMP 还原来说,P-code 不是「锦上添花的可视化」,而是把「读汇编」这件事换成「查图」


1. 拿到 P-code

pypcode 吃的是机器码,不是汇编文本。所以明文汇编得先用 keystone 编一遍:

import keystone, pypcode

ARCH = "AARCH64:LE:64:v8A"          # SLEIGH 规范名:Ghidra 的 AArch64

ks = keystone.Ks(keystone.KS_ARCH_ARM64, keystone.KS_MODE_LITTLE_ENDIAN)
enc, count = ks.asm("ldr w9, [x23]")

ctx = pypcode.Context(ARCH)
ops = ctx.translate(bytes(enc), base_address=0x2e87e8)   # 返回 Translation
ops.ops                                                   # PcodeOp 列表

两个参数值得说:

base_address 决定 Sleigh 译码时的指令地址,进而决定所有 PC 相对指令bl / adr / 字面量 ldr)算出来的目标常量。你的 trace 里给的是模块偏移(RVA),喂进去才能还原出真实的调用目标;不给就是 0,目标全错。注意它只对第一条指令一定对 —— 把不连续的指令拼成一块喂进去,后面的就都歪了。

ctx 是个重家伙,构造一次就够了。几万条指令的循环里反复 pypcode.Context(ARCH) 会死得很难看。

补一句:keystone 的 AArch64 停在 v8.0,ARMv8.1 的 LSE 原子指令(casal / ldaddal 之类)一条都不认。trace 里真出现的话,一条编不出来就会带崩整块 —— 要么退回 llvm-mc -show-encoding 取编码,要么逐条编译、编不动的走兜底。pypcode 那边是认这些指令的,拿到字节就能正常译。

2. PcodeOp:三个分支

每个 PcodeOp 就三样东西:

op.opcode   # 操作标签
op.output   # 输出 Varnode(可能为 None)
op.inputs   # 输入 Varnode 列表

opcode 拿名字用 op.opcode.name,常见的有:

类别 例子
搬运 COPY
算术/逻辑 INT_ADD INT_MULT INT_LEFT INT_AND INT_XOR
比较 INT_EQUAL INT_SLESS INT_LESS
扩展/截断 INT_ZEXT INT_SEXT SUBPIECE
内存 LOAD STORE
控制流 BRANCH CBRANCH BRANCHIND CALL CALLIND RETURN
边界 IMARK
OpCode.COPY
  output: unique[c700:8]    input: x23

3. varnode:一切都拆成三元组

P-code 里没有「操作数」这种模糊概念,任何输入输出都是 varnode,一个 (space, offset, size) 三元组:

vn.space.name   # 'register' / 'unique' / 'ram' / 'const'
vn.offset       # 空间内的偏移,含义随 space 变
vn.size         # 字节长度

拿 x23 举例:

print(vn.space.name, hex(vn.offset), vn.size)
# register 0x40b8 8

size 这东西很关键:x23w23offset 完全相同(0x40b8),只有 size 不同(8 / 4)。这是后面所有「按 offset 认寄存器」写法的根据,也是「按名字认会漏」的原因。

全部寄存器表可以直接从 Context 拿:

for vn, name in ctx.getAllRegisters().items():
    print(name, hex(vn.offset), vn.size)
# x23 0x40b8 8
# w23 0x40b8 4

4. space 决定 offset 是什么(核心)

这是全文最重要的一张表:

space offset 含义 实战意义
register 寄存器在 register space 的偏移 真实世界的输入,值来自 CPU / trace
unique 指令内部临时值,SLEIGH 顺序分配 中间价,编译器生成的临时变量
ram 内存地址 内存里的东西
const 常量值本身 已知的确定值

对应的表示法:

register[0x40b8:8]     # 寄存器,偏移 0x40b8
unique[c700:8]         # 临时变量
ram[0x794d9af5c8:8]    # 内存地址 0x794d9af5c8
const[0x30:8]          # 常量 0x30

const 的 offset 就是值本身,这一点第一次见会愣一下 —— 它不指向任何存储,const[0x30:8] 的意思就是「这里有个 0x30」。

看一眼真实的 ldr w9, [x23] 提升出来是什么:

IMARK    in=[ram[0x2e87e8:4]]
COPY     unique[0xc700:8] = register[0x40b8:8]         ; x23 → 临时
LOAD     unique[0x48f00:4] = *(ram)unique[0xc700:8]    ; 用临时当地址去读
INT_ZEXT register[0x4048:8] = zext(unique[0x48f00:4])  ; 零扩展回 x9

一条汇编,四条 P-code。 而且注意第二行 —— 这就是后面要讲的坑。

5. 三个必踩的坑

5.1 IMARK:指令边界藏在流里

pypcode 译每条指令时,会在流里先插一个 IMARK,它的输入就是被译指令的地址:

IMARK    in=[ram[0x2e87e8:4]]

PcodeOp 本身不携带地址,你拿到的是一整条打平的 p-code 流。想知道「这条 op 属于哪条汇编」,唯一的依据就是 IMARK:

  • IMARK 不入业务逻辑,只用来切边界
  • 它后面直到下一个 IMARK 之前的真实 p-code,都属于这条指令
  • A64 定长 4 字节,所以每条指令恰好一个 IMARKnop / hint 这类是「一个 IMARK + 零条 op」)

我一开始不知道这个,直接拿 op 去找地址,找了半天找不到。这是最容易卡住半天的地方。

5.2 LOAD / STORE 的第一个输入不是地址

LOAD  out=unique[0x48f00:4]  in=[const[0x3f857ee0:8], unique[0xc700:8]]
                                        ^^^^^^^^^^^^^^^^   ^^^^^^^^^^^^^^^^
                                        这个是空间标记      这个才是地址

第一个输入是 pypcode 的「地址空间标记」常量,它的值是 ram 空间的基址,而且每个 Context 构造时随机化(实测两次跑差得十万八千里)。真正的地址是第二个输入。

# 正确姿势
addr  = op.inputs[1]        # LOAD / STORE 都是
value = op.inputs[2]        # STORE 才有

不处理这个的话,你跨进程重跑一遍,所有二代 IR 全对不上 —— 因为那串随机基址每次都变。我的做法是把它归一成 0。

5.3 unique 的 offset 会被复用

unique 的生命周期只在单条指令内,所以 SLEIGH 会复用编号。同一个 unique[0xae00:8],在这条指令里是左移的源,在另一条指令里可能是完全不相干的东西:

; ldr x10, [x24, x10, lsl #3]
unique[ae00:8] = COPY x10
unique[da00:8] = COPY unique[ae00:8]
unique[da00:8] = INT_LEFT unique[da00:8], 0x3
...
; str x10, [x24, x13, lsl #3]      ← 另一条指令
unique[ae00:8] = COPY x13           ← 同一个 offset,值完全无关

所以跨指令不能按 unique 的 offset 认「同一个值」。 这条在写数据流分析时是个隐蔽的坑,特别是当你想着「先把整个流扫一遍,回头再统一分类」的时候 —— 回头那一格早被后面的指令覆盖了。要分类就在扫的过程中分类。

6. 折叠:把 register 踢进 const

到这一步,P-code 还只是「汇编的另一种写法」。真正让它变成影子的,是把已知的值代进去。

概念很简单:折叠 = 把 varnode 从 register space 踢到 const space

折叠前:  register[0x40b8:8]          ; 这是「x23」,值未知
折叠后:  const[0x7853c7e1dc:8]       ; 这是「0x7853c7e1dc」,值已知

值从哪来?从 trace(动态执行)来,不是 P-code 自己算出来的。

# 一条 trace 行(七神 VMP)
{'asm': 'ldr w9, [x23]', 'read': {'X23': '0x7853c7e1dc'}, 'write': {'W9': '0x0'}}

read 列是执行前的值,write 列是执行后的值。手工折一条看看:

原始汇编:  madd x10, x12, x11, x10
read 列:   {X12: 0x0, X11: 0xdec, X10: 0x794d9af5c8}

第一代(纯 SLEIGH):
    x10 = (x12 * x11) + x10

第二代(把三个源 varnode 折成常量):
    x10 = (0x0 * 0xdec) + 0x794d9af5c8
          └──────────────┘
           顺手就能常量折叠成 0x794d9af5c8

两个概念别混:

  • 替换:源 varnode 换成 trace 里的常量值
  • 常量折叠:代数化简,能算的算出来,中间 unique 变死代码消掉

顺序是「先折输入,再化简」,因为 unique 的值 trace 不记,只能靠输入折成常量之后化简掉。

折叠的边界就是 trace 给了啥

trace 给了 能折什么
读列(源寄存器值) 折源
写列(目标寄存器值) 折目标
内存地址 → 值 没记 —— 所以 LOAD/STORE 折不了「这地址里是啥」,只能靠输出寄存器已知之后反向折

还有两个已知的洞,动手前先有个心理准备:

  1. flags / NZCV 不记cmp 的写列是空的,csel 的读列也不含 NZCV。凡是要靠条件码穿透的控制流(cbz / tbz / csel),到这里会断。
  2. 内存地址 → 值不记。追控制流够用,追内存 key 不够。

7. 实战:一条 VMP handler 的完整走读

光讲概念没感觉,上真家伙。下面这段是我在还原一个线程化 VMP(七神)时截的,一条普通 handler

先把原文汇放在这:

0x2e87e8  ldr w9, [x23]                  ; 读 VM 的 IP
0x2e87ec  mov w12, #0x30                 ; VM 指令大小
0x2e87f0  ldr x11, [x20]                 ; 字节码基址
0x2e87f4  ldrb w10, [x8, #0x8]           ; 操作数:源寄存器索引
0x2e87f8  add w9, w9, #0x1               ; IP++
0x2e87fc  ldrb w13, [x8, #0x9]           ; 操作数:目标寄存器索引
0x2e8800  ldrsh x14, [x8, #0xa]          ; 操作数:16 位有符号偏移
0x2e8804  nop
0x2e8808  smaddl x8, w9, w12, x11        ; X8 = base + IP * 0x30(下一条指令)
0x2e880c  ldr x10, [x24, x10, lsl #3]    ; regs[src]
0x2e8810  str w9, [x23]                  ; 写回 IP
0x2e8814  ldrh w11, [x8]                 ; 下一条的 opcode
0x2e8818  add x10, x10, x14              ; regs[src] += offset
0x2e881c  str x10, [x24, x13, lsl #3]    ; regs[dst] = 新值
0x2e8820  ldr x11, [x25, x11, lsl #3]    ; 查 handler 表
0x2e8824  br x11                         ; 跳下一个 handler

trace 里同一条的 read/write 列(只列有用的):

{'asm': 'ldr w9, [x23]',   'read': {'X23': '0x7853c7e1dc'}, 'write': {'W9': '0x0'}}
{'asm': 'ldrb w10, [x8, #0x8]', 'read': {'X8': '0x7c4f75c1d0'}, 'write': {'W10': '0x1'}}
{'asm': 'add w9, w9, #0x1',     'read': {'W9': '0x0'},  'write': {'W9': '0x1'}}
{'asm': 'ldr x10, [x24, x10, lsl #3]', 'read': {'X24': '0x7853c84240', 'X10': '0x1'}}

逐条拆

ldr w9, [x23] —— 读 IP

IMARK    in=[ram[0x2e87e8:4]]
COPY     unique[c700:8] = x23
LOAD     unique[48f00:4] = *(ram)unique[c700:8]
INT_ZEXT x9 = zext(unique[48f00:4])

注意这里:x23 压根没出现在 LOAD 里。它只出现在 COPY 的输入端 —— SLEIGH 先把寄存器搬进一个临时,再用临时当地址去读。

这一点是我做锚点追踪时最大的认知修正:在汇编层看,「读 IP 的是这条 ldr,它引用了 x23」;在 P-code 层看,「x23 是一条 COPY 的源,真正读内存那条 LOAD 的地址是个 unique」。 想顺着寄存器找人,必须在 varnode 这一层追,不能在指令这一层认。

mov w12, #0x30

IMARK
COPY     x12 = 0x30         ; 常量直接就是 const[0x30:8]

ldr x11, [x20] —— 取字节码基址

COPY     unique[c800:8] = x20
LOAD     x11 = *(ram)unique[c800:8]

同一个套路。trace 里 X11 = 0x7c4f75c1d0,这就是字节码数组的基址。

ldrb w10, [x8, #0x8] —— 读操作数(源寄存器索引)

INT_ADD  unique[bc00:8] = x8 + 0x8
LOAD     unique[4a100:1] = *(ram)unique[bc00:8]
INT_ZEXT x10 = zext(unique[4a100:1])

x8 是当前 VM 指令的地址(threaded dispatch 的游标),+0x8 就是这个指令记录里的第一个操作数字节。trace 给出 W10 = 0x1 —— 源寄存器索引是 1

add w9, w9, #0x1 —— IP++

COPY       unique[22d00:4] = 0x1
INT_CARRY  tmpCY = carry(w9, unique[22d00:4])
INT_SCARRY tmpOV = scarry(w9, unique[22d00:4])
INT_ADD    unique[22f00:4] = w9 + unique[22d00:4]
INT_SLESS  tmpNG = unique[22f00:4] s< 0x0
INT_EQUAL  tmpZR = unique[22f00:4] == 0x0
INT_ZEXT   x9 = zext(unique[22f00:4])

一条 add 编译出 7 条 P-code。 多出来的全是 flags(carry / overflow / negative / zero)—— 这就是「隐式副作用被显式化」的意思:汇编里你看不出 add 顺手改了 NZCV,P-code 里它明明白白写在流上。

ldrb w13, [x8, #0x9] / ⑦ ldrsh x14, [x8, #0xa]

INT_ADD  unique[bc00:8] = x8 + 0x9        ; ← 同一个 unique offset,值变了
LOAD     unique[4a100:1] = *(ram)unique[bc00:8]
INT_ZEXT x13 = zext(unique[4a100:1])

INT_ADD  unique[be00:8] = x8 + 0xa
LOAD     unique[4c100:2] = *(ram)unique[be00:8]
INT_SEXT x14 = sext(unique[4c100:2])      ; 16 位有符号

⑦ 就是 5.3 那个坑的现场:unique[bc00:8] 在 ④ 和 ⑥ 里是两个不同的值。拿 offset 当身份跨指令用,必错。

另外 sext 说明这个操作数是有符号的 —— trace 里 X14 = 0xfffffffffffffb60,也就是 -0x4a0。这是 VM 里「带偏移的寄存器访问」的那个偏移量。

smaddl x8, w9, w12, x11 —— 算下一条指令地址

INT_SEXT  unique[6db00:8] = sext(w9)      ; IP(已经是自增后的 1)
INT_SEXT  unique[6dd00:8] = sext(w12)     ; 0x30
INT_MULT  unique[6df00:8] = unique[6db00:8] * unique[6dd00:8]
INT_ADD   x8 = x11 + unique[6df00:8]      ; base + IP * 0x30

trace 给出 X8 = 0x7c4f75c200,而 0x7c4f75c1d0 + 1*0x30 = 0x7c4f75c200 —— 对上了

这条顺带把 VM 的指令格式给透了:每条 VM 指令占 0x30 字节,IP 是「第几条」的下标,不是字节偏移。

ldr x10, [x24, x10, lsl #3] —— 取虚拟寄存器

COPY      unique[ae00:8] = x10            ; 索引 1
COPY      unique[da00:8] = unique[ae00:8]
INT_LEFT  unique[da00:8] = unique[da00:8] << 0x3    ; *8
INT_ADD   unique[e300:8] = x24 + unique[da00:8]
LOAD      x10 = *(ram)unique[e300:8]

x24虚拟寄存器文件基址[x24 + idx*8] 就是 regs[idx]。trace 里 X24 = 0x7853c84240idx = 1,读出 x10 = 0x7853c84200

连 LSL 都被展开成 4 条 P-code —— 藏不了东西。

str w9, [x23] —— 写回 IP

COPY     unique[c700:8] = x23
STORE    *(ram)unique[c700:8] = w9

STORE 的输入是 [空间标记, 地址, 值],值就是 w9 = 0x1要取「存回去的值」,直接看 inputs[2]

ldrh w11, [x8] —— 读下一条的 opcode

COPY     unique[c600:8] = x8
LOAD     unique[4a500:2] = *(ram)unique[c600:8]
INT_ZEXT x11 = zext(unique[4a500:2])

x8 就是 ⑧ 算出来的「下一条指令地址」,从它开头读 2 字节 = 下一个 opcode。trace 里 W11 = 0x8d

add x10, x10, x14 —— 真正干活的地方

COPY        unique[23f00:8] = x14
INT_CARRY   tmpCY = ...
INT_SCARRY  tmpOV = ...
INT_ADD     unique[24100:8] = x10 + unique[23f00:8]
INT_SLESS   tmpNG = ...
INT_EQUAL   tmpZR = ...
COPY        x10 = unique[24100:8]

0x7853c84200 + (-0x4a0) = 0x7853c83d60,trace 里 X10 = 0x7853c83d60 —— 对上。

str x10, [x24, x13, lsl #3] —— 写回虚拟寄存器

COPY      unique[74600:8] = x10
COPY      unique[ae00:8] = x13             ; 目标索引 1
COPY      unique[da00:8] = unique[ae00:8]
INT_LEFT  unique[da00:8] = unique[da00:8] << 0x3
INT_ADD   unique[e300:8] = x24 + unique[da00:8]
STORE     *(ram)unique[e300:8] = unique[74600:8]

ldr x11, [x25, x11, lsl #3] + br x11 —— 跳下一个 handler

COPY      unique[ae00:8] = x11             ; opcode 0x8d
INT_LEFT  unique[da00:8] = ... << 0x3
INT_ADD   unique[e300:8] = x25 + unique[da00:8]
LOAD      x11 = *(ram)unique[e300:8]       ; 查 handler 表
COPY      pc = x11
BRANCHIND pc

连起来是什么

把上面这些片段拼起来,这条 handler 干的事一句话就能说清:

regs[dst] = regs[src] + imm16,然后 IP++,然后跳到下一个 handler。

一条「虚拟寄存器 + 立即数偏移」指令。而且顺带把整个 VM 的骨架摸出来了:

  • VM 的 IP 在 [x23](x23 = ctx+0xC)
  • VM 的寄存器文件在 [x24 + idx*8](x24 = ctx+0x6070,32 个 8 字节槽)
  • handler 表在 [x25 + opcode*8](x25 固定)
  • 每条 VM 指令 0x30 字节,操作数在记录内偏移 +8 / +9 / +0xA

这四句话就是整个 VM 的骨架,剩下 1580 个 handler 全是在这个骨架上换算法。

而这三条锚点线(x23 / x24 / x25),全部是从 P-code 的 varnode 里追出来的 —— 看汇编你得一条条读,看 P-code 你可以写代码去追。

8. 实战:从 varnode 追锚点寄存器

接着上面说。既然锚点是 x23 / x24 / x25,那怎么自动化地找出「一条 handler 里,谁在读 IP、谁在写 IP」?

别在指令层找,在 varnode 层追。 这是我踩了坑之后的结论 —— 如果你按 space == "register" and name == "x23" 去扫 inputs,你只会收上来一堆 COPY(见 7.①),啥也说明不了。

思路是前向标记(一趟就行,P-code 是 SSA 序,def 先于 use):

种子:  register[0x40b8]  →  记上「ip」这个色

普通 op  :输出继承所有输入的色(并集);输入全无色就清色(寄存器被重新赋值了)
LOAD     :地址有色 ≠ 读出来的值有色 —— 把地址的色换成「值」色(ip → ipval)落到输出
STORE    :不产值,但地址/值的色要在扫到时就记下来

最后那条划重点:unique 的 offset 会被复用(5.3),所以分类必须在扫的过程中做;扫完再回头统一看,那一格早被覆盖了。

跑完之后你手上就有:

判定 含义
LOAD 地址带 ip 读 IP
STORE 地址带 ip 写 IP,inputs[2] 就是新 IP
LOAD 地址带 regf 读虚拟寄存器 regs[idx]
STORE 地址带 regf 写虚拟寄存器
LOAD 地址带 table 尾巴那次 dispatch

再补一句按 offset 别按名字x23w23 的 offset 都是 0x40b8,只有 size 不同。按名字认,w23 就被漏了;按 offset 认,两个视图自动归到同一个槽。

实测下来这个方法很好使。全量 trace 跑一遍,普通 handler 的 IP 变化量分布是这样的:

step=+1    22208 条      ; 顺序落下一个 slot
step=+3    13170 条
step=+4     4650 条      ; 一条 VM 指令占 4 个 slot
step=+5     1834 条
step=-10    1085 条      ; 负数 = 条件跳转
step=-23     772 条
...

几个反直觉的点:

  • step=+1 连一半都不到。 我原以为「一条 VM 指令 = IP+1」,实际大量指令占 3~6 个 slot。IP 是 slot 下标这件事,是从数据里看出来的,不是猜出来的。
  • 负数就是跳转。 抽到一条 CMP/BEQpre=17, post=6, step=-11,而它的 CSEL 选出来 offset 是 -12-12+1 = -11 —— 语义立刻对上,比读汇编快得多。
  • 同一个 handler 在不同 VM 调用里,锚点寄存器(x23)的值不一样(实测分别是 0x7853c6aefc0x7853c7e1dc,ctx 分配位置不同)。所以按寄存器值认锚点行不通,必须按 offset。 这个坑躲过一次就记一辈子。

9. 踩坑清单

动手之前先扫一眼,能省几天:

  1. 地址不在 op 上 —— 想知道 op 属于哪条指令,只能靠 IMARK 切边界
  2. LOAD / STORE 第一个输入不是地址 —— 是每进程随机化的空间标记常量,地址在 inputs[1]
  3. unique 的 offset 会被复用 —— 跨指令不能按它认身份
  4. x23 / w23 同 offset 不同 size —— 按 offset 认寄存器,别按名字
  5. translatebase_address 影响 PC 相对指令 —— 不连续指令拼块喂进去,第二条以后目标全错
  6. Context 构造很贵 —— 别放进循环
  7. keystone 不认 ARMv8.1 的 LSE 原子指令 —— 得留 llvm-mc 兜底
  8. trace 不记 flags 和「内存地址 → 值」 —— 控制流穿透会在这两处断

10. 结语

P-code 这东西看文档十分钟就能「懂」,但真正让它产生价值的是第 7、8 节那两步:把汇编的隐式数据流摊成显式的 varnode def-use,然后在 varnode 层写代码去追。

我在 VMP 还原里最大的一个转折点,就是意识到「锚点寄存器不该在指令层找,该在 varnode 层找」—— ldr w9, [x23] 在汇编层是「引用了 x23」,在 P-code 层却是「x23 是一条 COPY 的源,真正读内存那条 LOAD 的地址是个 unique」。同一件事,两个层次看到的完全不一样。

再往上一步就是「影子轨道」了:同一个 handler 提升两代,第一代是纯 SLEIGH 的静态骨架,第二代把 trace 的值折进 varnode。两代同源同 varnode,于是可以像影子一样互相穿透 —— 静态这边负责「长什么样」,动态那边负责「跑起来是什么」,各管各的。

这部分内容比较多,放在项目实践那篇里写了。


冰与火的战歌:Windows内核攻防实战高级班!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。

收藏
免费 1
打赏
分享
最新回复 (1)
雪    币: 112
活跃值: (9345)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
2
tql
1小时前
0
游客
登录 | 注册 方可回帖
返回