正好要分享一篇论文,所以做一下翻译工作

摘要

GPU 称为现代计算基础设施中不可或缺的一部分。他们在大规模数据集上执行大量的并行任务,并且在 3D 渲染和常规目的并行编程方面有丰富的用户层可接触的 APIs。不幸的是,桥接 API 和 底层硬件 的 GPU 驱动体积正变得越来越庞大且复杂,许多 GPU 驱动暴露了大面积的攻击面并且有非常严重的安全风险。

经过验证,Fuzzing 是一种通过发现潜在漏洞从而缓解安全风险的自动化测试方法。然而,当应用到 GPU 驱动时,现有的 fuzzer 开销较大,并且因为依赖物理GPU从而导致规模性不佳。此外,它们的效果也不是很好,因为在生成输入事件时往往难以满足依赖性和时间上的限制要求。

我们提出了 Moneta,这是一种新的 ex-vivo 驱动程序 fuzzing 方法,可以有状态地、有效地大规模 fuzz GPU 驱动程序。核心思想是① 通过协同结合 snapshot-and-rehost 和 record-and-replay 与我们提出的 GPU 栈虚拟化和 introspection 技术,恢复 in-vivo GPU driver 执行状态 ② 为了从恢复的执行状态中启用并行且多状态的 ex-vivo GPU driver fuzzing,我们实现了 Moneta 的原型且在三种主流 GPU 驱动上进行评估。我们的原型出发了深且实时的GPU 驱动状态,并且在英伟达的 GPU 驱动中找到了五个以前没发现的 bugs、在 AMD Radeon GPU 驱动中找到三个 bug、ARM Mali GPU driver 中找到三个。这十个bug都得到了厂商认定,并为其分配了 5 个 CVE 编号。

介绍

GPU 在各种计算平台中无处不见(手机、台式电脑、workstation)。它们为加速数据并行计算服务,比如渲染或者机器学习。不幸的是,为 GPU 提供支持的软件栈是非常复杂并且漏洞百出。位于 GPU 软件栈最底层的设备驱动尤为重要因为它们与内核权限有交互、有着巨大且通常是专有的代码库,因此暴露了较广的攻击面,攻击者可以通过系统调用交互轻易访问这些攻击面,比如使用 GPU 渲染的 web 浏览器或者使用张量计算的机器学习 runtimes。

fuzzing 是已被验证过识别设备驱动中漏洞的方式,也因此可以缓解 GPU 驱动带来的安全隐患,先前在设备驱动 fuzzing 的工作主要解决了以下两个众所周知的挑战:解决系统调用间的依赖问题(后文用 P1 代替),和提供高度真实的设备层的输入(后文用 P2 代替)。解决也被称作依赖问题的 P1,需要 fuzzer 生成满足执行顺序(比如 read(fd) 需要在 open() 函数之后调用)和参数值约束(比如 read 需要 open 的返回值作为参数)的系统调用序列。这一挑战在设备驱动 fuzzing 中尤为明显,因为① 专用的输入格式(比如 ioctl 的参数)和② 非常长的执行顺序链和系统调用间的参数值依赖。为了解决这一点,研究者提出了一系列动态和静态分析方法。

第二个挑战涉及在模糊测试期间为驱动程序提供高度真实的设备端输入。最朴素的想法是在 fuzzing 过程中使用真实设备,但这样做会严重限制设备驱动 fuzzer 的可用性和规模,因为只有至少一个硬件设备可访问时这些技术才能被使用,并且扩大规模依赖于设备的数量。为了解决这一问题,研究者提出了 ex-vivo 驱动 fuzzing 方法。与 fuzzing 过程中需要硬件 in-vivo 方法相比,ex-vivo 方法不需要硬件(通过硬件访问规避或者静态分析),且只在记录正常设备驱动执行时需要设备硬件,这些执行记录会在之后脱离硬件重放复现。

使用记录重放的 ex-vivo 驱动 fuzzing 十分有前景:记录重放在很大程度上缓解依赖关系挑战 (P1),因为记录由一系列已经满足 order 和 value 依赖关系的输入事件组成,同时还解决了设备端输入要求问题 (P2)。然而,想要实现高度真实的记录重放非常有挑战性(这一挑战在后文记为 P3),因为著名的非确定性问题。任何记录重放范围之外的因素(比如精确的中断或者输入事件的时机)很容易导致记录和重放执行间的差异。此外,即使记录重放精确地捕捉了满足前面说的所有约束的设备端输入事件,也可能无法处理在模糊测试期间生成的新的、以前未见过的 I/O 请求。在软件层模拟所有可能的硬件行为理论上可行,但这需要大量的工程。之前的工作通过从记录的设备 I/O 行为派生规则,以及使用基于派生规则的IO模拟,只解决了上述问题的一小部分。

这篇论文提出的 Moneta 是一种新的 ex-vivo GPU 驱动 fuzzing 方法,这种方法通过精确快速地大规模复现之前的 in-vivo 驱动执行状态,可以多状态地 fuzz GPU 驱动。我们通过结合“确定性的执行状态复现技术:快照重现“,可以克服记录重放的真实性挑战(P3)。快照重现的工作原理是拍摄通常使用真实 GPU 和 GPU 工作负载运行的 GPU 驱动程序的实时快照,并在 ex-vivo 且非 GPU 环境中重新托管快照,以重现快照中捕获的状态。因为从快照中恢复的 GPU 驱动状态精确的镜像了先前有真实 GPU 应用和硬件的 in-vivo 驱动执行结果,它们显著地缓解了记录重放和 ex-vivo fuzzing 中 IO 模拟的精确性挑战。

宏观上讲,Moneta 首先运行 GPU workload(比如渲染工作),在此期间 Moneta 会① 拍摄驱动状态的快照 ② 在拍摄快照后立即记录传递给 GPU 驱动的输入事件(比如 ioctl 调用)。接着,Moneta 精确地从这些快照和快照后的记录中复现状态,并从这一状态开始 fuzzing。这些被复现的执行状态保证了状态的多样性,也因此确保了 GPU 驱动 fuzzing 更有效,有效地规避了内核驱动 fuzzing 中的依赖性挑战(P1)和记录重放的真实性挑战(P3)。除此之外,作为 ex-vivo 驱动 fuzzing 方法,Moneta 显著缓解了硬件需求(P2),确保规模化 GPU 驱动 fuzzing。先前的工作解决的是上述问题的子集,而 Moneta 是第一个同时解决上述所有问题的工作。具体来说,Moneta ①基于现有快照的 fuzzing 方法,在 in-vivo 环境中拍摄快照并用于 ex-vivo fuzzing 环境 ②基于现有 ex-vivo 驱动 fuzzing 方法,通过存储 in-vivo 驱动快照缓解了 IO 模拟完全一致的需求。

Moneta 的具体设计目标有三方面:① 确保不同种类 GPU 驱动的可用性,包括台式和移动端的 GPU 驱动,② 支持在经典的 x86 服务器环境下大规模 fuzzing GPU 驱动,③ 在 ex-vivo 环境中保持设备执行状态的有效性以此保证重放和 fuzzing 的状态多样性。为此,Moneta 首先在虚拟机中启用可以直接访问物理 GPU 的 GPU 驱动。我们提出了“模拟器可快照重现的虚拟化”方案,并以此构建虚拟机,该方案适用于配备各种 GPU 的 x86 和 ARM 主流设备。Moneta 拍摄整个虚拟机的快照来捕获 GPU 驱动执行状态,这一状态之后会在(可能配备模拟 CPU 的)虚拟机中重现,我们可以非常轻易地在 x86 服务器中复制这一虚拟机,以此实现大规模 fuzzing。在快照重现过程中,Moneta 完整保留了快照中记录的实时驱动状态(驱动通过真实 GPU 和 GPU workload 建立的实时上下文)。最后,Moneta 开始从一个非常深且实时的驱动状态开始重放记录和 fuzzing,以此更有效地找到 bug。

我们的实验显示 Moneta 可以 fuzz 非常多的 GPU 驱动,包括英伟达、AMD Radeon 和 ARM Mali GPU driver。我们在 128 线程的 x86 服务器上实现了 128 个实例的并行化 fuzzing。快照和记录在保证 fuzzing 的状态多样性上十分有效。使用 Moneta,我们找到了 10 个前所未有的 bug。Moneta 在基本块级别增加了 GPU 驱动程序的覆盖率(在模糊测试 NVIDIA GPU 驱动程序时,平均高达 137.8%),高于比最先进的基线更强的基线。

