这期来自 Computerphile 的视频由帝国理工学院多核编程研究组负责人 Alastair F. Donaldson 教授主讲,深入浅出地讲解了模糊测试(Fuzzing)——一种通过输入随机数据来查找软件漏洞的高效技术 [00:00]。
教授将模糊测试的原理、应用场景以及极其重要的“覆盖率引导模糊测试”算法进行了全方位的拆解。以下是视频内容的详细总结:
一、 为什么需要模糊测试(Fuzzing)?
- 程序员的思维局限: 程序员在设计软件时,通常只会考虑软件“应该”做什么、用户“正常”会怎么用,或者用户会犯哪些显而易见的错误(例如打开一个不存在的文件)[00:14]。
- 未预期的输入(Unexpected Inputs): 软件往往在遇到完全意料之外的输入时崩溃。例如,用户用文档编辑器打开一个视频文件,或者粘贴大量乱七八糟的古怪内容 [00:36]。模糊测试就是通过扔给软件各种荒诞、随机的数据,来找出这些盲区 [00:51]。
- 互联网时代的刚需: 如今像 Chrome、Safari、Firefox 这样被数十亿人使用的浏览器属于完全开放的系统 [01:03]。如果遭遇钓鱼攻击,恶意网页可能包含任何恶意的 HTML 或 JavaScript。浏览器必须足够健壮,绝不能因为这些随机的恶意代码而暴露安全漏洞,从而给攻击者留下执行远程代码(Remote Execution Attack)的机会 [01:16]。
教授指出,模糊测试的测试光谱有两个极端:
- 一端是恶意/无序数据: 故意输入乱码、坏数据,目标是找出安全漏洞 [02:11]。
- 另一端是高度复杂/结构正确的数据: 输入非常复杂但符合规范的数据,目标是检查软件在复杂场景下的功能逻辑是否正确 [02:25]。
二、 模糊测试在编译器测试中的应用
教授重点介绍了如何用模糊测试来测试逻辑极其复杂的编译器(如 C 语言的 GCC、Clang 或 Java 的 JavaC)[02:52]。
1. 编译器的致命漏洞:错误编译(Miscompilation)
如果是普通的报错或崩溃还算好,编译器最怕的是“错误编译”——即编译器在没有任何警告或报错的情况下,成功将源码翻译成了二进制文件,但翻译后的程序语义完全变了 [03:19]。
举个简单例子: 源码写的是 $x + y$,但编译器在进行复杂的“代码优化”时出现了边缘 case,结果把指令错翻译成了 $x \times y$ [03:47]。
2. 差异测试(Differential Testing)机制
为了揪出编译器的这种深层逻辑漏洞,研究人员使用了一种方法 [04:12]:
- 利用一个名为 Csmith 的模糊测试工具,随机生成结构完全正确、但逻辑古怪复杂的 C 语言程序 [04:22, 07:41]。这些程序没有实际意义,但运行后会产生一个确定性的整数输出 [04:54]。
- 将同一个随机程序分别交个两个不同的编译器(比如 GCC 和 Clang)编译,生成两个不同的可执行文件(
1.exe和2.exe)[05:01, 05:32]。 - 给这两个程序完全相同的输入,看它们的输出结果是否一致 [05:50]。如果不一致,说明其中一个编译器绝对存在代码优化上的 Bug [06:36]。
3. 核心挑战:规避未定义行为(Undefined Behavior, UB)
C/C++ 语言中充满了未定义行为(如除以零、数组越界读写等) [06:46]。如果随机生成的程序本身包含 UB,不同的编译器输出不同结果就是合理的 [06:53]。因此,Csmith 这类工具的核心创新就在于如何确保生成的随机程序绝对不包含任何未定义行为 [07:10, 07:56]。
💡 教授的测试哲学: 测试只能用来“证明软件存在 Bug”,而不能“证明软件没有 Bug” [08:49]。除了涉及生命安全的高保真软件会使用“形式化验证”来做数学证明外,商业软件总是带病运行的,测试的意义在于让它越来越稳定 [09:11, 09:30]。
三、 覆盖率引导的模糊测试(Coverage-Guided Fuzzing)
如果只是盲目地瞎扔完全随机的字节,测试会非常低效。比如很多 Linux 命令行工具需要接收正确的参数(如 --only)才能解锁后面的深层代码,纯随机的数据几乎不可能碰巧撞上这种特定字符串 [11:47, 18:00]。
为了解决这个问题,Google 工程师多年前开发了著名的 AFL(American Fuzzy Lop) 工具,普及了“覆盖率引导的模糊测试” [11:11, 12:50]。
1. 迷宫比喻
教授把软件的内部逻辑比作一个迷宫 [11:32]。
- 初始语料库(Corpus): 模糊测试开始时,会有一个包含正常文件的语料库(例如测试 PDF 引擎时,先放入几个现实中正常的 PDF 文件)[13:19]。
- 变异与测试: 算法从语料库中拿出一个输入 $A$,对其进行随机变异(包括翻转某一位、替换字节、删除字节、或者把两个输入拼接在一起),生成新的输入 $A'$ [14:14, 14:42]。
- 反馈机制: 将 $A'$ 喂给软件。如果 $A'$ 让程序走到了之前从未到达过的迷宫新区域(即触发了新的代码覆盖率),它就被定义为“有价值的输入”,并被放回语料库中 [14:54, 15:12]。
- 淘汰机制: 如果另一个变异输入 $B'$ 走的是老路,没有带来任何新的覆盖率,就会被直接丢弃,不污染语料库 [15:50]。
2. 进化算法(Evolutionary Algorithm)
这种高效率的模糊测试(通常每秒运行成千上万次)本质上是一个遗传/进化算法 [16:37, 17:21]:
- 种群(Population): 输入语料库 [17:27]。
- 突变与交配(Mutation & Mating): 随机位翻转或文件拼接 [17:27, 17:34]。
- 适应度函数(Fitness Function): 谁能带来“新的代码覆盖率”,谁就能存活并繁衍 [17:41]。
通过这种方式,即使一开始丢进去的数据毫无逻辑,随着时间推移,工具也会隐式地“学习”到软件所期望的正确输入格式 [18:37]。例如,一旦它变异出了第一个连字符 - 并探测到了新代码,这个 - 就会被保留;后续变异在此基础上很容易演变成 --,接着变成 --o、--on……最终自己摸索出 --only 从而攻入迷宫深处,直到撞上 Bug 导致程序崩溃 [18:00, 17:02]。
Top comments (0)