原文:http://bits-please.blogspot.com/2015/08/exploring-qualcomms-trustzone.html
获取 TrustZone image
从两个不同的位置提取image
- 从手机设备本身
- 从google factory image
已经root
的Nexus 5设备,image存储在eMMC芯片
上,并且eMMC芯片的分区在/dev/block/platform/msm_sdcc.1
下,可以通过dd
命令进行复制 。
此外,在/dev/block/platform/msm_sdcc.1/by-name
分区下,包含tz
和tzb
这些有意义的名称:
tz
(TrustZone 的缩写),另一个名为tzb
,作为tz
映像的备份映像,并且与tz
映像相同。
直接从手机内部提取,可能存在两个问题:
- 尽管 TrustZone 映像存储在 eMMC 芯片上,但“正常世界”很容易无法访问它(通过要求设置系统总线上的 AxPROT 位),或者它的多个部分可能会丢失。
- 拉取整个分区的数据不会显示有关image真实(逻辑)边界的信息,因此需要一些额外的工作来确定image实际结束的位置。 (实际上,由于“tz”image是 ELF 二进制文件,因此它的大小包含在 ELF 标头中)。
因此,从设备中提取了一个image后,让我们看一下google factory image。
Nexus 5 的出厂镜像均可从 Google 下载。出厂映像包含一个包含所有默认映像的 ZIP,另外还包含引导加载程序映像。KTU84P
下载工厂映像并查找与 TrustZone 相关的字符串后,很快就发现bootloader
包含所需的代码。
然而,这里仍然有一个小问题需要解决 - 引导加载程序image的格式未知。无论如何,使用十六进制编辑器打开该文件并猜测其结构实际上非常简单:
引导加载程序文件具有以下结构:
- magic值(“BOOTLDR!”)- 8 个字节
- image数量 - 4 字节
- 从文件开头到image数据开头的偏移量 - 4 个字节
- image中包含的数据的总大小 - 4 字节
- 一个数组,其中包含与上面的“image数量”字段匹配的多个条目。数组中的每个条目都有两个字段:
- image名称 - 64 字节(零填充)
- image长度 - 4 字节
正如您在上图中看到的,引导加载程序映像包含一个名为“tz”的映像,这就是我们要查找的映像。为了解压该文件,我编写了一个小型 python 脚本(可在此处获取),该脚本接收引导加载程序映像并解压其中包含的所有文件。
提取图像并将其与之前从设备中提取的image进行比较后,我验证它们确实是相同的。所以我想这意味着我们现在可以继续检查 TrustZone image。
import sys, struct, osdef main():#Reading the commandline argumentsif len(sys.argv) != 3:print "USAGE: %s <BOOTLOADER_IMAGE> <OUTPUT_DIR>" % sys.argv[0]returnbootloader_path = sys.argv[1]output_path = sys.argv[2]#Verifying the magicbootloader_file = open(bootloader_path, 'rb')magic = bootloader_file.read(8)if magic != "BOOTLDR!":print "[-] Read incorrect magic: %s" % magic.encode("hex")returnprint "[+] Read correct magic"#Reading in the metadata blockimage_count,data_start_addr,total_size = struct.unpack("<III", bootloader_file.read(12))print "[+] Found %d images, starting at %08X, total size: %08X" % (image_count, data_start_addr, total_size)image_metadata = []for i in range(0, image_count):image_name = bootloader_file.read(64).strip('\x00')image_len = struct.unpack("<I", bootloader_file.read(4))[0]image_metadata.append((image_name, image_len))print "[+] Images: %s" % str(image_metadata)#Dumping each imagebootloader_file.seek(data_start_addr, 0)for image_name, image_len in image_metadata:print "[+] Dumping %s" % image_namedata = bootloader_file.read(image_len)open(os.path.join(output_path, image_name), 'wb').write(data)print "[+] Done"if __name__ == "__main__":main()
修复 TrustZone 映像
首先,检查该文件发现它实际上是一个 ELF 文件,这是一个好消息!这意味着内存段及其映射地址应该可供我们使用。
用 IDA Pro 打开文件并让自动分析运行一段时间后,我想开始逆向文件。然而,令人惊讶的是,似乎有很多分支指向未映射的地址(或者更确切地说,未包含在“tz”二进制文件中的地址)。
仔细一看,似乎所有指向无效地址的绝对分支都在文件的第一个代码段内,并且它们指向未映射的高地址。此外,第一个代码段的地址没有绝对分支。
这看起来有点可疑…那么我们看一下 ELF 文件的结构怎么样?执行 readelf 会显示以下内容:
有一个 NULL 段映射到更高的地址,它实际上对应于无效绝对分支指向的地址范围!
不管怎样,我做了一个相当安全的猜测,那就是第一个代码段实际上映射到了错误的地址,实际上应该映射到更高的地址 - 0xFE840000。很自然地,我想使用 IDA 的 rebase 功能对段进行 rebase,但是你瞧!这会导致 IDA 严重崩溃:
(0xFC58C48
地址太低了,不在加载地址范围之内)
我实际上不确定这是否是高通的反逆向功能,或者 NULL 段是否只是其内部构建过程的结果,但这可以通过手动修复 ELF 文件轻松绕过。所需要做的就是将 NULL 段移动到未使用的地址(因为 IDA 无论如何都会忽略它)Type 类型为NULL,没啥用,所以会被忽略,除非专门为tz编写了加载器
,并将第一个代码段从错误的地址 (0xFC86000) 移动到正确的地址 (0xFE840000)这个需要自己用IDA打开提取出的tz,稍微看看就能理解
,如下所示:
现在,在 IDA 中加载镜像后,所有绝对分支都有效了!这意味着我们可以继续分析image。
分析 TrustZone image
首先,应该指出的是,TrustZone 映像是一个相当大的 (285.5 KB) 二进制文件,包含相当少量的字符串,并且没有公共文档。此外,TrustZone 系统由完整的内核组成,具有执行应用程序等功能。所以…目前还不清楚我们应该从哪里开始,因为逆向整个二进制文件可能需要太长时间。
由于我们希望从应用程序处理器攻击 TrustZone 内核,因此最大的攻击面可能是安全监视器调用,这些调用使“正常世界”能够与“安全世界”进行交互。
当然,应该指出的是,我们还可以通过其他方式与 TrustZone 进行交互,例如共享内存甚至中断处理,但由于这些攻击面要小得多,因此最好从分析 SMC 调用。
那么我们如何找到 TrustZone 内核处理 SMC 调用的位置呢?首先,我们回想一下,在执行 SMC 调用时,与处理 SVC 调用(即“正常世界”中的常规系统调用)类似,“安全世界”必须注册向量的地址
。当遇到这样的指令时,处理器将跳转。
“安全世界”的等效项是MVBAR(监视器向量基地址寄存器),它提供向量的地址,该向量包含“安全世界”中处理器处理的不同事件的处理函数。
正向的MRC/MSR
MRS x0, TTBR0_EL1 // Move TTBR0_EL1 into x0
MSR TTBR0_EL1, x0 // Move x0 into TTBR0_EL1
每个系统寄存器都可看做是一个标号 正向的源码中可以写寄存器名称,编译器认识,但逆向的IDA中只能看到寄存器标号
使用任意一个插件,IDA将会识别系统寄存器
https://github.com/gdelugre/ida-arm-system-highlight
https://github.com/NeatMonster/AMIE
访问 MVBAR 是使用 MRC/MCR 操作码
和以下操作数完成的:
因此,这意味着我们可以简单地在 TrustZone 映像中搜索具有以下操作数的 MCR 操作码,并且我们应该能够找到“监视器向量”。事实上,在 IDA 中搜索操作码会返回以下匹配项:
正如您所看到的,“开始”符号的地址(顺便说一下,这是唯一导出的符号)被加载到 MVBAR 中
。
根据ARM文档,Monitor Vector
具有以下结构:
这意味着,如果我们查看前面提到的“开始”符号,我们可以将以下名称分配给该表中的地址:
(下图中解析的有问题
,Monitor Vector的首地址是0xFE810000
)
现在,我们可以分析SMC_VECTOR_HANDLER函数。
实际上,这个函数负责很多任务;
- 首先,它将所有状态寄存器和返回地址保存在预定义的地址中(在“安全世界”中),
- 然后,它将堆栈切换到预分配区域(也在“安全世界”中)。
- 最后,在进行必要的准备之后,它会继续分析用户请求的操作并据此进行操作。
由于发出 SMC 的代码存在于 Linux 内核的高通 MSM 分支中,因此我们可以看一下“正常世界”可以向“安全世界”发出的命令格式。
SMC and SCM(SCM没啥意义,就是高通自己给自己的SMC调用取了个名字)
令人困惑的是,高通选择将“正常世界”通过 SMC 操作码与“安全世界”交互的通道命名为 SCM(安全通道管理器)
。
无论如何,正如我在上一篇博客文章中提到的,“qseecom”驱动程序用于通过 SCM 与“安全世界”进行通信。
Qualcomm在相关源文件中提供的文档相当丰富,足以很好地掌握SCM命令的格式。
简而言之,SCM 命令分为两类:
常规 SCM call
- 参数很重
的调用方式,通过共享内存进行传参Atomic SCM call
- 轻量的调用方式,通过寄存器传参
常规 SCM call
- 当需要将信息从“正常世界”传递到“安全世界”时使用这些call,这是为 SCM call提供服务所必需的。
内核填充以下结构:
TrustZone 内核在为 SCM 调用提供服务后,将响应写回“scm_response”结构:
为了分配和填充这些结构,内核可以调用包装函数“scm_call”,该函数接收
- 指向内核空间缓冲区的指针,其中包含要发送的数据、数据应返回的位置
- 以及最重要的服务标识符和命令标识符。
每个 SCM 调用都有一个类别
,这意味着哪个
TrustZone 内核子系统负责处理该调用。这由服务标识符表示。命令标识符是指定在给定服务内请求哪个命令的代码。
在“scm_call”函数分配并填充“scm_command”和“scm_response”缓冲区后,它调用内部“__scm_call”函数刷新所有缓存(内部和外部缓存),并调用“smc”函数。
最后一个函数实际上执行 SMC 操作码,将控制权转移到 TrustZone 内核,如下所示:
请注意
- R0 设置为 1
- R1 设置为指向本地内核堆栈地址,该地址用作该调用的“上下文 ID”
- R2 设置为指向分配的“scm_command”结构的物理地址。
R0 中设置的这个“神奇”值表明这是一个常规的 SCM 调用,使用“scm_command”结构。然而,对于某些需要较少数据的命令,无缘无故地分配所有这些数据结构将是相当浪费的。为了解决这个问题,引入了另一种形式的 SCM 调用。
Atomic SCM call
- 对于参数数量相当低(最多四个参数)的调用,存在另一种请求 SCM 调用的方法。
有四个包装函数“scm_call_atomic_[1-4]”,它们对应于请求的参数数量。可以调用这些函数,以便使用给定的服务和命令 ID 以及给定的参数直接发出 SCM 调用的 SMC。
这是“scm_call_atomic1”函数的代码:
其中 SCM_ATOMIC 定义为:
请注意,服务 ID 和命令 ID 以及调用中的参数数量(在本例中为 1)都被编码到 R0 中。这取代了之前用于常规 SCM 调用的“神奇”值 1。
R0 中的这个不同值向 TrustZone 内核表明以下 SCM 调用是原子调用,这意味着参数将使用 R2-R5 传递(而不使用 R2 指向的结构)。
分析 SCM 调用
现在我们了解了 SCM 调用的工作原理,并且已经在 TrustZone 内核中找到了用于处理这些 SCM 调用的处理函数,我们可以开始反汇编 SCM 调用以尝试查找其中之一的漏洞。
我将跳过对 SCM 处理函数的大部分分析,因为其中大部分是用户输入的样板处理等。但是,在将堆栈切换到 TrustZone 区域并保存执行调用的原始寄存器之后,处理函数继续处理服务ID和命令ID,以便查看应该调用哪个内部处理函数。
为了轻松映射服务和命令 ID 以及相关处理函数,静态列表被编译到 TrustZone 映像的数据段中,并由 SCM 处理函数引用。以下是列表中的一小段内容:
如您所见,该列表具有以下结构:
- 指向包含 SCM 函数名称的字符串的指针
- call 类型
- 指向处理函数的指针
- 参数数量
- 每个参数的大小(每个参数一个 DWORD)
- 服务 ID 和命令 ID 连接成一个 DWORD - 例如,上面的“tz_blow_sw_fuse”函数的类型为 0x2002,这意味着它属于服务 ID 0x20,其命令 ID 为 0x02。
现在剩下的就是开始反汇编这些函数,并希望找到可利用的错误。
The Bug!
因此,在研究了所有上述 SMC 调用(全部 69 个)之后,我终于得到了以下函数:
通常,当使用常规 SCM 调用机制调用 SCM 命令时,R0 将包含结果地址
,该地址指向由内核分配的“scm_response”缓冲区,但也由 TrustZone 内核验证以确保它实际上是“允许”范围内的物理地址 - 即对应于 Linux 内核内存的物理地址,而不是 TrustZone 二进制文件中的内存位置。
此检查是使用内部函数执行的,我将在下一篇博客文章中更详细地介绍该函数。
但是如果我们使用原子 SCM 调用
来执行函数会发生什么?在这种情况下,使用的结果地址
是原子调用传递的第一个参数。
现在 - 你能看到上面函数中的错误吗?
与其他 SCM 处理函数相反,该函数没有验证 R0(“结果地址”)中的值,因此如果我们传入:
-
R1为非零值(为了通过第一个分支)(
原文有问题,是R0为非0
)
-
第四个参数(在上面的 var_1C 处传入)非零
LDR R0,[SP, #x28+var_1C]
CBZ R0, loc_FE84B372
- 进入最左侧的分支
-
R0 为任何物理地址,包括 TrustZone 地址空间范围内的地址
MOVS R6, R0
MOVS R1, #0
STR R1, [R6]
该函数将到达上面函数中最左边的分支,并在 R0 中包含的地址写入一个零 DWORD
。
What’s next? 下一步是什么?
在下一篇博文中,我将分享针对上述漏洞的详细(而且相当复杂!)利用,该漏洞可以在 TrustZone 内核中实现完整的代码执行。我还将发布完整的漏洞利用代码,敬请期待!