总结一下,我们的主要贡献如下:

  • GPU 驱动 fuzzing 的 ex-vivo 方法:我们称为 Moneta 的 exvivo 方法协同结合了快照重现和记录重放,从而实现 (i) 使用快照和记录进行有状态 GPU 驱动程序模糊测试,以及 (ii) 通过复制快照和记录进行大规模模糊测试。

  • GPU 栈虚拟化和观测技术:我们展示了(i)GPU 栈虚拟化技术(即,模拟器可重新托管的处理器虚拟化和内置 IOMMU 的 GPU 的直通 I/O 虚拟化)为所有主流GPU驱动程序的大规模模糊化量身定制,以及(ii)基于预跟踪和虚拟机监控程序的 GPU 栈观测技术,这些技术使有状态的 GPU 驱动程序模糊成为可能。

  • real-world 影响:我们开源了 Moneta1 的原型实现,使用它在 3 个 GPU 驱动程序中发现了 10 个错误,并显著增加了 Linux 内核中 GPU 软件堆栈的覆盖率。在我们负责任地披露后,相应的供应商确认了所有错误,并分配了 5 个 CVE。

背景与示例

GPU 栈概述

支持现代 GPU 图形和计算工作负载的软件栈包含用户空间应用程序理论上可以直接与之交互的多个组件:

  1. 提供 API 的图形或计算库,比如 OpenGL, Vulkan 和 OpenCL。这些库作为 app 和 GPU 驱动之间的中间件,将上层的 API 调用翻译为底层的、用户层 GPU 可以处理的低级调用。

  2. 用户层 GPU 驱动,这一驱动将前面提到的低级调用翻译为硬件可处理的数据和指令,然后通过 syscall 将它们发送给内核层 GPU 驱动

  3. 内核层 GPU 驱动,这一驱动管理 GPU 资源(比如缓冲区对象、纹理单元、着色器和计算单元)的分配和使用。它还协调对 GPU 的安全访问,通过面向用户空间的 API 接收的 GPU 数据和指令转发到 GPU 硬件,并可选择返回从硬件接收的数据。

作为优化性能的工具,大多数内核层 GPU 驱动都允许使用 MMIO 将驻留在 GPU 上的缓冲区对象直接映射到用户层可访问的程序地址空间。这一机制大幅提升特定的渲染和计算负载的性能,因为它允许用户层的栈直接提交数据给 GPU,完全绕过了内核层的 GPU 驱动。图形或计算库的 API 可以直接给 app 暴露这个功能。举个例子,OpenGL 允许 app 暂时或永久地将 GPU 内存中存储的数据缓冲区映射到它们的地址空间中。然而,用户层的 GPU 栈也可以内置地使用 MMIO 缓冲区,即使不需要 app 显式的请求。

因为它们的分层设计和内存映射优化,GPU 驱动有特别广的攻击面。虽然某些外设驱动程序只需担心通过其系统调用接口受到攻击,但攻击者可以通过以下方式以 GPU 软件栈为目标:(i) 调用图形/计算库或用户模式 GPU 驱动程序中的常规函数,(ii) 破坏图形/计算库或用户模式 GPU 驱动程序的内部状态,(iii) 使用系统调用调用内核模式 GPU 驱动程序中的函数, (iv) 损坏 GPU 驻留的 MMIO 缓冲区,或 (v) 它们的任意组合。

然而,并非对 GPU 堆栈的每次攻击都同样危险。例如,仅破坏图形/计算库或用户模式 GPU 驱动程序的攻击对系统其余部分的影响有限。破坏内核模式 GPU 驱动程序的攻击要危险得多,因为它们使攻击者可以完全控制内核,甚至可能控制整个系统。介于两者之间的某个地方是危害 GPU 的攻击,这些攻击只会破坏 GPU 上下文的隔离。攻击者可以使用受损的 GPU 作为内核入侵的垫脚石。不过这些攻击可以通过基于 IOMMU 的 GPU 隔离来缓解,因此我们只关注那些①在内核层 GPU 驱动中的 ②可以被用户层 app 利用的 ③不需要控制 GPU 固件的漏洞。

设备驱动 fuzzing

  1. In-Vivo 驱动 fuzzing:

    设备驱动程序模糊测试的一个关键挑战是生成满足特定值和排序约束的输入有效负载序列(例如,系统调用和 I/O 消息)。例如,应在 GPU 设备文件的 open 调用返回的文件描述符上执行特定于 GPU 驱动程序的 ioctl 命令。为了解决依赖性挑战(称为 P1),前人工作使用了 in-vivo fuzzing,当目标驱动程序在正常使用场景中运行时,它由真实应用程序和真实设备生成的系统调用和 I/O 消息驱动。例如,Charm 和 PeriScope 使用 in-vivo 方法对设备驱动程序的系统调用和外设接口进行模糊测试。这种方法有效地规避了驱动程序模糊测试器的依赖挑战,因为实际的输入生成器很容易将驱动程序引导到各种状态。但是,这种优势是以牺牲可用性和可伸缩性为代价的:只有当一个或多个硬件设备可用时,才能执行模糊测试,并且并行模糊测试只能根据可用设备的数量进行扩展。

  2. Ex-ViVo 驱动 fuzzing:

    为了减轻 in-vivo 方法带来的硬件要求(称为 P2),研究人员提出了 ex-vivo 设备驱动程序 fuzzing 的方法。Ex vivo fuzzer 在受控、无设备的环境中运行。之前的工作用模拟对象替换设备驱动程序,或者通过回顾过去对真实硬件正常驱动程序执行的观察来重现硬件行为。观察结果可以以两种不同的形式生成:(i) 在正常执行期间传递给驱动的所有外部输入的记录,以及 (ii) 在特定时间点捕获完整驱动执行状态的快照。然后,人们可以通过重放记录或重现快照(之前的工作主要用于嵌入式固件分析)来复现这些观察结果。Ex-vivo fuzzing 有助于对驱动程序进行大规模并行模糊测试,因为与硬件的物理实例不同,观测结果很容易复制。

复现驱动的旧执行状态

Ex-vivo 模糊测试器可以通过记录重放 (RnR) 或快照重现 (SnR) 复现过去的驱动程序执行状态。我们在表一总结了他们的共有优缺点,并详细说明它们在 fuzzer 中的使用

表一:  
  1. 记录重放:

    1. 步骤

      1. 记录所有驱动消耗的外部输入(比如 syscall 和传递给驱动的 IO 信息)

      2. 重放这些记录的信息来复现记录时观测到的驱动状态

    2. 通过重放一系列记录的输入信息,记录重放理论上可以复现所有的在一次记录中观测到的驱动执行状态。这意味着可以从不同的驱动执行状态开始 fuzzing,因此也可以触发更广的代码路径和 bug。

    3. 然而,精确且有效的记录重放是非常有挑战性的(P3)。虽然“确定性重放”可以精准的复现之前的执行状态,但这需要记录重放所有的“外部非确定性事件”,比如外设中断和超时。不幸的是记录这些内容会显著降低 fuzzing 的速度。虽然“非确定性重放”比确定性重放开销小,但由于设备驱动的高并行性和时间敏感性,它不能精确复现执行状态。

    4. 虽然有这些未解决的挑战,记录重放的可追溯性,即“追溯一个执行状态是如何产生”的能力,显著的优化了 fuzzing,因为记录可以用来当作 fuzzing 初始化输入的语料库。只要外部输入接口保持向后的版本兼容,可追溯性还允许重复使用记录来模糊测试不同配置的目标(例如,驱动程序配置了不同版本的内核)。

  2. 快照重现

    1. 步骤

      1. 在真实设备正常执行之后拍摄驱动的完整状态

      2. 在脱离设备的环境中重现这个快照

    2. 快照重现技术在嵌入式设备分析领域十分流行,通过存储快照,这一技术可以精确且快速地复现捕获的驱动状态,并且通过复现地状态可以深度探索驱动代码路径。

    3. 但该技术也存在限制。首先,该技术只复现驱动执行状态的子集,以粗粒度快照间隔捕获的驱动程序状态。这也意味着单独使用快照技术只能从子集里的这些状态开始 fuzzing,不能从这些状态之间的小状态开始;其次,快照重现记不住捕获的导致这一驱动执行状态的序列,这意味着它本身无法提供作为 fuzzing 的输入语料库;第三,每一个快照只捕获了目标软件特定版本的状态,因此即使状态相同,它也不能在配置不同的目标上复用。

