Khorina · 8月22日

如何使用软件Dump你的BootRom

最近曾写了一篇:【牛掰!这小哥用显微镜摄取芯片ROM,还原了芯片的二进制固件】,蛮多朋友说这要是采用多层堆叠,你还能这么原始的操作?自然不行,而且这也是一篇考古的文章。但是!!!

今天带来的这篇,老哥采用了软件非侵入式的方式,实现了破解Soc。从EL2 层层逆向最终获取到BL31的特权,再利用漏洞,最后Dum出了AMLogic A113X SoC的BootRom。

之前在写安全相关的文章的时候,就曾和大家交流过,EL31作为高级特权,一旦被破解接管,会带来很大的安全风险。话不多说,开始享用吧!

  • 原文:Dumping the Amlogic A113X Bootrom[1]
  • 译者:TrustZone

引言

在为参加2022年多伦多Pwn2Own大赛而对Sonos One(第二代)智能音箱进行调查时,我稍稍(呃)偏离了主线,转而进行了一次与AMLogic A113系列芯片启动链相关的小冒险。

这项工作受到了弗雷德里克·B(Frederic B)在AMLogic系统级芯片(SoC)方面工作的启发。一定要看看他写的精彩博文。

引用AMLogic自己的营销宣传语:

凭借四核64位CPU架构的强大计算能力,A113X无需外部DSP芯片即可支持主流的远场语音识别解决方案。A113X可支持8通道PDM和多通道I2S。凭借灵活的麦克风阵列以及音频输入输出接口,A113X是智能音箱和智能家居应用的理想选择。

Sonos One(第二代)基于A113D系统级芯片(SoC)。其启动链的保护措施令人惊讶地严密。

它使用了SoC提供的“安全启动”功能,这意味着所有引导程序阶段都被加密,并附加了RSA签名。

这些引导程序的加密密钥位于一个eFUSE阵列中,即使是特权(内核)代码,(通常)也无法读取。如今,这些设备出厂时配备的U-boot已被锁定,并由密码保护。

目标板

如上一段所述,Sonos One并不是用于一般性地实验A113安全启动链的很好选择。我四处寻找一种廉价且本地可用的替代品进行实验,偶然发现了联想Smart Clock Essential。

这是一个(当时)本地售价约为50欧元的小巧的“智能时钟”。在(终于)设法拆开这个设备后,我最终能够找到一些用于UART的测试点。

幸运的是,联想时钟的保护措施没有Sonos One那么严密。首先,可以通过UART发送一些字节来中断U-boot的启动过程,从而允许你进入U-boot shell。

其次,联想时钟上的OTP/eFUSE配置方式并没有禁用bootROM的USB启动协议。

应该是留着用于U盘升级用的。

联想时钟运行的是基于Linux/Android的操作系统镜像。在稍微理解了U-boot环境变量/脚本之后,我能够得出以下基本方案来启动该设备并进入root shell:

setenv adb_setting run set_adb_debuggable
run preboot
run bootcmd

我们其实对Linux的东西并不太感兴趣,但这提供了一种简单的方法,可以将/dev/mtd分区复制到USB大容量存储设备(这个设备有一个USB端口),而无需通过一些U-boot技巧来热插拔NAND闪存或通过串行转储闪存。

在转储闪存后,我了解到,尽管联想的U-Boot没有被锁定,但他们实际上确实使用了AMLogic提供的“安全启动”功能。

这意味着所有引导程序都以加密形式驻留在闪存中,并且其完整性通过验证RSA签名来确保。

A113X侦察

令人惊讶的是,很难找到一份关于A113X详细信息的数据手册或参考手册。

通常,这些手册只提供给购买芯片以实现其产品的供应商。幸运的是,经过一些侦查,我找到了一个中国的“文档共享”网页,可以在那里获取这些信息……不过,首先我得通过上传一些(独特的)文档来赚取一些积分。

在上传了几份(公开可用的)AMLogic数据手册后,我终于换回了(非公开可用的)数据手册。

碰巧的是,这份手册上有小米的水印,感谢那位决定违反保密协议的小米(关联)工程师!

啊哈哈哈哈,全方位的吐槽了。

ARM可信固件简述

在我们继续之前,让我们快速浏览一下遵循ARM可信固件参考实现(以下简称ATF)的平台(如AMLogic A113X)的启动路径的简化概述。

