CVE-2016-5195 脏牛漏洞浅析

前言

写这篇文章,主要是觉得自己学内核也有段时间了,也需要去了解了解这个在kernel几乎算得上的内核提权漏洞最经典的漏洞了

一、”dirty cow” 简介

脏牛漏洞之所以如此著名,主要在于其历史悠久且影响范围较广,根据 wiki 百科上的描述,脏牛漏洞自2007年9月 linux kernel 2.6.22 被引入,直到2018年linux kernel 4.8.3, 4.7.9, 4.4.26 之后才被彻底修复,影响在此之间的所有基于其中版本范围的Linux发行版。同时也是一个由Linus本人亲手修复的漏洞

二、相关概念

1. COW

COW 全名为 Copy-on-write,cow技术的应用范围很广泛,比较常见的是在 fork() 中的应用:

fork() creates a new process by duplicating the calling process.
The new process is referred to as the child process.  The calling
process is referred to as the parent process.

The child process and the parent process run in separate memory
spaces.  At the time of fork() both memory spaces have the same
content.  Memory writes, file mappings (mmap(2)), and unmappings
(munmap(2)) performed by one of the processes do not affect the
other.

以上一段是从man手册中截取出来的对fork的描述,从该描述中可知 fork 在创建子进程时,会对自身进程空间进行复制, fork 完成时,父子进程具有完全相同的进程空间。注意这里的复制并不是说直接将整个父进程地址空间的所有内容都复制一份后再分配给子进程(虽然第一代 UNIX 系统的确采用了这种非常耗时的做法),而是基于一种更为高效的思想:

「父进程与子进程共享所有的页框」而不是直接为子进程分配新的页框,「只有当任意一方尝试修改某个页框」的内容时内核才会为其分配一个新的页框,并将原页框中内容进行复制

解释一下为什么要这么办,从编码的角度来看,一般情况下在 fork 之后会存在一个 execve 或其他 exec 系列的函数来执行一个新的程序,在调用 execve 的时候,内核会将新程序的代码段、数据段……映射到子进程的内存中。(见于LilacCTF-2026中的vm题目)
上述创建子进程的过程,父进程将自身的内存空间完全拷贝给了子进程后,子进程很快就执行 execve 将新程序装载进入自己的内存中,覆盖了大部分父进程拷贝的内存,那么实际上大部分的父进程拷贝的数据是无用的。完全没有必要去将父进程的数据都复制一份后分配给子进程。因而内核引入了 Copy-on-write 技术,即当 fork 创建完子进程后,父子进程实际上共享物理内存,当父子进程中发生了对内存写入的操作时,内核再为子进程分配新的内存页并将改动写入新内存页中,也就是在 fork 之后,execve 之前的过程。

实现原理

  • fork() 系统调用之后,父子进程共享所有的页框,内核会将这些页框全部标为read-only

  • 由于所有页框被标为只读,当任意一方尝试修改某个页框时,便会触发「缺页异常」(page fault)——此时内核才会为其分配一个新的页框

2. mmap 与 COW

同样地,若是我们使用 mmap 映射了一个只具有读权限而不具有写权限的文件,当我们尝试向 mmap 映射区域写入内容时,也会触发写时复制机制(准确的说法是:这里触发的是缺页异常(Page Fault),而不完全等同于通常说的"写时复制"。)将该文件内容拷贝一份到内存中,此时进程对这块区域的读写操作便不会影响到硬盘上的文件。

// 以只读权限打开文件
int fd = open("file.txt", O_RDONLY);

// 用 PROT_READ 映射(只读)
void *p = mmap(NULL, size, PROT_READ, MAP_PRIVATE, fd, 0);

// 尝试写入 → 会发生什么?
*(char*)p = 'A';


进程尝试写入只读页面
        │
        ▼
   触发 Page Fault
   (保护性异常,SIGSEGV 或 COW)
        │
        ├─── MAP_SHARED + PROT_READ → 直接 SIGSEGV,进程崩溃
        │
        └─── MAP_PRIVATE + PROT_READ → 这里才是文章说的情况
                    │
                    ▼
             内核执行 COW:
             把这一页文件内容复制一份到新物理页
             PTE 指向新页,权限改为可写
                    │
                    ▼
             进程写入的是副本
             硬盘文件完全不受影响