总览

我们现在介绍 Moneta,一种新的 ex-vivo GPU 驱动 fuzzing 方法,协同结合了快照重现和记录重放两种技术来大规模复现之前的执行状态,并且使用这一状态来实现大规模多状态的 GPU 驱动模糊测试。我们在图一中展示了该方法:Moneta 首先在 GPU workload 在真实 GPU 上执行时捕获多样的 in-vivo GPU 驱动执行状态,①通过离散地拍摄状态快照,然后②持续的记录发送给驱动的外部输入序列,以此分别创建快照组和快照后记录。接着 Moneta 在脱离 GPU 的 ex-vivo 环境中复现这些驱动状态,③通过重现这些快照,然后④重放快照后记录。在细粒度地状态复现过程后,Moneta 通过先前的执行状态来多状态地 fuzz GPU 驱动。

Moneta 地具体设计目标有三方面:

  1. 广泛的可用性:Moneta 应该是一个通用的 ex-vivo GPU 驱动 fuzzer,不像先前的工作,Moneta 可以应用于所有的主流 GPU 驱动,包括独显和集显。

  2. x86-Host 重现性:Moneta 应该可以创建 ex-vivo 的 fuzzing 环境,以此在典型的多核 x86 宿主机上重现快照,从而能够大规模 fuzzing

  3. 实时状态可复现性:Moneta 应该能在 ex-vivo 的环境中复现驱动的实时状态,以至于 fuzzing 可以探索更多的驱动代码路径状态。

图二展示了 Moneta 为达成上述目标而实现的四个组件。在观测阶段,①Moneta 首先创建一个作为监控环境的虚拟机。整个 GPU 软件栈,包括目标 GPU 驱动都运行在这个监控环境中。Moneta 使用模拟器可快照重现的虚拟化方案来创建虚拟机,并给予虚拟机中我们想 fuzz 的驱动直接访问物理GPU的能力。②Moneta 之后在真实 GPU 上执行 GPU workload 时捕获不同的 GPU 驱动的状态。Moneta 使用基于虚拟机的、处理器级别的监控机制来自动生成虚拟机快照和它们的快照后记录。在 fuzzing 阶段,③ Moneta 重现快照并在大量 x86 宿主机上的虚拟机中复现捕获的驱动状态。这允许我们不依赖物理 GPU 而实现大规模 fuzzing。在该阶段中,Moneta 通过将驱动原始的输入源替换为 Moneta 自己的输入提供者,仔细地将 GPU 驱动的输入空间转换为 fuzzer 可操作的空间。④ 将快照生成的驱动状态作为 fuzzing 的入口点,Moneta 最终在这些虚拟机中实现多状态的 GPU 驱动 fuzzing,它的快照后记录作为进化算法的初始语料库。

威胁模型

我们的目标是用 Moneta 找到内核层 GPU 驱动的漏洞,这些漏洞可以被攻击者通过用户层的交互方式轻易触发。这也与先前在 GPU 驱动中和其他内核模式驱动程序中查找漏洞的工作一致(下面是两篇先前关于 GPU fuzzing 的研究)。

[13] O. Chang, “Attacking the Windows NVIDIA driver,” 2017. [Online]. Available: https://googleprojectzero.blogspot.com/2017/02/ attacking-windows-nvidia-driver.html

[39] D. Maier and F. Toepfer, “BSOD: Binary-only scalable fuzzing of device drivers,” in Proceedings of the International Symposium on Research in Attacks, Intrusions and Defenses (RAID), 2021.

攻击者可以是 local 的,他们①拥有访问设备文件接口的权限②希望能够提升权限至 root;攻击者也可以是 remote 的,他们已经远程掌控了一个进程,这个进程已经建立了与 GPU 驱动的连接,比如加速渲染、视频编解码或机器学习。

设计

处理器与 IO 虚拟化

Moneta 的监控阶段目标是生成驱动状态的快照,并在要 fuzz 的驱动对应的 GPU 上执行真实 workloads 时记录外部输入。我们通过在虚拟机中运行驱动和以完整虚拟机快照的形式捕获驱动状态的方式来实现前者。与以前 ex-vivo 的驱动 fuzzer 不同,我们在配有物理 GPU 的宿主机上实例化一个虚拟机。通过给予虚拟机直通物理 GPU 的权限,Moneta 可以在虚拟机中运行真实硬件支持的 GPU 驱动程序(即要 fuzzing 的目标)和 GPU workload。为了以 native speed 运行驱动和 workload,我们使用了在大多数配备了 GPU 的硬件平台都支持的硬件加速的 CPU 虚拟化特性。在后续的 fuzzing 阶段中,我们在脱离 GPU 并且可能是模拟 CPU 的环境中重现配备 GPU 的机器产生的虚拟机快照,这一技术更适合大规模 fuzzing。

支持重现的模拟处理器虚拟化

为了实现在许多种类的 GPU 驱动(包括独显和集显)上实现大规模 fuzzing,我们提出了一种叫做“支持重现的模拟处理器虚拟化”的技术。该技术的核心思想是在配备 GPU 的机器上创建一个虚拟环境,以便我们生成的虚拟机快照可以在配有全系统虚拟化能力的模拟器上重现。能够在模拟器中重现驱动快照让我们能够在比监控阶段用到的机器更高效的机器上 fuzz 驱动。比如,我们可以拍摄昂贵的、配有 Soc-integrated ARM Mali GPU 的 ARM SoC 的快照,然后就像表二展示的那样,我们可以在拥有数十个 CPU 核心的高端 x86 宿主机上重现快照,以此并行地运行高吞吐量的 fuzzer 实例。

理论上,有着不同的程度地模拟支持和模拟能力(包括从软件层面完全实现 CPU 的 CPU 模拟器),在虚拟化环境里重现完全虚拟化的虚拟机的快照是可行的。这是因为理想化的全虚拟化可以将物理硬件的完全抽象提供给虚拟机,这意味着虚拟机不知道它们在使用模拟设备、虚拟设备还是物理硬件。

然而实践中,大多数 CPU 模拟器无法提供如此理想的全虚拟化,因为他们缺乏对许多 CPU 特性的支持,这些支持是在物理 CPU 上可用的。ARM 的 Fixed Virtual Platforms(FVPs) 是特例因为它们精准的模拟了大部分 ARM CPU 的特性。但不幸的是,FVPs 比其他 CPU 模拟器慢得多,因此无法满足我们的设计需求。为了克服可以在 fuzzing 环境中轻松使用的模拟器“缺乏 CPU 特性”的问题,我们提出并实现了 CPU 特性子集化。核心思想是在我们的监控环境中禁用或限制某些 CPU 特性的访问,如果该特性在 fuzzing 环境中不可用。就像我们在 Ⅴ 中展示的那样,CPU 特性子集化允许我们在 x86 宿主机上使用完全模拟的 ARM CPUs 重现虚拟机快照,即使是那些 KVM 加速的 ARM 虚拟机。

直通 GPU 的 IO 虚拟化

为了在我们模拟器支持重现的虚拟机中运行目标 GPU 驱动,Moneta 给予虚拟机直接访问 GPU 硬件的能力。启用直通需要三步:首先,通过创建 MMU 页表入口点,Moneta 给予虚拟机的虚拟 CPU 直通 GPU 的 MMIO 区域,这些页表入口点将虚拟机的虚拟地址转换成物理机的物理地址,这些物理地址对应了 GPU 的 MMIO 区域;第二,Moneta 以“给虚拟机发送的虚拟中断”的形式转发 GPU 的物理中断;第三,Moneta 为 GPU DMA 提供对虚拟机物理内存的访问权限,并在 GPU 的 IOMMU 中创建页表条目。这些入口点将 IO 虚拟地址(IOVAs) 转换为物理机上虚拟机 DMA 缓冲区的物理地址,因此 GPU 可以直接通过 IOVAs 访问虚拟机的系统内存。

第三步并没有前两步那么直接,因为我们需要仔细考虑 IOMMU 的种类和架构。像 PCIe NVIDIA GPUs 的独显经常使用独立的 IOMMU,该 IOMMU 是被宿主机的 IOMMU 驱动控制的。在这个例子中,我们可以通过让宿主机填充 IOMMU 条目的方式启用 DMA,这些条目将 IOVAs 转换为宿主机上虚拟机 DMA 区域的物理内存地址。这意味着当我们使用独立 IOMMU 虚拟化 GPU 时,我们可以在虚拟机中运行没修改的 GPU 驱动。