我们现在将忽略SCP(系统控制处理器)等内容(尽管它可能会成为后续帖子的有趣主题),而专注于应用程序处理器上运行的内容。

image.png

从我这张画得很糟糕的图表中可以看出,系统复位后,执行将从BL1(即BootROM)开始。

BootROM将链式加载BL2,而BL2又将加载各个BL3阶段。

BL31是在最高特权级别(称为EL3)上运行的安全监视器代码。BL32是安全EL1的有效负载,在我们正在探索的目标上没有BL32有效负载。

第一个位于安全世界之外的“不受信任”代码是BL33。通常,你会在这里找到一个引导程序,如U-boot,它又会链式加载类似Linux的系统。

我们今天的目标是从不受信任(“正常世界”)上下文中运行代码,从而破坏EL3安全监视器!

想深入了解安全启动的可以看下面这几篇文章:

AMLogic USB recovery 模式

通过各种在线资源以及阅读Frederic的作品,我了解到了AMLogic SoC的一般启动流程。

USB recovery 模式特别有趣,因为它可以从任何USB主机进行交互。有一些开源项目致力于记录和实现这种USB recovery 模式协议,AMLogic自己只提供了一些闭源实用程序。

BootROM将检查两个“引导”引脚(POC1、POC2),以确定它将按什么顺序探测启动系统的各种方法。以下流程图说明了这一点:

image.png

所有各种启动方法的目标都是将下一阶段引导程序加载到内存中并开始运行。

下一阶段引导程序称为BL2。当强制执行安全启动时(就像我们使用的联想和Sonos目标板一样),**它只允许我们加载正确加密和签名的BL2二进制文件。**当然,我们缺少加密所需的加密密钥和签名所需的私钥。

通过研究pyamlboot和官方的aml-flash-tool二进制文件,我能够了解到一些用于与USB恢复代码通信的USB协议。

USB恢复协议使用常规的USB控制传输,并支持一些命令。

命令操作码被放入控制传输数据包(们)的bRequest中,而像addresses/offsets这样的东西通常被切成两个16位半部分,并填充到wValue和wIndex字段中。如果你对这些外来词感到陌生,并想了解有关被称为USB的这项被诅咒技术的详细细节……我恳请你参阅USB简介。

由于我们无法访问BootROM代码,因此无法研究实际实现。相反,我们将依赖公开可用的工具/代码和一些古老的黑盒测试。

PyAMLBoot为我们提供了以下可用命令表:

REQ_WRITE_MEM   = 0x01
REQ_READ_MEM    = 0x02
REQ_FILL_MEM    = 0x03
REQ_MODIFY_MEM  = 0x04
REQ_RUN_IN_ADDR = 0x05
REQ_WRITE_AUX   = 0x06
REQ_READ_AUX    = 0x07

*_MEM操作允许对SRAM(受限范围)进行读写。REQ_RUN_IN_ADDR操作在指定地址开始对BL2映像进行解密和验证。

如果验证成功,它将跳转到BL2入口点。

REQ_READ_AUX和REQ_WRITE_AUX可用于窥视/探查内存映射IO(受限范围)。

安全启动解密Oracle

通过USB加载BL2映像时,你使用REQ_WRITE_MEM命令将BL2映像数据以64字节为单位加载到SRAM中。

发送最后一块数据后,你发送一个REQ_RUN_IN_ADDR命令,并附带刚刚加载的BL2映像的SRAM基地址,以启动解密+验证+执行过程。

在对此过程进行一些黑盒测试时,我很快注意到了REQ_RUN_IN_ADDR行为中的一个有趣疏忽。放置在SRAM中的数据会在原地进行解密,当签名验证失败时,它并不会清除SRAM中的解密内容

在REQ_RUN_IN_ADDR命令失败后,我们仍然可以继续执行其他命令,因此我们可以使用REQ_READ_MEM命令来读取解密后的内容!本质上,这为我们提供了BL2映像以及使用相同算法/密钥加密的其他数据的解密Oracle。

对这个接口进行更多的黑盒测试后,我们发现加密是一个块大小为16字节的块密码,并且它表现出CBC模式下块密码的特性。

利用这个小技巧,我破坏了我从NAND转储(位于mtd0的最开始处)中获取的已知有效BL2映像的一些尾部字节,以使签名验证失败,并能够转储解密的BL2代码/数据进行进一步的静态分析。

逆向BL2

BL2负责加载BL31和BL33。BL31在安全世界中以最高特权上下文运行,称为EL3。

