视频加载失败

【论文阅读】Gordian: Defusing Logic Bombs in Symbolic Execution with LLM-Generated Ghost Code

8301 字
42 分钟
【论文阅读】Gordian: Defusing Logic Bombs in Symbolic Execution with LLM-Generated Ghost Code

论文阅读 · Symbolic Execution · LLM · Program Analysis

论文: Defusing Logic Bombs in Symbolic Execution with LLM-Generated Ghost Code
作者: Dimitrios Stamatios Bouras、Sergey Mechtaev
论文版本: arXiv v2,2026 年 9 月 8 日
代码与实验数据: Gordian Artifacts

1. 论文主要解决什么问题?#

这篇论文提出了一个基于 KLEE 的混合符号执行框架 Gordian。

它的核心思想其实很有意思:不让 LLM 直接代替 SMT Solver,让 LLM 帮 SMT Solver 生成一些辅助代码,把原本很难求解的约束转换成 Solver 更容易处理的形式。

传统符号执行(Symbolic Execution)可以沿着程序执行路径积累约束,然后调用 SMT Solver 求解满足路径条件的输入。

例如:

void foo(int x) {
if (x > 100) {
if (x % 7 == 3) {
trigger();
}
}
}

想要执行到 trigger(),只需要求解:

x>100∧x mod 7=3x > 100 \land x \bmod 7 = 3

Z3 之类的 SMT Solver 很擅长处理这种约束。

但是现实程序并不总是这么简单。假设换成下面这种:

double s = sin(x);
double c = cos(x);
if (s * c < 0) {
trigger();
}

这里的分支条件涉及三角函数。如果再叠加浮点计算、位运算、跨函数数据依赖,Solver 就有可能求不动。

除此之外,还有另一种经典问题:

if (head->next->next->next == head) {
trigger();
}

如果 head 指向一个完全符号化的动态数据结构,那么符号执行不仅需要考虑节点中的数据,还需要处理大量可能的指针别名关系和 Heap 结构。

这就是论文关注的两类主要困难:

  • Solver-hostile Computation: 非线性算术、浮点运算、复杂函数让 SMT 求解变得困难。
  • Heap Configuration Explosion: 动态数据结构可能存在海量指针连接方式,导致路径爆炸。

一些已有工作,比如 ConcoLLMic,尝试直接使用 LLM 推理程序路径、生成输入。

这种方法可以绕过部分 SMT 求解难题,但又会遇到新问题:当目标分支隐藏在多层函数调用之后,输入必须同时满足很多位置的条件时,LLM 很难保证全局约束一致。

Gordian 选择了一条折中路线:

简单、精确的约束继续交给 SMT Solver;Solver 处理不了的部分,再让 LLM 生成 Ghost Code 提供帮助。

论文提出三种 Ghost Code:

技术解决的问题核心做法
Code Inversion正向函数难以求解生成逆向计算代码,把后面的约束往前传
Surrogate Model函数太复杂,或者无法有效求逆用 Solver-friendly 的近似模型替换困难计算
Semantic Heap Partitioning指针和 Heap 结构导致路径爆炸只构造少量具有实际语义的数据结构形状

论文在 LogicBombs、FDLibM 以及 libexpat、jq、bc、libyaml 上进行实验。总体来看,相比传统符号执行 Baseline,Gordian 的覆盖率提升范围为 28.5%–115.2%;相比论文中的 LLM 符号执行 Baseline,提升范围为 74.1%–189.8%,同时大幅降低 Token 开销。

论文很值得看的一点就是,它是如何把 LLM 生成的代码接入原来的符号执行求解流程的。

2. Gordian 的整体工作流程#

先看整个系统是怎么跑的。

Gordian 并没有从零实现一个符号执行引擎,而是在 KLEE 上增加了一层自动化调度逻辑。LLM 负责识别困难代码并生成辅助程序,KLEE 继续负责路径探索和约束求解。

Target Program

原始程序 + Symbolic Harness

Exploratory KLEE Run

定位未覆盖分支

LLM Ghost Code Generation

选择困难片段与生成策略

Code Inversion

Surrogate Model

Semantic Heap Partitioning

Symbolic Execution + SMT

注入 Ghost Code,重新探索

Concrete Validation

在原始程序中重放输入

Target Program

原始程序 + Symbolic Harness

Exploratory KLEE Run

定位未覆盖分支

LLM Ghost Code Generation

选择困难片段与生成策略

Code Inversion

Surrogate Model

Semantic Heap Partitioning

Symbolic Execution + SMT

注入 Ghost Code,重新探索

Concrete Validation

在原始程序中重放输入

根据论文 Section 4 整理的简化流程。三种 Ghost Code 是不同的求解策略,并不意味着每个困难片段都要依次执行三种处理。

具体可以分成几个步骤。

第一步:先用 KLEE 跑一遍,看看程序卡在哪里。

Gordian 会先执行一次探索性符号执行,并将 Solver Timeout 缩短 90%。

这样做的目的不是直接拿到最高 Coverage,而是快速定位哪些分支还没有覆盖到。

