OLLVM 是一类实现的统称:凡在官方 LLVM 上叠加混淆 Pass 的都算。它源自瑞士 HEIG-VD 的 obfuscator-llvm,提出了指令替换(-sub)、虚假控制流(-bcf)、控制流平坦化(-fla)三种经典 Pass。原始项目停在 LLVM 4.0,后来的实现多是移植到新版 LLVM,或加上字符串加密、间接跳转等新手段。
本文不研究这些 Pass 怎么实现,本文以1万6千字详细记录了面对不同 Pass 时如何落地一套还原思路。ok准备发车

OLLVM 是一类实现而非固定工具,差异主要有四处:
一是底座 LLVM 版本,从 4 到 17 不等,现在能用的多是移植到新版的社区分支;二是 Pass Manager,旧版 Legacy PM、新版 New PM,注册和开关传递方式不同;三是开关命名,同样是平坦化,有的叫 -fla,有的叫 -irobf-cff;四是 Pass 种类,除三件套外还可能带字符串加密、间接跳转等,比如上海交大 GoSSIP 的 Armariris 就去掉了 BCF、加了字符串加密。
本文选 LLVM 16.0.6 作底座,配 wwh1004 维护的 ollvm-16,能编现代代码,开关沿用经典的 -sub / -bcf / -fla。
获取LVM 16 底座与 ollvm-16 混淆 Pass
将混淆 Pass 目录拷入 LLVM 的 lib 目录,并在构建系统中包含它
确认文件与配置到位
不报 unknown argument 即为成功:
写入 rc4.c:
配置编译所需的环境变量
编译四个对照样本:
下面是伪代码图:

分析阶段使用两个工具:D-810 与 GAMBA。前者是 IDA 插件,在反编译时基于微码进行去混淆;后者是独立的命令行 MBA 简化工具,用于处理 D-810 无法覆盖的残留表达式。
D-810 依赖 Hex-Rays 反编译器,要求 IDA 7.5 及以上;本文使用的 d810-ng 分支要求 IDA 9 与 Python 3.10 及以上。首先安装 Z3 求解器:
从 26eK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6%4x3o6m8@1P5X3g2F1K9r3g2A6L8h3g2J5i4K6u0r3k6o6R3I4x3q4)9J5k6r3&6Y4 获取源码,解压后将其放入 IDA 的 plugins 目录。
0d6K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8D9j5h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6W2M7$3S2S2M7X3c8Q4x3V1k6V1z5o6p5H3i4@1f1#2i4K6S2q4i4K6W2r3i4@1f1%4i4K6R3&6i4K6R3^5

重启ida在插件内就可以找到D810了 打开后长这样

关于配置的介绍可以去看目录下的介绍

GAMBA 为纯 Python 项目,从 48bK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6p5k6h3&6#2N6X3!0e0L8$3k6@1N6$3q4J5k6g2y4G2L8s2g2@1K9h3!0F1M7#2)9J5c8V1N6m8e0f1u0m8 获取并解压。其依赖为 NumPy(必需)与 Z3(仅在使用等价性验证时需要,前一步已安装):
安装完成后执行冒烟测试:

指令替换(instruction substitution)的核心手法是:把简单的算术或逻辑运算 替换成一组在数值上完全等价、但形式更冗长晦涩的表达式 运算结果一模一样 只是写法被故意复杂化 让反编译结果难以一眼看懂原本的语义 替换出来的东西通常表现为 MBA(Mixed Boolean-Arithmetic,混合布尔-算术)表达式——即把普通算术和位运算混在一起。
下面是混淆前与混淆后的对比

经 -sub 编译后,反编译结果的运算被替换为等价但晦涩的形式

