Pwnable, 7 solved, 647 Pts.
qemu+macOS ventura 13.6.1
upload your exploit binary and the server will run it for you, you can get the output of your exploit.
the flag is at /flag
Just pwn my first IOKit Driver!
nc baby-kit-driver.ctf.0ops.sjtu.cn 20001
macOS 13.6.1 kernel module pwn. Solve quite an expensive PoW, send a binary, and get its output back.
- Install OSX-KVM using the offline method.
- Boot with
./OpenCore-Boot.sh
.- Add
-s
toOpenCore-Boot.sh
for enabling the qemu gdbstub for kernel debugging. - If dropped to the UEFI shell, do:
fs0: cd efi/boot bootx64.efi
- Add
- Use docker-osxcross for compiling the exploit.
- Build gdb with
--target=x86_64-apple-darwin13
for kernel debugging. - Enable SSH.
- Copy and load the vulnerable kernel module with
kextload
. The first time an approval via GUI and a reboot are needed. - Copy the kernel from
/System/Library/Kernels/kernel
to the host, load it into the favorite decompiler. The kernel sources can be found here. - Check
/Library/Logs/DiagnosticReports/Kernel-*.panic
for panic messages. - After gdb is attached, the VM is normally stopped in
machine_idle()
. This makes it possible to infer the KASLR base for debugging purposes. - Breakpoints work reliably. Single-stepping is only possible with
stepi
. Other commands, likenexti
, more often than not, cause gdb to lose control. When usingsi
, disable interrupts by clearing IF usingset $eflags=$eflags&~0x200
, and set it back beforecontinue
. - The kernel module allows reading and writing a buffer, which is stored in the
following format:
union message { struct { void (*output)(void *dest, const void *src); char buf[0x100]; } v1; struct v2 { size_t size; void (*output)(void *dest, const void *src, size_t size); char buf[0x200]; }; char raw[0x300]; };
- Leak the kernel stack contents by reading -1 bytes from a v2 buffer.
- Obtain the RIP + RSI + *RSI control by racing reading vs the v2 -> v1 transition. Stabilize the race by storing the *RSI payload in advance.
- Pivot stack to RSI using JOP.
- Disable SMEP/SMAP and jump to the userspace shellcode using ROP.
- Get root by calling
proc_update_label()
andkauth_cred_setresuid()
. The creds are stored in read-only pages, so a direct write is not an option. - Restore the original RSP by scanning for leaked data starting at
current_cpu_datap()->cpu_kernel_stack
. Tweak a few other registers and values in memory, and return to the kernel module. - Try reading the flag, retry the race on failure.
flag{7ac21f7848a39f0aea63fa29d304226a}
As the description states, the challenge runs in QEMU, so it should be possible
to recreate the remote setup on the local Linux host. OSX-KVM turned up in
search and was quite usable. The installation steps from the front page worked
like a charm, but resulted in macOS 13.5.2, which was not what the challenge
description said. Using softwareupdate -i 'macOS Ventura 13.6.1-22G313'
failed with MSU_ERR_STAGE_SPLAT_FAILED
with no further explanations. The
attempt to do a manual upgrade by downloading the image with ./macrecovery.py download -b Mac-B4831CEBD52A0C4C -m 00000000000000000
bricked the system. The offline installer worked, though.
Booting the system with ./OpenCore-Boot.sh
sometimes dropped me to the scary
UEFI shell instead of the nice graphical menu. To get to the menu, I needed to
run fs0:/efi/boot/bootx64.efi
manually.
It should be possible to build the exploit natively, but I preferred to use the
host as much as possible, so I used the crazymax/osxcross:13.1-ubuntu
docker
image with the cross-compilers and the SDK. Somewhat counter-intuitively, the
image comes only with the cross-toolchain and nothing else. So I had to create
a Dockerfile
that added the base system.
Enabling SSH in the guest helped create automation for the other steps, such as
loading the kernel module, uploading and running the exploit, and reading crash
logs, which I put into the Makefile. The port forwarding was
already set up in OpenCore-Boot.sh
, so I only had to upload my public key
and put the following into ~/.ssh/config
on the host:
Host OSX-KVM
Hostname localhost
Port 2222
IdentityFile ~/.ssh/id_ecdsa
IdentitiesOnly yes
The kernel is stored in /System/Library/Kernels/kernel
inside the guest and
can be easily copied to the host with scp
. There are symbols inside it, so I
hoped I could use it with GDB. Unfortunately, my distro gdb-multiarch
didn't
support Mach-O binaries, Apple GDB
could not be built on Linux, and building GDB from source for the
x86_64-apple-darwin
target did not produce a GDB binary. After staring at the
GDB's configure
script for a while, I realized that I had to target
x86_64-apple-darwin13
! The resulting GDB accepted the kernel, but did not
load any symbols anyway, so I had to proceed with debugging the raw asm.
Knowing the randomized kernel base is important for debugging. It can be easily
calculated using the observation that attaching the debugger almost always
interrupts the VM at the *fc6: cli
instruction, which can be then found in
the kernel binary like this:
$ llvm-objdump -d kernel|grep fc6.*cli
ffffff80004dafc6: fa cli
The randomized kernel module base is also important. The kextstat -b keen.BabyKitDriver
command shows the non-randomized base, which then needs
to be adjusted by the value of vm_kernel_slide
. Yes, kernel and modules use
the same slide value.
With that, it's possible to put breakpoints on the kernel and on the module.
As the description states, the module is written using the IOKit framework. That's the first time I've seen it; the old Apple docs read like marketing materials, and the new Apple docs are quite concise, but in general it's not that different from Windows or Linux driver frameworks.
The driver provides the following two externalMethods
, which are quite
similar to ioctls:
kern_return_t BabyKitDriverUserClient::baby_read(void *ref, IOExternalMethodArguments *args) {
BabyKitDriver *drv;
kern_return_t ret;
char buf_v1[256];
char buf_v2[512];
size_t size;
bool is_v2;
drv = (BabyKitDriver *)getProvider();
is_v2 = drv->is_v2;
IOLog("BabyKitDriverUserClient::baby_read\n");
IOLog("version:%lld\n", is_v2);
if (!drv->message)
return 0;
if (is_v2) {
size = args->scalarInput[1];
if ((int64_t)size > (int64_t)drv->message->v2.size)
size = drv->message->v2.size;
memset(buf_v2, 0, sizeof(buf_v2));
drv->message->v2.output(buf_v2, drv->message->v2.buf, drv->message->v2.size);
ret = copyout(buf_v2, args->scalarInput[0], (size - 1) & 0xFFF);
} else {
memset(buf_v1, 0, sizeof(buf_v1));
drv->message->v1.output(buf_v1, &drv->message->v1.buf);
ret = copyout(buf_v1, args->scalarInput[0], 0x100);
}
return ret;
}
kern_return_t BabyKitDriverUserClient::baby_leaveMessage(void *ref, IOExternalMethodArguments *args) {
BabyKitDriver *drv;
kern_return_t ret;
size_t size;
bool is_v2;
drv = (BabyKitDriver *)getProvider();
is_v2 = args->scalarInput[0];
IOLog("BabyKitDriverUserClient::baby_leaveMessage\n");
if (!drv->message) {
drv->message = (struct message *)IOMalloc(sizeof(struct message));
if (is_v2)
drv->message->v2.output = output2;
else
drv->message->v1.output = output1;
}
if (is_v2) {
drv->message->v2.output = output2;
size = args->scalarInput[2];
if ((int64_t)size > 0x200)
size = 0x200;
drv->message->v2.size = size;
ret = copyin(args->scalarInput[1], &drv->message->v2.buf, size);
} else {
drv->message->v1.output = output1;
ret = copyin(args->scalarInput[1], &drv->message->v1.buf, 0x100);
}
drv->is_v2 = is_v2;
return ret;
}
One vulnerability stands out, since there is no good reason for doing
(size - 1) & 0xFFF
. It draws attention to the (int64_t)size > (int64_t)drv->message->v2.size
comparison, which also has no good reason to be
signed. And indeed, storing an empty v2 message and then reading -1 bytes leaks
0xFFF bytes of stack.
A similar problem exists for writing v2 messages.
Finally, the code does not use synchronization, so there are various data races.
In the baby_read(-1)
leak, one can find the address for returning to the
is_io_connect_method()
at a fixed offset, which makes it possible to compute
the kernel base.
It's tempting to do baby_leaveMessage(v2, -1)
, making copyout()
write as
many bytes as we want to the heap - arranging it to stop by putting the
userspace buffer right before a PROT_NONE
page. Unfortunately, copyout()
panics on large sizes, downgrading this bug to a mere DoS.
Race, on the other hand, is quite viable. The goal is to overwrite v2.output
with a controlled value by racing reads with writes that switch from v1 to v2:
writer reader
----------------------------------- ----------------------------------------------
if (is_v2) {
...
memset(buf_v2, 0, sizeof(buf_v2));
ret = copyin(args->scalarInput[1],
&drv->message->v1.buf,
0x100uLL);
drv->message->v2.output(buf_v2,
drv->message->v2.buf,
drv->message->v2.size);
Missing the race is not dangerous in most situations, except when the writer
thread updates drv->message->v1.output
and stalls. In this case the reader
thread will use a very large size, causing a panic. But the probability of this
happening doesn't seem to be significant enough for a CTF challenge.
As a result, we can jump anywhere, with RDI (buf_v2
) pointing to stack, and
RSI (drv->message->v2.buf
) pointing to up to 0x200 bytes of controlled data.
Since we control the data at RSI, we can perform JOP. It's hard to do many useful things with JOP alone, so we need to convert it to ROP like this:
gadget_1: add rsi, 0x58 ; call rax
gadget_2: mov rsp, rsi ; call rdi
Unfortunately there are not enough gadgets for setting up RAX and RDI that do
not conflict with each other. So we need to go for an even simpler chain that
loads a constant into RSI, and for that we need to leak the message address,
which is as easy as using a single mov [rdi+8], rsi; ret
gadget.
With that, we can build a JOP chain that pivots to RSI + 0x70.
It should be possible to achieve privilege escalation with ROP alone, but I deemed it to be too hard to debug, therefore I wanted to go for jumping to userspace shellcode that would do all the heavy lifting. The chain for that is quite simple: we only need to disable SMEP and SMAP in CR4, for which the good gadgets exist.
I've decided to write as much shellcode in C as possible. I needed assembly only for switching to the statically allocated stack in order to avoid damaging the kernel heap, and then for switching back to the kernel stack.
While kernel functions cannot be called from shellcode directly, since it's not linked with the kernel, calculating their addresses from the known kernel base and casting them to strongly typed function pointers is easy. The kernel base is stored by the userspace portion of the exploit in a global variable, and this variable is available from the shellcode.
The kernel allocates credentials in read-only memory, so they cannot be updated
directly. Instead, there are a couple kernel functions that need to be used:
proc_update_label()
and kauth_cred_setresuid()
. The usage example can be found in the setuid()
implementation.
One difficulty is that proc_update_label()
takes a closure as an argument.
For the purpose of making one in the exploit, a closure is just an array of
3 pointers the last one being a function pointer.
With that, we can safely set all kinds of uids to 0.
Now we need to return to userspace. There is return_to_user()
for that,
however, using it directly results in all sorts of nasty consequences.
For instance, IOKit takes some lock, which is then not released. There is a
sanity check that causes a panic if current_thread()->rwlock_count
is not 0
when returning to userspace. It can be fooled by manually decrementing that
value, but of course this causes a deadlock sooner or later.
Therefore, we better return back to the kernel module. For that, the exploit
needs to switch back to the kernel stack. We've lost the original value when
pivoting, so we need to calculate it. The kernel stack base is stored in
current_cpu_datap()->cpu_kernel_stack
. We can then scan it for known pointers
that have to be there, such as the return address to is_io_connect_method()
,
and subtract a fixed offset from that.
We also need to restore a handful of values that were damaged by the exploit:
- RBP, which is at the fixed offset from RSP.
- The saved length, which was set to a very large value by the race, and needs
to be changed to something more reasonable for
copyout()
to not panic. - R14, which is used by
is_io_connect_method()
and has to be 0.
With that, we happily return to userspace as root, and can read the flag and/or spawn the shell.
That was quite an interesting introduction to the macOS kernel pwn. Definitely not the easiest possible, but at least no knowledge of heap feng-shui was required.
OSX-KVM + GDB provides a reasonable debugging environment, which could nevertheless be improved, first and foremost, by adding or fixing the GDB support for parsing Mach-O symbols.
The amount of learning materials on the web is limited, comparing to the Linux kernel pwn, but still enough for getting started. The availability of the XNU kernel source code is a godsend. I found the following links especially useful:
- https://www.usenix.org/system/files/conference/woot17/woot17-paper-xu.pdf
- https://github.com/A2nkF/macOS-Kernel-Exploit
The macOS kernel mitigations and sanity checks are somewhat different from those in the Linux kernel, but the challenge author was merciful enough to give us the tools to circumvent them with reasonable effort.
Overall, this was quite hard as a first macOS kernel pwn experience - it took me about 20 hours to solve it (including install and reinstall times) - but I'm still glad I looked into it.