之后,它会根据未覆盖分支在控制流中出现的位置,以及该分支后面控制的代码区域大小进行排序。

翻译一下,就是优先解决那些位置比较靠前,并且卡住后会导致一大片代码都没办法探索的分支。

这比随机挑选一个求解失败的位置更合理。

第二步:提取相关代码,交给 LLM。

对于候选分支,Gordian 会收集当前分支条件、所在函数、之前可能影响分支的代码,以及直接相关的被调用函数。

这里需要注意,LLM 并不是直接读完整个项目。

Gordian 会构造一个局部代码上下文,并限制其大小。这样可以避免让 LLM 处理大量无关代码,也降低了 Token 成本。

第三步:LLM 生成 Ghost Code。

根据当前困难片段的特点,选择 Inversion、Surrogate Model 或 Heap Partitioning。

第四步:把 Ghost Code 接入 KLEE。

生成的代码有可能替换困难计算,也有可能被插入到目标函数调用之前。

随后重新执行符号执行,继续使用原来的 SMT Solver 求解其余约束。

第五步:在原始程序上验证。

这一点很关键。

LLM 生成的代码并不一定正确。Gordian 不会因为修改后的程序覆盖了某个分支,就认为原始程序也一定能覆盖。

最终生成的具体输入还要拿回原始程序执行,确认目标路径真的能够到达。

所以 Ghost Code 在这里更接近一种辅助搜索的启发式程序,而不是一个可以无条件相信的函数语义替代品。

3. Code Inversion:为什么要把程序倒过来执行?#

3.1 从一个简单的非线性约束开始#

论文 Figure 4 给出了下面这个例子:

int hard_func(int x) {
return (x * x + 6) % 97;
}
int main(void) {
int a = symbolic_input();
int b = hard_func(a);
if (b == 42) {
assert(a > 20);
}
}

这里用 symbolic_input() 表示符号输入,是为了方便阅读;论文原图使用符号变量 α\alpha。

假设我们想探索 b == 42 且 a > 20 的路径,那么需要求解:

(a2+6) mod 97=42(a^2 + 6) \bmod 97 = 42a>20a > 20

困难在于第一个约束包含平方和取模。这个小例子不意味着 Z3 一定解不了,而是用于说明更复杂的 Solver-hostile 函数可以如何处理。

Gordian 的想法是:既然正向求解 hard_func(a) == 42 可能很麻烦,能不能生成一个反向函数?也就是已知:

b = 42;

反过来求出可能的 a。

对于这个例子:

a2 mod 97=36a^2 \bmod 97 = 36

可以找到两个模 97 意义下的根:

a mod 97∈{6,91}a \bmod 97 \in \{6, 91\}

需要特别注意,6 和 91 是模 97 的剩余类代表,不是原始无界整数域上的全部解。论文示例的逆向函数只搜索 0..96,因此生成的是候选值。

再结合:

a>20a > 20

就可以选取 a=91a = 91。最终:

(912+6) mod 97=42(91^2 + 6) \bmod 97 = 42

这里真正重要的不是数学计算,而是求解方向发生了改变。

传统做法是让 Solver 直接处理整个复杂函数。Gordian 则让 LLM 生成一个类似这样的函数:

void f_inv(int b, int *results) {
int count = 0;
int t = (b - 6) % 97;
if (t < 0)
t += 97;
for (int a = 0; a < 97; a++) {
if ((a * a) % 97 == t) {
results[count++] = a;
if (count == 2)
break;
}
}
}

这段代码的含义就是:

  1. 根据目标输出 b 计算需要满足的模平方条件。
  2. 在一个有限候选域内搜索可能的 a。
  3. 把候选输入交还给 Gordian。
  4. 继续与原程序其他位置的路径约束进行协调。

代码本身并没有什么高级算法,核心是把求解困难函数的任务转换成具体程序搜索候选输入。

当然,真正的技术难点还在后面。

3.2 为什么只生成逆函数还不够?#

假设我们有一个困难函数:

y=f(x)y = f(x)

程序前面有一个条件:

πpre(x)\pi_{\mathrm{pre}}(x)

程序后面还有一个条件:

πsuf(y)\pi_{\mathrm{suf}}(y)

想要到达目标位置,必须同时满足:

πpre(x)∧y=f(x)∧πsuf(y)\pi_{\mathrm{pre}}(x) \land y=f(x) \land \pi_{\mathrm{suf}}(y)

LLM 可以通过逆函数,根据满足后置条件的 y 计算候选 x。但是这个 x 不一定满足前面的约束。

这就是关键问题:局部逆向计算只解决了函数附近的约束,不能保证整条程序路径的可行性。

因此,Gordian 设计了双向约束传播(Bidirectional Constraint Propagation)。

它的思路是:把困难函数两侧的约束暂时拆开求解,再通过正向和逆向计算,让两边产生的候选值逐步靠近。

3.3 Havoc:先把困难函数的输入输出关系断开#

原来的程序:

int b = hard_func(a);

在符号执行时,Gordian 暂时将其替换为:

int b = fresh_symbolic_value();

这类操作在程序分析中一般称为 havoc。

原来有 b=f(a)b=f(a),经过替换以后:

b=βb = \beta

其中 β\beta 是一个全新的符号变量。

也就是说,KLEE 暂时不需要理解 hard_func 是怎么计算的。它可以单独探索后面的逻辑:

if (b == 42) {
assert(a > 20);
}

于是求解器可以得到:

β=42\beta=42

以及:

a>20a>20

此时的路径约束确实容易解多了。但是这里引入了一个很严重的问题。

b 和 a 之间的真实关系被断开了。

Solver 可以随便选择一个满足 a > 20 的值,比如 a = 30,同时认为 b = 42。可真实程序里 hard_func(30) 不一定等于 42。

所以 havoc 只是暂时绕开了困难计算,并没有真正完成求解。接下来必须把这条关系补回来。

3.4 双向约束传播:让前后约束逐步对齐#

论文的 Figure 5 给出了完整算法。

是

否,仍有预算

预算耗尽

Suffix Constraints

求解目标输出 y

Ghost Inverse f⁻¹

从 y 反推候选输入 x

Prefix Optimization

修正 x,使其满足前置约束

Original Function f

正向执行,重新计算 y

前后约束是否对齐?

原始程序验证

本次尝试失败

是

否,仍有预算

预算耗尽

Suffix Constraints

求解目标输出 y

Ghost Inverse f⁻¹

从 y 反推候选输入 x

Prefix Optimization

修正 x,使其满足前置约束

Original Function f

正向执行,重新计算 y

前后约束是否对齐?

原始程序验证

本次尝试失败

根据论文 Figure 5 简化的双向传播流程。实际实现还包括后置约束优化、可满足性检查及失败退出条件。

Step 1:求解 Suffix Constraint。

先只考虑困难函数后面的约束:

SMT(πsuf)\mathrm{SMT}(\pi_{\mathrm{suf}})

求出一个目标输出 y0y_0。

Step 2:执行逆函数。

调用 LLM 生成的:

x0=f−1(y0)x_0 = f^{-1}(y_0)

现在得到一个可能让困难函数产生目标输出的输入。

Step 3:利用前置约束修正输入。

检查:

πpre(x0)\pi_{\mathrm{pre}}(x_0)

如果不满足,就不能直接使用这个值。Gordian 调用优化求解器(Optimization Modulo Theories, OMT),寻找一个既满足前置条件,又尽量接近原候选值的输入:

x1=arg⁡min⁡x⊨πpre∣x−x0∣x_1 = \arg\min_{x\models\pi_{\mathrm{pre}}} |x-x_0|

这里论文针对单个数值变量使用 L1 距离。如果涉及多个变量,直接优化数值距离可能比较昂贵,于是会使用 Partial MaxSAT,尽量保留更多原来的变量赋值,只修改必要的部分。

Step 4:正向执行真实函数,再修正后置状态。

有了 x1x_1,重新执行真实函数:

y1=f(x1)y_1 = f(x_1)

然后根据实际输出 y1y_1,在满足 Suffix Constraint 的候选空间中进行优化。

如果两侧约束已经对齐,就可以生成测试输入。否则继续:

y1→f−1→x2→f→y2y_1 \rightarrow f^{-1} \rightarrow x_2 \rightarrow f \rightarrow y_2

反复进行 Refinement。

从直觉上理解,就是:后面的约束告诉我们需要什么输出,逆函数告诉我们可能需要什么输入,前面的约束再告诉我们这个输入是否合法。

如果不合法,就调整输入,重新计算输出,再看看还能不能满足后面的条件。

两边不断调整,尝试找到一个共同满足全部条件的解。

不过,这个过程是启发式优化,并不保证一定收敛。如果预算耗尽或者近似误差太大,可能无法找到可行输入,即使目标路径实际上是可达的。

4. FDLibM 案例:一个更实际的浮点求解问题#

前面那个 hard_func 例子比较简单。论文 Figure 1 中的 FDLibM 案例更能体现 Gordian 的价值。

FDLibM 是一个数学函数库,包含大量浮点数处理、三角函数计算和位级优化逻辑。论文讨论的是 Bessel 函数实现中的 _ieee754_j0。

论文 Figure 1:FDLibM 案例——原始三角函数片段、LLM 生成的 Ghost Inversion 与双向传播示例
论文 Figure 1:FDLibM 案例——原始三角函数片段、LLM 生成的 Ghost Inversion 与双向传播示例

原论文 Figure 1:查看代码、Ghost Inversion 及双向传播示例。最重要的是原始三角函数片段与生成的逆向搜索函数之间的关系。

先看论文中的关键代码,省略无关部分:

hx = __HI(x);
ix = hx & 0x7fffffff;
if (ix >= 0x7ff00000)
return one / (x * x);
x = fabs(x);
if (ix >= 0x40000000) {
s = sin(x);
c = cos(x);
ss = s - c;
cc = s + c;
if (ix < 0x7fe00000) {
z = -cos(x + x);
if (s * c < 0)
cc = z / ss;
else
ss = z / cc;
}
}

