RFC-0078:适用于 Fuchsia 模糊测试的内核排错程序覆盖范围

RFC-0078:适用于 Fuchsia 模糊测试的内核排错程序覆盖范围
状态已接受
领域
  • 内核
说明

导出 Fuchsia 内核代码覆盖率,以便将其用于 Syzkaller 模糊测试。

作者
审核人
提交日期(年-月-日)2021-02-26
审核日期(年-月-日)2021-03-25

总结

此更改将引入新的系统调用,用于收集和传输内核代码覆盖率数据。系统调用只能在现有 sancov build 上实现。其他 build 变体不会受到影响(新的系统调用返回 ZX_ERR_NOT_SUPPORTED)。用于内核覆盖的初始客户端是系统调用模糊引擎 Syzkaller。已实现此方案的概念验证以及单元测试(请参阅后代更改),以评估其效果。

背景

Syzkaller 是一种覆盖率引导型内核模糊测试工具。它会生成一系列系统调用来测试操作系统,并依据覆盖率信息来改变它们并确定哪些序列是有用的。Fuchsia 中已经使用了 Syzkaller,但当前的集成不会收集代码覆盖率数据。

在 Fuchsia 上,Syzkaller 以 HostFuzzer 模式运行,在该模式下,模糊引擎 (syz-fuzzer) 位于模糊虚拟机之外,并与执行一系列系统调用的模糊代理 (syz-executor) 进行通信,并将覆盖率传回引擎。

您可以使用 Clang 的 SanitizerCoverage (sancov) 插桩来获取内核代码覆盖率。此插桩的工作原理是:在每个基本块上添加对 __sanitizer_cov_trace_pc_guard 的调用,然后我们实现该函数来跟踪访问过的程序计数器 (PC)。Linux 从 2016 年起便开始提供相关支持,其实现方式是按线程列出所涵盖的 PC。

Zircon 支持同样适用于 Zircon 内核的 sancov build 变体,以 sancov 格式(即命中 PC 的稀疏表)导出实时 VMO。但是,Syzkaller 会同时运行多个程序,并会考虑每个系统调用的覆盖率,因此我们需要更精细的信息。

要求

成功的实现必须以有助于模糊引擎的方式导出内核代码覆盖率数据。目前,主要消费者是 Syzkaller;因此,许多要求来自 Syzkaller 的架构和假设。具体要求如下:

  • 线程级粒度:Syskaller 的 syz-executor 使用线程池来执行系统调用,收集每个系统调用的覆盖率,即每个系统调用上下文中由内核执行的代码。

    它使用的格式是一组命中的程序计数器。每个工作线程的伪代码如下所示(每个作业都是一个系统调用):

Thread:
    Enable Coverage
    While True:
        Job <- wait for job event
        Start Tracking Coverage
        Execute Job
        Collect Coverage
        Signal job done

Syzkaller 实现的策略意味着,需要仅从那些为 syz-executor 工作器线程发起的系统调用提供服务的内核线程中收集内核覆盖率信息。

  • 快速:应最大限度地缩短收集覆盖率数据并将其传输到模糊引擎所需的时间,因为 syz-executor 会在运行每个系统调用后查询覆盖率信息。Syzkaller 通过更快速的收集和传输流程,在相同时间内测试更多计划。

  • 低噪声:当被测系统调用确定性地执行代码外,没有信号覆盖时,Syzkaller 可发挥最佳性能。对为 syz-executor 线程提供服务的内核线程进行全面插桩几乎可以做到这一点,但因为调度程序代码和服务中断会引入噪声。成功的实现应尽可能减少噪声。

  • 仅供测试用:在常规 build 中不应启用用于收集和导出覆盖率的功能。它们只能属于使用 sancov 变体的 build。覆盖率收集接口不一定稳定;它只能用于 Syskaller 等模糊引擎。非 sancov build 的内存用量或运行时性能不得受到影响。

不在范围内