BL33在普通世界中运行,通常由一个类似U-boot的引导程序组成,而该引导程序又会链式加载类似Linux的系统。

如果我们查看BL2的UART日志输出,可以看到:

Load FIP HDR from NAND, src: 0x0000c000, des: 0x01700000, size: 0x00004000, part: 0
Load BL3x from NAND, src: 0x00010000, des: 0x01704000, size: 0x000b0e00, part: 0
NOTICE:  BL31: v1.3(release):d3a620ec3
NOTICE:  BL31: Built : 10:32:40, Jan 20 2021
NOTICE:  BL31: AXG secure boot!
NOTICE:  BL31: BL33 decompress pass

“FIP HDR”是一个表,包含各种BL3x二进制大对象的偏移量/大小。

此表中的每个条目大小为0x28,最多可有32个条目。条目的布局为:

uint8_t uuid[0x10]
uint64_t offset
uint64_t size
uint64_t flags

利用我们之前用来解密BL2的解密Oracle,我们也可以解密FIP表以及所有BL3x数据。接下来,我们可以解析FIP,并使用一个简单的脚本来提取各个数据块:

为了好理解,先解释一下“Oracle”是一个比喻性的术语,用于描述一个能够提供某种特定信息或功能的实体或方法。在这里,“解密Oracle”特指一个能够解密特定数据(如BL2映像或FIP表等)的机制或方法。具体来说,它指的是利用已知的解密漏洞或弱点,通过发送特定的命令或数据来解密原本加密的内容。这下是不是好理解很多了。
#!/usr/bin/env/python

import sys
import struct
import binascii

FIP_ENTRY_COUNT_MAX = 32
FIP_ENTRY_SIZE = 0x28

if __name__ == "__main__":
    if len(sys.argv) != 3:
        print("usage: %s <input.bin> <outputdir>" % sys.argv[0])
        exit(0)

    input_filename, output_dir = sys.argv[1:]

    d = open(input_filename, "rb").read()[0x10:]
    fip_hdr = struct.unpack("<LLQ", d[0:0x10])

    assert(fip_hdr[0] == 0xaa640001)
    assert(fip_hdr[1] == 0x12345678)

    for i in range(FIP_ENTRY_COUNT_MAX):
        offs = 0x10 + (i * FIP_ENTRY_SIZE)
        entry = d[offs:offs+FIP_ENTRY_SIZE]
        offs, size, flags = struct.unpack("<QQQ", entry[0x10:])
        uuid = entry[0:0x10]

        if uuid == b"\x00"*16: break

        uuid_str = binascii.hexlify(uuid).decode()

        print("entry #%02d: %s - offs: %08x, size: %08x, flags: %x" % (
            i, uuid_str, offs, size, flags
        ))

        if size == 0: continue

        output_filename = "%s/%02d_%08x_%s" % (
            output_dir, i, offs, uuid_str
        )

        with open(output_filename, "wb") as fh:
            fh.write(d[offs:offs+size])
$ python3 fip.py mtd1.out fip_out
#00: 9766fd3d89bee849ae5d78a140608213 - offs: 00004000, size: 0000d800
#01: 47d4086d4cfe98469b952950cbbd5a00 - offs: 00011800, size: 00031600
#02: 05d0e18953dc13478d2b500a4b7a3e38 - offs: 00042e00, size: 00000000
#03: d6d0eea7fcead54b97829934f234b6e4 - offs: 00042e00, size: 00072000
#04: f41d1486cb95e6118488842b2b01ca38 - offs: 00000188, size: 00000468
#05: 4856ccc2cc85e611a5363c970e97a0ee - offs: 000005f0, size: 00000468

在检查提取出的数据块时,我们观察到:

UUID 9766fd3d89bee849ae5d78a140608213 = BL30
UUID 47d4086d4cfe98469b952950cbbd5a00 = BL31
UUID 05d0e18953dc13478d2b500a4b7a3e38 = empty (unused BL32 slot)
UUID d6d0eea7fcead54b97829934f234b6e4 = BL33
UUID f41d1486cb95e6118488842b2b01ca38 = metadata
UUID 4856ccc2cc85e611a5363c970e97a0ee = metadata

太好了!现在我们已经得到了BL31(EL3安全监视器)和BL33(U-boot)的明文二进制大对象!

逆向BL31

我们的目标是转储BootROM和eFUSE/OTP数据

