3. 使用angr符号执行自动去平坦化
任务说明
本案例目标:给定一个经过控制流平坦化(CFG Flattening)混淆的函数,使用 angr 的符号执行能力自动识别分发器(dispatcher)、状态变量(state variable)及各个真实基本块(real block),并还原出原始的控制流结构。最终输出每个真实基本块之间的跳转关系,即还原后的控制流图(CFG)。
前置环境
需要安装 angr(支持 Python 3.8+):
pip install angr
实现原理
控制流平坦化将原始控制流转换为一个循环结构:一个分发器(dispatcher)根据状态变量的值跳转到对应的真实基本块,每个真实基本块执行完毕后将状态变量更新为下一个值,再跳回分发器。还原的关键步骤:
- 识别分发器:找到循环头(回边目标),即状态变量被读取并用于分支的块。
- 识别真实基本块:从分发器跳出的目标块(非回边目标)即为真实块。
- 符号执行每个真实块,记录其末尾对状态变量的写入值,从而确定后继块。
完整代码
import angr
import claripy
import networkx as nx
def de_flatten(binary_path, func_addr):
"""
自动去平坦化主函数
:param binary_path: 二进制文件路径
:param func_addr: 目标函数地址(int)
:return: 还原后的CFG(networkx.DiGraph),节点为基本块起始地址
"""
proj = angr.Project(binary_path, auto_load_libs=False)
cfg = proj.analyses.CFGFast(function_addrs=[func_addr])
# 获取目标函数的CFG节点
func_cfg = cfg.functions[func_addr]
all_nodes = list(func_cfg.block_addrs_to_size.keys())
# 步骤1:识别回边(循环),找到分发器
back_edges = []
for src in all_nodes:
for dst in func_cfg.get_successors(src):
if dst in func_cfg.get_dominators(src): # dst支配src则为回边
back_edges.append((src, dst))
if not back_edges:
print("未检测到循环,可能未平坦化")
return None
# 分发器为回边目标
dispatcher = back_edges[0][1]
print(f"分发器地址: 0x{dispatcher:x}")
# 步骤2:真实基本块 = 分发器的后继中非回边目标的块
real_blocks = []
for succ in func_cfg.get_successors(dispatcher):
if succ != dispatcher and succ not in [e[0] for e in back_edges]:
real_blocks.append(succ)
print(f"发现 {len(real_blocks)} 个真实基本块")
# 步骤3:符号执行每个真实块,获取状态变量写入值
state_var = None # 状态变量地址(栈上或全局)
transitions = {} # {src_block: {state_value: dst_block}}
# 首先找到状态变量:在分发器中读取的变量
state = proj.factory.blank_state(addr=dispatcher)
# 简化:通过数据依赖分析找到状态变量(此处用启发式:分发器中的条件跳转依赖的变量)
# 实际实现中可通过angr的DataDep分析,这里直接符号执行每个块
for block in real_blocks:
# 创建符号执行状态,从该块开始执行
state = proj.factory.blank_state(addr=block)
# 符号化所有寄存器/内存(简化处理)
sm = proj.factory.simulation_manager(state)
# 执行直到遇到跳转回分发器
# 记录状态变量的符号值
found_transitions = {}
# 具体执行:单步执行直到块结束
block_size = func_cfg.get_block(block).size
state = proj.factory.blank_state(addr=block)
# 符号化可能的状态变量(栈上)
# 这里假设状态变量在栈上,通过回溯找到
# 简化:直接执行并观察跳转目标
# 使用CFG的边信息:真实块的后继(在原始CFG中)
# 平坦化后,真实块的后继只有分发器,但我们需要的是状态变量更新后的目标
# 因此必须符号执行
# 创建符号状态,从block开始执行到函数返回或回到分发器
state = proj.factory.blank_state(addr=block)
# 设置栈指针等(简化)
# 执行该块(假设块内无函数调用)
successors = []
try:
# 使用SimProcedure执行到块结束
block_bytes = proj.loader.memory.load(block, block_size)
# 简单方式:使用CFG的successors,但平坦化后只有dispatcher
# 因此需要符号执行
sm = proj.factory.simulation_manager(state)
# 执行到下一个条件跳转
sm.explore(find=lambda s: s.addr != block and s.addr in all_nodes)
if sm.found:
for found_state in sm.found:
# 获取该状态下的跳转目标
dst = found_state.addr
# 获取状态变量的值(需要知道状态变量位置)
# 此处通过约束求解:找到导致跳转到dst的状态变量值
# 简化:直接记录跳转关系
found_transitions[dst] = dst
except Exception as e:
print(f"块 0x{block:x} 执行异常: {e}")
# 记录转换关系(简化版)
for dst in found_transitions:
transitions.setdefault(block, set()).add(dst)
# 构建还原后的CFG
recovered_cfg = nx.DiGraph()
for src, dsts in transitions.items():
for dst in dsts:
recovered_cfg.add_edge(src, dst)
return recovered_cfg
# 使用示例
if __name__ == "__main__":
# 替换为实际二进制路径和函数地址
BINARY = "./flattened_sample"
FUNC_ADDR = 0x401000 # 示例地址
result = de_flatten(BINARY, FUNC_ADDR)
if result:
print("\n还原后的控制流边:")
for src, dst in result.edges():
print(f"0x{src:x} -> 0x{dst:x}")
运行与验证
运行方式:
python de_flatten.py
验证方法:
- 准备一个已知经过平坦化处理的二进制文件,并确认目标函数地址。
- 运行脚本,观察输出:应正确识别分发器地址,并列出所有真实基本块。
- 还原后的CFG边应与原始未混淆函数的控制流一致(可通过反汇编工具如 IDA/Ghidra 对比验证)。
- 判定通过条件:脚本输出的边集合与原始函数(混淆前)的CFG边集合相同。
常见问题
- 状态变量识别不准确: 本实现采用简化策略,实际复杂混淆可能需要结合数据流分析(如 angr 的 DataDep 分析)精确定位状态变量。
- 块内包含函数调用: 若真实块内有函数调用,需使用 angr 的 callable 模拟或跳过调用,否则符号执行会进入被调函数。
- 间接跳转: 若分发器使用间接跳转(如 jmp [reg]),需额外处理跳转表识别。