相比之下,虚拟化 SoC 集成的 GPU(如 ARM Mali GPU)需要修改其驱动程序。因为集成的 GPU 通常具有内置的 IOMMU,这些 IOMMU 是被 GPU 驱动所控制的。这意味着虚拟机中的 GPU 驱动必须设置从 IOVAs 到宿主机物理地址的映射关系,即使虚拟机无法访问对“虚拟机物理地址到宿主机物理地址”的映射。为了支持这种 IOMMU,我们修改了 GPU 驱动以调用自定义 hypercalls,以便在填充 IOMMU 页表条目时获取与虚拟机虚拟地址对应的主机物理内存地址。宿主机中 Moneta 的虚拟机管理器处理这些自定义的 hypercalls。为了维护虚拟机的可重现性,我们实现了自定义的 hypercall handlers,这些 handlers 在 fuzzing 过程中被虚拟机在模拟器中调用。我们将具体讲解直通 IO 虚拟化的实现在第 Ⅴ部分,该部分会以有着内置 IOMMU 的 ARM Mali GPU 为例。

快照和记录的生成

当目标 GPU 驱动在配备了真实物理 GPU 的“模拟器可重现的”虚拟机中运行时,Moneta 监控目标 GPU 驱动的执行。在这个虚拟机中,Moneta 运行真实的 GPU workloads(比如渲染),在此过程中我们生成下面的观测,这些观测可以捕获目标驱动的多种状态:①捕获精确 CPU 和内存状态的虚拟机快照 ②被 GPU 负荷调用的 syscall。

我们使用虚拟机代理,它利用进程和虚拟机自省机制自动生成这些观察结果。像图四展示的那样,这一代理是 Moneta 的一个用户层进程,它使用 linux ptrace API 来将自身插入客户机内核和生成 GPU 工作负载的 GPU 进程之间。该代理可以拦截、检查并可能操纵 GPU 进程的系统调用。Moneta 使用这些插入点来①指导 hypervisor 去生成快照②记录被 workload 进程调用的 syscall ③在 fuzzing 阶段的虚拟机用户空间中实例化 syscall 执行器。

虚拟机快照生成

通过向 hypervisor 发送一个快照拍摄请求,Moneta 的虚拟机代理可以在每个被插入的 syscall 中自动生成虚拟机快照。我们配置虚拟机代理在每执行特定数目的 ioctl 后就拍摄一次快照,这些 ioctl 是对 GPU 驱动设备文件进行操作的 ioctl。为了最小化被生成的快照镜像占用的存储空间,Moneta 用 CoW 文件系统镜像运行虚拟机,因此,只有相对于原始映像修改的数据块才会序列化到每个生成的快照映像中。

快照后记录的生成

就像 Ⅱ-C 中解释的那样,通过快照我们只能粗粒度地复现驱动状态。为了在快照点后细粒度的恢复状态,Moneta 也记录了在拍摄快照后提供给 GPU 驱动的外部输入,这些外部输入就是“快照后记录”。为了捕获外部输入,Moneta 的虚拟机代理记录所有被 GPU workload 进程调用的 syscall。除了 syscall,Moneta 也记录传递给 GPU 驱动的 IO 信息。我们现在的原型实现记录的是通过 MMIO 通道或者中断传递的 IO 信息。

保留状态的快照重现

在生成快照和它们的快照后记录后,Moneta 在 x86 宿主机上创建的 ex-vivo 环境中重现快照。

植入 Moneta 的输入执行器

一恢复虚拟机,Moneta 就通过它的虚拟机代理,用图四所示的② syscall 执行进程去替换原来的输入执行进程(即 GPU workload 进程和虚拟机代理本身),原来的这些输入执行进程在监控阶段驱动 GPU 驱动程序 in-vivo 的执行。虚拟机代理通过①强迫某一个 GPU 进程的线程去调用 execve syscall 同时结束其他 GPU 线程和用虚拟机代理本身,或者②结束整个 GPU 进程并用代理自己本身调用 execve 的方式来完成这一步。同时,Moneta 在虚拟机监控器中创建一个模拟的 GPU(如图四③所示),以此来替换物理 GPU,作为一个 PCI 设备或者平台设备,具体设备类型取决于物理 GPU 原来连接的总线类型。一替换好,Moneta 就能获得对 GPU 驱动输入空间的完全控制权,因此它可以直接给驱动提供任意序列的输入事件,无论他们是记录好的还是 fuzzer 生成的输入事件。

另一种向驱动传递任意输入事件的替代选择是按需 hooking,被现有的输入提供进程生成(比如 GPU 应用)的输入事件在它们被执行和动态变异时被 hook。我们选择了前一种方式,因为后者不允许随即插入或删除输入事件,它只允许变异现有输入生成器生成的输入事件。此外,hooking-based 方式需要 GPU 硬件,即设备端的输入提供器,这一点阻碍了大规模 fuzzing。

保存实时驱动内部的上下文

replacement-based 策略的一个挑战是:在替换过程中保存监控阶段捕获的驱动内部的状态。这一状态包含①User-mode Context:用户进程的上下文信息和 metadata,这些 metadata 生成了 GPU 的工作负荷 ②GPU Context:物理 GPU 的上下文信息和 metadata。Moneta 将这一状态迁移到 fuzzing 环境中并且需要保证这一状态在迁移后保持有效性。如果我们无意中使任何状态失效或丢弃,那么 fuzzing 阶段从驱动程序的初始状态开始,而此时几乎是无状态的。

  1. User-mode Context:Moneta 使用虚拟机代理捕获用户模式上下文。此外,此代理还为在监控环境中运行的 Moneta 控制的用户模式进程维护一个打开的文件描述符列表。在重现过程中,虚拟机代理用 Moneta 的 Syscall 执行器替换 GPU 进程和代理自己,并且它将引用 GPU 驱动程序的设备文件的文件描述符传输到 Syscall Executor 进程。

  2. GPU Context:在用模拟 GPU 替换物理 GPU 时,Moneta 会产生一种错觉,即 GPU 保持不变,以保留使用物理 GPU 建立的驱动程序上下文。为此,Moneta 在物理 GPU 最初连接到的同一总线上的同一地址实例化模拟 GPU,并在恢复快照时抑制总线上的设备分离和重新连接事件。然后,GPU 驱动程序可以继续执行,而不知道 GPU 替换。在重放和 fuzzing 期间,Moneta 将驱动程序和 GPU 之间的所有交互重定向到模拟的 GPU(参见 §IV-D)。我们使用快照后记录在模拟的 GPU 中模拟 MMIO 和 IRQ 响应,如 §V 中所述。

多状态的 GPU 驱动 fuzzing

Moneta 在 GPU 驱动上使用多状态 fuzzing 来寻找多状态的 bug。它通过从快照重现和记录重放中复现的状态实现这一点。我们用算法一正式描述 Moneta 的 fuzzing 算法,并在下面提供详细的描述。

确定性的实时驱动状态复现(第六行到第七行)

Moneta 的 fuzzing loop 首先通过快照恢复确定性地调用 in-vivo 驱动程序执行状态(参见第 6 行)。在这一过程中,Moneta 保存 GPU 驱动需要的 GPU 上下文 和 user-mode 上下文,如 §IV-C(第 7 行)中所述。Moneta 使用这些在快照中捕获的、在 Moneta 重现快照时保存的实时驱动内置上下文,来使 fuzzing 多状态化。在每一次 fuzzing loop 的迭代中,Moneta 使用 syzkaller 在快照恢复后生成一个程序去运行。在大多数情况下,Moneta 不能运行这个程序,因为它可能引用驱动内部的上下文,其文件描述符句柄在重现期间已更改。举个例子,syzkaller 生成的程序可能对某些 file 执行 syscall,但这些 file 的描述符已经在 Moneta 将它们变换成 syscall 执行器进程时就改变了。就像监控阶段记录的那样,这种情况会在该文件原先的描述符在当前 syscall 执行器的上下文中不可用时发生。

为了解决这一问题,Moneta 通过下面的方式生成每个程序:①插入伪 syscall,这些伪 syscall 咨询监控简短记录的 metadata,以此获取正确文件描述符 ②然后生成前面获取的描述符生成后续内容。图五展示了一个示例程序,该程序的最开始包含了两个这样的伪 syscall。后续的 syscall 使用伪 syscall 生成的资源。该技术允许 Moneta ①正确地表达系统调用的快照后记录,例如,在现有文件描述符上调用的 ioctl 调用 ②生成新的多状态化 syscall,这些调用在 fuzzing 期间使用现有资源作为其参数。

