Posted by Samuel Groß, Project Zero
Introduction
This is the first blog post in a three-part series that will detail how a vulnerability in iMessage can be exploited remotely without any user interaction on iOS 12.4 (fixed in iOS 12.4.1 in August 2023). It is essentially a more detailed version of my 36C3 talk from December 2023.
The first part of the series provides an in-depth discussion of the vulnerability, the second part presents a technique to remotely break ASLR, and the third part explains how to gain remote code execution afterwards.
The attack presented in this series allows an attacker, who is only in possession of a user’s Apple ID (mobile phone number or email address), to remotely gain control over the user’s iOS device within a few minutes. Afterwards, an attacker could exfiltrate files, passwords, 2FA codes, SMS and other messages, emails and other user and app data. They could also remotely activate the microphone and camera. All of this is possible without any user interaction (e.g. opening a URL sent by an attacker) or visual indicator (e.g. notifications) being displayed to the user. The attack exploits a single vulnerability, CVE-2023-8641 to first bypass ASLR, then execute code on the target device outside of the sandbox.
This research was mainly motivated by the following question: given only a remote memory corruption vulnerability, is it possible to achieve remote code execution on an iPhone without further vulnerabilities and without any form of user interaction? This blog post series shows that this is in fact possible.
The vulnerability was found as part of a joint vulnerability research project with Natalie Silvanovich and reported to Apple on July 29 2023, followed by the proof-of-concept exploit on August 9, 2023. The vulnerability was first mitigated in iOS 12.4.1, released on August 26, by making the vulnerable code unreachable over iMessage, then fully fixed in iOS 13.2, released on October 28 2023. Further hardening measures were suggested based on insights gained during exploit development, which, if implemented, should make similar exploits significantly harder in the future. As some of these hardening measures are also relevant to other messenger services and (mobile) operating systems, they will be mentioned throughout this series and summarized at the end of it.
For security researchers, a proof-of-concept exploit targeting iOS 12.4 on the iPhone XS is available here. In order to hinder abuse, it deliberately alerts the victim of an ongoing attack by displaying multiple notifications throughout the exploitation process. Furthermore, it does not achieve native code execution (i.e. shellcode execution), thus making it more difficult to combine it with an existing privilege escalation exploit. However, none of these restrictions require further vulnerabilities to remedy and only require re-engineering of the exploit by a capable attacker. The in-built restrictions and notifications are merely designed to stop abuse by unskilled attackers simply running the code. It should be noted that skilled attackers likely already have the capability offered by the released exploit code, either from finding vulnerabilities themselves, from reverse engineering patches as has been observed before, or, as 1-day exploit, by for example combining CVE-2023-8646, a remote infoleak bug, with any of the other memory corruption bugs that were found.
As usual, my hope is that this research will assist fellow security researchers and software developers by showing how well-intentioned mitigations can be bypassed, how modern exploitation techniques work and sharing some ideas that I have for ensuring that the weak spots that I've highlighted can be subsequently mitigated.
iMessage Architecture
Incoming iMessages pass through multiple services and frameworks before a notification is finally displayed to the user and the message is written to the messages database. The main services handling iMessages on iOS 12.4 without requiring user interaction are depicted below. A red border indicates the existence of a sandbox for the process.
The remotely reachable attack surface, including the notoriously complex NSKeyedUnarchiver API, as well as the iMessage data format, have been described extensively by Natalie Silvanovich in a previous blog post. For the purpose of this blog post series, it is important to realize that a vulnerability in the NSKeyedUnarchiver API can generally be triggered in two different contexts: in the sandboxed imagent and in the unsandboxed SpringBoard process (which manages the main iOS UI, including the homescreen). Both contexts have their advantages and disadvantages with regards to exploitation. For example, while SpringBoard already runs outside of the sandbox and thus appears to be the better target at first, it is also a somewhat critical system process and crashing it will cause a noticeable “respring” of the device, with the screen suddenly turning black and a loading icon appearing for a few seconds before the lock screen appears. Imagent on the other hand can safely crash without causing any system behaviour that might be observable to the user and will afterwards simply be restarted.
As of iOS 13, it appears that decoding of NSKeyedUnarchiver data no longer happens inside of SpringBoard but instead in the sandboxed IMDPersistenceAgent, thus significantly reducing the unsandboxed attack surface of iMessage.
iMessage Delivery
In order to deliver an exploit over iMessage, one needs to be able to send custom iMessages to the target. This requires interacting with Apple’s servers and dealing with iMessage’s end2end encryption. An easy way to do this, which was used for this research, is to reuse the existing code by hooking into the iMessage handling code inside imagent with a tool like frida. With that, sending a custom iMessage from a script running on macOS can be done by:
Building the desired payload (e.g. to trigger an NSKeyedUnarchiver bug) and storing it to disk
Calling a small apple script that instructs Messages.app to send a message with a placeholder content (e.g. “REPLACEME”) to the target
Hooking imagent with frida and replacing the content of outgoing iMessages containing the placeholder with the content of the payload file
In the same way it is also possible to receive incoming messages by using frida to hook the receiver functions in imagent.
As an example, the following iMessage (encoded as a binary plist), will be sent to the receiver when sending the message “REPLACEME” from Messages.app:
{
gid = "008412B9-A4F7-4B96-96C3-70C4276CB2BE";
gv = 8;
p = (
"mailto:sender@foo.bar",
"mailto:receiver@foo.bar"
);
pv = 0;
r = "6401430E-CDD3-4BC7-A377-7611706B431F";
t = "REPLACEME";
v = 1;
x = "<html><body>REPLACEME</body></html>";
}
The frida hook will then modify the message before it is serialized, encrypted, and sent to Apple’s servers:
{
gid = "008412B9-A4F7-4B96-96C3-70C4276CB2BE";
gv = 8;
p = (
"mailto:sender@foo.bar",
"mailto:receiver@foo.bar”
);
pv = 0;
r = "6401430E-CDD3-4BC7-A377-7611706B431F";
t = "REPLACEME";
v = 1;
x = "<html><body>REPLACEME</body></html>";
ati = <content of /private/var/tmp/com.apple.messages/payload>;
}
This will then cause imagent on the receiver’s device to decode the data in the ati field using the NSKeyedUnarchiver API. Example code that allows sending custom messages and dumping incoming ones can be found here.
CVE-2023-8641
The bug that was used for this research is CVE-2023-8641, corresponding to Project Zero issue 1917. It is another bug in the NSKeyedUnarchiver component which has been discussed in depth by Natalie. While it is likely that other memory corruption bugs found in the NSKeyedUnarchiver components during this research can be exploited in a similar way, this one seemed the most convenient to exploit.
The bug in question occurs during unarchiving of an NSSharedKeyDictionary, a special kind of NSDictionary in which the keys are declared up front in an NSSharedKeySet to allow for faster element access. Further, an NSSharedKeySet can itself have a subSharedKeySet, essentially building a linked list of KeySets.
To understand the vulnerability, it is first necessary to take a look at the NSKeyedUnarchiver serialization format. Below is a simple archive containing a serialized NSSharedKeyDictionary as printed by plutil(1) (NSKeyedArchiver encodes the object graph as a plist) and with some comments added to it:
{
"$archiver" => "NSKeyedArchiver"
# The objects contained in the archive are stored in this array
# and can be referenced during decoding using their index
"$objects" => [
# Index 0 always contains the nil value
0 => "$null"
# The serialized NSSharedKeyDictionary
1 => {
"$class" => <CFKeyedArchiverUID>{value = 7}
"NS.count" => 0
"NS.sideDic" => <CFKeyedArchiverUID>{value = 0}
"NS.skkeyset" => <CFKeyedArchiverUID>{value = 2}
}
# The NSSharedKeySet associated with the dictionary
2 => {
"$class" => <CFKeyedArchiverUID>{value = 6}
"NS.algorithmType" => 1
"NS.factor" => 3
"NS.g" => <00>
"NS.keys" => <CFKeyedArchiverUID>{value = 3}
"NS.M" => 6
"NS.numKey" => 1
"NS.rankTable" => <00000000 0001>
"NS.seed0" => 361949685
"NS.seed1" => 2328087422
"NS.select" => 0
"NS.subskset" => <CFKeyedArchiverUID>{value = 0}
}
# The keys of the NSSharedKeySet
3 => {
"$class" => <CFKeyedArchiverUID>{value = 5}
"NS.objects" => [
0 => <CFKeyedArchiverUID>{value = 4}
]
}
# The value of the first (and only) key
4 => "the_key"
# ObjC classes are stored in this format
5 => {
"$classes" => [
0 => "NSArray"
1 => "NSObject"
]
"$classname" => "NSArray"
}
6 => {
"$classes" => [
0 => "NSSharedKeySet"
1 => "NSObject"
]
"$classname" => "NSSharedKeySet"
}
7 => {
"$classes" => [
0 => "NSSharedKeyDictionary"
1 => "NSMutableDictionary"
2 => "NSDictionary"
3 => "NSObject"
]
"$classname" => "NSSharedKeyDictionary"
}
]
# A reference to the root object in the archive
"$top" => {
"root" => <CFKeyedArchiverUID>{value = 1}
}
"$version" => 100000
}
One thing to note here is that this serialization format supports references to the objects contained in it through the CFKeyedArchiverUID values. During unarchiving, the NSKeyedUnarchiver will keep a mapping of UIDs to objects, so that a single object can be referenced from multiple other objects in the same archive. Furthermore, the reference is added to the map before the object’s initWithCoder method (the constructor used during unarchiving) is invoked. As such, it becomes possible to decode cyclic object graphs, in which case an object can be referenced while it is currently being unarchived further up in the callstack. Interestingly, in that case the first object may not yet be fully initialized when it is referenced. This creates potential for bugs and is exactly what happens for CVE-2023-8641.
Below is Objective-C (ObjC) pseudocode for the initWithCoder implementations of the NSSharedKeyDictionary and NSSharedKeySet classes. Readers unfamiliar with ObjC can think of code constructs such as
[obj doXWith:y and:z];
as a method call on an object, similar to
obj->doX(y, z);
in C++.
-[NSSharedKeyDictionary initWithCoder:coder] {
self->_keyMap = [coder decodeObjectOfClass:[NSSharedKeySet class]
forKey:"NS.skkeyset"];
// ... decode values etc.
}
-[NSSharedKeySet initWithCoder:coder] {
self->_numKey = [coder decodeInt64ForKey:@"NS.numKey"];
self->_rankTable = [coder decodeBytesForKey:@"NS.rankTable"];
// ... copy more fields from the archive
self->_subSharedKeySet = [coder
decodeObjectOfClass:[NSSharedKeySet class]
forKey:@"NS.subskset"]];
NSArray* keys = [coder decodeObjectOfClasses:[...]
forKey:@"NS.keys"]];
if (self->_numKey != [keys count]) {
return fail(“Inconsistent archive);
}
self->_keys = calloc(self->_numKey, 8);
// copy keys into _keys
// Verify that all keys can be looked up
for (id key in keys) {
if ([self indexForKey:key] == -1) {
NSMutableArray* allKeys = [NSMutableArray arrayWithArray:keys];
[allKeys addObjectsFromArray:[self->_subSharedKeySet allKeys]];
return [NSSharedKeySet keySetWithKeys:allKeys];
}
}
}
The implementation of the indexForKey routine, which is invoked at the end of initWithCoder (line 20), is given below. It uses a hash of the key to index into _rankTable and uses the result as an index into _keys. It recursively searches for the key in the subSharedKeySet until it finds it or no more subSharedKeySets are available:
-[NSSharedKeySet indexForKey:] {
NSSharedKeySet* current = self;
uint32_t prevLength = 0;
while (current) {
// Compute a hash from the key and other internal values of
// the KeySet. Convert the hash to an index and ensure that it
// is within the bounds of rankTable
uint32_t rankTableIndex = ...;
uint32_t index = self->_rankTable[rankTableIndex];
if (index < self->_numKey) {
id candidate = self->_keys[index];
if (candidate != nil) {
if ([key isEqual:candidate]) {
return prevLength + index;
}
}
prevLength += self->_numKey;
current = self->_subSharedKeySet;
}
return -1;
}
Given the above logic, the following object graph will lead to memory corruption when deserialized:
Here is what happens during unarchiving:
The NSSharedKeyDictionary is unarchived, in turn unarchiving its SharedKeySet
SharedKeySet1’s initWithCoder runs up to the point where it unarchives its subSharedKeySet (line 6). At this point
_numKey is fully controlled by the attacker as no check has been performed on it yet (that will only happen after _keys has been unarchived)
_rankTable is also fully controlled
_keys is still nullptr (as ObjC objects are allocated via calloc)
SharedKeySet2 is completely unarchived. Its subSharedKeySet is now a reference to SharedKeySet1 (which is still being unarchived). At the end, it invokes indexForKey: for all keys in its _keys array (line 20)
As SharedKeySet2’s rankTable is all zeros, only the first key can be looked up on itself (see lines 8 through 15 in indexForKey:). The second key will then be looked up on SharedKeySet1. Here, as _numKey and _rankTable’s content are fully controlled, the code will, in line 10, index into _keys (which is nullptr) with a controlled index, causing a crash.
The following picture shows the slightly simplified callstack at the time of the crash:
As a result of this, a mostly arbitrary address (in this case 0x41414140, as the index is multiplied by 8, the element size) is now dereferenced and the result used as an ObjC object pointer (an “id”). There are, however, two restrictions on the addresses that can be accessed with this bug:
The address must be cleanly divisible by 8 (as the _keys array stores pointer sized values) and
it must be less than 32G as the index is a 4 byte unsigned integer
Luckily, at least on iOS, most (all?) interesting things in memory are located below 0x800000000 (32G) and thus become accessible using this primitive.
As it turns out, even a seemingly uninteresting null pointer dereference can become a pretty powerful exploit primitive under the right circumstances... :)
At this point no information about the target process’ address space is available. As such, some kind of information leak must first be constructed. This will be the topic of the next blog post.
Posting Komentar