最好能满足以下要求,但这些要求不在此 RFC 的讨论范围之内。那就把它们想象成“未来的作品”吧。

  • 实现更复杂的粒度和/或控制流跟踪,例如进程级跟踪或“指挥棒”,以跟踪对通信进程的覆盖率。

  • 提供排除内核特定部分的覆盖率集合的机制。这与低噪声要求有点不同,但内核级覆盖范围将足够低的噪声来指导 Syskaller 在初始实现中。

  • 从执行被测系统调用的线程以外的内核线程收集覆盖率数据。

设计

现有的 sancov 变体将进行扩展,以支持新的系统调用,以便收集和传输内核代码覆盖率数据。

实现

Syscalls(仅在 sancov 变体上启用)

引入了以下新的系统调用。sancov 变体 build 支持这些系统调用。

  • coverage_control(uint32_t action):请求内核使用新的缓冲区开始收集覆盖率数据 (action=KCOV_CTRL_START),或停止收集覆盖率数据 (action=KCOV_CTRL_STOP)。

  • coverage_collect(uintptr_t* buf, size_t count, size_t* actual, size_t* avail):请求内核将当前覆盖率数据复制到 buf 引用的用户空间缓冲区。唯一的可选参数是 avail。该操作不会消耗数据;在内核缓冲区被 coverage_control(KCOV_CTRL_START) 重置之前,数据将保持可用。复制到 buf 的字节数存储在 actual 中,当前可用的字节总数存储在 avail(如果已设置)中。返回值 ZX_ERR_NO_SPACE 表示内核的覆盖率缓冲区空间不足,并且覆盖率数据可能已丢失;返回值用于提示客户端它可能需要更频繁地收集,以确保不会丢失任何数据。

请注意,这些系统调用仅控制单个线程的覆盖率收集,对任何全局覆盖率收集没有任何影响。这样,Syzkaller 和其他模糊测试工具就可以确保只收集到自己关注的部分的覆盖率。

内核内存要求(仅限 sancov 变体)

  • 在启用了覆盖率的情况下,每个用户线程各有一个 300KiB 的缓冲区

  • 指向 ThreadDispatcher 中上述缓冲区的一个指针

  • ThreadDispatcher 中上述缓冲区中条目数的一个计数器

在为线程启用覆盖率后,内核会分配足够大的缓冲区来收集覆盖率,直到收集停用或重置为止。该缓冲区的大小将来可能会发生变化;初始大小为 300 KiB,大约是 sancov PC 表的大小。必须始终提交此内存;这通过在内核的根 VMAR 中创建一个内核专用 VmMapping(类似于 kstack 的创建方式)并将 VMAR 句柄存储在 ThreadDispatcher 中来实现。

ThreadDispatcher 存储指向此缓冲区的指针,以及其所含条目数的计数。如果某个会话超出覆盖率限制,则不会注册新的覆盖率。coverage_control(KCOV_CTRL_START) 将计数器重置为零(并开始收集新的覆盖率数据缓冲区);无需花费时间清空该缓冲区。为了避免在非 sancov build 中增加内存开销,系统可能会将这些 ThreadDispatcher 字段 #ifdef 输出。

Sancov 数据收集

__sanitizer_cov_trace_pc_guard 会检查当前正在运行的线程,以查看是否已启用缓冲区,如果是,则将命中的 PC 附加到列表中。系统调用在当前线程上运行,因此不会与其他线程争用。处理系统调用时,内核线程可能会中断;这样会产生噪音,但不会产生争用。为线程启用覆盖率后,缓冲区将保持分配状态,直到线程销毁。

__sanitizer_cov_trace_pc_guard 无法在运行时处理故障或异常,因为处理程序可能会再次调用 __sanitizer_cov_trace_pc_guard,并且最终可能会陷入循环。为了避免故障,必须始终提交用于存储 PC 的内存。

再次入境的风险

__sanitizer__* 函数之间存在一定程度的重入风险。请谨慎控制重入来源,以确保不会导致出现问题。具体而言:

  • __sanitizer__* 实现不会直接或间接调用 __sanitizer__* 函数

  • __sanitizer_* 函数不会获取任何锁

