17.2 差分测试与模糊测试
编译器的漏洞缺陷(bug)往往悄悄隐藏在测试工程师从未想到去手工编写的怪异程序中。差分测试和模糊测试(fuzzing)能自动化生成海量这类程序,但仅仅能“生成输入”本身还远远不够;测试活动还需要一个强大的 Oracle(判定器),用来准确判断观察到的执行结果是否可疑或不正确。
如果实现中包含一套以简单、直白且完全独立的方式实现了语言规范的参考解释器(reference interpreter),它就会是极好的 Oracle 判定器。我们可以把同一个具有已定义语义的测试案例程序,分别交给该参考解释器和待测的优化编译器去执行,然后仔细比对两者的可观察输出、退出执行状态以及允许发生的副作用。跨编译器测试(Cross-compiler testing)则在多个不同的独立语言实现版本(例如 GCC 与 Clang、或不同的 JavaScript 引擎)之间进行交叉比对;各方执行的一致性只是未发生未知 bug 的一种证据而非绝对数学证明,因为不同的编译器实现可能会共享某些由规范模糊引发的共同 bug,也可能在未定义行为(UB)下作出了不同但都符合各自平台合法性的个性化选择。
蜕变测试(Metamorphic testing)是一种极具实战价值的高级软件测试技术,它不强求预先知道每一个随机生成测试程序的绝对正确执行答案,而是通过对源程序进行一系列在语义上应该保持某种既定关系(Metamorphic Relation)的蜕变变换:例如给变量重命名、在控制流中安全增删死绑定、重排相互毫无执行顺序依赖的多个全局声明,或者采用迥然不同的编译优化级别。一旦最终这些变形后版本的映射关系被在运行中无情破坏(例如优化版与未优化版的行为不一致),便直接锁定了一个高质量的编译器 bug 候选。
测试驱动框架(Harness)的设计必须在初始阶段强力过滤掉那些包含未定义行为(UB)或深度依赖具体目标平台实现定义的测试案例程序。否则诸如除零、有符号整数溢出、未规定的表达式求值顺序差异、目标平台位宽差异以及未初始化变量的值读取,全都会造成合法但令人头疼的行为分歧。Harness 应当只负责规范化那些被规则明确允许发生可控变化的字段(例如路径诊断信息),并且必须要在完全相同的正确边界边界上进行严格的二进制比对。浮点数计算结果的比对通常需要深刻理解语言具体的浮点数语义关系(例如允许的精度偏差),而不能简单偷懒地进行绝对文本相等比對。
例如,一个带类型的自动生成器(typed generator)可以自动构建一段代码 let x: i32 = 7; print((x * 3) - x);。Harness 首先利用参考解释器去求值该 AST,随后分别以 -O0 和 -O2 编译优化标志进行发射执行,并强硬要求这三次独立的执行都必须在终端打印出数值 14。所有的执行结果都必须完整地拆分保存,而绝不能盲目采取所谓的“多数票制(majority voting)”:例如如果基线编译器与优化编译器在降低发射(lowering)同一段复杂 IR 模式时共享了同一个缺陷,那么即便两方输出了完全相同的错误结果,也绝不能将其作为通过验证的凭证去推翻独立的参考解释器 oracle 所给出的正确结果。执行超时(Timeout)、编译器崩溃(compiler crash)、由于内部断言引起的运行时捕获陷阱(runtime trap)以及最终的输出不匹配(output mismatch)是四种特征完全不同的执行反馈输入,应当在 Harness 中自动分发并归属到不同的编译器责任子域中去分别进行归类排查。
模糊测试(Fuzzer)是自适应的反向反馈循环
基于随机变异(mutation)的模糊测试器(fuzzer)通常会指定一个基础的种子用例语料库(corpus)出发去不断随机修改二进制字节或 token 结构。此类测试部署起来非常简单,能非常高效、快速地冲击语法解析器(parser),但纯粹杂乱无章的随机字节很难穿越复杂的校验机制从而到达编译器后端的深层优化与指令生成路径。相比之下,基于语法模板生成(generation)的 fuzzer 会严格按照语言文法规则(grammar)或直接通过生成带类型的 AST 树(typed AST)结构来自动组装测试程序,这能百分之百保证生成的样本语法彻底合法、控制流作用域完全正确且上下文类型完全相符。更为实用的混合系统则会一方面直接变异 AST 语法树结构,再将树结构降级输出为源代码,一方面在变异中定期交叉和拼装 corpus 中的其它高价值代码片段。
代码覆盖度引导(Coverage guidance)会时刻利用插桩测量分析去奖励并保留那些在运行中探索并触及了编译器全新控制流边缘基本块(edges)、全新分支判定条件(comparisons)或全新特定数据传输通路(data-flow features)的测试输入。必须时刻指出:单纯的代码覆盖率是引导模糊测试推进的“导航信号”,而不是模糊测试的最终目的:完全有可能发生两个测试程序在表面上访问了编译器后端同一条相同的控制流边,但是却在特定的算术关系、指针别名关联模式(alias pattern)或底层的中间表示形式(IR shape)上触发了完全不一样的深层崩溃逻辑。从编译器领域自身的特定反馈数据(例如全新生成的操作码组合、特定优化变换 pass 的频繁匹配、两两特殊的类类型组合或底层校验器 verifier 的内部变量状态分布)出发,往往能够把模糊测试器引导并航向至前所未有的深度优化代码深处。
模糊测试的初始语料库设计应当保持体积极小、但是内部模式高度多样:应当完美囊括该语言的每一种基本语法、关键数据类型、极限边界常量与已知的历史缺陷回归用例(regression codes),随后可以用冗余消除算法自动裁剪掉那些执行覆盖率存在高频重复的基础 seed。因为一旦单次的初始测试用例体积过大、格式过于冗长,就会在每轮的运行中严重拖慢模糊测试的吞吐(executions per second)。在运行时,应当强力配备包含完备静态/动态断言(assertions)与主流内存净化分析器(such as ASan/UBSan/MSan sanitizers)的待测编译器调试构建版本,使得任何隐藏在底层的内存非法越界破坏或分析不变量故障在发生的第一时间便立即可见并终止崩溃。每次模拟执行都必须严格对计算运行时间、物理内存以及最终的命令行磁盘输出设置死脑筋的强制上限;因为在现实中,编译器极有可能会在用户的编辑器插件或后台自动构建服务器中去加载并处理恶意的输入,因此执行的无限期挂起(hangs/timeouts)和计算资源爆炸崩溃,在现实工程中全都是极其严峻且极易遭到黑客攻击的漏洞与安全问题。
把模糊测试发现的随机崩溃提炼为确定性的持久行为规范
模糊测试发现的初始编译器崩溃用例文件往往包含成千上万个无辜或与故障无关的 token 字符。用例缩减工具(Reducer)会自动且反复执行“删除/简化部分代码片段”的循环,同时在这个过程中配合执行一个精准的有趣性判定脚本(interestingness test):该脚本要求比对出的新用例必须能百分之百重现与初始崩溃完全相同的 sanitizer 报错特征、完全相同的控制栈顶帧(stack frames signature)、完全一致的编译期断言报错,或者是完全相同的 wrong-code mismatch 逻辑。如果在缩减时仅仅简单粗暴地判定“退出状态码非零”,就会造成缩减逻辑立刻从原有的深度后端优化 bug,顺流向下漂移退化为一段平淡无奇的语法不合规、或者词法无法解析的低级格式错误。在进行用例缩减时,感知目标语言语法的用例缩减工具(Language-aware reduction)必须确保每一次的细微剔除动作都时刻维持住了原本复杂的实体声明、数据类型以及控制流拓扑约束。
对于被 reducer 提炼后的用例,我们可以利用其出错特征堆栈信息进行自动化的去重与分类,但绝对不能盲目轻信简单的调用栈哈希(stack hash):因为类似底层内联变换(inlining)、物理寄存器分配变动或内存分配器的偶发行为,极有可能将两个完全不同根因的 bug 误判并合并到了一起,也极有可能将同一个 bug 在不同优化级别下的表现错拆为了不同的报告。每次成功捕获 bug 后,都应当自动在 bug 数据库中保存一份极简的单波段故障复现代码(reproducer)、初始 seed 引用、当时该软件的精确编译版本系统控制 ID、测试运行时的全量参数配置(flags)、目标芯片架构 target、所有宿主运行环境特征、两侧 oracle 判定器给出的精确比对数据、以及用于缩减 of interestingness 脚本。(且慢,原文件是:以及 reduction predicate。我看错了。)
把随机崩溃变成持久证据
原始 fuzz failure 可能包含几千个无关 token。Reducer 反复删除或简化片段,同时保持一个 interestingness test:相同 sanitizer 类别与顶部 stack frame、相同 wrong-code mismatch,或者相同 compiler assertion。只要求“退出码非零”会让缩减过程从原 bug 裂变(且慢,原文件是:从原 bug 漂移)成普通语法错误。Language-aware reduction 还必须维持声明、类型和控制流约束。
可以按稳定 signature 去重,但不能只相信 stack hash;inlining 和 allocator 行为可能把问题错误合并或拆分。应保存最小 reproducer、seed、compiler revision、完整 flags、target、环境、两侧 oracle 输出以及 reduction predicate。关闭缺陷之前,必须把最终案例加入普通 regression suite。
Fuzzing 是统计活动。报告 executions per second、新路径或领域特征数、corpus 大小、首次发现用时、flaky 比例以及未测试配置。轮换 seed 和 build mode,但每个发现都要可复现。成熟的 fuzz campaign 会不断把随机发现转化为小型、确定、可长期维护的编译器行为规范。