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 格式(命中的 PC 的稀疏表)导出实时 VMO。不过,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_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,而 __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,而总 PC 数约为 51,000 个)。

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

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

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

在先技术和参考文档