基本信息
- 作者:Zhenkun Yang
- 单位:Intel Corporation
- 会议:2020 57th ACM/IEEE Design Automation Conference (DAC)
- 主题:Fuzzing、UEFI、Firmware、Security、Simics
- 链接:https://ieeexplore.ieee.org/document/9218694
内容概述
摘要:本文章提出了一个基于 Simics 虚拟平台的用于测试 UEFI 固件的 fuzzing 框架,成功实现用现有的 fuzzer 对固件进行 fuzzing。
在深入了解之前,首先需要搞清楚两个问题:
- fuzzing UEFI 等固件和 fuzzing 普通软件有什么区别?
- 为什么现有的 fuzzer 无法对固件进行 fuzzing?
对于普通的用户态软件来说,fuzzing 技术已经很成熟了。但是 UEFI 等固件和普通软件有一个很大的区别 —— 固件是运行在操作系统底层的,要想对固件进行模拟运行,就必须使用一些 VP(Virtual Platform 虚拟平台)。而现有的 fuzzer 都是和被测程序一起运行在用户态,通过调用 fork()
等 syscall 控制被测程序的状态,这些操作在 VP 下是无法实现的。
设计
这幅图展示了该测试框架的整体架构,分为 fuzzer 和 Simics 虚拟平台。
可以看到,文章思路就是将整个 Simics 作为一个程序来进行 fuzzing。对于 fuzzer 来说,整个 Simics —— 包括其里面模拟运行的被测固件 —— 就是一个被测的二进制文件。同时 Simics 被配置为仅运行我们想测试的固件。这样 Simics 就变成了一个衔接 fuzzer 和被测固件的中间件。
文章提出,当设计一个由反馈驱动的 fuzzing 框架的时候,需要考虑以下四点:
- 怎么以及什么时候来使用 fuzzing 输入:fuzzer 会产生原始字节流作为 fuzzing 的输入,需要用户来判断何时以及如何让被测固件读取输入。
- 怎么存储程序的状态:如果每次测试都从头开始运行,会很浪费时间。
- 如何收集反馈:执行的反馈对于 fuzzing 的性能是非常重要的,它能帮助 fuzzer 更高效地生成覆盖更多分支的测试用例。
- 如何监测异常行为:fuzzer 需要知道程序出错时的表现。
文章针对以上四点的解决方法分别是:
- 两种方法:一是通过 Simics 来将输入直接插桩;二是通过修改内部测试的固件源代码,让其主动调用自定的 API 来请求输入。
- 通过
fork
系统调用来复制整个 Simics 进程。 - 由于 Simics 仅反馈内部固件的行为,所以对于 fuzzing 引擎来说,虽然在 fuzz 整个 Simics 进程,但是只会收集到内部固件的反馈。
- 用 Simics 进行插桩,来捕获一些重要的固件 panic 错误(比如
CpuDeadLoop()
)。
实现
这幅图展示了利用该方法使用 AFL(American Fuzzy Lop)进行 fuzzing 的过程:
- AFL 根据用户提供的初始输入序列 (种子)来启动和进行初始化,然后启动 Simics 作为子进程。
- Simics 实例化 target,然后开始启动固件。
- 固件运行到目标点,然后通知 Simics 可以开始 fuzzing。
- AFL fork 一个 Simics 进程,包括 Simics 和其内部的运行的固件。
- 固件运行到一个需要读取输入的断点。
- Simics 从 host 复制输入 到 target,然后开始跟踪。
- 固件获取输入,开始执行,到运行到用户指定的断点时通知 Simics。
- Simics 处理跟踪的数据以获取分支覆盖率,并将信息反馈给 AFL,然后终止自己。
- AFL 根据数据生成新的测试用例,回到步骤 4 重复。
如果有输入产生了新的代码转换(这里不太知道怎么翻译),就会将其加入到序列中。测试会一直持续到用户终止或者是达到设定时间。
文章还针对 fuzzing 性能做了两点优化:
- 使用 Checkpoint 来保存 Simics 进程的快照,减小 fuzzing 过程中的内存占用。
- 使用 Simics 特有的插桩框架来跟踪内部固件的运行,减少线程同步带来的性能损失。
VP 和固件之间的通讯是通过 magic instructions 实现的。Simics 的 magic instructions 是 CPUID
指令加上未被使用的 RAX
寄存器的值。文章拓展了这个方法,通过使用 RBX
、RCX
、RDX
寄存器来传递更多信息,然后通过 RSI
寄存器返回结果。
实验
文章进行了三个实验。简单来说就是:
- A:目标是 SMI,在无需获取源代码的情况下,利用 Simics 来检测需要插桩的断点,再由 Simics 将输入插桩进去。
- B.1:目标是 USB I/O,修改固件代码,让固件自己在断点停下,并且调用 API 来请求输入。
- B.2:目标是 USB I/O,修改固件代码,让固件自己在断点停下,用 Simics 将输入插桩进去。
实验环境是:
- Intel Xeon E5-2697 @ 2.60 GHz、64 GB of RAM
- EDK II minimum platform firmware for Simics X58 platform(GitHub)
每秒钟能够执行 10 到 100 次迭代,取决于内存的占用、fuzz 的代码块长度、和每次输入后执行的深度。相比于 fuzzing 用户态程序慢 10 倍左右。
Fuzzing SMI Handlers
这个实验的目标是 SMM,一个 Intel 平台上的最高运行级,类似 RISC-V 的 M 态。SMM 能够访问所有的物理内存,其代码位于一段专有的内存 SMRAM,这段内存是被固件保护的,其他程序包括 OS 都无法访问。BIOS 就是 SMM 级别的。
在现代 OS 启动中,BIOS 在启动完 OS 后并不会直接从内存当中移除,而是会继续驻留在 SMRAM 这段内存中,通过 SMI(System Management Interrupt)来响应一些 OS 的请求。一个具体的 SMI 函数例子是:
可以看到,SMI handler 通过参数 ComBuffer
和 CombufferSize
来向 SMM 传递外部(例如 OS)的数据。如果在这段内存中构造恶意的数据,就有可能破坏 SMM,造成一些高危影响。
要想 fuzz 这个部分,在 SMI handler 被唤起的时候,通过替换参数对应的寄存器的值,指向我们自己构造的外部数据,就能运行我们自己构造的数据。
文章通过这种方式在 OpalPasswordSmm
和 ProblematicFunc
两个 SMI handler 中发现了 high critical 的错误。
这是使用了 fuzzing 之后的测试覆盖率图。实心是使用前,条状是使用后。可以看到相比手动测试,fuzzing 的覆盖率大幅提升了。
Fuzzing 硬件 I/O
文章用了一个之前在产品发布后才被人工发现的 bug 的例子(GitHub)来说明该 fuzzing 的有效性。
如上图所示,当一个新的 USB 设备连接到设备时,固件会尝试通过调用 UsbBuildDescTable()
来识别这个设备。
这个函数首先调用 UsbHcControlTransfer()
来从 USB 控制器获取设备描述符,然后调用 UsbParseConfigDesc()
从传输过来的数据当中将设备描述符解析出来。存储传输过来的数据的缓冲区由固件指定。但是,这个数据对于固件来说是不安全的,可能会导致缓冲区溢出等问题。
文章从两个 level 对这个过程进行了 fuzzing,并且都定位到了问题:
- firmware level:通过让修改固件让其自己调用自定义的
GetFuzzedInput()
来将 fuzzing 数据替换掉原本需要传输的数据。这种方法需要修改固件,但是比较直接。 - memory level:通过 VP 来监控 xHCI,主动替换掉其写入内存的数据。这种方式不需要修改固件,但是由于大多数由 xHCI 执行的内存写入不包含 USB 端的数据,而是一些用于维护 xHCI 正确运行的变量等,随意修改会导致 xHCI 被破坏。所以需要对 xHCI 进行分析,识别出有效的内存再进行修改。
总结
其实这篇文章最主要的创新点是 —— 将 Simics 等 VP 作为固件和 fuzzer 的中间件来实现利用现有的 fuzzer 对固件进行 fuzzing。其他部分应该是都工程上的问题。
难点应该是在 VP 和 fuzzer 之间适配,需要修改 VP 和 fuzzer 的代码,来实现一些拓展的东西,比如上面提到的自定义 API。需要对被测固件较为熟悉,才能针对性地进行 fuzzing。以及一个很现实的问题 —— 能否发现未知的 bug。
总的来说,算是一个很好的 fuzzing 底层固件的参考。或许可以将这个方法用在 RISC-V 架构的 SBI 的 fuzzing 上。