D-810 基于规则匹配工作,其能力取决于规则库的覆盖范围。本例中仍有两处表达式未被还原,分别位于反编译结果的第 44、45 行:
前者是嵌入数组下标中的取字节操作,后者是加密行的 MBA 形式。这两处交由 GAMBA 处理。
我不想手工去拆 费力不讨好 还容易拆错 不如直接交给ai去做 将GAMBA 官方 README丢给ai 然后写入如下提示词

经简单测试 AI会出现位宽和括号数量不对等的情况 需要人工简单看一下 GLM和deepseek可以很好的遵循指令生成正确的命令 但是偶尔也需要自己看一下
得到命令如下

至此就得到了简化后的最终表达式 可以将规则写入d810中供其识别 也可以更简单直接打个注释就行 省的麻烦
虚假控制流(bogus control flow)的核心手法是:用一个恒真或恒假的不透明谓词(opaque predicate),往原本线性的代码里塞进永远不会走(或永远会走)的虚假分支,让控制流图膨胀,人眼和反编译器都被迫去分析大量根本不影响结果的路径。
下面是混淆前后的对比
可以明显的看到相比原版的流程图新增了多条分支

那么面对bcf又该怎么处理呢
根据目前网上大佬们公开的方案有以下几种
这个最简单粗暴 选择bogus_loops.json后F5刷新

对照前后,D-810 精确地消除了三处 BCF 虚假结构
第一处,KSA 初始化循环。混淆前的 if (谓词) LABEL_18: v12[i]=i; 加 if (谓词) goto LABEL_18; 这一对不透明谓词分支,还原后彻底消失,坍缩回最干净的 for(i=0;i<256;++i) v12[i]=i;。
第二处,第二个循环里的空 while (谓词) ; 死循环被删除,for(j=0;;++j) 配 if(j>=256) break; 的畸形写法恢复成正常的 for(j=0; j<256; ++j)。
第三处,PRGA 前那个 do{...}while(谓词) 伪循环被展平成顺序的初始化语句,while(v5 < v13) 也恢复成标准的 for(k=0; k<v13; ++k)。
改指令的适用场景是"谓词变量分布零散、或不在 .bss、不方便整段改数据"时才用。ARM64 下改指令成本高,一般优先改数据。
第一步:识别谓词变量
谓词 = 一个条件表达式 就是 if(...) 或 while(...) 括号里那坨判断真假的东西
BCF 往代码里插进去一个看起来像正常判断、实际上结果永远不变的条件 这个永远不变的条件叫不透明谓词
当前样本长这样
括号里 x * (x - 1) % 2u && y >= 10 就是谓词。
怎么判断他是假 通过表达式代入值简单计算就可以知道
逻辑与得两边都成立才为真 看左边的表达式x * (x - 1) % 2u x设为5 带入式子中得到 5 * (5 - 1) % 2u 也就是5*4%2 = 0。
x 和 x-1 是连续两个整数 必有一个偶数 积必为偶 偶数%2 恒为 0 由此整个谓词恒为假
第二步:定位 + 判断
双击x或y跳转过去看看他在哪个段

判断的标准是看当前段是否除了x、y还有第三个陌生的合法变量 如果没有就可以一刀切 全部patch为一个固定的值
当前段从开始到结束都没有第三个变量所以可以直接patch的
第三步 设只读
鼠标选中段地址后打开菜单的编辑段

将权限设置为只读

第四步 patch
更改字节

写入


回到函数F5刷新一下就自动消除了 核心是依赖于IDA 的死代码消除 (DCE, Dead Code Elimination)

第一步 分析哪些指令需要修改:
对x变量按下x快捷键查看其交叉引用
发现 x 是走 GOT,不是直接 LDR。逐条分析:

type列的o是offset r是read
o类的三条不用管
重点看r类 第一个 rc4_crypt+14 LDR X9, [X9,#x_ptr@PAGEOFF] ; x ← 第一级:从GOT取x的地址到X9
+14是先从GOT把x的地址装进寄存器,后面六条才是真正读x的值。把这六条改成MOV w8,#0,这样谓词独到的x就是恒为0了
脚本可以让ai帮写
运行结果

angr 是底层框架,deflat 是基于 angr 写的脚本。deflat 的作用是用符号执行去除 OLLVM 混淆,分两个脚本,一个去虚假控制流(BCF),一个去控制流平坦化(FLA);整体思路是用符号执行"跑一遍"程序,看它实际会走哪些路,再把没走到的混淆代码抹掉、把真实的跳转关系补回去。
下载地址:03cK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6U0M7e0j5%4y4o6x3#2x3o6f1J5z5g2)9J5c8X3c8W2k6X3I4S2N6l9`.`.
下载后安装一下依赖
我们使用bogus_control_flow的debogus脚本来处理样本bcf
建议跑一下下面的脚本查一下angr 的加载基址 当前项目是0x400000
在ida中找到函数的地址

在bogus_control_flow目录下中运行命令

通过时间可以不难发现 跑的奇慢无比 主要原因是因为 这个函数里有循环 循环次数依赖一个符号值(比如 key 长度 data_len 或索引) angr 不知道这个值多大 只能把每种可能都当成一条路径去跑 路径数量指数级膨胀 越跑越慢 解决起来也简单
打开debogus 找到这段:
把它整段替换成:
改的就是给 blank_state 多加一个 add_options 参数 里面放两个零填充选项 作用是:未初始化的内存和寄存器一律填 0 而不是用符号变量 这样那些 warning 会消失 循环里也不会因为符号值产生一堆分支导致卡死
修改保存后再跑一遍
肥肠之快

效果如下

简单观察发现 少了一大截代码
for ( j = 0; ; ++j )变成了死循环 整个 PRGA 部分都没了 将前面的代码还原回去直接跑 静待结果(bushi 绕回来了 抄近路失败)


目前这条路我走不通 跑一整天可能都跑不完 感兴趣的兄弟可以去试试 欢迎大佬补充指点一下
来源:69cK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6%4N6%4N6Q4x3X3g2S2M7X3!0U0L8h3q4Y4i4K6u0W2j5$3&6Q4x3V1k6S2j5Y4y4Q4x3V1j5J5x3o6t1J5i4K6u0W2x3o6c8Q4x3X3f1H3x3e0f1%4
简单说说一下论文的核心思路
动态跑起来、hook 每条指令、识别不透明谓词、把恒真/恒假分支改成无条件跳转
OLLVM的BCF用的经典不透明谓词是(x*(x-1)%2)==0(奇偶相乘必得偶数,恒等于0永真)当前样本这段模式反复出现
关键特征就是x和y是两个全局变量 他们存在.bss里 程序运行期间从不被写入 这个就是污点源
所以判定一个基本块是不是虚假块 最可靠的动态方式就是监控x/y全局内存的读取 凡是这条条件跳转的判定值直接或间接来自x/y内存 就是不透名谓词分支
哪个地址在整个运行过程中 被读之前从没有被写过 他就是可疑的污点源
Qiling 基于 Unicorn 纯 Python装起来很简单
Qiling 模拟 ELF 需要一个 rootfs(里面放对应架构的动态库) 官方仓库自带 arm64 的 rootfs
API文档:6d9K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6V1L8$3y4K6i4K6u0W2M7h3W2D9K9h3&6Y4i4K6u0W2K9h3!0Q4x3V1k6W2L8W2)9J5c8X3I4S2N6r3g2K6N6q4)9J5c8X3S2G2N6%4c8G2i4K6u0r3

只运行so的rc4_crypt一个函数 跑完得到正确的RC4密文
so没有入口 指定从哪个地址开始到哪个地址结束就行 开始非常好找
开始地址:0x1648

