-
-
[原创]阿里 sgmian 第三篇:签名命令分析 70102 之 VMP 分析
-
发表于: 1小时前 37
-
时间: 2026 年 11 月 08 日 星期日 01:37:35 CST
作者: 人生导师
哈哈哈哈哈哈哈哈哈哈哈哈,成了,没想到我的方法在阿里身上更加好用,哈哈哈哈哈
其实也可以说是对阿里四神虚拟机的分析,希望能拿精华吧,简历上就缺一行然后格式就好看了
成了归成了,过程还是得记下来,不然下次换台 VM 又要从头摸。
先把结论摆这儿:淘宝这台 VM 不是我一开始以为的「另一个抖音」。
它是 direct-threaded 解释器,状态几乎全在寄存器里,handler 表只有一张;真正难的不是找 handler,是让 IR 在变长字节码槽、嵌套帧和 native 调用之间不散架。
按之前的叫法,淘宝这台我叫它四神。它和京东、快手、得物一样,属于「handler 内部直接算」的那一类,不是六神那种 OLLVM Pass 级 VMP。
虚拟机本身只有一页纸的入口:
命令层的参数封送、返回值组装在 2.70102 那篇已经铺过,这篇只讲 VM 本身:它怎么跑,怎么从 trace 还原到 IR。
我一开始把阿里想得太复杂了:一看到 vm_invoke 这种名字,下意识以为它是虚拟机入口,结果它只是个命令调度器——一张表、查表、调用。真正执行字节码的是它下游的 0x170D1C,一个十几条指令就能讲完的 direct-threaded 解释器。
这台 VM 和抖音七神最大的区别有两条:
代价是字节码槽是变长的:0x10 / 0x20 / 0x30 都有,还有调用/跳转后直接 [x21] 取指的形态。你要是假设「一条 VM 指令占一个 0x10 槽」,IR 的 vIP 会全错。
Java 侧 doCommandNative(70102, Object[]) 进来之后,先到 jni_doCommandNative(0x5A2FC),然后:
这里最容易误判的是 vm_invoke。它是重入的:
命令号第一段区分世界:a=7 是业务命令,a=1 是库命令。所以看到 vm_invoke 不能直接把它当虚拟机,它只是 L1 调度层;真正的 VM 是 g_vm_hook_fn 下游那个拿 x21/x23/x25/x27 的解释器。
0x170D1C 的参数约定是这样的:
入口先把它们钉死:
x3 是入口槽号,x21 = x22 + x3*0x10。这条式子很重要:它说明字节码槽的步长是 0x10,而且调用 VM 函数时传的是「函数入口相对字节码基址的槽号」,不是绝对地址。
ctx 的布局(按 x27 量):
这里有个细节要单独说:ctx+0x8 不是 vPC 本体,只是解释器每轮把 x21 存一份的簿记槽。真正决定下一条指令的是 x21。七神那台反过来,vPC 是 ctx+0xC 的内存槽(x23 指向它),字节码源在 x20,当前指令在 x8。两台的锚点不能混。
这台 VM 的锚点全是寄存器,入口一次定死,后面不再变:
锚点一律按 offset 认,不按名字认。 同一个 offset 上 w23/x23 是两个视图(size 4 和 8),按名字认会漏。这个坑在七神那篇已经踩过一次,这边直接按 offset 写进 lifter.py 的 ANCHOR:
和抖音那台的对照:
这张表就是移植的全部工作量。dy_7/VMP 那套 parse → matcher → lifter2 的结构没动,换的是 ANCHOR / SPACE / CTX_OFF / FILES / IP 这五个常量。
每个 handler 的最后四条基本是固定的:
切段要认的是 str x21,[x27,#8] 紧跟 br,不是「br 的寄存器来自 x23 表」。 原因是有条件跳转 handler 两个分支各自算自己的下一条:
两个分支都能查到自己的表项,所以「br 的寄存器来自 [x23, …]」这条判据在命中的分支上会漏。按簿记指令认,两个分支都会落在同一个段边界上。
拿一个最干净的 OR handler 看:
对应的 IR 一行:
0x221 是 opcode,0x179d00 是 handler,14834 是本次执行次数,<0x2> 是回写后的 vIP。opcode → handler → 语义这张表就是这么长出来的。
一条 VM 指令的槽布局:
N 不是常数。分发尾巴里的预增量就是答案:
这里要纠正 2.5 那篇的一个统计口径:那里还列了 399 条 ldrh w8,[x21,#0xa]。我后来查了,这 399 条全部来自 0x1afd20,那是个普通 native 函数,x21 在里面是另一个结构体指针,不是 VM 的 vPC。把它们算进 dispatch 会让总数虚高 399。真正的 dispatch 是:
和切段数 690,815 只差 2 条(收尾和跨深度的边界段),口径基本对得上。这个口径我当时没细看,后来对总数才发现,算是给「数字必须自洽」交了一次学费。
四条帧协议指令要单独记:
prologue 的实际动作(x25 是 vreg 基址):
return 反过来:
vreg 的角色划分也就清楚了:
8845 对 prologue/return、3781 次 call、161 次 exit、最大深度 3。这台 VM 有自己的调用约定,而且调用约定本身就是字节码语义的一部分。 所以 IR 里 @PROLOGUE、@RET、@CALLVM 不是装饰,是控制流骨架。
matcher.py 是流式的,规则只有一条:
一次 dispatch 跳转(br)收一段;段 = 上一次 dispatch 到这一次 dispatch 之间的完整执行切片,就是一个 handler。
ENTRY(0x170D1C) / EXIT(0x170AC4) 不是段边界,只是深度标记:执行到 ENTRY 深度 +1,执行到 EXIT 深度 -1。段可以跨 ENTRY/EXIT——复合 handler 里 blr 进嵌套 VM 时,外层段不会断,嵌套执行完继续回到外层段。interpreter.py 在段尾检查深度,如果 EXIT 把深度打回段首以下,就在 EXIT 处截断,避免把外层帧的原生代码混进来。
实测:
214 个 opcode 里,纯直线 133、块内条件分支 81,这两类正好凑满 214;另外有 13 个 handler 带 bl/blr,其中 4 个是复合指令:
这里的「分支」是块内条件,不是跨段的跳转。跨段跳转由 ip 线表达,下一节说。
整条流水线是:
这份 trace 是 DuckDroid 用 --stage 70102 --trace DIR 产出的:init 在 trace 开始前跑完,arm 在 doCommandNative 入口(libsgmainso+0x5A2FC),到它的 ret 结束;后台线程和 init 命令都不记录。两个实现细节决定了这份 trace 能不能用:
复跑对拍用 TAOBAO_RANDOM_SEED + TAOBAO_WALL_TIME_NS 固定熵流/时钟,否则每次熵流和时钟字段都会变,trace 没法逐条对。
淘宝这台不用 keystone 回汇编,原因很实际:
pypcode 用的是 AARCH64:LE:64:v8A。每译一条指令先插一个 IMARK,A64 定长 4 字节,所以 IMARK 和指令一一对应,用它当边界切 shadow:
值一律来自 trace 的 read/write 列,不来自求值:
x/w 是同一个物理槽的两个视图,必须按 offset 合并,再按 size 分层保留:
不这么干,x10 会被 w10 吃掉,后面所有值全错。
taint() 只做一件事:单趟扫 shadow,给每个 varnode 记「你的值派生自哪条锚点线」。
DEREF 表就是「指针 → 指针指向的值」:
x21 线、x23 线、x25 线、x24 线都是同一个函数换个种子。先用种子把锚点线拉出来,再谈语义,这是整套方法的核心顺序。
操作数线不是从 handler 头往下推,而是从写虚拟寄存器文件的 STORE 起,往上游跟 def-use。
理由和七神那篇一样:一条 VM 指令的结果就落在那几个 STORE 上。往前推是发散的,往回追是收敛的——每个 varnode 只有一处定义。p-code 不是严格 SSA,但块内是顺序语义:「use 之前最后一次写」就是它的 def,反向走是确定的,不用解数据流方程组。
根只有四类:
helper 里的槽位是 spill/reload + 别名算出来的,静态锚点认不准。所以 _ctx_delta() 沿 def 链往回走,根必须落在:
路上只允许 COPY、零符号扩展、常量加减、栈上 spill 再 reload。走到别的寄存器/算式就认不出,认不出就按裸指针 * 输出,不猜。这条「宁缺勿错」在 helper 里特别重要,因为 helper 有大量自己的栈帧和自校验状态机,认错了整段 VREG 都会废。
_img_addr() 负责另一件事:这个访存地址到底在 VM 镜像里,还是在运行期对象上。
SLEIGH 把 BFI/UBFX/LSR 展成 shift/or/and 链,一条指令能膨胀成几十个算符。这些是解码头,不是 VM 语义。不折的话,IR 就是一堵墙。
这台用的办法是追字节道:算式的第 k 个字节是从哪个内存字节来的。位对齐(整字节的移位/掩码)才追得动,追不动就不折。
够不够格折成「读」,三条判据缺一不可:
两个坑:
csel 也要特殊处理。A64 的 csel 没有 CSELECT op,SLEIGH 展成「CBRANCH + 同一格 unique 在分支前后各定义一次」。反向线性取「use 前最后一次写」只拿到落空那一支,条件整支丢。所以 _branches() 把 CBRANCH 夹出来的条件抓回来,_fold() 再把条件折成常量的 csel 收成一支。条件跳转的语义全在条件上,丢了条件等于丢了这条 handler。
一条 VM 指令一个 vline(可以有多行 IR),列和 code.log 对齐:
举例:
几条渲染规则:
整套东西拆到最后,其实就是三条线:
x23 只做两件事:取值、被 br。ldr xN,[x23,xN,lsl#3] 的 xN 是 opcode,查出来的就是 handler。切段按上一节的尾巴签名,不按 handler 内部。
ip_line() 取三个东西:
正常 dispatch 的 tree 是 ip + 0x10;条件/跳转/返回 handler 是 csel / ip + N*0x10 / VREG[0x1f]。str x21,[x27,#8] 是簿记,不在这条线上,_is_ip() 里专门把它排除掉。
_slot_index() 把绝对地址归一成槽号:
这样 vIP 跨 run、跨线程稳定,不会因为 ctx 地址变了就全乱。
operation() 扫所有 STORE:
种子是所有 STORE,不是「写 VREG 的 STORE」。这一点在七神那篇被打过脸:VM 的 store 指令一个字节都不落 VREG 文件,种子太窄会整类被过滤掉,看起来像「这条 handler 没算东西」。这边从一开始就放宽了。
全量 19,179,756 条 trace 跑一遍:
669,183 和 690,815 差的那 21,632 段,就是「只有 ip+0x10 隐含推进、没有数据写、没有显式分支」的段。它们不是丢了,是本来就没有可渲染的语义。
随便截一段真实 IR(out/handlers):
这屏 IR 里规律会自己浮出来:
把 pre -> post 串起来,就是一台 VM 的执行序列:ip+0x10 是顺序、跳变是分支、回跳是循环。CFG 不用单独造,从这列就能长出来。
13 个带 bl/blr 的 handler 里,有 4 个是真正的复合指令,内部会再进 VM:
这里的「次数」是 handler 执行次数,不是嵌套 VM 次数。真正进嵌套 VM 看 @VM_ENTER(161 次),别把 4,836 当成 4,836 次重入。
0x18C870 是最干净的样本:
call_vm 的返回值是被调 VM 函数的字节码入口;返回 0 表示长返回(被调函数已经把返回地址塞回 vreg[15])。所以 x21 = x0 是「跳进 VM 函数」,不是「函数返回」。0x18F2DC 多做了 orr/and,0x16FA70 用 call_vm2 走运行期方法对象,0x173E3C 走另一条 helper。
这四个之外,还有 resolve_native / lookup 两条不调用函数的「机制调用」:
vm_invoke 是另一条固定流程,不用看字节码:
本 trace 373 次 = 340 handler + 33 hook,两个出口严格一一对应。hook 落点 0x1B4DC0,就是 VM 的重入入口。
native 调用的 ABI 也固定:
native_checkpoints.tsv 有 5,857 个检查点,每个都记录 x0..x6 的符号表达式、callee 真正消费的实参上的 Java 依赖、额外实参。当前 sym_gaps=0。native 屏障的意思是:只锁 ABI,不进去;每次 native 调用后 mem 缓存作废,避免把 callee 内部的写当成 VM 的写。
前面一直在讲 VM 怎么跑,但 VM 并不认识 Java 对象。它看到的只是一块 216 字节的 sgm_cmd_args。从 Object[] args 到 VREG[0x10],中间有三层手:
这一节按这三层拆。
本 trace 的 Java 入参(hook_a.log):
Java 侧签名是 doCommandNative(int id, Object[] args),所有元素都是 boxed object。native 侧不能直接当 int/bool 用,必须走 JNI 取。
jni_doCommandNative(0x5A2FC)的头几行:
这里要纠正一个说法:b 不是 hash,是 (id % 10000) / 100。反编译里的 (5243 * x) >> 19 是「除以 100」的倒数乘法,5243 / 2^19 ≈ 1/100。所以 70102 拆成 (7,1,2),70703 拆成 (7,7,3),不是随机 hash。
ctx_jni 是栈上的 0x20 字节:
flag=1 表示走 native 表;status 是另一个 int,失败时 throw_sec_exception()。
vm_invoke(a,b,c,flag,ctx,status)(0x56F70):
(7,1,2,1) 在 native 表里查到 mod+0xD002C,也就是 cmd70102_handler。注意这一步还不是 VM:它只是命令 handler,和 (1,32,12) 那些库命令共用同一张调度器。
cmd70102_handler 在栈上开了一个 sgm_cmd_args args(216 字节),先把 args.str0.len 起的 0xD0 字节清零,再给几个短字符串预置栈缓冲:
str7 / str8 不预置(s=NULL, cap=0),只有非 null 且长度达到或超过 cap 时 getter 才 malloc。
然后按 Java 下标取:
字符串 getter 的动作(jni_get_string_arg 0x154744):
jni_get_bool_arg 是 GetObjectArrayElement → Boolean.booleanValue → &1;jni_get_int_arg 是 GetObjectArrayElement → Integer.intValue。都是「先取元素,再 unbox」,不是直接读 jintArray。
sgm_cstr 和 sgm_cmd_args 的布局:
本 trace 的实际值:
这里有两个细节:
vmrun(args, out, status)(0xD3410)先做两个检查:
然后把 args 和 8 个输出槽打包:
flag=0 走 script 表;表里没有 (7,1,2),于是落到 g_vm_hook_fn(0x1B4DC0)→ 0x1B0C50 → VM 解释器。
注意这里换了一个 ctx。 JNI 入口的 ctx_jni 是 {a,b,c,0,env,Object[]};vmrun 传给 VM 的 ctx 是 {&sgm_cmd_args, &out_slots}。VM 的 ctx[0] 不是 JNIEnv,是参数结构;ctx[1] 是输出槽。两套 ctx 不要混。
VM 入口 0x170D1C 拿到的 x1 是 v64。它把 x1 放进 x27(ctx),之后某条 VM 指令执行:
从这以后,VM 里的 Java 参数就是 VREG[0x10] 指向的那块 216 字节。字段访问全是固定偏移:
这张表就是「Java 参数在 VM 里到底从哪进」的完整清单。几个观察:
vmrun 把输出槽 strdup 成 out[0..7],cmd70102_handler 建一个 java.util.HashMap,按字段名 put 进去,header_map_to_java 用 NewObject + NewStringUTF + CallObjectMethod 返回 Java 对象。输入是 Object[] → sgm_cmd_args,输出是 out[] → HashMap;两条路在 cmd70102_handler 里交汇,但方向相反。
tools/sym_check.py 不是还原器,它是值流闭包检查:把 VM IR 当成单路径程序,符号化执行一遍,看每个值有没有来源、每个 native 实参能不能对上 VM 槽。
模型:
核心就两个函数,一读一写(简化版),正好对应「完整」和「对称」:
read_slot 发现槽里没有值,就记一条 gap;write_slot 发现旧 def 一次都没被读过就被覆盖,就记一条 orphan。这两个计数器就是 sym_gaps / sym_orphans 的来源。
它输出四样:
「完整」看 sym_gaps=0:没有哪个操作数是从空气里来的。每个读都能追到常量、Java 输入、ctx/module 基址、或某个 def;追不到就是 gap。
「对称」看两头:
provenance 精确字段核对 14 对 / 0 不对,是这套检查的抽样对账:挑了 14 个直接 load 的字段,符号来源和实测值逐一对,全部对上。
要说清楚它的边界:这是单路径检查,按 trace 顺序跑,不枚举分支;native 只锁 ABI,不进内部;heap 载荷和外部返回值记成 ?unknown(origin),不算缺值。它能回答「这次执行的值流有没有断、VM 槽和 native 实参对不对得上」,不能回答「任意输入下语义是否等价」。但对我们这种「先保证这次 trace 的骨架是完整的」的目标,够了。
下面是 out/handlers 的头部原文(; 开头是注释)。参数映射、VM 内使用点、native ABI、符号检查都在这。三处小改:b 那一行按反编译改成 (id%10000)/100(原文件写的是 hash);第一行那句指向还原用途的话删了,免得又把这篇写成还原入口;appKey 和 str1 的 token 前缀打码成 <appKey> / at88…。其余照原文。
文件头里 flag = 8 (Java args[11]) 是简写:Java 给的是 0,8 是 vmrun 的配置开关置的,见 11.4。再往后就是逐行 IR(vline / vIP / off / seq / kind / ir),第 9 节已经截过一段。
这台 VM 的「骨架」已经能看了,但离「还原成伪 C」还差几步:
顺序还是那句:先出块,再出 CFG,伪 C 排最后。 反了做不出来。
从最初接触这个 SDK,到逐渐摸清它的运行时结构、虚拟机调度机制和算法执行流程,整个过程比预想中复杂,也收获了不少意料之外的东西。
曾经觉得非常复杂的东西,在真正投入时间、建立模型、逐步验证之后,也开始变得清晰。
比起最终还原了多少函数、多少命令,我更看重这次研究带来的分析经验。、
未知的东西总会存在,今天是 SGMAIN,明天也可能是另一套完全陌生的执行体系。
继续研究,继续积累。
他日卧龙终得雨,今朝放鹤且冲天。
不过是些许风霜罢了。
Java doCommandNative(70102, Object[])
└ jni_doCommandNative 0x5A2FC
└ vm_invoke(7,1,2,flag=1) 0x56F70 ← 命令调度器,不是虚拟机
└ cmd70102_handler 0xD002C ← 参数封送 / 结果组装
└ vmrun 0xD3410
└ vm_invoke(7,1,2,flag=0) ← script 表 miss,落到 hook
└ g_vm_hook_fn 0x1B4DC0
└ 0x1B0C50 → 0x1B05C4 → 0x160300 → 0x18F6CC
└ 0x170D1C ← 真虚拟机入口
0x170d58 mov x26, x2 ; module
0x170d5c mov x23, x5 ; handler 表
0x170d60 mov x27, x1 ; VM ctx
0x170d64 mov x25, x4 ; 整数 vreg = ctx+0x40
0x170d68 mov x28, x0 ; 函数对象
0x170d6c add x24, x4, #0x100 ; 浮点文件 = ctx+0x140
0x170d70 ldr x22, [x26, #8] ; 字节码基址
0x170d74 mov x21, x22
0x170d78 add x21, x21, x3, lsl #4 ; 当前槽 = base + entry*0x10
0x170d7c ldrh w0, [x21]
0x170d80 ldr x1, [x23, x0, lsl #3]
0x170d84 str x21, [x27, #8]
0x170d88 br x1
jni_doCommandNative 0x5A2FC
└ vm_invoke(7,1,2, flag=1) 0x56F70 ← native 表
└ vm_resolve_command 0x567A8
└ cmd70102_handler 0xD002C ← 只做参数封送 / 结果组装
└ vmrun 0xD3410
└ vm_invoke(7,1,2, flag=0) ← script 表 miss
└ g_vm_hook_fn 0x1B4DC0
└ 0x1B0C50 → 0x1B05C4 → 0x160300
└ 0x18F6CC → 0x170D1C ← VM 内核
0x170d58 mov x26, x2
0x170d5c mov x23, x5
0x170d60 mov x27, x1
0x170d64 mov x25, x4
0x170d68 mov x28, x0
0x170d6c add x24, x4, #0x100 ; ctx+0x140
0x170d70 ldr x22, [x26, #8] ; 字节码基址
0x170d74 mov x21, x22
0x170d78 add x21, x21, x3, lsl #4
0x170d7c ldrh w0, [x21]
0x170d80 ldr x1, [x23, x0, lsl #3]
0x170d84 str x21, [x27, #8]
0x170d88 br x1
ANCHOR = {
0x40A8: "ip", # x21 字节码槽指针(控制流)
0x40C8: "regf", # x25 整数 vreg 文件(数据流)
0x40B8: "table", # x23 handler 表(边界)
0x40C0: "float", # x24 浮点文件(ctx+0x140)
0x40D8: "ctx", # x27 VM ctx
0x40D0: "module", # x26 module
0x40B0: "bcbase", # x22 字节码基址
0x40E0: "fnobj", # x28 函数对象
}
ldrh wN, [x21, #0x10]! ; 取下一条 opcode,x21 前移 0x10
ldr xN, [x23, xN, lsl #3] ; 查 handler 表
str x21, [x27, #8] ; 簿记当前 pc
br xN ; 尾跳
0x182308 ldrb w4, [x21, #4]
0x18230c ldr x3, [x25, x4, lsl #3]
0x182310 ldrsw x1, [x21, #8]
0x182314 cmp w3, w1
0x182318 b.eq 0x182334
0x18231c ldrsw x5, [x21, #0xc]
0x182320 add x21, x21, x5, lsl #4 ; 落空支:相对跳
0x182324 ldrh w8, [x21]
0x182328 ldr x2, [x23, x8, lsl #3]
0x18232c str x21, [x27, #8]
0x182330 br x2
0x182334 ldrh w12, [x21, #0x20]! ; 命中支:跳过 1 槽
0x182338 ldr x11, [x23, x12, lsl #3]
0x18233c str x21, [x27, #8]
0x182340 br x11
0x179d00 ldrb w2, [x21, #6] ; src1
0x179d04 ldr x0, [x25, x2, lsl #3]
0x179d08 ldrb w15, [x21, #7] ; src2
0x179d0c ldr x6, [x25, x15, lsl #3]
0x179d10 orr x12, x0, x6
0x179d14 ldrb w14, [x21, #4] ; dst
0x179d18 str x12, [x25, x14, lsl #3]
0x179d1c ldrh w19, [x21, #0x10]!
0x179d20 ldr x17, [x23, x19, lsl #3]
0x179d24 str x21, [x27, #8]
0x179d28 br x17
0x221 0x179d00 14834 orr VREG[0x1a], VREG[0x0], VREG[0x18] <0x2>
+0x00 u16 opcode
+0x02 .. +0x07 六个操作数(每字节一个寄存器号 / 小立即数)
+0x08 .. +0x0f 立即数 / 偏移
步长 = 0x10 × N
472143 + 105340 + 89681 + 23649 = 690813
0x186768 ldr x11, [x25, #0x70] ; vreg[14] = 旧 SP
0x18676c ldr x2, [x21, #8] ; 帧大小(负数)
0x186770 add x16, x11, x2 ; 新 SP
0x186774 str x16, [x25, #0x70]
0x186778 ldp x9, x10, [x25, #0x80] ; vreg[16..]
0x18677c stp x9, x10, [x11] ; 存到旧 SP
...
0x1867b8 ldp x9, x10, [x25, #0x40] ; vreg[8..9]
0x1867bc stp x9, x10, [x25, #0xc0] ; -> vreg[24..25]
...
0x1867d0 str x11, [x25, #0xf0] ; vreg[30] = 旧 SP(帧基址)
0x1867d4 ldr x19, [x25, #0x78] ; vreg[15] = 调用点
0x1867d8 str x19, [x25, #0xf8] ; vreg[31] = 返回地址
0x187594 ldr x20, [x25, #0xf0] ; 帧基址
0x187598 ldp x9, x10, [x25, #0xc0] ; vreg[24..]
0x18759c stp x9, x10, [x25, #0x40] ; -> vreg[8..]
...
0x1875b0 ldr x14, [x25, #0xf8] ; vreg[31] = 返回地址
0x1875b4 str x14, [x25, #0x78] ; -> vreg[15]
0x1875b8 ldp x9, x10, [x20] ; 恢复 vreg[16..31]
...
0x1875f8 str x20, [x25, #0x70] ; SP = 帧基址
0x1875fc ldr x21, [x25, #0x78] ; vPC = 返回地址