找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

3816

积分

0

好友

506

主题
发表于 1 小时前 | 查看: 3| 回复: 0

好不容易得到样本,上来一看:

反汇编或调试工具界面截图,左侧是sub_38C04等函数列表,右侧是IDA View-A的伪代码窗口,显示函数返回sub_38BF4()

看着就像 ollvm,全是些 sub 函数,逻辑肯定都藏在里面了。我暗自希望这是个 ollvm 标准版,于是打开插件准备一把梭哈。

深色主题软件界面截图,右侧展开Quick run plugins子菜单,列出了Swift、Rust language helper、LazyIDA等多个插件名称

没用,说明是标准 ollvm 的变体。服了,直接开始硬啃吧。

1. 第一锤子:确认是 OLLVM CFF

我对比了两个普通函数的开头,sub_49C4sub_87B0

; sub_49C4 开头               ; sub_87B0 开头
CMP          W8, W25              MOV       W9, #0xB1EA1D76
CSET         W9, LT               CMP       W8, W9
STR          W9, [SP,#arg_CDC]    CSET      W9, LT
LDR          X10, [X20,#0xC50]    STR       W9, [SP,#arg_88C]
LDR          X9, [X10,W9,UXTW#3]  LDR       X10, [X20,#0x800]
BR           X9                   LDR       X9, [X10,W9,UXTW#3]
                                   BR        X9

这个模板太眼熟了——OLLVM Control-Flow Flattening 的标志五件套:

  • CMP W8, <state_const>:比当前 state 与魔数
  • CSET Wx, LT:把比较结果布尔化
  • STR Wx, [SP, #...]:暂存到栈,加数据流复杂度
  • LDR X10, [X20, #table_off]:取分发表基址
  • LDR X9, [X10, Wx, UXTW#3]:索引乘 8 取目标
  • BR X9:间接跳转,丢失静态分析目标

字节级完全一致。除了 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 就在每处砍一刀,把一个完整的大函数切成上千段。这是 OLLVM CFF 的副作用,但作者很可能就是冲着这个来的。

入口函数里出现的三个立即数:

W22 = 0xFD6886DF   # 4019357919
W28 = 0xD8128E13   # 3625422867
W25 = 0x3664924A   # 912495690

我当时第一反应是字符串解密密钥,准备拿去试 XOR。32 位整数,出现在入口函数,看起来像 key——好像未知魔数。我甚至写了一段 Python:把 __data 段密文按 4 字节切片,跟这三个值挨个 XOR,看输出里有没有可见 ASCII 模式。结果当然是没有。

后来才明白这就是 OLLVM CFF 的 dispatcher state 初值,每个函数有独立的随机 state。在平坦化的 while-switch 结构里,这些值只是 case 的索引常量,没有任何密码学意义。看到入口函数装载几个高熵 32 位常量,先不要假定是密钥,先看它们在后续指令里怎么被消费——如果是 CMP W8, Wxxx 这种直接比较,99% 是 dispatcher state;如果是 EOR W?, W?, WxxxUMULLMADD 这种算术参与,才有可能是 key。

接着发现 sub_4000(入口)和 sub_32F14(普通函数)都引用了同一张分发表 off_42DA0。这个细节在当时不起眼,后面证明是关键——朴素 CFF 是每函数独立 dispatch table(每个函数有自己专属的 state→target 大表),这里是全局共享 dispatch table,意味着不能简单用 IDA 脚本对单函数解 CFF,必须全局重建 dispatch graph。这种共享方案的好处是省体积:朴素 CFF 会让 .text.const 急剧膨胀,共享 table 让所有函数共用一张大表,自己只取一段。坏处是反混淆的工程量提高,但这正是混淆器作者乐见的。

2. 迷局一:1492 个函数到底是什么

CFF 看清楚之后,我准备开干——先把 dispatch table 解出来,然后逐函数还原 CFG。

但搞到一半就感觉不对。问题出在数量上:1492 个函数,如果每个都是独立的 OLLVM CFF 函数,那 trampoline 数量应该跟函数数差不多。我数了一下,典型 trampoline 模板的实例只有两百多个。剩下 1200 多个“函数”是什么?

带着这个疑惑我又看了一眼 __cstring,84 字节;再看 __data 加密区,0x40328 字节。如果 1492 个函数大部分是真业务函数,那 __cstring 应该有几百到几千字节的明文残留(C 字符串字面量、调试串、__FUNCTION__ 这些总会漏一些),不可能压到 84 字节;反过来,如果 1492 个全是 trampoline,加密区也用不上 263 KB。这两个数字加起来给我的感觉是:真正的业务代码体量根本没到 1492 个函数,我对“函数”的计量方式是错的。

那能不能先反编译一个看起来普通的函数,看它到底长什么样?我挑了 sub_8794,因为它在数据交叉引用里出现频率特别高。在大量同质化的混淆函数里,挑高频节点反编译是一个被严重低估的姿势——任何一个被反编译干净的样本都会暴露整套混淆协议,因为协议必须自洽,一个样本就是一个完整切片。

sub_8794 反编译出来非常短,前几条指令长这样:

LDAR   WZR, [X0]                  # atomic load,丢值,纯内存屏障
ADRP   X8, dword_41DCC@PAGE
LDR    W8, [X8,#dword_41DCC@PAGEOFF]
ADRP   X9, dword_41DD0@PAGE
LDR    W9, [X9,#dword_41DD0@PAGEOFF]
ORR    W8, W8, W9
MOV    W9, #0xA96EA1AD
ORR    W8, W8, W9
SUB    W8, W8, W10                # W10 是上层留下的!
;; ... 一堆 MBA 计算 ...
LDR    W8, [X29,#-0xDC]           # 这一行让我愣住了

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_41DCCdword_41DD0 也查一下,加上它前面的 LDAR WZR, [X0] 用到的 atomic flag 地址,跑一遍 globals_probe.json 落盘:

[
  {"name": "dword_41DC4", "addr": "0x41DC4", "segment": "__data",
   "init_value": "0xBE732599", "description": "MBA seed key"},
  {"name": "dword_41DC8", "addr": "0x41DC8", "segment": "__data",
   "init_value": "0x43FFD974", "description": "MBA seed key"},
  {"name": "dword_41DCC", "addr": "0x41DCC", "segment": "__data",
   "init_value": "0x2338FB04", "description": "MBA seed key"},
  {"name": "dword_41DD0", "addr": "0x41DD0", "segment": "__data",
   "init_value": "0xF690C74D", "description": "MBA seed key"},
  {"name": "dword_41DD4", "addr": "0x41DD4", "segment": "__data",
   "init_value": "0x8B27A32A", "description": "MBA seed key"},
  {"name": "dword_41DD8", "addr": "0x41DD8", "segment": "__data",
   "init_value": "0x5AE39E39", "description": "MBA seed key"},
  {"name": "dword_4AF68", "addr": "0x4AF68", "segment": "__bss",
   "init_value": "0xFFFFFFFF", "description": "BSS atomic flag (LDAR/STLR pair)"},
  {"name": "dword_4AF80", "addr": "0x4AF80", "segment": "__bss",
   "init_value": "0xFFFFFFFF", "description": "BSS atomic flag"},
  {"name": "dword_4AF84", "addr": "0x4AF84", "segment": "__bss",
   "init_value": "0xFFFFFFFF", "description": "BSS atomic flag"}
]

6 个 MBA seed key 都在 __data,初值是高熵 32 位常量;3 个 atomic flag 都在 __bss,初值统一是 0xFFFFFFFF关键洞察:这些 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 比特”这件事,整体就崩了。

确认这个之后,反混淆策略整个推翻重写。我开始写一系列分析脚本,从 cff_step1_x20map.py 一直干到 cff_step19_parse_decomp_to_schedule.py,一步步把这棵尾调用链解构。脚本一共写了 19 个,加起来三千行左右。

3. 迷局二:xrefs_to(_MSHookMessageEx) 一个调用者都找不到

跟 CFF 的迷局并行的是 hook 注册表的迷局。

我习惯找 hook 注册函数的方式是:xrefs_to(_MSHookMessageEx) 看谁调它。逆向 iOS tweak 这是第一招,稳得不能再稳——MSHookMessageEx 是 MobileSubstrate 的入口 API,任何 tweak 想 hook ObjC 方法都得调它,谁调它谁就是 hook 注册函数,顺藤摸瓜整个 hook 列表都能挖出来。

这个 dylib 里这一招直接失败——xref 表显示只有几个 thunk 调它,真正的 hook 注册函数一个都看不见。

在 ida-pro-mcp 里跑:

xrefs = mcp__ida-pro-mcp__xrefs_to(MSHookMessageEx_addr)
# 返回 19 个 site,全是 4 指令 thunk 包装

19 个 thunk,每个 thunk 长这样:

ADRP X16, _MSHookMessageEx@GOTPAGE
LDR  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 引用的地方。

# 找所有 19 个 _MSHookMessageEx thunk 的地址
thunk_addrs = [...]  # 19 个

# 在 __const / __data 段全段扫描:
for offset in range(0x40000, 0x4B000, 8):
    val = mcp__ida-pro-mcp__get_u64(offset)
    if val in thunk_addrs:
        # 这个 8 字节槽里存了 thunk 地址,某个 BR Xn 会跳过来
        print(hex(offset), hex(val))

这种“在数据段里 grep 函数指针”的做法听起来粗暴,但它绕开了所有混淆层——你不需要懂 BDD,不需要解 OLLVM CFF,只要相信“thunk 地址必须出现在某个 8 字节槽里才能被间接调用”这个简单事实。所有间接调用最终都要把目标地址装进某个内存槽,而 8 字节对齐的内存槽是有限的,扫一遍就完了。

跑出来 167 个引用点,而不是 19 个。换句话说,真正的 hook 数量是 167,不是 19。19 是 thunk 数,167 是 thunk 被装入函数指针表的次数——每次装入对应一个 hook 注册。

到这里基本看清楚了,这个 dylib 的“反逆向价值”,也即作者的核心防护投入,不在于隐藏代码,而在于隐藏 hook 列表。隐藏 hook 列表的目的就一个:让别人不能直接照抄。这是商业反封号工具的核心商业秘密——算法可以重写,核心代码可以重新组织,但“作者花了多长时间发现微信哪些方法值得 hook、哪些方法 hook 后会引发副作用、哪些方法是关键卡口”这种领域知识,是用时间和试错堆出来的,这才是真值钱的部分。

4. 写反混淆器:223 个 trampoline 全部解码,0 错误

没办法了,只能写反混淆器一个个来。整体思路按 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 寄存器:

# cff_step1_x20map.py: 重建 X20 偏移 → mini-table 地址映射
import ida_funcs, ida_ua, idc

ENTRY = 0x4000
fn = ida_funcs.get_func(ENTRY)

last_adr_for_reg = {}     # 寄存器号 → 最近一次 ADRL 装入的地址
x20_map = {}              # x20_offset → mini-table 地址

ea = fn.start_ea
while ea < fn.end_ea:
    insn = ida_ua.insn_t()
    sz = ida_ua.decode_insn(insn, ea)
    if sz == 0:
        ea += 4; continue

    mnem = idc.print_insn_mnem(ea)

    # 1) ADRL/ADRP/ADR: 记录"哪个寄存器刚装了什么地址"
    if mnem in ("ADRL", "ADR", "ADRP"):
        reg = insn.Op1.reg
        target = idc.get_operand_value(ea, 1)
        last_adr_for_reg[reg] = target

    # 2) STR Xreg, [X20, #imm]: 把刚才装入 reg 的地址,存到 X20+imm
    elif mnem == "STR" and insn.Op2.type == ida_ua.o_displ:
        op2_str = idc.print_operand(ea, 1)
        src_reg = insn.Op1.reg
        if "X20" in op2_str and src_reg in last_adr_for_reg:
            disp = insn.Op2.addr
            x20_map[disp] = last_adr_for_reg[src_reg]

    ea += sz

import builtins; builtins._x20_map = x20_map

跑完得到 202 条映射,呈现明显的两段结构:X20[0x0..0x3D0] 全部指向 0x42DA0(共享大表的 122 个状态槽),X20[0x3E0..0xC50+] 指向从 0x47348 起递减的滑动窗口段。后来把这个映射 dump 成 JSON 重看才发现实际是 5 段结构外加 3 个特殊槽,其中 X20[0xC78] 指向 0x4AF68——LDAR 用的 atomic flag。这个 LDAR flag 后来被证明是首次/再次入口判定的关键。

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,这三条几乎从来不变,把它们卡住其它的就好办了:

# cff_step2_scan_trampolines.py: 扫 1492 个函数,识别 trampoline 模板
import ida_funcs, ida_ua, ida_bytes, idc, idautils, ida_segment, builtins

x20_map  = builtins._x20_map
text_seg = ida_segment.get_segm_by_name("__text")

# sub_4000 prologue 装的魔数寄存器(一开始只知道 3 个)
W_REG_CONSTANTS = {
    22: 0xFD6886DF, 25: 0x3664924A, 28: 0xD8128E13,
}

trampolines, errors = [], []
for fn_ea in idautils.Functions(text_seg.start_ea, text_seg.end_ea):
    if fn_ea == 0x4000:
        continue   # sub_4000 自己不是 trampoline,跳过

    insns = list(decode_function(fn_ea))   # [(ea, mnem, ops, insn_t), ...]
    if not (4 <= len(insns) <= 12):
        continue

    # 末尾必须是 BR Xn
    if insns[-1][1] != "BR":
        continue
    # 倒数第 2 条: LDR Xn, [Xm, Wk, UXTW#3]   ← mini-table 索引
    if insns[-2][1] != "LDR" or "UXTW#3" not in insns[-2][2][1]:
        continue
    # 倒数第 3 条: LDR Xn, [X20, #imm]   ← mini-table 基址
    pp = insns[-3]
    if pp[1] != "LDR" or "X20" not in pp[2][1]:
        continue
    x20_offset = pp[3].Op2.addr & 0xFFFFFFFF

    # 往前找 CMP / CSET 解 cmp_const(8 种模式后面会讲)
    cmp_const = resolve_cmp_const(insns, W_REG_CONSTANTS)

    # 用 X20 偏移查 step1 的映射拿到 mini-table 地址,读两个槽
    table_addr = x20_map.get(x20_offset)
    if table_addr is None:
        errors.append({"fn": fn_ea, "x20_offset": x20_offset})
        continue

    trampolines.append({
        "ea":           fn_ea,
        "x20_offset":   x20_offset,
        "table_addr":   table_addr,
        "cmp_const":    cmp_const,
        "false_target": ida_bytes.get_qword(table_addr),
        "true_target":  ida_bytes.get_qword(table_addr + 8),
    })

builtins._trampolines = trampolines
# 跑完: 223 个 trampoline,errors = 0

resolve_cmp_const 是个递归往前扫的小函数,处理几种比较模式。第一版我只覆盖了三种:

CMP  W8, #imm                         ✓
CMP  W8, W22/W25/W28                  ✓ (上面那三个魔数寄存器)
CMP  W8, W?  + 前面 MOV W?, #imm       ✓

漏掉的:

CMP  W8, [SP, #imm]                        ✗ 栈变量
CMP  W8, W7/W9/W10/W11                     ✗ 其他寄存器
CMN  W8, #imm                               ✗ 负数比较
TST  W8, #imm                               ✗ 位测试
LDR  W?, [X20, #imm] + CMP W8, W?           ✗ X20 数组取常量(最常见!)

第二版补全所有这些模式,解析率到 83%。剩下 17% 卡在 sub_4000 末尾隐藏的 13 个魔数寄存器——我之前只看到 W22/W25/W28,实际 prologue 末尾紧跟着另外 13 个 MOV Wn, #imm,把其他寄存器初始化成更多魔数:

W12 = 0x40D58939     W17 = 0x52BD65EA
W13 = 0x3F4EFB5E     W0  = 0x7C2AD684
W14 = 0x3BEEF50F     W1  = 0x68470F1E
W15 = 0x50856051     W2  = 0x84FF6501
W16 = 0x5B167B19     W3  = 0x822E0282
W4  = 0x97F5C604
W27 = 0xCA599231
W23 = 0xFF9AE195

这 13 个 MOV 装在 0x493C-0x49A4,夹在 prologue 主体跟函数末尾的 BR 之间,我第一遍读 sub_4000 是从入口顺序读的,看到 X20 数组初始化和那一大堆 ADRL+STR 之后觉得已经摸清模式就跳过去了,完全错过了末尾这 13 行。这是典型的“以为读完了其实没读”。手工补完之后解析率到 100%(16 个魔数寄存器,从只看见 3 个变成 16 个),回头看 step2 跑出的 cmp_const 列表,所有“未知”标签都消失了。

这些 cmp_const 的值看起来都是高熵随机数(0x3664924A0x5245100A0x673C44250x72E2FCCE0x78AE4B81……),平均 popcount 接近 16(理论期望也是 16),没有明显模式——这几乎可以肯定是 PRNG 的连续输出。这种模式有个有趣的副产品:如果能猜出 PRNG 的种子和算法,就可以预测整条 trampoline 链的 cmp_const 序列,这反过来可以做“哪些函数被切割成了 trampoline”的预测器。

step4 是真正的反混淆器,把 223 个描述符渲染成 if-else 树:

# cff_step4_unflatten.py: BDD 反扁平化,输出 if-else 树
from collections import Counter
import builtins

descriptors = builtins._tramp_descriptors
desc_by_ea  = {d["ea"]: d for d in descriptors}
tramp_set   = set(desc_by_ea)

# 1) 入口 / 出口 / 入度
referenced, all_targets = set(), set()
in_degree = Counter()
for d in descriptors:
    for k in ("false_target", "true_target"):
        t = d.get(k)
        all_targets.add(t)
        if t in tramp_set:
            referenced.add(t)
            in_degree[t] += 1

entries = tramp_set - referenced     # 没被任何 trampoline 跳来的
exits   = all_targets - tramp_set    # 跳出 BDD 的真实函数

# 2) 递归渲染(visited 防环,max_depth 防爆栈)
def render(ea, indent, visited, max_depth=15):
    pad = "  " * indent
    if ea not in tramp_set:
        return f"{pad}-> sub_{ea:X}()  [exit]\n"
    if ea in visited:
        return f"{pad}-> sub_{ea:X}  [LOOP]\n"
    if max_depth <= 0:
        return f"{pad}-> sub_{ea:X}  [DEPTH LIMIT]\n"

    d    = desc_by_ea[ea]
    cond = d["cset_cond_pretty"]                     # < / == / ...
    cst  = f"0x{d['cmp_const']:X}" if d.get("cmp_const") else "?"

    out  = f"{pad}if W8 {cond} {cst}:  // sub_{ea:X}\n"
    out += render(d["true_target"],  indent+1, visited|{ea}, max_depth-1)
    out += f"{pad}else:\n"
    out += render(d["false_target"], indent+1, visited|{ea}, max_depth-1)
    return out

# 3) 输出全部入口
output = []
for entry in sorted(entries):
    output.append(f"### ENTRY: sub_{entry:X}  (in-degree={in_degree.get(entry, 0)})")
    output.append(render(entry, 0, set()))

open("data/unflattened_bdd.txt", "w").write("\n".join(output))

跑完产物 4.7 MB / 84557 行 if-else,平均每个入口几百到几千层嵌套。文件大但不离谱,grep 找特定 hook 的 dispatch 路径很方便——比如要看 sub_8794 在哪些上游被引用,直接 grep -n "sub_8794" unflattened_bdd.txt | head -50 就能列出来。

unflatten_summary.json 里的关键数字:74 个入口 / 53 个出口 / 149 个内部节点。出口是真正跳出 BDD 的“业务函数”,其中 38 个是 hook 的 newImp(后面会讲它们里面 36 个还是 BDD,只 2 个是真函数)。74 入口意味着这棵尾调用链不是一棵“大树”,是 74 棵相对独立的子树并存,每棵子树对应一个 hook dispatch 路径。

入度统计揭露了 BDD 的拓扑形状极不均匀:

sub_8794    in-degree=130   ← 58% 的 trampoline 都跳来这里
sub_49C4    in-degree= 20   ← 主干链 #1 根节点
sub_87B0    in-degree= 20   ← 主干链 #2 根节点
sub_8F08    in-degree= 13
其余 ...    in-degree ≤ 2

130 + 20 + 20 + 13 = 183 条边收敛到 4 个节点(占 446 条边的 41%),其余 263 条边平均分配到 217 个节点。这种“少数节点高度收敛 + 多数节点零散”的拓扑反映 OLLVM 用了 ROBDD(Reduced Ordered BDD) 优化——把多个判定路径里相同的尾段合并成共用节点,这是 BDD 数据结构的标准压缩。

223 个 trampoline 全部成功还原,errors = 0。这件事本身就是最强的信号——它证明我的模型 100% 准确,没有漏掉任何一种 trampoline 形态。0 错误的真正含义是模板假设零反例——任何 trampoline 一旦满足“6-7 条定式指令 + CSET LT + LDR [X20+off] + LDR [X10, Wx UXTW#3] + BR X9”模板,X20 偏移在映射表里一定能查到,F/T target 一定指向合法的 trampoline 地址。剩余 85% 没匹配上的不是反例,是模板变种模型对了,剩下的就是工程量;模型错了,跑 100% 命中率也是徒劳

5. 找解密器:xref 全军覆没,dref 一击命中

反混淆完成之后,主战场转移到字符串解密。__data 段里有 0x40328 字节高熵密文,得搞清楚解密算法和 key。

我的第一反应是找解密器函数——解密器肯定要“写”密文区(把解密结果写到某个 buffer),那么 xrefs_to(0x40000) 应该能找到所有写入点。这是逆向加密字符串最直接的入口。

# 第一轮(失败)
for cipher_byte in range(0x40000, 0x42500):
    refs = mcp__ida-pro-mcp__xrefs_to(cipher_byte)
    # 几乎全是 0 个引用!

整整一天我卡在这里。0 个 xref 意味着 IDA 没有任何指令“显式引用”这个地址。但密文确实存在,确实有人解密它,这是个矛盾。

我换了个角度想: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 解密的强指标):

# cff_step16_find_dec_v3.py: dref 扫全函数,找 master decryptor
import idc, idautils, ida_funcs, ida_ua

CIPHER_MIN, CIPHER_MAX = 0x40000, 0x42500
candidates = []

for fn_ea in idautils.Functions():
    fn = ida_funcs.get_func(fn_ea)
    if not fn: continue
    sz = fn.end_ea - fn.start_ea
    if sz < 30 or sz > 5000:
        continue   # 排除 thunk + sub_4000 这种巨型初始化函数

    W_addrs, R_addrs = set(), set()
    n_eor = 0

    ea = fn.start_ea
    while ea < fn.end_ea:
        mn = idc.print_insn_mnem(ea)

        # 关键: dref 而不是 xref
        for dref in idautils.DataRefsFrom(ea):
            if CIPHER_MIN <= dref < CIPHER_MAX:
                if mn in ("STR", "STRB", "STUR", "STURB"):
                    W_addrs.add(dref)
                elif mn in ("LDR", "LDRB", "LDUR", "LDURB"):
                    R_addrs.add(dref)

        if mn == "EOR":
            n_eor += 1
        ea = idc.next_head(ea)

    if len(W_addrs) > 10:
        candidates.append({
            "ea": fn_ea, "size": sz,
            "n_W": len(W_addrs), "n_R": len(R_addrs), "n_eor": n_eor,
            "W_range": (min(W_addrs), max(W_addrs)),
        })

candidates.sort(key=lambda x: -x["n_W"])
# 跑完: 15 个候选,头部 3 个写入量最大

几个阈值都是经验值:size < 30 排除 thunk,size > 5000 排除 sub_4000 这种巨型初始化函数,n_W > 10 卡掉只是顺便引用一两个常量的普通函数。最后剩下来的几乎全是 master decryptor。

跑完落盘 data/decryptor_candidates.csv,15 行,头部几行(按 n_W 排序)长这样:

addr     name        size  n_R  n_W  n_eor  W_range
0x4f34   sub_4F34    2260  107  108  18     0x40005-0x4026B
0x6df4   sub_6DF4    2532  109  108  22     0x40005-0x4026B   ← W_range 跟上一行完全一样!
0x1e418  sub_1E418   2556  119  116  23     0x41130-0x4127C
0x22ab8  sub_22AB8   1724   76   73  12     0x4152F-0x415DF
0x26b3c  sub_26B3C   1160   49   47   6     0x416E9-0x4176E
0x1d0b8  sub_1D0B8    420   13   12   5     0x41032-0x4103D
0x1d3e0  sub_1D3E0    292   11   12   5     0x41032-0x4103D   ← 跟上一行同 W_range
... 其余小节点

光看这个表就能看出细节:第二三行(sub_6DF4 / sub_1D3E0)的 W_range 跟它们前一行完全相同,这就是孪生——写入同样的 108 / 12 个字节地址,只是 MBA 模板不同。n_eor 越高的越像 OLLVM 字符串解密的“显式 EOR”版本(不那么努力 MBA 的);n_eor 低的可能 MBA 强度更高,EOR 全被位掩码替代了。

跑出来的 15 个候选里,头部 3 个写入量最大:

sub_168C4   写 161 个独立字节,W_range = 0x40590-0x41001    ★ master
sub_FB2C    写 160 个独立字节,W_range = 0x40642-0x40FC4
sub_10A30   写 160 个独立字节,W_range = 0x40642-0x40FC4    ← 跟 sub_FB2C 范围完全相同

注意第三行那个奇怪的事实:sub_FB2Csub_10A30 写的范围完全一样。第一眼看到的时候我以为是哪里统计错了,跑了一遍验证,确实是两个不同的函数(地址相差 0xF04),但 W_addrs 集合完全相同。这是 OLLVM 的“复制粘贴 MBA 重写”模式——每个真 master 在地址相邻 0x1000-0x4000 的位置有一个 MBA 重写孪生。代数等价,但 MBA 模板不同。

具体例子,反编译出来同一行的两个版本:

// sub_FB2C
byte_40F60 = (~byte_40F30 & 0x89 | byte_40F30 & 0x76) ^ 0x13;

// sub_10A30
byte_40F60 = byte_40F30 ^ 0x9A;

代数化简两者等价,因为 0x89 | 0x76 = 0xFF0x89 & 0x76 = 0,所以 ~b & 0x89 | b & 0x76 = b ^ 0x89,再 XOR 0x13 得到 b ^ (0x89 ^ 0x13) = b ^ 0x9A,跟 sub_10A30 完全一样。

这种孪生不是冗余备份,是双 MBA 写法测试——同一组 K 值用两套不同的 MBA 模板生成两份代码,把分析者引入“哪个是真的”的死胡同。检测方法很简单:找写地址集合 100% 相等的两个函数,跳过其中之一。这能直接砍掉一半的工作量。

6. 数学化简:OLLVM 的 8 种 MBA 模板,代数化简后全是 b ^ K

确定 sub_168C4 是主解密器,我开始反编译它的解密公式。

它写得很恶心,121 个字节解密公式,每一个都用不同的 MBA 表达式包装。我看到的前几个:

plain[0] = b ^ 0xE0;                              // 直接 XOR
plain[1] = ~b & 0x83 | b & 0x7C;                  // 位掩码 1
plain[2] = ~((b | 0x33) & (~b | 0xCC));           // 位掩码 2
plain[3] = b - 2 * (b & 0xF3) - 13;               // 算术 1
plain[4] = (~b & 0xE9 | b & 0x16) ^ 0xF6;         // 嵌套 XOR
plain[5] = b - 2 * (b & 0x50) + 80;               // 算术 2
plain[6] = ~(b & 0xA4 | ~b & 0x5B);               // 位掩码 3

刚开始看到这些我有点头疼,以为得逐条手工化简或者上 SMT solver。但后来我盯着这些公式看了一会儿,发现一个规律:所有这些公式,实质上只做一件事——XOR

公式形态 代数化简 真实操作
b ^ K b ^ K XOR K
~b & A \| b & B (A+B=0xFF, A&B=0) b ^ A XOR A
~((b\|A) & (~b\|B)) (同上约束) b ^ B XOR B
~(b & A \| ~b & B) b ^ ~A XOR ~A
b - 2*(b & A) ± C (A 是单 bit 掩码或全位) (b ^ A) ± C XOR + 加减
(... bitmask ...) ^ K 双重 XOR 链 XOR (M ^ K)

证明的话用真值表展开就行,比如 ~b & A | b & B,当 A | B == 0xFFA & B == 0 时,A 和 B 互补:

b 的某一位 = 0 时:~b 该位 = 1,~b & A = A 的对应位,b & B = 0
                   → 输出 = A 的对应位
b 的某一位 = 1 时:~b 该位 = 0,~b & A = 0,b & B = B 的对应位
                   → 输出 = B 的对应位

把 A 和 B 拼起来等于 0xFF,而 b XOR A 在 b=0 时输出 A,b=1 时输出 ~A = B(当 A+B=0xFF 时)。完全等价。OLLVM 用 8 种写法绕死分析者,代数学一拳打回原形。

第一版 step17 是最笨的办法:把 IDA 反编译出来的 121 行 C 表达式逐行抄成 Python lambda,放进列表,直接跑:

# cff_step17_apply_dec_168C4.py: 第一版手工照搬反编译输出
import ida_bytes
def b(addr):
    return ida_bytes.get_byte(addr)

# 把 sub_168C4 反编译里每一行 byte_DST = expr; 抄成 (DST, lambda) 元组
RULES = [
    # 第一段 0x40D5D-0x40D67 → 0x40D7D-0x40D87 (+0x20, 11 字节)
    (0x40D7D, lambda: b(0x40D5D) ^ 0xE0),
    (0x40D7E, lambda: b(0x40D5E) ^ 0xBA),
    (0x40D7F, lambda: ((~b(0x40D5F) & 0x83 | b(0x40D5F) & 0x7C) ^ 0xE4) & 0xFF),
    (0x40D80, lambda: (b(0x40D60) - 2 * (b(0x40D60) & 0xF3) - 13) & 0xFF),
    (0x40D81, lambda: b(0x40D61) ^ 0x23),
    (0x40D82, lambda: ~(b(0x40D62) & 0xA4 | ~b(0x40D62) & 0x5B) & 0xFF),
    (0x40D83, lambda: ~((b(0x40D63) | 0x33) & (~b(0x40D63) | 0xCC)) & 0xFF),
    # ... 共 121 条
]

decrypted = {dst: fn() & 0xFF for dst, fn in RULES}
plaintext = bytes(decrypted[a] for a in sorted(decrypted))
print(plaintext.split(b"\x00"))   # 9 段明文字符串

跑出 9 段明文之后,我意识到下一个 master(sub_FB2C)还有 160 行公式要抄,后面还有 14 个 master 等着——总共一千多行公式手抄,只要错一处就得回头 debug。这时候才动手把这一步抽象成通用解析器:

# cff_step19_parse_decomp_to_schedule.py: 通用 schedule 解析器
import re

ASSIGN_RE   = re.compile(r"byte_([0-9A-Fa-f]+)\s*=\s*(.+?);", re.S)
BYTE_REF_RE = re.compile(r"byte_([0-9A-Fa-f]+)")

def parse_pseudo_to_schedule(pseudo_text):
    """把 IDA 反编译输出的 C 赋值行,转成可 eval 的 Python 表达式列表"""
    schedule = []
    for m in ASSIGN_RE.finditer(pseudo_text):
        dst_addr = int(m.group(1), 16)
        c_expr   = m.group(2).strip()

        # 跳过"右侧无 byte_ 引用"的行(栈引用 / 形参 / 临时变量)
        if not BYTE_REF_RE.search(c_expr):
            continue

        # byte_XXXX → b(0xXXXX),整体 & 0xFF 防止 ~ 在 Python 里的有符号扩展
        py_expr = BYTE_REF_RE.sub(lambda mm: f"b(0x{mm.group(1)})", c_expr)
        py_expr = f"({py_expr}) & 0xFF"

        schedule.append((dst_addr, py_expr))
    return schedule

def apply_schedule(schedule, byte_func):
    """跑 schedule,输出 dst → plaintext byte 字典"""
    return {dst: eval(expr, {"b": byte_func}) for dst, expr in schedule}

# 用法:
# pseudo = open("sub_168C4_decompile.c").read()
# sched  = parse_pseudo_to_schedule(pseudo)
# result = apply_schedule(sched, byte_func=ida_bytes.get_byte)

整套流程不需要 SMT solver、不需要 IR 转换、不需要代数化简,Python 标准库就够。这是写工具时的一种思路:当对手已经把信息明文表达在 C 代码里,你就别费劲去做“理解”那一步,直接把代码当函数用

7. 第一次明文曝光:9 段字符串炸开整个反封号机制

跑出来的 9 段明文是这样的:

0x40D87:  rRequest:                     # 加好友请求 SEL 后缀
0x40DDF:  MMTokenService                # ★ ObjC 类名
0x40E60:  MMClientCacheManager          # ★ ObjC 类名
0x40BFE:  checkConfig                   # ★ SEL
0x40590:  reportCrashLog:type:          # ★★★ 被 nullify 的 hook!
0x40B20:  reportDeviceToken:            # ★★ 设备 Token 上报
0x40CB0:  refreshTokenWithPolicy:       # ★★ Token 刷新
0x40AE2:  getAppSignType                # 应用签名类型
0x40FF0:  isJailbrokenDevice            # ★★★★ 越狱检测!

isJailbrokenDevice 配合后面发现的 9 个 nullsub_1 newImp,机制完全清楚了:dylib 把微信内部用于检测越狱、上报安全事件的方法,全部 hook 成空操作或返回伪值

reportCrashLog:type: 直接被替换成 nullsub_1(汇编层面就是 MOV X0, #0; RET 两条指令)。任何代码调它都直接返回。崩溃信息永远不会到达微信服务器。崩溃日志在风控里有非常高的价值——黑产的 hook 工具崩溃模式跟正常用户完全不同(频繁在 ObjC runtime 内部崩溃、栈帧带有 MobileSubstrate 痕迹、崩溃地址固定在某些 hook trampoline),把这条上报通路砍掉,等于砍掉一个核心信号源。

isJailbrokenDevice 被 hook 让它永远返回 NO。微信代码里所有 if ([self isJailbrokenDevice]) { ban_user(); } 这种判断,直接走“非越狱”分支。

MMTokenService 是微信 Token 系统的核心类。dylib 拦截这个类的 refreshTokenWithPolicy: 等方法,控制 Token 刷新行为,维持账号“看起来合法”的状态。

那一刻我意识到一件事:这个 dylib 不是在破解微信的风控算法,是在切断风控的信息源。这是攻防中典型的高维降维打击——算法再强,数据进不来等于零。微信服务端可能跑着最先进的图神经网络做账号关系建模、用 LSTM 做行为时序异常检测,但如果客户端连“isJailbrokenDevice = YES”这条信号都不上报、连崩溃日志都不发,模型再强也是在猜。

8. 扩搜索面:孪生 + 双份镜像

这里我犯了另一个错误:以为找全了
之前我扫的 cipher 区是 0x40500-0x41100,因为这是 sub_168C4 写入的范围。我以为 cipher 总量就这么大。直到我去看 sub_FB2C(被识别为孪生)和它的孪生 sub_10A30 的 W_range——0x40642-0x40FC4,跟 sub_168C4 部分重叠但不完全相同。两个 master 不是覆盖同一片区域的“主备”关系,而是分别管不同的字符串区。

我把搜索面扩大到整个 __data 段(0x40000 - 0x42500),用同样的 dref 扫描,这次找到了 17 个 master decryptor(包括 5 对孪生)。

跑完每一个,累计解出 66 个独立明文字符串、981 字节。一下子从 4% 覆盖跳到接近全覆盖。

Master 明文范围 字节数 主要内容
sub_168C4 0x40590-0x41001 161 hook 主区(MMTokenService 系列)
sub_FB2C 0x40642-0x40FC4 160 hook 主区(MMContext / ExptService)
sub_5A4C 0x400D5-0x4031F 215 反作弊核心类(MMRuntimeIntegrity 等)
sub_4F34 0x40005-0x4026B 108 dylib 头部(isEnvironmentTrusted 等)
sub_1E418 0x41130-0x4127C 116 A/B 配置 key
sub_22AB8 0x4152F-0x415DF 73 状态字典 key
sub_26B3C 0x416E9-0x4176E 47 越狱痕迹擦除
sub_33CE8+sub_33A24 0x41BA0-0x41BBC 29 ★ SHA-256("") 伪值前 29 字节
sub_2E5E8 0x4196A-0x41A64 12 静态可见 / 96 不可见 栈指针变种
... 其余 8 个小节点 辅助类名

最后那个 sub_2E5E8 是这个项目的死角,放到第 11 节讲。

里面 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 注册函数中出现的 _class_addMethod import 完全对得上。

中间还有个意外发现——hook 注册表是双份镜像的。我把 167 个 hook site 按地址段分布画出来:

0x118dc - 0x11dc0   ~64 个 hook
0x1bb98 - 0x1c2b4   ~64 个 hook   ← 跟上面完全相同的 SEL/Class 序列!
0x12128 - 0x121b8   ~6 个,签名相关
0x16598 - 0x16624   ~6 个,签名相关
0x1c134 - 0x1c2b4   ~20 个,Token/Crash 相关

两段 64 个 hook 是同一份。我先是怀疑自己脚本统计错了,把每个 hook 的 SEL 地址 + Class 地址对都列出来比对,确实是同样的序列出现两次,只是注册函数名不同,代码体也几乎一样。原因猜测有几个:

ObjC swizzling 在 `+load` 早晚两个时机各执行一次
反卸载机制——即使别人写脚本 unhook 一份,另一份依然在
不同微信版本兼容——一份对 8.0.30,另一份对 8.0.40,运行时根据版本选

我觉得可能是第 3 个。

9. 167 hook 完整关联:从二进制反扫到作战图

到这一步素材都齐了:66 个明文字符串、167 个 hook 注册 site。剩下要做的是把每个 hook 的 SEL/Class 跟明文对应起来。

每个 hook 注册 site 长这样:

; 大约在每个 hook 注册前 40 条指令内,会有这两条 ADRP+ADD 拼字符串地址
ADRP X0, #class_name_addr@PAGE
ADD  X0, X0, #class_name_addr@PAGEOFF   ; X0 = ObjC class name 地址
...
ADRP X1, #sel_name_addr@PAGE
ADD  X1, X1, #sel_name_addr@PAGEOFF      ; X1 = SEL 字符串地址
...
BL   _MSHookMessageEx                    ; (实际是 LDR + BR 间接调用)

_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 动态注册模式:

# cff_step6_extract_hooks.py: 从 5 个注册函数提取 167 hook 三元组
def extract_hooks_from_func(fn_ea):
    insns = list(decode_function(fn_ea))   # [(ea, mnem, ops, op_vals), ...]
    hooks = []

    # 1) 找所有 BL _MSHookMessageEx 或它的 thunk(0x38xxx 范围)
    hook_calls = []
    for i, (ea, mn, ops, ovs) in enumerate(insns):
        if mn != "BL":
            continue
        target = ovs[0]
        target_name = idc.get_name(target) or ""
        if "_MSHookMessageEx" in target_name or 0x38000 <= target < 0x39000:
            hook_calls.append((i, ea))

    # 2) 对每个 hook call 反扫前 25 条
    for call_idx, call_ea in hook_calls:
        sel_str = class_str = newImp = origImp = None

        for j in range(call_idx - 1, max(call_idx - 25, -1), -1):
            ea_j, mn_j, ops_j, ovs_j = insns[j]

            if mn_j == "ADRL":
                dst = ops_j[0].strip()
                tgt = ovs_j[1]
                # ADRL X2/X3 一般是 newImp / origImp 槽
                if dst == "X2" and newImp is None:
                    newImp = tgt
                elif dst == "X3" and origImp is None:
                    origImp = tgt

            elif mn_j == "BL":
                tname = idc.get_name(ovs_j[0]) or ""
                # _sel_registerName 之前的 ADRL X0 是 SEL 字符串地址
                if "_sel_registerName" in tname and sel_str is None:
                    if j > 0 and insns[j-1][1] == "ADRL":
                        sel_str = read_cstring(insns[j-1][3].Op2.value)
                # _objc_getClass 之前的 ADRL X0 是 Class 字符串地址
                elif "_objc_getClass" in tname and class_str is None:
                    if j > 0 and insns[j-1][1] == "ADRL":
                        class_str = read_cstring(insns[j-1][3].Op2.value)

        hooks.append({
            "site": call_ea, "class": class_str, "sel": sel_str,
            "newImp": newImp, "origImp_slot": origImp,
        })
    return hooks

all_hooks = []
for fn_ea in [0x1BB50, 0x118D4, 0x1C12C, 0x12114, 0x16504]:
    all_hooks.extend(extract_hooks_from_func(fn_ea))
# 跑完: 167 个 hook 三元组

25 条指令的回扫窗口是经验值。一开始我用 15,漏掉了被 OLLVM 拉远的 ADRL 对;后来用 50,又把上一个 hook 的字符串引用也扫进来了。25 是覆盖整个 hook 注册块、不污染前一个 hook 的折衷值。

step6 跑完拿到的 class_str / sel_str 此时还可能是密文——如果字符串区当时还没解密,read_cstring 读出来的就是高熵 binary。所以真正能拿到明文 hook 列表必须等到 step17/19 把字符串解密完之后,再跑 step25 做关联:

# cff_step25_join_hooks_strings.py: hook site 跟明文字符串表对应
import json, idc, idautils

# 已解密的字符串表: [{"addr": 0x40DDF, "plaintext": "MMTokenService", "length": 14}, ...]
plaintext_table = json.load(open("data/all_decrypted_strings.json"))

def lookup_str(addr):
    """addr 落在某个明文字符串内则返回该串,支持中段命中(指针落在串中间)"""
    for s in plaintext_table:
        if s["addr"] <= addr < s["addr"] + s["length"] + 1:
            return s, addr - s["addr"]
    return None, 0

def find_str_refs(site_ea, lookback=40):
    """从 hook site 反扫前 40 条指令,收集所有落在密文区的 ADRP+ADD 拼出地址"""
    refs = []
    ea = site_ea
    for _ in range(lookback):
        ea = idc.prev_head(ea)

        # 1) IDA dref(ADRP+ADD 数据流分析能拼出的地址)
        for dref in idautils.DataRefsFrom(ea):
            if 0x40000 <= dref < 0x42500:
                refs.append(dref)

        # 2) 手动 ADRP+ADD 配对(IDA 偶尔识别不全,兜底)
        if idc.print_insn_mnem(ea) == "ADRP":
            base = idc.get_operand_value(ea, 1)
            nx   = idc.next_head(ea)
            if idc.print_insn_mnem(nx) == "ADD":
                offs   = idc.get_operand_value(nx, 2)
                target = base + offs
                if 0x40000 <= target < 0x42500:
                    refs.append(target)
    return refs

# 主流程
results = []
for h in all_hooks:
    refs = find_str_refs(int(h["site"], 16))
    str_refs = []
    for addr in set(refs):
        s, off = lookup_str(addr)
        if s:
            str_refs.append({"plaintext": s["plaintext"], "offset": off})
        else:
            # 未解密的,直接读密文 hex 留作后续审计
            cipher = bytes(idc.get_wide_byte(addr+k) for k in range(64))
            cipher = cipher[:cipher.index(0)] if 0 in cipher else cipher
            str_refs.append({"cipher_hex": cipher.hex()})
    results.append({"site": h["site"], "refs": str_refs})

# 跑完: 164/167 拿到 ≥ 2 个 string ref(SEL+Class 完整对)

这一步有两个细节。第一个是 find_str_refs 同时用 idautils.DataRefsFrom(IDA dref)和手动 ADRP+ADD 配对兜底——单靠 dref 在某些 OLLVM 拉远的指令对上识别不全,加手动配对能覆盖剩下的 5%。第二个是 lookup_str 支持“中段命中”——ADRP+ADD 拼出来的不一定是字符串起点,可能是字符串中段某个字符的位置,这种情况要根据 offset 推回完整字符串。如果不支持中段命中,这一步会少匹配 20% 左右的 hook。

跑完结果:164 / 167 拿到了 SEL+Class 完整对,3 个只拿到一个引用——这 3 个特殊的可能是 class_addMethod 而不是 MSHookMessageEx,后者只需要 Class 不需要现成 SEL。

最终关联结果按业务分类整理:

反作弊核心类(微信侧)

MMRuntimeIntegrity        — 运行时完整性
MMSecurityPolicyValidator — 安全策略验证器
MMAntiTamperGuard         — 反篡改保护
MMTokenService            — Token 服务(风控核心)
MMClientCacheManager      — 客户端缓存管理
MMContext                 — 微信全局 Context
ExptService               — A/B 实验服务
CUtility                  — 工具类

被 hook 的关键 SEL

isJailbrokenDevice                      → NO
isEnvironmentTrusted                    → YES
auditLoadedImages                       → 跳过(过滤掉 MobileSubstrate 路径)
verifyCodeSignature:                    → YES
validateCertChain:depth:                → trusted
getAppBundleSignatureHash               → 原版预期值
getAppSignType                          → 原版类型
flushSecurityEvents                     → return (nullify)
reportCrashLog:type:                    → return 0 (nullsub_1)
reportDeviceToken:                      → return (nullify)
refreshTokenWithPolicy:                 → return (nullify)
getStringExpt:                          → 劫持
getBoolExpt:                            → 劫持
startVerifyContact:opcode:verifyMsg:    → 加好友核心动作(改参数)
sendVerifyUserRequest:                  → 加好友请求
checkConfig                             → 配置劫持
startMonitoring                         → no-op
refreshPolicyStore                      → no-op

auditLoadedImages 这个名字一看就知道是“扫一遍 dyld 已加载的所有 image,看有没有 MobileSubstrate / Cydia / Theos 这种越狱痕迹”。dylib 把它 hook 成“过滤掉自己”——调用时返回 image 列表,但去掉了 dylib 自己和 Substrate 的相关条目。这是一个有意思的细节:作者没有简单粗暴地让 auditLoadedImages 返回空,而是返回一个“过滤后的真实列表”,因为返回空会立即触发风控(“没有任何 dylib 加载?这设备不正常”),而返回过滤后的真实列表,在统计模式上跟正常用户完全吻合。

A/B 配置 key(劫持目标)

clicfg_gogwcs_ios_runtime_sdk_dc_dc_report_enable           → NO
clicfg_gogwcs_ios_runtime_sdk_main_cert_chain_report        → NO
cert_valid                                                  → 写入 YES
env_abnormal                                                → 写入 NO
jb_indicator                                                → removeObjectForKey:

clicfg_gogwcs_ios_runtime_sdk_* 这套 key 是微信 ConfigManager 的命名规范,clicfg 是“client config”,“gogwcs” 看起来是某个内部团队代号,“ios_runtime_sdk” 表明这是 iOS 客户端的运行时安全 SDK。这两个 key 一旦被 hook 返回 NO,整套上报链路从源头就被关闭——比 nullify 拦截更优雅。

10. SHA-256("")

sub_33CE8 解出来 7 字节明文:e3b0c44,在地址 0x41BA0。
sub_33A24 解出来 22 字节明文:298fc1c149afbf4c8996fb,在地址 0x41BA7。

两段相邻地址。拼起来:

0x41BA0:  e3b0c44298fc1c149afbf4c8996fb     (29 字节)

这 29 字节是什么?第一眼看到的时候我没认出来,以为是某种 token 或者 nonce。我把它拷到 grep 里跟所有解出来的字符串比对,没匹配上;放到 Google 搜了一下,瞬间明白了——这是 SHA-256("") 的前 29 字符。完整 SHA-256 空字符串值是:

e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855

这个字符串在 dylib 里被解密出来,只有一个用途——当微信调用 integrityHash getter 时,dylib hook 这个 getter,直接返回这个 SHA-256("") 伪值。不计算真 hash,不读取真数据,直接糊弄过去。

微信可能只检查“返回值是 64 字符的 hex 串”(格式校验),没有做 hash(actual_data) == reported_hash 的双向绑定。一句话总结:任何 hash 校验如果不绑定数据,等于摆设。客户端自己算自己上报,服务端只看格式不看内容,逻辑上等于零防护。

为什么作者偏偏选 SHA-256("") 而不是别的伪值?有几个理由:

  • 它是常量,可以硬编码进 dylib,不需要每次运行时计算
  • 它是个真实存在的合法 SHA-256 输出,如果服务端有“是否落在合法 hash 值域”的弱校验,这个值能过
  • 它“看起来很正常”——一串纯 hex,任何启发式检测都看不出异常

11. 死角:sub_2E5E8 栈指针变种

sub_2E5E8 是个解密器,但它的写入方式跟其他 master 完全不同。看签名:

__int64 __fastcall sub_2E5E8(int a1, _BYTE *a2, _BYTE *a3, _BYTE *a4, _BYTE *a5);

5 个参数,后 4 个是 BYTE 指针。函数体内还额外用了 6 个未在签名里的指针寄存器(X11/X12/X13/X14/X15/X16),通过寄存器从外面传进来。

这 10 个指针总共构成 5 对 source→dest 段:

X11 → X12 (v5/v6)    18 字节
X13 → X14 (v7/v8)    19 字节
X15 → X16 (v9/v10)   16 字节
X1  → X2  (a2/a3)    26 字节   ← 函数签名传入
X3  → X4  (a4/a5)    17 字节   ← 函数签名传入
小计指针段                   96 字节   ★ IDA dref 看不到!

外加两段直接 ADRL 加载的可见段:

ADRL byte_41961 → byte_4196A   3 字节  ★ 静态可解
ADRL byte_41A53 → byte_41A5C   9 字节  ★ 静态可解

可见的 12 字节解出来是 NSN+umber\0 拼出 NSNumber\0

剩下 96 字节卡死了。原因是 caller 通过 BR 间接跳进 sub_2E5E8,IDA 的 xref 看不到调用者。caller 在 BR 之前怎么设置 X11/X13/X15 的值,涉及 ADRP+ADD+ADD 链或者从 struct 字段加载,需要逐 caller 推断——但 caller 是谁,我们根本不知道(因为 BR 间接跳)。

这是 OLLVM CFF + MBA 之外的另一种反静态手段:寄存器混淆。让关键常量不出现在指令立即数里,而是从某个寄存器传进来,这个寄存器在更早的某个调用方设置,IDA 的数据流分析需要“地址常量作为指令立即数”才能起作用,寄存器传递的常量它推不动。

这种混淆比 OLLVM CFF + MBA 都更狠。代码混淆(CFF + MBA)对付的是“理解”,但代数学一拳打回原形;寄存器混淆对付的是“信息可达性”——你看到 LDR Xn, [X11],你知道是从某个地址 load,但你根本不知道 X11 是什么——它从外部寄存器传进来,而外部又来自更外部。要破得跑模拟,跑模拟需要完整 CFG,完整 CFG 已经被前面的 CFF 砍碎。层层嵌套之后,静态分析就是不行

如果有越狱设备 + frida,破这个 30 秒搞定:hook sub_2E5E8 入口,dump 5 个源指针的值,96 字节 cipher 全部跑出来。Frida 脚本大概 10 行就够:

Interceptor.attach(Module.findBaseAddress("JiDe.dylib").add(0x2E5E8), {
  onEnter(args) {
    // ARM64 standard: a2=X1, a3=X2, a4=X3, a5=X4
    // 加上自定义 X11/X13/X15
    const x11 = this.context.x11;
    const x13 = this.context.x13;
    const x15 = this.context.x15;
    console.log("X11:", hexdump(x11, {length: 32}));
    console.log("X13:", hexdump(x13, {length: 32}));
    console.log("X15:", hexdump(x15, {length: 32}));
    // ... a2-a5 同理
  }
});

但本样本无越狱设备无 frida,这 96 字节是静态分析的硬边界。dylib 的整体反封号机制理解已经 95%+ 完整。

12. 最终的反封号机制全图

所有材料拼起来,这个 dylib 的反封号攻击对应微信的 6 层防御,每一层都布了对口攻击点:

微信反作弊的 6 层防御                     dylib 的 6 层攻击
───────────────────────────────────────────────────────────
① 环境信任检测                             让所有“是否越狱/被注入”返回 NO
   MMRuntimeIntegrity                       isJailbrokenDevice → NO
                                            isEnvironmentTrusted → YES
                                            auditLoadedImages → 跳过

② 签名 / 完整性校验                        让所有“签名/Hash 校验”返回通过
   MMSecurityPolicyValidator                verifyCodeSignature: → YES
   CUtility                                 validateCertChain: → trusted
                                            getAppBundleSignatureHash → 原版值
                                            integrityHash → SHA-256("") 伪值

③ 反篡改监控                               让监控功能空转
   MMAntiTamperGuard                        startMonitoring → no-op
                                            refreshPolicyStore → no-op

④ 事件上报通道                             让所有“风险事件上报”无声失败
   MMTokenService                           flushSecurityEvents → return
                                            reportCrashLog:type: → nullsub_1
                                            reportDeviceToken: → return
                                            refreshTokenWithPolicy: → return

⑤ A/B 配置开关(★ 最优雅)                 从源头让微信“自己决定”不上报
   ExptService                              getBoolExpt:(@"clicfg_*_dc_dc_report")
                                            → NO,根本不调用 ④

⑥ 状态字典 + 核心动作                       擦除已检出痕迹 + 改加好友参数
   NSDictionary 状态                        removeObjectForKey:(@"jb_indicator")
   MMContext                                startVerifyContact:opcode: → 改参数

里面第 ⑤ 层的设计最值得一说。直觉的做法是 ④:hook 上报函数本身,让它静默。但作者额外做了 ⑤:hook A/B 配置,让上层 if 直接走 false 分支。为什么要冗余两层?

只 nullify ④ 的代价:微信代码里 if ([MMRuntimeIntegrity isEnvironmentTrusted] == NO) { [self flushSecurityEvents]; } 这种调用链,nullify 拦不住 if 之前的检测,服务端可以从其他接口反查“环境检测是否触发”。hook ⑤ 的优势:让微信自己决定不调用上报。客户端代码路径 100% 走“不需要上报”分支,调用栈干净,寄存器状态、函数耗时都“看起来”是合规客户端,服务端没有任何“客户端被 hook”的反向指纹。

更微妙的是 ④ 跟 ⑤ 的冗余配合。如果只 hook ⑤ 不 hook ④,极端情况下 A/B 配置可能被服务端覆盖(微信下发新配置强制开启 clicfg_*_dc_dc_report_enable = YES),这时候上层 if 又会调进 ④,如果 ④ 没 hook 就漏掉一次上报。两层都 hook,无论 A/B 配置怎么变,上报永远到不了服务器。这是工程上的纵深防御思维,作者不是单点打洞,而是布多层防线。

13. 没做完的事

36 个 BDD 包装的 newImp 内部参数改写细节。我只确认了 SEL/Class 对应关系,具体 hook 替换函数里怎么改 startVerifyContact:opcode:verifyMsg: 的参数还没深入。这部分静态分析能给出的信息有限,主要是因为 newImp 本身也是 BDD 节点(36/38 个 newImp 末尾是 BR Xn 不是 RET),里面的逻辑分散在 BDD 链路上,不在单个函数里。

sub_2E5E8 的 96 字节静态死角。无越狱设备无 frida 的硬边界。

sub_F584 这第二个 dyld init 函数没深挖__init_offsets 段里有两个入口,sub_4000 是主入口(0xE00 大栈帧那个),sub_F584 是第二个,大约 1296 字节,结构跟 sub_4000 类似(也在栈上构建 mini-table)。它的 BDD 入口不同,可能挂载的是另一组 hook,也可能是某种 fallback。

sub_B50C 这个 dyld_register_func_for_add_image 回调的语义。1740 字节,在栈上构建巨型表,既是 dyld 回调又是 BDD 节点。它的真正用途是“等微信主二进制加载完之后再触发某些 hook”,这是 Substrate tweak 的标准延迟 hook 模式。我确认了它的入口结构,把它构建的指针表 off_47358 落盘到 data/off_47358_table.csv,其中 is_shared_subexpr=True 的条目就是 ROBDD 的共享子表达式——同一个判断节点被多个上游路径复用。这跟主 BDD(sub_4000 的尾调用链)是两套独立机制,但风格一致,都是 OBDD 优化。

14. 数据交付

完整数据落在附件,样本估计搞不出来了。这里汇总一下完整目录结构:

lib/                                        总 19 个脚本,~3000 行
├── cff_step1_x20map.py              2.3 KB  X20 偏移映射重建
├── cff_step2_scan_trampolines.py    6.0 KB  trampoline 三元组提取
├── cff_step3_v3_full_descriptor.py  7.2 KB  完整描述符(CSET 谓词 + 栈槽)
├── cff_step4_unflatten.py           6.2 KB  BDD 反扁平化
├── cff_step5_subF584_probe.py       4.1 KB  第二个 dyld init 探测
├── cff_step6_extract_hooks.py       5.9 KB  hook 注册函数提取
├── cff_step7_substrate_analyze.py   4.5 KB  Substrate 框架识别
├── cff_step8_substrate_callers.py   3.0 KB  thunk 反向 caller 追踪
├── cff_step9_globals_probe.py       2.5 KB  全局变量探测
├── cff_step10_classify_newimps.py   2.0 KB  newImp BDD/真函数分类
├── cff_step11_nullify_callers.py    2.0 KB  nullify hook 识别
├── cff_step12-16_find_dec*.py               dref 扫描找 master(5 版迭代)
├── cff_step17_apply_dec_168C4.py   11.6 KB  第一版手工 lambda 列表
├── cff_step18_export_all.py        27   KB  批量导出聚合
├── cff_step19_parse_decomp_to_schedule.py  3.5 KB  通用 8-MBA parser
├── cff_step20-23_run_*.py                   批量跑 16 个 master
├── cff_step24_merge_all_strings.py  5.1 KB  字符串合并去重
├── cff_step25_join_hooks_strings.py 5.7 KB  hook 跟字符串关联
└── cff_step26_run_2E5E8_adrl.py             sub_2E5E8 静态可见部分

data/
├── x20_map.json                    5.2 KB   202 条 X20 偏移 → mini-table
├── trampolines.json               67   KB   223 个完整描述符
├── trampolines_compact.json       31   KB   紧凑版(int 而非 hex 字符串)
├── errors.json                    335  B    0 错误占位(审计用)
├── edges.csv                      17   KB   446 条 BDD 边
├── cff_bdd.dot                    33   KB   GraphViz dot 文件
├── unflatten_summary.json          2.4 KB   74 入口 / 53 出口 / 149 内部
├── unflattened_bdd.txt             4.7 MB   反扁平化 if-else 树
├── decryptor_candidates.csv        1.2 KB   15 个解密器候选
├── master_decryptors.csv          348  B    17 个 master(含 5 对孪生)
├── dec_168C4_schedule.csv         12   KB   sub_168C4 121 行解密 schedule
├── dec_168C4_schedule.json        33   KB   同上 JSON
├── dec_summary.json                2.2 KB   解密总体摘要
├── decrypted_strings.csv           1.4 KB   第一批 9 段明文(sub_168C4)
├── all_decrypted_strings.csv       4.4 KB   ★ 66 个独立明文 / 981 字节
├── all_decrypted_strings.json     10   KB   同上 JSON
├── off_47358_table.csv             1.1 KB   sub_B50C OBDD 共享子表达式表
├── off_47358_table.json            4.7 KB   同上 JSON
├── globals_probe.json              1.9 KB   MBA seed keys + atomic flags
├── hooks_extracted.json           39   KB   167 hook 注册 site 原始信息
├── all_hooks_decrypted.csv        63   KB   ★ 167 hook 完整关联表
├── all_hooks_decrypted.json      283   KB   同上 JSON,带详细 refs 链
├── nullify_hooks.csv               1.1 KB   ★ 9 个 nullify hook
├── nullify_hooks.json              2.3 KB   同上 JSON
└── newimp_classification.csv       1.8 KB   ★ 38 newImp 分类(36 BDD + 2 真函数)

本文为看雪论坛精华文章,由 qqqiu 原创。

如果你想深入了解更多关于逆向工程或底层安全分析的原理与案例,可以访问 云栈社区的安全/渗透/逆向版块




上一篇:任天堂九年磨一剑:《朋友收集:梦想生活》如何成为终极圈内梗制造机
下一篇:运维事件恢复的7条实战法则:从定界隔离到事后复盘完全指南
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-5-26 03:21 , Processed in 0.625671 second(s), 41 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表