【论文阅读】Sleuth: A Switchable Dual-Mode Fuzzer to Investigate Bug Impacts Following a Single PoC
- 1【论文阅读】Dytan: A Generic Dynamic Taint Analysis Framework
- 2【论文阅读】All You Ever Wanted to Know About Dynamic Taint Analysis and Forward Symbolic Execution
- 3【论文阅读】Augur: Dynamic Taint Analysis for Asynchronous JavaScript
- 4【论文阅读】VIPER-MCP: Detecting and Exploiting Vulnerabilities in Model Context Protocol Servers
- 5【论文阅读】Exploring Static Taint Analysis in LLMs: A Dynamic Benchmarking Framework for Measurement and Enhancement
- 6【论文阅读】Locus: Agentic Predicate Synthesis for Directed Fuzzing
- 7【论文阅读】Make Agent Defeat Agent: Automatic Detection of Taint-Style Vulnerabilities in LLM-based Agents
- 8【论文阅读】Sleuth: A Switchable Dual-Mode Fuzzer to Investigate Bug Impacts Following a Single PoC本文
论文: Sleuth: A Switchable Dual-Mode Fuzzer to Investigate Bug Impacts Following a Single PoC 会议: ISSTA 2024 作者: Haolai Wei, Liwei Chen, Zhijie Zhang, Gang Shi, Dan Meng 关键词: Fuzzing、Bug Impact、Patch Testing、AFL++、Taint Analysis
传统 Fuzzing 的目标一般是:
这个程序还有没有漏洞?
安全研究员好不容易在某个开源软件里挖出一个洞,提交了一个 PoC(Proof of Concept),开发者一看崩溃了,赶紧查一下原因,加个边界检查或者判空,补丁就发出来了。
但问题是,只基于这一个 PoC 去理解漏洞的影响(Bug Impact)是极其狭隘的。同样一个根因漏洞,通过不同的路径、不同的数据流触发,可能会表现出完全不同的崩溃类型(比如一开始只是空指针解引用 null pointer dereference,换个路径可能变成堆溢出 heap-buffer-overflow 或者释放后使用 use-after-free),崩溃发生的位置也可能完全不一样。
简单来说,论文中的 Sleuth 是一个可以根据反馈动态切换”深度探索”和”广度探索”模式的双模式 Fuzzer,旨在用单个 PoC 为起点,自动挖掘出这个漏洞尽可能多的 Bug Impacts。
1. 背景与核心痛点:为什么现有的方案不够?
在展开讲 Sleuth 之前,我们需要先达成一个共识:什么是 Bug Impact?
论文把由同一个漏洞产生的不同触发结果称为 Bug Impact。
论文里给出了明确的定义,一个 Bug Impact 是一个二元组 <Impact type, Crash position>(论文中称为 impact-pair):
- Impact type:崩溃的类型,比如
heap-buffer-overflow read、use-after-free write等。 - Crash position:崩溃发生的代码行号。
同一个漏洞根因,如果能在不同的 Crash position 触发不同的 Impact type,对攻击者来说利用方式和危害程度是完全不同的。为了自动化地挖掘这些 Impact,传统做法通常是基于 Fuzzing 的”崩溃探索模式”(比如 AFL++ 的 afl-cexp)。作者最终希望做到的是:
输入一个已经存在的 PoC,让 Fuzzer 自动围绕这个 PoC 继续探索,尽可能找到同一个漏洞能够产生的更多 Bug Impact。
但这里有一个极其难平衡的痛点:时间分配。
当我们拿到一个初始 PoC,触发了一个初始崩溃(论文中称为 origin),接下来探索新 Impact 的方向有两个:
- 深度探索 (In-depth):继续围绕当前的 origin 崩溃点,改变数据流状态,看能不能在同一点位引发不同的崩溃类型(比如从读变成写,或者溢出字节数变多)。
- 广度探索 (Breadth):离开 origin,去探索那些还没被覆盖到的代码区域,看能不能在别的代码位置触发同一个根因的崩溃。
现有的工具往往顾此失彼:afl-cexp 依赖边缘覆盖率盲目探路,容易在无关的路径上浪费时间;Evocatio 虽然能探索 Bug Capabilities,但主要聚焦在相同的代码区域,对不同的崩溃位置极不敏感;内核侧的 SyzScope 虽然也是双模式,但它是分阶段进行的,效率极低。
Sleuth 的核心思想非常直接:别死磕一个模式,加个监控器,根据 Fuzzing 过程中的反馈,动态在这两个模式之间无缝切换。
2. Sleuth 的设计与实现
Sleuth 的整体工作流分为两个阶段:预分析阶段 (Pre-analysis Phase) 和 Bug Impacts 探索阶段 (Bug Impacts Exploration Phase)。


2.1 预分析阶段:磨刀不误砍柴工
在这个阶段,Sleuth 需要提取出能够指导后续 Fuzzing 的信息,主要包括两个核心概念:Crash Summary 和 Memory-Relevant Graph (MRG)。
在这个阶段,Sleuth 接收一个包含单个 PoC 的程序作为输入,并提供双模式探索所需的必要信息。这里主要包括三个任务:
- 使用崩溃分析器提取崩溃摘要。
- 使用包含污点分析和层级分布策略的静态分析器构建与内存相关的图。
- 将上述信息集成到程序中。
(1) Crash Analyzer(崩溃分析器)与 Crash Summary(崩溃摘要)
为了不盲目探索,我们需要一种机制来评估当前崩溃点的”潜力”。Sleuth 引入了 Crash Summary,定义为一个元组 <P, (O, N)>:
P(Crash Position):崩溃位置。Sleuth 提取 ASAN 报告的堆栈帧并计算哈希,用来唯一标识控制流。(O, N):O是访问越界时相对于 victim 对象的偏移量(Offset),N是访问的字节数。这俩参数代表了崩溃点当前的数据流状态。
例如,在列表 1 的第 17 行通过溢出缓冲区触发了崩溃。假设缓冲区的边界地址是 10,通过崩溃访问的地址是 15,并且能够读取 3 个字节。那么 P 的值是第 17 行,O 是 5,N 是 3。通常,一个 P 可能对应多个 (O, N) 对。
为什么这么设计?
这就好比给崩溃点做了一次指纹提取。如果 P 不变,但 (O, N) 变了,说明我们在同一个位置探索出了新的数据流状态,值得继续深挖(深度探索)。如果 P 和 (O, N) 都不变了,说明这个点挖干了,得换个地方(广度探索)。
(2) Static Analyzer(静态分析器)与 MRG
广度探索时,怎么知道哪些未探索的代码区域最有可能隐藏着同一个漏洞的 Impact?靠瞎跑肯定不行。Sleuth 使用 LLVM 层的静态分析(基于 SVF 的指向分析和污点分析)构建了一个内存关联图 Memory-Relevant Graph (MRG)。

构建过程(参见上图):
- 污点分析 Taint Analysis:从初始崩溃点出发,把引起崩溃的变量作为 Source,反向追踪它的定义链(Definition Chain),直到追踪到没有新的定义为止。同时,向前追踪 Sink 点(读写访问),收集所有与这个内存对象相关的变量。以图 3(a) 为例:首先提取第 17 行的初始崩溃位置,并识别指针 dst 指向受害对象;然后将 dst 作为源位置,向回追踪到第 13、12、2 行这些定义的位置,定义的变量 dst、in、out 指向相关的内存对象(图 3b);最后执行正向分析,并识别第 20 行和 29 行可能包含潜在影响。
- 级别分布 Level Distribution:追踪出来的节点太多了,得有优先级。论文设计了一个
Level公式。简单来说,如果两个变量之间有数据流依赖,说明它们”距离近”;距离初始漏洞对象越近的代码块,触发同一个漏洞的概率越高。在污点分析之后,执行层级分布过程,构建一个仅包含与内存相关对象定义和使用位置的记忆相关图(MRG)。
翻译一下就是: 既然这个内存对象是导致崩溃的元凶,那么任何跟它有数据流交集的代码块,都是潜在的”犯罪同伙”,我们按照亲疏关系(Level)给它们排个优先级。这样 Fuzzer 在广度探索时,就能优先跑向那些”嫌疑最大”的代码块。
最后,通过 LLVM Pass 把 MRG 的信息(Level 等)插桩到程序的基本块(Basic Block)中。
2.2 探索阶段:动态切换与双模式调度
这里就是 Sleuth 的灵魂了。
代码插装
代码插装的目的是把 MRG 变成 Fuzzer 能吃的覆盖率。
先说为什么不能直接用 AFL 那套 edge coverage。
AFL 的 edge coverage 本质是记录 prev_loc ^ cur_loc,也就是”上一个基本块到当前基本块”的跳转。但 Sleuth 的 MRG 里,一条边连接的两个基本块很可能在 CFG 上根本不相邻,中间隔了十万八千里。你没法用传统 prev_loc 去表示”我是从 MRG 里的哪个起点走到这个终点的”。
所以 Sleuth 自己设计了一套插桩算法,也就是论文里的 Algorithm 1: Memory-Relevant Instrumentation:

