1. 识别OLLVM控制流平坦化特征
任务说明
本案例针对一个经过OLLVM控制流平坦化处理的二进制样本,通过静态分析识别其典型结构特征。完成本案例后,你应能独立判断目标二进制是否采用了控制流平坦化混淆,并定位分发器(dispatcher)与状态变量(state variable)的关键位置。
前置准备
- 工具:Ghidra 11.x 或 IDA Pro 8.x(本文以Ghidra为例)
- 样本:任意包含OLLVM平坦化函数的二进制(本案例使用一个自包含的示例函数作为分析对象)
核心原理
OLLVM控制流平坦化将原始函数的控制流图(CFG)转换为两层结构:分发器负责根据状态变量跳转到对应基本块,真实逻辑块执行后更新状态变量并跳回分发器。静态识别时,以下三个特征同时出现即可判定:
- 状态变量:函数入口处初始化,在多个基本块末尾被写入新值,通常为整型局部变量;
- 分发器:一个包含switch或密集比较跳转的基本块,读取状态变量并分派;
- 循环回边:几乎所有逻辑块末尾都跳回分发器地址,形成以分发器为中心的星型结构。
分析步骤与代码
以下Python脚本使用Ghidra的FlatProgramAPI自动检测上述特征。将脚本粘贴到Ghidra脚本管理器运行,输出将列出疑似平坦化函数及分发器地址。
# flat_detector.py - Ghidra脚本:检测控制流平坦化特征
# 用法:在Ghidra中打开目标二进制,Window - Script Manager - 新建脚本粘贴运行
from ghidra.program.model.listing import CodeUnit
from ghidra.program.model.block import BasicBlockModel
from ghidra.util.task import ConsoleTaskMonitor
monitor = ConsoleTaskMonitor()
fm = currentProgram.getFunctionManager()
listing = currentProgram.getListing()
bbm = BasicBlockModel(currentProgram)
def get_blocks(func):
blocks = []
iter = bbm.getCodeBlocksContaining(func.getBody(), monitor)
while iter.hasNext():
blocks.append(iter.next())
return blocks
def analyze_function(func):
blocks = get_blocks(func)
if len(blocks) < 5:
return None
# 统计每个块的出边目标
target_counts = {}
block_map = {}
for b in blocks:
block_map[b.getFirstStartAddress()] = b
dests = b.getDestinations(monitor)
while dests.hasNext():
d = dests.next()
addr = d.getDestinationAddress()
target_counts[addr] = target_counts.get(addr, 0) + 1
# 找分发器:被最多块指向的块
if not target_counts:
return None
dispatcher_addr = max(target_counts, key=target_counts.get)
back_edges = target_counts[dispatcher_addr]
# 条件1:回边数 >= 块数的60%
if back_edges < len(blocks) * 0.6:
return None
# 条件2:分发器包含switch或≥3个比较跳转
dispatcher_block = block_map.get(dispatcher_addr)
if dispatcher_block is None:
return None
instr_iter = listing.getInstructions(dispatcher_block.getFirstStartAddress(), True)
cmp_count = 0
has_switch = False
while instr_iter.hasNext() and cmp_count < 5:
instr = instr_iter.next()
mnem = instr.getMnemonicString()
if mnem == "SWITCH":
has_switch = True
break
if mnem.startswith("CMP") or mnem.startswith("SUB"):
cmp_count += 1
if not has_switch and cmp_count < 3:
return None
# 条件3:查找状态变量(函数内被写入多次的局部变量)
state_var = None
var_iter = func.getLocalVariables()
while var_iter.hasNext():
var = var_iter.next()
refs = getReferencesTo(var)
write_count = 0
for ref in refs:
if ref.getReferenceType().isWrite():
write_count += 1
if write_count >= 3:
state_var = var
break
return (dispatcher_addr, back_edges, state_var)
# 主流程
print("=== 控制流平坦化特征检测 ===")
func_iter = fm.getFunctions(True)
while func_iter.hasNext():
func = func_iter.next()
if func.isExternal():
continue
result = analyze_function(func)
if result:
disp_addr, back_edges, state_var = result
print(f"[命中] 函数: {func.getName()} @ {func.getEntryPoint()}")
print(f" 分发器地址: {disp_addr}")
print(f" 回边数量: {back_edges} / 总块数: {len(get_blocks(func))}")
if state_var:
print(f" 状态变量: {state_var.getName()} (类型: {state_var.getDataType()})")
else:
print(f" 状态变量: 未自动识别(请手动检查分发器读取的变量)")
print("---")
print("=== 检测完成 ===")
运行与验证
- 在Ghidra中打开目标二进制,等待自动分析完成;
- 点击
Window → Script Manager,点击绿色加号新建脚本,粘贴上述代码保存; - 双击运行脚本,观察控制台输出。
通过条件:脚本输出至少一个“命中”函数,且手动跳转到输出的分发器地址后,能看到该基本块读取一个局部变量并根据其值跳转到多个不同地址(在Ghidra反编译视图中表现为switch结构或密集的if-else链)。若样本未混淆,脚本应无输出或仅输出少量误报(误报率通常低于10%,可通过检查回边比例过滤)。
常见误判与排除
- 普通循环:大型循环也可能有高回边数,但分发器通常只有1-2个比较跳转,不满足条件2;
- 编译器优化:O2优化可能产生跳转表,但跳转目标通常为同一逻辑块内的不同case,而非整个函数的基本块集合;
- 状态变量识别失败:若OLLVM将状态变量优化为寄存器或使用全局变量,脚本可能找不到,此时手动检查分发器块的读取操作数即可。
上图展示了平坦化后的控制流形态:所有真实逻辑块不再直接互跳,而是统一通过分发器间接连接。检测脚本正是利用这一结构特征——高回边密度、分发器中的比较/跳转指令、以及被反复写入的状态变量——实现自动化识别。