因此,我们需要找到一种在EL3安全监视器(BL31)上下文中运行代码的方法。我开始研究开源的ATF参考实现。

“Arm SiP服务”的文档中提到:

SiP服务是由芯片实现者或平台提供商提供的非标准、特定于平台的服务。它们通过从EL3以下的异常等级执行的SMC(“SMC调用”)指令进行访问。

感兴趣的可以了解一下SMC指令:

这听起来是一个寻找漏洞的好起点。它涉及偏离开源参考实现的特定于供应商的代码,并且可以通过调用SMC(安全监视器调用)指令从“普通世界”访问。

通过阅读ATF代码,我们了解到SMC调用被划分为不同的“服务”,其中SiP是其中之一。这些服务是从rt_svc_desc条目表中注册的。

这些服务描述符包含(除其他内容外)服务的名称和两个函数指针,一个用于初始化(init),另一个用于处理实际的安全监视器调用(handle)。

SiP服务方便地被称为sip_svc,因此通过跟踪对这个字符串常量的引用,我很快找到了SiP SMC例程分发器。

我本以为可能只会识别出少数几个特定于供应商的SMC函数,但令我惊讶的是,总共有115个。handle函数包含一个针对各种特定于供应商的SMC ID的大型switch case,并通过在一个我称为platform_ops的大型表中查找函数指针来分发它们的功能。

platform_ops指针由SiP服务init函数初始化,并位于EL3代码的.data部分。

115个例程(更不用说它们调用的所有子例程了)是相当繁琐的逆向工作。

幸运的是,其中很多都是样板代码,比如简单地返回共享内存缓冲区位置的指针的例程等。

platform_ops表中的很多条目也是NULL指针,如果你尝试调用其中任何一个,SiP SMC分发器都会失败。

在剔除了一些之后,我们剩下的例程与(令人惊讶的)加密操作有关,对OTP/eFUSE数据的有限访问,以及与某种“安全存储”设施相关的一整群例程。

感兴趣的话,这里推荐大家阅读。

安全存储

“安全存储”功能提供了一种使用AES密钥对键值对进行加密的方法,该密钥在正常世界中是不可见的。

Linux(或在EL2中运行的其他任何操作系统)可以查询安全存储,并使用这些特定于供应商的SMC调用从该存储中读取/写入项。

逆向与存储相关的例程揭示了可以通过SMC接口调用的以下核心功能:

  • SMC 0x82000069 - SIP_CMD_STORAGE_PARSE:

此例程用于解析(加密的)安全存储二进制大对象,在实际从存储中读取或写入项之前会调用它。

  • SMC 0x82000061 - SIP_CMD_STORAGE_READ:

此例程用于从安全存储中读取项。项的名称包含在请求体中。

  • SMC 0x82000062 - SIP_CMD_STORAGE_WRITE:

此例程用于在安全存储中写入/更新项。

  • SMC 0x82000068 - SIP_CMD_STORAGE_REMOVE:

此例程用于从安全存储中移除项。

  • SMC 0x82000067 - SIP_CMD_STORAGE_LIST:

此例程用于获取安全存储中所有项(名称)的列表。

要开始使用此安全存储,我们需要SIP_CMD_STORAGE_PARSE,它接受一个参数,该参数包含加密存储二进制大对象的大小。要解析的实际加密存储二进制大对象被写入位于DRAM中的共享内存缓冲区。此共享缓冲区的地址是固定的,可以使用SMC 0x82000025检索,该地址是0x5080000。

SIP_CMD_STORAGE_PARSE处理程序接受的最大大小为0x40000字节。存储以大小为0x200的明文头部开始,如下所示:

uint8_t  magic[0x10];
uint32_t key_version;
uint32_t seed_mode;
uint8_t  body_hash[0x20];
uint8_t  padding[];

在头部之后,我们会发现包含存储项的加密主体。如果key_version字段包含的值大于0,它将计算加密主体的SHA256摘要,并将其与body_hash中的值进行比较。如果不匹配,它将退出剩余的解析逻辑。

接下来,解析例程最初将仅使用CBC模式的AES-256解密加密主体的前0x200字节。我们将这个大小为0x200的块称为参数块。根据seed_mode的值,它将以下列方式构造AES密钥和初始化向量(IV):

seed_mode = 1

错误,无效

seed_mode = 2

AES密钥 = EL3代码.data部分中的一个硬编码值 AES IV = 全零

seed_mode = 其他任何值:

AES密钥 = 来自eFUSE/OTP的12字节CPUID与.data部分中的一个固定20字节值拼接而成

AES IV = 来自eFUSE/OTP的12字节CPUID与.data部分中的一个固定4字节值拼接而成

太好了,即使我们不知道CPUID的值(尽管很容易恢复/获取),我们现在也可以加密自己任意构造的“安全存储”二进制大对象并将其提供给解析器!

参数块包含一系列(嵌套的)TLV(类型-长度-值)条目,这些条目用于描述剩余主体数据的一些属性。每个TLV条目都由一个32位类型、一个32位大小以及随后的大小字节的数据组成。

外层TLV的类型为0x1 TYPE_PARAM_HEADER,内层主体是一个单独的TLV,其类型为0x2 TYPE_ENCRYPTED_SIZE,指定了主体其余部分的大小。

在参数块之后,我们拥有实际的键条目,这些键条目也被编码为一系列嵌套的TLV。

一个键条目总是以一个类型为TYPE_KEY_DEFINITION(0x3)的TLV开始。这个TLV的主体包含多个TLV,描述了这个键条目的属性。KEY_DEFINITION的可能类型值为:

image.png
正确构造的KEY_DEFINITION条目将被内部存储在一个位于EL3 .bss段中的key_entry条目数组中,最多可有64个条目。key_entry的结构如下所示:

/ sizeof(key_entry) == 0x90
struct key_entry {
    uint8_t  name[80];
    uint32_t name_len;
    uint32_t buffer_status;
    uint32_t key_type;
    uint32_t value_size;
    uint8_t  *value_ptr;
    uint8_t  hash[0x20];
    uint32_t key_in_use;
    uint32_t unknown;
}

key_in_use值指定一个键条目是否有效,它是通过将值的SHA256摘要与哈希进行比较后设置的。

“Pwning BL31”(攻陷BL31)

解析器代码中用于填充这个key_entry值数组的循环大致如下所示:

uint32_t key_entry_size_out;
g_keys_count = 0;
while (encrypted_size) {
    key_out = &g_keys[g_keys_count];
    if (parse_key(keyheap_ptr, key_out, &key_entry_size_out)) {
        goto ERROR_BAIL;
    }

    sha256(key_out->value_ptr, key_out->value_size, value_hash);
    key_hash = key_out->key_hash;
    if (!memcmp(key_hash, value_hash, 32)) {
        key_out->key_in_use = 1;
        ++g_keys_count;
    } else {
        key_out->key_in_use = 1;
    }

    keyheap_ptr = keyheap_ptr + key_entry_size_out;
    encrypted_size -= key_entry_size_out;
}

一个显而易见的问题是,g_keys_count没有强制执行的上限,这会导致g_keys(即key_entry结构体的数组)溢出。这看起来是一个很有希望的漏洞!

最初,我尝试利用这个溢出覆盖.data段末尾的platform_ops指针来执行代码。但这样做需要大约3740个key_entry对象,而且由于某些key_entry结构体成员的不幸对齐,沿途会破坏很多指针/数据,并用不受控制的数据填充。

我更加仔细地研究了内存布局:

0000: uint32_t  g_keys_count;
0004: key_entry g_keys[64];
2404: uint64_t  g_key_version;
240c: uint8_t   param_sector_decrypted[0x200];

在密钥数组旁边有一个用于解密参数块的临时缓冲区。因此,如果我们向正在解析的存储块中添加超过64个条目,就可以覆盖这个临时缓冲区……

但实际上在那一点上它已经不再被使用了。那么这有什么用呢?它会在那里写入足够正常的key_entry对象,这并不会给我们带来任何实际的好处。

如果我们查看SIP_CMD_STORAGE_READSIP_CMD_STORAGE_WRITE的实现,我们会发现它们都通过给定的名称来查找相应的key_entry。执行此操作的函数如下所示:

int key_find_by_name(void *key_name, unsigned int match_len)
{
  int key_index;
  key_entry *current_key;

  key_index = 0;
  while (1) {
    if (key_index > g_keys_count) {
      return 0xFFFFFFFFLL;
    }
    current_key = &g_keys[key_index];
    if ( (current_key->key_in_use & 1) != 0
      && current_key->name_len == match_len
      && !(unsigned int)memcmp(&g_keys[key_index], key_name, match_len)) {
      break;
    }
    ++key_index;
  }
  return key_index;
}

