| 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 支持 sancov build 变体,该变体也适用于 Zircon 内核,可导出 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_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'd out。
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 中启用显示此信息的系统调用,可以降低在生产设备上泄露此信息的风险。
隐私注意事项
此提案不涉及收集或处理用户数据。
测试
测试分两个阶段进行:
- 单元测试会提取从 Zircon 映像中提取的系统调用地址,并检查在多种条件(例如,单个系统调用、多个系统调用、多个通信线程、线程崩溃等)下是否存在(以及不存在)各种系统调用
- 集成测试在虚拟机上运行,因为用户空间程序无法获取内核符号信息。虚拟机将覆盖率数据导出到主机环境,在该环境中,sancov 代码覆盖率工具用于验证 PC 是否属于预期的内核函数。
除了初始实现之外,该计划还包括提供单元测试和确保新系统调用在非 sancov build 变体中返回 ZX_ERR_NOT_SUPPORTED 的测试。
文档
新的系统调用将以常规方式集成到 Zircon 文档中,同时还会提供注意事项和 build 说明,以解释系统调用与 sancov build 变体之间的关系。
缺点、替代方案和未知因素
在此设计和实现过程中,我们考虑了以下替代方案,但最终拒绝了这些方案:
数据格式:一种替代方案是保留当前的 Sancov 格式,但通过系统调用按线程导出。虽然这种方法可行,但效率不高,因为每次都需要将整个 400KiB PC 表复制到用户空间,而单个系统调用期间实际命中的 PC 列表通常要小得多(例如,具有 2 个句柄的 1KiB 缓冲区的
zx_channel_read收集 163 个 PC,zx_channel_write收集 127 个 PC,而总 PC 数约为 51k)。插桩方法:有两种编译器插桩覆盖率替代方案:Intel 处理器跟踪或 QEMU 插桩。这些替代方案可能可行,但需要花费大量精力进行设置,并且不如 Clang 的 SanitizerCoverage 插桩灵活。
API 设计:我们最初的设计是在内核和用户空间之间共享一个 VMO,而不是使用单独的 cover_collect 方法将覆盖率信息复制到用户空间。不过,我们最终决定不这样做,因为 Zircon 团队不建议这样做:vmo 不应在内核和用户空间之间共享,但这样做的好处是我们无需将覆盖率从内核复制到用户空间。
测试方法:我们考虑了一种成本更高(但可能更彻底)的测试方法:在宿主机上运行测试并启动虚拟机。测试会执行一系列系统调用,然后将覆盖率从虚拟机中泄露出来,并使用 sancov 代码覆盖率工具验证 PC 是否属于预期的内核函数。