Skip to content

Latest commit

 

History

History

BabyKitDriver

BabyKitDriver

Description

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

attachment

Summary

macOS 13.6.1 kernel module pwn. Solve quite an expensive PoW, send a binary, and get its output back.

TL;DR

  • Install OSX-KVM using the offline method.
  • Boot with ./OpenCore-Boot.sh.
    • Add -s to OpenCore-Boot.sh for enabling the qemu gdbstub for kernel debugging.
    • If dropped to the UEFI shell, do:
      fs0:
      cd efi/boot
      bootx64.efi
      
  • 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, like nexti, more often than not, cause gdb to lose control. When using si, disable interrupts by clearing IF using set $eflags=$eflags&~0x200, and set it back before continue.
  • 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() and kauth_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}

Setup

OS

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.

Building

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.

Testing

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

Debugging

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.

Analysis

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.

Exploitation

Breaking KASLR

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.

Controlling RIP

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.

Pivoting stack

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.

Executing shellcode

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.

Escalating privileges

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.

Resuming the kernel module

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.

Conclusion

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:

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.