结合页表来看 COW 的过程
触发写入前:

进程 PTE ──────────────→ 文件页(物理内存/磁盘缓存)
                          只读,P=1, W=0


触发写入后,内核 COW 处理:

进程 PTE ──────────────→ 新物理页(内容是文件的副本)
                          可写,P=1, W=1

文件页(原来那份)────→ 文件缓存,保持不变
                          硬盘文件也不变

3. linux 虚拟内存

(1). VMA简介(Virtual Memory Area)

虚拟内存概念的引入,以32位系统为例,进程可以“独享”3G大小的用户空间,且进程之间的操作是互相隔离的,对相同虚拟地址的操作并不会产生冲突。只有当进程开始操作申请到的内存时,内核才会触发缺页异常将指定的物理页面换入内存中。
进程的虚拟内存会被分成若干区域,这些区域就是 VMA,VMA的各种属性由 vm_area_struct 结构来描述:

struct vm_area_struct {
    struct mm_struct * vm_mm;   /* 所属的内存描述符 */
    unsigned long vm_start;    /* vma的起始地址 */
    unsigned long vm_end;       /* vma的结束地址 */

    /* 该vma的在一个进程的vma链表中的前驱vma和后驱vma指针,链表中的vma都是按地址来排序的*/
    struct vm_area_struct *vm_next, *vm_prev;

    pgprot_t vm_page_prot;      /* vma的访问权限 */
    unsigned long vm_flags;    /* 标识集 */

    struct rb_node vm_rb;      /* 红黑树中对应的节点 */

    /*
     * For areas with an address space and backing store,
     * linkage into the address_space->i_mmap prio tree, or
     * linkage to the list of like vmas hanging off its node, or
     * linkage of vma in the address_space->i_mmap_nonlinear list.
     */
    /* shared联合体用于和address space关联 */
    union {
        struct {
            struct list_head list;/* 用于链入非线性映射的链表 */
            void *parent;   /* aligns with prio_tree_node parent */
            struct vm_area_struct *head;
        } vm_set;

        struct raw_prio_tree_node prio_tree_node;/*线性映射则链入i_mmap优先树*/
    } shared;

    /*
     * A file's MAP_PRIVATE vma can be in both i_mmap tree and anon_vma
     * list, after a COW of one of the file pages.  A MAP_SHARED vma
     * can only be in the i_mmap tree.  An anonymous MAP_PRIVATE, stack
     * or brk vma (with NULL file) can only be in an anon_vma list.
     */
    /*anno_vma_node和annon_vma用于管理源自匿名映射的共享页*/
    struct list_head anon_vma_node; /* Serialized by anon_vma->lock */
    struct anon_vma *anon_vma;  /* Serialized by page_table_lock */

    /* Function pointers to deal with this struct. */
    /*该vma上的各种标准操作函数指针集*/
    const struct vm_operations_struct *vm_ops;

    /* Information about our backing store: */
    unsigned long vm_pgoff;     /* 映射文件的偏移量,以PAGE_SIZE为单位 */
    struct file * vm_file;          /* 映射的文件,没有则为NULL */
    void * vm_private_data;     /* was vm_pte (shared mem) */
    unsigned long vm_truncate_count;/* truncate_count or restart_addr */

#ifndef CONFIG_MMU
    struct vm_region *vm_region;    /* NOMMU mapping region */
#endif
#ifdef CONFIG_NUMA
    struct mempolicy *vm_policy;    /* NUMA policy for the VMA */
#endif
};

(2). 映射关系

这里首先介绍一下 Linux 四级页表的基本知识点,当我们需要实际操作一个页面的时候,ring3 程序使用的是虚拟地址,ring0 需要对虚拟地址进行转换,对应到物理地址后才能对内存进行操作。

为什么要存在页表呢?
真实的物理内存只有固定的大小,但是操作系统给每个进程都会提供同样大小的虚拟内存,那么问题来了,物理内存只有固定的大小,想要让所有的进程都感觉自己使用了所有的物理内存应该怎么做呢?

—> 将每个进程活跃的页面放到物理内存中,不活跃的就从物理内存中换出,等到需要的时候再从外存中调入,同一时刻进程真正活跃的页面 相对于其整个占用的内存空间来说是比较小的。

此时,就引出了另外一个问题,我怎么知道我要访问的虚拟内存页面是哪个物理内存页面?

