3、符号执行入门:符号值、路径约束、执行树、符号执行引擎工作原理
好,咱们今天聊点硬核的——符号执行。
说实话,我第一次接触这个概念是在分析一个被严重混淆的Android Native层函数。那时候IDA F5出来的伪代码,简直像一锅粥。后来我导师丢给我一句话:「试试符号执行」。嗯,就是这句话,让我打开了新世界的大门。
符号执行是什么?说白了,它不把变量当成具体数值,而是当成一个「符号」。就像数学里的x、y,你不知道它具体是多少,但你可以带着它做运算、列方程。
3.1 符号值:从具体到抽象
传统调试器里,你设个断点,看eax寄存器等于0x12345678。这是具体值。
符号执行里,eax被表示成一个符号——比如α。它代表「某个未知的整数」。程序怎么操作eax,我们就怎么操作α。加法、减法、异或,全都用符号表达式记录下来。
举个例子:
int a = input(); // a = α
int b = a * 2; // b = α * 2
int c = b + 1; // c = α * 2 + 1
if (c == 100) { // 路径约束:α * 2 + 1 == 100
// 成功路径
}
你看,a、b、c都不是具体数字,而是符号表达式。最后那个if条件,变成了一个约束方程。
我个人习惯把符号值理解成「带标签的代数式」。每个符号都有一个唯一ID,引擎会维护一张映射表,记录每个变量当前对应的符号表达式。
3.2 路径约束:程序的分叉路口
程序执行到条件分支时,会发生什么?
真实执行:CPU根据标志位跳转,走其中一条路。
符号执行:两条路都走。但每条路都有一个「路径约束」——也就是走到这条路必须满足的条件。
比如上面那个if语句:
- 真分支的路径约束:α * 2 + 1 == 100
- 假分支的路径约束:α * 2 + 1 != 100
我在项目中遇到过最头疼的情况:混淆器会在循环里插入大量无意义的分支,导致路径约束爆炸。你想想看,一个循环跑100次,每次产生2条路径,那就是2^100条路径——神仙也跑不完。
避坑指南:我曾经在一个CTF题目里卡了三天,就是因为没注意到路径约束里有个隐含的整数溢出条件。符号执行引擎默认用无限精度整数,但真实CPU是32位或64位。一定要手动设置位宽,否则求解器给出的输入值可能在真实环境下跑不通。
3.3 执行树:程序的所有可能性
执行树,就是把程序所有可能的执行路径画成一棵树。
根节点是程序入口。每个条件分支产生两个子节点。每条从根到叶子的路径,对应一组特定的输入值和路径约束。
我画了一张图,帮你理解这个结构:
执行树的大小,直接决定了符号执行的效率。混淆器最常用的手段就是制造「路径爆炸」——让这棵树长得枝繁叶茂,直到你的内存撑爆。
3.4 符号执行引擎工作原理
引擎是怎么工作的?我拆成四个步骤来讲:
- 符号化输入:把程序输入(比如文件内容、网络包、命令行参数)标记为符号变量。
- 符号解释执行:模拟执行程序,所有运算都用符号表达式表示。维护一个「符号状态」——包括寄存器、内存、栈的符号值。
- 路径约束收集:每遇到一个条件分支,把分支条件加入当前路径的约束集合。
- 约束求解:用SMT求解器(比如Z3)检查路径约束是否可满足。如果可以,反推出具体的输入值。
核心流程伪代码:
function symbolic_execute(program, input_symbols):
state = {pc: 0, regs: {}, mem: {}, constraints: []}
while state.pc < program.length:
inst = program[state.pc]
if inst is BRANCH:
cond = symbolic_eval(inst.condition, state)
# 分叉:真分支
state_true = clone(state)
state_true.constraints.append(cond)
state_true.pc = inst.target_true
worklist.push(state_true)
# 分叉:假分支
state_false = clone(state)
state_false.constraints.append(NOT(cond))
state_false.pc = inst.target_false
worklist.push(state_false)
else:
state = execute_inst(inst, state)
state.pc += 1
# 到达终点的路径,用求解器检查
for each terminal_state in worklist:
if solver.check(terminal_state.constraints) == SAT:
return solver.get_model() # 返回具体输入
嗯,这里要注意:实际引擎远比这个复杂。比如内存地址如果是符号值,你得处理「符号地址访问」——这涉及到内存建模,是个大坑。
我记得有一次分析一个VMProtect变种,它的混淆器把控制流扁平化了,所有基本块通过一个分发器跳转。传统静态分析根本看不清逻辑。但符号执行不一样——它不关心控制流长什么样,只关心路径约束是否可满足。最终我通过约束求解,直接拿到了触发特定行为的输入值。
性能陷阱:符号执行不是银弹。遇到以下情况,引擎会非常吃力:
- 循环次数依赖符号值(路径爆炸)
- 复杂的浮点运算(SMT求解器对浮点支持较弱)
- 系统调用、外部库函数(需要手动建模)
- 自修改代码(符号状态难以维护)
我曾经在一个样本里遇到循环展开+ opaque predicate的组合,符号执行跑了12小时没出结果。后来改用concolic执行(混合具体执行和符号执行),才在10分钟内搞定。
3.5 主流符号执行引擎对比
| 引擎 | 平台 | 特点 | 我的评价 |
|---|---|---|---|
| Angr | 二进制(x86/ARM/MIPS等) | Python API丰富,支持多种后端求解器 | 入门首选,文档全,社区活跃 |
| KLEE | LLVM IR | 针对C/C++源码,路径搜索策略优秀 | 适合分析开源项目,但需要编译成LLVM |
| S2E | 全系统模拟 | 基于QEMU,可分析内核和驱动 | 重型武器,配置复杂,但功能强大 |
| Triton | 二进制(x86/x64) | 动态符号执行,支持taint分析 | 适合做漏洞挖掘,性能不错 |
我个人习惯从Angr入手。它的Python接口很友好,而且内置了CFG恢复、调用约定分析等实用功能。你写个十几行脚本,就能对一个混淆函数做符号执行。
小技巧:用Angr时,记得设置avoid参数跳过那些明显无用的路径(比如异常处理、死循环)。我一般会先静态分析一下,找出所有「看起来不对劲」的基本块,然后告诉引擎:「这些地方别去,浪费时间」。
好了,符号执行的基础就这些。说白了,它就是把程序执行问题转化成约束求解问题。你不需要模拟每条指令的具体效果,只需要维护符号表达式和路径约束,最后交给求解器去算。
下一节我们会深入实战——用符号执行破解一个真实的控制流混淆样本。到时候你会看到,那些让IDA崩溃的混淆技巧,在符号执行面前有多脆弱。