Attribution is hard — at least for Dock: A Safari sandbox escape & LPE
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 CFRunArrayItem
s internally:
- Starting at index 0, length 11, “bold” attribute set
- Starting at index 11, length 4, no attributes
- 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:
- The fake object to trigger the indirect call and enter a small JOP stub to pivot the stack.
- 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:
- Call
AuthorizationCreate
andAuthorizationMakeExternalForm
to produce a token with the uninstalld entitlement. - Spawn a binary called
fakedock
which registers a mach service. - Lookup the
fakedock
service. - Send the receiving end of the
com.apple.dock.server
service, as well as the authorization token tofakedock
. - 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 :)