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 的执行流程,我总结为两步:

  1. 具体执行阶段:用具体的输入值(比如整数 0、字符串 "abc")去执行程序。记录下每一条语句执行后的具体变量值。
  2. 符号收集阶段:在具体执行的同时,把涉及输入变量的操作转换成符号表达式。遇到分支时,记录下分支条件对应的符号约束。

举个例子,看下面这段代码:

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 来分析:

  1. 初始输入:比如 "aaaaaa"(6个a)。
  2. 具体执行:第一个字符 'a' 和 's' 比较,不相等,直接返回 0。
  3. 符号收集:分支条件 input[0] != 's' 为真,记录路径条件。
  4. 取反:input[0] == 's',求解器给出新输入 "saaaaa"。
  5. 再次执行:第一个字符匹配,第二个字符 'a' 和 'e' 比较,不相等。
  6. 继续取反、求解、执行……直到找到 "secret"。

你看,Concolic Testing 就像一把手术刀,精准地沿着路径条件切下去,一步步逼近目标。

总结:Concolic Testing 的核心价值在于,它用具体执行解决了符号执行的「不可落地」问题,同时保留了符号分析对路径的「引导能力」。这是它成为漏洞挖掘利器的根本原因。

下一章,我们会深入 Concolic Testing 的工程实现,看看 KLEE 和 Angr 这些工具是怎么把理论变成代码的。到时候我会分享一些我在实际项目中踩过的坑,嗯,保证让你少走弯路。