链表游戏:CVE-2017-10661之完全利用

原文来自安全客,作者:huahuaisadog@360 Vulpecker Team
原文链接:https://www.anquanke.com/post/id/129468

最近在整理自己以前写的一些Android内核漏洞利用的代码,发现了一些新的思路。

CVE-2017-10661的利用是去年CORE TEAM在hitcon上分享过的:https://hitcon.org/2017/CMT/slide-files/d1_s3_r0.pdf。他们给出的利用是在有CAP_SYS_TIME这个capable权限下的利用方式,而普通用户没这个权限。最近整理到这里的时候,想了想如何利用这个漏洞从0权限到root呢?没想到竟然还能有一些收获,分享一哈:

  • CVE-2017-10661简单分析
  • CAP_SYS_TIME下的利用
  • pipe的TOCTTOU
  • 思考下链表操作与UAF
  • 0权限下的利用
CVE-2017-10661简单分析

关于CVE-2017-10661的分析和SYS_TIME下的利用,CORE TEAM的ppt中已经有比较清晰的解释。我这里再简单的用文字描述一遍吧。

这个漏洞存在于Linux内核代码 fs/timerfd.c的timerfd_setup_cancel函数中:

static void timerfd_setup_cancel(struct timerfd_ctx *ctx, int flags)
{
    if ((ctx->clockid == CLOCK_REALTIME ||
         ctx->clockid == CLOCK_REALTIME_ALARM) &&
        (flags & TFD_TIMER_ABSTIME) && (flags & TFD_TIMER_CANCEL_ON_SET)) {
        if (!ctx->might_cancel) {      //[1][2]
            ctx->might_cancel = true;  //[3][4]
            spin_lock(&cancel_lock);
            list_add_rcu(&ctx->clist, &cancel_list); //[5][6]
            spin_unlock(&cancel_lock);
        }
    } else if (ctx->might_cancel) {
        timerfd_remove_cancel(ctx);
    }
}

这里会有一个race condition:假设两个线程同时对同一个ctx执行timerfd_setup_cancel操作,可能会出现这样的情况(垂直方向为时间线):

Thread1                  Thread2

[1]检查ctx->might_cancel,值为false

. [2]检查ctx->might_cancel,值为false

[3]将ctx->might_cancel赋值为true

. [4]将ctx->might_cancel赋值为true

[5]将ctx加入到cancel_list中

. [6]将ctx再次加入到cancel_list中

所以,这里其实是因为ctx->might_cancel是临界资源,而这个函数对它的读写并没有加锁,虽然在if(!ctx->might_cancel)ctx->might_cancel的时间间隔很小,但是还是可以产生资源冲突的情况,也就导致了后面的问题:会对同一个节点执行两次list_add_rcu操作,这是一个非常严重的问题。

首先cancel_list是一个带头结点的循环双链表。list_add_rcu是一个头插法加入节点的操作,所以第一次调用后,链表结构如图:

而对我们的victim ctx再次调用list_add_rcu会变成什么样子呢?

static inline void list_add_rcu(struct list_head *new, struct list_head *head) {
    __list_add_rcu(new, head, head->next);
}

static inline void __list_add_rcu(struct list_head *new,
        struct list_head *prev, struct list_head *next)
{
    new->next = next;
    new->prev = prev;
    rcu_assign_pointer(list_next_rcu(prev), new); //可以看做 prev->next = new;
    next->prev = new;
}

要注意的是,第二次操作,我们的new == head->next,于是操作相当于:

victim->next = victim;

victim->prev = victim;

那么链表这时候就变成了这样:

可以看到victim的next指针和prev指针都指向了自己。这时候就会发生一系列问题,第一我们再也没办法通过链表来访问到victim ctx后面的节点了(这点和漏洞利用关系不大),第二我们也没办法将victim这个节点从链表上删除,尽管我们可以在kfree ctx之前对其执行list_del_rcu操作:

static inline void __list_del(struct list_head * prev, struct list_head * next)
{
    next->prev = prev;
    prev->next = next;
}

static inline void __list_del_entry(struct list_head *entry)
{
    __list_del(entry->prev, entry->next);
}

static inline void list_del_rcu(struct list_head *entry)
{
    __list_del_entry(entry); 
    //上一句可描述为:
    //entry->next->prev = entry->prev;
    //entry->prev->next = entry->next;
    entry->prev = LIST_POISON2;
}

于是list_del_rcu执行之后,链表又变成了这样子:

所以尽管之后会执行kfree将victim ctx给free掉,但是我们的cancel_list链表还保存着这段free掉的ctx的指针:head->next以及ctx->prev。所以如果后续有对cancel_list链表的一些操作,就会产生USE-AFTER-FREE的问题。