假设我们想进入:

if (s * c < 0)

这里至少需要解决两组条件。

第一组来自前面的位运算:

hx = __HI(x);
ix = hx & 0x7fffffff;
if (ix >= 0x40000000)

对于相关的 IEEE-754 double 数值范围,这个判断对应:

∣x∣≥2 |x| \ge 2

还有前面与后面的数值范围检查。

第二组来自三角函数:

sin⁡(x)cos⁡(x)<0\sin(x)\cos(x)<0

也就是说,我们不仅要让三角函数的乘积小于 0,还必须保证 x 能通过前面的位级判断。

这正是单独使用 LLM 猜输入比较难的地方。

LLM 能想到某个角度使 sin(x) 与 cos(x) 异号,但不意味着它能同时正确处理 _ieee754_j0 中涉及的 IEEE-754 位运算和控制流条件。

4.1 先把 sin 和 cos 暂时符号化#

Gordian 选择困难片段:

s = sin(x);
c = cos(x);

将其替换为两个新符号:

s=α,c=βs=\alpha,\qquad c=\beta

这样后面的分支就变成:

αβ<0\alpha\beta<0

对于 SMT Solver 来说,这比直接处理完整三角函数关系要容易得多。

Solver 可能返回:

(α0,β0)=(10,−1)(\alpha_0,\beta_0)=(10,-1)

显然 10×(−1)<010\times(-1)<0,后置约束满足。

但这里有一个问题。对于真实的 sin 和 cos:

sin⁡2(x)+cos⁡2(x)=1\sin^2(x)+\cos^2(x)=1

因此 (10, -1) 根本不可能是真实的三角函数输出。

原因很简单:前面通过 havoc 暂时删掉了 s、c 与 x 的联系。

Solver 给出的后置状态只满足局部路径条件,不一定满足原始函数语义。

4.2 LLM 生成近似逆函数#

Gordian 让 LLM 生成一个逆向搜索函数。

原论文使用黄金分割搜索(Golden-section Search),寻找使下面误差尽可能小的 x:

E(x)=(sin⁡(x)−α)2+(cos⁡(x)−β)2E(x)=(\sin(x)-\alpha)^2+(\cos(x)-\beta)^2

这个设计很巧。

因为对于任意输入的目标 (α, β),都不保证存在严格的逆。

于是这里干脆不要求一定求出真正的逆函数,而是寻找一个输出尽量接近目标状态的输入。

对于 (10, -1),论文得到近似值:

x0≈1.67x_0\approx1.67

但是:

1.67<21.67<2

不满足前面的约束。

4.3 利用前置条件修正输入#

Gordian 现在不再重新让 LLM 想一个输入,而是直接调用优化求解器:

min⁡∣x−1.67∣\min |x-1.67|

满足:

∣x∣≥2 |x|\ge2

于是得到:

x1=2x_1=2

然后真正执行:

s = sin(2.0);
c = cos(2.0);

得到近似:

s≈0.909,c≈−0.416s\approx0.909,\qquad c\approx-0.416

因此 s×c<0s\times c<0,同时 ∣x∣≥2|x|\ge2,前后的约束终于对齐了。

这时候再把 x = 2.0 放回原始程序执行,确认能否真实到达目标分支。

这就是 Gordian 的 Code Inversion 真正解决的问题:

不是单纯生成一个逆函数,而是用逆函数在困难代码两侧传递候选状态,再借助 SMT/OMT 修正这些状态,直到满足整条执行路径的约束。

这一点比简单地让 LLM 猜一个满足三角函数条件的输入更重要。

5. Surrogate Model:求不了逆,就换一个 Solver 能理解的模型#

Code Inversion 并不适合所有程序片段。

例如,一个函数可能丢失了大量输入信息:

int classify(double x) {
if (x < 0)
return -1;
if (x == 0)
return 0;
return 1;
}

对于输出 1,可能存在大量不同输入。

还有一些函数依赖文件系统、外部状态或者复杂计算结果,想生成准确的逆函数本身就比较困难。

因此,Gordian 提出了第二种方案:代理模型(Surrogate Model)。

思路是让 LLM 生成一个更容易符号执行的近似实现。

设原来的困难片段为:

y=f(x)y=f(x)

LLM 生成:

y=g(x)y=g(x)

其中 g 比 f 更容易转换成 SMT 约束。

例如,假设我们只关心某个复杂数值函数的输出符号,那么有时可以构造一个简化模型:

int sign_model(double x) {
if (x < 0)
return -1;
if (x > 0)
return 1;
return 0;
}

这里的代码只是为了说明 Surrogate 思路,并不是论文给出的具体生成实例。

关键不是近似函数在所有输入上的输出都必须和原函数完全相同,而是它能不能保留当前路径探索真正关心的行为。

比如目标只依赖:

if (result > 0)