因此,只有那些不会导致无限递归或死锁的中断才是重入的根源。下文讨论了中断导致的覆盖范围数据中的噪声。

覆盖率数据中的噪声

在此设计中,覆盖率数据中会出现以下噪声来源:

  • 在缓冲区重置之后、返回之前执行 coverage_control(KCOV_CTRL_START) 中的代码

  • 在停止收集前执行 coverage_control(KCOV_CTRL_STOP) 中的代码

  • 在将数据复制到客户端缓冲区之前执行 coverage_collect 中的代码

  • 处理目标用户线程的系统调用时执行的中断

其中大多数噪声来源近乎确定性(即相同的代码路径将出现在每一批收集到的覆盖范围中)。某些来源可通过将特定代码列入拒绝名单的 sancov 机制来消除,但这些干扰来源足够小且可以预测,初始实现不会增加管理拒绝名单的复杂性。

性能

sancov 变体 build 的性能不会发生任何变化。受此变更影响的唯一部署是执行新系统调用的 sancov 变体 build。在这种情况下,期望性能保持不变,但当线程启用内核覆盖时,性能会突然下降,因为该线程执行的每个系统调用都会在内核中执行的每个基本块上触发缓冲区写入。这种性能下降是可接受的,因为它仅在为了收集此类数据而运行内核模糊测试工具引擎时才会引入。

安全注意事项

内核代码地址通常被视为非常敏感的信息,对攻击者来说非常宝贵。通过启用仅在 sancov(仅限测试、非生产)变体 build 中显示此信息的系统调用,可以缓解在正式版设备上泄露此信息的风险。

隐私注意事项

此方案不涉及收集或处理用户数据。

测试

测试分两个阶段实现:

  1. 单元测试会提取从 zircon 映像中提取的系统调用地址,并在多个条件(例如,单个系统调用、多个系统调用、多个通信线程、线程崩溃等)下检查各种系统调用是否存在(和是否存在)
  2. 集成测试在虚拟机上运行,因为用户空间程序无法获取内核符号信息。虚拟机会将覆盖率数据导出到主机环境,在主机环境中,sancov 代码覆盖率工具用于验证 PC 是否属于预期的内核函数。

除了初始实现之外,我们还计划进行单元测试和测试,确保新的系统调用在非 sancov build 变体中返回 ZX_ERR_NOT_SUPPORTED

文档

新的系统调用将按常规方式集成到 Zircon 文档中,同时提供注意事项和构建说明,以说明系统调用与 sancov build 变体之间的关系。

缺点、替代方案和未知情况

作为本次设计和实现的一部分,我们考虑了以下替代方案,但拒绝了:

  • 数据格式:一种替代方案是保留当前的 Sancov 格式,但通过系统调用按线程按线程导出该格式。虽然这种方法可以运行,但效率低下,因为它每次都需要将整个 400KiB PC 表复制到用户空间,而在单个系统调用期间实际命中的 PC 列表通常要少得多(例如,包含 2 个句柄的 1KiB 缓冲区的 zx_channel_read 会收集 163 个 PC,zx_channel_write 会收集 127 个 PC,而总共约 51000 个 PC)。

  • 插桩方法:Intel Processor Trace 或 QEMU 插桩是两种通过编译器插桩的覆盖率替代方案。这些替代方案可能是可行的,但它们的设置工作需要大量精力,并且不如 Clang 的 SanitizerCoverage 插桩方法灵活。

  • API 设计:我们的原始设计是在内核和用户空间之间共享一个 VMO,而不是使用单独的 cover_collect 方法将覆盖率信息复制到用户空间。但我们决定不这样做,因为 Zircon 团队并不建议这样做:vmos 不会在内核和用户空间之间共享,但这样做的好处是,我们不需要将覆盖范围从内核复制到用户空间。

  • 测试方法:考虑采用一种更昂贵(但可能更全面)的测试方法:在主机上运行测试并启动虚拟机。测试会执行一系列系统调用,然后从虚拟机中渗漏出覆盖率,然后使用 sancov 代码覆盖率工具验证 PC 是否属于预期的内核函数。

早期技术和参考资料