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 执行树:程序的所有可能性

执行树,就是把程序所有可能的执行路径画成一棵树。

根节点是程序入口。每个条件分支产生两个子节点。每条从根到叶子的路径,对应一组特定的输入值和路径约束。

我画了一张图,帮你理解这个结构:

符号执行树示例 入口 if(x>0) if(x>0) α > 0 α ≤ 0 y=1 y=2 y=3 y=4 结束1 结束2 结束3 结束4 每条路径对应一组路径约束:{α>0, ...} → 求解器可反推出具体输入

执行树的大小,直接决定了符号执行的效率。混淆器最常用的手段就是制造「路径爆炸」——让这棵树长得枝繁叶茂,直到你的内存撑爆。

3.4 符号执行引擎工作原理

引擎是怎么工作的?我拆成四个步骤来讲:

  1. 符号化输入:把程序输入(比如文件内容、网络包、命令行参数)标记为符号变量。
  2. 符号解释执行:模拟执行程序,所有运算都用符号表达式表示。维护一个「符号状态」——包括寄存器、内存、栈的符号值。
  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崩溃的混淆技巧,在符号执行面前有多脆弱。


专注资料整理