首页
社区
课程
招聘
某反作弊 VMP Native 层深度逆向:从 643KB 混淆 SO 到 RC4-like Mixer 的完整穿透路径
发表于: 14小时前 494

某反作弊 VMP Native 层深度逆向:从 643KB 混淆 SO 到 RC4-like Mixer 的完整穿透路径

14小时前
494

逆向只是我的一个乐趣而已,风控才有意思欧,ios流量太少了来安卓圈子玩玩,写的不好多担当打开一看 图片描述 插件好像秒不了,无语。

在逆向重度混淆样本时,不要一来f5。按"由表及里、由元信息到代码"的层级逐级穿透:

这个顺序非常重要。 30 秒的段表扫描能告诉你的信息量,远超在错误的函数上花 3 天做 F5。

readelf -l 看 PT_LOAD 段:

整理成表:

几个立即能得出的结论:

在 IDA 里看 .init_array

.init_array[0] = 0x55C1 基本只做 stack canary / 版本噪声检查;.init_array[1] = 0x7739 更有意思——它会做一批环境路径探测:

这些是自动化测试工具(AutoJS、按键精灵、触动精灵)和 root 框架的特征目录。 .init_array 阶段就开始做环境检测,比 JNI_OnLoad 更早。

这是本样本最有信息量的元信息之一。一个 643KB 的 SO 文件,导入表里只有 24 个符号。正常的 Android native 库(同等大小)通常有 100-300 个导入符号。

24 个符号意味着:这个库极度自包含,几乎不依赖外部 libc 函数。 它很可能在内部实现了自己的内存管理、字符串处理、甚至系统调用封装。事实上,后续分析确认了这一点——target.so 使用 SVC #0 直接系统调用,绕过 libc wrapper,让 strace 级别的 hook 失效。

更关键的是:没有 Java_* 导出符号。 这意味着 JNI native method 的注册不是通过标准的静态绑定(函数名匹配),而是通过 JNI_OnLoad 里的 RegisterNatives 动态注册。后续的 unidbg 实验证实了这一点。

在 IDA 的 Functions 窗口里按大小排序,sub_275CC 以 1404 条 ARM 指令(约 5.6KB)排在前列。对于一个混淆库来说,单个函数 1404 条指令不算离谱,但它有两个异常特征:

这个模式非常像解释器——同一个执行引擎,不同的字节码程序。

打开 sub_275CC 的反汇编,主循环的核心 dispatch 序列如下:

0x27638LDR R5, [R0] 是 VM 的 fetch 指令——它从当前 VM PC(R0)处读取一个 32-bit bytecode word 到 R5。这个地址是后续所有动态 hook 的主锚点。

0x276C4ADD PC, LR, R1 是 dispatch 的核心——它是一个 computed goto,直接跳转到 dispatch_table[opcode] 对应的 handler。这里 LR 不是返回地址,而是被预设为 dispatch 表基址附近的地址。

注意一个容易犯的错误: dispatch 表的起点是 0x276C8ADD PC 的下一个 4 字节对齐地址),而不是 0x276C4。如果误把 0x276C4 当表起点,所有 handler 地址会整体偏 4 字节,后续还原全部会错。

每个 handler 执行完毕后,会跳回 0x27638 重新 fetch 下一条指令,形成经典的 fetch-decode-execute 循环。

ADD PC, LR, R1 的下一条指令地址是 0x276C8,这就是 dispatch 表的起点。表中每个条目是一个 4 字节的偏移量,加上 0x276C4ADD PC 指令的地址)就得到对应 handler 的入口地址。

注意一个容易犯的错误: dispatch 表的起点是 0x276C8 而不是 0x276C4。如果误把 0x276C4 当表起点,所有 handler 地址会整体偏 4 字节,后续还原全部会错。

提取出的 64 条 dispatch 表:

64 个 opcode 中有 29 个指向同一个 DEFAULT handler,这意味着实际有效的指令类型只有 13 种。29 个 DEFAULT opcode 的存在不是浪费——它们是 opcode 空间膨胀的结果,让逆向者无法简单地通过 opcode 出现频率来推断指令语义。

target.so 里有三份几乎完全相同的解释器实现:

用脚本做 byte-level 对比:

差异全部集中在 BL(Branch with Link)指令的立即数偏移字段——因为 BL 使用 PC-relative 寻址,三份克隆在不同的 .text 地址上,所以同一个目标函数的偏移量自然不同。反汇编后,三份 BL 的目标地址完全相同。