结束地址呢 1A80?这是 IDA 标注的函数字节范围终点
程序是顺着执行流跑的 不是从头顺着地址跑到尾
看返回块
RET 一执行 控制权就交回调用者 函数就结束了 0x1A58 就是运行意义上的终点
那么除了人肉去找有没有更加优雅的思路
有的有的 兄弟有的有的 既然都上动态调试了 那还说啥了 都是兄弟

函数返回时会执行 RET RET 干的事是:把 X30(返回地址寄存器)里的值装进 PC 程序跳回调用者
x30里面的返回地址是可以设置成一个现实中不可能是代码的地址作为标记 比如说0x0 那么
函数正常跑 跑到任意一个RET PC就会跳到0x0
然后hook指令 一旦发现PC跳到了0x0 就可以知道函数刚返回了 而上一条执行的指令地址就是那个RET 也就是出口
这样不管函数有几个RET 出口在哪里 都不需要手动去找 程序从哪里返回 就抓到哪里
那么 怎么抓上一条指令
hook每条指令 回调里能拿到当前指令地址 用一个变量 last_addr 记住上一条指令的地址 当发现当前PC == 标记地址(0x0)的时候 last_addr就是ret的地址
但是这里有个小细节 PC跳到0x0的时候 0x0处压根没有合法代码 qiling会因取指失败停下或者报错
更干净的做法就是不等他跳过去 而是在ret指令本身执行之前就识别他
识别方法:反汇编当前指令 如果助记符是ret 那当前地址就是出口 记录下来并自动停止
这个方法更直接 不依赖标记地址 也不会触发非法取指
那么要是有多个RET呢?
这个套路其实也管用 因为程序一次运行只会经过其中一个ret 走到哪个ret就返回 其他的ret这次没有走到
所以 用当前这组输入跑一次 抓到的是这条路径的出口
换不同的输入再跑 可能抓到别的RET 抓到别的出口
把多次运行抓到的出口收集起来 就是这个函数的全部出口合集
运行结果:


结果一模一样 函数成功运行 也得到了ret只有一个 地址是0x1a58
x 在偏移 0x3CA0 y 在 0x3CA4 各 4 字节 而且它们是 EXPORT(导出符号)名字就叫 x、y
这是最简单的情况:污点源地址 = base + 0x3CA0(x)和 base + 0x3CA4(y),直接从 IDA 抄偏移就行

当前样本比较简单 那么要是.bss有程序真正的全局变量(比如说某个计数器、缓冲区)
不能把整个.bss都当作污染源 否则会把真实变量也染脏导致真跳转被误判成假跳转
区分的办法有两个
其一 就是用符号去判断 当前这个样本就是个例子 x、y有导出符号 直接按照名字定位 其他的变量不管 适用于符号没有被去掉的情况
其二 靠只读不写的特征 对整个.bss都监控 跑一遍 凡是被读之前从没有被写过 且参与了条件跳转运算的 就是污染源
用只读特征验证它们确实是污点源 全程没被写

从x/y触发 追踪经过的寄存器和标志位 跑完后对每个条件跳转能判断 依据的值是脏(假跳转)还是净(真跳转)并且按照地址去重统计
这里踩了好几个坑
第一个 x/y通过GOT间接引用 qiling没做重定位 读取出来的地址是0
序言用ADPR+LDR从GOT槽中取出x/y的地址 而qiling加载so的时候没有取填这个槽 导致ldr w8,[x8]里 x8=0 最终污点注入失败 全程0脏
解决的方案就是在序言存入地址进栈的两条str x9,[sp,...]执行之前 直接把x9覆盖写成正确的x/y的地址 这样绕开GOT 在数据流关键点注入正确的地址
坑二:qiling自带的反汇编器 detail未开 insn.operands 为空 自己建 capstone 实例并显式 md.detail = True