细粒度的驱动状态复现和多状态化 fuzzing(第九行到第十九行)

Moneta 在快照生成后立刻记录被调用的 syscall 序列,因为这很有可能是一条合法的 syscall 序列,该序列能够满足这些 syscall 间的依赖关系(因此解决了 P1)。Moneta 将这些快照后记录作为它进化 fuzzing 的初始的种子语料库。当一个快照后记录从语料库中被选中并在 fuzzing 过程中变异(第十行),被变异的程序包含记录的前缀(k 条被记录的输入事件中的 n 条记作 R_s^{(n)}),后面接上 fuzzer 变异的或者生成的部分(记作 F)。注意,记录的任何前缀一旦执行(第 11 行),都可以重现在记录时观察到的特定驱动程序状态。然后,通过执行变异或者生成的部分,Moneta 多状态地从先前复现的执行状态开始 fuzz 驱动

通过检查点恢复(第 8 行和第 20 行)重置状态的 fuzzing loop。

通过使用检查点恢复技术,Moneta 构建了一个可重置状态的 fuzzing loop。当一个新的 fuzzing campaign 开始时,Moneta 立即在一次成功的保留状态的快照重现后创建整个虚拟机的检查点,该检查点安装由 Moneta 的 fuzzer 控制的 Syscall 执行器,但在执行程序使用任何 fuzzer 生成的输入之前。不同于我们在监控阶段生成的快照,该检查点是短暂的。也就是说,它只在每个 fuzzing campaign 中使用。Moneta 在每次 fuzzing loop 结束后恢复这个短暂的检查点,以此保证下一次的输入可以总是从一个干净的、刚被复现的驱动状态开始执行。

实现细节

适配 ARMv8-to-x86 重现的 CPU 特性子集化

我们为 ARMv8 到 x86 虚拟机重现场景实现了“模拟器支持重现”的虚拟化技术(参见 §IV-A);也就是说,我们将从 ARMv8 SoC 拍摄的虚拟机快照重现到运行 QEMU 4.0.0 的 ARMv8 CPU 仿真器(称为 max)的 x86(64 位)机器上。我们配置了 ARMv8-based 的监控环境,以此通过 KVM 使用硬件加速虚拟化。我们发现 max 缺乏许多与性能监控和安全增强有关的特性。具体来说,它不(完全)模拟 ARMv8 的以下功能:性能监控单元 (PMU)、永不权限访问 (PAN)、用户访问覆盖 (UAO) 以及 ARMv8 的通用中断控制器 v3 (GICv3) 提供的中断转换服务 (ITS)。我们通过在虚拟机管理程序级别从观察环境中删除上述所有功能来实现我们提议的 CPU 功能子集,方法是将它们从 KVM 功能中删除,或者通过修改虚拟 CPU 的配置寄存器。

NVIDIA/AMD GPU 直通

GPU 直通需要配置虚拟机,以便①虚拟机能够直接访问 GPU 的 MMIO 区域 ② GPU 的中断在触发时被直接转发到虚拟机 ③ GPU 的 IOMMU 将 IOVAs 转换成分配给虚拟机的物理内存地址。NVIDIA 和 AMD GPU 是连接到 PCIe 总线的独立 GPU,使用独立 IOMMU。Moneta 使用 Linux 的 VFIO 机制将它们分配给 guest [60]。在主机内核中运行的 VFIO 驱动程序配置 IOMMU 驱动程序,以便将 GPU 分配的客户机使用的完整系统内存公开给 GPU 以进行 DMA。

ARM Mali GPU 直通

与可以使用 PCIe root complex 提供的 IOMMU 功能的独立 GPU 不同,SoC 集成的 GPU(如 ARM Mali GPU)通常使用自己的内置 IOMMU,并且内置的 IOMMU 被运行在虚拟机中的 GPU 驱动管理。我们通过修改 GPU 驱动程序,将内置 IOMMU 重新用于 GPU 直通。具体来说,我们重新编程了内置 IOMMU 的转换条目,以便 GPU 用于 DMA 的 IOVA 转换为宿主机物理地址空间,而不是虚拟机物理地址空间。为此,Moneta 引入了一个新的 hypercall,该调用将虚拟机物理地址作为参数,并在将页面固定到该地址后返回其相应的宿主机物理地址。我们修改后的 ARM Mali GPU 驱动程序调用此 hypercall,并在为给定 IOVA 范围构建页表条目时使用 hypercall 返回的主机物理地址。

生成快照和快照后记录

我们基于 strace 实施了 Moneta 的 Guest Agent(参见 §IV-B),以监控和操作 GPU 应用程序进程的 syscall。在监控阶段,代理在每个系统调用中有两个主要用途:它被动地观察和记录系统调用,并通过调用 hypercall 来拍摄虚拟机快照创建后,我们将每个快照与随后的系统调用记录相关联。GPU 驱动程序将对特定于供应商的 (通常是嵌套的) 对象的引用传递给 ioctl 调用。我们在 Guest Agent 中添加了一个最小解析例程,该例程通过将这些对象复制到字节数组中来记录这些对象。此实现需要相当少量的手动工作。通常,我们只需要弄清楚每种对象的大小。对于引用嵌套对象的 ioctl 参数,我们还必须自定义解析例程,以将整个嵌套对象序列化并复制到字节数组中。在最坏的情况下,对于 NVIDIA 驱动程序中的特定 ioctl 操作,这意味着我们必须序列化和复制三个级别的对象

多状态的 GPU 驱动重放和 fuzzing

我们基于 syzkaller 实现了 Moneta fuzzing GPU 驱动程序时的 syscall 接口(参见 §IV-D)。通过将 syscall 记录转换为 syzkaller 程序,我们添加了对重放 syzkaller 中记录的系统调用的支持,以便 syzkaller 的输入执行程序可以执行(或重放)它们。对于设备端输入,我们①通过 hook 调用对应访问函数的 call 来记录所有 GPU 驱动产生的 MMIO 和 IRQ 访问 ②基于记录产生规则(比如 read-only 或者 read-write 规则),以此捕获物理 GPU 上每个 MMIO 位置的行为。③通过 Moneta 的 hypervisor 中的模拟 GPU 设备,实现基于规则的 MMIO 和 IRQ 模拟。为了实现我们状态可重置的 fuzzing loop,我们使用了现有的检查点恢复技术。当 fuzzing 英伟达和 AMD GPU 驱动时,我们使用了 Agamotto 这一现有实现。当 fuzzing ARM Mali GPU 驱动时,我们使用了 QEMU 的快照和恢复机制。

实验评估

A. 快照和记录的生成

fuzzing 目标:内核层 GPU 驱动

我们的主要目标是下面的三个被广泛使用的内核层 GPU 驱动:

  1. 英伟达 GPU linux 驱动:我们使用了官方 NVIDIA Linux Open GPU 内核模块的 530.41.03 版本,我们选择了开源模块来促进编译时源代码插桩。

  2. AMD Radeon GPU Linux 驱动:我们使用了上游 Linux 内核 (v6.8) 中提供的 AMD GPU 的直接渲染管理器 (DRM) 驱动程序。

  3. ARM Mali GPU 的 Linux 驱动:我们使用了 ARM 官方 Mali 驱动程序开发工具包 (DDK)(版本号 r25p0-01eac0)的 vendormodified 版本(版本号 g11p0-01eac0)。如 §IV-A 中所述,我们修改了此驱动程序,以使用内置 IOMMU 添加对 GPU 直通的支持。

为了最大化 fuzzing 的效率,我们使用两个内核 sanitizer 对这些驱动程序进行了检测:KASan 和 UBSan。由于我们在编译时插入了必要的 instrumentation,因此我们需要对 fuzz 的所有驱动程序进行源代码访问。但是,通常我们不需要修改源代码。唯一的例外是当我们需要为具有内置 IOMMU 的 GPU(例如 ARM Mali GPU)的驱动程序添加半虚拟化支持时,正如我们在 §IV-A 中解释的那样。

用户层 GPU 驱动 / API 库 和 GPU 配置

在 Moneta 的观察阶段,我们使用表 IV 中所示的用户模式驱动程序/库和物理 GPU 运行了三个目标内核模式 GPU 驱动程序。