—> 此时页表(分页机制)就闪亮登场了,通过虚拟地址以及 cr3 提供的信息进行转换后,就可以找到虚拟地址对应的物理地址。

通常来说,需要多级页表来完成映射关系,级数越高,页表所占的内存就越小,效率就越低。
但是级数也不能太少,不然以 32位 系统为例,ring3 可用的虚拟内存大小为 3G,一页内存大小为 4k,即 3*2^{30} / 4*2^{10} = 786432 个页面,一个页面需要 4 byte 的页表,也就是对于一个32位的系统而言,其就需要 768个物理页来存储页表,但是问题在于,即便一个进程只用了 10MB 内存,也需要维护 3MB 的页表——描述"什么都没有"的结构本身就占了大量空间。对于动辄几十上百个进程的操作系统来说这样的开销是不可容忍的。

Linux 中采用的是四级页表的存储方式

字段

描述

CR3

指向一个PDPT(页目录指针表)

PGD

指向PDPT4个项中的一个

PMD

指向页目录中512项中的一个

PTE

指向页表中512项中的一个

page offset

4kb页中的偏移

CR3 寄存器
    │
    ▼
┌─────────┐     只有 4 个表项
│  PDPT   │ ← 新增的第三级,4 × 8byte = 32 byte
└────┬────┘
     │
     ▼
┌─────────┐
│页目录 PD │ ← 原来的第一级
└────┬────┘
     │
     ▼
┌─────────┐
│页表  PT  │ ← 原来的第二级
└────┬────┘
     │
     ▼
 物理页面

因此需要根据虚拟地址和 cr3 的信息进行四次寻址才能拿到 pte 的地址。

4. 缺页异常(page fault)

在 CPU 中使用 MMU(Memory Management Unit,内存管理单元)进行虚拟内存与物理内存间的映射,而在系统中并非所有的虚拟内存页都有着对应的物理内存页, 当软件试图访问已映射在虚拟地址空间中,但是并未被加载在物理内存中的一个分页时,MMU 无法完成由虚拟内存到物理内存间的转换,此时便会产生「缺页异常」(page fault)

可能出现缺页异常的情况如下:

  • 线性地址不在虚拟地址空间中

  • 线性地址在虚拟地址空间中,但没有访问权限

  • 线性地址在虚拟地址空间中,但没有与物理地址间建立映射关系

虽然被命名为 “fault” ,但是缺页异常的发生并不一定代表出错

分类

软性缺页异常(soft page fault)

软性缺页异常意味着相关的页已经被载入内存中,但是并未向 MMU 进行注册,此时内核只需要在 MMU 中注册相关页对应的物理页即可

可能出现软性缺页异常的情况如下:

  • 两个进程间共享相同的物理页框,操作系统为其中一个装载并注册了相应的页,但是没有为另一个进程注册

  • 该页已被从 CPU 的工作集(在某段时间间隔 ∆ 里,进程实际要访问的页面的集合,为提高性能,只有经常被使用的页才能驻留在工作集中,而长期不用的页则会被从工作集中移除)中移除,但是尚未被交换到磁盘上;若是程序重新需要使用该页内容,CPU 只需要向 MMU 重新注册该页即可

硬性缺页异常(hard page fault)

硬性缺页异常意味着相关的页未经被载入内存中,此时操作系统便需要寻找到一个合适且空闲的物理页/将另一个使用中的页写到硬盘上,随后向该物理页内写入相应内容,并在 MMU 中注册该页

硬性缺页异常的开销极大,因此部分操作系统也会采取延迟页载入的策略——只有到万不得已时才会分配新的物理页,这也是 Linux 内核的做法

若是频繁地发生硬性缺页异常则会引发系统颠簸(system thrashing,有的书上也叫系统抖动)——因资源耗尽而无法正常完成工作

无效缺页异常(invalid page fault)

无效缺页异常意味着程序访问了一个无效的内存地址(内存地址不存在于进程地址空间),在 Linux 下内核会向进程发送 SIGSEGV 信号。

处理缺页异常

在接下来的分析过程中所涉及到的地址如无说明皆为【线性地址】