这个例程有一个很小但很有趣的特性:它并不假设g_keys的最大索引是min(g_keys_count, 64),而是假设g_keys_count总是在g_keys的最大容量范围内。

让我们再深入研究一下(大大简化后的版本)解析函数:

int parse_storage() {
    g_seed_mode = -1;
    g_key_version = -1;
    int param_parsed[2];

    if (strcmp(header.magic, "AMLSECURITY")) {
        goto ERROR_BAIL;
    }

    g_seed_mode = header.seed_mode;
    g_key_version = header.key_version;

    decrypt(param_sector_encrypted, param_sector_decrypted, 0x200);

    if (!parse_param_sector(param_sector_decrypted, param_parsed)) {}
        reset_key_heap();
        memset(g_keys, 0, sizeof(key_entry) * 64);
        return 0;
    }

    g_keys_count = 0;

    decrypt(storage_body_enc, storage_body_dec, storage_body_size);

    while(encrypted_size) {
        // .. key parsing logic
    }
}

如果我们第二次调用SIP_CMD_STORAGE_PARSE,就可以控制最终进入param_sector_decrypted临时缓冲区的内容。这实际上允许我们伪造任意的key_entry对象。

问题是……第二次调用SIP_CMD_STORAGE_PARSE会将g_keys_count重置为0……除非我们让parse_param_sector失败;那么它只会擦除g_keys(但仅限于其最初的64个条目的最大容量),而不会改变g_keys_count!这意味着索引为64和更高的密钥将与param_sector_decrypted缓冲区重叠。

让我们来检查parse_param_sector的逻辑:

typedef struct {
    uint32_t encrypted_size;
    uint32_t type_11_val;
} param_block_t;

int parse_param_sector(uint8_t *param_sector_buf, param_block_t *out) {
    int result;
    int remaining;

    tlv_t *tlv_root = (tlv_t*)param_sector_buf;
    uint8_t *p = (param_sector_buf + 8);

    remaining = tlv_root->size;
    result = 0xFFFFFFFF;

    if ( tlv_root->type == 1 ) {
        while (remaining) {
            tlv_t *tlv_next = (tlv_t*)p;
            remaining -= (tlv_next->size + 8);
            if (tlv_next->type == 2) {
                out->encrypted_size = *(uint32_t*)(p + 8);
            } else if (tlv_next->type == 11 ) {
                out->type_11_val = *(uint32_t*)(p + 8);
            }
            p += (tlv_next->size + 8);
        }
        return 0;
    }

    return result;
}

如我们所见,它解析了一个嵌套的TLV结构,该结构需要以类型为0x1的TLV开始(我将此称为TYPE_PARAM_BLOCK_START)。如果不是这样……它将返回一个错误代码。很简单!

第一个(部分)与param_sector_decrypted重叠的密钥索引是64,但最初的8个字节被g_key_version的值占用,当我们第二次触发存储解析时,这个值会被覆盖。虽然仍然可以使用,但为了方便起见,让我们把我们关心的伪造key_entry放入下一个槽位(索引65),因为它完全与param_sector_decrypted中的受控数据重叠。

我们伪造的key_entry将如下所示:

image.png

现在,如果我们触发一个SIP_CMD_STORAGE_READ操作,请求读取名称为XXXX的存储项的值,它将遍历g_keys数组,直到到达我们伪造的key_entry对象,并将从key_entry.value_ptr开始的key_entry.value_size(8)个字节复制到共享内存中的输出缓冲区,然后我们可以从那里检索它。这给了我们一个任意的read64原语!

我们可以使用完全相同的方法,但是用SIP_CMD_STORAGE_WRITE来获取一个任意的write64原语。

使用我们的write64原语,我们终于可以覆盖platform_ops的指针,从而劫持SiP SMC分发器的函数指针表。

导出eFUSE/OTP数据

所以我们在安全监视器中已经有了一些控制流劫持的能力。接下来呢?让我们试着以某种方式导出eFUSE/OTP数据。

首先,让我们稍微升级一下我们的原语集。SiP SMC分发器会根据SMC ID从platform_ops表中调用正确的函数指针,并使用最初传递给SMC的正确数量的参数来调用这个函数。我注意到SMC 0x820000FF会将原始的SMC参数(X1,X2,X3,X4)原封不动地传递给SMC 0x820000FF的处理程序(作为X0,X1,X2,X3)。