我们使用了单独安装的特定于供应商的闭源用户模式驱动程序和库,例如 libEGL nvidia.so,或者我们使用的客户作系统发行版(Debian 11 代号为“bullseye”)中包含的通用驱动程序和库,例如 libOpenCL.so。在 GPU 方面,我们使用了以下内容:对于 AMD 和 ARM GPU 驱动,我们使用了专用的 PCIe AMD Radeon RX 580 GPU 和 ARM Mali-G610 GPU,分别集成到 RK3588S SoC 中。对于 NVIDIA GPU 驱动程序,我们使用了具有不同处理器架构的最新三代 PCIe NVIDIA GPU。这种种类的 NVIDIA GPU 使我们能够捕获 NVIDIA GPU 驱动程序的更广泛状态,因为该驱动程序包含许多特定于硬件的代码路径。

GPU workload 配置

我们在观察阶段运行了多个图形和计算工作负载。

  1. 图形工作负载:我们选择了在 Chromium 浏览器中运行的 WebGL 应用程序。具体来说,我们访问了一个渲染水族馆场景的网站。 我们选择这个工作负载是因为最近的研究表明,许多 GPU 驱动程序错误可以通过 WebGL 接口来利用。

  2. 计算工作负载:我们使用了生成分形图像的 OpenCL 程序。我们选择了一个简单的程序,因为大多数计算工作负载在内核驱动程序中执行类似的代码路径。在 GPU 加速计算库的不同选项中,我们选择了 OpenCL,因为大多数 GPU 供应商都支持它。

我们使用了多个 GPU 工作负载类来覆盖各种内核模式驱动程序代码路径。不同类别的 GPU 工作负载使用 (i)不同的用户模式库集(例如 OpenGL [54] 和 OpenCL [34]),(ii)不同的用户模式专有 GPU 驱动程序,(iii)通常不同的内核模式驱动程序模块。例如,在 NVIDIA GPU 上运行计算工作负载会触发名为 nvidia-uvm 的 NVIDIA 内核驱动程序模块中的代码路径,这些路径与图形工作负载涵盖的代码路径不同。

生成的快照和快照后记录

使用上面详述的 GPU 软件栈和工作负载配置,我们为目标的每个驱动程序生成了大量快照及其快照后记录。对于 NVIDIA 和 AMD Radeon GPU 驱动程序,我们结合使用图形和计算工作负载为每个驱动程序生成了 128 个唯一快照。对于 ARM Mali GPU 驱动程序,我们只使用计算工作负载生成快照,因为供应商定制的支持 WebGL 的 Chromium 在我们的虚拟机环境中不起作用。对于每个快照,我们在快照后记录中包含了 200 个系统调用。这是一个保守的选择,因为只有不到十几个(200 个)有助于增加覆盖范围,因为由于不确定性,驱动程序执行在十几个调用之后开始与记录的执行不同。我们使用了整个工作负载持续时间生成的 I/O 记录来推导出 MMIO 和 IRQ 规则,如 §V 中所述。

B. Fuzzing 效果

我们在两个主要方面评估了 Moneta 的有效性:(i)它对内核层 GPU 软件栈(包括目标 GPU 驱动程序)的覆盖率,以及(ii)bug 查找能力。

实验设置

按照我们的威胁模型(参见 §III),我们使用 Moneta 对目标 GPU 驱动程序的系统调用接口进行了模糊测试。我们开展了 3 次 24 小时的模糊测试活动,每个目标驱动一次。在每个 fuzzing campaign 中,我们通过利用所有快照以及为该驱动程序生成的快照后记录(参见 §VI-A) fuzz 了单个 GPU 驱动。所有的 fuzzing campaign,包括目标是 ARM Mali GPU 驱动的那个,在配备两个 64 线程 Intel Xeon 8358 CPU(总共 128 个线程)和 1TB RAM 的机器上执行。

我们在每个 campaign 中并行运行了 128 个模糊测试实例。 我们将驱动程序的每个快照(及其快照后记录)分配给一个或多个模糊测试实例,并仅为每个模糊测试实例提供单个快照。这意味着每个模糊测试实例始终从从单个快照及其相应的快照后记录中调用的状态对目标驱动程序进行模糊测试。

我们①通过重放 syscall 的快照后记录,将源于用户层的的输入注入驱动程序 ②通过使用源自 I/O 记录的规则模拟 GPU 来注入设备发起的输入,如 §V 中所述。我们通过为每个模糊测试实例提供其指定的 syscall 快照后记录作为初始语料库,执行了覆盖率引导的进化模糊测试。为了优先对目标 GPU 驱动程序而不是其他内核组件进行模糊测试,我们将覆盖率插桩限制为①目标 GPU 驱动程序②两个用于 GPU 加速的内核子系统,即 drivers/gpu 和 drivers/video。我们还启用了 Linux 内核的 KASan 和 UBSan 插桩,以便在 Moneta 触发时更可靠地检测错误。

基准配置

由于 Moneta 的主要贡献是将记录重放和快照重现相结合以实现更有效的 GPU 驱动程序模糊测试,因此我们首先削弱了 Moneta 重新托管 in-vivo 快照的能力。我们将此配置称为 No Snapshot (无快照)。 第一个 baseline 差不多是“使用记录重放的 SOTA GPU 驱动程序模糊测试器”的增强版本,并具有以下增强功能:①Moneta 在 syscall 接口上的记录重放功能 ②Moneta 的快照重现功能,尽管使用的是在成功探测驱动程序后拍摄的单个快照 ③Moneta 的 ARM-to-x86 快照重现技术。我们引入了另一种称为 No Snapshot/Replay 的配置,它还剥离了 Moneta 的 syscall 端记录重放功能。第二个 baseline 反映了 syzkaller 的常见使用场景,其中进化模糊测试是在没有从 in-vivo 驱动程序执行派生的起始语料库的情况下进行的。

我们强调,为了保守地(因此准确地)评估 Moneta 过去的执行状态复现(即快照重现和记录重放)对 GPU 驱动程序模糊测试的有效性,baseline 都配置为使用 ①我们为 Moneta 手动编写的相同的特定于驱动程序的 ioctl 语法 ② Moneta 的设备端仿真。我们在 §VI-D 中单独评估了 Moneta 的设备端仿真的影响

代码覆盖率结果

我们在图 6 中描述了在模糊测试活动期间获得的目标 GPU 驱动程序的基本块覆盖率。Moneta 在我们针对的所有三个 GPU 驱动程序中始终优于两个强大的 baseline。 这意味着 Moneta 确实可以通过使用快照重现来回避由使用记录重放引起的非确定性问题(P3)。

我们的进一步调查揭示了非确定性影响“记录重放真实性和增强fuzzing有效性”的方式有很多种,例如,NVIDIA GPU 驱动程序的系统调用有效负载(Moneta 性能最佳)通常包括 ①特定的虚拟地址值(可能因 ASLR 而随机化或不受记录重放控制)②分配给各种驱动程序控制资源的特定驱动程序内部句柄值 (当其他资源的创建或删除不受完全控制时,这些值可能会更改,就像前文提到的文件描述符)。理论上,这些值可以包含在记录重放的抽象中。但是,鉴于 GPU 驱动程序的 ioctl 输入空间是巨大且专有的,因此这样做需要大量的工程工作。Moneta 不需要这样的努力,并且可以复现 in-vivo 执行状态,①状态中的值会确定性地恢复 ②重放记录的输入所要求的时间限制更容易满足。

类似的不确定性问题也使设备端 input 生成复杂化。尽管我们从捕获真实 I/O 行为的 I/O 记录中得出规则,但基于记录的基于规则的 I/O 仿真(包括 BSOD 和 Moneta 的仿真)无法像真实 GPU 的 I/O 行为那样准确。Moneta 的快照重现还有助于缓解 GPU 端 I/O 仿真高度真实的挑战,因为通过Moneta的快照重现复现的驱动程序状态精确地复制了过去与真实GPU交互的 in-vivo 驱动程序执行的结果。尽管在恢复 in-vivo 快照后 GPU I/O 行为可能仍未准确模拟,但我们可以使用在真实 GPU 演示该行为后捕获的另一个快照来绕过这种难以模拟的行为。

在比较两个基线时,我们看到代表记录重放的第一个基线(用 No Snapshot(无快照)表示)优于另一个基线(No Snapshot/Replay)。它实现了更高的覆盖率,受益于初始语料库计划,但覆盖率仅略高,这主要是由于前面提到的记录重放带来的非确定性挑战。

bug 检测结果

