
macOS NFS 클라이언트 access-cache 레이스(CVE-2026-43687)에 대한 리버스 엔지니어링 노트와 자체 포함 PoC로, kext 디스어셈블리 diff 및 dtrace 레이스 캡처를 포함합니다.
macOS NFS 클라이언트 액세스 캐시 경쟁 조건(CVE-2026-43687)에 대한 독립적인 리버스 엔지니어링과, 해당 경쟁 조건을 트리거하고 dtrace로 실시간으로 포착하는 동작하는 PoC입니다.
이 버그는 _nfs_vnop_access에서 nfsnode 액세스 캐시 포인터를 동기화 없이 읽는 문제입니다. 악의적인 NFSv3 서버는 다른 스레드가 액세스 캐시를 읽는 동안 재할당하도록 유도할 수 있으며, 이로 인해 악의적인 서버가 영향을 줄 수 있는 커널 메모리 노출이 발생합니다. macOS 26.7은 nfsnode+0x158에 lck_rw_t를 삽입하고 캐시 읽기 주변에서 이를 공유 모드로 획득하여 이 문제를 수정합니다.
| 필드 | 값 |
|---|---|
| CVE | CVE-2026-43687 |
| 구성 요소 | com.apple.filesystems.nfs (_nfs_vnop_access) |
| 영향 받는 버전 | macOS Tahoe 26.6 및 이전, iOS 26.x 및 이전 |
| 패치된 버전 | macOS Tahoe 26.7, macOS Golden Gate 27, iOS 26.7, iOS 27 |
| 권고 영향 | "악의적인 NFS 서버에 연결하면 커널 메모리가 노출될 수 있습니다." |
| CVSS v3.1 | 6.5 (Medium) — AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:N |
| 보고자 | KRsecurity의 R4mbb 및 Peter Malone (Apple 권고 기준) |
CVE-2026-43687에 대한 Apple의 권고는 영향과 패치 버전을 문서화합니다. 하지만 기술적 메커니즘은 문서화하지 않습니다:
nfsnode의 어떤 필드가 경쟁 조건에 노출되는지stat(2)가 아닌 access(2)를 사용해야 하는지작성 시점에 공개된 기술 문서는 발견되지 않았습니다. 이 저장소는 26.6과 26.7 사이의 NFS kext에 대한 독립적인 리버스 엔지니어링 분석과, 실제 대상에서 경쟁 조건을 재현하는 동작하는 PoC로 이 공백을 채웁니다.
이것은 발견 주장이 아닙니다. 이 CVE는 R4mbb와 Peter Malone에 의해 보고되었고 Apple에 의해 패치되었습니다. 여기서의 기여는 기술적 분석과 재현입니다.
NFS 클라이언트의 _nfs_vnop_access는 per-UID 액세스 캐시 배열에 대한 포인터인 nfsnode+0x158을 단일 호출 내에서 두 번 읽으며, 어떤 잠금도 보유하지 않습니다:
; macOS 26.6, com.apple.filesystems.nfs
fffffe000b52def4 ldr w8, [x20, #0x160] ; count
fffffe000b52def8 cmp w23, w8
fffffe000b52defc b.ge ...
fffffe000b52df00 ldr x8, [x20, #0x158] ; cache ptr (read #1)
...
fffffe000b52df98 ldr x8, [x20, #0x158] ; cache ptr (read #2)
fffffe000b52dfb0 ldr w21, [x9] ; dereference
기록자(writer)인 _nfs_nget는 클라이언트가 서버로부터 새로운 UID를 볼 때마다 kalloc_data로 배열을 재할당하고 결과를 +0x158에 저장합니다:
fffffe000b52100c bl 0xfffffe000b6a1dd8 ; kalloc
fffffe000b521010 str x0, [x22, #0x158] ; cache ptr
fffffe000b521018 str w20, [x22, #0x160] ; cache count
다른 스레드가 리더의 두 로드 사이에 동일한 nfsnode에서 _nfs_nget에 도달하면, 두 번째 로드는 새 포인터를 반환하지만 첫 번째 읽기 — 이미 오프셋을 계산하는 데 사용된 — 는 이전 포인터를 기반으로 했습니다. 이후의 역참조는 해제된 커널 힙에서 읽습니다.
26.7 수정:
fffffe000b9e3064 str x0, [x22, #0x168] ; cache ptr moved
fffffe000b9e306c str w28, [x22, #0x170] ; cache count moved
fffffe000b9e307c add x0, x22, #0x158 ; lock slot
fffffe000b9e3084 bl _lck_rw_init ; init RW lock
그리고 _nfs_vnop_access에서:
fffffe000b9f0200 add x0, x20, #0x158
fffffe000b9f0204 bl _lck_rw_lock_shared ; take the lock
fffffe000b9f0208 ldr x9, [x20, #0x168] ; cache pointer
...
fffffe000b9f0230 bl _lck_rw_unlock_shared ; release the lock
구조체 레이아웃 변경:
_nfs_nget 및 _nfs_vnop_access의 디스어셈블리 diff — docs/PATCH_DIFF.md 참조nfsnode+0x158+0x158에 lck_rw_t 삽입, 캐시 포인터를 +0x168로 이동, 읽기 주변에서 lck_rw_lock_shared 획득access(2)는 _nfs_vnop_access로 진입하고, stat(2)는 _nfs_getattr를 거쳐 취약한 함수에 도달하지 않음전체 문서는 docs/ANALYSIS.md를, 주소와 로그 샘플은 docs/ARTIFACTS.md를 참조하세요.
poc.sh — 단일 파일의 자체 포함 PoC:
nfsd를 중지127.0.0.1에서 시작noac로 새 마운트포인트에 익스포트를 마운트test -r / test -w를 통해 access(2)를 발행하는 per-user 해머를 생성nfs_vnop_access에 dtrace 프로브를 연결하여 호출 중간에 nfsnode+0x158이 변경되는 모든 호출을 기록_nfs_vnop_access의 동기화되지 않은 읽기가 단일 호출 중에 배열 변경을 관찰하는 것경쟁 조건은 노출의 전제 조건입니다. 경쟁 조건을 실제 누출로 전환하려면 공격자는 리더가 오래된 포인터를 사용하는 것을 관찰하고 해당 바이트를 읽을 수 있는 곳으로 전파해야 합니다. arm64e에서 nfsnode+0x158의 값은 PAC 서명되어 있고 부팅별 키는 사용자 영역에서 사용할 수 없으므로, dtrace만으로는 경쟁 조건을 관찰할 수 있지만 포인터를 디코딩할 수는 없습니다. docs/ANALYSIS.md의 "Paths tested and ruled out" 섹션을 참조하세요.
입증된 영향은 경쟁 조건 자체 — 26.7 RW-잠금 수정이 닫는 정확한 윈도우입니다.
dtrace 사용 가능 (일부 설치에서는 SIP 조정이 필요할 수 있음)python3, dscl, mountPoC는 전적으로 대상에서 실행됩니다. 악의적인 NFS 서버는 127.0.0.1에 바인딩되고 마운트는 루프백을 통해 이루어집니다. 이는 네트워크 구성 없이 PoC를 자체 포함적이고 재현 가능하게 유지합니다.
chmod +x poc.sh
sudo ./poc.sh
환경 변수를 통한 선택적 튜닝:
sudo HAMMER_COUNT=30 RUN_SECONDS=600 ./poc.sh
HAMMER_COUNT는 per-user 해머 스레드 수를 설정합니다 (존재하는 nfsuserNNN 계정을 사용하고, 필요에 따라 생성합니다). RUN_SECONDS는 dtrace 윈도우를 설정합니다.
[*] ensuring nfsuser accounts exist (UID 201..240)
nfsuser accounts available: 12
[*] starting evil NFS server on 127.0.0.1:2049
[*] mounting /Users/Shared/nfs_test (with noac)
mount check: hello.txt statable
[*] spawning up to 12 per-user threads + 1 root loop
started 12 user threads + 1 root loop
[*] running dtrace for 180s — looking for RACE lines
[*] stopping dtrace
[*] cleaning up
=================== SUMMARY ===================
RACE events caught: 16
Vulnerable interleaving observed — sample:
RACE nd=fffffe5f53cbb940 in=(0,0) out=(b0967e0023297878,0)
RACE nd=fffffe5f534db940 in=(0,0) out=(84dd7e0023297878,0)
RACE nd=fffffe5f53cbb940 in=(0,0) out=(c9a37e0023297878,0)
RACE nd=fffffe5f538ab940 in=(0,0) out=(1867e0023297878,0)
RACE nd=fffffe5f539db940 in=(0,0) out=(149bfe0023297878,0)
CVE-2026-43687 trigger SUCCESSFUL
===============================================
각 RACE 줄은 액세스 캐시 포인터가 호출 중간에 변경된 nfs_vnop_access의 한 호출입니다.
패치된 시스템(26.7 / 27)에서는 동일한 워크로드가 RACE 이벤트를 0건 생성합니다. 디스어셈블리 비교는 docs/PATCH_DIFF.md를 참조하세요.
PoC 서버의 두 가지 버그가 개발 중에 수정되었으며, 유사한 도구를 만드는 다른 사람들이 같은 문제를 겪지 않도록 여기에 문서화합니다:
ACCESS3resok는 fattr3가 아닌 post_op_attr를 요구합니다. RFC 1813은 응답을 post_op_attr obj_attributes; uint32 access;로 정의합니다. post_op_attr는 fattr3 앞에 bool 접두사를 포함합니다. 이 bool을 생략하면 응답이 4바이트 짧아지고, 클라이언트는 이를 조용히 거부하며 마운트는 사용 가능해지지 않습니다.
AppleDouble 이름(._*)에 대한 LOOKUP은 NFS3ERR_STALE(70)이 아닌 NFS3ERR_NOENT(2)를 반환해야 합니다. macOS는 정상적인 경로 해석 중에 ._<name> 사이드카를 탐색합니다. STALE을 반환하면 마운트가 오염됩니다.
둘 다 docs/ANALYSIS.md에 문서화되어 있습니다.
RACE 출력의 out 값은 서로 다른 nfsnode에 걸쳐 일정한 하위 48비트 패턴(...7e0023297878)을 가지며, 상위 16비트에서만 달라집니다. 이는 해당 필드가 원시 커널 포인터가 아니라 PAC 서명되었거나 난독화되었음을 확인시켜 줍니다. 사용자 영역에서 상태 전이(NULL → 채워짐)를 관찰할 수는 있지만, 커널의 부팅별 PAC 키 없이는 포인터를 디코딩하거나 역참조할 수 없습니다.
같은 이유로 KDK에 대한 심볼화는 이 값들에 유용하지 않습니다 — 이들은 텍스트 상대 주소가 아닙니다.
이 저장소는 방어적 보안 연구 및 교육 목적으로만 제공됩니다.
MIT. LICENSE 참조.
| 오프셋 | macOS 26.6 | macOS 26.7 |
|---|
+0x158 | 캐시 배열 포인터 | lck_rw_t |
+0x160 | 캐시 카운트 | (잠금의 일부) |
+0x168 | (기타) | 캐시 배열 포인터 |
+0x170 | (기타) | 캐시 카운트 |