仅针对「文件映射缺页异常」而言,大致的流程如下图所示:

    ┌─────────────────────────────────────────────────────────────┐
    │                   文件映射缺页异常处理流程                  │
    └─────────────────────────────────────────────────────────────┘

                                    │
                                    ▼
                            ┌─────────────────┐
                            │   触发缺页异常  │
                            │(访问文件映射页) │
                            └─────────────────┘
                                    │
                                    ▼
                            ┌─────────────────┐
                            │   查找页表项    │
                            │   (检查PTE状态) │
                            └─────────────────┘
                                    │
                    ┌───────────────┴───────────────┐
                    ▼                               ▼
            ┌───────────────┐                ┌───────────────┐
            │   PTE有效?   │───────否───────│   触发SIGSEGV │
            │  (页已缓存)   │                │   (非法访问)  │
            └───────────────┘                └───────────────┘
                    │                                    │
                    │是                                  │
                    ▼                                    ▼
            ┌───────────────┐                            │
            │  页是否在     │                            │
            │  页缓存中?   │                            │
            └───────────────┘                            │
                    │                                    │
        ┌───────────┴───────────┐                        │
        ▼                       ▼                        │
┌───────────────┐       ┌───────────────┐                │
│   已在页缓存  │       │   不在页缓存  │                │
│   (Page Cache)│       │(需要从文件读) │                │
└───────────────┘       └───────────────┘                │
        │                       │                        │
        │                       ▼                        │
        │               ┌───────────────┐                │
        │               │  分配物理页   │                │
        │               │  (可能有回收) │                │
        │               └───────────────┘                │
        │                       │                        │
        │                       ▼                        │
        │               ┌───────────────┐                │
        │               │  启动I/O操作  │                │
        │               │ 从文件系统读取│                │
        │               └───────────────┘                │
        │                       │                        │
        │                       ▼                        │
        │               ┌───────────────┐                │
        │               │   I/O完成     │                │
        │               │  (DMA到内存)  │                │
        │               └───────────────┘                │
        │                       │                        │
        └───────────────┐       │                        │
                        ▼       ▼                        │
                  ┌───────────────────┐                  │
                  │   更新页表项      │                  │
                  │   (设置PTE有效位) │                  │
                  └───────────────────┘                  │
                          │                              │
                          ▼                              │
                  ┌───────────────────┐                  │
                  │   返回用户空间    │                  │
                  │  重新执行指令     │                  │
                  └───────────────────┘                  │
                          │                              │
                          ▼                              │
                  ┌───────────────────┐                  │
                  │     完成访问      │                  │
                  │  (进程继续执行)   │                  │
                  └───────────────────┘                  │
                          │                              │
                          └──────────────────────────────┘

┌─────────────────────────────────────────────────────────────┐
│                   关键数据结构说明                          │
├─────────────────────────────────────────────────────────────┤
│ struct page {                                               │
│     unsigned long flags;    // 页状态标志                   │
│     atomic_t _refcount;     // 引用计数                     │
│     struct address_space *mapping; // 指向所属文件映射      │
│     pgoff_t index;          // 在文件中的页偏移             │
│     void *virtual;          // 内核虚拟地址                 │
│ };                                                          │
├─────────────────────────────────────────────────────────────┤
│ struct vm_area_struct {                                     │
│     unsigned long vm_start;    // 起始地址                  │
│     unsigned long vm_end;      // 结束地址                  │
│     struct file *vm_file;      // 映射的文件                │
│     unsigned long vm_pgoff;    // 文件内的偏移              │
│     unsigned long vm_flags;    // 访问权限                  │
│ };                                                          │
└─────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────┐
│                   关键函数调用链                            │
├─────────────────────────────────────────────────────────────┤
│ do_page_fault()                                             │
│   ├── handle_mm_fault()                                     │
│   │     └── handle_pte_fault()                              │
│   │           ├── do_anonymous_page()  // 匿名页            │
│   │           └── do_file_page_fault()  // 文件映射页       │
│   │                 └── filemap_fault()                     │
│   │                       ├── find_get_page()  // 查找页缓存│
│   │                       ├── page_cache_read() // 读文件   │
│   │                       └── do_async_page_fault()         │
│   └── 返回用户空间                                          │
└─────────────────────────────────────────────────────────────┘

5. COW 与 缺页异常相关流程

当我们使用mmap映射一个只读文件,随后开辟一个新进程,尝试通过 /proc/self/mem 文件直接往一个原有的共享页面写入内容时,其流程应当如下:

系统调用:write的执行流

用户态的 write 系统调用最终对应的是内核中的 sys_write(),该系统调用定义于 fs/read_write.c 中,如下:

直接在源码里查 sys_write 是没法查到的,这是因为系统调用对应的内核函数名都是由宏 SYSCALL_DEFINE最终拼接而成,可以参见这里

SYSCALL_DEFINE3(write, unsigned int, fd, const char __user *, buf,
		size_t, count)
{
	struct fd f = fdget_pos(fd);
	ssize_t ret = -EBADF;

	if (f.file) {
		loff_t pos = file_pos_read(f.file);
		ret = vfs_write(f.file, buf, count, &pos);
		if (ret >= 0)
			file_pos_write(f.file, pos);
		fdput_pos(f);
	}

	return ret;
}

中间的具体执行过程并非本篇重点,我们暂且略过,快进到其调用并写入用户内存页的步骤,执行流如下:

entry_SYSCALL_64()
	sys_write()
		vfs_write()
			__vfs_write()
				file->f_op->write()

/proc/self/mem:绕过页表项权限

“脏牛”通常利用的是 /proc/self/mem 进行越权写入,这也是整个“脏牛”利用中较为核心的流程

对于该文件,其执行流如下:

这个函数接收一个 file 结构指针(表示打开的 /proc/pid/mem 文件)、用户空间的缓冲区 buf、要读写的字节数 count、文件位置指针 ppos,以及一个表示读或写的标志 write。函数首先从 file->private_data 中获取目标进程的内存描述符 mm_struct,这个结构包含了目标进程的所有内存管理信息。如果获取不到 mm 就直接返回 0。接着函数分配一个临时页面作为数据中转站,这是内核空间的内存页,用于在用户空间和目标进程内存之间传输数据。

函数的核心逻辑建立在一个循环中,每次处理一页大小的数据。在每次循环中,函数会根据当前剩余字节数决定本次处理的大小(不超过一页)。如果是写操作,函数首先使用 copy_from_user 将用户空间的数据复制到刚才分配的临时页面中,如果复制失败则跳出循环。接着调用关键函数 access_remote_vm(),这个函数负责实际的跨进程内存访问,它会遍历目标进程的页表,找到对应的物理页面,然后在内核中建立临时映射,最后将临时页面的数据复制到目标物理页面(写操作)或者将目标物理页面的数据复制到临时页面(读操作)。这个函数返回实际成功访问的字节数。

如果是读操作,在 access_remote_vm() 成功将目标进程的数据读入临时页面后,函数使用 copy_to_user 将临时页面的数据复制回用户空间的缓冲区。每次成功处理后,函数更新缓冲区指针、地址位置、已复制字节数和剩余字节数,然后继续循环处理剩余数据。循环结束后,更新文件位置指针,释放对目标进程内存描述符的引用(mmput),最后释放临时页面并返回实际复制的字节数。

其中 access_remote_vm() 函数本身为 __access_remote_vm() 函数的套娃,该函数位于 mm/memory.c 中,代码如下:

/*
 * Access another process' address space as given in mm.  If non-NULL, use the
 * given task for page fault accounting.
 */
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;

	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(tsk, mm, addr, 1,
				write, 1, &page, &vma); //获取操作(从...读取/向...写入)对应的目标内存页
		if (ret <= 0) { //失败了,未能获取到用户页
#ifndef CONFIG_HAVE_IOREMAP_PROT
			break;
#else
			/*
			 * Check if this is a VM_IO | VM_PFNMAP VMA, which
			 * we can access using slightly different code.
			 */
			vma = find_vma(mm, addr);
			if (!vma || vma->vm_start > addr)
				break;
			if (vma->vm_ops && vma->vm_ops->access)
				ret = vma->vm_ops->access(vma, addr, buf,
							  len, write);
			if (ret <= 0)
				break;
			bytes = ret;
#endif
		} else {
			bytes = len;
			offset = addr & (PAGE_SIZE-1);
			if (bytes > PAGE_SIZE-offset)
				bytes = PAGE_SIZE-offset;

            // 利用 kmap 为获取到的页面建立临时映射,因为我们获取的是 page 结构体,需要映射到一个虚拟地址之后才能进行写入
			maddr = kmap(page);
            /*
            * 分两种情况:读/写
            * 内核将 read/write 的流程统一于 mm_rw() 函数中,这也是为什么上层函数是 'mem_rw' 而不是 'mem_read/mem_write'
            */
			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);
		}
		len -= bytes;
		buf += bytes;
		addr += bytes;
	}
	up_read(&mm->mmap_sem);

	return buf - old_buf;
}