Moneta 的 fuzzing campaign 总共发现了 10 个以前未知的错误,这些错误存在于我们模糊测试的所有驱动程序中。如表 V 所示,我们在 NVIDIA 中发现了 5 个错误,在 AMD Radeon 中发现了 3 个错误,在 ARM Mali 的 GPU 驱动程序中发现了 2 个错误。

由于我们原型中的 GPU 仿真并不完全准确,因此理论上 Moneta 会误报。对于我们在模糊测试期间触发的每个 GPU 驱动程序错误,我们都验证了它确实可以在我们的威胁模型下真实触发,通过在配备真实 GPU 的机器上调用最小化的 bug 触发系统调用序列的方式(在 §A 中披露)。尽管理论上是可能的,但我们在实验过程中没有遇到任何误报。

我们检查了 Moneta 发现的所有 bug,以评估 Moneta 的 in-vivo 驱动状态复现能力的有效性。 在这些 bug 中,NVIDIA GPU 驱动程序中的四个 bug 只能通过使用 Moneta 的状态重现发现。触发它们需要深入的 in-vivo 驱动程序执行状态,仅通过记录重放很难调用,如表 V 中的 Minimized Bug Trigger 列所示。在这四个 bug 中,我们在 §VI-F 中详细介绍了两个高严重性错误。虽然单独使用记录重放也发现了 ARM Mali 驱动程序中的两个 bug,但这些错误仍然证明了 Moneta 的 ARM-to-x86 重现功能是有用的。也就是说,x86 服务器比高端 ARM 服务器更容易访问,可以通过使用 Moneta 来查找 ARM-SoC 连接的 GPU 驱动程序 bug。

C. Fuzzing 吞吐量

表 Ⅵ显示了每个实验的①执行的输入数量②覆盖率引导模糊测试结束时语料库中保留的输入数量。在对 NVIDIA GPU 驱动程序进行模糊测试时,Moneta 在这两个指标上都明显优于 baseline。但是,当对 AMD Radeon 和 ARM Mali GPU 驱动程序进行模糊测试时,完整 Moneta 配置中的模糊测试速度会低于两个 baseline。 然而,即使执行次数较少, Moneta 覆盖的驱动程序代码路径也比所有 GPU 驱动程序中的所有 baseline 都多,因此,通过覆盖率引导的模糊测试,在语料库中保留了更多的输入。

fuzzing 速度慢是反对使用仿真器进行模糊测试的常见理由。Moneta 使用 CPU 仿真器进行 ARM Mali GPU 驱动程序模糊测试,并且可以将从配备 GPU 的 ARM 平台拍摄的快照重现到具有模拟 ARM CPU 的非本机 (x86) 平台上。尽管比模糊测试 x86 驱动程序慢,但我们认为,在非本机平台上模糊测试 ARM 的 Mali GPU 驱动程序仍然可以实现合理的模糊测试速度(在 24 小时内最多数百万个输入),如表 VI 所示。这可以归因于 Moneta 能够在多核服务器 CPU 上大规模并行化模糊测试,例如我们用于模糊测试 ARM Mali GPU 驱动程序的 128 线程 x86 服务器。

D. 其他 Ablation 研究

尽管我们在 §VI-B 中表明,Moneta 可以通过快照重现和记录重放在多个 baseline 上的协同组合来改进 GPU 驱动程序模糊测试,这些 baseline 代表了内核(驱动程序)模糊测试的先前进展,但我们进行了额外的研究来量化 Moneta 各个组件的贡献。我们通过计算基本块覆盖率来衡量它们的有效性,并针对 NVIDIA 的 GPU 驱动程序,主要是因为它在目标驱动程序中最复杂,并且因为 Moneta 在对其进行模糊测试时最有效。

保留状态的重现技术的影响

我们首先表明,在没有“保留状态的重现技术”(参见 §IV-C)的情况下,驱动程序会有效地忽略使用真实 GPU 工作负载捕获的深入、有趣的状态。我们创建一个 Moneta 的配置,该配置会削弱其用户模式上下文保留(参见 §IV-C),并使用此配置运行相同的模糊测试实验。图 7a 描述了与 Moneta 的完整配置相比的结果。我们发现不保留用户模式上下文的配置的性能明显差于保留用户模式上下文的配置,这表明了 Moneta 的状态保留重新托管的有效性。

设备 I/O 仿真的影响

我们使用 Moneta 运行相同的模糊测试实验,但没有其设备仿真,并在图 7b 中描述了结果。结果显示,在没有 I/O 仿真的情况下,通过 syscall 接口进行 GPU 驱动程序模糊测试的性能会变差。事实上,在 fuzzing campaign 期间,我们观察到 GPU 软件栈发出了许多 MMIO 访问。例如,其中之一是检查 GPU 连接状态。如果未模拟此 MMIO 读取,则驱动程序将拒绝继续执行。

E. baseline 代表性

为了支持我们的说法,即 Moneta 的无快照配置是 BSOD 的更强版本,我们将此 baseline 配置与 BSOD4 的开源版本进行了比较。 尽管 BSOD 是最先进的 GPU 驱动程序模糊测试器,提供了几个关键贡献,但我们的比较特别关注它记录和重放 GPU 驱动程序的设备端输入(即 MMIO 和 IRQ)的能力。由于 BSOD 的开源版本是为模糊测试使用不同 NVIDIA GPU 集我们调整了 BSOD,使其使用与 Moneta 的设备端记录重放用于模拟 GPU 端输入的相同 I/O 记录。为了确保公平性,我们还为 BSOD 提供了我们使用的 NVIDIA 驱动程序 ioctl 语法。通过这些调整,我们对同一目标执行了 128 个实例的模糊测试,即在同一客户内核中运行的同一 NVIDIA 驱动程序。图 7c 中描述的结果证实了我们自己的 No Snapshot baseline 优于 BSOD。运行的 NVIDIA GPU 驱动程序而量身定制的

F. 案例研究: NVIDIA 驱动程序中的两次 OOB 写入

在 NVIDIA GPU 驱动程序中需要 Moneta 通过其快照重新托管实现确定性调用能力的 4 个错误中,我们详细介绍了两个 slab 越界 (OOB) 写入错误,由表 V 中的 Id. 1 和 2 标识。供应商将这些 bug 的严重性评为高,并指出成功利用这些 bug 可能会导致代码执行、拒绝服务、权限升级、信息泄露和数据篡改。

Moneta 通过恢复在多次 syscall 后拍摄的快照,然后对重现期间保留的现有文件描述符进行单个 ioctl 调用来触发这两个 bug。每个触发 bug 的 ioctl 调用的有效负载都包含三个 handle 值(其类型为 NvHandle),这些值引用驱动程序控制的资源,这些值必须是有效的 handle 值才能触发 bug。但是,由于不确定性,仅通过记录重放或通过 fuzzing 来重现有效的句柄值是具有挑战性的。此外,即使我们使用确定性记录重放,在没有快照重现的情况下触发这些错误也需要很长的模糊测试时间。

Moneta 通过恢复 GPU 应用程序调用 339 次 syscall 后拍摄的快照,然后调用 fuzzing 构建的 ioctl 调用来触发第一个 OOB 错误。OOB 访问是在图形上下文(即 vidmemConstruct IMPL)中专门触发的,因此只能通过恢复从图形工作负载获取的快照来触发。我们使用的图形工作负载的快照后记录包含数百个调用错误触发函数的错误触发 ioctl,而在从计算工作负载获得的记录中很少观察到相同的 ioctl(最多一次)。此 bug 可能会提供受约束的 write-where-what 原语。通过构建 ioctl 有效负载,攻击者可以首先使内存分配函数的 size 参数溢出。通过控制此无符号整数溢出的数量,攻击者可以分配一个大小小于其所持有对象的内存区域。驱动程序随后访问此对象的各种字段,其中大多数字段越界,覆盖与分配的内存区域相邻的受害者对象。通过操作堆,攻击者可以 (i)在受害者对象之前分配此区域(暗示 write-where-what 中的 where),以及 (ii) 部分控制写入此相邻对象的值,因为 ioctl 有效负载会影响该值(暗示什么)。

Moneta 在还原 9441 次 syscall 后拍摄的快照后,通过调用 ioctl 调用触发了第二个 OOB 错误。与第一个 OOB 错误类似,触发此错误始于攻击者分配的内存区域小于它应该包含的对象,这是由相同的无符号整数溢出引起的。但是,与第一个 OOB 错误不同的是,此对象是一个数组,并且溢出出发了不同的上下文(即 pmaRegmapScanDiscontiguous)。此上下文中的循环迭代此数组,其终止条件也由 ioctl 有效负载确定,最终导致越界访问。在操作堆布局的帮助下,攻击者可以在受害者对象之前分配此对象(隐含 write-where-what中的 where)。然后,攻击者可以触发越界写入错误,从而覆盖受害者对象。此外,此攻击者可以控制写入受害者对象的值(隐含什么),因为它也是 ioctl 有效负载的一部分。