克隆 3 有 0 个交叉引用——它是死代码。 没有任何函数调用它、没有任何指针引用它。它存在的唯一目的是:让逆向者在找到它之后多花一周时间分析,最后发现它根本不被执行。 8KB × 2 份无用克隆 = 16KB 的 .text 空间,换来的是逆向者的时间成本。

这也从侧面证实了"通用 IR 解释器"假说——只有解释器才值得被克隆 3 份。如果是普通的算法函数,克隆它没有意义。

每条 VM 指令是一个 32-bit word。通过分析 sub_275CC 中各 handler 对指令 word 的位提取操作,还原出以下字段布局:

对应的 native 汇编(在 OP11 handler 0x27F30 附近):

Python 版的解码器:

dst 字段的编码方式值得注意——它不是连续的 5 位,而是 bit[12:11] 拼上 bit[31]。这种非标准的字段拆分是 VMP 设计者有意为之,让自动化反汇编工具难以正确提取寄存器号。

VM 有 32 个通用寄存器(R0-R31)和一个 64-bit 累加器(acc_lo + acc_hi),累加器主要用于 div/mod 运算的商和余数。

frame 布局从 sub_275CC 的入口参数推导:

这个公式是后续所有动态观测的基础。算错 frame 布局,所有寄存器值都会变成假数据。

在 13 种 handler 中,OP11(primary = 11)是最复杂的一个。它不是一个单一操作,而是一个二级路由器——通过 ext 字段(word 的低 12 位)选择具体的 ALU 操作。

OP11 handler 的入口 native 汇编(0x27F30):

UDIVMOD handler 的关键实现(0x293E0 附近):