那么这个函数主要就分如下几步:

  • 通过 get_user_pages() 获取到对应的内存页(注意这里获取的是 page 结构体,因为该物理页不一定有映射)

  • 通过 kmap() 获取到该内存页映射到的虚拟地址(若无则会建立新的临时映射)

  • 通过 copy_from_user_page()/copy_to_user_page() 读/写对应的内存页

我们在这里主要关注点在写之前——该函数使用 get_user_pages() 获取对应的内存页,主要还是套娃,其会调用 __get_user_pages_locked() ,该函数最终调用 __get_user_pages(),定义于 mm/gup.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)
{
	long i = 0;
	unsigned int page_mask;
	struct vm_area_struct *vma = NULL;

	if (!nr_pages)
		return 0;

	VM_BUG_ON(!!pages != !!(gup_flags & FOLL_GET));

	/*
	 * If FOLL_FORCE is set then do not force a full fault as the hinting
	 * fault information is unrelated to the reference behaviour of a task
	 * using the address space
	 */
	if (!(gup_flags & FOLL_FORCE))
		gup_flags |= FOLL_NUMA;

	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);
			if (!vma && in_gate_area(mm, start)) {
				int ret;
				ret = get_gate_page(mm, start & PAGE_MASK,
						gup_flags, &vma,
						pages ? &pages[i] : NULL);
				if (ret)
					return i ? : ret;
				page_mask = 0;
				goto next_page;
			}

			if (!vma || check_vma_flags(vma, gup_flags))
				return i ? : -EFAULT;
			if (is_vm_hugetlb_page(vma)) {
				i = follow_hugetlb_page(mm, vma, pages, vmas,
						&start, &nr_pages, i,
						gup_flags);
				continue;
			}
		}
retry:
		/*
		 * If we have a pending SIGKILL, don't keep faulting pages and
		 * potentially allocating memory.
		 */
		if (unlikely(fatal_signal_pending(current)))
			return i ? i : -ERESTARTSYS;
		cond_resched();
		page = follow_page_mask(vma, start, foll_flags, &page_mask);// 获取虚拟地址对应的物理页(page结构体)
		if (!page) {// 失败了
            		/*
            		/* 两种原因:
            		* (1) 不存在对应的物理页(未与物理页见建立相应的映射关系)
            		* (2) 存在这样的物理页,但是没有相应的操作权限(如该页不可写)
            		* 在 COW 流程中会先走(1),然后走(2)
            		*/
			int ret;
			ret = faultin_page(tsk, vma, start, &foll_flags,
					nonblocking);//【核心】处理缺页异常
			switch (ret) {
			case 0:
				goto retry;//成功处理缺页异常,回去重新尝试调页
			case -EFAULT:
			case -ENOMEM:
			case -EHWPOISON:
				return i ? i : ret;
			case -EBUSY:
				return i;
			case -ENOENT:
				goto next_page;
			}
			BUG();
		} else if (PTR_ERR(page) == -EEXIST) {
			/*
			 * Proper page table entry exists, but no corresponding
			 * struct page.
			 */
			goto next_page;
		} else if (IS_ERR(page)) {
			return i ? i : PTR_ERR(page);
		}
		if (pages) {
			pages[i] = page;
			flush_anon_page(vma, page, start);
			flush_dcache_page(page);
			page_mask = 0;
		}
next_page:
		if (vmas) {
			vmas[i] = vma;
			page_mask = 0;
		}
		page_increm = 1 + (~(start >> PAGE_SHIFT) & page_mask);
		if (page_increm > nr_pages)
			page_increm = nr_pages;
		i += page_increm;
		start += page_increm * PAGE_SIZE;
		nr_pages -= page_increm;
	} while (nr_pages);
	return i;
}
EXPORT_SYMBOL(__get_user_pages);

COW的两个要点:

  • 在我们第一次尝试访问某个内存页时,由于延迟绑定机制,Linux尚未建立起该页与对应物理页间的映射,此时 follow_page_mask() 返回 NULL;由于没获取到对应内存页,接下来调用 faultin_page() 函数解决缺页异常,分配物理页

  • 调用 faultin_page() 函数成功解决缺页异常之后会回到 retry 标签,接下来会重新调用 follow_page_mask() ,而若是当前进程对于该页没有写权限(二级页表标记为不可写),则还是会返回NULL;由于没获取到对应内存页,接下来调用 faultin_page() 函数解决缺页异常,进行写时复制

到了这里,mem_rw() 大致的流程便一目了然了:

mem_rw()
	__get_free_page()//获取空闲页,将要写入的数据进行拷贝
	access_remote_vm()
		__access_remote_vm()// 写入数据,执行 write 这一系统调用的核心功能
			get_user_pages()
				__get_user_pages_locked()
					__get_user_pages()//获取对应的用户进程的内存页
						follow_page_mask()//调内存页的核心函数
						faultin_page()//解决缺页异常

接下来来到缺页异常的处理函数 faultin_page() 的流程。

第一次触发缺页异常

由于 Linux 的延迟绑定机制,在第一次访问某个内存页之前 Linux kernel 并不会为其分配物理页,于是我们没法获取到对应的页表项, follow_page_mask() 返回 NULL,此时便会进入 faultin_page() 函数处理缺页异常,该函数定义于 mm/gup.c 中,如下:

static int faultin_page(struct task_struct *tsk, struct vm_area_struct *vma,
		unsigned long address, unsigned int *flags, int *nonblocking)
{
	struct mm_struct *mm = vma->vm_mm;
	unsigned int fault_flags = 0;
	int ret;

	/* mlock all present pages, but do not fault in new pages */
	if ((*flags & (FOLL_POPULATE | FOLL_MLOCK)) == FOLL_MLOCK)
		return -ENOENT;
	/* For mm_populate(), just skip the stack guard page. */
	if ((*flags & FOLL_POPULATE) &&
			(stack_guard_page_start(vma, address) ||
			 stack_guard_page_end(vma, address + PAGE_SIZE)))
		return -ENOENT;
	if (*flags & FOLL_WRITE)//因为我们要写入该页,所以该标志位存在
		fault_flags |= FAULT_FLAG_WRITE;
	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;
	}

	ret = handle_mm_fault(mm, vma, address, fault_flags);//分配内存页
	if (ret & VM_FAULT_ERROR) {
		if (ret & VM_FAULT_OOM)
			return -ENOMEM;
		if (ret & (VM_FAULT_HWPOISON | VM_FAULT_HWPOISON_LARGE))
			return *flags & FOLL_HWPOISON ? -EHWPOISON : -EFAULT;
		if (ret & (VM_FAULT_SIGBUS | VM_FAULT_SIGSEGV))
			return -EFAULT;
		BUG();
	}

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

	/*
	 * The VM_FAULT_WRITE bit tells us that do_wp_page has broken COW when
	 * necessary, even if maybe_mkwrite decided not to set pte_write. We
	 * can thus safely do subsequent page lookups as if they were reads.
	 * But only do so when looping for pte_write is futile: in some cases
	 * userspace may also be wanting to write to the gotten user page,
	 * which a read fault here might prevent (a readonly page might get
	 * reCOWed by userspace write).
	 */
	if ((ret & VM_FAULT_WRITE) && !(vma->vm_flags & VM_WRITE))//第二次缺页异常会走到这里,清除 FOLL_WRITE 标志位
		*flags &= ~FOLL_WRITE;
	return 0;
}

大致的调用流程如下:

faultin_page()
    handle_mm_fault()
        __handle_mm_fault()
            handle_pte_fault()//发现pte为空,第一次访问该页
                do_fault()//非匿名页,直接调入
                    do_cow_fault()//我们要写入该页,所以走到了这里
                    	do_set_pte()
                            maybe_mkwrite()
                                pte_mkdirty()//将该页标脏

之后该页被调入主存中,但是此时我们并无对该页的写权限

第二次触发缺页异常

虽然我们成功调入了内存页,但是由于我们对该页并无写权限, follow_page_mask() 依旧会返回 NULL ,再次触发缺页异常,于是我们再次进入 faultin_page() 函数,来到了「写时复制」的流程,细节在前面已经分析过了,这里便不再赘叙