因此,劫持platform_ops表中的这个条目,同时保持其他条目不变,当我们调用SMC 0x820000FF时,就会给我们一个任意的call4(最多带有4个受控参数的函数调用)原语。

在逆向分析过程中,我已经确定了从OTP读取数据的EL3代码。这是通过使用某种邮箱MMIO接口向SCP(系统控制处理器)发送SCPI消息(确切地说是命令0x8000C2)来实现的。当然,我们不必重新实现这一过程,我们只需要调用为我们执行此操作的便利函数即可。

这个函数的原型是:

int aml_scpi_cmd_efuse_read(void *out, uint32_t offset, uint32_t size);

因此,为了使用我们的EL3 SMC漏洞导出eFUSE/OTP数据,我们只需:call4(aml_scpi_cmd_efuse_read, SOME_DRAM_ADDRESS, 0, 0x100)

之后,我们就可以从“正常世界”的DRAM中将其读回。:-)

导出BootROM

我们几乎完成了,但现在既然我们能够在这种特权上下文中运行代码,那么拥有一份应用处理器的BootROM副本将会很不错。从(泄露的)A113X数据手册中我们可以了解到,BootROM的物理地址是0xffff0000。

然而,我们不能简单地从EL3读取它,因为BL31配置了MMU,并且没有我们可以读取的VA -> PA 0xffff0000的映射。让我们来学习一些关于MMU设置和页表的知识。

Aarch64内存模型的复杂细节相当繁琐,我们只会尝试理解修补现有配置/页表所需的最少量细节,以便我们能够导出ROM。;-)

在EL3中,通过使用MSR指令写入特殊寄存器TTBR0_EL3(EL3的转换表基址寄存器0)来配置一级页表地址。另一个重要的特殊寄存器是TCR_EL3(EL3的转换控制寄存器),它配置了诸如页粒度和地址空间大小之类的事项。

如果我们跟踪ATF代码(和BL31的反汇编代码),我们将找到包含以下对这些特殊寄存器的写操作的例程enable_mmu_el3

TCR_EL3 = 0x80803520
TTBR0_EL3 = base_xlation_table

0x80803520 对应于 TCR_EL3 的以下设置:

image.png

地址空间的大小可以通过 64 - T0SZ 来计算。所以在这个例子中,我们有一个32位的地址空间。对于4KiB的页粒度和32位的地址空间,没有0级页表,我们从1级开始。1级页表的索引位是从第30位到(包括)第38位。

对于一个32位的地址,这意味着它只会被第30位和第31位索引,这给我们一个正好有4个条目的1级页表。每个条目跨越1GiB(0x40000000)。

如果我们从写入TTBR0_EL3的地址读取32个字节来导出1级页表,我们会看到:

0x00000000051c6003
0x0000000000000000
0x0000000000000000
0x00000000051c9003

页表中的条目的最低2位描述了它们所指向的地址的类型。任何没有设置第0位的条目都被认为是无效的。第一个和最后一个条目的最低2位的值为0x3,这意味着它们是指向下一级表地址的地址。

2级页表是使用虚拟地址的第21:29位(9位)进行索引的。由于我们对地址0xffff0000感兴趣,我们可以通过取第21:29位(即0x1ff)来计算表中的索引。2级表中的每个条目都覆盖一个2MiB的区域,所以条目0x1ff覆盖ffe00000-ffffffff。

在0x51c9000 + (0x1ff * 8)处,我们找到了值0x51cd003,这是3级页表的地址。在3级页表中,不再允许有更多的间接引用。3级表中的每个64位值都描述了4KiB页面的映射。

3级描述符值的结构如下:

image.png
页面属性高位由3个单比特属性组成(忽略任何保留位):连续提示位(52),PXN位(53)和XN位(54)。

我们并不真正关心强制执行XN(永不执行)或PXN(特权永不执行),并且对于我们即将引入的页表条目,我们也不必设置连续提示位,因此我们的UPAT值为零。

页面属性低位(10位)稍微复杂一些,其结构如下:

image.png

attrindex位0需要与已配置的MAIRn(内存属性间接寄存器)寄存器的正确索引相匹配。在enable_mmu_el3中,他们配置了MAIR1,所以我们使用attrindex值为0b001。

ns位是非安全位,由于我们将使用特权read64原语来导出ROM,我们可以简单地将此位设置为0。

条目中的两个ap字段根据AP[2:1]访问权限模型映射到页面访问权限。同样,由于我们只会使用特权read64原语来访问这个区域,所以将这些位设置为0是安全的,这使得条目对我们的特权级别是可读写的。

