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

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

导出 Fuchsia 内核代码覆盖率,以便通过 Syzkaller 进行模糊测试。

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

摘要

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

背景

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

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

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

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

要求

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

  • 线程级粒度:Syzkaller 的 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 变体将进行扩展,以支持能够收集和传输内核代码覆盖率数据的新系统调用。

实现

系统调用(仅在 sancov 变体上启用)

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

  • coverage_control(uint32_t action):请求内核使用新的缓冲区 (action=KCOV_CTRL_START) 开始收集覆盖率数据 (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 中上述缓冲区中的条目数量的一个计数器

启用线程覆盖率后,内核会分配足够大的缓冲区来收集覆盖率,直到收集被禁用或重置为止。此缓冲区的大小将来可能会发生变化;最初将为 300KiB,大约与 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 格式,但通过系统调用按线程导出该格式。虽然这种方法可行,但效率低下,因为它每次都需要将整个 400 KiB PC 表复制到用户空间,而单次系统调用期间命中的实际 PC 列表通常要少得多(例如,具有 2 个句柄的 1KiB 缓冲区的 zx_channel_read 会收集 163 个 PC,zx_channel_write 会收集 127 个 PC,而总共大约 5.1 万个 PC)。

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

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

  • 测试方法:考虑了一种成本更高(但可能更全面)的测试方法:在主机上运行测试并启动虚拟机。测试会执行一系列系统调用,然后从虚拟机外泄露覆盖范围,并使用 sancov 代码覆盖工具验证 PC 是否属于预期的内核函数。

先验技术和参考资料