This is a quick write-up of CVE-2019-8603, a heap out-of-bounds read in Dock and the com.apple.uninstalld service, which could lead to a controlled CFRelease call and escape the WebContent sandbox on macOS, ending up as root.

As an added bonus, CVE-2019-8606 gave us a way from root to kernel code execution via a race condition in kextutil. Together with an RCE bug in WebKit provided by qwertyoruiopz and bkth, we used these to fully own Safari at Pwn2Own this year.

Both bugs were fixed in macOS 10.14.5, so let’s dig in.

The bug

I found this bug when developing a coverage-guided fuzzer and testing it on the AXUnserializeCFType function, a simple parser featured in last year’s Pwn2Own that I didn’t really expect to contain any bugs. Turns out I was wrong. For some reason, this function is yet another implemention of CoreFoundation object serialization. It is part of the HIServices framework, and the code is contained in the corresponding dylib.

One of the types that can be deserialized by this function is CFAttributedString, a string where each character is associated with a CFDictionary holding arbitrary attributes to describe the given character. Those attributes can be colors, fonts, or whatever else the user cares about. Which in our case is code execution.

To represent this information efficiently, instead of allocating a dictionary for each individual character, the data structure uses run-length compression:

// from CFAttributedString.c
struct __CFAttributedString {
    CFRuntimeBase base;
    CFStringRef string;
    CFRunArrayRef attributeArray;  // <- CFRunArray of CFDictinaryRef's
};

// from CFRunArray.c
typedef struct {
    CFIndex length;
    CFTypeRef obj;
} CFRunArrayItem;

typedef struct _CFRunArrayGuts {	/* Variable sized block. */
    CFIndex numRefs;                        /* For "copy on write" behavior */
    CFIndex length;                         /* Total count of values stored by the CFRunArrayItems in list */
    CFIndex numBlocks, maxBlocks;           /* These describe the number of CFRunArrayItems in list */
    CFIndex cachedBlock, cachedLocation;    /* Cache from last lookup */
    CFRunArrayItem list[0]; /* GCC */
} CFRunArrayGuts;

/* Definition of the CF struct for CFRunArray */
struct __CFRunArray {
    CFRuntimeBase base;
    CFRunArrayGuts *guts;
};

For example, the string “attribution is hard” could be represented as 3 CFRunArrayItems internally:

  1. Starting at index 0, length 11, “bold” attribute set
  2. Starting at index 11, length 4, no attributes
  3. Starting at index 15, length 4, “italic” attribute

There are clearly some invariants that have to be maintained, such as that the union of all runs spans the entire string (and not more!) and that none of the runs overlap.

The deserialization function cfAttributedStringUnserialize has two paths. The first one is simple: It just reads a string and calls CFAttributedStringCreate with NULL for the attributes dict. This means that the second one must be where the juice is, and indeed: It parses a string, along with a list of ranges and their associated dictionaries and then calls the internal function _CFAttributedStringCreateWithRuns:

CFAttributedStringRef _CFAttributedStringCreateWithRuns(
        CFAllocatorRef alloc,
        CFStringRef str,
        const CFDictionaryRef *attrDictionaries,
        const CFRange *runRanges,
        CFIndex numRuns) { ...

The parser correctly ensures that the number of runs and dictionaries matches up, but it performs no validation whatsoever on the actual range information. And neither does _CFAttributedStringCreateWithRuns:

    for (cnt = 0; cnt < numRuns; cnt++) {
	CFMutableDictionaryRef attrs = __CFAttributedStringCreateAttributesDictionary(alloc, attrDictionaries[cnt]);
	__CFAssertRangeIsWithinLength(len, runRanges[cnt].location, runRanges[cnt].length); // <- ouch
	CFRunArrayReplace(newAttrStr->attributeArray, runRanges[cnt], attrs, runRanges[cnt].length);
	CFRelease(attrs);
    }

The assert is not present in relase builds. Hence, CFRunArrayReplace is called with a fully controlled range and newLength values:

void CFRunArrayReplace(CFRunArrayRef array, CFRange range, CFTypeRef newObject, CFIndex newLength) {
    CFRunArrayGuts *guts = array->guts;
    CFRange blockRange;
    CFIndex block, toBeDeleted, firstEmptyBlock, lastEmptyBlock;

    // [[ 1 ]]

    // ??? if (range.location + range.length > guts->length) BoundsError;
    if (range.length == 0) return;

    if (newLength == 0) newObject = NULL;

    // [...]

    /* This call also sets the cache to point to this block */

    // [[ 2 ]]
    block = blockForLocation(guts, range.location, &blockRange);
    guts->length -= range.length;

    /* Figure out how much to delete from this block */
    toBeDeleted = blockRange.length - (range.location - blockRange.location);
    if (toBeDeleted > range.length) toBeDeleted = range.length;

    /* Delete that count */

    // [[ 3 ]]
    if ((guts->list[block].length -= toBeDeleted) == 0) FREE(guts->list[block].obj);
    ...

At [[ 1 ]], it is obvious that whoever wrote this code was dubious about invalid parameters being passed in, but didn’t quite get around to changing the function signature to be able to return an error.

At [[ 2 ]], things start to go haywire: blockForLocation can return an out-of-bounds index if range.location is too large. The FREE at [[ 3 ]] puts the final nail into the coffin by calling CFRelease on a pointer fetched from the out-of-bounds index. This can in turn lead to a call to objc_release, which consults a vtable to find the Objective-C function for the release selector:

id objc_msgSend(id obj, SEL sel, ...)
{
  __objc2_class *cls; // r10
  __int128 *v3; // r11
  __int64 i; // r11

  if ( !obj )
    return 0LL;
  if ( obj & 1 )
  {
    // [...]
  }
  else
  {
    cls = (*obj & 0x7FFFFFFFFFF8LL);
  }
  v3 = &cls->vtab[cls->mask & sel];
  if ( sel == *v3 )
    return (*(v3 + 1))();   // <- OUCH!!

Note that if we fully control the obj value passed in, we can easily enter the else case of the first check and reach the indirect call, provided that we can place a fake object at a known location, and we know the address of the release selector. Fortunately, in a sandbox escape scenario the latter is a non-issue: All libraries are mapped at the same address system-wide, which includes the selector.

Heap grooming

In order to turn this bug into a controlled call to CFRelease, we have to place certain values right after the CFRunArray which is accessed out-of-bounds. We achieve this by using allocation and deallocation primitives in the parser itself. Specifically, the parser allows us to create a dictionary and repeatedly set entries that in turn get parsed from the input stream.

By adding a new entry to the dictionary, we can allocate an object. By overwriting that entry later, the object gets freed. This primitive is enough to create a very predictable sequence of holes, one of which will be occupied by the CFRunArray and followed by a CFString object containing data that we control.

The exact layout is a bit subtle, but getting it right was just a matter of some trial and error and figuring out good objects to use for spraying. We end up with a single input to the parser that does all the grooming, and then triggers the bug reliably.

Dock

We used this bug twice: First to exploit the com.apple.dock.server service hosted by Dock and reachable from the WebContent sandbox. Its mach-based protocol is created via MIG, the Mach Interface Generator.

We are attacking the handler for message ID 96508, which I didn’t really bother to reverse. The important part is that it expects some data to parse via AXUnserializeCFType as an out-of-line memory descriptor which we will happily provide. MIG will also happily map gigabytes of data provided by us into the address space of the receiver, which is a well-known heap spraying technique that allows us to place arbitrary data at a known location.

We make sure to repeat the same data on every single page of our large spray (~800 MiB). It consists of:

  1. The fake object to trigger the indirect call and enter a small JOP stub to pivot the stack.
  2. A ROP chain which does all the heavy lifting.

We know where one of these pages will end up due to a lack of entropy. Note the distinct lack of any kind of information leak in this exploit.

The case of uninstalld

We could stop here and pop a calculator with normal user privileges. But we really want kernel code execution. Somewhere towards the end of my analysis I typed AXUnserializeCFType into Google and noticed Project Zero issue 1219, which was about a very shallow out-of-bounds bug in the same function from 2017. In hindsight the following quote by Ian Beer aged really well:

From a cursory inspection it’s clear that these methods don’t expect to parse untrusted data.

The first method, cfStringUnserialize, trusts the length field in the serialized representation and uses that to byte-swap the string without any bounds checking leading to memory corruption.

I would guess that all the other unserialization methods should also be closely examined.

[…]

Amusingly this opensource facebook code on github contains a workaround for a memory safety issue in cfAttributedStringUnserialize: https://github.com/facebook/WebDriverAgent/pull/99/files

Hilarious really. To this day I do not know if the bug addressed by Facebook via in-memory patching actually had the same root cause. But anyways, Ian Beer also mentioned back then that the com.apple.uninstalld service, running as root, in turn talks to Dock and calls AXUnserializeCFType on data provided by Dock. So, probably we can impersonate Dock and provide uninstalld with our payload, simply repeating the same exploit again.

There were a couple of issues that I came across when trying this:

  • To get uninstalld to do anything, we have to provide it with an authorization token that has a certain entitlement embedded in the Dock binary.
  • I did not actually manage to map my own code inside of Dock, I assume due to the addition of certain code signing mechanisms from some time in 2018.
  • While Dock is running, we can’t register the com.apple.dock.server endpoint since Dock is occupying it.

I don’t recall what was the exact reason that I couldn’t just kill Dock and register this endpoint from a different process, after creating and dumping the authorization token. There was a probably reason, or maybe I was just eager to learn all about how Mach services work. In any case, I ended up doing all the following from the ROP chain running inside Dock:

  1. Call AuthorizationCreate and AuthorizationMakeExternalForm to produce a token with the uninstalld entitlement.
  2. Spawn a binary called fakedock which registers a mach service.
  3. Lookup the fakedock service.
  4. Send the receiving end of the com.apple.dock.server service, as well as the authorization token to fakedock.
  5. Sleep forever.

fakedock would wait for the receive right and the token, and would from then on impersonate the com.apple.dock.server service. It would then talk to uninstalld to cause it to uninstall an application, which would in turn trigger it to “connect back” and receive our exploit payload via a certain sequence of MIG calls that we need to process and respond to in the appropriate manner. The ROP chain for uninstalld is just a call to system with our final root payload.

From root to kernel: TOCTOU in kextutil

OK so this bug won’t take long to describe. kextutil lets you load a kernel extension as root, but it performs certain checks such as for the code signature and user approval. We obviously need to bypass those checks to load our own unsigned code without user interaction. Our go-to approach for bypassing checks on files are race conditions. Often with symlinks. This worked here also.

After doing all the checking, which was described in detail recently by logic bug specialist CodeColorist, kextutil -load will eventually call into IOKit!OSKextLoadWithOptions to send a load request to the kernel.

However, if the provided kext path is a symlink, we can just point it to somewhere else in between those operations.

A couple of conditions need to be fulfilled for this process to go exactly as planned, one of which is the correct timing for swapping the destination of the symlink. To achieve this I ran kextutil -verbose 6 -load /path/to/kext which prints a lot of debug information, and provided an almost full POSIX pipe as STDOUT. That way it would overflow the pipe at a specific point during the execution and suspend until I replaced the symlinks and cleared the pipe. The end result was the loading of an unsigned kext, which we just used to disable SIP as a simple demo. This is what the end result looked like.

After this exploit was finished, my friend and CTF team mate Linus Henze pointed me to an even easier way to trigger the race condition reliably: kextutil actually has a flag -i which would prompt the user after the security checks, but before loading the kext. It doesn’t quite ask “would you like to change your symlink now and resume loading something else?” but that would have been more to the point :)