求爷爷告奶奶搞来了这个样本,上来一看: 看着就像ollvm 都是些sub函数 逻辑肯定都在sub函数里面了,希望是ollvm标准版 打开插件准备一把梭哈 没用 说明标准ollvm 变体服了 开始正篇:
我对比了两个普通函数的开头,sub_49C4 和 sub_87B0:
这个模板太眼熟了 —— OLLVM Control-Flow Flattening 的标志五件套:
字节级一致。两个函数除了 cmp 常量(W25 vs #0xB1EA1D76)和 X20 偏移不同,指令序列、寄存器编号、移位形式完全一样,这种"指令骨架完全相同、只换立即数"的模板化代码,只有混淆器编译能产生,人手写或者普通编译器优化都不会出这种结果。可以肯定用了 obfuscator-llvm 或它的衍生(Hikari、Pluto-LLVM、Snake-LLVM 这些)。
值得多看一眼的是 BR X9 这一行的杀伤力。BR 是 ARM64 的间接跳转,目标地址在寄存器里,IDA 的递归下降反汇编无法静态确定它跳到哪 。每碰到一个 BR Xn,IDA 默认就把这里当成函数边界 —— 因为再往下的字节它不能保证是不是新函数的开头。这就是为什么这个 dylib 会出现 1492 个 sub_XXXX:作者把所有原本的条件分支全部翻译成 BR,IDA 看到一片 BR 就在每处砍一刀,把原本的 1 个大函数切成上千段。这是 OLLVM CFF 的副作用,但作者很可能就是冲着这个副作用来的。
入口函数里出现的三个立即数:
我当时第一反应是字符串解密密钥,准备拿去试 XOR。32 位整数,出现在入口函数,看起来像 key —— 好像未知魔数的。我都已经写好了一段 Python:把 __data 段密文按 4 字节切片,跟这三个值挨个 XOR,看输出里有没有可见 ASCII 模式。结果当然是没有。气死我了。
后来发现这就是 OLLVM CFF 的 dispatcher state 初值,每个函数有独立的随机 state。在平坦化的 while-switch 结构里,这些值只是 case 的索引常量,没有任何密码学意义,“坑货啊”brute force 是浪费时间 。看到入口函数装载几个高熵 32 位常量,先不要假定是密钥,先看它们在后续指令里被怎么消费 —— 如果是 CMP W8, Wxxx 这种直接比较,99% 是 dispatcher state;如果是 EOR W?, W?, Wxxx、UMULL、MADD 这种算术参与,才有可能是 key。兄弟们记住喽
然后发现:sub_4000(入口)和 sub_32F14(普通函数)都引用了同一张分发表 off_42DA0。这个细节在当时不起眼,我甚至差点跳过去,后面证明是关键 —— 朴素 CFF 是每函数独立 dispatch table(每个函数有自己专属的 state→target 大表),这里是全局共享 dispatch table ,意味着不能简单用 IDA 脚本对单函数解 CFF,必须全局重建 dispatch graph。这种共享方案的好处是省体积:朴素 CFF 会让 .text 和 .const 急剧膨胀,共享 table 让所有函数共用一张大表,自己只取一段。坏处是反混淆的工程量提高,但这是混淆器作者乐见的。可恶。
CFF 看清楚之后,我准备开干 —— 先把 dispatch table 解出来,然后逐函数还原 CFG。现有的脚本好像都失效了,是变种,总有朋友问你怎么不trace,别急别急。(后面有狗头保命)
但搞到一半就感觉不对。问题出在数量上:1492 个函数,如果每个都是独立的 OLLVM CFF 函数,那 trampoline 数量应该跟函数数差不多。我数了一下,典型 trampoline 模板的实例只有两百多个。剩下 1200 多个"函数"是什么?
带着这个不对劲我又看了一眼 __cstring,84 字节;再看 __data 加密区,0x40328 字节。如果 1492 个函数大部分是真业务函数,那 __cstring 应该有几百到几千字节的明文剩余(C 字符串字面量、调试串、__FUNCTION__ 这些总会漏一些),不可能压到 84 字节;反过来如果 1492 个全是 trampoline,加密区也用不上 263 KB。这两个数字加起来给我的感觉是:真正的业务代码体量根本没到 1492 个函数 ,我对"函数"的计量方式是错的。
那能不能:先反编译一个看起来普通的函数,看它到底长什么样 。我挑了 sub_8794,因为它在数据交叉引用里出现频率特别高(后来知道是 130/223,占了 58%)。挑高频节点反编译,在大量同质化的混淆函数里这是一个被严重低估的姿势 —— 任何一个被反编译干净的样本都会暴露整套混淆协议,因为协议必须自洽,一个样本就是一个完整切片。
sub_8794 反编译出来非常短,前几条指令长这样:
LDR W8, [X29, #-0xDC] —— 从栈上读 W8 。
我之前一直以为 W8 是 dispatcher 的 state 寄存器,顺着指令流传递。但这里赤裸裸地从栈帧读,意味着:W8 的值不在寄存器里持续传递,而是每次进 trampoline 都从栈上读、算完再存回栈 。
这件事的后劲挺大的。它解释了之前一个困惑:在 lldb 或者 IDA 调试视图里盯 W8,你永远看到它"不停被覆盖、没有规律",因为 W8 在跨 trampoline 的层面根本不是状态,只是临时 cache。真正的状态藏在栈上的某个固定 offset(这里是 [X29-0xDC]) 。OLLVM 这一手把"状态"从寄存器移到栈,对人工逆向是降维打击 —— 大多数逆向者下意识把寄存器当 first-class、把栈当 second-class,跟踪状态时盯的是寄存器视图,根本不会去看栈帧的某个固定 offset。
更狠的是,我去看 sub_8794 有没有自己的 prologue,没有 。它没有 STP X29, X30, [SP, #-0x10]! 这种栈帧建立。也就是说,sub_8794 不是个真正意义上的"函数",它是某个母函数(就是 sub_4000)栈帧上的一段代码,通过尾调用链一路 BR 跳过来的。函数体里的 X29 不是它自己建立的,是从调用方继承来的;它读 [X29-0xDC] 实际上读的是调用方栈帧上的某个位置 —— 这个位置由调用方的调用方填充,可能再往上一层才是真正的源头。
顺手把它 ORR 进来的两个全局 dword_41DCC 和 dword_41DD0 也查一下,加上它前面的 LDAR WZR, [X0] 用到的 atomic flag 地址,跑一遍 globals_probe.json 落盘:
6 个 MBA seed key 都在 __data,初值是高熵 32 位常量;3 个 atomic flag 都在 __bss,初值统一是 0xFFFFFFFF(BSS 段约定的"未初始化"标记)。关键洞察 :这些 dword_41DCx 不是状态字,是只读的 MBA 种子常量。每个 trampoline 在 MBA 计算里取不同的种子组合(像 sub_8794 取 41DCC + 41DD0)+ 一个魔数偏移(0xA96EA1AD)+ 一个上游传入的 W10 → 算出本节点的判断函数。整套 BDD 的"信息载体"实际上不在内存里,而在控制流的累积路径上 —— 每条不同的执行路径选择不同的种子组合,产生不同的最终行为。这是 BDD 对"分支信息"做的反直觉编码:信息不存在节点里,信息存在路径里 。
这一刻整个理解全翻了。
之前的模型 :1492 个独立函数,每个函数都做 OLLVM CFF。
修正后的模型 :整个 dylib 实质上是一个超级函数。sub_4000 是入口,负责建立栈帧、写入分发表、初始化 state。然后通过尾调用链 (BR Xn) 跳到一系列 trampoline,每个 trampoline 从 [X29-0xDC] 读 state,跟一个魔数比较,根据结果索引分发表跳到下一个 trampoline。1492 个"函数"实际是一个尾调用链上的 1492 个基本块 ,IDA 因为它们都是 BR Xn 跳进来的,误以为是函数。
你可以把这个东西理解成 OLLVM 把一个普通的 while-switch 状态机,整个翻译成了基于尾调用的递归形式,而且把状态字段从寄存器降级到栈槽 。这样静态分析就废了 —— 你看任何一个 trampoline,都不知道它的 W8 是从哪里来的、要去哪里,因为 W8 从栈读出来,而栈在调用方写入。再加上CSET LT 永远只产 0 或 1 这一个事实,每个 trampoline 实际只编码 1 比特决策(< 取 1,≥ 取 0),根本不是经典 OLLVM CFF 的 N 路 switch。1492 个 trampoline 串起来不是一棵 N 叉决策树,而是一棵二叉决策图(BDD) 。
这个观察直接改变了反混淆代价的量级。经典 CFF 反混淆需要对每个 case 做符号执行求 state 关系,O(N²) 复杂度,1492 个节点意味着数小时到数天的求解器跑;binary-fork 这种结构每个节点只藏 1 比特,直接读两个指针就能拿到分支去向,O(N) 就能推完。整个混淆体系的工作量从"两到五人周"降到"两到五人天" 。混淆器作者很可能是想用函数数量把对手吓退(看到 1492 个 sub_XXXX 大多数人就放弃了),但忽略了单点信息量 —— 一旦识破"每个 trampoline 只藏 1 比特"这件事,整体就崩了。
确认这个之后,反混淆策略整个推翻重写。我开始写一系列分析脚本(ai真是个好东西,后面微调后效果真是不错啊),从 cff_step1_x20map.py 一直干到 cff_step19_parse_decomp_to_schedule.py,一步步把这棵尾调用链解构。脚本一共写了 19 个,平均每个一两百行,加起来三千行左右,后面会挑核心几个讲玩玩。
跟 CFF 的迷局并行的是 hook 注册表的迷局。
我习惯找 hook 注册函数的方式是:xrefs_to(_MSHookMessageEx) 看谁调它。逆向 iOS tweak 这是第一招,稳得不能再稳 —— MSHookMessageEx 是 MobileSubstrate 的入口 API,任何 tweak 想 hook ObjC 方法都得调它,谁调它谁就是 hook 注册函数,顺藤摸瓜整个 hook 列表都能挖出来。
这个 dylib 里这一招直接失败 —— xref 表显示只有几个 thunk 调它,真正的 hook 注册函数一个都看不见。
xrefs = mcp__ida-pro-mcp__xrefs_to(MSHookMessageEx_addr)
19 个 thunk,每个 thunk 长这样:
ADRP X16, _MSHookMessageEx@GOTPAGELDR X16, [X16, _MSHookMessageEx@GOTPAGEOFF]BR X16继续追这 19 个 thunk 的 xref —— 还是找不到真正的调用方。因为调用方是通过函数指针表(BDD 节点出口)跳进来的,不是直接 BL,所以 IDA 的 xref 网络在这一层就断了。
这是混淆的另一层手段:间接化所有调用关系。原本 BL _MSHookMessageEx 一行能搞定,被改成 LDR X8, [X20, #off]; BR X8,IDA 只能告诉你 BR X8 跳了出去,跳到哪里它不知道。X8 在更早的某条 LDR X8, [X20, #imm] 设置,X20 是栈基址,栈基址上面装的是另一个表,这个表的内容由 sub_4000 prologue 在运行时填充 —— 静态分析不跑 prologue 就拿不到这些数据,IDA 默认不模拟运行,所以一片漆黑。
更阴险的是 这个鸟dylib 还做了一层反静态:把 MSHookMessageEx 的调用包成 19 个不同地址的 4 指令 thunk(sub_3835C / sub_3839C / sub_383DC ……),每个 hook 注册函数随机用其中一个 thunk。这就让你哪怕用脚本扫"有谁直接 BL 到 _MSHookMessageEx"也只能找到 thunk 自己,而不是真正的注册函数。
我后来用了非常笨但有效的办法:直接在二进制里搜索 thunk 地址被 LDR 引用的地方。
thunk_addrs = [...] # 19 个
这种"在数据段里 grep 函数指针"的做法听起来粗暴,但它绕开了所有混淆层 —— 你不需要懂 BDD,不需要解 OLLVM CFF,只要相信"thunk 地址必须出现在某个 8 字节槽里才能被间接调用"这个简单事实。所有间接调用最终都要把目标地址装进某个内存槽,而 8 字节对齐的内存槽是有限的,扫一遍就完了。
跑出来 167 个引用点,而不是 19 个。换句话说,真正的 hook 数量是 167,不是 19。19 是 thunk 数,167 是 thunk 被装入函数指针表的次数 —— 每次装入对应一个 hook 注册。后面我把这 167 个引用点按地址段分布画出来,意外发现里面还有"双份镜像"结构,这个先按下不表,放到第 8 章讲。
到这里猜到这个 dylib 的"反逆向价值"也就是说作者的核心防护投入,不在于隐藏代码,而在于隐藏 hook 列表。隐藏 hook 列表的目的就一个:别人不能直接照抄。这是商业反封号工具的核心商业秘密 —— 算法可以重写,核心代码可以重新组织,但"作者花了多长时间发现微信哪些方法值得 hook、哪些方法 hook 后会引发副作用、哪些方法是关键卡口"这种领域知识,是用时间和试错堆出来的,这才是真值钱的部分。
没招了怎么办,不trace怎么办,只能写反混淆器一个个来写了。整体思路按 OBDD(Reduced Ordered BDD)展开 —— 因为这棵尾调用链本质就是一棵决策树,每个 trampoline 是一个决策节点,根据 W8 的值二选一往下走。BDD 这个数据结构在 SAT/SMT 求解、模型检查、电路 EDA 里用得很多,核心思想就是把布尔函数表示成一棵分叉树,每层节点对一个输入比特做判断,共享相同的子树以减少节点数。OLLVM 在这里把一个常规 if-else 嵌套结构编译成 BDD,本质上是把逻辑结构延展到运行时。
step1 先重建 X20 偏移映射。sub_4000 的 prologue 里把一个巨型指针表的基址 hoist 到栈帧上的某个槽,后续所有 trampoline 用 LDR X10, [X20, #imm] 读这张表。要解 BDD 必须先把这张表完整还原出来。
ARM64 的 ADRL Xn, off_xxxx + STR Xn, [X20, #imm] 是两步指令,光看单条 STR 拿不到目标地址,必须维护一个 shadow state 跟踪"哪个寄存器最近被装载了什么地址",看到 STR 时再回查 source 寄存器:
跑完得到 202 条映射 ,呈现明显的两段结构:X20[0x0..0x3D0] 全部指向 0x42DA0(共享大表的 122 个状态槽),X20[0x3E0..0xC50+] 指向从 0x47348 起递减的滑动窗口段。后来把这个映射 dump 成 JSON 重看才发现实际是 5 段结构外加 3 个特殊槽,其中 X20[0xC78] 指向 0x4AF68 —— LDAR 用的 atomic flag。这个 LDAR flag 后来被证明是首次/再次入口判定的关键,但当时 step1 没意识到,分析到这里我都无语了。
后来dump吧,哎dump 这个动作本身就是分析步骤 。我原本只是想"把结果存盘免得 IDA 关掉就丢",但落盘的过程被迫做了格式化和聚合,看到 JSON 文件里的 5 段结构 + 特殊槽,马上就发现之前"两段结构"的判断不完整。都dump了继续干吧。
step2 扫每个 trampoline,提取 (cmp_const, true_target, false_target) 三元组。模式匹配的锚点是末尾 3 条定式指令 —— LDR Xn, [X20, #imm](取 mini-table 基址) + LDR Xn, [Xm, Wk, UXTW#3](以 0/1 索引)+ BR Xn,这三条几乎从来不变,把它们卡住其它的就好办了:
resolve_cmp_const 是个递归往前扫的小函数,处理几种比较模式。第一版我只覆盖了三种:
漏掉的:
第二版补全所有这些模式,解析率到 83%。剩下 17% 卡在 sub_4000 末尾隐藏的 13 个魔数寄存器 —— 我之前只看到 W22/W25/W28,实际 prologue 末尾紧跟着另外 13 个 MOV Wn, #imm,把其他寄存器初始化成更多魔数:
这 13 个 MOV 装在 0x493C-0x49A4,夹在 prologue 主体跟函数末尾的 BR 之间,我第一遍读 sub_4000 是从入口顺序读的,看到 X20 数组初始化和那一大堆 ADRL+STR 之后觉得已经摸清模式就跳过去了,完全错过了末尾这 13 行。这是典型的"以为读完了其实没读"。手工补完之后解析率到 100%(16 个魔数寄存器,从只看见 3 个变成 16 个),回头看 step2 跑出的 cmp_const 列表,所有"未知"标签都消失了。
这些 cmp_const 的值看起来都是高熵随机数(0x3664924A、0x5245100A、0x673C4425、0x72E2FCCE、0x78AE4B81 ……),平均 popcount 接近 16(理论期望也是 16),没有明显模式 —— 这几乎可以肯定是 PRNG 的连续输出。这种模式有个有趣的副产品:如果能猜出 PRNG 的种子和算法,就可以预测整条 trampoline 链的 cmp_const 序列 ,这反过来可以做"哪些函数被切割成了 trampoline"的预测器。后面就不想做他的其他样本了。然后继续
step3 v3 把每个 trampoline 完整解构 —— 不光是 cmp_const + F/T 三元组,还包括 CSET 谓词类型(LT/EQ/NE)、栈读写槽 offset、比较常量来源标签(W22_global / MOV+CMP / 未知 caller 传入)。这一步最大的副产品是统计 read/write 槽的分布,跑完发现 186 个不同的 write offset(后来证明 184 个是 dead store,纯烟雾弹),read offset 只有 2 个固定位置([X29-0xDC] 和 [X29-0x74])。这种"写得到处都是,读只有两个点"的反差,直接揭露了 这个OLLVM 玩"假数据流"。
step4 是真正的反混淆器,把 223 个描述符渲染成 if-else 树。算法很直接:入口 = trampoline 集合 - 任何被 F/T 引用的 EA (没有上游),出口 = F/T target - trampoline 集合 (跳出 BDD 的真实业务函数),从每个入口 DFS 递归渲染:
跑完产物 4.7 MB / 84557 行 if-else,平均每个入口几百到几千层嵌套。文件大但不离谱,grep 找特定 hook 的 dispatch 路径很方便 —— 比如要看 sub_8794 在哪些上游被引用,直接 grep -n "sub_8794" unflattened_bdd.txt | head -50 就能列出来。
落盘到 data/ 的产物有 4 份:
unflatten_summary.json 里的几个关键数字值得拎出来:74 个入口 / 53 个出口 / 149 个内部节点 。出口是真正跳出 BDD 的"业务函数",其中 38 个是 hook 的 newImp(后面会讲它们里面 36 个还是 BDD,只 2 个是真函数)。74 入口意味着这棵尾调用链不是一棵"大树",是 74 棵相对独立的子树并存,每棵子树对应一个 hook dispatch 路径。
入度统计揭露了 BDD 的拓扑形状极不均匀:
130 + 20 + 20 + 13 = 183 条边收敛到 4 个节点(占 446 条边的 41%) ,其余 263 条边平均分配到 217 个节点。这种"少数节点高度收敛 + 多数节点零散"的拓扑反映 OLLVM 用了 ROBDD(Reduced Ordered BDD)优化 —— 把多个判定路径里相同的尾段合并成共用节点,这是 BDD 数据结构的标准压缩。
223 个 trampoline 全部成功还原,errors = 0 。这件事本身就是最强的信号 —— 它证明我的模型 100% 准确,没有漏掉任何一种 trampoline 形态。如果模型不准,稍微有点边界情况没覆盖,就会有 5%-10% 的解析失败。0 错误意味着已经摸到这个混淆器的全部规律 。
很多人会盯着 223 / 1492 ≈ 14.9% 这个分子,觉得"覆盖率不高"。这是看反了。真正应该盯的是另一个数字:errors = 0。0 错误的真正含义是模板假设零反例 —— 任何 trampoline 一旦满足"6-7 条定式指令 + CSET LT + LDR [X20+off] + LDR [X10, Wx UXTW#3] + BR X9"模板,X20 偏移在映射表里一定能查到,F/T target 一定指向合法的 trampoline 地址。剩余 85% 没匹配上的不是反例,是模板变种 (比如 CSET NE 而不是 LT、不同栈保存模式),后面继续做模式分类就能纳入。模型对了,剩下的就是工程量;模型错了,跑 100% 命中率也是徒劳 。
跑完之后看入度统计:
sub_8794 是个巨型汇聚点。58% 的 trampoline 都最终汇到这里。这个节点对应的是 BDD 里高频复用的子表达式,在 ROBDD 优化里这种共享子节点是核心特性 —— 同一个判断逻辑被多条路径复用,只算一次。
第一次看到 130 这个入度的时候我以为它有什么特殊语义,反编译过去才发现 sub_8794 自己也是个标准 trampoline,就是上面给的那 7 行 LDAR + LDR + ORR + ... + LDR [X29, #-0xDC] + BR。它的"特殊性"完全来自图拓扑(高入度),不是语义。到这里我都无语了这人到底要干啥。继续找解密器吧。
反混淆完成之后,主战场转移到字符串解密。__data 段里有 0x40328 字节高熵密文,得搞清楚解密算法和 key。(不要问我为什么不trace了)
我的第一反应是找解密器函数 —— 解密器肯定要"写"密文区(把解密结果写到某个 buffer),那么 xrefs_to(0x40000) 应该能找到所有写入点。这是逆向加密字符串最直接的入口,十次有九次能用。
整整一天我卡在这里。0 个 xref 意味着 IDA 没有任何指令"显式引用"这个地址。但密文确实存在,确实有人解密它,这是个矛盾。
更让人怀疑人生的是,我去看几个怀疑是字符串入口的地址(后来确认是密文段开头):xrefs_to(0x40300) = 0,xrefs_to(0x40E30) = 0,xrefs_to(0x4020E) = 0,扫了一圈想找的都是 0。当时一度怀疑是不是 IDB 没解析完整,关掉 IDA 重开了一次,跑了 idaapi.auto_wait(),xref 表还是空。我也试过反方向,挑几个明显是解密器嫌疑的函数(写入量大、含 EOR、含循环)看它的 DataRefsTo,期望能跟 0x40000 段产生关联,也是 0。
我换了个角度想:IDA 的 xref 是已经确认的链接,但 IDA 还有一种更宽松的引用 —— dref(data reference),是数据流分析推断出来的"指令可能访问的数据地址" 。OLLVM 阻断了 xref(因为 xref 检查指令是否"明确引用某地址"),但阻断不了 dref(IDA 的 ADRP+ADD 重组分析)。
具体的差别在于,xref 是 IDA 看到形如 MOV X0, addr / LDR X0, [addr] 这种"指令的立即数操作数本身就是地址"才会建立的;但 ARM64 没法在单条指令里塞一个完整 64 位地址,通常用 ADRP X8, page; ADD X8, X8, #pageoff 两条指令拼出来。OLLVM 把这两条指令拆开,中间塞一堆无关 MOV/STR/CMP,IDA 的 xref 引擎匹配不到这种"两步寻址"模式,就不建立 xref。但 IDA 的 dref 引擎专门做 ADRP+ADD 的指令对配对,这种数据流分析在两步寻址下仍然有效。OLLVM 阻断了 xref,但阻断不了 dref ,这是它自己挡不住自己的数据流分析。
第二轮 —— 不再扫"谁引用了密文地址",换成扫"哪些函数对密文区写入次数最多",而且区分 STR(写)和 LDR(读),顺手统计 EOR 指令数(疑似 XOR 解密的强指标):
几个阈值都是经验值:size < 30 排除 thunk(thunk 通常 4 条指令),size > 5000 排除 sub_4000 这种巨型初始化函数(它会把所有 mini-table 地址 hoist 到栈,会误判),n_W > 10 卡掉只是顺便引用一两个常量的普通函数。最后剩下来的几乎全是 master decryptor。
跑完落盘 data/decryptor_candidates.csv,15 行,头部几行(按 n_W 排序)长这样:
光看这个表就能看出几个细节:第二三行(sub_6DF4 / sub_1D3E0)的 W_range 跟它们前一行完全相同,这就是孪生 —— 写入同样的 108 / 12 个字节地址,只是 MBA 模板不同。n_eor 越高的越像 OLLVM 字符串解密的"显式 EOR"版本(不那么努力 MBA 的);n_eor 低的可能 MBA 强度更高,EOR 全被位掩码替代了。后面挑解密器先反编译,我会优先挑 n_eor > 10 的版本看(MBA 弱、可读性高),化简后再去对照 MBA 强的孪生。
跑出来的 15 个候选里,头部 3 个写入量最大:
注意第三行那个奇怪的事实:sub_FB2C 和 sub_10A30 写的范围完全一样。第一眼看到的时候我以为是哪里统计错了,跑了一遍验证,确实是两个不同的函数(地址相差 0xF04),但 W_addrs 集合完全相同。这是 OLLVM 的"复制粘贴 MBA 重写"模式 —— 每个真 master 在地址相邻 0x1000-0x4000 的位置有一个 MBA 重写孪生。代数等价,但 MBA 模板不同。
具体例子,反编译出来同一行的两个版本:
代数化简两者等价,因为 0x89 | 0x76 = 0xFF 且 0x89 & 0x76 = 0,所以 ~b & 0x89 | b & 0x76 = b ^ 0x89,再 XOR 0x13 得到 b ^ (0x89 ^ 0x13) = b ^ 0x9A,跟 sub_10A30 完全一样。
这种孪生不是冗余备份,是双 MBA 写法测试 —— 同一组 K 值用两套不同的 MBA 模板生成两份代码,把分析者引入"哪个是真的"的死胡同。检测方法很简单:找写地址集合 100% 相等的两个函数,跳过其中之一 。这能直接砍掉一半的工作量。后续每次发现新 master,我都会立刻在地址相邻 0x1000-0x4000 内扫一圈找它的孪生,基本都能扫到。整个项目里我一共找到 6 对孪生,等于免费省了 6 倍的反编译工作。美滋滋。
确定 sub_168C4 是主解密器,我开始反编译它的解密公式。
它写得很恶心,121 个字节解密公式,每一个都用不同的 MBA 表达式包装。我看到的前几个:
刚开始看到这些我有点头疼,以为得逐条手工化简或者上 SMT solver。Z3、Souper、msynth 这种工具我都准备好了,甚至已经在脑子里规划"先写一个 IR 转换器把 IDA 反编译输出的 C 表达式转成 Z3 约束,然后用 simplify 求等价 XOR 形式" —— 听起来很正统,但工程量是日级别。
后来我盯着这些公式看了一会儿,发现一个规律:所有这些公式,实质上只做一件事 —— XOR 。
证明的话用真值表展开就行,比如 ~b & A | b & B,当 A | B == 0xFF 且 A & B == 0 时,A 和 B 互补:
把 A 和 B 拼起来等于 0xFF,而 b XOR A 在 b=0 时输出 A,b=1 时输出 ~A = B(当 A+B=0xFF 时)。完全等价。
b - 2*(b & A) ± C 这个看着最唬人,展开也很简单。b - 2*(b & A) 在每一位上的行为:如果 A 该位为 0,那么 b & A 该位为 0,整个减法不影响这一位;如果 A 该位为 1,那么 b & A 该位 = b 该位,b - 2*(b & A) 在该位上做的是减去自己的两倍,等价于翻转这一位再加一个 carry,carry 累计起来在跨位的整体效果上等于 b XOR A 加一个常数偏移 ±C。这是 OLLVM 的标准 MBA 编码,代数等价于 (b ^ A) ± C。
OLLVM 用 8 种写法绕死分析者,代数学一拳打回原形。
我没有真的去手工化简每条公式。第一版 step17 是最笨的办法:把 IDA 反编译出来的 121 行 C 表达式逐行抄成 Python lambda,放进列表,直接跑:
这种写法的好处是稳得不能再稳(美) —— 你眼睛看着反编译输出抄一行,Python lambda 跑一行,字节按字节验证,几乎不可能错。坏处是121 行手写到第 50 行的时候我已经开始按错位 —— 复制 byte_40D60 的时候多带一个数字、& 0xF3 写成 & 0xF7,这种小错很容易漏。最后我在 121 行里至少改了 5-6 处错位。
跑出 9 段明文之后,我意识到下一个 master(sub_FB2C)还有 160 行公式要抄,后面还有 14 个 master 等着 —— 总共一千多行公式手抄,只要错一处就得回头 debug。这时候才动手把这一步抽象成通用解析器:
整套流程不需要 SMT solver、不需要 IR 转换、不需要代数化简,Python 标准库就够。这是写工具时的一种思路:当对手已经把信息明文表达在 C 代码里,你就别费劲去做"理解"那一步,直接把代码当函数用 。Hex-Rays 反编译已经是高质量的 C 代码,IDA 自己已经做了一遍语义提取,你只是需要"执行"它。
step19 写完之后剩下 14 个 master 全部直接喂反编译伪代码,30 秒内拿到全部明文。这是整个项目最有复用价值的产出 ,下次遇到任何 OLLVM 字符串解密器,把反编译伪代码喂进去,几乎是即时出结果。后续 step20-23(批量跑 FB2C / 3 个 big master / 3 个 medium master / 8 个 minor master)都基于这个 parser,平均每个 master 写不到 30 行就能跑通。
落盘到 data/:
dec_168C4_schedule.csv 把每个字节的解密公式 + 等价 XOR 后的真实 key 都列出来了。这个文件之所以专门留下来,是想保留"具体 OLLVM 字节级混淆长什么样"的样本 —— 后面我自己写其他混淆器解密器时,直接拿这个文件当模板照着改就行,不用从头摸索 8 种 MBA 模板的代数化简。
跑完 sub_168C4 的 121 行公式,提取出 9 段密文 → 明文映射:
注意"密文区在前,明文区在后,偏移 +0x20 出现 6 次"这个规律 —— 这个OLLVM 解密器会在固定偏移处放置明文输出 buffer,这是它的工程便利,也是我们的攻击面。一旦看到这个规律,后面找其他 master 的明文区就有了模板:给定密文起点,在 +0x10、+0x0D、+0x0F、+0x20 这几个常见偏移上找明文区,基本一打一个准 。这种"偏移规律"一旦识别出来,后续的 master 解密就是流水线工作 —— 找写入位置 → 反编译公式 → eval 出 key → 拼明文。继续跑。
跑出来的 9 段明文是这样的:
第一段拿出来的瞬间我猜这个 dylib 在干什么了。
isJailbrokenDevice 配合后面发现的 9 个 nullsub_1 newImp,机制完全清楚了:dylib 把wx内部用于检测越狱、上报安全事件的方法,全部 hook 成空操作或返回伪值 。
reportCrashLog:type: 直接被替换成 nullsub_1(汇编层面就是 MOV X0, #0; RET 两条指令)。任何代码调它都直接返回。崩溃信息永远不会到达微信服务器。崩溃日志在风控里有非常高的价值 —— 黑产的 hook 工具崩溃模式跟正常用户完全不同(频繁在 ObjC runtime 内部崩溃、栈帧带有 MobileSubstrate 痕迹、崩溃地址固定在某些 hook trampoline),把这条上报通路砍掉,等于砍掉一个核心信号源。
isJailbrokenDevice 被 hook 让它永远返回 NO。wx代码里所有 if ([self isJailbrokenDevice]) { ban_user(); } 这种判断,直接走"非越狱"分支。这是单点替换的经典姿势,简单粗暴但有效 —— 客户端检测的所有"判定 BOOL"都可以靠 hook 改返回值瓦解。
MMTokenService 是wx Token 系统的核心类。dylib 拦截这个类的 refreshTokenWithPolicy: 等方法,控制 Token 刷新行为,维持账号"看起来合法"的状态。Token 是微信账号在服务端的"通行证",每隔一段时间会按策略刷新,如果服务端发现某个账号的 Token 刷新行为异常(比如该刷的时候没刷、刷新参数对不上历史模式),会触发风控复核 —— hook 这一层就是控制 dylib 用户的 Token 刷新节奏,让它跟正常用户的统计模式吻合。
那一刻我意识到一件事:这个 dylib 不是在破解wx的风控算法,是在切断风控的信息源 。这是攻防中典型的高维降维打击 —— 算法再强,数据进不来等于零。wx服务端可能跑着最先进的图神经网络做账号关系建模、用 LSTM 做行为时序异常检测,但如果客户端连"isJailbrokenDevice = YES"这条信号都不上报、连崩溃日志都不发,模型再强也是在猜。
但只有 9 段我没有满足。__data 段里有 0x40328 字节密文,这 161 字节才占了 4%。还有 96% 在哪?当时我以为 sub_168C4 已经覆盖完了 hook 字符串区(0x40500-0x41100),剩下的 96% 是别的用途 —— 可能是错误信息、调试串、内部协议字段。后来证明这个判断完全错了,真正的 hook 字符串横跨整个 0x40000-0x42500,我刚刚只挖了入口处的一小块。这个错误判断让我又损失了大半天 —— 我先去尝试反编译 38 个 newImp 函数,做完之后才回头继续找其他 master,如果一开始就把搜索面扩到整个 __data,可以省下一天。继续干。
这里我犯了另一个错误:以为找全了 。之前我扫的 cipher 区是 0x40500-0x41100,因为这是 sub_168C4 写入的范围。我以为 cipher 总量就这么大。直到我去看 sub_FB2C(被识别为孪生)和它的孪生 sub_10A30 的 W_range —— 0x40642-0x40FC4,跟 sub_168C4 部分重叠但不完全相同。两个 master 不是覆盖同一片区域的"主备"关系,而是分别管不同的字符串区。这个事实推翻了我的"161 字节 = hook 字符串总量"判断。我把搜索面扩大到整个 __data 段(0x40000 - 0x42500),用同样的 dref 扫描,这次找到了 17 个 master decryptor(包括 5 对孪生)。这一步给我留下另一条经验:逆向时定下"字符串区"边界后,永远再扩 3-5 倍重扫一遍 。边界往往是分析者的"假设",不是数据的"真实"。我之前的 281 字节边界,扩 3-5 倍正好对上 1100 字节的真实跨度,这种数量级关系不是偶然 —— 第一次定边界总是低估,因为你只看到了入口处的明显结构,后面散落的、不那么显眼的字符串区会被忽略。跑完每一个,累计解出 66 个独立明文字符串、981 字节。一下子从 4% 覆盖跳到接近全覆盖。
辅助类名最后那个 sub_2E5E8 是这个项目的死角,放到第 11 节讲。落盘到 data/:
all_decrypted_strings.csv 每行一个明文字符串 + 它的地址 + 长度 + 由哪个 master 解出来 + 原始 hex,头部 10 行长这样:
里面 B@:@、v@:、@@: 这种 4-5 字节短串是 ObjC method type encoding(B = BOOL,@ = id,: = SEL,v = void,i = int),是 ObjC runtime 在 class_addMethod 等 API 里用的方法签名格式。它们出现在解密结果里说明 dylib 不光 swizzle 已存在的方法,还会用 class_addMethod 给目标类新增 方法 —— 这跟 hook 注册函数 sub_1BB50 等里出现的 _class_addMethod import 完全对得上,是 ObjC 动态新增方法标准流程。com.tencent.xin.security.policy 这一串是xx安全策略的 NSUserDefaults suite name(也可能是 entitlement key、CFBundle 子标识)。这种内部命名空间一旦曝光,等于把"wx哪些安全配置存在哪里"的索引递给了对手。看到这条字符串的瞬间我就知道这个 dylib 作者大概率有内部知识 —— 这种命名空间靠纯逆向猜出来的概率极低,要么有内部文档,要么逆向了多个wx版本对照得出。中间还有个意外发现 —— hook 注册表是双份镜像的 。我把 167 个 hook site 按地址段分布画出来:
两段 64 个 hook 是同一份。我先是怀疑自己脚本统计错了,把每个 hook 的 SEL 地址 + Class 地址对都列出来比对,确实是同样的序列出现两次,只是注册函数名不同(sub_1BB50 和 sub_118D4),代码体也几乎一样(1500 字节 vs 1412 字节,差异主要来自 OLLVM 复制时的 MBA 重写)。原因猜测有几个:
我觉得可能是于第 3 个
到这一步素材都齐了:66 个明文字符串、167 个 hook 注册 site。剩下要做的是把每个 hook 的 SEL/Class 跟明文对应起来。这一步是把"散落的零件"装回"整机":字符串告诉你 dylib 想 hook 什么,注册 site 告诉你它在哪里 hook,两者一对应就是完整的攻击地图。
每个 hook 注册 site 长这样:
_MSHookMessageEx 的标准签名是 void MSHookMessageEx(Class cls, SEL sel, IMP newImp, IMP *origImp) —— 第一个参数是目标类,第二个是 selector,第三个是替换实现,第四个是用来回填原 IMP 指针的输出参数。所以 hook site 之前的 ADRP+ADD 序列里,前两组拼字符串地址(class name 和 sel name)被传给 _objc_getClass 和 _sel_registerName,把字符串转成运行时对象再喂给 MSHookMessageEx。这是 Substrate tweak 的标准动态注册流程,结构稳定。
分两步走:step6 先把 167 个 hook 注册 site 的原始信息抽出来 (每个 site 是哪个 ObjC class、SEL、newImp、origImp 槽地址),step25 再把这些地址跟 step17/19 解出的明文表对应起来 。
step6 的核心思路是:对每个 hook 注册函数(sub_1BB50 / sub_118D4 / sub_1C12C / sub_12114 / sub_16504),线性扫一遍找所有 BL _MSHookMessageEx 调用,然后对每个调用反扫前 25 条指令找 ADRL 的目标。SEL 的识别不是直接看 BL _MSHookMessageEx 之前的 ADRL,而是先找 BL _sel_registerName,再看它前面紧跟的 ADRL X0 —— 这是稳定的 ObjC 动态注册模式:
25 条指令的回扫窗口是经验值。一开始我用 15,漏掉了被 OLLVM 拉远的 ADRL 对;后来用 50,又把上一个 hook 的字符串引用也扫进来了。25 是覆盖整个 hook 注册块、不污染前一个 hook 的折衷值。
step6 跑完拿到的 class_str / sel_str 此时还可能是密文 —— 如果字符串区当时还没解密,read_cstring 读出来的就是高熵 binary。所以真正能拿到明文 hook 列表必须等到 step17/19 把字符串解密完之后,再跑 step25 做关联:
这一步有两个细节。第一个是 find_str_refs 同时用 idautils.DataRefsFrom(IDA dref)和手动 ADRP+ADD 配对兜底 —— 单靠 dref 在某些 OLLVM 拉远的指令对上识别不全,加手动配对能覆盖剩下的 5%。第二个是 lookup_str 支持"中段命中" —— ADRP+ADD 拼出来的不一定是字符串起点,可能是字符串中段某个字符的位置(比如 "reportCrashLog:type:" 的 t 字符位置),这种情况要根据 offset 推回完整字符串。如果不支持中段命中,这一步会少匹配 20% 左右的 hook。这是我跑第一版的时候发现的:有 30 多个 hook 的 SEL/Class 都"找不到",debug 了半天才发现 ADRP+ADD 拼出的是字符串中段地址。
跑完结果:
传播安全知识、拓宽行业人脉——看雪讲师团队等你加入!
上传的附件: