状态爆炸的数学原理:为什么路径会指数级增长?

做符号执行的人,早晚都会撞上状态爆炸这堵墙。我第一次遇到是在分析一个嵌入式协议栈时——代码才几千行,angr跑了两个小时还没出结果。我当时就纳闷:这玩意儿到底卡在哪了?

后来我仔细研究了状态空间树的结构,才明白问题出在哪。说白了,路径爆炸不是bug,是数学规律在作祟。

状态空间树:每条路径都是一个分支

我们先从最基础的概念说起。angr在执行程序时,每遇到一个条件分支(比如if-else),就会产生两个状态:一个走真分支,一个走假分支。这些状态不断分裂,最终形成一棵树——这就是状态空间树

举个例子,看这段代码:

int x = sym_input();  // 符号输入
if (x > 10) {
    // 分支A
} else {
    // 分支B
}
if (x < 20) {
    // 分支C
} else {
    // 分支D
}

这里有两个条件分支,每个分支产生2个状态。组合起来,总共有2×2=4条路径。你想想看,如果程序里有n个分支,那路径数就是2的n次方。这就是指数增长的雏形。

核心公式:路径总数 = 2分支数

当分支数为10时,路径数=1024;分支数为20时,路径数=1,048,576;分支数为30时,路径数=1,073,741,824。

分支因子:不只是二选一

刚才的例子是二分支,但实际程序里分支因子远不止2。switch-case语句、多态调用、循环展开,都会产生更大的分支因子。

我记得分析过一个网络协议解析器,里面有个switch语句有16个case。每次执行到这里,状态就分裂成16份。再加上外层还有几个if-else嵌套,状态数直接起飞。

更通用的公式是这样的:

状态总数 = ∏(每个分支点的分支数)

其中:
- 分支点:程序中所有可能产生状态分裂的位置
- 分支数:该位置可能的分支数量

举个例子:

分支点类型 分支因子 示例
if-else 2 if (x > 0) { ... } else { ... }
switch-case n(case数量) switch(x) { case 1: ... case 2: ... }
循环(未约束) 无限 while(1) { if (cond) break; }
函数指针/虚函数 目标函数数量 ptr->func()

注意:循环是状态爆炸的头号杀手。一个循环体里如果有条件分支,每次迭代都会产生新状态。循环10次,分支因子是2,那状态数就是210=1024。如果循环里有多个分支,那数字会更恐怖。

指数增长的数学本质

为什么路径会爆炸?本质上是组合爆炸。每个分支点独立产生状态,然后这些状态互相组合,形成笛卡尔积。

用数学语言说:

设程序有k个分支点,每个分支点的分支数分别为b₁, b₂, ..., bₖ
则总状态数 S = b₁ × b₂ × ... × bₖ

当所有bᵢ = 2时,S = 2ᵏ
当k = 50时,S ≈ 1.1259 × 10¹⁵

10的15次方是什么概念?假设angr每秒能处理100万个状态,那也需要大约35年才能跑完。嗯,这就是为什么你的分析会卡住。

一个真实的爆炸案例

我曾经分析过一个USB设备驱动,代码大概5000行。里面有个循环处理USB控制传输,循环体里有个switch语句,有8个case。循环次数取决于传输数据长度,最大是64字节。

你算算看:每次循环产生8个状态,循环64次,总状态数就是864。这个数字比宇宙中的原子数还大。我当时等了两个小时,angr只探索了不到1%的状态空间。后来我用了路径剪枝和符号摘要,才把问题搞定。

我的经验:遇到状态爆炸,先别急着优化代码。先画一下状态空间树,数数有多少个分支点,每个分支点的分支因子是多少。算清楚理论上的状态上限,你就能判断这个问题到底能不能硬跑。

状态空间树的可视化

下面这张图展示了一个简单程序的状态空间树。程序有3个分支点,每个分支点产生2个分支,总共8条路径。

入口 分支1 分支1 分支2 分支2 分支2 分支2 路径总数 = 2 × 2 × 2 = 8 3个分支点,每个分支因子为2 当分支点增加到50个时,路径数 ≈ 1.13 × 10¹⁵

这张图里,每个红色叶子节点代表一条完整路径。3个分支点就有8条路径。如果分支点增加到10个,叶子节点就是1024个。增加到20个,就是100多万个。这就是指数增长的威力。

总结一下

状态爆炸的数学原理其实不复杂:

  • 状态空间树:每个分支点产生新状态,形成树状结构
  • 分支因子:每个分支点可能的分支数量,决定了树的宽度
  • 指数增长:路径总数 = 分支因子的分支点次方

理解了这个原理,你就能预判哪些代码会爆炸,哪些不会。比如纯顺序执行的代码,分支因子是1,状态数就是1,永远不会爆炸。而嵌套循环加switch-case的代码,基本就是定时炸弹。

下一章我会讲怎么用angr的API来检测和量化状态爆炸,以及一些实用的剪枝策略。到时候我会分享几个我踩过的坑,希望能帮你少走弯路。


专注资料整理