那么我们可能并不需要完整还原 result 的所有数值精度。

当然,这种简化有风险。

如果近似模型错误地认为某个输入可以进入目标分支,符号执行就可能生成一个无效测试。

因此 Gordian 的处理是:

Surrogate Model→SMT 求解→Concrete Input→Original Program Validation\text{Surrogate Model}\rightarrow\text{SMT 求解}\rightarrow \text{Concrete Input}\rightarrow\text{Original Program Validation}

Surrogate 负责提供更容易探索的候选路径,原始程序负责最终确认这个路径是不是真的存在。

和 Inversion 相比,Surrogate 更直接。

Inversion 保留原始函数用于正向计算,并通过逆向传播协调约束。

Surrogate 则用一个更容易求解的近似实现,直接简化符号执行过程。

前者主要改变约束传播方式,后者主要改变约束生成方式。

6. Semantic Heap Partitioning:让符号执行不要乱连指针#

第三种 Ghost Code 解决的是另一个问题:Heap Configuration Explosion。

这个问题在链表、树、Skiplist 以及各种 Parser 内部结构中很常见。

6.1 为什么完全符号化的 Heap 很难探索?#

考虑论文 Figure 2 的简化 Skiplist:

typedef struct SNode {
int val;
char *name;
struct SNode *lvl0;
struct SNode *lvl1;
} SNode;

其中:

  • lvl0 指向普通链表中的下一个节点。
  • lvl1 是高层跳跃指针,可以跨过部分节点。
  • val 和 name 保存节点的数据。

现在有一个函数:

int proc_if_ring(SNode *h, SNode *g) {
SNode *a = h ? h->lvl0 : NULL;
SNode *b = a ? a->lvl0 : NULL;
SNode *c = b ? b->lvl0 : NULL;
SNode *d = c ? c->lvl0 : NULL;
if (!h || !a || !b || !c || !d)
return 0;
if (d != h)
return 0;
if (h->lvl1 != b ||
a->lvl1 != c ||
b->lvl1 != d ||
c->lvl1 != a)
return 0;
SNode *g1 = g ? g->lvl0 : NULL;
SNode *g2 = g1 ? g1->lvl0 : NULL;
if (g2 != h)
return 0;
return proc_ring_values(h);
}

我们希望到达:

return proc_ring_values(h);

这要求传入的数据结构满足一系列非常具体的条件。

首先,四个节点必须构成一个环:

h→a→b→c→hh\rightarrow a\rightarrow b\rightarrow c\rightarrow h

其次,lvl1 还必须满足:

h.lvl1=ba.lvl1=cb.lvl1=hc.lvl1=a\begin{aligned} h.\mathrm{lvl1}&=b\\ a.\mathrm{lvl1}&=c\\ b.\mathrm{lvl1}&=h\\ c.\mathrm{lvl1}&=a \end{aligned}

最后,g 沿着 lvl0 走两步,还必须回到 h。

假设所有节点和指针都是 Symbolic 的。

那么 KLEE 就需要探索不同的节点别名关系、空指针情况、环形结构以及多层指针组合。

这里的问题不是某一个判断特别难,而是能够构造出来的 Heap 形状实在太多了。

大量探索可能浪费在无效链表、断裂节点、错误环结构上。

论文中,普通 KLEE 在 10 分钟的限制内没有到达目标函数。

6.2 Gordian:直接生成具有语义的数据结构#

Gordian 让 LLM 分析 SNode 结构以及 proc_if_ring 中的判断,然后生成一个专门构造 Heap 形状的函数:

void constrain_heap(
SNode *n0,
SNode *n1,
SNode *n2,
SNode *n3,
SNode **h,
SNode **g
) {
switch (xi) {
case 1:
/* broken chain */
break;
/* other topology cases */
case 5:
n0->lvl0 = n1;
n1->lvl0 = n2;
n2->lvl0 = n3;
n3->lvl0 = n0;
n0->lvl1 = n2;
n1->lvl1 = n3;
n2->lvl1 = n0;
n3->lvl1 = n1;
*h = n0;
*g = n2;
break;
}
}

这里的 xi 表示一个新的符号化拓扑选择变量;代码根据论文示例做了精简。

当 xi == 5 时,Gordian 构造的就是能够满足目标检查的 Ring。

论文 Figure 2:Ring Shape 示意——蓝色为 lvl0 环,橙色虚线示意 lvl1 跨节点连接
论文 Figure 2:Ring Shape 示意——蓝色为 lvl0 环,橙色虚线示意 lvl1 跨节点连接

根据论文 Figure 2 整理的 Ring Shape 示意。蓝色表示 lvl0 的环,橙色虚线示意 lvl1 的跨节点连接关系,具体关系以代码中的赋值为准。

这套结构就满足 d == h 以及所有 lvl1 判断。

同时设置:

*g = n2;

因为:

n2->lvl0 == n3
n3->lvl0 == n0

所以从 g 开始沿 lvl0 走两步,刚好回到 h。

这样一来,程序就能够到达 proc_ring_values(h)。