这里 [R7, #-0x18][R7, #-0x14] 就是 acc_lo 和 acc_hi 在 frame 中的位置。sub_89AB0 是 compiler runtime 的 __aeabi_uidiv(unsigned integer division)。MLS 是 "Multiply and Subtract":R1 = R3 - R0 * R2,即余数。

关键的 ext 值及其语义:

反直觉点: 标准的 MAC/Hash 主循环通常不需要除法操作。0x54B UDIVMOD 的存在说明这不是传统的密码学算法——它更像是一个自定义的 byte-index mixing 方案,用除法余数作为表索引。

在分析过程中发现了一条极其特殊的指令——ext = 0xF8B。它的表面行为像是一个间接跳转(PC = R31 指向的地址),但实际上它做了三件事:

这不是普通的 branch 或 call,而是一个 协程式的 continuation passing 指令。每次 F8B 相当于一个 longjmp 到一个新的执行帧。

动态验证确认了这一点:patch R31 为不同的地址,F8B 确实会跳到对应位置,同时 R29 发生非零偏移——而普通的 branch 不会改变 frame 指针。

5 个函数调用 sub_275CC,每个传入不同的 bytecode program 指针:

两个算法 wrapper 的 IDA 反编译对比:

核心区别:24CBC 返回 v6[4](计算出的签名值),270E8 返回全局变量 dword_9AD78 的增量(可能是 nonce 或 sequence 推进)。但两者传入 sub_275CC 的 bytecode 在所有 1024 个 dword 上是 逐位一致的

在早期分析中,我曾经试图用 0xCB 字节出现的频率来区分算法 wrapper 和业务 wrapper。这是一个误判

0xCB 实际上只是 primary_opcode = 11(OP11)的编码副产物——因为 11 的十六进制是 0x0B,而 VM 指令的低 12 位(ext 字段)经常以 xCB 的形式出现。业务 wrapper 里的 OP11 数量(291-327 条)反而远高于算法 wrapper(180-204 条),所以 0xCB 密度不仅不能区分算法,反而会指向错误的方向。

为了避免主观判断,我写了一套自动化脚本对 5 个 wrapper 做全面差分。结果极其清晰:

更细致地看 OP11 ext 频率分布,差异更加明显。以下是各 wrapper 的 OP11 ext 出现次数:

算法 wrapper 独有的 ext 集合(业务 wrapper 中出现次数 = 0):

0x10B(IDIVMOD)和 0x54B(UDIVMOD)只出现在算法 wrapper 中——这不是密度差异,是有/无的二元差异,是最硬的判据。

区分算法 wrapper 的判据不是单一指标,而是三件套同时成立:

前三个业务 wrapper 没有任何一项满足。

OP11 ext 的完整分布更加直观地展示了差异:

反直觉点: 业务 wrapper 的 OP11 密度(30%)远高于算法 wrapper(23%)。算法的"特殊"不在于计算量大,而在于它用了业务不用的操作类型——特别是 div/mod。

对比三个业务 wrapper 的 bytecode,在绝对 VA 地址 0x8F470..0x8FB8C 区间内发现了 476 个 dword 完全一致。这说明 VMP 工具链在生成 bytecode 时会把一段通用的"运行时支撑代码"(类似 prologue/epilogue/状态管理模板)内联到每个 program 中。

最强的证据:24CBC 和 270E8 的 1024 个 dword 在所有位置上逐位一致。两者的区别只在 wrapper 层——24CBC 返回 v6[4](局部数组的第 5 个元素),270E8 返回 dword_9AD78 的增量。

它们不是两套不同的算法,而是同一套算法的两个入口视图。

在确认 24CBC/270E8 是算法 wrapper 之后,下一步是在 4096 字节的 bytecode 中找到算法核心。

线索是 hard anchor0x54B(UDIVMOD)在 bytecode VA 0x90AC4 处只出现一次。

核心窗口 raw hex dump (0x90AC4..0x90BC8,66 条 VM word):

IDA 内 handler recovery trace 确认的完整事件表:

注意 3 个 EXEC_REJECTED 点(0x90AC80x90B440x90BC4):它们的 ext 值(0x8CB0x9CB)不被 OP11 handler 的 ext-router 接受。这不是"mixer 窗口错了"——它们说明需要 branch-aware / path-sensitive trace,不能把窗口内所有 word 都当顺序执行。这些 rejected word 更可能是 branch delay guard:在语义断点之后放一条保证被拒绝的指令,防止线性 fetch 意外执行。

从这个表追踪数据流:

这个结构和 RC4 的 PRGA 非常相似:i/j 通过取模生成 → 查 S-box → swap → 用 swap 后的值索引 S-box 生成 keystream byte → XOR 输出。

但我不把它命名为标准 RC4,因为还缺几个关键证据:

因此,最保守的命名是 RC4-like byte permutation / XOR mixer

VMP bytecode 中不是所有 dword 都是指令。有些区域是内嵌的常量数据(lookup table、密码学常量、配置参数),被 LOAD 指令通过地址偏移引用。我把这些区域叫做"数据岛"。

数据岛的识别方法是:在线性 sweep 时,如果一段连续的 dword 的 primary opcode 几乎全部落在 DEFAULT handler 上(概率约 45%),而且这段区域被某个远跳转(branch target 大于 data island end)跳过,那它大概率是数据岛。

24CBC/270E8 共有 4 段数据岛,134 个 dword。业务 wrapper 有 0 段。

0x90944..0x909FC 这段数据岛做已知密码学常量匹配,结果非常明确。

Blowfish P-array 连续命中(10 个 dword):

Blowfish S-box[0] 前缀连续命中(22 个 dword):

总计 32 个连续 dword 精确命中标准密码学常量。 这些值全部是 π(圆周率)的十六进制分段——Blowfish 算法使用它们作为 "nothing-up-my-sleeve" 初始化常量,意思是"常量来源于数学常数,没有后门"。

同时,脚本还对所有数据岛做了 16-byte 块的 Shannon 熵分析:

只有 0x90944..0x909FC 能被已知密码学常量库硬匹配。其他三段更像结构化数据、指针或混淆缓冲区。

尽管命中了 Blowfish 常量,但这段数据岛不是从 P-array[0] 开始的。标准 Blowfish P-array 起始值是 0x243F6A88 / 0x85A308D3 / 0x13198A2E,而 0x90944..0x9097C 的前 15 个 dword 不匹配任何已知 Blowfish 常量。

我做了一个验证:把前 8 个不匹配的 dword 分别 XOR 标准 P[0..7],看结果是否有 key 模式。结论是没有——XOR 结果无规律、非 ASCII、无周期性。 所以这不是 "P[0..7] 被 key schedule 覆写"的情况。

更准确的说法是:该样本的开发者借用了 π 常量作为初始化种子,但算法不是标准 Blowfish。 这些常量可能用在 key derivation 或 whitening 阶段,而不是作为 Blowfish 的 P-array 直接使用。

0x90C00..0x90E50 曾经在线性 sweep 中被"解码"出一些看起来像 OP11 指令的 word。但 IDA xref 分析揭示了真相——这段区域实际上是 XOR 编码的 ELF/JNI 辅助字符串。

0x90CD8 为例,线性解码会得到:

一个"完美"的 OP11 SUB 指令。但 IDA xref 揭示 sub_33134 会用逐字节 XOR 解密 0x90CD5..0x90CD8

0x90CD8 不是 VM 的 SUB 指令——它是 JNI 签名 ()Z(返回 boolean 的无参方法)的 NUL 终止字节!

同样的方法还原了其他字符串:

这些函数的命名(find_self_elf_basevalidate_elf_headercheck_linker_mappingfind_rodata_section)也印证了它们的用途:target.so 的反篡改/自检子系统在运行时解析自身的 ELF 结构。

核心教训:

只要高熵数据的 4 字节碰巧满足 OP11 的形态,线性解码就会产生"漂亮但错误"的算法指令。在 VMP 分析中,任何线性 sweep 的结果都必须经过 native xref / branch-aware CFG 的独立交叉验证。

target.so 有一个完全独立于签名算法的 CRC32 自检系统:

sub_75C58 的核心循环是典型的 slicing-by-4 优化 CRC32:

CRC32 的调用方式很特别——通过 vtable / 任务对象间接调用:

反逆向效果: hook sub_75C58 无效——只要攻击者换掉 vtable 里的函数指针,就绕过了整个 CRC32 校验。CRC32 是可替换的"叶子算法",真正的防线在 vtable 框架层。

CRC32 校验的期望值不是硬编码的魔数,而是利用了 CRC32 的数学性质:如果在数据末尾追加一个预计算的 4 字节 CRC32 值,那么整段数据(包括追加的 4 字节)的 CRC32 必然归零

代码中的判断就是:

逆向者即使理解了 CRC32 的计算逻辑,也找不到一个"期望值"来比对——因为期望值就是 0,而 0 不是一个有辨识度的常量。

该样本的反作弊是双轨并行设计:

两轨完全解耦,无共享代码/数据。但存在单向依赖:CRC32 扫描整个 .text 段,所以如果你 patch 了轨道 1 的 native handler 来做动态分析,轨道 2 的 CRC32 校验会失败。

在 mixer 入口 0x90AC4UDIVMOD acc = (reg3 /u reg18, reg3 %u reg18) 中,reg18 是除数。它决定了:

用 handler-aware 的 def-use 分析(排除 DEFAULT handler 的字段噪声),从 idx 633(0x90AC4)的 rA = 18 反向追踪。完整的 def-use 链:

结论:reg18 是从 wrapper 入口参数经 6 层 frame 状态链派生的。 这条链的每一步都依赖运行时值——GROUP_B_LOAD 从内存读数据,GROUP_F_ALU_IMM 对 frame 指针做偏移。静态分析可以告诉你"reg18 来自 frame",但无法告诉你"reg18 等于多少"。

作为对比,reg3(UDIVMOD 的被除数)的 backward slice 也追到了 INPUT:

注意 reg3 的 slice 经过了 0xF8B(F8B frame transition 指令),这进一步证实了 mixer 的输入状态依赖 frame continuation 机制。

def-use 分析还产出了一个副产品——dead def 统计。在 1024 条指令中有 186 个 dead def(写了某个寄存器但后续从未读取),覆盖 29 个寄存器。

ACC_LO 的所有 def 都是 dead —— 这意味着 UDIVMOD 的商(R3 / R18)从来没被使用过,只用了余数(R3 % R18)。这完全符合 RC4 的 i % N 模式——你只需要取模的结果,不需要商。

一个容易掉进去的坑:如果用朴素的字段提取公式(dst = ((word >> 11) & 0x1E) | (word >> 31))做 def-use,会在 DEFAULT handler 上产生大量假定义。例如 idx 516 / 0x908F0 的 raw = 0x399198D0,按字段公式提取出 dst = 18,但它的 handler 是 DEFAULT——DEFAULT handler 不会写 reg18。

规则:凡是做 VM register slice,必须使用 handler-aware 的 def/use 集合。 裸字段公式只适合做候选点枚举。

这是整个研究中最巧妙的发现之一。

在 unidbg 模拟执行中,VM 程序能跑到 0x90878(F8B frame transition),但之后的 continuation 链断裂:

R25 - 0x1293 指向的地址是 0x9B547(image-relative),落在 .bss 段内——运行时才初始化的区域。在 dummy frame 下这里全是零。

0x9B547 不是一个孤立的 .bss 地址。IDA xref 复核发现:

sub_274E0 在首次调用 CRC32 时按 IEEE reflected polynomial 0xEDB88320 生成这张表。按照标准 CRC32 表生成公式计算:

在 unidbg 中先调用 sub_274E0+1(Thumb 模式)初始化 CRC32 table,再跑 sub_275CC

CRC32 表生成函数 sub_274E0 的核心逻辑:

unidbg 测试命令:

关键输出日志:

CRC32 表初始化前后的对比一目了然。table[0xD3] 从 0 变成了 0x1FDA836E0x9B547 处的 uint16 从 0x0000 变成了 0xCD1F

继续跑到 0x908FC,pivot 读取的日志:

验证链条:

这个机制极其精巧:

通过 unidbg 的 Unicorn2 后端执行完整的 Dalvik 初始化链路:

.init_array 执行日志(Unicorn2 下可以承接坏写异常并继续):

.init_array[1] 的坏写(0x93223c2a)是环境探测的副产物——在 unidbg 模拟环境下某些全局指针未被正确初始化。Unicorn2 可以吞掉这个异常并继续执行,Dynarmic 则会直接 abort 整个进程。

JNI_OnLoad 的注册输出:

4 个注册方法的角色分析:

funcA(II[B)[B 是主签名的 Java 入口。 它接收两个 int 参数和一个 byte[] 输入,返回签名 byte[]。该函数与其他几个签名接口(funcBfuncC 等)均以神话生物命名,共同构成反作弊的签名接口矩阵。

funcA 在进入 VMP bytecode 之前,会通过一连串 JNI 回调做环境指纹采集:

从 APK 反编译(com/example/sdk/core/a.java)拿到了 njss 的真实实现——它是一个 selector dispatcher,接收 (int selector, Object obj) 参数,按 selector 值分发到不同的 Java 桥接逻辑:

njss 不是黑盒占位函数,而是该 SDK 在 native 和 Java 之间的标准化桥接器。native 侧通过 CallStaticObjectMethodV 调用它,按 selector 获取设备指纹、签名材料和配置信息。

坑啊:

通过这轮深度分析,可以提炼出该反作弊框架的几个核心设计哲学:

native 层是通用的 IR 解释器,上面跑的 bytecode 才是真正的算法。逆向者必须同时拿到三层才能重建:

由于算法在 bytecode 层而不是 native 层,开发者可以不发版就换掉整个签名算法——只要 ISA 不变,替换 .rodata 中的 bytecode program 就够了。这是传统 native 混淆做不到的。

funcA 在进入 VMP bytecode 之前,会通过 JNI 密集回调 Java 层采集设备指纹。整个门控链路按实际触发顺序排列:

第一层:线程与应用上下文

第二层:资产与签名验证

第三层:系统设置与设备标识

第四层:反调试与反自动化

第五层:反 Hook 与反 Xposed

第六层:设备指纹汇总

第七层:环境指纹打包

在 unidbg 中,每一层的 mock 策略遵循同一个原则:只记录参数,返回最小合理值,绝不伪造复杂对象。

总计补了 30+ 个最小 stub,覆盖 7 层环境检测。整个过程是纯机械的迭代循环:

补完所有 JNI stub 后,funcA 不再抛任何异常,能完整执行并返回。在 sub_275CC 入口断点上观测到:

但 watchVmPc(监控 0x900E0 / 0x90AC4 等算法 bytecode 地址)全部为 0。

bytecode base 0x8F360 对应的是 sub_24C4C——一个业务 wrapper,不是算法 wrapper。

回看 wrapper 表:

这解释了 watchVmPc 全 0:监控地址全在算法 bytecode 范围内(0x900E0..0x910E0),而 VMP 实际执行的是业务 bytecode(0x8F360..0x90360)。 VM 解释器确实在正常工作,只是跑的是另一段字节码。


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

上传的附件:
收藏
免费 32
支持
分享
最新回复 (6)
雪    币: 7
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
2
感谢分享
14小时前
0
雪    币: 76
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
3
看看
11小时前
0
雪    币: 3657
活跃值: (4789)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
4
学习一下
11小时前
0
雪    币: 277
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
5
大佬能出一篇ollvm混淆的么?  或者有已经好的ndk,我自己在编译ollvm,用的时候会有错误,导致有一些东西用不了混淆,我的是windows,
10小时前
0
雪    币: 4179
活跃值: (6857)
能力值: ( LV3,RANK:20 )
在线值:
发帖
回帖
粉丝
6
感谢分享
10小时前
0
雪    币: 8
活跃值: (427)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
7
大佬的思路清晰,分析手法纯熟,值得学习
8小时前
0
游客
登录 | 注册 方可回帖
返回