这篇文章记录的是一次闲鱼 Android 端 x-sign 参数算法的还原过程。样本里的核心逻辑不在普通 Java 层,也不直接以清晰的 native 函数形式出现,而是被放进 AVMP 虚拟机执行。native 层能看到大量解释器、handler、helper 和 descriptor 调用,但真正决定参数结果的数据流藏在 VMCode 里。
本文使用的样本环境大致如下:
分析过程中同时使用了静态和动态证据:静态部分包括 handler table 恢复、VMCode dump、opcode 语义归纳、VMCode 到 IR 翻译;动态部分包括参数捕获、指令级 trace、关键 VM slot 和内存状态快照。文章里的地址、index、opcode 和中间值都来自这些分析产物。
本文不会完整展开所有业务字段,也不会把每个 helper 都逆完。重点放在一个更通用的问题上:面对 AVMP 这类虚拟机保护,怎么从“看不懂的 native dispatch”推进到“能切片、能验证、能还原算法的数据流”。x-sign 只是案例,用来说明 VMCode 转 IR 对参数算法分析到底有什么实际帮助。
在分析带虚拟机保护的参数算法时,最容易陷入的误区是:一直跟 native 汇编,以为把解释器看完就等于把算法看完。实际上,解释器只是执行引擎,真正的参数算法通常在自定义 VMCode 里。本文后面会先介绍 AVMP 的 VMCode 设计,再说明 VMCode 如何转成 IR,最后用 x-sign 的 MD5、SHA1、XOR、Base64 链路说明 IR 对算法还原的贡献。
在普通 native 算法里,我们通常找入口函数、看参数、看内存读写、跟调用链,最后把核心逻辑还原成伪代码。
但 AVMP 保护后,真实逻辑会被拆成几层:
如果只看 native,会看到大量 handler:
这些 handler 不是业务算法本身,只是 VM 指令的实现。直接跟这些 handler,相当于在 CPU 层分析每条机器指令,而不是看编译后的函数逻辑。
所以更有效的路线是:
本次样本里,AVMP launcher 会调用真正的解释器函数。解释器拿到几个关键参数:
解释器的核心流程可以抽象成:
这里有几个重要信息:
这一步的目标不是完全理解所有 handler,而是确认“解释器怎么取指、怎么分发、VMCode 从哪里读”。
样本里 handler table 位于:
但直接看 ELF 文件时,这个区域是零填充。原因是 table 项不是静态写死在文件里的绝对地址,而是通过 .rela.dyn 重定位生成。
也就是说,静态恢复时不能只读文件里的 table bytes,而要解析 relocation:
恢复后可以得到:
这个映射非常关键。后面所有 VMCode decode 都依赖它:
为了避免静态误判,需要做一次运行时交叉验证:观察解释器里真实使用的 table base,比如 X23 = module_base + 0x2ADC60,并确认运行时分发的 handler 和 RELA 恢复出来的 handler 一致。
通过 runtime dump 和 handler 访问模式,可以确认当前 VMCode 指令格式大致如下:
同一个 imm 字段在不同 opcode 里有不同解释:
例如:
看一个真实 VMCode 记录会更直观。x-sign 最终输出点 0x453f 的原始 16 字节如下:
按上面的结构拆开:
结合 opcode 0x242 = st64_base_imm,这条记录就能翻译成:
再看前一条 0x453e:
两条连起来就是:
也就是从 VM frame 里取出输出对象指针,再把 %r8 指向的 x-sign 对象写到输出结构体的 +0x20 字段。这个例子说明,16 字节记录不是抽象猜测,而是可以逐字节拆出来,并且能和最终业务语义对应上。
这里最容易踩坑的是跳转。很多 branch opcode 不是跳到字节偏移,而是跳到 VM 指令索引:
如果把 delta 当字节偏移,CFG 会完全错。
opcode 语义可以分三层确认。
第一层是 handler 短路径。很多 handler 非常短,语义可以直接从汇编看出来。例如 st64_base_imm:
可以翻成:
第二层是 handler 家族。比如一组 load/store opcode 结构类似,只是访问宽度不同:
这种可以先确认几个代表 handler,再批量归纳。
第三层是 helper 型 opcode。有些 handler 本身只做参数搬运,然后调用一个复杂 helper:
这类不能只看 handler,要继续看 helper。比如某组 raw-FP128 opcode:
这里故意命名成 rawfp128,而不是直接叫 IEEE f128。原因是静态证据能证明它是扩展浮点样式的 raw representation 和 arithmetic helper,但不能随便声称它就是某个标准 ABI 类型。
拿到 opcode 语义后,第一步可以先输出伪汇编:
这一步已经比 native handler 清楚很多,但还不够。伪汇编适合阅读,不适合自动化分析。比如想反向追 %r8 从哪里来、哪些 block 写过 x-sign 对象、哪些循环是 XOR loop,就需要更结构化的表示。
所以第二步是转成 IR。
这里的 IR 不是为了做编译优化,而是为了逆向分析。目标是:
VM slot 统一表示成:
raw-FP / SIMD 类 slot 统一表示成:
这样可以把 ARM64 寄存器噪声去掉,只保留 VM 层变量。
不同宽度的 load/store 统一成:
如果是符号扩展,也要显式保留:
否则后续判断有符号比较、长度检查时会出错。
算术统一成表达式:
这样一眼就能看出:
如果再接一个 xor 常量,就可以识别成:
branch 和 jump 统一成:
AVMP 里经常有 bytecode 子过程,调用和返回不一定是普通 call/ret。对于这种情况,可以在 CFG 构建时做保守近似:对某些直接 jump 同时保留目标边和 fallthrough 边,用来覆盖 VMCode 子过程返回路径。
这不适合生成最终伪代码,但足够做“目标 store 是否可达”“某个窗口附近有哪些 producer”。
不是所有东西都应该强行还原。对未完全解释的复杂调用,IR 保留 side effect:
这样做有两个好处:
本次 x-sign 的完整 AVMP 镜像大小是 0x4e500,历史确认的 x-sign 入口是:
最终输出点位于:
转成 IR 后是:
这条指令的含义很明确:
也就是说,x-sign 最终不是某个 native 函数直接返回的字符串,而是 VM 内部构造出的对象,被写入输出结构体偏移 0x20。
从这条 store 反向看,可以得到:
这说明:
再把同一位置和完整 trace 对上,可以看到真实运行时值:
这说明静态 IR 里的:
在该次 trace 中对应真实值:
继续往前看这个 xsign_object 的字段,还能看到:
也就是说,0x453f 不是随便一个对象赋值,而是最终把长度为 0x66 的 x-sign payload 对象挂到输出结果上。
这类信息如果直接从 native handler 跟,会被大量 dispatch 淹没;在 IR 上则是几行数据流。
同一个 0x4e500 镜像,在不同捕获里可能有不同 entry。例如:
这两个 entry 对应同一个镜像 hash,但静态可达结果不同:
所以分析 AVMP 时不能只说“这个 image 是 x-sign”,还要说明:
否则很容易出现“同一个 VMCode 镜像,为什么这次切不到输出点”的误判。
IR 的另一个价值是识别算法形态。
比如某个 seed 变换在 IR 中长这样:
读成高级语义就是:
再比如一个字节 XOR loop:
在动态 trace 里补上具体值后,就可以确认:
本次 x-sign trace 里有一个更具体的 XOR run。0x78c50:bb_464d 这段循环一共命中了 6 组,其中最后一组直接参与最终 Base64 输入:
这个 a9 4e 不是静态常量直接拷贝出来的,而是前面 0x468f..0x4696 的 key setup 算出来的。trace 中两个字节的计算例子如下:
最终 Base64 前的 69 字节二进制状态是:
它的结构可以拆成:
标准 Base64 后得到 92 字节 tail:
前面再拼固定/半固定 header:
所以该次 trace 的最终 payload 是:
这里还可以再往上游补一段:tmp21 里的 md5(input) 来源。
早期我们在 0x4e500 的 x-sign 路径里定位到一段 MD5-like routine。静态上它有非常标准的 MD5 特征:
动态验证时,在 AVMP index 0x4502 捕获 MD5 输入,在 0x4503 捕获 digest 输出,再在 0x4514/0x4520 附近捕获 tmp21。其中一个样本是:
这个 input 也不是随机二进制。捕获到的 MD5 输入是 100% 可打印字符串,开头和 doCommandNative(70102) 的 Java 参数 arg[1] 是同一类 canonical signing string:
后面还会继续拼接一段可打印的 security material,所以关系更准确地说是:
不同请求、不同时间戳会得到不同的 digest。上面 e731... 是 MD5 封装验证样本;后面完整 x-sign 链路里的 122eff... 是另一条 trace 的 digest16。值不同不影响数据流关系。
把 MD5 来源、tmp21 封装、下游 XOR/Base64 串起来,当前 trace 中从参数材料到 x-sign 的算法流程可以写成:
其中几个关键具体值是:
这类 loop 在 native handler 层看起来是大量 ldr/str/br 的解释器噪声;在 IR 层就是普通的字节循环。
静态 IR 里还可以看到一些 hash-like 或 base64-like 的块:
这些信息很有用,但不能直接等价为“它就是当前 x-sign 主路径”。原因是一个 AVMP 镜像可能包含多段通用算法实现、工具函数、备用路径或不同命令共享的逻辑。
正确做法是:
在本次分析中,IR 帮助把“看起来像标准算法的块”和“当前 x-sign 实际执行路径”分开。这样可以避免因为看到 SHA/MD5 常量就过早下结论。
早期 decoder 里大量 opcode 只能叫:
这种情况下,IR 仍然能跑,但切片和算法识别会有很多断点。随着 opcode 语义补全,IR 质量会明显提升。
本次处理后的一个结果是:
并且剩余 opaque 全是单例。更重要的是:
这意味着 x-sign 相关路径的 opcode 层面已经基本打通。后面真正的难点转为:
换句话说,VMCode 转 IR 不是终点,但它把问题从“指令级看不懂”推进到了“数据流和对象语义还原”。
结合这次 x-sign 分析,可以整理出一套相对通用的流程。
目标是找到:
如果看到类似:
基本可以判断进入了 VM 解释器。
dump 时至少记录:
同一个 image 可能有多个 entry,所以不要只按 size 去重,最好按 image hash 去重,同时保留 entry 列表。
如果 table 在文件里不是直接地址,要看 relocation。
输出最好长这样:
并用运行时分发做一次校验。
优先级可以这样排:
不要为了清零 opaque 去分析所有单例。只有落在目标路径上的单例才值得优先处理。
IR 不必一步到 SSA。先做到:
这些已经足够支持很多静态分析。
[培训]《冰与火的战歌:Windows内核攻防实战》!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。