由于这一次成功获取到了一个可写的内存页,此时 faultin_page() 函数会清除 foll_flagsFOLL_WRITE 标志位

大致流程如下:

faultin_page()
    handle_mm_fault()
        __handle_mm_fault()
            handle_pte_fault()
                do_wp_page()
                	reuse_swap_page(old_page)
                		wp_page_reuse()

第三次尝试获取内存页

接下来的流程最终回到 __get_user_pages() 的 retry 标签,第三次尝试获取内存页,此时 foll_flagsFOLL_WRITE 标志位已经被清除,内核认为该页可写,于是 follow_page_mask() 函数成功获取到该内存页,接下来便是常规的写入流程, COW 结束。

漏洞分析

既然CVE-2016-5195俗称「dirtyCOW」,毫无疑问漏洞出现在 COW 的过程当中,现在让我们来重新审视整个 COW 的过程

多线程竞争

我们在通过 follow_page_mask() 函数获取对应的内存页之前,用以判断该内存页是否会被写入的逻辑是根据 foll_flagsFOLL_WRITE 标志位进行判断的,但是决定 从该内存页读出数据/向该内存页写入数据 则是由传入给 mem_rw() 函数的参数 write 决定的。

当一个只读页要被写入时,正常的 COW 流程应该是:

  • 检测到只读页要写入 → 触发缺页异常 → COW 复制新页 → 写入新页,改的是副本,不会影响原文件。

但是我们来思考如下竞争过程,假如我们启动了两个线程:

首先我们这里假设某进程通过mmap接口将只读文件A映射到虚拟内存上。

    // 1. 打开只读文件 A
    int fd = open("A", O_RDONLY);
    if (fd == -1) {
        perror("open");
        exit(1);
    }
    
    // 2. 获取文件大小(用于确定映射长度)
    struct stat st;
    if (fstat(fd, &st) == -1) {
        perror("fstat");
        close(fd);
        exit(1);
    }
    
    // 3. 使用 mmap 创建私有只读映射
    void *addr = mmap(
        NULL,                    // 让内核选择起始地址
        st.st_size,              // 映射文件全部内容
        PROT_READ,               // 只读权限
        MAP_PRIVATE,             // 私有映射(写时复制)
        fd,                      // 文件描述符
        0                        // 从文件开头开始映射
    );

mmap映射的参数如上所示,文件A以私有只读方式方式被映射到虚拟内存上。MAP_PRIVATE标志在这里起到了重要的作用的,首先它会让COW机制起作用,其次COW分出来的页都是匿名页,向内存中写入的内容不会同步到磁盘文件中去。

  • 第一个线程尝试向「仅具有读权限的mmap映射区域写入内容」,此时便会触发缺页异常,进入到写时复制(COW)的流程当中

  • 第二个线程使用 madvise() 函数通知内核「第一个线程要写入的那块区域标为未使用」,也就是让这段映射失效/回收,迫使页表反复掉页,此时由 COW 分配得到的新内存页将会被再次调出

四次获取内存页 & 三次缺页异常

我们不难想到的是,既然这两个线程跑在竞争态,在第一个线程走完两次缺页异常的流程之后,成功获取到了一个可写的内存页,此时 faultin_page() 函数会清除该页的 foll_flagsFOLL_WRITE 标志位,若是第二个线程调用 madvise() 将页表项中的该页再次调出,第一个线程在第三次尝试获取内存页时便无法获取到内存页,便会再次触发缺页异常,接下来进入到 faultin_page() 的流程获取原内存页

__get_user_pages() 函数中 foll_flagsFOLL_WRITE 标志位已经在第二次尝试获取内存页、第二次触发缺页异常被清除, 此时该函数 第四次尝试获取内存页,由于不存在标志位的冲突,便可以 直接“正常”的 获取到原来的文件页。

接下来便回到了 mem_rw()的写流程,此时我们便成功绕过了 foll_flags对于读写的检测,成功获取到只有读权限的内存页,完成越权写

poc

待续待续..................


CVE-2016-5195 脏牛漏洞浅析
https://a1b2rt.cn//archives/cve-2016-5195-zang-niu-lou-dong-qian-xi
作者
A1b2rt
发布于
2026年03月24日
许可协议