讨论与限制

GPU 侧的攻击面

本工作侧重于寻找可以被用户层攻击者利用的内核层驱动漏洞,但从 GPU 侧攻击驱动也是有可能的。GPU 可能不需要控制内核层驱动就直接被用户层攻击者控制,而被控制的 GPU 有可以作为之后攻击内核层驱动的跳板。或者,攻击者可以在“drive-by”(例如,邪恶女仆)攻击场景中使用 PCIe 插槽甚至 Thunderbolt 端口 [40] 将恶意 PCIe 设备物理连接到受害者机器上,该机器将自己伪装为 GPU。一连接到恶意 GPU,它的 GPU 驱动就会连接到 CPU 侧,而该驱动之后可以被恶意 GPU 攻击。我们打算之后通过 fuzz GPU 驱动的 IO 接口来探索 GPU 侧的攻击面。

远程攻击面

我们的工作 fuzz 内核层的 GPU 驱动,但是在攻击者可以完全访问驱动暴露在用户层的接口的前提下开展。这些接口其实可以进一步暴露给远程不可信的用户,尽管是间接的。例如,用于加速网页内容的 WebGL [30],用于通过 Web 加速一般计算的 WebGPU [43],或用于加速视频内容解码的 H.264-on-GPU [63]。这些远程内容通常在沙箱中执行 [72],并且由于沙箱的原因,它们可能无法触发 Moneta 发现的低级 GPU 驱动程序错误。然而,我们认为 Moneta 可以发现的漏洞仍然很有价值,因为它们对于构建一个完整的远程漏洞利用链来破坏内核至关重要。此外,除了模糊测试器植入部分外,Moneta 的大部分设计也可以应用于直接暴露于远程内容的 WebGL、WebGPU 和 H.264 接口的模糊测试。在我们未来的工作中,我们打算增强 Moneta 将这些接口直接 fuzz GPU 软件堆栈的能力。

Fuzz 非 Linux 的 GPU 驱动

我们当前的 Moneta 实现面向在 Linux 中运行的内核模式 GPU 驱动程序。我们希望,通过额外的工程工作,我们的方法也可以应用于在其他作系统中运行的 GPU 驱动程序的模糊测试。当以非类 Unix作系统(如 Windows)中运行的 GPU 驱动程序为目标时,大部分工程工作将用于协调系统调用接口中的差异。Moneta 的 Guest Agent 的基于 ptrace 的实现也将被非类 Unix作系统提供的类似流程自省机制所取代。在虚拟机管理程序级别实现的 Moneta 的其他组件(例如 CPU 功能子集)将跨作系统无缝工作。

与其他动态分析协同工作

Moneta 的 GPU 驱动程序执行调用功能还可以满足模糊测试以外的动态分析需求,例如交互式调试和漏洞利用开发。当 Moneta 通过模糊测试识别 GPU 驱动程序中的错误时,可以使用 Moneta 以交互方式调试它们。交互式调试有助于为潜在问题制定适当的修复程序,并评估 bug 的利用潜力。如果该漏洞可以被利用,Moneta 甚至可以帮助交互式漏洞利用开发。此外,Moneta 的有状态模糊测试也可以从其他动态错误查找技术中受益。虽然我们只将 KASAN [36] 和 KUBSAN [51] 应用于 GPU 驱动程序,但其他工具(如内存泄漏检测器和数据竞争错误检测器)也可以与 Moneta 一起使用来查找其他类型的错误。

相关工作

不需要真实设备的驱动分析

为了将 Moneta 置于无设备驱动程序分析的更广泛环境中,我们进一步讨论了非模糊测试方法和其他无设备模糊测试方法。非模糊测试方法使用静态指针/模式分析 [10]、[38]、[9]、[8]、[41] 或符号执行 [49]、[37]、[19]、[45]、[23] 来静态推理驱动程序的行为。另一项工作使用静态分析来增强驱动程序模糊测试,通过符号执行 [56]、[69]、输入格式推理 [14]、[25] 和其他驱动程序定制的静态分析 [17]、[76]。所有这些技术都无需硬件设备即可分析驱动程序,但可能会受到静态分析的精度和可扩展性挑战。此外,由于 GPU 无处不在,因此可以轻松使用 GPU-in-the-loop 方法(如 Moneta)来补充静态分析。

与之前依赖静态分析的工作不同,SATURN [70] 使用模糊测试来增强 USB 驱动程序模糊测试。这个想法是配置一对 USB 驱动程序,一个在主机端,另一个在 gadgets(即设备)端,让它们相互交互。SATURN 同时对两侧进行模糊测试,以触发对面的有趣(或错误)行为。但是,由于对 GPU(即设备端)固件进行模糊测试的难度,这种方法并不容易扩展到 GPU 驱动程序模糊测试。

固件重现与 fuzzing

固件重现是一种在模拟环境中运行固件而无需硬件的技术 [68]。由于其不需要硬件的关键优势,研究人员提出了许多固件重新托管解决方案,这些解决方案使用各种静态和动态分析技术来提高重现环境中固件执行的真实性 [24], [50], [21], [16], [33], [75], [55], [52], [29]。Moneta 与这一行的不同之处在于,它针对配备 GPU 的硬件平台,这些平台比固件重现目标的嵌入式设备功能更强大。特别是,Moneta 使用大多数配备 GPU 的硬件平台中提供的硬件辅助虚拟化,并引入了新的虚拟化基元,有助于在缺乏 GPU 硬件的强大 x86 计算机上重现虚拟机快照。

为 fuzzing 而实现的快照重现技术

采用快照和重新托管方法进行模糊测试的想法受到先前使用裸机内存快照的固件重新托管工作的启发 [50], [29]。这些工作与我们的工作有如下几点不同之处:(i)之前的工作拍摄裸机内存快照,而我们的工作拍摄虚拟机快照,(ii) 之前的工作仅使用快照,而我们的工作将快照与记录和重放结合使用,这需要仔细的状态保留,以及 (iii) 之前的工作针对的是相关但不同的固件模糊测试问题,而我们的工作针对 GPU 设备驱动程序模糊测试。EASIER 还使用快照技术来促进 Ex-vivo Android 设备驱动程序模糊测试 [48];但是,它通过手动转储部分内核内存和 CPU 寄存器来创建自定义快照,这需要大量的工程工作才能在模拟器中重新托管这些部分快照。此外,与 Moneta 不同,EASIER 既不使用记录和重放,也不执行状态模糊测试。

用于内核模糊测试的系统调用插入

系统调用插入已被证明在内核模糊测试中很有用 [15]、[11]。Moneta 还在每次 in-vivo 快照创建和 Ex-vivo 快照还原后使用系统调用插入。 具体来说, Moneta 使用 Linux ptrace API 插入原始 GPU 工作负载进程调用的系统调用,(i) 在每次创建快照后记录它们,以及 (ii) 通过在快照还原后将其中一个调用强制更改为 execve 来植入输入执行程序。但是,ptrace 的一个众所周知的缺点是其运行时开销,这可能会减慢快照后记录的创建速度以及 GPU 工作负载进程本身。为了解决这一限制,可以使用为降低开销而优化的替代系统调用插入机制[65]、[35]、[64]、[67]、[66]、[73]、[31]、[28]、[53]。

结论

我们提出了 Moneta,这是一种新型的设备驱动程序模糊测试器,它结合了 SnR 和 RnR,可以确定性地调用深度驱动程序状态,同时保持进化模糊测试的能力。在观察阶段,Moneta 观察与虚拟机中的目标驱动程序交互的应用程序,该虚拟机实现了我们建议的仿真器可重新托管虚拟化技术。Moneta 会定期生成系统快照和系统调用跟踪,作为其模糊测试阶段的语料库。这一整套技术使 Moneta 能够使用强大的测试基础设施,无需我们要对其驱动程序进行模糊测试的设备的物理实例,从而在大规模范围内高效且有效地进行模糊测试。我们全面评估了 Moneta,并在一组广泛使用的开源 GPU 驱动程序上展示了其独特的功能。我们发现并负责任地披露了 10 个以前未被发现的错误。