CVSS2
Attack Vector
NETWORK
Attack Complexity
LOW
Authentication
NONE
Confidentiality Impact
PARTIAL
Integrity Impact
NONE
Availability Impact
NONE
AV:N/AC:L/Au:N/C:P/I:N/A:N
CVSS3
Attack Vector
NETWORK
Attack Complexity
LOW
Privileges Required
NONE
User Interaction
NONE
Scope
UNCHANGED
Confidentiality Impact
HIGH
Integrity Impact
NONE
Availability Impact
NONE
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N
EPSS
Percentile
94.0%
Posted by Ian Beer, Project Zero
TL;DR
This chain targeted iOS 11-11.4.1, spanning almost 10 months. This is the first chain we observed which had a separate sandbox escape exploit.
The sandbox escape vulnerability was a severe security regression in libxpc, where refactoring lead to a < bounds check becoming a != comparison against the boundary value. The value being checked was read directly from an IPC message, and used to index an array to fetch a function pointer.
Itβs difficult to understand how this error could be introduced into a core IPC library that shipped to end users. While errors are common in software development, a serious one like this should have quickly been found by a unit test, code review or even fuzzing. Itβs especially unfortunate as this location would naturally be one of the first ones an attacker would look, as I detail below.
targets: 5s through X, 11.0 through 11.4
Devices:
iPhone6,1 (5s, N51AP)
iPhone6,2 (5s, N53AP)
iPhone7,1 (6 plus, N56AP)
iPhone7,2 (6, N61AP)
iPhone8,1 (6s, N71AP)
iPhone8,2 (6s plus, N66AP)
iPhone8,4 (SE, N69AP)
iPhone9,1 (7, D10AP)
iPhone9,2 (7 plus, D11AP)
iPhone9,3 (7, D101AP)
iPhone9,4 (7 plus, D111AP)
iPhone10,1 (8, D20AP)
iPhone10,2 (8 plus, D21AP)
iPhone10,3 (X, D22AP)
iPhone10,4 (8, D201AP)
iPhone10,5 (8 plus, D211AP)
iPhone10,6 (X, D221AP)
Versions:
15A372 (11.0 - 19 Sep 2017)
15A402 (11.0.1 - 26 Sep 2017)
15A403 (11.0.2 - 26 Sep 2017 - seems to be 8/8plus only, which didnβt get 15A402)
15A421 (11.0.2 - 3 Oct 2017)
15A432 (11.0.3 - 11 Oct 2017)
15B93 (11.1 - 31 Oct 2017)
15B150 (11.1.1 - 9 Nov 2017)
15B202 (11.1.2 - 16 Nov 2017)
15C114 (11.2 - 2 Dec 2017)
15C153 (11.2.1 - 13 Dec 2017)
15C202 (11.2.2 - 8 Jan 2018)
15D60 (11.2.5 - 23 Jan 2018)
15D100 (11.2.6 - 19 Feb 2018)
15E216 (11.3 - 29 Mar 2018)
15E302 (11.3.1 - 24 Apr 2018)
15F79 (11.4 - 29 May 2018)
first unsupported version: 11.4.1 - 9 July 2018
Starting from this third chain the privesc binaries have a different structure. Rather than using the system loader and linking against the required symbols, they instead resolve all the required symbols themselves via dlsym (with the address of dlsym getting passed in from the JSC exploit.) Hereβs a snippet from the start of the symbol resolution function:
syscall = dlsym(RTLD_DEFAULT, βsyscallβ);
memcpy = dlsym(RTLD_DEFAULT, βmemcpyβ);
memset = dlsym(RTLD_DEFAULT, βmemsetβ);
mach_msg = dlsym(RTLD_DEFAULT, βmach_msgβ);
stat = dlsym(RTLD_DEFAULT, βstatβ);
open = dlsym(RTLD_DEFAULT, βopenβ);
read = dlsym(RTLD_DEFAULT, βreadβ);
close = dlsym(RTLD_DEFAULT, βcloseβ);
β¦
Interestingly, this seems to be an append-only list, and there are plenty of symbols which arenβt used. In Appendix A Iβve enumerated those, and guessed what bugs they might have been targeting with earlier versions of this framework.
Checking for prior compromise
Like PE2, after the kernel exploit has successfully run they make a system modification which can be observed from inside the sandbox. This time they add the string βiop114β to the device bootargs which can be read from inside the WebContent sandbox via the kern.bootargs sysctl:
sysctlbyname(βkern.bootargsβ, bootargs, &v7, 0LL, 0LL);
if (strcmp(bootargs, βiop114β)) {
syslog(0, βto sleep β¦β);
while (1)
sleep(1000);
}
XPC (which probably stands for βCrossβ-Process Communication) is an IPC mechanism which uses mach messages as a transport layer. It was introduced in 2011 around the time of iOS 5. XPC messages are serialized object trees, typically with a dictionary at the root. XPC also contains functionality for exposing and managing named services; newer IPC services tend to be built on XPC rather than the legacy MIG system.
XPC was marketed as a security boundary; at the 2011 Apple World Wide Developers Conference (WWDC) Apple explicitly stated the benefits of isolation via XPC as βLittle to no harm if service is exploitedβ and that it βMinimizes impact of exploits.β Unfortunately, there has been a long history of bugs in XPC; both in the core library as well as in how services used its APIs. See for example the following P0 issues: 80, 92, 121, 130, 1247, 1713. Core XPC bugs are quite useful, as they allow you to target any process which uses XPC.
This particular bug appears to have been introduced via some refactoring in iOS 11 in the way that the XPC code parses serialized xpc dictionary objects in βfast modeβ. Hereβs the old code:
struct _context {
xpc_dictionary* dict;
char* target_key;
xpc_serializer* result;
int* found
};
int64
_xpc_dictionary_look_up_wire_apply(
char *current_key,
xpc_serializer* serializer,
struct _context *context)
{
if ( !current_key )
return 0;
if (strcmp(context->target_key, current_key))
return _skip_value(serializer);
// key matches; result is current state of serializer
memcpy(context->result, serializer, 0xB0);
*(context->found) = 1;
return 0;
}
An xpc_serializer object is a wrapper around a raw, unparsed XPC message. (The xpc_serializer type is responsible for both serialization and deserialization.)
Hereβs an example serialized XPC message:
In XPCβs βslow modeβ an incoming message is completely deserialized into XPC objects when itβs received. The fast mode instead attempts to lazily search for values inside the serialized dictionaries when theyβre first requested, rather than parsing everything upfront. It does this by comparing the keys in the serialized dictionary against the desired key; if the current key doesnβt match they call skip_value to jump over the payload value of the current key to the next key in the serialized XPC dictionary object.
int skip_value(xpc_serializer* serializer)
{
uint32_t wireid;
uint64_t wire_length;
wireid = read_id(xpc_serializer);
if (wireid == 0x1A000)
return 0LL;
wire_length = xpc_types[wireid >> 12]->wire_length(serializer);
if (wire_length == -1 ||
wire_length > serializer->remaining)
return 0;
// skip over the value
xpc_serializer_advance(serializer, wire_length);
return 1;
}
uint32_t read_id(xpc_serializer* serializer)
{
// ensure there are 4 bytes to be read; return pointer to them
wireid_ptr = xpc_serializer_read(serializer, 4, 0, 0);
if ( !wireid_ptr )
return 0x1A000;
uint32_t wireid = *wireid_ptr;
uint32_t typeid = wireid >> 12;
// if any bits other than 12-20 are set,
// or the type_index is 0, fail
if (wireid & 0xFFF00FFF ||
typeid == 0
typeid >= _xpc_ntypes) { // 0x19
return 0x1A000LL;
}
return wireid;
}
skip_value first calls read_id, which reads 4 bytes from the serialized message. Those four bytes are the wireid value, which tells XPC the type of the serialized value. read_id also verifies that the wireid is valid: the xpc typeid is contained in bits 12-20 of the wireid, only those bits may be set and the value of the typeid must be greater than zero and less than 0x19. If these conditions arenβt met then read_id returns the sentinel wireid value of 0x1A000. skip_id checks for this sentinel return value from read_id and aborts. If read_id returns a valid wireid value, then skip_id uses the typeid bits to index the xpc_types array and call a function pointer read indirectly from there.
Letβs take a look at how this code changed in iOS 11. The prototype for xpc_dictionary_look_up_wire_apply is unchanged:
int64
_xpc_dictionary_look_up_wire_apply(
char *current_key,
xpc_serializer* serializer,
struct _context *context)
{
if (!current_key)
return 0;
if (strcmp(context->target_key, current_key))
return skip_id_and_value(serializer);
memcpy(context->result, serializer, 0xB0);
*(context->found) = 1;
return 0;
}
The call to skip_value has been replaced with a call to skip_id_and_value however:
int64 skip_id_and_value(xpc_serializer* serializer)
{
uint32_t* wireid_ptr = xpc_serializer_read(serializer, 4, 0, 0);
if (!wireid_ptr)
return 0;
uint32_t wireid = *wireid_ptr;
if (wireid != 0x1B000)
return skip_value(xpc_serializer, wireid);
return 0;
}
Thereβs no call to read_id anymore (which was responsible for both reading and verifying the id) instead skip_id_and_value reads the four byte wireid value itself. Curiously it compares the four-byte wireid value against 0x1B000. Is this comparison supposed to actually be something like this?
wireid < 0x1B000
Something seems very wrong.
The controlled wireid value, which can now be any value apart from 0x1B000, is passed to skip_value; which has a different prototype to before now taking a wireid in addition to the xpc_serializer:
int64
skip_value(xpc_serializer* serializer, uint32_t wireid)
{
// declare function pointer
uint32_t (wire_length_fptr*)(xpc_serializer*);
wire_length_fptr = xpc_wire_length_from_wire_id(wireid);
uint32_t wire_length = wire_length_fptr(serializer)
if (wire_length == -1 ||
wire_length > serializer->remaining) {
return 0;
}
xpc_serializer_advance(serializer, wire_length);
return 1;
}
uint32_t ()(xpc_serializer)
xpc_wire_length_from_wire_id(uint32_t wireid)
{
return xpc_types[wireid >> 12]->wire_length;
}
Not only has the prototype of skip_value changed; the precondition has changed too: it used to be the case that skip_value was responsible for verifying the wireid value in the message. Thatβs no longer the case. The wireid value is passed directly to xpc_wire_length_from_wire_id where the lower 12-bits are shifted out and the upper 20 are used to directly index the xpc_types array. xpc_types is an array of pointers to Objective-C classes; the field at +0x90 is the wire_length function pointer, which will be called by skip_value.
What happened to all the bounds checking? Lots of code changed subtly here; the semantics of the functions changed and in the end a correct bounds check seems to have become a comparison against just a single invalid value.
Looking at the other xpc_wire_length_from_wire_id call-sites they are all dominated by calls to _xpc_class_id_from_wire_valid, which actually validates the wireid:
int xpc_class_id_from_wire_valid(uint32_t wireid)
{
if (((wire_id - 0x1000) < 0x1A000) &&
((wire_id & 0xFFF00F00) == 0)) {
return 1;
}
return 0;
}
Itβs very simple to hit this bug; anywhere between iOS 11.0 and 11.4.1 just flip a few bits in an XPC message and youβll probably hit it. This is why I believe that fuzzing or a unit test would have quickly found this issue.
XPC eXploitation
Letβs take a closer look at exactly what will happen when the vulnerability is triggered:
int64 skip_id_and_value(xpc_serializer* serializer)
{
uint32_t* wireid_ptr = xpc_serializer_read(serializer, 4, 0, 0);
if (!wireid_ptr)
return 0;
uint32_t wireid = *wireid_ptr;
if (wireid != 0x1B000)
return skip_value(xpc_serializer, wireid);
xpc_serializer_read returns a pointer into the raw mach message buffer; itβs just ensuring that there are at least 4 bytes left to read. As long as those 4 bytes donβt contain the value 0x1B000, theyβll pass the checks.
Letβs look at the iOS 11 version of skip_value again:
int64
skip_value(xpc_serializer* serializer, uint32_t wireid)
{
// declare function pointer
uint32_t (wire_length_fptr*)(xpc_serializer*);
wire_length_fptr = xpc_wire_length_from_wire_id(wireid);
uint32_t wire_length = wire_length_fptr(serializer)
Each XPC type (eg xpc_dictionary, xpc_string, xpc_uint64) defines a function to determine how large their serialized payload is. For fixed-sized objects, such as an xpc_uint64, this will just return a constant (an xpc_uint64 payload is always 8 bytes):
__xpc_uint64_wire_length
MOV W0, #8
RET
Similarly, an xpc_uuid object always has a 0x10 byte payload:
__xpc_uuid_wire_length
MOV W0, #0x10
RET
For variable-sized types the length needs to be read from the serialized object:
__xpc_string_wire_length
B __xpc_wire_length
All variable-sized xpc objects record their size in bytes directly after their wireid, so _xpc_wire_length just reads the next 4 bytes without consuming them.
_xpc_wire_length_from_wire_id looks up the correct function pointer to call:
uint32_t ()(xpc_serializer)
xpc_wire_length_from_wire_id(uint32_t wireid)
{
return xpc_types[wireid >> 12]->wire_length;
}
xpc_types is an array of pointers to the relevant Objective-C class objects:
__xpc_types:
libxpc:__const:DCQ 0
libxpc:__const:DCQ OBJC_CLASS$_OS_xpc_null
libxpc:__const:DCQ OBJC_CLASS$_OS_xpc_bool
libxpc:__const:DCQ OBJC_CLASS$_OS_xpc_int64
libxpc:__const:DCQ OBJC_CLASS$_OS_xpc_uint64
libxpc:__const:DCQ OBJC_CLASS$_OS_xpc_double
libxpc:__const:DCQ OBJC_CLASS$_OS_xpc_pointer
libxpc:__const:DCQ OBJC_CLASS$_OS_xpc_date
libxpc:__const:DCQ OBJC_CLASS$_OS_xpc_data
libxpc:__const:DCQ OBJC_CLASS$_OS_xpc_string
libxpc:__const:DCQ OBJC_CLASS$_OS_xpc_uuid
libxpc:__const:DCQ OBJC_CLASS$_OS_xpc_fd
libxpc:__const:DCQ OBJC_CLASS$_OS_xpc_shmem
libxpc:__const:DCQ OBJC_CLASS$_OS_xpc_mach_send
libxpc:__const:DCQ OBJC_CLASS$_OS_xpc_array
libxpc:__const:DCQ OBJC_CLASS$_OS_xpc_dictionary
libxpc:__const:DCQ OBJC_CLASS$_OS_xpc_error
libxpc:__const:DCQ OBJC_CLASS$_OS_xpc_connection
libxpc:__const:DCQ OBJC_CLASS$_OS_xpc_endpoint
libxpc:__const:DCQ OBJC_CLASS$_OS_xpc_serializer
libxpc:__const:DCQ OBJC_CLASS$_OS_xpc_pipe
libxpc:__const:DCQ OBJC_CLASS$_OS_xpc_mach_recv
libxpc:__const:DCQ OBJC_CLASS$_OS_xpc_bundle
libxpc:__const:DCQ OBJC_CLASS$_OS_xpc_service
libxpc:__const:DCQ OBJC_CLASS$_OS_xpc_service_instance
libxpc:__const:DCQ OBJC_CLASS$_OS_xpc_activity
libxpc:__const:DCQ OBJC_CLASS$_OS_xpc_file_transfer
__xpc_ool_types:
libxpc:__const:DCQ OBJC_CLASS$_OS_xpc_fd
libxpc:__const:DCQ OBJC_CLASS$_OS_xpc_shmem
libxpc:__const:DCQ OBJC_CLASS$_OS_xpc_mach_send
libxpc:__const:DCQ OBJC_CLASS$_OS_xpc_connection
libxpc:__const:DCQ OBJC_CLASS$_OS_xpc_endpoint
libxpc:__const:DCQ OBJC_CLASS$_OS_xpc_mach_recv
libxpc:__const:DCQ OBJC_CLASS$_OS_xpc_file_transfer
The value at offset +0x90 in each xpc typeβs class object is its wire_length function pointer. That function pointer will be called with one argument, which is a pointer to the current xpc_serializer object.
This gives quite an interesting exploitation primitive:
They control an array index i, which can be between 0x1c and 0x100000 (since itβs the upper 20 bits of the controlled wireid value). That will index the xpc_types array, in the const segment of the libxpc.dylib library in the shared cache. The code will read the pointer at the offset they provide (without bounds checking) then call the function pointer at offset +0x90 from that:
When F_PTR gets called, no register will point to controlled data. X0 will point to the current xpc_serializer, so that seems like the logical choice for targeting to make something more interesting happen. The relevant fields of an xpc_serializer object which can be indirectly controlled are:
CVSS2
Attack Vector
NETWORK
Attack Complexity
LOW
Authentication
NONE
Confidentiality Impact
PARTIAL
Integrity Impact
NONE
Availability Impact
NONE
AV:N/AC:L/Au:N/C:P/I:N/A:N
CVSS3
Attack Vector
NETWORK
Attack Complexity
LOW
Privileges Required
NONE
User Interaction
NONE
Scope
UNCHANGED
Confidentiality Impact
HIGH
Integrity Impact
NONE
Availability Impact
NONE
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N
EPSS
Percentile
94.0%