4、动态符号执行核心概念:Concolic Testing 的由来与原理
好,我们进入第四章。
前几章我们聊了符号执行的基础,也聊了它为什么在实际工程中「跑不动」。说白了,纯符号执行就像在脑子里模拟整个程序宇宙,变量全是符号,路径条件一多,求解器直接罢工。
那怎么办?
学术界和工业界想了个折中的办法——Concolic Testing。这个名字是 Concolic = Concrete + Symbolic 的合成词。我个人觉得,这是符号执行从理论走向实战最关键的一步。
4.1 为什么需要 Concolic?
纯符号执行有个硬伤:遇到系统调用、浮点运算、或者复杂的指针操作时,约束求解器会变得非常慢,甚至直接报错。
我在项目中遇到过这样一个场景:分析一个网络协议栈的解析函数,里面有个循环,循环次数取决于报文长度。纯符号执行下,求解器要处理几十个分支条件,跑了半小时还没出结果。
嗯,这显然不现实。
Concolic Testing 的思路很简单:用真实的具体值去跑程序,同时用符号值去记录路径条件。你想想看,这样既避免了求解器处理所有复杂逻辑,又能通过符号约束来探索新路径。
核心思想:Concolic Testing 是一种混合执行技术。它同时维护两个状态——具体状态(Concrete State)和符号状态(Symbolic State)。程序用具体值执行,但路径条件用符号表达式记录。
4.2 Concolic Testing 的由来
这个概念最早是由 Patrice Godefroid 等人在 2005 年提出的,论文叫《DART: Directed Automated Random Testing》。
DART 的思路很直接:先随机生成一些输入,让程序跑起来。跑的过程中,记录下每条分支的路径条件。然后取反其中某个条件,生成新的输入,去探索没走过的分支。
说白了,就是用具体执行来引导符号分析。
后来,KLEE、Angr、S2E 这些工具都采用了类似的思想。我个人习惯把 Concolic Testing 看作是「带导航的随机测试」——随机测试是瞎跑,Concolic 是有目的地跑。
4.3 工作原理:两步走
Concolic Testing 的执行流程,我总结为两步:
- 具体执行阶段:用具体的输入值(比如整数 0、字符串 "abc")去执行程序。记录下每一条语句执行后的具体变量值。
- 符号收集阶段:在具体执行的同时,把涉及输入变量的操作转换成符号表达式。遇到分支时,记录下分支条件对应的符号约束。
举个例子,看下面这段代码:
int foo(int x, int y) {
int z = x + y;
if (z > 10) {
return 1;
} else {
return 0;
}
}
假设我们第一次用具体输入 x=3, y=2 去执行:
- 具体执行:z = 5,5 > 10 为假,走 else 分支,返回 0。
- 符号收集:z 的符号表达式是 x + y,分支条件是 (x + y) > 10。
- 记录路径条件:
(x + y) <= 10(因为走的是 else 分支)。
然后,我们取反这个条件:(x + y) > 10。求解器会给出一个新输入,比如 x=8, y=5。用这个新输入再跑一次,就能覆盖到 if 分支。
避坑指南:我曾经在分析一个二进制程序时,发现 Concolic 执行总是卡在某个循环里。后来排查发现,是因为循环条件依赖于一个全局变量,而我没有把全局变量也符号化。记住:所有可能影响路径的输入源,都要考虑符号化。
4.4 具体执行与符号执行的交互
Concolic Testing 最巧妙的地方,在于它如何处理具体值和符号值的交互。
当程序遇到一个操作,其中既有具体值又有符号值时:
- 具体值直接参与运算,保证程序能正常跑下去。
- 符号值被传播,形成新的符号表达式。
举个例子:
int a = sym_input(); // a 是符号值
int b = 5; // b 是具体值
int c = a + b; // c 是符号值,表达式为 a + 5
if (c > 20) { ... } // 分支条件为 a + 5 > 20
这里 b 是具体值 5,但 a 是符号值。c 的结果虽然是具体值(比如 a=10 时 c=15),但它的符号表达式是 a+5。分支条件也是基于符号表达式记录的。
你想想看,如果纯符号执行,遇到 a+b 这种操作,求解器要处理 a+5 这个表达式,没问题。但如果 b 是一个复杂的结构体指针,纯符号执行就头疼了。Concolic 直接用具体值去解引用指针,完美避开这个坑。
4.5 路径探索策略
Concolic Testing 的路径探索,本质上是一个深度优先搜索过程。但实际工程中,我们不会傻傻地一条路走到黑。
常用的策略有:
| 策略名称 | 描述 | 适用场景 |
|---|---|---|
| DFS(深度优先) | 每次取反最近的一条分支条件 | 路径深度较浅的程序 |
| BFS(广度优先) | 优先探索靠近入口的分支 | 需要覆盖所有表层逻辑 |
| 随机策略 | 随机选择一条分支取反 | 大规模程序,避免陷入局部 |
| 覆盖率引导 | 优先探索能覆盖新代码块的分支 | 追求高覆盖率时 |
我个人习惯在分析漏洞时,先用 DFS 快速深入,再用覆盖率引导策略做全面覆盖。这样既能找到深层漏洞,又不会漏掉表层逻辑。
注意:Concolic Testing 不是万能的。遇到以下情况,它也会失效:
- 路径爆炸:程序分支太多,探索不完。
- 约束求解超时:非线性约束、浮点运算等。
- 环境交互:依赖外部文件、网络、硬件等。
我曾经在一个驱动分析项目中,Concolic 执行到第 3 层循环就炸了——路径数量呈指数增长。最后只能手动加一些剪枝条件,才勉强跑完。
4.6 与纯符号执行的对比
为了让你更直观地理解,我列个对比表:
| 特性 | 纯符号执行 | Concolic Testing |
|---|---|---|
| 执行方式 | 完全在符号层面模拟 | 具体值执行 + 符号收集 |
| 路径探索 | 理论上可覆盖所有路径 | 受限于具体输入,逐步探索 |
| 求解器压力 | 高,所有约束都要求解 | 低,只求解路径条件 |
| 处理复杂逻辑 | 困难(系统调用、指针等) | 容易(直接用具体值) |
| 实际可用性 | 低,主要用于小规模程序 | 高,可用于真实软件 |
说白了,纯符号执行是「理想主义者」,Concolic Testing 是「实用主义者」。在漏洞挖掘这个领域,实用主义往往更有效。
4.7 一个完整的 Concolic 执行示例
我们来看一个稍微复杂点的例子,模拟一个简单的密码校验函数:
int check_password(char *input) {
char *correct = "secret";
int i = 0;
while (input[i] != '\0' && correct[i] != '\0') {
if (input[i] != correct[i]) {
return 0; // 密码错误
}
i++;
}
if (input[i] == '\0' && correct[i] == '\0') {
return 1; // 密码正确
}
return 0;
}
用 Concolic Testing 来分析:
- 初始输入:比如 "aaaaaa"(6个a)。
- 具体执行:第一个字符 'a' 和 's' 比较,不相等,直接返回 0。
- 符号收集:分支条件
input[0] != 's'为真,记录路径条件。 - 取反:
input[0] == 's',求解器给出新输入 "saaaaa"。 - 再次执行:第一个字符匹配,第二个字符 'a' 和 'e' 比较,不相等。
- 继续取反、求解、执行……直到找到 "secret"。
你看,Concolic Testing 就像一把手术刀,精准地沿着路径条件切下去,一步步逼近目标。
总结:Concolic Testing 的核心价值在于,它用具体执行解决了符号执行的「不可落地」问题,同时保留了符号分析对路径的「引导能力」。这是它成为漏洞挖掘利器的根本原因。
下一章,我们会深入 Concolic Testing 的工程实现,看看 KLEE 和 Angr 这些工具是怎么把理论变成代码的。到时候我会分享一些我在实际项目中踩过的坑,嗯,保证让你少走弯路。