论文对这个例子的报告是:使用 Gordian 后,可以在 2 秒内覆盖目标函数以及 proc_ring_values 的全部分支。

6.3 为什么不直接把所有字段都具体化?#

这是 Semantic Heap Partitioning 比较关键的设计。

它并不是让 LLM 生成一整个完全确定的数据结构。

对于 Ring 而言,Gordian 只固定必要的指针连接关系。

例如:

n0->lvl0 = n1;
n1->lvl0 = n2;
n2->lvl0 = n3;
n3->lvl0 = n0;

但节点中的 n0->val、n1->val、n2->val、n3->val 以及相关数据字段,仍然可以保留为 Symbolic。

原因很简单。

我们现在已经知道程序必须接收一个 Ring 才能继续执行,那就没必要让 Solver 再花大量时间探索链表结构。

但是进入 proc_ring_values(h) 以后,可能还需要探索:

if (h->val == 1337) {
trigger();
}

如果提前把 val 也写死,就可能错过这个分支。

因此 Gordian 固定的是:Shape(结构);保留的是:Contents(数据内容)。

这就形成了论文所谓的 Semantic Topology:部分具体、部分符号化的 Heap 配置。

其本质是利用 LLM 的程序语义理解能力,对原本庞大的 Heap 搜索空间进行有意义的划分。

但这里也存在覆盖范围上的取舍:LLM 没有生成的结构形状,可能就不会被探索到。它是一种搜索空间约束,并不是完整的 Heap 状态枚举。

7. 实验结果:Ghost Code 到底有没有用?#

论文一共设计了五个研究问题,分别关注困难路径覆盖、真实项目适用性、Token 开销、覆盖率增长趋势,以及是否探索到了其他方法无法到达的代码。

7.1 实验环境#

Gordian 基于 KLEE 实现,使用 Z3 作为求解器,并在涉及浮点操作时使用 KLEE-Float。

实验运行在 Ubuntu 24.04.3 LTS、Intel Core Ultra 7 258V、32 GiB RAM 的机器上。论文给每个工具配置分配了 36 小时的 Wall-clock 预算。

LLM Backend 包括 GPT-5、Claude 3.7 和 DeepSeek-Chat。

主要测试对象如下:

Benchmark规模主要考察的问题
LogicBombs53 个程序不同类型的困难约束和 Logic Bomb
FDLibM78 个 Entry Points浮点计算、复杂数学函数
libexpat11,828 LOCXML 解析、复杂输入和状态
jq3,399 LOCJSON 处理
bc8,182 LOC表达式解析和执行
libyaml2,760 LOCYAML 解析

Baseline 包括 KLEE-STP、KLEE-Z3、KLEE-Float-Z3,以及 LLM-based 的 ConcoLLMic 和 Cottontail。

7.2 RQ1:能不能真正解决 Solver-hostile 的 Logic Bomb?#

LogicBombs 中共有 53 个 Logic Bomb,每个程序包含两条 Critical Path,因此一共 106 条 Critical Path。

论文 Table 1 的部分结果:

方法触发 Logic Bomb 数量Critical Paths Coverage
KLEE-STP2059
KLEE-Z31756
KLEE-Float-Z32160
ConcoLLMic + Claude 3.73583
Gordian + Claude 3.750103
Gordian + GPT-549102

这里最有意思的一点是,Gordian 并不需要让 LLM 从头到尾推理整个路径。

它只要成功跨过那个最困难的程序片段,剩下的约束仍然由 KLEE 负责处理。

论文 Figure 8 的消融实验还表明,三种 Ghost Code 覆盖的困难类型并不完全重合,因此三者组合比只使用一种方法更有效。

7.3 RQ2:在真实项目上还能不能有效?#

在 FDLibM 上,Gordian 达到最高 91.7% 的平均行覆盖率和 90.2% 的总体行覆盖率。

相比之下,ConcoLLMic 在 FDLibM 上的平均行覆盖率只有 37.2%–40.0%。

这说明 LLM 即使能够理解三角函数或数学公式,也不一定能够稳定地生成满足多层浮点路径条件的输入。

Gordian 则通过 Inversion 和 SMT 结合,把复杂数学片段与其他路径条件协调起来。

结构化输入程序的结果同样明显。以下采用论文 Table 3 中 GPT-5 Backend 的总体行覆盖率数据,并为 KLEE 选择相应项目上表现最好的传统配置:

Benchmark最佳 KLEEGordian + GPT-5
libexpat14.35%47.75%
jq29.80%40.98%
bc44.70%69.79%
libyaml23.74%65.59%

为什么 libexpat、libyaml 这种程序适合 Gordian?

因为它们内部涉及大量状态对象、复杂输入结构和指针关系。

如果完全依赖符号执行遍历所有可能的输入和 Heap 结构,搜索空间非常大。

Semantic Heap Partitioning 可以提前选出一些有意义的结构,让 KLEE 把计算资源花在结构满足要求之后的程序行为上。

