物联网设备中固件组件的动态分析
导语:在各种攻击性安全技术中,在分析 IoT/IIoT 设备的安全性时,漏洞评估是重中之重。
在各种攻击性安全技术中,在分析 IoT/IIoT 设备的安全性时,漏洞评估是重中之重。在大多数情况下,此类设备是使用黑盒测试方法进行分析的,在这种方法中,研究人员实际上对研究对象一无所知。通常,这意味着设备固件的源代码不可用,研究人员只能使用用户手册和一些用户论坛上讨论设备操作的几个线程。
IoT/IIoT 设备的漏洞评估基于对其固件的分析。它分几个阶段执行:准备固件(提取和解包),从研究人员的角度搜索感兴趣的组件,在模拟器中运行固件或其部件,最后搜索漏洞。在最后阶段使用了多种技术,包括静态和动态分析以及模糊测试。
分析设备固件的传统方法是将 QEMU 仿真器与 GNU 调试器结合使用。我们决定讨论其他不太明显的固件处理工具,包括 Renode 和 Qiling。这些工具中的每一个都有其自身的特性、优势和局限性,使其对某些类型的任务有效。
Renode 是一种旨在模拟整个系统的工具,包括内存芯片、传感器、显示器和其他外围设备。它还可以模拟多个处理器(在多处理器设备上)之间的交互,每个处理器都可以有自己的架构和固件。Renode 还可以将仿真硬件与实现为可编程逻辑设备(FPGA 芯片)的真实硬件互连。
Qiling 是一个用于模拟可执行文件的高级多平台框架。它可以模拟多种操作系统和环境,包括不同成熟度的 Windows、MacOS、Linux、QNX、BSD、UEFI、DOS、MBR 和以太坊虚拟机。它支持 x86、x86_64、ARM、ARM64、MIPS 和 8086 架构和各种可执行文件格式。它还可以模拟 MBR 加载过程。
我们选择了一个现实世界的设备,一个主要制造商的网络录像机,作为我们研究的对象。该设备基于海思平台,运行Linux。
从制造商网站下载的固件包含一个文件,其中 binwalk 工具检测到 CramFS 文件系统。解压文件后,我们发现 uImage——Linux 内核和 initramfs 的组合映像——以及几个加密脚本和 TAR 档案。
DECIMAL HEXADECIMAL DESCRIPTION -------------------------------------------------------------------------------- 0 0x0 uImage header, header size: 64 bytes, header CRC: 0xCA9A1902, created: 2019-08-23 07:16:16, image size: 4414954 bytes, Data Address: 0x40008000, Entry Point: 0x40008000, data CRC: 0xDE0F30AC, OS: Linux, CPU: ARM, image type: OS Kernel Image, compression type: none, image name: "Linux-3.18.20" 64 0x40 Linux kernel ARM boot executable zImage (little-endian) 2464 0x9A0 device tree image (dtb) 16560 0x40B0 LZMA compressed data, properties: 0x5D, dictionary size: 33554432 bytes, uncompressed size: -1 bytes 4401848 0x432AB8 device tree image (dtb) 1 2 3 4 5 6 7 DECIMAL HEXADECIMAL DESCRIPTION -------------------------------------------------------------------------------- 0 0x0 uImage header, header size: 64 bytes, header CRC: 0xCA9A1902, created: 2019-08-23 07:16:16, image size: 4414954 bytes, Data Address: 0x40008000, Entry Point: 0x40008000, data CRC: 0xDE0F30AC, OS: Linux, CPU: ARM, image type: OS Kernel Image, compression type: none, image name: "Linux-3.18.20" 64 0x40 Linux kernel ARM boot executable zImage (little-endian) 2464 0x9A0 device tree image (dtb) 16560 0x40B0 LZMA compressed data, properties: 0x5D, dictionary size: 33554432 bytes, uncompressed size: -1 bytes 4401848 0x432AB8 device tree image (dtb)
下面,我们从系统层面看一下Renode和Qiling的运行情况。
有关在应用程序级别使用这些工具的信息(以录像机的固件为例),请参阅文章的完整版本。
使用 Renode 的系统级仿真
Renode 是一个完整的系统仿真实用程序,其开发人员主要将其定位为旨在简化嵌入式软件开发、调试和自动化测试的工具。但是,它也可以用作动态分析工具,在漏洞评估期间分析系统的行为。Renode 可用于运行小型嵌入式实时操作系统和成熟的操作系统,如 Linux 或 QNX。该模拟器大部分是用 C# 编写的,因此它的功能可以相对较快地适应研究人员的需求。
描述模拟平台
作为单芯片系统一部分的外围设备通常可通过内存映射 I/O (MMIO) 获得——相应外围模块的寄存器映射到的物理内存区域。Renode 提供了使用带有 .repl(RE节点PL格式)扩展名的配置文件从构建块构建片上系统的能力,该文件描述了哪些设备应该映射到哪些内存地址。
有关可用外围设备和所用平台的内存映射的信息可以在 SoC 文档(如果公开)中找到。如果文档不可用,则可以通过分析设备树 Blob (DTB) 的内容来找到此信息,DTB 是描述 Linux 内核在嵌入式设备上运行 Linux 所需的平台的数据块。
在正在分析的固件中,DTB 块附加到 uImage 文件的末尾(根据 binwalk 工具的信息)。使用 dtc 工具将 DTB 转换为可读格式(DTS)后,我们可以使用它为 Renode 创建平台描述。
开始仿真
必须准备一个初始化脚本,以便在 REPL 文件中描述的平台上运行一些有用的东西。该脚本通常将可执行代码加载到虚拟内存中,配置处理器寄存器,设置额外的事件处理程序,配置调试消息的输出(如果需要)等。
:name: HiSilicon :description: To run Linux on HiSilicon using sysbus $name?="HiSilicon" mach create $name machine LoadPlatformDescription @platforms/cpus/hisilicon.repl logLevel 0 ### create externals ### showAnalyzer sysbus.uart0 ### redirect memory for Linux ### sysbus Redirect 0xC0000000 0x40000000 0x8000000 ### load binaries ### sysbus LoadBinary "/home/research/digicap.out/uImage" 0x40008000 sysbus LoadAtags "console=ttyS0,115200 mem=128M@0x40000000 nosmp maxcpus=0" 0x8000000 0x40000100 ### set registers ### cpu SetRegisterUnsafe 2 0x40000100 # atags cpu PC 0x40008040 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 :name: HiSilicon :description: To run Linux on HiSilicon using sysbus $name?="HiSilicon" mach create $name machine LoadPlatformDescription @platforms/cpus/hisilicon.repl logLevel 0 ### create externals ### showAnalyzer sysbus.uart0 ### redirect memory for Linux ### sysbus Redirect 0xC0000000 0x40000000 0x8000000 ### load binaries ### sysbus LoadBinary "/home/research/digicap.out/uImage" 0x40008000 sysbus LoadAtags "console=ttyS0,115200 mem=128M@0x40000000 nosmp maxcpus=0" 0x8000000 0x40000100 ### set registers ### cpu SetRegisterUnsafe 2 0x40000100 # atags cpu PC 0x40008040
该脚本将 uImage 文件加载到平台内存中的 binwalk 输出地址,配置内核参数,并将控制权传递给地址 0x40008040,因为前 0x40 字节由 uImage 标头获取。
开始仿真后,我们得到了一个功能齐全的终端,我们可以与它进行交互,就像我们在任何 Linux 系统上的终端一样:
Renode 模拟器提供了足够的功能来快速开始对正在研究的固件进行动态分析。作为一个动手示例,我们能够部分运行网络视频录像机的固件,而无需实际手头有录像机。在接下来的步骤中,我们可以使用模拟文件系统中可用的工具来解密加密的固件文件,提取提供记录器功能的内核模块并分析它们的逻辑等。
由于 Renode 仿真器为基于 ARM 架构的片上系统中常用的外设提供了足够广泛的支持,因此无需编写任何额外代码即可查看功能齐全的 Linux 终端。同时,在必要时,仿真器的模块化架构及其脚本和插件编写功能使得在足以进行研究的水平上实现对任何缺乏功能的支持变得相对容易。
该工具的显着特点之一是它使用系统级仿真。因此,很难使用它来模糊测试或调试在模拟操作系统中运行的用户空间应用程序。
该工具的缺点包括缺乏详细的文档,现有文档仅描述了最基本的使用场景。在实现更复杂的东西时,例如新的外围设备,或者试图了解特定内置命令的工作原理时,您必须反复参考 GitHub 上的项目存储库并研究模拟器本身和捆绑的源代码外围设备。
使用 Qiling 框架进行模糊测试
Qiling 框架是用 Python 编写的,这使得根据研究人员的特定需求调整其功能非常容易。麒麟框架底层有独角兽引擎,它只是一个机器指令的模拟器,而麒麟提供了许多高级功能,例如从文件系统中读取文件、加载动态库等。
与 QEMU 相比,Qiling Framework 可以模拟更多平台,并提供灵活的模拟过程配置,包括即时修改执行代码的能力。此外,它是一个跨平台框架,这意味着它可以用来在 Linux 上模拟 Windows 或 QNX 可执行文件,反之亦然。
作为演示的一部分,我们将尝试使用 Qiling 模糊测试 hrsaverify 实用程序,它是我们正在分析的固件的一部分,使用 AFL++,一个用于验证加密文件的实用程序,它将文件的路径用于被验证为论据。Qiling 框架在其存储库的示例/fuzzing 目录中已经有几个运行 AFL++ fuzzer 的示例。我们将修改名为 linux_x8664 的示例以运行 hrsaverify。运行 fuzzer 的修改脚本如下所示:
import unicornafl as UcAfl UcAfl.monkeypatch() import os, sys from typing import Any, Optional sys.path.append("../../..") from qiling import Qiling from qiling.const import QL_VERBOSE from qiling.extensions import pipe def main(input_file: str): ql = Qiling(["../../rootfs/hikroot/usr/bin/hrsaverify", "/test"], "../../rootfs/hikroot", verbose=QL_VERBOSE.OFF, # keep qiling logging off console=False, # thwart program output stdin=None, stdout=None, stderr=None) # don't care about stdin/stdout def place_input_callback(uc: UcAfl.Uc, input: bytes, persistent_round: int, data: Any) -> Optional[bool]: """Called with every newly generated input.""" with open("../../rootfs/hikroot/test", "wb") as f: f.write(input) def start_afl(_ql: Qiling): """Callback from inside.""" # We start our AFL forkserver or run once if AFL is not available. # This will only return after the fuzzing stopped. try: if not _ql.uc.afl_fuzz(input_file=input_file, place_input_callback=place_input_callback, exits=[ql.os.exit_point]): _ql.log.warning("Ran once without AFL attached") os._exit(0) except UcAfl.UcAflError as ex: if ex.errno != UcAfl.UC_AFL_RET_CALLED_TWICE: raise # Image base address ba = 0x10000 # Set a hook on main() to let unicorn fork and start instrumentation ql.hook_address(callback=start_afl, address=ba + 0x8d8) # Okay, ready to roll ql.run() if __name__ == "__main__": if len(sys.argv) == 1: raise ValueError("No input file provided.") main(sys.argv[1]) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 import unicornafl as UcAfl UcAfl.monkeypatch() import os, sys from typing import Any, Optional sys.path.append("../../..") from qiling import Qiling from qiling.const import QL_VERBOSE from qiling.extensions import pipe def main(input_file: str): ql = Qiling(["../../rootfs/hikroot/usr/bin/hrsaverify", "/test"], "../../rootfs/hikroot", verbose=QL_VERBOSE.OFF, # keep qiling logging off console=False, # thwart program output stdin=None, stdout=None, stderr=None) # don't care about stdin/stdout def place_input_callback(uc: UcAfl.Uc, input: bytes, persistent_round: int, data: Any) -> Optional[bool]: """Called with every newly generated input.""" with open("../../rootfs/hikroot/test", "wb") as f: f.write(input) def start_afl(_ql: Qiling): """Callback from inside.""" # We start our AFL forkserver or run once if AFL is not available. # This will only return after the fuzzing stopped. try: if not _ql.uc.afl_fuzz(input_file=input_file, place_input_callback=place_input_callback, exits=[ql.os.exit_point]): _ql.log.warning("Ran once without AFL attached") os._exit(0) except UcAfl.UcAflError as ex: if ex.errno != UcAfl.UC_AFL_RET_CALLED_TWICE: raise # Image base address ba = 0x10000 # Set a hook on main() to let unicorn fork and start instrumentation ql.hook_address(callback=start_afl, address=ba + 0x8d8) # Okay, ready to roll ql.run() if __name__ == "__main__": if len(sys.argv) == 1: raise ValueError("No input file provided.") main(sys.argv[1])
我们首先要查找的是可执行文件的基地址(在我们的例子中为 0x10000),即主函数的地址。有时需要在其他地址上额外设置钩子,当遇到这些地址时,模糊器应将其视为崩溃。例如,在 QNX 环境中(在 qnx_arm 目录中)运行 AFL 时,为 libc 中 SignalKill 函数的地址设置了这种类型的附加处理程序。在 hrsaverify 的情况下,不需要额外的处理程序。还应该记住,所有必须对正在运行的应用程序可用的文件都应该放在 sysroot 中,并且应该传递它们的相对路径(在这种情况下,../../rootfs/hikroot/)。
AFL++ 使用以下命令启动:
AFL_AUTORESUME=1 AFL_PATH="$(realpath ./AFLplusplus)" PATH="$AFL_PATH:$PATH" afl-fuzz -i afl_inputs -o afl_outputs -U -- python ./fuzz_arm_linux.py @@ 1 AFL_AUTORESUME=1 AFL_PATH="$(realpath ./AFLplusplus)" PATH="$AFL_PATH:$PATH" afl-fuzz -i afl_inputs -o afl_outputs -U -- python ./fuzz_arm_linux.py @@
AFL fuzzer 将启动,一段时间后我们会看到一些崩溃:
Qiling 是一个很有前途的工具,其主要优势在于其高度的灵活性、可扩展性以及对各种架构和环境的支持。在无法使用 QEMU 的情况下,该框架可以作为 QEMU 的替代品。然而,由于使用 Python,其高灵活性和浅学习曲线也导致其相对较低的仿真和模糊测试速度。
发表评论