这也就是这个漏洞的成因了。

CAP_SYS_TIME下的利用

CORE TEAM的ppt里给出了这种利用方式。他们从victim ctx释放后并没有真正从cancel_list拿下来,仍然可以通过遍历cancel_list访问到victim ctx这一点做文章。

对cancel_list的遍历在函数timerfd_clock_was_set:

void timerfd_clock_was_set(void)
{
    ktime_t moffs = ktime_get_monotonic_offset();
    struct timerfd_ctx *ctx;
    unsigned long flags;
    rcu_read_lock();
    list_for_each_entry_rcu(ctx, &cancel_list, clist) {
        if (!ctx->might_cancel)
            continue;
        spin_lock_irqsave(&ctx->wqh.lock, flags);
        if (ctx->moffs.tv64 != moffs.tv64) {
            ctx->moffs.tv64 = KTIME_MAX;
            ctx->ticks++;
            wake_up_locked(&ctx->wqh);  //会走到 __wake_up_common函数
        }
        spin_unlock_irqrestore(&ctx->wqh.lock, flags);
    }
    rcu_read_unlock();
}

static void __wake_up_common(wait_queue_head_t *q, unsigned int mode,
            int nr_exclusive, int wake_flags, void *key)
{
    wait_queue_t *curr, *next;
    list_for_each_entry_safe(curr, next, &q->task_list, task_list) {
        unsigned flags = curr->flags;
        if (curr->func(curr, mode, wake_flags, key) && //curr->func
                (flags & WQ_FLAG_EXCLUSIVE) && !--nr_exclusive)
            break;
    }
}

思路就是

  • 等victim ctx被free之后,进行堆喷将victim ctx覆盖成自己精心构造的数据(这里可以用keyctl或者是sendmmsg实现)。

  • 然后调用timerfd_clock_was_set函数,这时会遍历cancel_list,由于head->next就是我们的victim ctx,所以victim ctx会被这次操作引用到。数据构造得OK的话,会调用wake_up_locked(&ctx->wqh),而ctx就是我们的victim ctx

  • 这以后ctx->wqh是自己定义的数据,所以\_\_wake\_up\_common的curr,curr->func也是我们可以决定的。

  • 所以执行到curr->func的时候,我们就控制了PC寄存器,而X0等于我们的curr

  • 劫持了pc,之后找rop/jop就能轻松实现提权操作,这里不再多说。

为什么说这是CAP_SYS_TIME权限下的利用方法呢?因为timerfd_clock_was_set函数的调用链是这样:

timerfd_clock_was_set <-- clock_was_set <-- do_settimeofday <-- do_sys_settimeofday <--SYS_setttimeofday

用户态需要调用settimeofday这个系统调用来触发。而在do_sys_settimeofday函数里有对CAP_SYS_TIME的检查:

int do_sys_settimeofday(const struct timespec *tv, const struct timezone *tz)
{
    ...
    error = security_settime(tv, tz); //权限检查
    if (error)
        return error;
    ...
    if (tv)
        return do_settimeofday(tv);
    return 0;
}


static inline int security_settime(const struct timespec *ts,
                   const struct timezone *tz)
{
    return cap_settime(ts, tz);
}

int cap_settime(const struct timespec *ts, const struct timezone *tz)
{
    if (!capable(CAP_SYS_TIME)) //检查CAP_SYS_TIME
        return -EPERM;
    return 0;
}

所以我们如果想以这种方式来利用这个漏洞,就需要进程本身有CAP_SYS_TIME的权限,这也就限制了这种方法的适用范围。于是我们想要从0权限来利用这个漏洞,就得另辟蹊径。

pipe的TOCTTOU

在介绍0权限的利用方法思路之前,我觉得得先介绍下pipe的TOCTTOU机制,因为这个是接下来利用思路的一个基础。关于这部分的内容,也可以参考shendi大牛的slide

TOCTTOU : time of check to time of use .写程序的时候通常都会在使用前,对要使用的数据进行一个检查。而这个检查的时间点,和使用的时间点之间,其实是有空隙的。如果能在这个时间空隙里,做到对已经check的数据的更改,那么就可能在use的时刻,使用到非法的数据。

pipe的readv / writev就是这样一个典型。以readv为例,readv会在do_readv_writevrw_copy_check_uvector函数里对用户态传进来的所有iovector进行合法性检查:

struct iovec {
    void *iov_base;
    size_t iov_len;
};
ssize_t rw_copy_check_uvector(int type, const struct iovec __user * uvector,
                  unsigned long nr_segs, unsigned long fast_segs,
                  struct iovec *fast_pointer,
                  struct iovec **ret_pointer)
{
    unsigned long seg;
    ssize_t ret;
    struct iovec *iov = fast_pointer;
    ...
    if (nr_segs > fast_segs) {
        iov = kmalloc(nr_segs*sizeof(struct iovec), GFP_KERNEL);  //[1]
        ...
    }
    if (copy_from_user(iov, uvector, nr_segs*sizeof(*uvector))) {
        ...
    }
    ...
    for (seg = 0; seg < nr_segs; seg++) {
        void __user *buf = iov[seg].iov_base;
        ssize_t len = (ssize_t)iov[seg].iov_len;
    ...
        if (type >= 0
            && unlikely(!access_ok(vrfy_dir(type), buf, len))) {  //[2]
            ret = -EFAULT;
            goto out;
        }
    ...
    }
}

可以看到这个检查函数做了两件事:

[1]如果iovector的个数比较多(大于8),就会kmalloc一段内存,然后将用户态传来的iovector拷贝进去。当然如果比较小,就直接把用户态传来的iovector放到栈上。

[2]对iovector进行合法性检查,确保所有的iovecor的iov_base都是用户态地址。

这里也就是pipe的time of check。

在检查通过之后,会去执行pipe_read函数,相信分析过CVE-2015-1805的朋友们都知道,pipe_read函数里对iovector的iov_base只会做是不是可写地址的检查,而不会做是不是用户态地址的检查,然后有数据就写入。pipe_read函数往iovector的iov_base里写入数据的时刻(__copy_to_user),就是pipe的time of use。

那么这个check 和 use的间隙是多长呢?这取决于我们什么时候往pipe的buffer里写入数据。因为pipe_read默认是阻塞的,如果pipe的buffer里没有数据,pipe_read就会一直被阻塞,直到我们调用writev往pipe的buffer写数据。

所以,pipe的time of check to time of use这个间隔,可以由我们自己控制。

如果在这个时间间隔有办法对iovector进行更改,那么就可能往非法地址写入数据:

那么,怎么才能在这个时间间隔,对iovector进行更改呢?

这当然要通过漏洞来实现:

1,堆溢出漏洞。前面分析知道,如果有8个以上的的iovctor,就会调用kmalloc来存储这些iovector。如果能有一个内核堆溢出漏洞,那么只要把堆布局好,就能让溢出的数据,该卸掉iovector的iov_base.

2,UAF漏洞。要知道,我们kmalloc的iovector也是有占位功能的,如果使用iovector进行堆喷,将free过的victim进行占位。然后触发UAF,如果这个use的操作,能对占位的iovector进行更改,那么也就实现了目的。

知道了pipe的TOCTTOU的基础,我们可以来重新思考下CVE-2017-10661。

思考下链表操作与UAF

链表其实是个变化过程比较多的数据结构,对某节点的删除或者添加都会影响相邻的节点。那如果一个节点出现了问题,对它的相邻节点进行一系列操作会产生什么样的变化呢?在基于CVE-2017-10661将链表破坏之后,我在这里将给出两种情景。首先贴一张已经释放了victim ctx之后,cancel_list的状态图吧:

victim ctx已经被free,但是head->next和ctx_A->prev仍然保留着这段内存的指针。那么:

情景一:添加一个新的节点ctx_B

同样还是头插法,于是下面这几段代码会执行:

ctx_B->next = head->next;

ctx_B->prev = head;

head->next->prev = ctx_B; //这里等价于 victim_mem->data2 = ctx_B

head->next = ctx_B;

可以看到,这个添加操作(list_add_rcu)会对已经free了的内存进行操作,会将victim_mem->data2赋值为ctx_B。语言总是没有图片来的直观,添加操作执行后链表的状态如图:

结合我们之前讨论的pipe TOCTTOU,如果victim_mem刚好是由我们的pipe的iovector所占位,那么这里对data2的更改,可能就会对某个iov_base进行更改:iov_base = ctx_B。那么这样就允许我们对ctx_B->list进行任意写入。

情景二:删除节点ctx_A

删除操作会影响前后两个节点,我们假设ctx_A的next节点是ctx_C,那么就有:

ctx_A->prev->next = ctx_A->next;//等价于 victim_mem->data1 = ctx_C

ctx_A->next->prev = ctx_A->prev;//等价于 ctx_C->prev = victim_mem

ctx_A->prev = LIST_POISION2;

与情景1类似,这个删除操作(list_del_rcu),也会已经free了的内存进行操作,将victim_mem->data1赋值为ctx_C:

同样的,如果victim_mem刚好是由我们的pipe的iovector占位,对data1的更改,也可能改掉iov_base:iov_base = ctx_C。这样也就能对ctx_C->list进行任意写入。

为什么要给出两种情景呢?因为我们需要考虑一个究竟是data1对应iov_base,还是data2对应iov_base。iovector的结构是这样:

struct iovec {

void *iov_base;

size_t iov_len;

};

64位下,struct iovec是16字节大小,跟上面list结构的大小一样。于是data1和data2中必有一个是iov_base,一个是iov_len。而我们需要改的是iov_base。所以上述两种情景,根据具体情况就能找到一种适用的。

问题又来了,比如说情景二,能够对ctx_C->list进行任意写入又能做什么呢?

能够对双链表某节点的next,prev指针进行完全控制,是一件很恐怖的事情。因为在删除这个节点的时候,会导致一个很严重的问题。具体怎么回事我们看代码:

static inline void list_del_rcu(struct list_head *entry)
{
    __list_del_entry(entry); 
    //上一句可描述为:
    //entry->next->prev = entry->prev;
    //entry->prev->next = entry->next;
    entry->prev = LIST_POISON2;
}

假设我们将prev指针改为target_address,next指针改为target_value。那么上述代码就等价于:

*(uint64_t)(target_value + 8) = target_address;

*(uint64_t)(target_address) = target_value;

于是这导致了一个任意地址写入任意内容的问题。当然,写入的内容没那么任意,它的值必须也要是一个可写的地址。

0权限下的利用

有了上述的讨论之后,我们利用的思路逐渐明朗。

我们的ctx是0xF8的大小,处于0x100的slab块里面,所以地址总是0地址对其。那么如果要做iovector进行占位,得到的地址也总是0地址对其,所以里面元素的iov_base也会是0地址对其。在我测试的机器(nexus6p)上,next指针偏移是0xE0,prev指针是0xE8。所以我们需要选择情景二:删除victim的next节点。那么我们的步骤应该是:‘

在创造victim ctx之前,将ctx_C加入cancel_list,然后将ctx_A加入cancel_list

赢得竞争,导致victim ctx被list_add_rcu两次

对victim ctx执行list_del_rcu操作,并将victim_ctx释放,此时cacncel_list是这样:

用iovector进行堆喷,使得其将victim mem占位:

这时pipe_read被阻塞,执行删除ctx_A的操作,会导致iov_base的更改,改成指向我们的ctx_C:

然后我们执行pipe_write,这时会导致ctx_C的next指针和prev指针被我们改写。next指针改写为target_value,prev指针改写为target_addr:

最后我们对ctx_C执行删除节点的操作,就能实现任意地址写任意内容了,当然写的内容不能那么任意。 在这之后,再进行提权是一件很容易的事情。这里简单描述两种做法:

1,target_addr设置为&ptmx_cdev->ops,target_value设置为0x30000000。这样我们在用户态0x30000000布置好函数指针, 后续操作就很容易了。修改task_prctl相关的也是一样的道理。

2,增加/修改地址转换表中的内存描述符。这个虽然说原理比较复杂,介绍起来可能比本文之前说的所有的内容还要长,但是实现起来却是很方便。像nexus6p这样的机器,kernel的第一级地址转换表的地址固定为0xFFFFFFC00007d000,在中添加一条合适的内存描述符,就能实现在用户态读取/修改kernel的text段的内容,实现kernel patch。提权也就很轻松了,而且好处是不需要找各种各样的地址,自己读取kernel的内容,自己能计算出来,可以做成通用的root。不过这种方法在三星这种有RKP保护的机器上不适用,或者说得绕过才行。

然后,这个漏洞,其实还是可以转化为任意地址写任意内容,这次的写的内容可以任意,但是做法就不一样了。需要把iov_len做得长一点,把对ctx_C的写入转化为一个堆溢出的漏洞。然后达成目标。

江湖规矩放图:

最后,对于文中出现的问题,还请各路大牛加以斧正,欢迎技术交流:huahuaisadog@gmail.com

参考文档 1, https://hitcon.org/2017/CMT/slide-files/d1_s3_r0.pdf

2, https://android.googlesource.com/kernel/msm/+/0fecf48887cf173503612936bad2c85b436a5296%5E%21/#F0

3, https://android.googlesource.com/kernel/msm/+/e7a3029ebf4175889e8bdb278fd9cf02a211118c/fs/read_write.c

4, https://github.com/retme7/My-Slides/blob/master/The-Art-of-Exploiting-Unconventional-Use-after-free-Bugs-in-Android-Kernel.pdf



评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值