这里需要注意,论文报告的是 Coverage,而不是发现了多少真实漏洞。因此这些结果支持“能够探索更多代码路径”,不能直接等同于“漏洞发现能力提升相同比例”。

7.4 RQ3:为什么 Gordian 的 Token 成本这么低?#

这一点也很值得注意。

论文统计了结构化输入 Benchmark 上的 Token 消耗:

LLMGordianConcoLLMicCottontail
Claude 3.70.40M24.41M6.87M
GPT-50.47M14.90M2.81M
DeepSeek-Chat0.40M14.91M5.02M

使用 GPT-5 时,Gordian 消耗约 47 万 Token,而 ConcoLLMic 消耗约 1490 万 Token。

其实原因很直接。

ConcoLLMic 这类方法会持续调用 LLM 理解执行过程、分析约束并尝试生成输入。

Gordian 则只在识别出 Solver-hostile 代码片段时调用 LLM。

生成一次 Ghost Code 后,后续的大部分路径探索仍然交给符号执行引擎。

论文报告 LLM 调用约占 Gordian 总执行时间的 9.2%。

LLM 在这里不是执行引擎,而是一个按需调用的程序转换组件。

这也是它能够降低 Token 开销的根本原因。

7.5 RQ4 / RQ5:覆盖率为什么呈阶梯式增长?#

论文 Figure 10 展示了 Coverage 随时间变化的趋势。

普通 KLEE 在运行初期快速获得一部分 Coverage,随后逐渐进入 Plateau。

原因是容易探索的路径很快覆盖完了,剩下的路径可能涉及复杂约束或数据结构。

而 Gordian 的覆盖率曲线呈现出明显的阶梯状。

当某个 Ghost Code 成功生成并解除一处求解瓶颈之后,原本被挡住的一片代码区域会突然变得可达。

于是 Coverage 出现明显增长。

这一点很符合 Gordian 的设计逻辑。

它并不一定每分钟都比 KLEE 多探索很多路径,而是通过解决关键阻塞点,打开新的程序区域。

论文还在 libyaml 上分析了不同方法覆盖代码的重合情况。Gordian 独有的覆盖行数占全部已覆盖行数的 19.2%,说明它的改进并不只是重复覆盖其他方法已经访问过的代码。

8. Ghost Code 不可靠怎么办?#

读到这里,其实还有一个非常现实的问题:

LLM 生成的 Ghost Code 本身就可能是错的。

比如:

  • 逆函数不是真正的逆,或者漏掉合法输入。
  • Surrogate Model 对原始函数的行为近似不准确。
  • Heap Partitioning 没有考虑到某种特殊的数据结构形状。

那么为什么 Gordian 还能使用这些代码?

关键在于,它对 Ghost Code 的定位不是 Ground Truth。

Ghost Code 负责帮助搜索,原始程序负责验证。

论文对 100 次随机抽样的 Inversion 尝试进行了分类:

结果占比
生成或执行失败7%
接口不匹配8%
逆向语义错误22%
Refinement 未收敛18%
成功触发目标路径45%

这个结果其实比只展示成功率更有价值。

因为它说明 LLM 生成的逆向程序确实不够稳定。

22% 的样本虽然具有正确接口,但语义上不成立;还有 18% 的尝试无法让双向传播收敛。

Gordian 并没有解决 LLM 的幻觉问题,而是通过具体执行验证,让错误的 Ghost Code 不至于直接转化成错误的覆盖结果。

不过,这里要区分两个概念:

Soundness: 报告已经到达的目标路径时,存在原始程序的具体执行作为依据。

Completeness: 是否能找到所有实际上可达的目标路径。

Gordian 通过 Concrete Validation 保护前者,但不能保证后者。

如果 Ghost Code 错误,或者 Heap Partitioning 没有生成所需结构,可能就漏掉实际存在的路径。

所以这套方法允许 False Negative,但不会仅凭 Ghost Code 的近似执行就报告一个虚假的真实覆盖结果。

9. 评价#

这篇论文有意思的地方,是它没有沿着“让 LLM 代替 Solver”的方向继续做,而是重新考虑了 LLM 在符号执行中的角色。

从技术设计上看,有三个比较值得关注的点。

第一个是 Code Inversion 和双向约束传播的结合。

单纯让 LLM 生成一个逆函数,其实不能解决深层路径的全局约束问题。

但 Gordian 把程序在困难片段处切开,通过 havoc 简化约束,再使用逆向计算、正向执行和优化求解器不断协调前后状态。

这样 LLM 就不需要一次性理解整条程序执行路径,而是只需要给出一个局部的候选状态转换方法。

第二个是 Semantic Heap Partitioning。

这一点在程序分析中很实用。

符号执行面对复杂数据结构时,困难不一定是某个分支无法求解,而是 Solver 根本没有足够时间探索所有可能的 Heap Shape。

Gordian 干脆利用 LLM 对数据结构的理解,提前构造一些有语义的拓扑,再把节点内容保持 Symbolic。

这相当于把大量结构搜索工作从 SMT 层转移到了生成的辅助代码中。