污点状态:维护两样 tainted_regs(脏寄存器集合)、tainted_flags(NZCV 是否脏)ARM64 条件跳转靠 NZCV
三条传播规则
规则一(注入):从x/y读值 目标寄存器变脏
规则二(传播/洗白):源有脏则目标脏 源全净则目标被洗白(干净值覆盖)
规则三(标志位):写 NZCV 的指令源脏则 NZCV 脏 读 NZCV 的指令(CSET 等)NZCV 脏则目标脏
w8 和 x8 是同一物理寄存器 归一化成 x8 判断 load 是否读 x/y:解析内存操作数 基址寄存器当前值加位移得实际地址 看是否落在 x/y 范围
输出如下:
6假3真

对每条判定为假的跳转 记录他实际总是跳到哪个地址 得到假跳地址->固定目标地址的映射表 作为patch的依据
不透明谓词恒真或者恒假 所以一条假跳转每次执行都往同一个方向走 只需要观察他执行时的实际行为:跳了 就记录跳转目标 没跳 就记录顺序执行的下一条地址 多次执行验证方向唯一 确认后存进映射表
具体 tbnz w8,#0, #target 如果这次判定条件成立(跳转发生) 下一条执行的指令地址就是target 不成立则是当前指令的下一条(off+4)
怎么拿到下一条实际执行的地址?
在指令hook里 记住上一条指令是不是待观察的假跳转 等下一条指令进hook的时候 他的地址就是假跳转的实际去向 用一个pending变量传递

把假跳转->固定目标 翻译成具体的字节修改方案 每条假跳转指令改成无条件跳转到固定目标 只计算不写文件
一条假跳转 tbnz w8, #0, #target 已知他每次都会去real_target 那么就把他替换成B real_target 这样执行流直达真实后继
不再经过不透明谓词判断 死分支自然不可达
ARM64的B指令是相对跳转 编码规则:0x14000000 | ((offset/4) & 0x03FFFFFF) 其中offset=目标地址-当前指令地址(字节) 必须4字节对其
把假跳转原地转换成同长度的B 既保持布局又达到无条件跳到真实后继的效果 原来的死分支块无人跳转 成为不可达代码 可以再用NOP填充
新增函数:
make_branch 按 B 指令编码规则算出 4 字节 这里用的是函数内偏移(off、target 都是相对 base 的偏移) 因为 B 是相对跳转 偏移之差与 base 无关 算出来的编码可直接写进文件对应偏移
build_patch_plan 把每条假跳转映射成新指令字节 得到的 plan 是文件偏移 → 4 字节的字典 后面patch的时候用得到

首先就是文件偏移不等于RVA 运行时用的是RVA(base+偏移) 但是写入文件的时候必须要用文件偏移 两者通过ELF的PT_LOAD段换算 公式为: 文件偏移 = 段文件偏移+(RVA-段虚拟地址) 不换算直接拿RVA写入文件会导致字节写错位置(这个坑研究半天)
用 keystone 汇编 不手算机器码 b #{rel} 直接生成正确的 4 字节 负偏移(往回跳)的补码由它处理
patch_so.py:


控制流平坦化(FLA)将函数原本有序的基本块打散,交由一个中央分发器根据状态变量的值逐个调度执行,从而摧毁原始控制流结构
下面是混淆前后的对比图

序言:函数唯一入口,执行原始的栈帧构建等准备工作,并把状态变量初始化为第一个真实块对应的值,然后进入分发器。
分发器:一个 while(true) 套 switch(state) 的循环,反复读状态变量,把执行权派发给对应真实块,原有块间跳转全被它接管。
真实块:程序真正的业务逻辑所在,被拆成互不相连的孤岛,末尾不跳向下一块,只负责算出下一个状态值并交回调度。
预处理器:真实块与分发器之间的汇聚点,收拢所有真实块出口,统一完成状态变量更新,再无条件跳回分发器,闭合循环。
return块:状态变量到达约定终止值时才被派发,负责恢复栈帧并返回,是唯一不跳回分发器、真正终结函数的出口。

传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!!
最后于 4小时前
被北袅编辑
,原因: