对 Gleam 编译器进行模糊测试
能否通过生成随机程序来发现编译器中的漏洞呢?发布时间为 2026 年 8 月 25 日,周二。
引言
有人经常查看 Gleam 的更新日志和问题跟踪器,非常喜欢这个项目以及为其做出贡献的人们。但每次看到与代码生成或 Erlang 和 JavaScript 之间不同输出相关的问题时,就会纠结,因为根本没办法“穷举所有 Gleam 程序”,运行它们并查看是否存在问题。此人把这想象成一个棋盘,棋盘上有近乎无限的可能局面,希望这个棋盘上摆满 Gleam 程序,并且有一个无限大的程序数据库,以此来发现那些未经测试的边界情况。
最初,此人尝试让一个大语言模型(LLM)来帮忙,让它阅读大量过去的 Gleam 问题,并“深入思考”以找出更多边界情况。它提出了各种各样的位数组组合、嵌套匿名函数和嵌套 `use` 模式。不出所料,这种方法收效甚微。花费了 20 美元的令牌费用后,它只发现了一个问题,该问题被立即报告并修复。虽然一个总比没有好,但“LLM 模糊测试”存在很多问题:成本高、结果不确定,有点像在玩老虎机。不过,此人一直回避尝试另一个想法,因为它听起来工作量太大了,那就是结构感知模糊测试。
结构感知模糊测试
编写软件并非易事,人类在这方面也并非总能做得很好。为了提供帮助,开发了其他软件,能够部分自动化地搜索漏洞。其中一种程序就是模糊测试器(fuzzer),它们生成随机输入,供程序使用。其原理是,从大规模来看,这些随机输入的分布方式会使未曾考虑到的边界情况浮出水面。
模糊测试器的输入范围很广,从完全随机的乱码字节到高度结构化的、了解语法的抽象语法树(AST)都有。向程序输入完全随机的字节,通常用于处理图像、文件、网络请求、协议等的用例。在许多案例中,模糊测试都发现了开源软件中的实际安全漏洞和缺陷。此外,模糊测试器还通过缓冲区溢出发现了一些实际可利用的安全漏洞。谷歌有一个名为“OSS Fuzz”的项目,会持续对许多重要的开源项目进行模糊测试。
在这个案例中,要处理的不是浏览器或网络协议,而是一个编译器,这就为结构感知模糊测试提供了可能。这意味着不是生成随机字节流,而是生成源代码或 AST 形式的代码流。
走进 Gleam
Gleam 有几个特点,使其成为模糊测试的理想候选对象:
- 双目标代码生成:它能为 JavaScript 和 Erlang 两个目标平台生成代码,可以比较同一程序在两个目标平台上的输出,并标记出任何差异。
- 简约的语法:至少与大多数其他流行编程语言相比是这样,可以用相对较少的代码生成有效的程序,覆盖该语言提供的几乎所有概念。
- 静态类型:能确保程序在运行时不会崩溃,但这并不意味着类型系统不会有漏洞。过去就曾出现过与类型推断相关的问题,语言的每个方面都需要有自己的测试方法。
- 函数式特性:一切皆为表达式的特点,让程序的组合和结构变得非常方便。
- 基于 Rust 编写:Gleam 编译器本身是用 Rust 编写的,这使得集成现有的模糊测试工具变得非常容易,可以测试编译器的部分功能,而无需运行单个 `.gleam` 文件。
参考资源
将深入探讨模糊测试器的更多技术细节,但不会涉及太多代码或详细内容。模糊测试器将基于生成式,而非变异式。在一篇文章中,作者得出结论,至少对于 WebAssembly(Wasm)来说,变异式方法比生成式方法发现的问题要多得多,所以,在未来的项目中实现变异式方法可能是值得的!若想更深入地探讨这个话题,可以查看相关资源。可以在分叉的 Gleam 仓库的特定分支中找到 Gleam 模糊测试器的完整代码。
阶段一:解析器
模糊测试器的一个重要设计选择是使用公共编译器 API。尽管编译器 API 可能没有稳定性保证,但这能让其轻松与未来版本的 Gleam 保持兼容。同时,它还避免了处理实现细节,有助于确保不会产生误报或漏报。
以下是解析器如何捕获和分类输出的一些示例,展示了不同代码输入对应的编译结果,如编译成功、解析错误、分析拒绝等。使用 `fuzz crate` 和一些包装代码,可以迅速向 Gleam 编译器发送随机生成的输入(目前还未结构化),看看是否能让编译器崩溃,而非只是给出带有更多上下文的错误信息。只运行 1 秒,因为输出量很大,展示了运行过程中的一些信息和结果。
这样做的好处是,无需运行 `gleam` 二进制文件,就能测试所有内容,它在内存中通过 Rust 编译器管道运行。当让这个模糊测试器运行一段时间后,它真的在 nightly 版本中发现了一个回归问题,而在 `v1.18.1`(撰写本文时 Gleam 的最新版本)中并未出现该问题。关键输入是 `const` 表达式中的管道操作符 `|>`,例如:`const b = 1 |> 2`。一旦发现问题,还会使用 `git bisect` 来处理该问题,以区分是 nightly 版本的回归问题,还是当前 Gleam 最新版本存在的问题。当然,理想情况下,不会只让它运行 1 秒,而是运行数小时。公平地说,在这一步发现的漏洞很容易捕获,但可能无法发现代码生成方面的漏洞。在发布本文时,为了专注于类型安全的程序,libFuzzer 包已从分支中移除,计划在项目的后期阶段进行更有针对性、更高效的编译器崩溃测试(即 [tree - splicer)。
阶段二:类型安全的程序
为了生成类型安全的 Gleam 程序,将构建一个“生成器(smith)”。创建自己简化版的 Gleam AST,然后以概率方式生成程序。这样,当生成器决定“我们需要一个解析为 `Int` 的表达式”时,它可以提供一个字面量值,如 `3`,或者一个匿名函数 `fn() { 3 }()`,或者一个变量,等等。通过这种方式,可以可靠地在程序之间创造出大量的多样性,最终发现新的边界情况。
展示了其中一个程序的样子,看起来像是一堆乱码,但这正是目的,生成的程序由随机选择的有效表达式组合而成,希望能发现那些在一个或两个目标平台上导致错误行为的组合。但是,如果一个 Gleam 程序成功编译,如何知道它是否产生了错误行为呢?这就是两个编译目标发挥作用的地方。例如,如果一个包含 `case` 表达式的程序存在漏洞,而该逻辑在 Erlang 中正确实现,那么当在每个分支中提供不同的值时,JavaScript 目标平台上的输出就会不同,这样就能发现问题。这种方法并非万无一失,但它是一个很好的起点。编译器中可能存在在两个目标平台上都会出现的漏洞,因此这种方法可能会产生误报。正如常言所说:没有万能的解决方案。在介绍 Gleam 生成器之前,要稍微岔开一下话题。
`echo` 问题
JavaScript 和 Erlang 对运行时的值表示方式有不同的理解。例如,JavaScript 中没有专门的整数和浮点数类型,只有 `Number`。当使用 Gleam 的内置 `echo` 关键字将某些值转换为字符串时,它们的表示方式也会有所不同。展示了一个程序在 Erlang 和 JavaScript 上的输出,在 JavaScript 中,`1.0` 打印为 `1`,而在 Erlang 中,位数组 `<<1, 2, 3>>` 打印为 `"\u{0001}\u{0002}\u{0003}"`。在 Erlang 中,记录 `Wibble` 不包含任何标签。如果想在模糊测试器中直接比较输出,这就不太理想。
能想到几种解决这个问题的方法:有意不测试那些知道 `echo` 输出会有差异的值;在 Gleam 中构建一个自定义的 `echo` 函数,并将其注入到生成的模块中;在 Rust 中构建一个自定义的“解析 Gleam 输出”的功能,因为可以从生成的 `Module` AST 中知道哪些值会在 Gleam 中被 `echo` 输出。在这个版本中,选择了第三种方法。由于这是一个约束性很强的问题,生成了大量的测试用例,并编写了相应的代码来解析任何来自 Gleam 程序的 `echo` 输出,并将其解析为 Rust 枚举类型,以便可预测地比较输出。虽然这远非完美,但目前看来效果不错。现在一切就绪,是时候启动模糊测试器,让它生成 10 万个程序了,不过这里又要岔开一下话题。
重复发现和阻塞问题
一旦启动模糊测试器,它会生成大量相似结构的程序。因此,如果发现了一个漏洞,后续还会不断生成能复现该漏洞的程序。有几种方法来处理这个问题:修改 Gleam 生成器的代码,在问题修复之前,不生成某些表达式的组合;如果有可用的修复方案(无论是自己的还是作为 Pull Request),为运行模糊测试器的 Gleam 分叉版本提供并应用补丁,这样可以继续运行模糊测试器,直到修复该问题的 Pull Request 在 nightly 版本中可用,并合并回分叉版本;如果问题是一个错误信息(而不是值不匹配),通常可以找到一个 `string.contains` 语句,在分析时跳过这个问题。
在撰写本文时,选择了第三种方法。确实尝试过打补丁的方法,但这假设 Pull Request 或修复一定是正确的,且不会引入任何新的漏洞。最好还是依赖官方仓库中 Gleam 的准确状态。例如,发现了两个 JavaScript 代码生成方面的问题,所以目前模糊测试器会完全忽略任何与官方仓库中报告的问题具有相似特征的问题。这确实意味着可能会跳过其他具有相似错误特征的代码生成问题。如果程序 AST 中包含导致这些问题的精确表达式,可以引入更多的启发式方法和分析。从更大的规模来看,这绝对是个好主意,因为目前是以 100 个程序为一批运行模糊测试器,并手动验证它跳过或标记为“新漏洞”的任何发现。在 100 个程序中,通常只有少数几个,所以一个人处理起来还是可以应付的。还展示了判断是否为特定问题的代码示例。
使用模糊测试器
如果想在仓库中亲自尝试一下,可以按照示例的命令进行操作,展示了不同种子运行模糊测试的结果,包括匹配情况、值的对比等。还可以将正在运行的程序打印到标准输出,也能批量对程序进行模糊测试,展示了批量测试的过程和相关信息。