
Reverse engineering notes and a self-contained PoC for the macOS NFS client access-cache race (CVE-2026-43687), with kext disassembly diff and dtrace race capture.
Independent reverse engineering of the macOS NFS client access-cache race (CVE-2026-43687), plus a working PoC that triggers the race and captures it live with dtrace.
The bug is an unsynchronized read of the nfsnode access-cache pointer
in _nfs_vnop_access. A malicious NFSv3 server can drive the access
cache to reallocate while another thread is reading it, producing a
kernel memory disclosure that a hostile server can influence. macOS 26.7
fixes this by inserting an lck_rw_t at nfsnode+0x158 and taking it
shared around the cache read.
| Field | Value |
|---|---|
| CVE | CVE-2026-43687 |
| Component | com.apple.filesystems.nfs (_nfs_vnop_access) |
| Affected | macOS Tahoe 26.6 and earlier, iOS 26.x and earlier |
| Patched in | macOS Tahoe 26.7, macOS Golden Gate 27, iOS 26.7, iOS 27 |
| Advisory impact | "Connecting to a malicious NFS server may disclose kernel memory." |
| CVSS v3.1 | 6.5 (Medium) — AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:N |
| Reported by | R4mbb of KRsecurity and Peter Malone (per Apple's advisory) |
Apple's advisory for CVE-2026-43687 documents the impact and the patch version. It does not document the technical mechanism:
nfsnode is racedaccess(2) rather than stat(2)No public technical writeup was found at the time of writing. This repository fills that gap with an independent reverse engineering analysis of the NFS kext between 26.6 and 26.7, and a working PoC that reproduces the race on a live target.
This is not a discovery claim. The CVE was reported by R4mbb and Peter Malone and patched by Apple. The contribution here is the technical analysis and the reproduction.
_nfs_vnop_access in the NFS client reads nfsnode+0x158 — the pointer
to the per-UID access-cache array — twice inside a single call,
without holding any lock:
; 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
The writer, _nfs_nget, reallocates the array with kalloc_data and
stores the result at +0x158 whenever the client sees a new UID from
the server:
fffffe000b52100c bl 0xfffffe000b6a1dd8 ; kalloc
fffffe000b521010 str x0, [x22, #0x158] ; cache ptr
fffffe000b521018 str w20, [x22, #0x160] ; cache count
If another thread reaches _nfs_nget on the same nfsnode between the
reader's two loads, the second load returns the new pointer while the
first read — already used to compute an offset — was based on the old
one. The subsequent dereference reads from freed kernel heap.
The 26.7 fix:
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
and in _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
Struct layout change:
_nfs_nget and _nfs_vnop_access between the
26.6 and 26.7 NFS kexts — see
docs/PATCH_DIFF.mdnfsnode+0x158 in 26.6lck_rw_t inserted at +0x158, cache
pointer moved to +0x168, lck_rw_lock_shared taken around the readaccess(2) enters _nfs_vnop_access; stat(2)
goes through _nfs_getattr and never reaches the vulnerable functionSee docs/ANALYSIS.md for the full writeup and
docs/ARTIFACTS.md for addresses and log samples.
poc.sh — a single-file, self-contained PoC:
nfsd to free port 2049127.0.0.1 that rotates the
reported UID in every replynoacaccess(2) via test -r /
test -wnfs_vnop_access, recording any call
where nfsnode+0x158 changes mid-call_nfs_vnop_access observing the array
change during a single callThe race is the precondition for the disclosure. To turn the race into
an actual leak, an attacker would need to observe the reader using the
stale pointer and propagate those bytes somewhere they can read. On
arm64e, the value at nfsnode+0x158 is PAC-signed and the per-boot key
is not available from userland, so dtrace alone can observe the race
but cannot decode the pointer. See the "Paths tested and ruled out"
section in docs/ANALYSIS.md.
The demonstrated impact is the race itself — the exact window that the 26.7 RW-lock fix closes.
dtrace available (may require SIP adjustment on some installs)python3, dscl, mountThe PoC runs entirely on the target. The malicious NFS server binds to
127.0.0.1 and the mount is over loopback. This keeps the PoC
self-contained and reproducible without network configuration.
chmod +x poc.sh
sudo ./poc.sh
Optional tuning via environment variables:
sudo HAMMER_COUNT=30 RUN_SECONDS=600 ./poc.sh
HAMMER_COUNT sets the number of per-user hammer threads (uses
whatever nfsuserNNN accounts exist, creating them as needed).
RUN_SECONDS sets the dtrace window.
[*] 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
===============================================
Each RACE line is one call of nfs_vnop_access where the access-cache
pointer changed mid-call.
On a patched system (26.7 / 27), the same workload produces zero RACE
events. See docs/PATCH_DIFF.md for the
disassembly comparison.
Two bugs in the PoC's server were fixed during development and are documented here so others building similar tooling don't hit them:
ACCESS3resok requires post_op_attr, not fattr3. RFC 1813
defines the reply as post_op_attr obj_attributes; uint32 access;.
post_op_attr includes a bool prefix before the fattr3. Omitting
that bool makes the reply 4 bytes short; the client silently rejects
it and the mount never becomes usable.
LOOKUP for AppleDouble names (._*) must return NFS3ERR_NOENT
(2), not NFS3ERR_STALE (70). macOS probes for ._<name> sidecars
during normal path resolution. Returning STALE poisons the mount.
Both are documented in docs/ANALYSIS.md.
The out values in the RACE output have a constant low-48-bit pattern
(...7e0023297878) across different nfsnodes, varying only in the high
16 bits. That confirms the field is PAC-signed or obfuscated, not a
raw kernel pointer. You can observe the state transition (NULL →
populated) from userland, but you cannot decode or dereference the
pointer without the kernel's per-boot PAC key.
For the same reason, symbolication against the KDK is not useful for these values — they aren't text-relative addresses.
This repository is provided for defensive security research and education only.
MIT. See LICENSE.
| Offset | macOS 26.6 | macOS 26.7 |
|---|
+0x158 | cache array pointer | lck_rw_t |
+0x160 | cache count | (part of lock) |
+0x168 | (other) | cache array pointer |
+0x170 | (other) | cache count |