逆向嵌入式设备引导程序(U-Boot)- 第二部分

译文声明
本文是翻译文章,文章原作者:ZI0BLACK & THEZERO
原文地址:https://www.shielder.com/blog/2022/03/reversing-embedded-device-bootloader-u-boot-p.2/

译文仅供参考,具体内容表达以及含义原文为准。

Reversing embedded device bootloader (U-Boot) - p.2 逆向嵌入式设备引导程序(U-Boot)- 第二部分

本博文并不是ARM固件逆向工程入门教程或攻击特定物联网设备的指南。 我们的目标是分享我们的经验,也许可以为您节省一些宝贵的时间和减少一些让您头痛的困难。

概要

第一篇文章介绍了底层上理论性的方面的知识,而这篇文章将展示我们如何最终解密内核映像。不要恐慌——我们不会像第一篇文章那样啰嗦。

如果你还没看过第一篇文章,请访问此链接 https://www.shielder.com/blog/2022/03/reversing-embedded-device-bootloader-u-boot-p.1/ 查看。

在进入正题之前,让我们看看关于内核解密工作的图片。

Unicorn使用了QEMU的CPU仿真组件(为了适应项目的设计需要,做了很多改变),但有一个很大的区别,它只仿真CPU的操作,而不像QEMU那样处理系统的其他部分。因此,该引擎可以模拟QEMU可以模拟的所有指令,但除此之外,Unicorn的优势还在于其他地方。Unicorn是一个框架,它提供了简单的方法来扩展其功能,并在其基础上构建工具。它很灵活,可以模拟没有上下文的原始代码。与QEMU相比,Unicorn是轻量级的,因为它被剥离了所有不涉及CPU仿真的子系统。最后,选择QEMU还是Unicorn真的取决于你想实现的目标和你所拥有的信息/预先要求。

Why and How

在逆向分析了U-Boot二进制文件中的大部分自定义解密功能后,我们决定模拟该二进制文件,让它为我们解密内核,这样会更容易。

裸机二进制文件不需要中间的软件抽象层(通常由操作系统提供),这使得仿真 “更容易”,但我们也说过,引导程序会初始化所有硬件组件,使事情变得复杂。与Unicorn不同,我们也看到QEMU提供了对外围设备的支持,所以它应该是我们完成这项任务的选择。在做出最终决定之前,让我们看看解密功能是如何被调用的。

在第108行,解密函数使用了两个指针,第一个指针指向存储加密文件内容的内存区域,而第二个指针指向将存储解密文件的内存区域。还有第三个参数,表明应该读取多少个字节。由于我们的逆向工程努力,我们知道AES密钥在U-Boot二进制文件中是硬编码的,所有的密钥衍生功能都在block_aes_decrypt函数中。因此,我们可以推测,解密可以在不使用来自外部设备的任何信息的情况下进行,也就是说,U-Boot本身的内容和要解密的文件就足够了。

最后,我们只需要运行一段汇编并读/写一些内存–有什么比Unicorn更适合做这个呢?

下面是一个基于Unicorn和Capstone的代码片段,我们用它来模拟U-Boot并解密内核。

from __future__ import print_function
from ctypes import sizeof
from unicorn import *
from unicorn.arm_const import *
from unicorn.unicorn_const import *
from capstone import *
import struct, binascii

#callback of the code hook
def hook_code(uc, addr, size, user_data): 
	mem = uc.mem_read(addr, size)
	disas_single(bytes(mem),addr)

#disassembly each istruction and print the mnemonic name
def disas_single(data,addr):
		for i in capmd.disasm(data,addr):
			print("0x%x:\t%s\t%s" % (i.address, i.mnemonic, i.op_str))
			break
			
#create a new instance of capstone
capmd = Cs(UC_ARCH_ARM, UC_MODE_ARM) 

#code to be emulated
in_file = open("u-boot.bin", "rb") # opening for [r]eading as [b]inary
ARM_CODE32 = in_file.read()
in_file.close()

# file to be decrypted
in_file = open("kernel.img.raw", "rb") # opening for [r]eading as [b]inary
FILE_TOBE_DEC = in_file.read()
in_file.close()

# U-Boot base address
# we have seen this in the previous article (DDR start at 8000_0000)
ADDRESS = 0x80800000

