求爷爷告奶奶搞来了这个样本,上来一看: 看着就像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)+ 一个
...(已截断)
---
来源: 看雪论坛
原文链接: https://bbs.kanxue.com/thread-290993.htm
纯静态硬啃xx反封号 dylib:OLLVM CFF MBA 全链路反混淆,还原 167 hook 攻防全图(看着吓人
497 浏览
9 回复
感谢分享
666,太强了
感谢分享
感谢分享
感谢分享
感谢分享
tql
这次不逃、不躲,正面解决问题了吗
666学习一下