符号执行与约束求解:让代码自己“跑”出漏洞

符号执行这东西,我第一次接触是在做固件安全审计的时候。当时有个二进制补丁,逆向分析看得我头大,后来用符号执行工具一跑,直接找到了一个隐藏了五年的缓冲区溢出。嗯,从那天起我就知道,这技术不是花架子。

说白了,符号执行就是让程序在“符号值”上运行,而不是具体的数字。你想想看,传统测试你给个输入,程序走一条路。符号执行不一样,它把所有可能的输入都抽象成符号,程序每遇到一个分支,两条路都走。这样就能覆盖所有执行路径。

符号执行的核心原理

我习惯把符号执行拆成三个部分来理解:

  • 符号化输入:把输入变量变成符号,比如 x → α,y → β
  • 路径约束收集:每经过一个条件分支,就把条件记录下来
  • 约束求解:用求解器检查路径是否可达,生成具体测试用例

举个例子,你看这段代码:

int foo(int x, int y) {
    int z = x + y;
    if (z > 100) {
        // 路径A
        return z * 2;
    } else {
        // 路径B
        return z / 2;
    }
}

传统测试你只能试几个值。符号执行会怎么做?它把 x 记作 α,y 记作 β,z 就是 α+β。走到 if 分支时,路径约束是 α+β > 100;走到 else 分支,约束是 α+β ≤ 100。然后约束求解器一算,就能给出具体的测试数据。

关键点:符号执行不是“猜”输入,而是用数学方式推导出能触发特定路径的输入值。

路径爆炸问题——绕不过去的坎

我在项目中遇到过最头疼的事,就是路径爆炸。一个循环跑100次,每次循环里有个if,那路径数就是2的100次方。这数字有多大?比宇宙中的原子数还多。你不可能全跑完。

为什么会这样?因为符号执行是穷举式的。每个分支点都分裂出两条路径,路径数随分支点指数增长。实际代码里,一个函数几十个分支点太常见了。

怎么解决?我常用的策略有这几个:

策略 做法 代价
路径剪枝 设定路径深度上限,超过就放弃 可能漏掉深层漏洞
符号合并 把多条路径的约束合并成一个公式 精度下降,可能误报
启发式搜索 优先探索“看起来有漏洞”的路径 依赖启发函数质量
混合执行 符号执行 + 具体执行交替进行 实现复杂

我的经验:实际项目中,我一般先用静态分析扫一遍,找出可疑点,然后只对这些点做符号执行。别上来就全量跑,那是给自己找麻烦。

约束求解器——符号执行的“发动机”

符号执行收集了一堆约束,谁来解?约束求解器。我主要用过两个:Z3 和 STP。

Z3——微软出品的全能选手

Z3 是微软研究院开发的,支持位向量、数组、字符串、浮点数……几乎你能想到的类型它都支持。我在做 Linux 内核驱动漏洞挖掘时,用的就是 Z3。它的 API 设计得很干净,C 语言调用起来也不费劲。

// 用 Z3 解一个简单的约束:x + y > 10 且 x < 5
Z3_context ctx = Z3_mk_context(config);
Z3_solver s = Z3_mk_solver(ctx);

Z3_symbol x_sym = Z3_mk_string_symbol(ctx, "x");
Z3_ast x = Z3_mk_const(ctx, x_sym, Z3_mk_int_sort(ctx));
Z3_symbol y_sym = Z3_mk_string_symbol(ctx, "y");
Z3_ast y = Z3_mk_const(ctx, y_sym, Z3_mk_int_sort(ctx));

// x + y > 10
Z3_ast sum = Z3_mk_add(ctx, x, y);
Z3_ast c1 = Z3_mk_gt(ctx, sum, Z3_mk_int(ctx, 10, Z3_mk_int_sort(ctx)));

// x < 5
Z3_ast c2 = Z3_mk_lt(ctx, x, Z3_mk_int(ctx, 5, Z3_mk_int_sort(ctx)));

Z3_solver_assert(ctx, s, c1);
Z3_solver_assert(ctx, s, c2);

Z3_lbool result = Z3_solver_check(ctx, s);
if (result == Z3_L_TRUE) {
    Z3_model model = Z3_solver_get_model(ctx, s);
    // 从 model 中提取 x 和 y 的值
}

STP——轻量级位向量求解器

STP 专注于位向量和数组,速度比 Z3 快不少。如果你只处理整数运算和内存操作,STP 是更好的选择。我记得有一次跑一个网络协议解析器的符号执行,Z3 要 30 秒,STP 只要 8 秒。

不过 STP 的 API 比较简陋,文档也少。我建议新手先从 Z3 入手,等熟悉了约束求解的套路,再考虑 STP。

特性 Z3 STP
支持类型 位向量、数组、字符串、浮点数、实数 位向量、数组
性能 通用,复杂约束较慢 位向量运算极快
API 易用性 优秀,多语言绑定 一般,C 接口为主
社区活跃度 高,文档丰富 低,靠邮件列表

在漏洞挖掘中的应用

符号执行在漏洞挖掘里,主要干三件事:

  1. 生成测试用例:自动构造能触发特定路径的输入
  2. 验证漏洞可达性:静态分析报了个警告,符号执行能告诉你“这个漏洞到底能不能触发”
  3. 绕过保护机制:比如绕过 ASLR,符号执行可以计算出精确的偏移量

我曾经用符号执行挖过一个 HTTP 解析器的漏洞。静态分析报了个空指针解引用,但我不确定是不是误报。用符号执行一跑,约束求解器给出了一个具体的 HTTP 请求包——发过去,服务端直接崩溃。嗯,确认了,不是误报。

注意:符号执行不是银弹。遇到复杂的外部调用(比如系统调用、硬件交互),符号执行就抓瞎了。这时候需要结合具体执行,也就是混合执行(Concolic Testing)。

知识体系总览

下面这张图,是我自己总结的符号执行知识体系。你看一眼,心里就有谱了:

符号执行 符号化输入 变量 → 符号表达式 路径约束收集 分支条件 → 约束集 约束求解 Z3 / STP → 具体值 挑战:路径爆炸 分支数指数增长 → 无法穷举 解决方案:剪枝 / 合并 / 启发式 应用:漏洞挖掘 测试用例生成 / 可达性验证 保护机制绕过 核心思想:用数学代替猜测,用约束代替枚举 符号执行 + 约束求解 = 自动化漏洞发现

这张图把符号执行的三个核心环节串起来了。你从左往右看:先把输入符号化,然后一路走一路收集约束,最后交给求解器算出具体值。路径爆炸是最大的拦路虎,但也不是没办法对付。

我的建议:学符号执行,别一上来就啃理论。找个现成的工具(比如 KLEE、Angr),拿个简单的 C 程序跑一跑。看到工具自动生成测试用例的那一刻,你就明白这东西有多强了。

好了,符号执行的核心内容就这些。记住一句话:符号执行不是魔法,它只是用数学的方式,让代码自己“说”出它的漏洞。

公众号:蓝海资料掘金营,微信 deep3321