Posted by Samuel Groß, Project Zero
This is the third and last post in a series about a remote, interactionless iPhone exploit over iMessage. The first blog post introduced the exploited vulnerability, and the second blog post described a way to perform a heapspray, leaking the shared cache base address.
At this point, ASLR has been broken as the shared cache’s base address is known and controlled data can be placed at a known address with the heap spray. What remains is to exploit the vulnerability one more time to gain code execution.
After a short introduction to some relevant ObjC internals, an exploit for devices without pointer authentication (PAC) will be outlined. It involves creating code pointers, so it no longer works with pointer authentication enabled. Afterwards, a different exploit that works against PAC and non-PAC devices will be presented. Finally, a technique to chain the presented attack with a kernel exploit, which involves implementing the kernel exploit in JavaScript, will be shown.
Objective-C for the Remote Code Executer
ObjC is a superset of C which has object oriented programming features added. ObjC adds, amongst others, the concepts of objects, classes with methods and properties, and inheritance to the language. Most objects in ObjC ultimately inherit from NSObject, the root object class. A simple snippet of ObjC code is shown next. It creates and initializes an instance of a Class, then calls a method on the instance.
Bob* bob = [[Bob alloc] init];
[bob doSomething];
ObjC relies heavily on reference counting for managing the lifetime of objects. As such, every object has a refcount, either inline as part of the ISA word (see below) or out of line in a global table. Code that operates on an object then has to perform objc_retain and objc_release calls on the object. These must either be placed manually by the programmer or are inserted automatically by the compiler if automatic reference counting (ARC) is enabled.
Internally, an object in ObjC is a chunk of memory (usually allocated through calloc) that always starts with an “ISA” value followed by instance variables/properties. The ISA value is a pointer-sized value containing the following information:
A pointer to the Class of which this object is an instance
An inline reference counter
A few additional flag bits
An ObjC Class describes its instances, but it is also an ObjC object itself. As such, Classes in ObjC are not only a compile time concept but also exist at runtime, enabling introspection and reflection. A Class contains the following bits of information:
The ISA word (as it is an Objc Object itself), pointing to a dedicated metaclass
A pointer to the superclass if any
A method cache to speed up method implementation lookups
The method table
The list of instance variables (name and offset) that each instance has
where the Selector is a unique c-string containing the name of the method and the Implementation is a pointer to the native function implementing the method.
With that, here is roughly what happens when the following piece of ObjC code from above is compiled and executed: first, at compile time, the three ObjC method calls are translated to the following pseudo-C code (assuming ARC is enabled):
Bob* bob = objc_msgSend(BobClass, "alloc");
// Refcount is already 1 at this point, so no need for a objc_retain()
bob = objc_msgSend(bob, "init");
objc_msgSend(bob, "doSomething");
...
objc_release(bob);
Dereference the object pointer to retrieve the ISA value and extract the Class pointer from it in turn
Perform a lookup for the requested method implementation in the Class’ method cache. This is done by essentially hashing the selector’s address to obtain a table index, then comparing the entry’s selector with the requested one
If that fails, continue to look up the method in the method table
If a method Implementation was found, it is (tail-) called and passed any additional arguments that were passed to objc_msgSend. Otherwise an exception is raised.
Note that since selectors are guaranteed to be unique by the ObjC runtime, comparison of two selectors is possible simply by comparing the pointer values.
objc_release on the other hand will decrement the object’s refcount (assuming no custom retain/release has been implemented for the object by overwriting the corresponding methods).
If decrementing the object’s refcount sets it to zero, it invokes the dealloc method (the destructor of the object) and then frees the object’s memory chunk.
Native Code Execution on non-PAC Devices
Given the above insights into the ObjC runtime internals and the capabilities gained from the second part of this series, it is now possible to implement a simple exploit to gain native code execution on devices without pointer authentication (PAC), namely the iPhone X and earlier.
Shown next is the code fragment from [NSSharedKeySet indexForKey:] which reads the candidate value from an address of the attacker’s choosing (as _keys is nullptr and index is controlled) and processes it.
id candidate = self->_keys[index];
if (candidate != nil) {
if ([key isEqual:candidate]) {
return prevLength + index;
}
}
If the key is a NSString, then one of the first things that [NSString isEqual:] will do with the given argument is calling [arg isNSString__] on it (see the implementation of [NSString isEqual:] in Foundation.framework). As such, native code execution can be achieved in the following way:
Perform the heap spray as described in part 2. The heap spray should contain a fake object pointing to a fake class containing a fake method cache with an entry for the method ‘isNSString__’ with a controlled IMP pointer. The heap spray should also contain a pointer to the fake object
Trigger the vulnerability to read that pointer and have it be passed to [key isEqual:]. This will in turn invoke ‘isNSString__’ on the faked object, thus pointing the instruction pointer to an address of the attacker’s choosing
Perform a stack pivot and implement a payload in ROP
At this point, an attacker has succeeded in exploiting a device that does not support PAC (iPhone X and earlier). However, with PAC enabled, the above attack is no longer possible as is explained in the next section.
The Impact of PAC in Userspace
Brandon Azad has previously done some great research detailing how PAC works in the kernel. PAC in userspace is not too different. Next is a high level summary of how PAC works in userspace. For more information, the reader is referred to llvm’s documentation of PAC.
Every code pointer (function pointer, ObjC method pointer, return address, …) is signed with a secret key as well as an optional 64-bit “context” value. The resulting signature is truncated to 24 bits which are then stored in the upper part of a pointer, which are normally unused. Before jumping to a code pointer, the pointer’s signature is verified by recomputing it with the same key and context value and comparing the result to the bits stored in the pointer. If they match, the signature bits are cleared, leaving a valid pointer to be dereferenced. If they don’t match, the pointer becomes clobbered, thus leading to an access violation when it is subsequently dereferenced.
The main purpose of the context value is to prevent pointer swapping attacks in which a signed pointer is copied from one location in the process to another. As an example, ObjC method Implementation pointers stored in the method cache use the address of the cache entry as context while return addresses on the stack use the address of themselves in the stackframe as context. As such, the two cannot be swapped.
To summarize, with PAC enabled and without a bug in the implementation of PAC itself or a signing gadget (a piece of code that can legitimately be invoked and which will add a PAC signature to an arbitrary pointer and return the result to the attacker), it becomes impossible to fake code pointers like the previously described exploit does. The bypass described next assumes such a “flawless” implementation.
One attack that is still viable despite PAC is to create fake instances of ObjC classes and call existing (legitimate) methods on them. This is possible because PAC does not currently protect the ISA value which identifies a class instance (this is likely due to the fact that the ISA value does not have enough spare bits for a signature). As such, knowing the base address of the dyld shared cache (which contains all the ObjC Class objects), it becomes possible to fake instances of any class in any library currently loaded in the process.
However, on the code path that is currently taken ([NSSharedKeySet indexForKey:], only a small number of selectors are ever sent to the controlled object, for example ‘__isNSString’. This is likely not very useful as none of the available implementations perform anything particularly interesting. Another code path yielding a different primitive thus has to be found.
A Useful Exploit Primitive
As described above, ObjC relies heavily on reference counting, and one of the most common operations performed on an object (besides objc_msgSend) is objc_release. Due to that, many memory corruption vulnerabilities can be turned into an objc_release call on a controlled object. This is also the case here, although some more effort is required as, by itself, the code used during [NSSharedKeySet indexForKey:] will not call objc_release on the attacker controlled object. As such, a new object graph is required once again to reach a different piece of code, in this case in [NSSharedKeyDictionary setObject:ForKey:], called during [NSSharedKeyDictionary initWithCoder:] as shown next.
-[NSSharedKeyDictionary initWithCoder:coder] {
self->_keyMap = [coder decodeObjectOfClass:[NSSharedKeySet class]
forKey:@”Ns.skkeyset”];
self->_values = calloc([self->_keyMap count], 8);
NSArray* keys = [coder decodeObjectOfClasses:[...]
forKey:@”NS.keys”]];
NSArray* values = [coder decodeObjectOfClasses:[...]
forKey:@”NS.values”]];
if ([keys count] != [values count]) { // return error }
for (int i = 0; i < [keys count]; i++) {
[self setObject:[values objectAtIndex:i]
forKey:[keys objectAtIndex:i]];
}
}
-[NSSharedKeyDictionary setObject:obj forKey:key] {
uint32_t index = [self->_keyMap indexForKey:key];
if (index != -1) {
id oldval = self->_values[index];
objc_retain(obj);
self->_values[index] = obj;
objc_release(oldval);
}
}
Note that in [NSSharedKeyDictionary initWithCoder:], there is no check for nullptr after the call to calloc() in line 4. This is likely not a problem under normal circumstances as another allocation of the same size would have to succeed first (for the SharedKeySet) and its content (multiple gigabytes of data) would have to be sent over iMessage. However, it does become a problem in combination with the current vulnerability, as now a _numKey value of one of the SharedKeySets is not yet validated at the time this code runs, thus allowing an attacker to cause calloc() to fail without requiring other huge allocations to succeed first. Afterwards, [NSSharedKeyDictionary setObject:forKey:] can be tricked to again read an ObjC id from an attacker controlled address (line 4) and subsequently call objc_release on it (line 7). The object graph shown next triggers this case and performs an objc_release call on an attacker controlled value. The return value of calloc() has since been checked against nullptr, in which case unarchiving is aborted.
When the shown object graph is unarchived, SharedKeyDictionary2 will attempt to store the value for “k2” in itself and will thus call [SharedKeySet2 indexForKey:"k2"]. This in turn will recurse to SharedKeySet1, which will also not find the key (as the index fetched from its _rankTable is larger than _numKey) and recurse further. Finally, SharedKeySet3 will be able to lookup “k2” and return the currently accumulated offset, which is (1 + 0x41414140/8). SharedKeyDictionary2 will then access 0x41414148, call objc_release on the value read there and finally write the NSString “foobar” to that address. This now provides the arbitrary objc_release primitive required for further exploitation.
With that, it is now possible to get any legitimate ‘dealloc’ or ‘.cxx_desctruct’ (which are additional destructors automatically generated by the compiler) method called. There are about 50,000 such functions in the shared cache. Quite a lot of gadgets! To find interesting gadgets, the following IDAPython snippet can be used on a loaded dyld_shared_cache image. It enumerates the available destructors and writes their name + decompiled code to disk:
for funcea in Functions():
funcName = GetFunctionName(funcea)
if ‘dealloc]’ in funcName or ‘.cxx_desctruct]’ in funcName:
func = get_func(funcea)
outfile.write(str(decompile(func)) + ‘\n’)
One approach to finding interesting dealloc “gadgets” is to grep the decompiled code for specific selectors that are being sent to other objects, further increasing the amount of code that can be executed. One dealloc implementation that is particularly interesting is printed below:
-[MPMediaPickerController dealloc](MPMediaPickerController *self, SEL)
{
v3 = objc_msgSend(self->someField, "invoke");
objc_unsafeClaimAutoreleasedReturnValue(v3);
objc_msgSend(self->someOtherField, "setMediaPickerController:", 0);
objc_msgSendSuper2(self, "dealloc");
}
This sends the “invoke” selector to an object read from the faked self object. “invoke” is for example implemented by NSInvocation objects, which are essentially bound functions: a triplet of (target object, selector, arguments) which, when sent the “invoke” selector, will call the stored selector on the target object with the stored arguments. It is thus possible to call any ObjC method on a completely controlled object with arbitrary arguments. Quite a powerful primitive. In fact, already powerful enough to pop calc. The call:
[UIApplication launchApplicationWithIdentifier:@"com.apple.calculator" suspended:NO]
...will do the job just fine :)
The final heap spray layout then looks roughly as depicted below. The heap spray must now be a bit bigger because the previously used address 0x110000000, which will now also be used as the size argument to calloc, will not cause calloc to fail on newer devices, so a higher address such as 0x140000000 is necessary. Note also that the call to ‘setMediaPickerController’ in the dealloc implementation can simply be survived by setting someOtherField to zero, in which case objc_msgSend generally becomes a nop. NSInvocation objects are made up of a “frame”, a pointer to a buffer containing the arguments for the call, as well as storage for the return value and a reference to a NSMethodSignature object containing information about the number and type of the arguments. The latter two fields are omitted in the diagram for brieviety.
To trigger the controlled ObjC method invocation from [NSSharedKeyDictionary setObject:obj forKey:key], the address 0x140003ff8 must be accessed so that a pointer to the fake MPMediaPickerController is passed to objc_release.
The video below shows the exploit in action. Note that there is no “respring” or similar crash behaviour happening as SpringBoard continues normally after executing the payload.
While opening a calculator is nice, the ultimate goal of an exploit like the one presented here is likely to stage a kernel exploit. One option for that is to use the current capability to open a WebView and serve a classic browser exploit. However, that would likely require a separate browser vulnerability. The final question is thus whether it is possible to directly chain a kernel exploit given the current primitive. The remainder of this blog post will now explore this option.
SeLector Oriented Programming (SLOP)
The technique described here was not part of the initial exploit PoC that was sent to Apple. As such, it was separately reported on September 13 as an exploitation technique (noting that exploitation techniques are not subject to Project Zero's 90 day deadlines)
Expanding on the current exploitation capability, the ability to call a single method on a controlled object with controlled arguments, the next step is to gain the ability to chain multiple method calls together. Furthermore, it should also be possible to use the return values of method calls, for example as arguments to subsequent ones, and to be able to read and write process memory. The following technique, dubbed SLOP for SeLector Oriented Programming ;), allows this.
First, it is possible to chain multiple selector calls together by creating a fake NSArray containing fake NSInvocation objects, then calling [NSArray makeObjectsPerformSelector:@selector(invoke)] on it (through the “bootstrap” NSInvocation that is invoked during [MPMediaPickerController dealloc]). This will invoke each of the NSInvocations, thus performing multiple independent ObjC method calls.
Next, by using the [NSInvocation getReturnValue:ptr] method, it is possible to write the return value of a previous method call somewhere in memory, for example into the arguments buffer of a following call. With that, it is possible to use return values of method calls as arguments for subsequent ones.
A simple SLOP chain then looks like this:
This chain is equivalent to the following ObjC code:
id result = [target1 doX];
[target2 doY:result];
For the final primitive, the ability to read and write process memory, the [NSInvocation getArgument:atIndex] method can be used, of which simplified pseudocode is shown next:
void -[NSInvocation getArgument:(void*)addr atIndex:(uint)idx] {
if (idx >= [self->_signature numberOfArguments]) {
...; // abort with exception
}
memcpy(addr, &self->_frame[idx], 8);
}
As the above method can be invoked using SLOP, it is possible to perform arbitrary memory reads and writes (with ’setArgument:atIndex’ instead) by creating fake NSInvocation objects where the _frame member points to the desired address. Conveniently, this also allows reading and writing at an offset from the pointer which facilitates accessing or corrupting members of some data structure. This will be an important primitive for the next part.
With SLOP, it is now possible to execute arbitrary ObjC methods. As this happens outside of the sandbox in SpringBoard, it is already sufficient to for example gain access to user data. However, by itself, SLOP is likely not powerful enough to execute a kernel exploit, which is the final step for a full device compromise. This is for example due to the lack of (obvious) control-flow primitives that SLOP provides.
Chaining a Kernel Exploit
One approach for executing a kernel exploit is then to use SLOP to pivot into a scripting context, for example into JavaScriptCore.framework, and implement the kernel exploit in that scripting language. Pivoting into JavaScript from SLOP is easily possible using the following method calls:
JSContext* ctx = [[JSContext alloc] init];
[ctx evaluateScript:scriptContent];
In order to execute a kernel exploit in JavaScript, the following primitives will have to be bridged into JavaScript or recreated there:
The ability to read and write the memory of the current process
The ability to call C functions and in particular syscalls
Gaining memory read/write in JavaScript is rather easy with a standard browser exploitation technique: corrupting JavaScript DataViews/TypedArrays. In particular, two DataView objects can be created in JS, then returned to ObjC, where the first one is corrupted so that its backing memory pointer now points to the second one. The resulting situation is shown in the image below. Corrupting the backing storage pointer is possible by using the memory read/write primitive in SLOP as described above.
Subsequently, it is now possible to read from/write to arbitrary addresses from JavaScript by first corrupting the backing storage pointer of the second DataView through the first one, then accessing the second DataView’s storage. Conveniently, Gigacage, a mitigation in JavaScriptCore designed to hinder the abuse of ArrayBuffers and similar in such a way, is not active in JSC builds for Cocoa. This solves point 1 above.
Afterwards, point 2 can be achieved with the following two ObjC methods:
[CNFileServices dlsym::], which is just a thin wrapper around dlsym(3). With PAC enabled, dlsym will sign returned function pointers with context zero (as no sensible context value is available to the caller) before returning them
[NSInvocation invokeUsingIMP:], which accepts an IMP, a function pointer signed with context zero, as argument and calls it with the arguments from its frame buffer, which are thus fully controlled
Combining these two ObjC methods allows calling of arbitrary exported C functions with arbitrary arguments. Finally, it is also possible to bridge this capability into JavaScript: JavaScript core supports bridging ObjC objects by implementing the JSExport protocol and specifying the methods that should be exposed to JS. ObjC methods bridged in that way are implemented by creating one NSInvocation object for every method and invoking it when the method is called in JS. Given the memory read/write capability, it is then possible to corrupt this NSInvocation object to call a different method on a different object, for example [CNFileServices dlsym::] or [NSInvocation invokeUsingIMP:].
With all of that and a bit of wrapper code to expose the required functionality in a convenient way, a reimplementation of Ned Williamson’s trigger for CVE-2023-8605 aka SockPuppet looks like this (see crash_kernel.js for the full code):
let sonpx = memory.alloc(8);
memory.write8(sonpx, new Int64("0x0000000100000001"));
let minmtu = memory.alloc(8);
memory.write8(minmtu, new Int64("0xffffffffffffffff"));
let n0 = new Int64(0);
let n4 = new Int64(4);
let n8 = new Int64(8);
while (true) {
let s = socket(AF_INET6, SOCK_STREAM, IPPROTO_TCP);
setsockopt(s, SOL_SOCKET, SO_NP_EXTENSIONS, sonpx, n8);
setsockopt(s, IPPROTO_IPV6, IPV6_USE_MIN_MTU, minmtu, n4);
disconnectx(s, n0, n0);
// The next setsockopt will write into a freed buffer, so
// give the kernel some time to reclaim it first.
usleep(1000);
setsockopt(s, IPPROTO_IPV6, IPV6_USE_MIN_MTU, minmtu, n4);
close(s);
}
This demonstrates that it is possible to execute a kernel exploit from JavaScript after a successful remote exploit over iMessage and without any further vulnerabilities (e.g. a JavaScriptCore RCE exploit).
Improving Messenger Security
The insights gained during the exploitation work led to numerous recommendations for hardening measures, most of which have already been mentioned throughout this series where appropriate and have also been communicated to Apple. As they could also be relevant for other messenger and mobile operating system vendors, they are restated next.
As much code as possible should be put behind user interaction, in particular when receiving messages from Tulisan saya senders.
Communication side channels in the interactionless attack surface such as automatic delivery receipts should be removed. If that is not possible, the receipts should at least be sent in a way that prevents them from being abused to construct a crash oracle, for example by sending them on the server side or from a dedicated process
Processes handling incoming data should be sandboxed as much as possible and should be disallowed any network interaction to stop an attacker from constructing a communication channel (and afterwards an info leak) through a memory corruption or logic bug. This would also prevent bugs such as CVE-2023-8646 from being easily exploitable.
ASLR should be as strong as possible. In particular, both heap spraying and brute force guessing should be impractical on any remote attack surface. Similarly, on iOS, the ASLR of the dyld shared cache region could be improved.
Some probabilistic defenses (ASLR, and in the future memory tagging) can be defeated if the attacker gets enough attempts (as is the case here with imagent). As such, it seems desirable to limit the number of attempts an attacker gets whenever possible and to deploy monitoring that is able to detect failed exploitation attempts.
Since developing and reporting the presented exploit to Apple on August 9, the following security relevant changes to iOS have taken place:
A large part of the NSKeyedUnarchiver attack surface in iMessage was blocked (by disallowing the decoding of child classes), thus rendering this bug and others unexploitable over iMessage
The vulnerability exploited here was fixed and assigned CVE-2023-8641
The decoding of the “BP” NSKeyedUnarchiver payload contained in incoming iMessages was moved into a sandboxed process (IMDPersistenceAgent). As such, an attacker exploiting an NSKeyedUnarchiver vulnerability over iMessage will now also need to break out of a sandbox.
A new unsigned int field, _magic, was added to the NSInvocation class. This field is set to the value of a per-process random global variable during initialization and asserted to be equal to it again during [NSInvocation invoke]. As such, it is no longer possible to create fake NSInvocation instances (which is a requirement for the presented PAC bypass) without leaking this value beforehand. Further, it appears that efforts are being made to protect the selector and target fields of NSInvocation with PAC in the future, additionally hindering abuse of NSInvocations even in case of an attacker with memory read/write capabilities.
The [MPMediaPickerController dealloc] method, which was used to bootstrap the PAC bypass by sending the “invoke” selector to an attacker-controlled object, appears to have been removed. However, there are still other dealloc implementations that call “invoke” on an attacker controlled object left. These might be removed in the future as well. As described above, it is in any case now more difficult to create fake NSInvocation instances.
This list could be incomplete as it was compiled through reverse engineering and manual experimentation, as Apple do not currently share any details of how their issues were fixed with security researchers.
On a final note, while good sandboxing is critical, it should not be relied upon blindly. As an example to consider, the vulnerability exploited here would likely also be usable to escape any sandbox on the device as the targeted API is commonly exposed over IPC as well.
Conclusion
This blog post series demonstrated that, despite numerous exploit mitigations being deployed, it is still possible to exploit memory corruption vulnerabilities in a non-interactive setting such as mobile messaging services and without an additional remote infoleak vulnerability as is commonly deemed necessary. The exploited vulnerability has been fixed by Apple and a few further hardening measures have already been deployed. A key insight of this research was that ASLR, which in theory should offer strong protection in this attack scenario, is not as strong in practice. In particular, ASLR can be defeated through heap spraying and through crash oracles, which can be constructed from commonly available side channels such as automatic delivery receipts. A second insight was that fairly arbitrary code, powerful enough to execute a kernel exploit, could be executed without ever creating or corrupting a code pointer, thus effectively bypassing PAC. Finally, based on the developed exploit, numerous suggestions for attack-surface reduction, hardening, and exploit mitigations were presented. My hope is that this research will ultimately help all vendors by highlighting how small design decisions can have significant security consequences and to hopefully better protect their users from these kinds of attacks.
Posting Komentar