CVE-2019-9213 分析笔记

CVE-2019-9213

漏洞概述:

这个漏洞并不能提权,它应该属于组合技里面关键的一环,同样问题的开始出现在/proc/$pid/mem上,如果有写目标内存权限的话,那么是可以在目标用户内存空间为0的虚拟地址写东西的,那么如果再配上一个内核里面的null pointer dereference的洞是有可能制造提权效果的。

漏洞分析:

mem_open较之之前的CVE-2012-0056并没什么发生明显的变化,只是把一些操作封装起来了,这次出现问题地方比较深,直接来看mem_write:

static ssize_t mem_write(struct file *file, const char __user *buf,
			 size_t count, loff_t *ppos)
{
	return mem_rw(file, (char __user*)buf, count, ppos, 1);
}

write 和 read也整合到了一起。

static ssize_t mem_rw(struct file *file, char __user *buf,
			size_t count, loff_t *ppos, int write)
{
	...
	while (count > 0) {
		int this_len = min_t(int, count, PAGE_SIZE);

		if (write && copy_from_user(page, buf, this_len)) {
			copied = -EFAULT;
			break;
		}

		this_len = access_remote_vm(mm, addr, page, this_len, flags);
	...

回顾一下之前的CVE,之前的CVE利用点在于mm结构是mem_write才获取的,那么可以execl来替换掉/proc/self/mem,导致了su可以写自己的内存。那么在这里同样是拿su来写,但是写的是其他进程的内存,写其他低权限进程的内存,有什么作用呢?似乎也没什么作用,但是这里竟然能写到目标内存虚拟地址为0的地方上。

这里需要把目光聚集在是如何获取到这个地址0的。接着看access_remote_vm:

int access_remote_vm(struct mm_struct *mm, unsigned long addr,
		void *buf, int len, unsigned int gup_flags)
{
	return __access_remote_vm(NULL, mm, addr, buf, len, gup_flags);
}

int __access_remote_vm(struct task_struct *tsk, struct mm_struct *mm,
		unsigned long addr, void *buf, int len, unsigned int gup_flags)
{
	struct vm_area_struct *vma;
	void *old_buf = buf;
	int write = gup_flags & FOLL_WRITE;

	down_read(&mm->mmap_sem);
	/* ignore errors, just check how much was successfully transferred */
	while (len) {
		int bytes, ret, offset;
		void *maddr;
		struct page *page = NULL;

		ret = get_user_pages_remote(tsk, mm, addr, 1,
				gup_flags, &page, &vma, NULL);
		...
		maddr = kmap(page);
			if (write) {
				copy_to_user_page(vma, page, addr,
						  maddr + offset, buf, bytes);
				set_page_dirty_lock(page);
			} else {
				copy_from_user_page(vma, page, addr,
						    buf, maddr + offset, bytes);
			}
			kunmap(page);
			put_page(page);
			...
}

获取目标page的地方并不在这里,但是这里把获取目标page和写page分开了。所以这里只需要重点关注get_user_pages_remote,接下来的一些过程比较冗余,不想直接拉代码跟记流水账一样,所以这里只会列出一些重点的地方。 :)

tatic long __get_user_pages(struct task_struct *tsk, struct mm_struct *mm,
		unsigned long start, unsigned long nr_pages,
		unsigned int gup_flags, struct page **pages,
		struct vm_area_struct **vmas, int *nonblocking)
{
	...
	do {
			struct page *page;
			unsigned int foll_flags = gup_flags;
			unsigned int page_increm;

			/* first iteration or cross vma bound */
			if (!vma || start >= vma->vm_end) {
				vma = find_extend_vma(mm, start);
	...

第一次迭代会去初始化vma,什么是vma?就是虚拟内存,如果你去看/proc/$pid/maps内容,其中每一行就是一个vma块。

struct vm_area_struct *find_vma(struct mm_struct *mm, unsigned long addr)
{
	struct rb_node *rb_node;
	struct vm_area_struct *vma;
	...
	rb_node = mm->mm_rb.rb_node;
	while (rb_node) {
		struct vm_area_struct *tmp;
		tmp = rb_entry(rb_node, struct vm_area_struct, vm_rb);
		if (tmp->vm_end > addr) {
			vma = tmp;
			if (tmp->vm_start <= addr)
				break;
			rb_node = rb_node->rb_left;
		} else
			rb_node = rb_node->rb_right;
	}
	if (vma)
		vmacache_update(addr, vma);
	return vma;
}

这里就是具体找合适vma结构的地方,有一个宗旨addr < vma->end,mm->mm_rb是个红黑二叉树结构,不要想的太过于复杂,在结构上就是和普通二叉树数搜索是一样的,小的在左子节点,大的在右子节点,通过vma->vm_start <= addr判断,然后不断的逼近合适的vma区域。

在这里你是可以发现addr 如果太大,大于高地址的vma->vma_end那么肯定是会返回NULL的,但比较小的话,小于低地址的vma->vma_start是会返回这个低地址所对应的vma。

再进一步看拿到vma是怎么处理的:

find_extend_vma(struct mm_struct *mm, unsigned long addr)
{
	struct vm_area_struct *vma;
	unsigned long start;

	addr &= PAGE_MASK;
	vma = find_vma(mm, addr);
	if (!vma)
		return NULL;
	if (vma->vm_start <= addr)
		return vma;
	if (!(vma->vm_flags & VM_GROWSDOWN))
		return NULL;
	start = vma->vm_start;
	if (expand_stack(vma, addr))
		return NULL;
	if (vma->vm_flags & VM_LOCKED)
		populate_vma_page_range(vma, addr, start, NULL);
	return vma;
}

很显然,我们如果说传入的addr是0,即使我们用mmap分配到虚拟地址最低的位置0x10000.这个值可以查看/proc/sys/vm/mmap_min_addr,也是不在这个vma范围的。但是有趣的来了,如果这个vma的flag设置了VM_GROWSDOWN是会进行虚拟内存向下扩展的。

但是会进行一项security_mmap_addr的检查:

int cap_mmap_addr(unsigned long addr)
{
	int ret = 0;

	if (addr < dac_mmap_min_addr) {
		ret = cap_capable(current_cred(), &init_user_ns, CAP_SYS_RAWIO,
				  SECURITY_CAP_AUDIT);
		/* set PF_SUPERPRIV if it turns out we allow the low mmap */
		if (ret == 0)
			current->flags |= PF_SUPERPRIV;
	}
	return ret;
}

这里检查很显然已经用su绕过了,current_cred()取的写/proc/self/mem的进程。接下来的一步,在这里我就有些不理解了:

	prev = vma->vm_prev;
	/* Check that both stack segments have the same anon_vma? */
	if (prev && !(prev->vm_flags & VM_GROWSDOWN) &&
			(prev->vm_flags & (VM_WRITE|VM_READ|VM_EXEC))) {
		if (address - prev->vm_end < stack_guard_gap)
			return -ENOMEM;
	}

按照前面遍历的过程,此时vma拿到的肯定是地址最低的地方,怎么可能还会有更低的地方?这里的检查有什么作用?然后我想了一下整个过程,其实这里有道理的。可能会出现这样一种情况:

--------------|low
|VMA		  |
--------------|high
|			 \|/  <-----------addr
--------------
|VMA		  |
--------------

这里出现stack_guard_gap为1M,是一种当vma内存增长时保护措施,具体的可以看看这篇文章https://blog.qualys.com/securitylabs/2017/06/19/the-stack-clash

回到本文的主题,显然这里一切正常,绕过了mmap_min_addr的限制向下扩展内存,然后用缺页中断,分配真正物理内存。以至于可以在用户空间0虚拟地址写入构造的数据,这个mmap_min_addr设置的初衷就是为了减少linux kernel里面null pointer dereference的隐患。
也并非不可以在虚拟地址0上写东西,这个mmap_min_addr的是可以直接设置的。

思考:

https://cert.360.cn/report/detail?id=58e8387ec4c79693354d4797871536ea 这篇文章的师傅发表了一个观点说,修复的方法似乎并不合理。但我认为这恰恰是最合理的。

笔者以为这样修补没有真正解决问题。这是一个逻辑漏洞,根本原因在于可以通过两个进程绕过security_mmap_addr函数中cap_capable(current_cred()……)的检查逻辑

师傅认为这里的cap_capable检查逻辑存在问题。我感觉这里并没有错,只是用错了地方。

If the process is attempting to map memory below dac_mmap_min_addr they need CAP_SYS_RAWIO. The other parameters to this function are unused by the capability security module. Returns 0 if this mapping should be allowed-EPERM if not.

从上述注释可以看的出来,the process想要获取目标内存低于dac_mmap_min_addr的内存映射,必须要有CAP_SYS_RAWIO的权限。这个地方权限判断不应该放在进程读写这个地方,想要获取的目标地址并不是属于当前进程,security_mmap_addr应该是用在当前进程下的地址判断。

但是如果说其他地方也用到这个security_mmap_addr,如果处于进程间的读写话,也是有可能出现问题的。我也搜索了一下存在security_mmap_addr的函数。只有一个get_unmapped_area有,这个函数发生在用户进程空间需要映射新的内存时候。这也很难把和多进程的操作联系起来。

所以正如官方修复的那样,直接删掉这个地方不合理的权限判断,扩展低于mmap_min_addr的地址时直接返回error。

但是这里还是可以通过指定VM_GROWSDOWN来向下扩展内存。这是比较有趣的地方,虽然不能扩展至mmap_min_addr以下。接下来就是分析利用这个洞的组合技。:)

5 个赞