但这也意味着 Coverage 会依赖 LLM 对关键结构的识别能力,生成的 Topology 不完整就有可能漏掉重要路径。

第三个是将 LLM 输出限制在可验证的辅助角色中。

这可能是我最认可的设计。

与其要求 LLM 直接给出一定正确的路径输入,不如让它完成自己比较擅长的工作:理解局部代码、识别数学结构、生成搜索算法以及构造数据结构。

最终结果仍然由原始程序执行验证。

当然,Gordian 还有一些明显限制。

目前的实现需要修改代码、重新编译并多次调用 KLEE,对复杂构建系统会有一定工程开销。另外,论文当前的双向传播实现一次只处理一个困难片段,并不支持同时对一条路径上的多个困难函数进行联合逆向协调。

如果困难约束广泛分布在多个函数中,或者依赖复杂的外部环境,那么只解决一个局部片段可能仍然不够。

而且从实验结果来看,Inversion 本身只有 45% 的端到端成功率,也说明这个过程远没有达到稳定可靠的程度。

不过,整体思路还是很值得学习的:

不要让 LLM 接管整个符号执行,而是让它针对 Solver 真正处理不了的地方生成辅助程序。能够形式化求解的部分继续使用 Solver,无法精确推理的部分通过启发式搜索产生候选值,再回到原始程序验证。

这类 LLM + Program Analysis 的组合方式,比完全依赖自然语言推理整个执行路径更加务实。

论文的完整实现、Benchmark、Harness、生成的测试用例以及执行日志都已经放在 Gordian Artifact 中,后续如果要进一步研究,最值得看的应该是 Ghost Code 的生成和注入逻辑,以及 Bidirectional Constraint Propagation 在实际 KLEE 工作流中的实现。


本文技术细节和实验数据依据论文 arXiv v2 的 Sections 2–6,关键算法对应 Figures 1、2、4、5,实验结果对应 Tables 1、3、4、6。

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

【论文阅读】Gordian: Defusing Logic Bombs in Symbolic Execution with LLM-Generated Ghost Code
https://fmout.site/posts/gordian-llm-ghost-code-symbolic-execution/
作者
远山
发布于
2026-10-10
许可协议
CC BY-NC-SA 4.0
相关文章智能推荐
1
【论文阅读】Agentic Concolic Execution: 让 LLM 接管 Concolic Execution 的建模与求解
论文阅读CONCOLLMIC 将符号化建模与约束求解交给 LLM 智能体,同时保留具体执行、代码执行与 SMT 求解器:相比 KLEE/SymCC/SymSan 平均分支覆盖率提升 115%–233%,相比 AFL++ 提升 81%,发现 11 个未知漏洞(1 个获 CVE-2025-4945),平均成本约 $53.86/项目。
2
【论文阅读】Locus: Agentic Predicate Synthesis for Directed Fuzzing
论文阅读ICSE 2026 论文,Locus 结合 LLM Agent 与符号执行,在程序任意中间位置合成捕获执行进度的谓词,为定向模糊测试提供语义里程碑:定向 Fuzzer 平均加速 70.3×,最高 214.2×,并挖出 9 个未修复的零日漏洞。
3
【论文阅读】Exploring Static Taint Analysis in LLMs: A Dynamic Benchmarking Framework for Measurement and Enhancement
论文阅读通过 190 个基础生成单元动态拼装出近乎无限的测试用例,以轻量级动态污点分析自动获取 Ground Truth,再用模型专属的错误总结实现免训练自我纠错——LLMCAPLENS 为评估与提升 LLM 静态污点分析能力提供了一套完整框架。
4
【论文阅读】Sleuth: A Switchable Dual-Mode Fuzzer to Investigate Bug Impacts Following a Single PoC
论文阅读ISSTA 2024 论文,Sleuth 以单个 PoC 为起点自动挖掘同一漏洞的多种 Bug Impact:Crash Summary 指纹、MRG 记忆关联图插桩,配合监控器驱动的"深度/广度"双模式动态切换——86% 的 CVE 中发现新 Impact,总计 856 个。
5
【论文阅读】Make Agent Defeat Agent: Automatic Detection of Taint-Style Vulnerabilities in LLM-based Agents
论文阅读复旦与 UC Davis 合作提出的 AgentFuzz——首个面向 LLM Agent 污点型漏洞的定向灰盒模糊测试框架,用 LLM 生成功能特定种子、以语义+距离+惩罚三维反馈调度、靠 concolic 执行解约束变异,在 20 个热门开源 Agent 中发现 34 个 0-day(精度 100%),获 23 个 CVE。
随机文章随机推荐
Profile Image of the Author
远山
Hello, I'm 远山.
公告
欢迎来到我的博客!
分类
标签
站点统计
文章
25
分类
6
标签
23
总字数
127,321
运行时长
0 天
最后活动
0 天前
站点信息
构建平台
Local
博客版本
Firefly v6.16.7
文章许可
CC BY-NC-SA 4.0
文章目录