符号执行基础理论:符号变量与具体值、路径约束与求解器、执行树与状态空间

好,咱们进入正题。符号执行这东西,说白了就是让程序「带着问号跑」。你想想看,平时我们调试程序,都是给具体的输入值,比如传个整数 5,或者字符串 "admin"。但符号执行不一样——它给程序一个符号变量,比如 α,然后看程序怎么处理这个未知数。

我个人习惯把符号执行比作「侦探破案」。具体执行是看结果,符号执行是看过程。嗯,咱们一个一个来拆解。

符号变量与具体值

先搞清楚最基础的概念:符号变量和具体值有什么区别?

具体值,就是实实在在的数字、字符串。比如 int x = 10;,这个 x 就是具体值。你给它加 1,结果一定是 11。

符号变量呢?它是个「占位符」。比如 int x = α;,这个 α 代表「任意可能的整数」。你给它加 1,结果就是 α+1——还是一个符号表达式。

我在项目中遇到过一种情况:用具体值测试时,某个漏洞死活复现不出来。后来换成符号执行,才发现那个漏洞需要输入满足一个非常复杂的条件——比如 a > 100 && b < 50 && a + b == 200。用具体值去猜?累死你。符号变量直接帮你把条件列出来,交给求解器去算。

核心区别一句话:

  • 具体值:已知的、确定的数值。程序跑一遍,结果唯一。
  • 符号变量:未知的、抽象的符号。程序跑一遍,结果是一堆表达式。

举个简单的代码例子:

int foo(int x) {  // x 是具体值还是符号变量?
    int y = x * 2;
    if (y > 100) {
        return 1;
    } else {
        return 0;
    }
}

如果 x 是具体值,比如 x=60,那 y=120,走 true 分支,返回 1。如果 x 是符号变量 α,那 y=2α,路径条件就是 2α > 100 或 2α <= 100。求解器会告诉你:α > 50 时走 true,α <= 50 时走 false。

我的小技巧:刚开始学符号执行时,可以把符号变量想象成「代数里的 x」。你解方程时不会把 x 换成具体数字,对吧?符号执行也一样——保持变量为符号,直到最后让求解器去算。

路径约束与求解器

好,现在程序带着符号变量跑起来了。每遇到一个分支,就会产生一个「路径约束」。路径约束就是程序走到当前这条路径必须满足的条件集合。

我曾经踩过一个坑:以为路径约束就是简单的 if 条件。其实不是。路径约束是累积的。你想想看,程序经过多个 if 之后,路径约束是所有分支条件的「与」组合。

比如这段代码:

void check(int a, int b) {
    if (a > 0) {
        // 路径约束1: a > 0
        if (b < 10) {
            // 路径约束2: a > 0 && b < 10
            if (a + b == 50) {
                // 路径约束3: a > 0 && b < 10 && a + b == 50
                crash();  // 目标路径
            }
        }
    }
}

要触发 crash(),必须同时满足三个条件。这就是路径约束的威力——它把多个条件串联起来,形成一个逻辑公式。

那求解器是干嘛的?说白了,求解器就是「解方程的工具」。你把路径约束扔给它,它告诉你:有没有解?如果有,具体值是什么?

求解器类型 能解什么 常见工具
SMT 求解器 整数、实数、位向量、数组、字符串 Z3, CVC4, Yices
SAT 求解器 布尔逻辑公式 MiniSat, Glucose
约束求解器 特定领域的约束(如路径条件) STP, Boolector

注意:求解器不是万能的。遇到非线性运算(比如乘法、除法、三角函数),求解器可能算不出来,或者算得很慢。我遇到过最头疼的情况是:路径约束里有个 a * b == 100,Z3 直接超时。后来改成位向量表示才搞定。

嗯,这里要注意:路径约束和求解器是符号执行的「心脏」。没有求解器,符号变量就只是一堆没用的符号。没有路径约束,求解器也不知道该解什么方程。

执行树与状态空间

最后聊聊执行树和状态空间。这两个概念其实是一体两面。

执行树:程序所有可能执行路径的树形表示。每个节点代表一个程序状态(比如当前执行到哪一行、变量值是什么),每条边代表一次执行步骤(比如赋值、分支)。

状态空间:所有可能程序状态的集合。说白了,就是执行树里所有节点的集合。

为什么要把这两个概念拎出来?因为符号执行本质上就是在「遍历执行树」或「探索状态空间」。

我刚开始做符号执行时,以为就是把所有路径都跑一遍。后来发现——天呐,路径数量是指数级增长的!每多一个 if,路径数就翻倍。10 个 if 就是 1024 条路径,20 个 if 就是 100 万条。这还只是理论值,实际程序里循环、递归更可怕。

举个例子:

void example(int n) {
    int x = 0;
    for (int i = 0; i < n; i++) {
        if (x % 2 == 0) {
            x = x + 1;
        } else {
            x = x * 2;
        }
    }
}

这个循环每迭代一次,就有两个分支。如果 n=10,路径数就是 2^10 = 1024。如果 n=20,就是 100 多万条。符号执行要处理这么多路径?不现实。

所以实际中怎么搞?

  • 路径剪枝:如果某条路径的约束已经无解(比如 a > 0 && a < 0),直接扔掉,不往下探索。
  • 状态合并:把多个相似的状态合并成一个。比如两个状态只有变量值不同,但路径约束一样,可以合并。
  • 启发式搜索:不遍历所有路径,而是优先探索「看起来有戏」的路径(比如离目标代码近的路径)。

我个人习惯用「深度优先 + 路径剪枝」的组合。先沿着一条路径走到黑,如果发现约束无解,就回溯。这样内存占用小,适合找特定漏洞。

但要注意:深度优先可能漏掉某些路径。如果你在做「全覆盖测试」,那最好用广度优先或随机搜索。

避坑指南:我曾经在一个 CTF 题目里用符号执行找漏洞,结果跑了 3 个小时没出结果。后来发现是循环次数太大,状态空间爆炸了。解决办法是「循环展开」——把循环手动展开成有限次,或者设置最大迭代次数。嗯,从那以后我写符号执行脚本时,都会先估算一下状态空间的大小。

总结一下今天的内容:

  • 符号变量 vs 具体值:符号变量是「未知数」,具体值是「已知数」。符号执行用符号变量代替输入,让程序跑出表达式。
  • 路径约束与求解器:路径约束是程序走到某条路径必须满足的条件。求解器负责解这些条件,给出具体输入值。
  • 执行树与状态空间:执行树是所有路径的树形表示,状态空间是所有状态的集合。实际中要避免状态爆炸,用剪枝、合并、启发式搜索来优化。

下节课咱们会讲「符号执行引擎的架构」,到时候我会拿一个真实的开源工具来拆解。你想想看,一个符号执行引擎到底是怎么把代码变成约束,再交给求解器的?嗯,到时候见分晓。