特别说明:本帖不提供一键脱壳或过检测代码,仅公开我在还原 VMP 虚拟机执行框架时的寄存器锚定方法论和 IR 提升过程中的卡点。我知道它不完整,但市面上公开资料里,大多数还是在讲 handler 和字节码,手动分析
依旧是 VMP 方面的内容但 VMP 也确实是一个难点,各种各样的无限变体,各种各样的思路,还是先来回顾一下
Unpacking Virtualization Obfuscators(WOOT 2009)的六步法则
然后看我们做了什么方面的简化和优化
VM 本身分析
首先直接来一遍流程
so: 0x2e01b8(ollvm混淆外壳)
-> 0x2E2B34(间接调用外壳)
-> 0x2E2BE8(壳调用点)
-> 0x2d3780(初始化字节码与调用VM;内部先调 sub_2E5C68 初始化VM上下文、sub_2E5D28 写ctx字段)
-> 0x2E5C2C(VM会被嵌套调用,入口被封装成独立函数;用 off_3E1E30[*(ByteCode+88)] 选健壮检测包装)
-> 0x2e66d0(VM健壮检测:栈/指针边界校验;同类包装还有 0x2e67a8、0x2e68b8)
-> 0x2E7FE8(vmOneHandler,VM执行)
这就是从外层混淆一步一步进到 VM 的流程了,来看第一个 handler 的汇编逻辑,我也是小小做了一些语义化,这个还好追一些,如果是 OLLVM 太长就参考六神的处理方式吧,我是简单小看了一下
.text:00000000002E7FE8 SUB SP, SP, #0x70
STP X29, X30, [SP,#0x10+var_s0]
STP X28, X27, [SP,#0x10+var_s10]
STP X26, X25, [SP,#0x10+var_s20]
STP X24, X23, [SP,#0x10+var_s30]
STP X22, X21, [SP,#0x10+var_s40]
STP X20, X19, [SP,#0x10+var_s50]
ADD X29, SP, #0x10
LDR X9, [X0] ; 从 X0(ByteCode**) 取实际字节码指针
MOV X23, X1 ; 定位到VM CTX的基址,为后续做准备
MOV W8, #0x6064
ADRL X25, handlerTables ; 从这里就可以定数据流转和handler跳转边界了
MOV X19, X1 ; VM上下文,这个不知道是做什么的
LDRH W10, [X9]
MOV X20, X0 ; 以后的字节码来源,锚点寄存器之一
STR WZR, [X23,#0xC]! ; 指针加法,初始化 IP = 0
ADD X24, X23, X8 ; X24 = ctx+0x6070 = 整数虚拟寄存器文件基址(32×8B)
MOV W8, #0x6164
LDR X10, [X25,X10,LSL#3]
ADD X26, X23, X8 ; X26 = ctx+0x6170 = 32位浮点寄存器文件基址([x26,idx,lsl #2])
MOV W8, #0x61E4
ADD X27, X23, X8 ; X27 = ctx+0x61F0 = 64位浮点寄存器文件基址([x27,idx,lsl #3])
MOV X8, X9
STP X26, X27, [SP,#0x10+var_10]
.text:00000000002E804C BR X10
那么也就差不多理解了锚点位置了
- x23:既是 IP 字段的地址,又是算所有寄存器文件基址的基准(ctx+0xC),后续对寄存器的计算根据这个进行的定位
- x20:字节码来源(ByteCode**,[x20] 才是实际字节码指针)
- x19:VM 上下文(ctx 基址)
- x25:handler 表
- x24:整数虚拟寄存器文件基址(ctx+0x6070,32 个 8 字节槽,索引 0-31)
- x26:32 位浮点/字寄存器文件基址(ctx+0x6170,[x26,idx,lsl #2])
- x27:64 位浮点寄存器文件基址(ctx+0x61F0,[x27,idx,lsl #3])
- x8:当前 VM 指令指针(threaded dispatch 契约:handler 从 [x8+8]/[x8+9]/[x8+0xA]/[x8+0x10] 读操作数,并在尾部推进到下一条 48 字节指令,下个 handler 直接接着用)
X24/X26/X27 的判定依据
IDA + trace 交叉验证
1. 初始化函数直接种值 —— 铁证
sub_2E5C68(0x2e5c68,由 0x2d3780 调用,初始化 VM 上下文):
MOV X8, #0x803; MOVK 0xC0C,<<16; MOVK 0x706,<<32; MOVK 0x200,<<48 ; X8 = 0x020007060C0C0803 魔数
STR X8, [X0] ; ctx+0x0 = 魔数
STRH W9, [X0,#8] ; ctx+0x8 = 0x200
STR WZR, [X0,#0xC] ; ctx+0xC = 0(IP)
STR XZR, [X19,#0x10] ; ctx+0x10 = 0
STRB W10, [X19,#0x20] ; ctx+0x20 = 1
MOV W8, #0x6070
ADD X0, X0, X8 ; X0 = ctx+0x6070
BL .memset ; memset(ctx+0x6070, 0, 0x381) ← 清零整个寄存器区
MOV W9, #0x6030
ADD X20, X19, X9 ; X20 = ctx+0x6030
STR X20, [X19,#0x6078] ; ctx+0x6078 = ctx+0x6030 ← 寄存器槽1预置(= [x24+8])
trace 里 VM 入口前 [x24+8] = 0x7853c84200 = ctx+0x6030,与上完全吻合。
2. trace 统计
全 trace 共 34.3 万次 [x24, idx, lsl/uxtw #3] 索引访问,索引值严格落在 [0, 31] → 32 个 8 字节槽 = 0x100,恰好填满 0x6070 → 0x6170
3. handler 统一模板
所有 handler 一致:a1=x8(当前 VM 指令),操作数在 [x8+8]/[x8+9]/[x8+0xA],访问虚拟寄存器一律 *(x24 + 8*idx)。实测示例:
| 0x2eae28 | CMP/BEQ:if(reg[a]==reg[b]) offset=0; IP += offset+1 |
| 0x2eb03c | SHL:reg[dst] = reg[src] << (shift+32) |
| 0x2e92bc | OR:reg[dst] = reg[a] | reg[b] |
| 0x2e96b8 | LDRB:reg[dst] = *(u8*)(reg[base] + i16) |
| 0x2e8e40 | STR:*(reg[base] + i16) = reg[val] |
| 0x2e95cc | LDR32→32 位浮点文件:*(u32*)(x26 + 4*idx) = *(u32*)(reg[base]+i16) |
| 0x2e9e90 | 64 位浮点 →STR:*(u64*)(reg[base]+i16) = *(u64*)(x27 + 8*idx) |
4. VM 上下文布局(ctx 由 0x2d3780 在栈上分配,sp 传入)
| +0x0 | 魔数 0x020007060C0C0803 |
| +0x8 | 0x200(标志) |
| +0xC | IP(4 字节,入口清零) |
| +0x10 | 调用链恢复指针 |
| +0x20 | 8 位索引(健壮检测用) |
| +0x6030 | 预置到寄存器 1 的基址值 |
| +0x6070 (0x100) | x24:整数虚拟寄存器文件,32×8B,idx 0-31 |
| +0x6170 (0x80) | x26:32 位浮点寄存器文件 |
| +0x61F0 (0x200) | x27:64 位浮点寄存器文件 |
5. 0x2E87E8 语义(修正)
reg[dst] = reg[src] + signed_offset,其中 src = 字节码[8],dst = 字节码[9](上文第二个版本注释写反了):
X10 = regs[字节码[8]]; ; 读出源寄存器(通常是指针)
X10 += signed(字节码[0xA]); ; 重定位
regs[字节码[9]] = X10; ; 写回目标寄存器
6. 调用链细节
- sub_2E5C2C(0x2e5c2c) 是 VM 入口封装:off_3E1E30[*(ByteCode+88)] 从 0x2e66d0/0x2e67a8/0x2e68b8 三选一,再 blr。
- 三个健壮检测包装逻辑一致:栈/指针边界校验(越界触发 MEMORY[0xDEADBEEF00000001]=1 的 trap),然后 bl 调 vmOneHandler(ByteCode+off, ctx)。
思路说明
实际 handler 观摩
来看一个实际的 handler 吧,从 trace 里面拿一条简单的,handler 表的数量是真不少的,基础 handler 是从 0x3E1E58 到 0x3E3708 一共是 1580 个,调用到了 335 个(日志量 340 万),然后还有 VMP 专属的 CF 系列函数调用,也是要单独处理的,还有 VMP 的栈操作或者寄存器归一化操作,这里就可以直接采用编译器前人所说的多趟(Pass)了,最著名的就是 LLVM Pass 了,相信各位大佬有研究过 OLLVM 的也看过这东西
.text:00000000002E87E8 LDR W9, [X23] ; 读 VM 的 IP 寄存器
.text:00000000002E87EC MOV W12, #0x30 ; W12 = 0x30(VM 指令大小)
.text:00000000002E87F0 LDR X11, [X20] ; 从 X20 读取当前字节码基址
.text:00000000002E87F4 LDRB W10, [X8,#8] ; 读字节码偏移 +8 的 1 字节操作数(寄存器索引:源)
.text:00000000002E87F8 ADD W9, W9, #1 ; IP++
.text:00000000002E87FC LDRB W13, [X8,#9] ; 读字节码偏移 +9 的 1 字节操作数(寄存器索引:目标)
.text:00000000002E8800 LDRSH X14, [X8,#0xA] ; 读字节码偏移 +0xA 的 16 位有符号偏移量
.text:00000000002E8804 NOP
.text:00000000002E8808 SMADDL X8, W9, W12, X11 ; X8 = 字节码基址 + IP * 0x30(下一条 VM 指令地址)
.text:00000000002E880C LDR X10, [X24,X10,LSL#3] ; 取源寄存器值:regs[src]
.text:00000000002E8810 STR W9, [X23] ; 更新 VM 的 IP 寄存器
.text:00000000002E8814 LDRH W11, [X8] ; 读下一条 VM 指令的 opcode
.text:00000000002E8818 ADD X10, X10, X14 ; 源寄存器值(通常是指针)+= 偏移量
.text:00000000002E881C STR X10, [X24,X13,LSL#3] ; regs[dst] = 新值
.text:00000000002E8820 LDR X11, [X25,X11,LSL#3] ; 用下一条 opcode 查 handler 表
.text:00000000002E8824 BR X11 ; 跳转到下一个 handler
首先我们确定一下我们要注意的:IP、(src1、src2、dest)三个寄存器,还有一个棘手的东西x8寄存器,这东西改后给下一条使用的,这时候就需要一个标记位置,我斗胆猜测一下nop就是给x8的变化准备的,随后就给我打死了,这玩意是真没啥规律啊,我也是真没招了
这时候就需要冷静下来了,从简单的开始想:
- 找 x25 寄存器相关内容,当然,其实也就两个一个取值然后赋值,被赋值的寄存器跳转下一个 handler
- IP 方面就找取 x23 以及存 x23 的寄存器,看看它是怎么进行操作与被操作的
- 字节码相关的寄存器,x8 是会更新迭代的,这块难点就是拿到值,不过我有 trace 记录,感觉可以考虑代入一下?
这三条主要的线就迭代出来了,第一个负责我们切割 handler 的边界,第二可以看执行的对不对,第三个就是 handler 逻辑方面的了
The "iframe" tag is not supported by your browser.
The "iframe" tag is not supported by your browser.
The "iframe" tag is not supported by your browser.
VM 的返回:vmRetHandler(0x2ebc2c,原 sub_2EBC2C)
在具体看之前,我们来找一下控制 VM 返回的函数,找到以后就可以知道 VMP 的嵌套深度了,入口 0x2e6774(sub_2E66D0 里 bl vmOneHandler)和返回 0x2ebc54 能精确对上之后,调用深度就有戏了。这里记一下怎么找到的这个 RET,算是 log + IDA 对拍的一个标准打法。
怎么定位的
VM 里所有 handler 都是 br 尾跳(threaded interpreter),唯一一次真正的 ret 就是返回。而每个 wrapper 都是 bl vmOneHandler 进 VM,bl 会写 LR,所以:
在 code.log 里搜 ret 指令,且执行前 LR = bl 设下的返回地址,命中的就是 VMP 的 RET。
全 trace 61 次 VM 执行(bl 在 0x2e6774,LR=0x794da10778),全部命中同一个 ret —— 模块偏移 0x2ebc54,返回点 0x2e6778,精确弹回 wrapper。
2d22f 0x794da15c40 0x2ebc40 "str w8, [x23]" ; 返回值 → ctx+0xC
2d230 0x794da15c44 0x2ebc44 "ldp x24, x23, [sp, #0x40]"
2d231 0x794da15c48 0x2ebc48 "ldp x28, x27, [sp, #0x20]"
2d232 0x794da15c4c 0x2ebc4c "ldp x29, x30, [sp, #0x10]" ; 恢复 LR=0x794da10778
2d233 0x794da15c50 0x2ebc50 "add sp, sp, #0x70" ; 拆 vmOneHandler 的帧
2d234 0x794da15c54 0x2ebc54 "ret"
2d235 0x794da10778 0x2e6778 "ldr x8, [x19, #0x10]" ; 回到 wrapper
返回 handler 逻辑
它就是个普通 handler,不是特殊路径,opcode 0xbd 分派过来的:
.text:00000000002EBC2C LDRB W8, [X8,#8] ; 操作数 = 返回哪个寄存器槽(本次 trace 是 3)
.text:00000000002EBC30 LDP X20, X19, [SP,#0x60] ; ┐
.text:00000000002EBC34 LDR X8, [X24,X8,LSL#3] ; │ 从寄存器文件取返回值(64 位)
.text:00000000002EBC38 LDP X22, X21, [SP,#0x50] ; │
.text:00000000002EBC3C LDP X26, X25, [SP,#0x30] ; │ 拆帧
.text:00000000002EBC40 STR W8, [X23] ; │ 存低 32 位 → ctx+0xC(返回值)
.text:00000000002EBC44 LDP X24, X23, [SP,#0x40] ; │
.text:00000000002EBC48 LDP X28, X27, [SP,#0x20] ; │
.text:00000000002EBC4C LDP X29, X30, [SP,#0x10] ; │ 恢复 LR
.text:00000000002EBC50 ADD SP, SP, #0x70 ; ┘ 帧 = vmOneHandler 开出来的那 0x70
.text:00000000002EBC54 RET X30
链尾的普通 handler(0x2e8e7c)读到下一条 opcode 0xbd → handlerTables[0xbd] = 0x2ebc2c → br x11 接进返回 handler,跟其它分派一模一样。
返回值怎么传的(ctx+0xC 槽复用)
- ctx+0xC 这个槽是复用的:VM 运行期间存 IP(每条指令 0x30 字节,smaddl x8, w9, w12, x11 推进,handler 尾 str w9, [x23] 写回),返回时被覆盖成返回值
- 注意 ldr x8 取的是 64 位,str w8 只存低 32 位 → 返回值被 32 位截断,还原时得留个心眼
- 本次 trace 返回值是 0(W8=0x0)
其实到这块以后我都想直接写汇编还原了,我也感觉可以像一个办法尝试汇编去做这块内容(?自己写一个库),因为到 IR 层面以后就真很难直接对应 trace 了,这块就很蛋疼
handler 切割
文本解析之类的就不提了,我们直接定 x25 寄存器,然后看 x25 取值后的内容有没有被 br 跳转或者 br 跳转寄存器的值是不是被 x25 赋的,具体操作可以看看代码,我写的也是非常非常简单了
IP 递增
在 handler 切割的基础上,直接找 x23 寄存器,看它取的地方和存的地方,代入 trace 看看加了多少或者 P-code 画图找操作位置
IR 的产出
这也是最难的点也是全篇的精华所在,这块就先上帝视角一下,先尝试解决难点
- 抖音最 der 的 handler 内 bl,但是逻辑还是 handler 内的,特征非常非常符合函数
- VMP 的专属调用 Call Function,这块要专门去处理,看一下对 VM CTX 的具体操作
折叠处理法
根据 ARM64 的指令集地址长度一致,从 bl、blr 等等调用指令的地址到加 4 的地址,被调用的内容写一个列表内,handler 逻辑写在一个列表内,这时候我在想要是 VM 调用 VM 怎么办?难不成真写那么多?
好像又不需要,对 CALL 类直接根据 trace 提取相关信息,调用点写描述而非全部内容
CALL@{Addr|Arg} // 同时又可以标记出来来源,LIBC、Call Function、VM reg/stack
但是折的极限呢?VM 调用自己、封装的逻辑函数、libc 等等系统函数这些怎么处理?有一个思路,但是不是很靠谱直接深度优先,处理最深层次的 handler,然后逐步回溯,但是好像又不太需要想这种问题,我最前面都已经切出来 handler 分块了,我处理的只有 handler 这种颗粒度的东西,感觉还需要一次折叠,然后根据 vmp 的锚点寄存器进行数据流追踪,这时候就差不多可以理解到什么函数是操作 vm 相关(栈、寄存器),什么是原生函数,然后开始折叠
第一次折叠拆出来 handler 的操作,第二次折叠拆出来 VM 在执行原生逻辑前做了什么东西,怎么准备参数和返回值的
第二次折叠这块还是图算法,看看都准备了什么锚点寄存器或者是对上下文怎么操作的,然后怎么传递给原生函数的,原生函数的还原不在我们考虑方法内,这块可以考虑直接输出原始日志交给模型看
参数方面也要对齐,什么参数交给谁了,如果不写明白模型就跟傻子一样,瞎对照了
(上帝视角:这块我后面直接选择了递归函数调用栈了,不过是只看 blr 和 bl 两个内容,看看 blr 第二次走的都是谁,是不是 x8 这个标记,如果不是的话走的是谁,因为我们已经有了全部的调用栈了,遍历一下也不难)
现在又迷起来了,这是真有点懵逼了,卡了两个点
函数跳板
外层像是一个小跳板层,bl 到一个函数内,但是那个函数除了开栈之外什么都没做
.text:00000000002F3624 sub_2F3624 ; DATA XREF: .data.rel.ro:00000000003E2B10↓o
.text:00000000002F3624 ADD X0, X8, #8
.text:00000000002F3628 MOV X1, X19
.text:00000000002F362C BL sub_3090E0
.text:00000000002F3630 LDR W8, [X19,#0xC]
.text:00000000002F3634 MOV W10, #0x30 ; '0'
.text:00000000002F3638 LDR X9, [X20]
.text:00000000002F363C NOP
.text:00000000002F3640 SMADDL X8, W8, W10, X9
.text:00000000002F3644 LDRH W9, [X8]
.text:00000000002F3648 LDR X9, [X25,X9,LSL#3]
.text:00000000002F364C BR X9
.text:00000000003090E0 ; __int64 __fastcall sub_3090E0(_QWORD *, __int64)
.text:00000000003090E0 sub_3090E0 ; CODE XREF: sub_2F3624+8↑p
.text:00000000003090E0
.text:00000000003090E0 var_50 = -0x50
.text:00000000003090E0 var_4C = -0x4C
.text:00000000003090E0 var_48 = -0x48
.text:00000000003090E0 var_3C = -0x3C
.text:00000000003090E0 var_38 = -0x38
.text:00000000003090E0 var_30 = -0x30
.text:00000000003090E0 var_28 = -0x28
.text:00000000003090E0 var_20 = -0x20
.text:00000000003090E0 var_18 = -0x18
.text:00000000003090E0 var_10 = -0x10
.text:00000000003090E0 var_8 = -8
.text:00000000003090E0
.text:00000000003090E0 SUB SP, SP, #0x50
.text:00000000003090E4 LDR X14, [X0,#8]
.text:00000000003090E8 MOV W11, #0x6064
.text:00000000003090EC LDR W12, [X1,#0xC]!
.text:00000000003090F0 ADD X15, X1, X11
.text:00000000003090F4 MOV W8, #0xE8C7
.text:00000000003090F8 LDRB W16, [X14,#1]
.text:00000000003090FC MOV W9, #0xD2B8
.text:0000000000309100 MOVK W8, #0xDB0F,LSL#16
.text:0000000000309104 MOVK W9, #0xBEBF,LSL#16
.text:0000000000309108 STR X15, [SP,#0x50+var_38]
.text:000000000030910C ADD X11, SP, #0x50+var_4C
.text:0000000000309110 ADD X15, X15, X16,LSL#3
.text:0000000000309114 STR W12, [SP,#0x50+var_3C]
.text:0000000000309118 LDP X17, X16, [SP,#0x50+var_20]
.text:000000000030911C MOV X12, SP
.text:0000000000309120 ADD X14, X14, #2
.text:0000000000309124 MOV W10, #0xCDDA
.text:0000000000309128 MOV W13, #0xC16F
.text:000000000030912C MOVK W10, #0xA2F1,LSL#16
.text:0000000000309130 STR X1, [SP,#0x50+var_48]
.text:0000000000309134 MOVK W13, #0x1C95,LSL#16
.text:0000000000309138 STP X11, X12, [SP,#0x50+var_10]
.text:000000000030913C STP W9, W8, [SP,#0x50+var_50]
.text:0000000000309140 STP X15, X14, [SP,#0x50+var_30]
.text:0000000000309144 LDR W1, [X11]
.text:0000000000309148 CMP W1, W8
.text:000000000030914C B.EQ loc_3091C0
.text:0000000000309150
.text:0000000000309150 loc_309150 ; CODE XREF: sub_3090E0+B4↓j
.text:0000000000309150 ; sub_3090E0+DC↓j
.text:0000000000309150 LDR W8, [SP,#0x50+var_3C]
.text:0000000000309154 STP X17, X16, [SP,#0x50+var_20]
.text:0000000000309158 LDRB W9, [X16]
.text:000000000030915C MOV W0, #1
.text:0000000000309160 LDR X12, [SP,#0x50+var_48]
.text:0000000000309164 ADD W8, W8, #3
.text:0000000000309168 LDR X10, [SP,#0x50+var_38]
.text:000000000030916C LDRSH X11, [X17,#2]
.text:0000000000309170 STR X11, [X10,X9,LSL#3]
.text:0000000000309174 STR W8, [X12]
.text:0000000000309178 ADD SP, SP, #0x50 ; 'P'
.text:000000000030917C RET
.text:0000000000309180 ; —————————————————————————
.text:0000000000309180
.text:0000000000309180 loc_309180 ; CODE XREF: sub_3090E0+EC↓j
.text:0000000000309180 STR W10, [X12]
.text:0000000000309184 STR W8, [X11]
.text:0000000000309188 MOV W1, W8
.text:000000000030918C CMP W8, W8
.text:0000000000309190 B.EQ loc_3091C0
.text:0000000000309194 B loc_309150
.text:0000000000309198 ; —————————————————————————
.text:0000000000309198
.text:0000000000309198 loc_309198 ; CODE XREF: sub_3090E0+E8↓j
.text:0000000000309198 LDRH W16, [X14]
.text:000000000030919C LDR X17, [X0]
.text:00000000003091A0 LSL W16, W16, #0x10
.text:00000000003091A4 SXTW X1, W16
.text:00000000003091A8 ADD X16, X17, #1
.text:00000000003091AC STR X1, [X15]
.text:00000000003091B0 STR W13, [X11]
.text:00000000003091B4 MOV W1, W13
.text:00000000003091B8 CMP W13, W8
.text:00000000003091BC B.NE loc_309150
.text:00000000003091C0
.text:00000000003091C0 loc_3091C0 ; CODE XREF: sub_3090E0+6C↑j
.text:00000000003091C0 ; sub_3090E0+B0↑j
.text:00000000003091C0 LDR W1, [X12]
.text:00000000003091C4 CMP W1, W9
.text:00000000003091C8 B.NE loc_309198
.text:00000000003091CC B loc_309180
.text:00000000002F113C sub_2F113C ; DATA XREF: .data.rel.ro:00000000003E28F8↓o
.text:00000000002F113C ADD X0, X8, #8
.text:00000000002F1140 MOV X1, X19
.text:00000000002F1144 BL sub_3083B0
.text:00000000002F1148 LDR W8, [X19,#0xC]
.text:00000000002F114C MOV W10, #0x30 ; '0'
.text:00000000002F1150 LDR X9, [X20]
.text:00000000002F1154 NOP
.text:00000000002F1158 SMADDL X8, W8, W10, X9
.text:00000000002F115C LDRH W9, [X8]
.text:00000000002F1160 LDR X9, [X25,X9,LSL#3]
.text:00000000002F1164 BR X9
.text:00000000003083B0 ; __int64 __fastcall sub_3083B0(unsigned __int8 **, __int64)
.text:00000000003083B0 sub_3083B0 ; CODE XREF: sub_2F113C+8↑p
.text:00000000003083B0
.text:00000000003083B0 var_50 = -0x50
.text:00000000003083B0 var_4C = -0x4C
.text:00000000003083B0 var_48 = -0x48
.text:00000000003083B0 var_3C = -0x3C
.text:00000000003083B0 var_38 = -0x38
.text:00000000003083B0 var_2C = -0x2C
.text:00000000003083B0 var_28 = -0x28
.text:00000000003083B0 var_20 = -0x20
.text:00000000003083B0 var_11 = -0x11
.text:00000000003083B0 var_10 = -0x10
.text:00000000003083B0 var_8 = -8
.text:00000000003083B0
.text:00000000003083B0 SUB SP, SP, #0x50
.text:00000000003083B4 LDR X11, [X0,#8]
.text:00000000003083B8 MOV W8, #0xB2EB
.text:00000000003083BC MOV W10, #0xE126
.text:00000000003083C0 MOV W12, #0x6064
.text:00000000003083C4 MOVK W8, #0xCE33,LSL#16
.text:00000000003083C8 ADD X9, SP, #0x50+var_4C
.text:00000000003083CC LDR W2, [X1,#0xC]!
.text:00000000003083D0 MOVK W10, #0xCC48,LSL#16
.text:00000000003083D4 ADD X12, X1, X12
.text:00000000003083D8 LDRB W13, [X11]
.text:00000000003083DC MOV X16, SP
.text:00000000003083E0 LDR X17, [SP,#0x50+var_20]
.text:00000000003083E4 MOV W14, #0x90CD
.text:00000000003083E8 LDRB W3, [SP,#0x50+var_11]
.text:00000000003083EC MOV W15, #0x3DD1
.text:00000000003083F0 STR X1, [SP,#0x50+var_48]
.text:00000000003083F4 MOVK W14, #0xA950,LSL#16
.text:00000000003083F8 STR W2, [SP,#0x50+var_3C]
.text:00000000003083FC MOVK W15, #0x82BD,LSL#16
.text:0000000000308400 STR X11, [SP,#0x50+var_38]
.text:0000000000308404 ADD W2, W2, #2
.text:0000000000308408 STR X12, [SP,#0x50+var_28]
.text:000000000030840C STRB W13, [SP,#0x50+var_2C]
.text:0000000000308410 STP W8, W10, [SP,#0x50+var_50]
.text:0000000000308414 STP X9, X16, [SP,#0x50+var_10]
.text:0000000000308418 LDR W4, [X9]
.text:000000000030841C CMP W4, W10
.text:0000000000308420 B.EQ loc_3084B4
.text:0000000000308424
.text:0000000000308424 loc_308424 ; CODE XREF: sub_3083B0+BC↓j
.text:0000000000308424 ; sub_3083B0+100↓j
.text:0000000000308424 LDRSH W8, [X17,#2]
.text:0000000000308428 ANDS W10, W3, #1
.text:000000000030842C LDR W9, [SP,#0x50+var_3C]
.text:0000000000308430 MOV W0, #1
.text:0000000000308434 STR X17, [SP,#0x50+var_20]
.text:0000000000308438 CSEL W8, W8, WZR, NE
.text:000000000030843C STRB W10, [SP,#0x50+var_11]
.text:0000000000308440 ADD W8, W9, W8
.text:0000000000308444 LDR X9, [SP,#0x50+var_48]
.text:0000000000308448 ADD W8, W8, #3
.text:000000000030844C STR W8, [X9]
.text:0000000000308450 ADD SP, SP, #0x50 ; 'P'
.text:0000000000308454 RET
.text:0000000000308458 ; —————————————————————————
.text:0000000000308458
.text:0000000000308458 loc_308458 ; CODE XREF: sub_3083B0+110↓j
.text:0000000000308458 STR W14, [X16]
.text:000000000030845C STR W10, [X9]
.text:0000000000308460 MOV W4, W10
.text:0000000000308464 CMP W10, W10
.text:0000000000308468 B.EQ loc_3084B4
.text:000000000030846C B loc_308424
.text:0000000000308470 ; —————————————————————————
.text:0000000000308470
.text:0000000000308470 loc_308470 ; CODE XREF: sub_3083B0+10C↓j
.text:0000000000308470 LDR X4, [X12,X13,LSL#3]
.text:0000000000308474 STR W2, [X1]
.text:0000000000308478 LDRH W3, [X11,#2]
.text:000000000030847C LDR X17, [X0]
.text:0000000000308480 LDRB W5, [X11,#1]
.text:0000000000308484 AND X3, X4, X3
.text:0000000000308488 LDRB W6, [X17]
.text:000000000030848C LDRB W4, [X17,#1]
.text:0000000000308490 STR X3, [X12,X5,LSL#3]
.text:0000000000308494 LDR X3, [X12,X6,LSL#3]
.text:0000000000308498 LDR X4, [X12,X4,LSL#3]
.text:000000000030849C STR W15, [X9]
.text:00000000003084A0 CMP X3, X4
.text:00000000003084A4 CSET W3, EQ
.text:00000000003084A8 MOV W4, W15
.text:00000000003084AC CMP W15, W10
.text:00000000003084B0 B.NE loc_308424
.text:00000000003084B4
.text:00000000003084B4 loc_3084B4 ; CODE XREF: sub_3083B0+70↑j
.text:00000000003084B4 ; sub_3083B0+B8↑j
.text:00000000003084B4 LDR W4, [X16]
.text:00000000003084B8 CMP W4, W8
.text:00000000003084BC B.NE loc_308470
.text:00000000003084C0 B loc_308458
.text:00000000003084C0 ; End of function sub_3083B0
.text:00000000003084C0
.text:00000000003084C4
这种这种情况很多,但是再仔细观看一下,就能发现这玩意两个参数,第一个参数是字节码,第二个参数是 VM CTX,我们就可以利用这一点进行思考,因为这种特征也非常非常少,这块也可以作为一个特征进行匹配
间接调用
接下来看 CALL 类调用的 handler,这也挺抽象的
.text:0000000000358E14 ; __int64 __fastcall sub_358E14(_QWORD **, unsigned __int8 *, unsigned int)
.text:0000000000358E14 sub_358E14 ; CODE XREF: sub_2E87B4+C↑p
.text:0000000000358E14 ; sub_2EDA70+80↑p …
.text:0000000000358E14
.text:0000000000358E14 var_18 = -0x18
.text:0000000000358E14 var_10 = -0x10
.text:0000000000358E14 var_8 = -8
.text:0000000000358E14 var_s0 = 0
.text:0000000000358E14 var_s8 = 8
.text:0000000000358E14
.text:0000000000358E14 SUB SP, SP, #0x30
.text:0000000000358E18 STP X29, X30, [SP,#0x20+var_s0]
.text:0000000000358E1C ADD X29, SP, #0x20
.text:0000000000358E20 LDRB W10, [X1]
.text:0000000000358E24 ADRL X9, off_3E41D8
.text:0000000000358E2C LDRSW X11, [X1,#0xC]
.text:0000000000358E30 ADD X8, SP, #0x20+var_18
.text:0000000000358E34 ADD X10, X1, X10,LSL#3
.text:0000000000358E38 STR X11, [X10,#0x6070]
.text:0000000000358E3C LDR X10, [X0]
.text:0000000000358E40 ADD X0, SP, #0x20+var_10
.text:0000000000358E44 LDR X10, [X10]
.text:0000000000358E48 LDR X10, [X10,W2,UXTW#3]
.text:0000000000358E4C STP X1, X8, [SP,#0x20+var_18]
.text:0000000000358E50 LDR X11, [X10,#0x58]
.text:0000000000358E54 STR X10, [SP,#0x20+var_8]
.text:0000000000358E58 LDR X9, [X9,X11,LSL#3]
.text:0000000000358E5C BLR X9
.text:0000000000358E60 LDP X29, X30, [SP,#0x20+var_s0]
.text:0000000000358E64 ADD SP, SP, #0x30 ; '0'
.text:0000000000358E68 RET
.text:00000000002E6694 ; __int64 __fastcall sub_2E6694(unsigned __int8 ***)
.text:00000000002E6694 sub_2E6694 ; DATA XREF: .data.rel.ro:off_3E1E30↓o
.text:00000000002E6694 ; .data.rel.ro:off_3E41D8↓o
.text:00000000002E6694
.text:00000000002E6694 var_20 = -0x20
.text:00000000002E6694 var_18 = -0x18
.text:00000000002E6694 var_10 = -0x10
.text:00000000002E6694
.text:00000000002E6694 STP X29, X30, [SP,#var_20]!
.text:00000000002E6698 STR X19, [SP,#0x20+var_10]
.text:00000000002E669C MOV X29, SP
.text:00000000002E66A0 LDP X8, X9, [X0]
.text:00000000002E66A4 LDR X19, [X8]
.text:00000000002E66A8 LDR X8, [X9]
.text:00000000002E66AC MOV X0, X19
.text:00000000002E66B0 BLR X8
.text:00000000002E66B4 LDRB W8, [X19]
.text:00000000002E66B8 ADD X8, X19, X8,LSL#3
.text:00000000002E66BC LDR X8, [X8,#0x6070]
.text:00000000002E66C0 STR W8, [X19,#0xC]
.text:00000000002E66C4 LDR X19, [SP,#0x20+var_10]
.text:00000000002E66C8 LDP X29, X30, [SP+0x20+var_20],#0x20
.text:00000000002E66CC RET
.text:00000000002D1D5C
.text:00000000002D1D5C ; __int64 __fastcall sub_2D1D5C(__int64)
.text:00000000002D1D5C sub_2D1D5C ; DATA XREF: sub_2D2844+1D8↓o
.text:00000000002D1D5C
.text:00000000002D1D5C var_10 = -0x10
.text:00000000002D1D5C
.text:00000000002D1D5C STR X30, [SP,#var_10]!
.text:00000000002D1D60 MOV W1, #8
.text:00000000002D1D64 BL sub_2E5D1C ; vm_read_int_reg
.text:00000000002D1D68 BL sub_2C8AE8
.text:00000000002D1D6C LDR X30, [SP+0x10+var_10],#0x10
.text:00000000002D1D70 RET
一步一步的调用进来的情况,这块最难的也是 x8 的处理,万一它不走 x8 呢?这块不炸了?(哈哈哈哈哈,上帝视角来了,这确实全走 x8 间接调用)我想的是加一个 if 或者 try,看看不走的情况,这种思路的好处就是可以看见走或者不走都经历了什么可以二分定位,OK 接下来就可以确定写法了
我打算用 Python 的match … case …写,首先就是特征的匹配,其次是对间接调用的匹配(上帝视角:后面用的递归最深栈调用进行二分排查了,没有软件流水线)
其实还可以外层 if 语句来判断是不是复合语句,然后在间接匹配时候用 match 看有没有走 x8 间接调用
VM CTX 操作
这点就也需要一个函数来进行操作,我们来看一个实例
.text:00000000002D1D08 sub_2D1D08 ; DATA XREF: sub_2D2844+1A0↓o
.text:00000000002D1D08
.text:00000000002D1D08 var_20 = -0x20
.text:00000000002D1D08 var_10 = -0x10
.text:00000000002D1D08 var_8 = -8
.text:00000000002D1D08
.text:00000000002D1D08 STR X30, [SP,#var_20]!
.text:00000000002D1D0C STP X20, X19, [SP,#0x20+var_10]
.text:00000000002D1D10 MOV W1, #8
.text:00000000002D1D14 MOV X19, X0
.text:00000000002D1D18 BL vm_read_int_reg
.text:00000000002D1D1C MOV X20, X0
.text:00000000002D1D20 MOV X0, X19
.text:00000000002D1D24 MOV W1, #9
.text:00000000002D1D28 BL vm_read_int_reg
.text:00000000002D1D2C MOV X1, X0
.text:00000000002D1D30 MOV X0, X20
.text:00000000002D1D34 BL sub_2D8890
.text:00000000002D1D38 LDP X20, X19, [SP,#0x20+var_10]
.text:00000000002D1D3C LDR X30, [SP+0x20+var_20],#0x20
.text:00000000002D1D40 RET
.text:00000000002E5D1C ; __int64 __fastcall vm_read_int_reg(__int64, int)
.text:00000000002E5D1C vm_read_int_reg ; CODE XREF: sub_2D1A58+C↑p
.text:00000000002E5D1C ; sub_2D1A84+C↑p …
.text:00000000002E5D1C ADD X8, X0, W1,SXTW#3
.text:00000000002E5D20 LDR X0, [X8,#0x6070]
.text:00000000002E5D24 RET
.text:00000000002E5D28 ; __int64 __fastcall vm_write_int_reg(__int64 result, int, __int64)
.text:00000000002E5D28 vm_write_int_reg ; CODE XREF: sub_2D1A58+20↑p
.text:00000000002E5D28 ; sub_2D1A84+20↑p …
.text:00000000002E5D28 ADD X8, X0, W1,SXTW#3
.text:00000000002E5D2C STR X2, [X8,#0x6070]
.text:00000000002E5D30 RET
我们可以看见它直接通过索引访问虚拟机的整数寄存器文件,现在函数名也不是很好,但是先这样吧
这块吧其实也可以继续做折叠然后看全部的东西,每个到这的就输出它这个函数又调用了什么函数,做特征匹配,这还是得益于 VMP 的精确构造,要不然是真没办法一直对特征进行总结,就算是 Windows 上的 VMP 本尊也是巨大的特征否则也不会被整理出来那么多还原资料了
(上帝视角:对于这块其实可能真的只有普通的整数寄存器有,浮点数寄存器我是完完全全没找到相关的内容,这主要和 VM 构造有关了)
图算法的实现与困境
昨天给一个 CTF 出题去了,虽然只有一道安卓题目,今天继续思考接下来的处理
首先是改了一下对于调用栈的东西,换成递归形式了,这样也可以更好的查找和拼接数据,感觉马上要开始写图算法了,好难啊,不过车到山前必有路,船到桥头自然直,不用担心这一块,思考一下前瞻性问题吧,说难也难说不难也不难,可能是我畏难心理吧
基本 handler 图算法
bl 调用的复合 handler 图算法
调用其他函数的 handler 图算法
VM CTX 的相关的函数操作,这也是图算法所需要实现的内容,不过这块肯定不会非常多的,实在不行就再 tm 的找规律去
逻辑函数的处理
wc 了,就在刚刚模型给了我一个顶级思路
影子轨道
由于我们选择 P-code(实际是任意的,只需要保证 IR 的不变性,不能让汇编每次提升出来有随机性) 进行 IR 的提升,所以这时候就可以考虑提升两代出来
- 原始的汇编提升,由主要逻辑进行提升,负责后续的算法或者去混淆实现
- 具体值代入提升,这块我们读取 trace 的值然后写进去,这样双方可以像影子一样穿透实现,其实这块我感觉思路还有很多很多
不过啊,还是有一些难点的,有 IR、实现、代入等等方面的,我先说我想到的困难
解法来了,也是吃上 IR 的红利了,我用 P-code 的
首先汇编会提升出来,这样就有了模板了,这时候我们会有varnode,这玩意不知道是啥就去看 P-code 的使用基础中的基础了,然后我们拿这个模板进行寄存器的遍历与代换,把实际 trace 记录值代入模板得到第二代产物,也就是有了具体值,这时候我们就解决了所遇见的大难题,接下来我拿一条汇编进行演示
idx 偏移 汇编 寄存器读 寄存器写
17052c 0x285eb0 "madd x10, x12, x11, x10" {X12:0x0, X11:0xdec, X10:0x794d9af5c8} {X10:0x794d9af5c8}
第一代(SLEIGH 提升):
x10 = (x12 * x11) + x10
第二代(影子折叠,把三个源 varnode 换成 trace 值):
x10 = (0x0 * 0xdec) + 0x794d9af5c8
在 varnode 层折叠,把 varnode 的 space 换掉
- space = register,offset = 寄存器编号 → 这是"寄存器 x12"
- space = unique,offset = SLEIGH 临时分配的号 → 这是"指令内部中间值"
- space = ram,offset = 内存地址 → 这是"内存某个位置"
- space = const,offset = 那个值本身 → 这是"常量"
这里我引入进来那个影子轨道的思路吧,让模型整理的
影子轨道 · 完整思路
两代 IR,同源同 varnode:
- 第一代 = 原始汇编 → SLEIGH → P-code。语义完整的"真身",静态骨架,不产值。
- 第二代 = 第一代 + trace 值 → varnode 折叠 → P-code。带具体值的"影子",做向导、穿透。
关键认知:值来自 trace(动态执行),不是第一代自己算出来的。第一代不执行不产值,只回答"这段指令长啥样";trace 回答"这次跑起来值是啥"。两代是同一批指令的两个视图,别混成一个东西。
对齐(影子身份):两代从同一批字节、同一套 SLEIGH 提升,unique varnode 对同一条指令分配是确定性的,所以共享同一套 varnode 命名空间。影子身份靠这个,不靠"两代汇编都合法"。PC 锚点用 RVA(trace 第三列),不能用绝对地址(ASLR 漂移)。
折叠的本质:varnode = (space, offset, size),折叠 = 把 varnode 从 register space 踢到 const space。分两步:
unique 生命周期只在单条指令内,trace 不记它,只能靠"输入寄存器折成常量后化简"消掉。顺序:先折输入,再化简。
折叠边界(trace 给了啥才能折啥):
- 读列(源寄存器值)→ 折源
- 写列(目标寄存器值)→ 折目标
- 内存地址 → 值没记 → LOAD/STORE 只能靠"输出寄存器已知"后向折,没法前向折"这地址里是啥"
varnode 红利:SLEIGH 白送寄存器重命名 + 值编号。汇编里 x10 复用、内存别名、条件码这些最脏的隐式数据流,提升时全显式化,每个值一个 varnode,跨指令 def-use 现成。这条 def-use = DFG 骨架,直接接"DFG + 反向污点"那套。所以 P-code 不是"任意的"——换个没 varnode / 显式 def-use 的 IR,这层得自己重写。
两个坑:
难点答案:
- 纯值汇编合法吗 → 别改汇编字节。imm 长度变 → 地址错位 →SLEIGH 认错指令边界 →varnode 全乱。正确是 P-code 层替换,不碰字节。
- 代入多少/怎么代 → 自己构造记录字典,整个 handler 作为一个列表,列表内是字典,将实际值记录进字典内,后续算法根据这个字典进行
这块实现直接看我代码好了,然后遇见一个小屌毛的地方,发现我没寄存器全部的映射啊,然后自己问模型然后给自己气半死,最后还是翻代码,然后找到 ctx 函数可以获取全部寄存器的偏移,最终实现如下,用的时候直接 Python 语法拿就行了
_ctx = pypcode.Context(ARCH)
AllRegisters = {
v.offset: (name, v.size) for v, name in self._ctx.getAllRegisters().items()
}
给各位看一段吧,避免不知道发生了什么,这个是我替换出来的东西,然后就是准备拿这个进行还原的,分号后面是地址、汇编,还原期间没分号行内容
; 0x2e87e8 ldr w9, [x23]
unique[c700:8] = COPY 0x7853c7e1dc:8
unique[48f00:4] = LOAD ram(unique[c700:8])
x9:8 = INT_ZEXT unique[48f00:4]
; 0x2e87ec mov w12, #0x30
x12:8 = COPY 0x30:8
; 0x2e87f0 ldr x11, [x20]
unique[c800:8] = COPY 0x7aaf5ce868:8
x11:8 = LOAD ram(unique[c800:8])
; 0x2e87f4 ldrb w10, [x8, #0x8]
unique[bc00:8] = INT_ADD 0x7c4f75c1d0:8, 0x8:8
unique[4a100:1] = LOAD ram(unique[bc00:8])
x10:8 = INT_ZEXT unique[4a100:1]
; 0x2e87f8 add w9, w9, #0x1
unique[22d00:4] = COPY 0x1:4
tmpCY:1 = INT_CARRY 0x0:4, unique[22d00:4]
tmpOV:1 = INT_SCARRY 0x0:4, unique[22d00:4]
unique[22f00:4] = INT_ADD 0x0:4, unique[22d00:4]
tmpNG:1 = INT_SLESS unique[22f00:4], 0x0:4
tmpZR:1 = INT_EQUAL unique[22f00:4], 0x0:4
x9:8 = INT_ZEXT unique[22f00:4]
; 0x2e87fc ldrb w13, [x8, #0x9]
unique[bc00:8] = INT_ADD 0x7c4f75c1d0:8, 0x9:8
unique[4a100:1] = LOAD ram(unique[bc00:8])
x13:8 = INT_ZEXT unique[4a100:1]
; 0x2e8800 ldrsh x14, [x8, #0xa]
unique[be00:8] = INT_ADD 0x7c4f75c1d0:8, 0xa:8
unique[4c100:2] = LOAD ram(unique[be00:8])
x14:8 = INT_SEXT unique[4c100:2]
; 0x2e8808 smaddl x8, w9, w12, x11
unique[6db00:8] = INT_SEXT 0x1:4
unique[6dd00:8] = INT_SEXT 0x30:4
unique[6df00:8] = INT_MULT unique[6db00:8], unique[6dd00:8]
x8:8 = INT_ADD 0x7c4f75c1d0:8, unique[6df00:8]
; 0x2e880c ldr x10, [x24, x10, lsl #3]
unique[ae00:8] = COPY 0x1:8
unique[da00:8] = COPY unique[ae00:8]
unique[da00:8] = INT_LEFT unique[da00:8], 0x3:8
unique[e300:8] = INT_ADD 0x7853c84240:8, unique[da00:8]
x10:8 = LOAD ram(unique[e300:8])
; 0x2e8810 str w9, [x23]
unique[c700:8] = COPY 0x7853c7e1dc:8
STORE ram(unique[c700:8]), 0x1:4
; 0x2e8814 ldrh w11, [x8]
unique[c600:8] = COPY 0x7c4f75c200:8
unique[4a500:2] = LOAD ram(unique[c600:8])
x11:8 = INT_ZEXT unique[4a500:2]
; 0x2e8818 add x10, x10, x14
unique[23f00:8] = COPY 0xfffffffffffffb60:8
tmpCY:1 = INT_CARRY 0x7853c84200:8, unique[23f00:8]
tmpOV:1 = INT_SCARRY 0x7853c84200:8, unique[23f00:8]
unique[24100:8] = INT_ADD 0x7853c84200:8, unique[23f00:8]
tmpNG:1 = INT_SLESS unique[24100:8], 0x0:8
tmpZR:1 = INT_EQUAL unique[24100:8], 0x0:8
x10:8 = COPY unique[24100:8]
; 0x2e881c str x10, [x24, x13, lsl #3]
unique[74600:8] = COPY 0x7853c83d60:8
unique[ae00:8] = COPY 0x1:8
unique[da00:8] = COPY unique[ae00:8]
unique[da00:8] = INT_LEFT unique[da00:8], 0x3:8
unique[e300:8] = INT_ADD 0x7853c84240:8, unique[da00:8]
STORE ram(unique[e300:8]), unique[74600:8]
; 0x2e8820 ldr x11, [x25, x11, lsl #3]
unique[ae00:8] = COPY 0x8d:8
unique[da00:8] = COPY unique[ae00:8]
unique[da00:8] = INT_LEFT unique[da00:8], 0x3:8
unique[e300:8] = INT_ADD 0x794db0be58:8, unique[da00:8]
x11:8 = LOAD ram(unique[e300:8])
; 0x2e8824 br x11
pc:8 = COPY 0x794da14cd4:8
BRANCHIND pc:8
这时候我们有了值,但是吧,还得想一下怎么去做分析我的思路是直接写三个函数进行,因为上述三条线,但是其中 x25 的寄存器跳转又现的不那么重要,所以主线就是先定把这些寄存器明确出来
燃尽了,脑子给我看麻了啊,不过还是冷静一下看看来源再说,这可见另一篇文章了,P-code IR 解析与使用篇章
开始写还原
思路理清了就该动手。三条线里我先啃 x23 —— 它一条就能定下这条 VM 指令的控制流效果(顺序还是跳转),后面几条线都得落在它上面。
一开始想得挺简单:handler 里 ldr wN, [x23] 和 str wN, [x23] 就那两条,读 trace 的读列写列,取出来的值、存回去的值不就都有了?汇编层确实能做。
但是不对 —— 我都把 IR 提升到 P-code 了,再回头去抠汇编字符串,那这一代提升是白干的。
卡点:x23 在影子 IR 里只露一次面
真上 P-code 才发现问题:
; 0x2e87e8 ldr w9, [x23]
unique[c700:8] = COPY 0x7853c7e1dc:8 ; ← x23 在这,但它只是把地址搬进临时
unique[48f00:4] = LOAD ram(unique[c700:8]) ; ← 真正读 IP 的是这条,地址是 unique
x9:8 = INT_ZEXT unique[48f00:4]
x23 在 P-code 上不是「被读的寄存器」,是「被 COPY 出去的一个地址常量」。
所以按 space == "register" 去扫 inputs,收上来一堆 COPY,啥也说明不了。写侧同理:
; 0x2e8810 str w9, [x23]
unique[c700:8] = COPY 0x7853c7e1dc:8
STORE ram(unique[c700:8]), 0x1:4
「存回去的值」确实在 STORE 第 3 个操作数上,但「哪个 STORE 才是存 IP 的」得先顺着 varnode 把它找出来。
解法:按 offset 种种子,做前向标记
先记一个坑:锚点寄存器一律按 offset 认,不按名字。同一个 offset 上 w23/x23 是两个视图(size 4 和 8),按名字认会漏。x23 = 0x40B8。
然后扫一趟 shadow(SSA 序,def 先于 use,单趟够用),给每个 varnode 记一个「你的值是从哪条线派生来的」:
| 普通 op | 输出继承所有输入的色(并集) |
| 输入全无色 | 输出断链、清色(寄存器被重新赋值了) |
| LOAD | 地址有色 ≠ 读出来的值有色 —— 地址的色过 DEREF 换成「值」色(ip → ipval)落到输出 |
| STORE | 不产值,但地址/值两路的色扫到时就记下来 |
最后那条得强调一下:unique 的 offset 会被后面的指令复用,扫完再回头看那一格早就被覆盖了,分类必须在扫的过程中做。
这个函数不是只为 x23 写的 —— x24(虚拟寄存器文件)、x25(handler 表)就是同一个函数换个种子,DEREF 表里位置都留好了。
pre / post 怎么取
两个点都在 shadow 上取:
- pre:读 [x23] 那条 LOAD 之后,值落到哪个 register 上,那个 register 的 trace 值才是取出来的 IP。为什么不直接取 LOAD 的输出?因为它是 unique,trace 不记内存,它没有值 —— 得往后找第一条把结果写进 register 的 op。
- post:写 [x23] 那条 STORE 的第 3 个操作数,直接就是存回去的新 IP。
一个 handler 可能写两次 IP,取最后一条。
实测
2M 行切片,46899 条走到这条线的 handler:
22208 step=1
13170 step=3
4650 step=4
1834 step=5
968 step=6
1085 step=-10
930 step=-4
772 step=-23
720 step=-58
41 只写不读
1 拿不到新 IP
(其余为各种负数,略)
三个发现:
① step=1 只是顺序落下一个 slot,但 step=3/4/5/6 是常态。 我原来以为「一条 VM 指令 = IP+1」,被打脸了。实测 0x2fa4d8:ldr w11,[x23](0x19)→ add w11,w11,#4 → str w11,[x23](0x1d),然后 smaddl x10, w11, #0x30, base 算出 base + 0x1d*0x30,跟 trace 完全吻合 —— IP 确实是 0x30 字节 slot 的下标,这条指令就是占 4 个 slot。
② 负数 = 条件跳转。 抽到 0x2eae28,正是前面表里那个 CMP/BEQ:pre=17, post=6, step=-11,CSEL 选出 offset=-12,-12+1=-11 对上「if(eq) offset=0; IP += offset+1」。
③ 41 条「只写不读」全是 0x2ebc2c —— 就是前面那个 RET handler。ldrb w8,[x8,#8]; ldr x8,[x24,x8,lsl#3]; str w8,[x23]:把 regs[idx] 当 IP 写进 ctx+0xC(槽复用),压根不读旧 IP。分类和前面的分析对上了,算是交叉验证。
还有个值的说道:0x2eae28 那条里 X23 的值是 0x7853c6aefc,而 0x2e87e8 里是 0x7853c7e1dc。同一条 handler 在不同 VM 调用里,ctx 基址是不一样的 —— 按寄存器值去认锚点行不通,必须按 offset。这个设计躲过一坑。
顺手挖出来一个切割 bug
上面那个「1 条拿不到新 IP」本来没在意,结果一查带出来个大的。
先是发现一批 handler 取不到值(一开始 38 条),追下去发现它们的 entry_off 是 0x2e6778 —— 这不是 handler,是 wrapper 里 bl vmOneHandler 的下一条指令。把那段 dump 出来更离谱:
depth 4, nlines 376, entry_off 0x2e6778
0x2e6778 ldr x8, [x19, #0x10] ← wrapper 收尾
…
0x27264c stp x29, x30, [sp, #-0x50]! ← 一堆原生函数体混在里面
…
0x2f1858 br x9 ← 最后才落到一个真 handler 的尾巴
根因是**「段」没跟着调用栈走**。current_depth 只是个计数器 —— 它知道「现在在第几层 VM」,但不知道「每一层的段从哪开始、断在哪、返回后该续到哪」。段是一个全局 cur_handler,没有栈。
两个具体的点:
# 嵌套进 VM 的时候
if off in self.ENTRY:
self.current_depth += 1
self.cur_handler = [inst] # ← 外层已经攒好的指令,整个丢掉
# 退出的时候
if off == self.EXIT:
yield self._close(inst, "exit")
self.current_depth = max(0, self.current_depth – 1)
# ← 流回到的是包装层,它在 depth >= 1 里,于是被当成新 handler 从头累积
量了一下:切片里 46 次 ENTRY,45 次是嵌套触发的,每次丢掉的段长 57~1997 条指令。也就是说大批 handler 的头(含 ldr w9,[x23] 那一段)都是被丢掉的,剩下的尾巴被粘进假段里。
修法很简单,段入栈:
# ENTRY
self.frames.append(self.cur_handler) # 挂起外层段
self.cur_handler = [inst]
# EXIT
yield self._close(inst, "exit")
self.current_depth = max(0, self.current_depth – 1)
self.cur_handler = self.frames.pop() if self.frames else []
修完:
段数 = 49243 唯一 entry = 264
结束方式 = {'dispatch': 49202, 'exit': 41}
假段 0x2e6778 = 0 (修前 37)
取不到值 = 1 (修前 38)
包装层那几条会续进外层 handler 的段 —— 这是对的:外层 handler 本来就是 bl 到 VM 入口的,折叠层会把它们连同被调的整个函数体折进那个调用节点的 body,不进 main。
最后一条漏网的
修完还剩 1 条,0x2e837c。它是真 handler,只是不走 x23:
0x2e8380 ldr x9, [x19, #0x10] ; ctx+0x10 = 调用链恢复指针
0x2e8388 ldr w9, [x9, #0xc] ; 读内层 ctx 的 IP
0x2e838c sub w8, w8, w9 ; regs[idx] – 内层IP
0x2e8390 add w9, w8, #0x3 ; ┐ 负数向零取整的补偿
0x2e8394 cmp w8, #0x0 ; │
0x2e8398 csel w8, w9, w8, lt ; ┘
0x2e83a0 asr w10, w8, #2 ; /4
0x2e83ac str w10, [x19, #0xc] ; ★ 写 IP:x19(ctx 基址) + 0xc
[x23] 和 [x19, #0xc] 是同一个槽的两种寻址 —— x23 就是 ctx+0xC 的指针。这条 handler 走的是后者,所以 x23 的种子扫不到它。
覆盖情况(2M 行切片):
(写[x23], 写[x19,#0xc])
(True, False): 46898
(False, True): 1
切片里就这一种 handler 走 x19 路径,而且只跑过 1 次。要覆盖它得让标记函数认「地址 = INT_ADD ctx, 0xc」这个形状(等于给色彩加偏移量),为一个跑 1 次的 handler 加这套不划算,先挂着。
倒是它那个 /4 值得记一笔:尾巴 dispatch 用的是 IP * 0x30,这里是 /4,说明它算目标走的是另一套寻址约定。样本太少,等写到 CF 类语义再回头看。
操作数线:从 STORE 反向切片
x23 那条线定的是控制流,x24/x26/x27 这三条定的是操作数。动手之前我以为得顺着指令往下推 —— 从 handler 头开始,看它一步步算出什么。真写起来发现得反着走:从写虚拟寄存器文件的 STORE 起,往上游跟 def-use。
理由很简单:一条 VM 指令的结果就落在那几个 STORE 上。往前推是发散的(一条指令写几个槽,每个槽的值又各有一堆中间量),往回追是收敛的(每个 varnode 只有一处定义)。
两条线:
- 索引线 = STORE 的地址操作数 —— 写进哪个槽
- 值线 = STORE 的第 3 个操作数 —— 写了什么
p-code 不是严格 SSA(寄存器槽反复写、unique 槽跨指令复用),但块内是顺序语义:「use 之前最后一次写」就是它的 def。所以反向走是确定的,不用解数据流方程组。
def _defs(self, shir): # varnode -> [(op 序号, op), …]
def _def_before(defs, key, pos): # 二分找 use 之前那一次写; 找不到 = 这个 varnode 是根
影子求值:值从哪来
切片本身只要结构,但后面的折叠要值。值还是走影子轨道那一套,三档:
| 寄存器 | trace 读/写列就是地面真值,不算 |
| unique | 按算子语义推(加/减/与/或/移位/取符号那几个) |
| LOAD | 读的是内存,trace 不记 —— 只能反查:同一条指令内谁读了它、又写进寄存器,那个寄存器的写列就是读出来的值 |
反查那条要按 LOAD 自己的宽度截断(后面 sext 了不算),否则 ldrsh 读出来的 u16 会带着符号扩展。
卡点:位拼装能吐出一堵墙
第一条能跑的 handler 是干净的,两行就完了。换一个就崩了 —— 0x2ea7cc 直接吐出来一棵几百个节点的树:
v[(((bc[+0x8] & ~(0xffffff)) | (((bc[+0x8] & ~(0xff0000)) | (((bc[+0xa] >> 0x10) |
(bc[+0xa] << (0x20 – 0x10))) & 0xff0000)) & 0xffffff)) >> 0x10)] = sext((v[(((((…
SLEIGH 把 BFI W8, W9, #16, #8 展成 (dst & ~mask) | (rot(src) & mask),再叠上 UBFX/LSR 的提取,一条指令膨胀成几十个算符。这些移位/掩码是解码头 —— 把字节从字节码里挑出来按位拼成操作数,不是 VM 语义。不折的话,IR 就是这堵墙。
解法:追字节道
不猜、也不写规则表,用 trace 值本身当判据:算式的第 k 字节是从哪个内存字节来的。位对齐(整字节的移位/掩码)才追得动,追不动就不折。
# 字节道来历: {输出第 k 字节: (空间, 地址, 该字节值)}
# 空间是 "bc"/"ctx"(地址是字节偏移) 或 "*"(地址是个地址节点 —— 操作数是从字节码里
# 的指针取出来的), "c" 表示这一道是常量
掩码/或/移位按字节逐道推:& mask 掩掉的道是恒 0、整字节保留的道跟着走、半字节跨算的不追;>> 8n 把道整体挪下来;| 两道合一,一边是常量 0 就让另一边过。
够不够格折成"读",三条判据缺一不可:
两个坑都值得记:
① 只比值会认错字节。 0x2ea7cc 这次 W8 & 0xff 和 W8 >> 16 都等于 0x14 —— 字节 +8 和字节 +0xa 恰好同值,拿值去撞候选就撞到别的字节上了。所以顺序必须是先由来历定是哪个字节,值只做确认。
② 来历相同 ≠ 位置相同。 第 2 条判据就是这么来的:u16[+8] << 8 的字节 0 也来自 +8,但那是移位不是提取。这个是在 0x2fdaf0 上抓到的 —— 那次字节值恰好是 0,只靠值比对判不出来(0 和 0 相等),只能靠"落在哪一道"。
折完就是手画的那棵树:
VREG[0x14] = sext((VREG[0xb] >> (VREG[0x14] & 0x1f))) | 0xa4
(| 0xa4 是回写后的 IP,见下面 IR 那段。)
IR 长什么样
中间换过几版形态(语义摘要 + 表达式树 → 逐指令左右对照 → 只要 IR),最后定下来是一条 VM 指令一行:
VREG[0x1] = (VREG[0x1] + -0x4a0) | 0x1
VREG[0x6] = *(VREG[0x5] + 0xc) | 0xf
*(VREG[0x1] + 0x498) = VREG[0x3] | 0x3
几条规则:
- 虚拟寄存器文件三套名字:VREG(整数,x24)、FREG(浮点,x26,4 字节步长)、DREG(双精度,x27,8 字节步长)。x25 是 handler 表,故意不种。
- 槽号写实例值(VREG[0x14])—— 槽号本身就是这条指令的操作数。
- 解引用不折:*(VREG[0x5] + 0xc) 折了操作就没了。sext(*(…)) 同理。
- 子树里没有读(没有 VREG 读、没有解引用)的式子整体算成一个数,负数写成 -0x4a0:sext(0xfb60) 就是这么变成 -0x4a0 的(ldrsh 读出来的 16 位原文,符号扩展后是负偏移)。
- 写 vPC 的那条 STORE 不占行,它是行尾的 | ip。
卡点:种子太窄,漏掉一整类 handler
上面那行 *(VREG[0x1] + 0x498) = VREG[0x3] 是后来才有的。中间有个 handler 让我一度怀疑反向是错的:
0x2e8e40 ldr w9, [x23] ; IP
0x2e8e4c ldrb w10, [x8, #8] ; 源虚拟寄存器号
0x2e8e54 ldrb w13, [x8, #9] ; 目标虚拟寄存器号
0x2e8e58 ldrsh x14, [x8, #0xa] ; 偏移
0x2e8e64 ldr x10, [x24, x10, lsl#3] ; x10 = VREG[源] 的值(一个指针)
0x2e8e68 ldr x12, [x24, x13, lsl#3] ; x12 = VREG[目标] 的值
0x2e8e74 str x12, [x10, x14] ; ★ 往内存写, 不是往 x24 写
它一个字节都不落 x24 —— 这是 VM 的 store 指令:把 VREG[目标] 写进「VREG[源] 指向的对象 + 偏移」。我原来把种子定成「写 x24 的 STORE」,这一整类全被过滤掉了,看起来就像"这条 handler 没算东西"。
结论是:反向没问题,是种子太窄。 种子应该是所有 STORE —— 写虚拟寄存器文件和写内存都是「这条 VM 指令干了什么」;读(load)本来就会在值表达式里被反向带出来,不用单独收。
改完之后它出来的是:
*(VREG[0x1] + 0x498) = VREG[0x3] | 0x3
拿 trace 对:ldrb w10,[x8,#8]=1(源)、ldrb w13,[x8,#9]=3(目标)、ldrsh x14,[x8,#0xa]=0x498、X10=0x6d9da72d60=VREG[1]、X12=0x0=VREG[3],全对上。
实测
全量 749 万行 trace 跑一遍:
段数 = 232281 唯一 entry = 266
每个 entry 都有 IR(2~6 行,合计 875 行)
异常 0 渲染撞上限 0
「每个 entry 都有」这句是放宽种子之后才成立的:之前有 15 个 entry 一行都打不出来,因为它们是纯写内存的(一个字节不落 x24)—— 种子一放宽,这 15 个全出来了,正好补上 266 这个数。
40 万行抽样那趟也顺手量了:4567 个带 IR 的段、12003 行 IR、0 条表达式撞上限 —— 折叠把位拼装真的收干净了,不是靠截断遮丑。
最后是那个感悟的引子,一屏 IR 里规律自己往外冒:
*(VREG[0x1] + 0x498) = VREG[0x3] | 0x3
*(VREG[0x1] + 0x490) = VREG[0x4] | 0x4
*(VREG[0x1] + 0x488) = VREG[0x1b] | 0x5
*(VREG[0x1] + 0x480) = VREG[0x1a] | 0x6
偏移每次 −8、源槽号递减 —— 一个结构体字段的批量搬运,展开成直线 IR 之后规律自己浮出来了。同理 (*(0x70173276a8) + 0x0/0xe0/0x140) 是同一个对象的字段访问:基址一样、常量偏移不同。
顺着这个再往下走一步就很自然:现在行尾只有一个 IP(回写后的),把取出来那个 pre 也带上写成 0x2 -> 0x3,这 23 万行 IR 就不再是孤立片段,而是 VM 层的执行序列 —— IP+1 是顺序、跳变是分支、回跳是循环,CFG 和循环边界自己就掉出来了。
网硕互联帮助中心





评论前必须登录!
注册