sh字段用于指定此页面的“可共享”属性。我们将在这里指定一个值0b10(外部可共享),以便任何处理器、核心和外设都可以访问它。(尽管这当然不是严格必要的)

af位我们将设置为1,原因是我从未完全理解过。最后,nG(非全局)位表示一个条目是否是全局的,我们将清除此位以将其标记为全局。

我们最终得到的LPAT值为0x181。

综上所述,我们可以通过以下方式构造我们的页表条目:

(addr & 0xfffff000) | (0x181 << 2) | 3

我们(目前)还不确切知道BootROM有多大,但我们可以从映射0xffff0000开始的64KiB页面开始,看看这能带我们走多远。为了计算我们正在修补的L3页表中的索引,我们可以简单地做(addr - 0xffe00000) / 0x1000,当然,如果我们想要字节偏移量,就将这个索引乘以8(记住每个页表条目是64位)。

让我们用一个简单的Python代码片段来总结:

#!/usr/bin/env python

LPAT = 0x181
UPAT = 0

tbl_start = 0xffe00000
map_start = 0xffff0000
map_end = map_start + (1024 * 64)

for addr in range(map_start, map_end, 0x1000):
    index = (addr - tbl_start) // 0x1000
    entry = (addr & 0xfffff000) | (UPAT << 52) | (LPAT << 2) | 3
    print("write64(L3_TABLE + 0x%04x, 0x%016x)" % (index * 8, entry))

这将给我们提供一个64位写操作列表,我们需要这些操作来以(1:1,身份映射,虚拟地址=物理地址)的方式映射BootROM区域。之后,我们只需使用我们的read64原语将整个64KiB区域导出到文件中。当然,我们本可以很容易地将read64原语升级为允许我们更快读取数据的版本,这样导出全部64KiB就需要大约一分钟……但在这所有工作之后,谁又会介意一点小悬念呢?;-)

在检查了我们得到的结果数据后,我们可以很快注意到一些重复的字符串,并可以得出结论,映像在0x8000字节后循环。因此,ROM的实际大小是32KiB。

蛮有趣他们的KB竟然是KiB
$ sha256sum a113x_bootrom.bin
7d1f63f6ddec05f538243aaa532c0503517de8ce9d2033d2b36b6c79695be626  a113x_bootrom.bin

结束语 & 接下来是什么?

你读到这里了!

我开始写这篇文章是在去年12月……但是,为公众记录东西总是比研究新东西要少很多乐趣,等等之类的。

不管怎样,我希望你喜欢这篇文章!欢迎指出任何不一致之处。本文中描述的EL3漏洞利用方法适用于联想智能时钟(AMLogic A113X SoC)和Sonos One第二代(AMLogic A113D SoC)。该漏洞利用最初是在联想时钟上从U-boot环境中进行原型制作的,后来被移植到Sonos One上的Linux用户空间环境(借助一个自定义内核模块)。所有这些代码都可以在Github上找到。

如果能在a113x/d启动ROM中找到一些漏洞利用方法,那就太好了。我做了一些粗略的逆向工程工作,以快速检查a113x启动ROM中是否也存在amlogic-usbdl漏洞,但似乎并非如此。(当然,欢迎再次检查:-))

即使存在这样的漏洞,对于永久破解Sonos One第二代也是无用的,因为他们会烧毁eFUSE,从而禁用BootROM中的所有USB恢复功能。:-(

后面会有更多有趣内容,敬请关注!

参考资料

[1]Dumping the Amlogic A113X Bootrom

[2]【转】ATF中SMC深入理解

作者:Hcoco
文章来源:TrustZone

推荐阅读

更多物联网安全,PSA等技术干货请关注平台安全架构(PSA)专栏。欢迎添加极术小姐姐微信(id:aijishu20)加入PSA技术交流群,请备注研究方向。
推荐阅读
关注数
4570
内容数
192
Arm发布的PSA旨在为物联网安全提供一套全面的安全指导方针,使从芯片制造商到设备开发商等价值链中的每位成员都能成功实现安全运行。
目录
极术微信服务号
关注极术微信号
实时接收点赞提醒和评论通知
安谋科技学堂公众号
关注安谋科技学堂
实时获取安谋科技及 Arm 教学资源
安谋科技招聘公众号
关注安谋科技招聘
实时获取安谋科技中国职位信息