print("Emulate ARM code")
print("Shielder")
try:
    # Initialize emulator in ARM-32bit mode
    # with "ARM" ARM instruction set
    mu = Uc(UC_ARCH_ARM, UC_MODE_ARM)

    # map U-boot in memory for this emulation
    # "// (1024 * 1024)" for memory allign pourses
    i = len(ARM_CODE32) // (1024 * 1024)
    mem_size = (1024 * 1024) + (i * (1024 * 1024))
    mu.mem_map(ADDRESS, mem_size, perms=UC_PROT_ALL)
    # write machine code to be emulated to memory
    mu.mem_write(ADDRESS, ARM_CODE32)
    
    # map STACK
    stack_address = ADDRESS + mem_size
    # 2MB 
    stack_size = (1024 * 1024) * 2
    mu.mem_map(stack_address, stack_size, perms=UC_PROT_ALL)
    
    # map the Kernel in RAM memory for this emulation
    # remember that RAM starts at 8000_0000
    # there we call RAM a sub-region of memory inside the RAM itself 
    ram_address = ADDRESS + mem_size + stack_size
    ram_size = (1024 * 1024) * 8
    mu.mem_map(ram_address, ram_size, perms=UC_PROT_ALL)
    # write file to be decrypted to memory
    mu.mem_write(ram_address, FILE_TOBE_DEC)

    # initialize machine registries
    mu.reg_write(UC_ARM_REG_SP, stack_address)
    # first argument, memory pointer to the location of the file
    mu.reg_write(UC_ARM_REG_R0, ram_address)
    # second argument, memory pointer to the location on which write the file
    mu.reg_write(UC_ARM_REG_R1, ram_address) 
    # third argument, block size to be read from memory pointed by r0
    mu.reg_write(UC_ARM_REG_R2, 512) 

    # hook any instruction and disassembly them with capstone
    mu.hook_add(UC_HOOK_CODE, hook_code)

    # emulate code in infinite time
    # Address + start/end of the block_aes_decrypt function
    # this trick save much headaches
    mu.emu_start(ADDRESS+0x8c40, ADDRESS+0x8c44) 

    # now print out some registers
    print("Emulation done. Below is the CPU context")

    r_r0 = mu.reg_read(UC_ARM_REG_R0)
    r_r1 = mu.reg_read(UC_ARM_REG_R1)
    r_r2 = mu.reg_read(UC_ARM_REG_R2)
    r_pc = mu.reg_read(UC_ARM_REG_PC)
    print(">>> r0 = 0x%x" %r_r0)
    print(">>> r1 = 0x%x" %r_r1)
    print(">>> r2 = 0x%x" %r_r2)
    print(">>> pc = 0x%x" %r_pc)

    print("\nReading data from first 512byte of the RAM at: "+hex(ram_address))
    print("==== BEGIN ====")
    ram_data = mu.mem_read(ram_address, 512)
    print(str(binascii.hexlify(ram_data)))
    print("==== END ====")

    # from the reversed binary, we know which are the magic bytes
    # at the beginning of the kernel
    if b"27051956" == binascii.hexlify(bytearray(ram_data[:4])):
        print("\nMagic Bytes match :)\n\n")
        with open("test.bin", "wb") as f:
            f.write(ram_data)

except UcError as e:
    print("ERROR: %s" % e)

注意:该脚本只是一个PoC,用于解密内核的前512字节。

在第76行,我们指定了开始执行的指令地址(ADDRESS+0x8c40)和结束执行的地址(ADDRESS+0x8c44)。这些地址与block_aes_decrypt函数有关,它是我们要调用的解密内核的实际函数。

在下面的图片中,我们可以更清楚地看到这些地址所指向的是什么。

我们在分支block_aes_decrypt之前就开始执行。我们还配置了前三个ARM参数寄存器,r0指向加密的内核,r1指向我们写入解密内核的内存区域,r2是要读取/写入的大小。最后,我们在ret之后立即停止模拟,因为不需要再执行任何东西,而且我们要避免解密的内核被覆盖。

您不能使用Qiling

在2021年12月,我们接受了Qling/Unicorn的一位开发负责人员 @kj.xwings.l的采访。

https//www.youtube.com/watch?v=14nqjkvr_gu

您可以想象,从采访中,QilingLab和WIP QilingLab2,我们是Qling项目的粉丝,那么我们为什么不使用它?

答案非常简单,当我们在2021年底尝试时,Qling不支持运行裸金属二进制文件。但是,我们确信这只是一个加载的问题,因为Qling的核心是Unicorn,我们知道可以实现此类功能。

这是@TheZero展示他的编码技巧的地方,他在Qling中实现了裸机二进制的仿真能力。出于一个有趣的巧合,其他人也在试图填补这一功能,并在我们设法获得工作的POC几天后(但在我们准备好进行补丁提交之前)进行了PR。

注意:@CQ的PR没有被合并,但@xwings和@CQ本人后来在其他提交中介绍了这个功能(例如 这个https://github.com/qilingframework/qiling/commit/626b79d21de62321e6b6895d21a3f384aa8b07d7)。

又是裸二进制

所以现在我们已经从加密镜像中获得了我们的内核,我们可以把它加载到Ghidra中并开始逆向工程。不幸的是,Ghidra检测到的内核是一个RAW二进制文件,自动分析的输出结果简直是一团糟。

将RAW内核二进制文件转换为ELF文件

幸运的是,在这种情况下,一个开源的工具派上了用场。

vmlinux-to-elf允许从vmlinux/vmlinuz/bzImage/zImage内核镜像(无论是原始二进制blob还是预先存在但被剥离的.ELF文件)获得一个完全可分析的.ELF文件,并恢复了函数和变量符号。GitHub repo (https://github.com/marin-m/vmlinux-to-elf)

注意:如果你想了解这个过程的模式,请查看项目README.md中的解释,它真的很吸引人。

结语

由于我们不想展示内核逆向分析的过程(这不在本系列文章的范围内),所以是时候总结一下一切了。

这个过程并不简单,我们面临着各种困难,但这是一个很好的机会,让我们了解到引导程序是如何工作的,如何反转裸机二进制文件,如何模拟它们,……你永远不会停止学习!

这个系列的文章是由于Shieldder给予其员工的研究时间而得以实现的。你喜欢在研究新漏洞类别的同时破坏硬件和嵌入式系统吗?把你的简历给我们吧!

Pitch Time

创建一个安全的物联网设备很困难,不是吗? Shielder可以帮助你在向市场发布产品之前验证你的硬件和固件的安全性。请查看我们的物联网安全服务,了解更多信息:https://www.shielder.it/services/iot-security/

参考资料

Table of Contents