算法输入输出:
- 输入:目标程序
P,以及预分析阶段生成的 memory-relevant graphG。 - 输出:插桩后的程序。
先看伪代码:
Input: target program P, and memory-relevant graph GOutput: instrumented program P1 BB ← ∅2 Level ← {}3 InFlow ← {}4 E ← get_edges(G)5 for e ∈ E do6 insert Level[e] ← get_level(e, G)7 bb_end, bb_start ← get_basic_block(e)8 BB ← BB ∪ bb_end ∪ bb_start9 insert InFlow[bb_end] ← bb_start10 for b ∈ InFlow[i], 0 ≤ i < len(InFlow) do11 P ← instrument_global(b, s, 0)12 for f ∈ P do13 for b ∈ f do14 if b ∈ BB then15 P ← instrument_tag(b, random())16 for b ∈ f do17 if b ∈ InFlow then18 P ← instrument_cov(tag(b), tag(InFlow[b]), Level)19 return P翻译一下:
第 1-4 行:初始化。
BB用来收集所有涉及到的基本块。Level存每条 MRG 边的 level,也就是”这条边离漏洞对象有多近”。InFlow是一个 map,记录每个bb_end可能从哪些bb_start过来。E是 MRG 里所有边。
第 5-9 行:遍历 MRG 的每条边。
对每条边 e:
- 拿到它的 level,塞进
Level[e]。 - 找到这条边的起点基本块
bb_start和终点基本块bb_end。 - 把这两个基本块都加入
BB集合。 - 在
InFlow里记录:bb_end的前驱包含bb_start。如果bb_start和bb_end是同一个基本块,论文说 Sleuth 不会保留这个bb_start,避免自环干扰。
第 10-11 行:给每个 bb_start 插一个全局哨兵变量 s,初始值为 0。
这个 s 就是”执行标记”。程序跑到这个基本块,就把 s 置 1;没跑到就还是 0。后面在 bb_end 处要靠它来判断”这次执行到底有没有经过某个前驱”。
第 12-15 行:给 BB 里的每个基本块插一个随机 tag。
tag(b) 就是一个随机数,用来唯一标识基本块 b。你可以把它理解成基本块的身份证号。
第 16-18 行:在边级别插桩 instrument_cov。
对每个 bb_end,Sleuth 会插一段 coverage 计算逻辑。它读取所有可能前驱 bb_start 的哨兵 s,如果某个前驱执行过,就把它的 tag 贡献出来。最终得到一个值,表示”这次执行是从哪些 MRG 起点到达了这个终点”。
具体的覆盖率公式是:
tag(bb_end) ⊕ (tag(bb_start_1) ∧ bb_start_1 → s) ⊕ ... ⊕ (tag(bb_start_n) ∧ bb_start_n → s)这里:
bb_start_i → s就是前面插的全局哨兵,执行过是 1,没执行是 0。tag(bb_start_i) ∧ (bb_start_i → s):如果这个前驱执行过,就保留它的 tag;没执行过,就变成 0。- 全部异或起来,再和
tag(bb_end)异或,得到当前bb_end的 MRG 覆盖指纹。 - 因为一个
bb_end可能对应多条 MRG 边,每条边有自己的 level,Sleuth 取这些边里最小的 level 作为这个bb_end的 level。
举个具体例子:
假设 bb_end 有两个前驱 bb_start_A 和 bb_start_B,tag 分别是 0x1234 和 0x5678。本次执行只经过了 A,没经过 B:
- A 的哨兵
s = 1,B 的哨兵s = 0。 - 计算:
tag(end) ^ (0x1234 & 1) ^ (0x5678 & 0) = tag(end) ^ 0x1234。 - 这样 Fuzzer 就能区分”从 A 来”和”从 B 来”。如果两个都经过,就异或两个 tag,得到另一个不同的值。
Fuzzer 看到新的 cov 值,就知道发现了一条新的 MRG 边覆盖。
核心逻辑是:Sleuth 用”全局哨兵 + 随机 tag + 异或”把 MRG 里那些不相邻的边,压成了一个 Fuzzer 能识别的 coverage 值,同时把 level 打包进 map,让广度探索知道该往哪跑。
动态切换策略 (Switching Strategy)
预分析阶段结束后,程序已经被插桩,Crash Summary 和 MRG 信息也都有了。接下来就是 Fuzzing 循环。
Sleuth 要解决的核心问题是:什么时候继续深挖当前崩溃点?什么时候放弃,去别处找新崩溃点?
它不靠静态分配时间,而是靠一个 Monitor 持续观察 Crash Summary 的变化。
Sleuth 在 Fuzzing 循环里挂了一个 Monitor 组件,它持续监控两个指标:
τ:距离上一次发现新崩溃位置P的时间间隔。ε:距离上一次发现新的(O, N)对的执行间隔。
状态机逻辑如下:
- 初始默认是深度探索模式。
- 如果
τ > maxTime(默认 8 分钟)或ε > maxExec(默认 50000 次执行),且没有发现新的 Crash Summary,说明当前崩溃点已经被榨干了,Monitor 触发切换,进入广度探索模式。 - 在广度探索模式下,一旦发现了新的崩溃位置
P(说明找到了新的可探索点),立刻切回深度探索模式。
双模式探索 (Dual-Mode Exploration)
两个模式下,种子队列的保留和选择策略是截然不同的:
- 种子保留:哪些变异结果有资格进入队列。
- 种子选择:下一轮从队列里挑哪个种子去变异。
| 模式 | 种子保留 (Seed Retention) | 种子选择 (Seed Selection) |
|---|---|---|
| 深度探索 | 仅保留引入了新 Crash Summary 的崩溃测试用例。 | 优先选择到达了”尚未发现的崩溃位置”的种子;如果没有,选择执行速度最快的种子。 |
| 广度探索 | 仅当种子触达了包含潜在 Bug Impact 的新基本块,或触达了包含潜在 Impact 的新边时,才保留种子。 | 根据插桩的 MRG Level 对种子排序,优先选择到达了 Level 最高(距离漏洞对象最近)的基本块的种子;如果没有新基本块,退回传统覆盖率引导策略。 |
这套组合的逻辑非常清晰:深度模式死磕数据流,广度模式借着静态分析的指引去扩宽控制流。
- 插桩解决的是”MRG 边怎么表示成 Fuzzer 能吃的 coverage”;
- 切换策略解决的是”什么时候换模式”;
- 双模式调度解决的是”换模式后选哪些种子”。
实现
Sleuth 基于 LLVM 12.0.0 基础设施和模糊测试工具 AFL++,我们使用 6k 行 C/C++ 代码实现了 Sleuth 的可切换双模式。
在预分析阶段,受 Evocatio 启发,使用 asan 接口和 execinfo 库修改了原始的 sanitizer,以合成崩溃分析器。
至于静态分析器,我们输入单个 PoC 和目标程序的 LLVM IR。在论文的实现中,我们从 ASAN 报告中提取了初始崩溃位置和相应的指令。然后,基于先前工作的功能,利用 SVF 的 Andersen 指针分析,构建 SVFG,并在 SVFG 中执行污点分析以收集与内存相关的对象。简而言之,论文将所有分析功能集成到一个程序中,并将其封装在 LLVM 编译器内。最后,论文最终生成了一个 JSON 文件来保存与内存相关的图。
论文在 50 个真实 CVE 上进行了评估(25 个来自 Evocatio 基准,25 个新增),每个实验跑 12 小时,重复 5 轮,对比基线是 AFL++ 的 afl-cexp 和 Evocatio。
Sleuth 在 86% 的 CVE 中发现了新的 Bug Impacts,总共发现了 856 个 Impacts(afl-cexp 是 584 个,Evocatio 只有 294 个)。相比 afl-cexp,Impacts 数量增加了 46.6%。特别是对于有多个崩溃点的大型 CVE(如 CVE-2020-11895),Sleuth 的优势极其明显,因为它不会在一个点上吊死。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!









京公网安备11011402057358号