逆天而行 2025-11-01 18:00 上海
看雪论坛作者ID:逆天而行
羽扇纶巾,谈笑间,小小混淆樯橹灰飞烟灭。
相信每个逆向萌新在刚接触加固App时,都曾有过下面这个“痛的领悟”:兴致勃勃地把样本拖进 IDA,准备大展拳脚,结果……
F5一按,满屏的
while乱舞,控制流图(CFG)扭曲得像一碗打翻的意大利面。脑海里只剩下一句话:“卧槽,这还分析个毛线!”
没错,这就是我们今天的主角——控制流平坦化(Control Flow Flattening, FLA)。
市面上不乏 d8、jeb 等强大的反混淆工具,它们能一键还原大部分标准 OLLVM 混淆。但用别人的轮子,爽则爽矣,总觉得隔靴搔痒。正如上篇文章所言:不造一次轮子,怎能体会轮子里的精髓?
所以,本着“我学会 = 大家学会”的开源精神,我决定写下这篇“施工笔记”。让我们一起,亲手揭开 OLLVM-FLA 的神秘面纱,看透它背后的设计哲学。
FLA 混淆
一、FLA 的核心组件解剖
经过一番资料翻阅和实战分析,我为大家总结了一套“傻瓜式”的 FLA 结构图鉴。任何复杂的 FLA 函数,都可以看作是以下几种“零件”的组合:
任何像意面一样打结的 FLA 函数,都能拆成这几类部件(AArch64 口径):
◆序言块(Prologue):入口,初始化状态或栈框架。
◆预分发块(Pre-Dispatcher):真实块的汇合点;入度通常最高。
◆主分发器(Master Dispatcher):预分发块的唯一后继;按state分发。
◆真实块(Real Blocks):承载业务语义;执行完多半回到预分发块形成循环。
◆返回块(Return Block):正常收束的终点,可视为特殊真实块。
◆子分发器(Sub-Dispatcher):多级/嵌套调度时的次级分发节点。
◆虚假块(Bogus Blocks):假计算 + 无条件跳转,用来搅局。
◆连接块(Connector Blocks):相邻真实块之间的过渡边,工程上单列方便重连。
一句话:先锚“预分发”,再锁“主分发”,真实块就是它的前驱们。其他多半是陪跑。
快速识别技巧
掌握了组件定义,识别它们就跟给小动物分类一样简单,抓住关键特征即可:
1.序言块:函数的第一个块,无可争议的Entry。
2.返回块:没有后继(Successors)的块,执行到这就结束了。
3.预分发块:拥有**最多前驱(Predecessors)**的块,因为几乎所有真实块都指向它。
4.主分发块:通常是预分发块的唯一后继,负责做switch跳转。
5.真实块:所有指向预分发块的那些前驱块。
6.虚假块:剩下的都是。
一句话总结:以“预分发块”为突破口,先抓大佬(分发器),再揪小弟(真实块),剩下的杂鱼(虚假块)直接忽略。
二、拨乱反正:如何重建原始控制流?
识别出所有真实块后,下一个问题是:它们原本的邻里关系是怎样的?
思路其实非常朴素:“骗”程序自己跑一遍,我们在一旁做记录。唯一的难点在于如何处理分支。
我们来看一个典型的真实块结尾:
; 伪代码示例
LDRB W8, [X19,W24,SXTW] ; 从某处加载一个值
MOV W9, #0x1F4E8494
CMP W8, #0 ; 比较
MOV W8, #0x4FA089C5
CSEL W9, W9, W8, EQ ; 如果相等(EQ),W9 = W8,否则 W9 = W9
B loc_pre_dispatcher ; 跳转到预分发块这个块的state变量(可能存储在W9)有两个可能的值,对应了if-else的两条路径。我们只要能控制这个选择,就能探索所有可能的执行路径。
目前主流的两种自动化方案:
◆模拟执行 (Emulation) - 如 Unicorn Engine
◆符号执行 (Symbolic Execution) - 如 angr
我个人更偏爱用 angr 做快速实验,因为它能把我们从繁琐的分支处理中解放出来,专注于逻辑本身。
三、魔改 FLA 的思路
说实话,原版 OLLVM-FLA 在当今各种商业级加固面前,已经有些不够看了。d8、jeb 等工具的去混淆脚本早已对它了如指掌。
如果我们是防御方,就要思考如何让它变得更“毒辣”。以下是我的一些“魔改”思路,部分已实践:
1.破坏“真实块 -> 预分发块”的固定模式
◆骚操作:在真实块和预分发块之间插入一个或多个无意义的“中间块”,形成真实块 -> 中间块 -> ... -> 预分发块的跳转链。
◆杀伤力:直接破坏了“预分发块的前驱都是真实块”这一强特征,让依赖此特征的自动化脚本当场懵逼。
◆实践效果:我测试过这个思路,d8 和 jeb 的通用脚本确实都失效了,效果拔群!
2.制造多个返回块
◆骚操作:正常函数只有一个逻辑终点。如果我通过不同的state值,让函数可以从五六个不同的代码块return呢?
◆杀伤力:许多分析工具都基于“单入口,单出口”的假设。多出口会让CFG分析和路径探索变得异常复杂。
3.序言块迷宫
◆骚操作:在真正的序言块之后,不直接进入主分发器,而是先经过一连串由虚假块组成的“迷宫”,兜兜转转再回到主循环。
◆杀伤力:干扰分析者对函数主体的快速定位。
4.多级/嵌套分发器
◆骚操作:将一个大的switch拆成多个小的、嵌套的switch。例如,主分发器决定跳转到A或B,而A本身又是一个子分发器,内部维护自己的一套state和代码块。
◆杀伤力:这会形成多层状态机,简单的单层state跟踪脚本会直接“跑飞”,分析难度指数级上升。
BR混淆
BR混淆原理
这招堪称OLLVM的“当家花旦”,杀伤力极大,专治各种不服。
通俗的讲,它把原本“明明白白的条件跳转”(b.eq/ne/...)给拆了,改写成先把分支结果编码到一个状态变量,再通过一个像“中央车站”一样的统一调度器(Dispatcher)+ 间接跳转(br xN/跳表)去落到对应的基本块。你看,所有人都得先去车站报到,然后再决定去哪,这下IDA可就懵了,因为它画不出清晰的路线图了。
混淆前(邻家小妹,清纯可人):
arm
; 简单的 if-else
cmp w8, #0
b.ne loc_then ; 如果 w8 != 0, 跳转到 then 代码块
b loc_else ; 否则,跳转到 else 代码块BR 混淆后(浓妆艳抹,六亲不认):
arm
; 状态选择
cmpw8, #0
cselw9, #0xDEADBEEF, #0xCAFEF00D, ne ; 条件成立选一个“魔法数”,否则选另一个
strw9, [sp, #0x10] ; 写入状态变量(可在栈/全局/寄存器)
bdispatcher ; 去“中央车站”报到
; -------------------------
dispatcher:
ldrw9, [sp, #0x10] ; 读取状态
; ...一堆真假块的计算...
adrx10, jumptable ; 寻址跳表
ldrx11, [x10, w9, lsl #3] ; 根据状态计算,取出真实目标地址
brx11 ; 间接跳转,起飞!x86/x64上也是一个道理,常见把jcc换成cmovcc/ 计算索引,再jmp [table+idx*8]。原理很简单,就是在CPU执行的那一刻,我们这些静态分析的凡人才知道它到底要去哪。
破局之法:符号执行的降维打击
解决思路无非两条路:
1.模拟执行:用Unicorn或者DBI框架(如Frida)跑起来,在dispatcher那里下断点,打印出寄存器的值,再手动修回去。这叫“体力活”,适合硬汉,但我们是儒将,讲究一个“雅”字。
2.符号执行:让机器自己去算!我们只需要告诉它:“老铁,从这儿开始跑,把所有可能的分支都给我算出来!” 这就是约束求解的魅力。
我个人是angr的铁粉,这种场景简直是为它量身定做的。 talk is cheap,直接上代码。
假设我们有这么一段被混淆的函数,我们想知道在dispatcher之后,它到底会跳到哪两个地方去。
python
运行
import angr
import logging
logging.getLogger('angr').setLevel('ERROR')
elf_path = "path/to/your/binary"
fun_addr = 0x401000 # 混淆函数的起始地址
dispatcher_addr = 0x401080 # dispatcher 的地址
proj = angr.Project(elf_path, load_options={'auto_load_libs': False})
state = proj.factory.entry_state(addr=fun_addr)
simgr = proj.factory.simulation_manager(state)
print(f"[*] 开始探索函数 0x{fun_addr:x}...")
# 让 angr 跑到 dispatcher 的位置
simgr.explore(find=dispatcher_addr)
if not simgr.found:
print(f"[!] 没能跑到 dispatcher at 0x{dispatcher_addr:x},检查下地址?")
else:
found_state = simgr.found[0]
print(f"[*] 成功抵达 dispatcher!当前状态:{found_state}")
# 从 dispatcher 开始,继续单步执行,直到发生分支
successors = found_state.step()
# 再次步进,穿过复杂的计算,直到 br 指令
while len(successors.successors) == 1:
successors = successors.successors[0].step()
if len(successors.successors) > 1:
print("
[+] 抓到你了!发现分支:")
for i, succ_state in enumerate(successors.successors):
branch_target = succ_state.addr
# 我们可以让 angr 帮我们求解出触发这个分支的条件
condition = succ_state.solver.constraints
print(f" - 分支 {i+1}: 跳转到 -> 0x{branch_target:x}")
# print(f" 触发条件: {condition}") # 如果需要,可以打印具体约束
print("
[*] 搞定!这两个地址就是你要找的真实块。现在可以去写patch脚本了。")
else:
print("[!] 奇怪,dispatcher 之后没有发现分支,可能混淆逻辑更复杂。")看到没?我们全程没去看那些乱七八糟的计算,angr就像一个开了上帝视角的玩家,直接告诉我们最终的两个落点。拿到了这两个地址,无论是用KeyStone写个patch脚本把b dispatcher改成b.ne loc_real_A; b loc_real_B,还是在IDA里手动修复,那都是手到擒来。
指令替换(SUB)
SUB混淆原理
这招就更“文”一点了,它不搞结构,专搞你的脑子。它会把简单的算术运算,用一堆看起来复杂但结果等价的指令序列来替换。
代数等价的“七十二变”:
◆a - b ≡ a + (-b)(这太基础了)
◆-b ≡ ~b + 1(补码的基本原理,但写成汇编就有点唬人)
◆a - b ≡ (a + c) - (b + c)(c可以是任意常量,甚至可以是从某个不透明谓词算出来的)
◆a - (b + k) ≡ (a - b) - k
◆a - (b - k) ≡ (a - b) + k
◆a + b ≡ a - (~b + 1)(加法变减法)
这还只是冰山一角。它就像一个魔术师,在你眼前一通操作,你以为他变了个大象出来,其实他只是把手里的兔子换了个颜色。
破解之道:让“专业工具”做专业的事
我个人觉得,跟这玩意儿斗智斗勇,性价比不高。为什么?因为现代化的逆向工具已经越来越“卷”了。
1. 相信IDA,相信JEB现在的IDA(比如9.2之后的版本)和JEB,它们的反编译器已经集成了相当强大的代数化简引擎。很多时候,你看着汇编是一坨屎山,切到反编译窗口一看:return a - b;。 那一刻,你会感动得流下泪来,想给Hex-Rays的工程师们磕一个。
[图片:展示一段混淆的SUB汇编,和旁边IDA F5后清爽的C代码的对比图]
2. 终极武器:代码IR化 + 优化器当然,如果真遇到连IDA都“消化不良”的硬骨头,那我也有一点小小的思路,咱们可以上“核武器”。
这个思路就是:将目标机器码提升(lift)到中间表示(IR),然后调用强大的编译器优化器去“盘”它。
LLVM本身就是靠优化器吃饭的,那我们为什么不“以其人之道,还治其人之身”呢?
流程大概是这样:二进制指令 -> VEX IR / LLVM IR -> 运行一系列优化Pass(如常量折叠、代数化简) -> 优化后的IR -> 反编译回C代码
angr本身就干了第一步,它把机器码转成了VEX IR。我们可以写脚本遍历VEX IR,做一些模式匹配和替换。更高级的玩法是使用rev.ng或mcsema这类工具,它们能把二进制完整地翻译成LLVM IR,然后你就可以用法力无边的LLVM优化Pass去处理了。这就好比请了编译器大神来帮你做逆向,专业对口了属于是。
实战测试
FLA篇
FLA 的混淆还原 我觉得就两步:
1.定位到真实块
2.还原真实块的关系
就这两步,基本就完成了。 所以我编写了一个 ida 脚本 辅助还原
这个是arm64架构下的ollvm fla混淆,如图,很多标准的结构已经不对了。
先用插件 上个色 对任意基本块 鼠标右键 选择插件,设置该函数。
点击插件的寻找结构 可以辅助你找一些真实块,当然这个不一定准确。
可以看见 大部分结构还是对应出来了,但是被llvm 优化后 有部分 真实块是没有被上色的,这时候就可以人工的灵活添加上,把一些不是真实块的删除。
当然这些操作 你也可以直接 对 脚本的编辑框进行操作。
经过设置插件编辑框内容:
真实块:[0x2390C0, 0x2390F4, 0x239148]
探测块:[0x2390C0, 0x2390F4, 0x239124, 0x239134, 0x239148, 0x239158]
这里为啥有个探测块? 考虑到灵活性 有的真实块 是连接在一起。
这两个块都是 真实块,又刚好是 真实块的后继。所以这里的填写的真实块就是需要修改后继的真实块,(不需要添加序言),探测块就是真正的真实块,还要把返回块加进去,到时候才能匹配上。
然后点击处理 fla 等一会儿,就再目标文件下生成了一个patch文件了 ,就很简单完美还原了。
BR混淆篇
思路就是:对指定的汇编 约束求解 看又啥值? 一般两个 又或者 一个 然后aptch 修改就搞定!
样本:
F5只能看到一个
这里利用约束求解 到指定br的值
def symbolic_execution(project, Br_addr, start_addr, hook_addrs=None, modify_value=None, inspect=False):
def retn_procedure(state):
"""hook 用:遇到 call/bl 直接‘ret’,防止路径跑飞到库/外部"""
ip = state.solver.eval(state.regs.ip)
project.unhook(ip)
return
def statement_inspect(state):
expressions = list(
state.scratch.irsb.statements[state.inspect.statement].expressions)
if len(expressions) != 0 and isinstance(expressions[0], pyvex.expr.ITE):
state.scratch.temps[expressions[0].cond.tmp] = modify_value
state.inspect._breakpoints['statement'] = []
if hook_addrs is not None:
skip_length = 4 # ARM/ARM64 指令 4 字节
for hook_addr in hook_addrs:
project.hook(hook_addr, retn_procedure, length=skip_length)
state = project.factory.blank_state(addr=start_addr, remove_options={
angr.sim_options.LAZY_SOLVES})
if inspect:
state.inspect.b(
'statement', when=angr.state_plugins.inspect.BP_BEFORE, action=statement_inspect)
sm = project.factory.simulation_manager(state)
sm.explore(find=Br_addr)
if len(sm.found) > 0:
found = sm.found[0]
return found.solver.eval(found.regs.x8)
return None # 未命中得到结果 patch 即可
sub 的还原没啥可说的,用d8,ida 9.2 等等 基本可以还原。 想要实践可以试试 llvm 优化ir 的方式。
作者的话
代码思路参考过很多项目:
https://bbs.kanxue.com/thread-285968.htm
https://bbs.kanxue.com/thread-288598.htm
https://bbs.kanxue.com/thread-286256.htm
https://bbs.kanxue.com/thread-287262.htm
https://bbs.kanxue.com/thread-286549.htm
等等 很多道友的文章。
我将fla 的还原制作为了ida的插件,br的还原只是脚本(不想写ida插件了 哈哈哈) ,当然都上传到我的github上面了
https://github.com/NiTianErXing666/-AndroidReverse/tree/main/OLLVM
代码写的很烂,思路可行,愿君斟酌。
总结
总结成四句话,送给读者当作一页小抄:
◆锚定预分发:入度最高的块,找到它就找到核心。
◆收集真实块:它的前驱就是业务逻辑的碎片。
◆重建邻接:骗程序自己跑,把真实路径记下来。
◆抓住 BR:盯住br xN,看清它要跳向何处。
如此一来,曾经乱舞的while、缠成意面的 CFG,就会在羽扇轻摇、谈笑间化作规整的直线。
樯橹灰飞烟灭,不过是多看一眼预分发,多盯一刻跳表的事。
愿你下次再遇见 OLLVM,能心里暗笑一句:“小小混淆,终究不过烟火。”
本文仅用于技术研究、教学与自有样本的安全分析。请勿用于未授权的目标或任何非法用途。涉及商业加固样本时,请遵守当地法律与授权协议。
*本文为看雪论坛优秀文章,由 逆天而行原创,转载请注明来自看雪社区
球分享
球点赞
球在看
点击阅读原文查看更多
