
Note di reverse engineering e un PoC self-contained per la race della access-cache del client NFS di macOS (CVE-2026-43687), con diff del disassemblaggio del kext e cattura della race tramite dtrace.
Reverse engineering indipendente della race sulla access-cache del client NFS di macOS (CVE-2026-43687), più un PoC funzionante che innesca la race e la cattura in tempo reale con dtrace.
Il bug è una lettura non sincronizzata del puntatore alla access-cache
del nfsnode in _nfs_vnop_access. Un server NFSv3 malevolo può
costringere la access-cache a riallocare mentre un altro thread la sta
leggendo, producendo una divulgazione di memoria del kernel che un server
ostile può influenzare. macOS 26.7 corregge questo inserendo un
lck_rw_t a nfsnode+0x158 e acquisendolo in modalità shared attorno
alla lettura della cache.
| Campo | Valore |
|---|---|
| CVE | CVE-2026-43687 |
| Componente | com.apple.filesystems.nfs (_nfs_vnop_access) |
| Affetto | macOS Tahoe 26.6 e precedenti, iOS 26.x e precedenti |
| Corretto in | macOS Tahoe 26.7, macOS Golden Gate 27, iOS 26.7, iOS 27 |
| Impatto dell'advisory | "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 |
| Segnalato da | R4mbb di KRsecurity e Peter Malone (secondo l'advisory di Apple) |
L'advisory di Apple per CVE-2026-43687 documenta l'impatto e la versione della patch. Non documenta il meccanismo tecnico:
nfsnode è soggetto alla raceaccess(2) invece di stat(2)Al momento della stesura non è stato trovato alcun writeup tecnico pubblico. Questo repository colma tale lacuna con un'analisi di reverse engineering indipendente del kext NFS tra 26.6 e 26.7, e un PoC funzionante che riproduce la race su un target live.
Questa non è una rivendicazione di scoperta. Il CVE è stato segnalato da R4mbb e Peter Malone e corretto da Apple. Il contributo qui è l'analisi tecnica e la riproduzione.
_nfs_vnop_access nel client NFS legge nfsnode+0x158 — il puntatore
all'array della access-cache per-UID — due volte all'interno di una
singola chiamata, senza tenere alcun 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
Il writer, _nfs_nget, rialloca l'array con kalloc_data e memorizza
il risultato a +0x158 ogni volta che il client vede un nuovo UID dal
server:
fffffe000b52100c bl 0xfffffe000b6a1dd8 ; kalloc
fffffe000b521010 str x0, [x22, #0x158] ; cache ptr
fffffe000b521018 str w20, [x22, #0x160] ; cache count
Se un altro thread raggiunge _nfs_nget sullo stesso nfsnode tra le
due load del reader, la seconda load restituisce il nuovo puntatore
mentre la prima lettura — già usata per calcolare un offset — era basata
su quello vecchio. La successiva dereferenziazione legge dall'heap del
kernel liberato.
La correzione in 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
e 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
Modifica del layout della struct:
_nfs_nget e _nfs_vnop_access tra i
kext NFS 26.6 e 26.7 — vedi
docs/PATCH_DIFF.mdnfsnode+0x158 in 26.6lck_rw_t inserito a +0x158,
puntatore della cache spostato a +0x168, lck_rw_lock_shared
acquisito attorno alla letturaaccess(2) entra in _nfs_vnop_access;
stat(2) passa attraverso _nfs_getattr e non raggiunge mai la
funzione vulnerabileVedi docs/ANALYSIS.md per il writeup completo e
docs/ARTIFACTS.md per gli indirizzi e i campioni
di log.
poc.sh — un PoC self-contained in un singolo file:
nfsd di Apple per liberare la porta 2049127.0.0.1 che ruota l'UID
riportato in ogni rispostanoacaccess(2) tramite
test -r / test -wnfs_vnop_access, registrando ogni
chiamata in cui nfsnode+0x158 cambia a metà chiamata_nfs_vnop_access che osserva
l'array cambiare durante una singola chiamataLa race è la precondizione per la divulgazione. Per trasformare la race
in una fuga effettiva, un attaccante dovrebbe osservare il reader che
usa il puntatore stale e propagare quei byte da qualche parte dove può
leggerli. Su arm64e, il valore a nfsnode+0x158 è PAC-signed e la
chiave per-boot non è disponibile dall'userland, quindi dtrace da solo
può osservare la race ma non può decodificare il puntatore. Vedi la
sezione "Paths tested and ruled out" in
docs/ANALYSIS.md.
L'impatto dimostrato è la race stessa — esattamente la finestra che la correzione con RW-lock in 26.7 chiude.
dtrace disponibile (può richiedere l'aggiustamento di SIP su alcune
installazioni)python3, dscl, mountIl PoC gira interamente sul target. Il server NFS malevolo si lega a
127.0.0.1 e il mount avviene su loopback. Questo mantiene il PoC
self-contained e riproducibile senza configurazione di rete.
chmod +x poc.sh
sudo ./poc.sh
Tuning opzionale tramite variabili d'ambiente:
sudo HAMMER_COUNT=30 RUN_SECONDS=600 ./poc.sh
HAMMER_COUNT imposta il numero di thread hammer per-utente (usa
qualunque account nfsuserNNN esista, creandoli se necessario).
RUN_SECONDS imposta la finestra di 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
===============================================
Ogni riga RACE è una chiamata di nfs_vnop_access in cui il
puntatore della access-cache è cambiato a metà chiamata.
Su un sistema corretto (26.7 / 27), lo stesso carico di lavoro produce
zero eventi RACE. Vedi docs/PATCH_DIFF.md per il
confronto del disassemblato.
Due bug nel server del PoC sono stati corretti durante lo sviluppo e sono documentati qui affinché altri che costruiscono tooling simile non li incontrino:
ACCESS3resok richiede post_op_attr, non fattr3. La RFC 1813
definisce la risposta come post_op_attr obj_attributes; uint32 access;.
post_op_attr include un prefisso bool prima del fattr3. Omettere
quel bool rende la risposta 4 byte più corta; il client la rifiuta
silenziosamente e il mount non diventa mai utilizzabile.
LOOKUP per i nomi AppleDouble (._*) deve restituire
NFS3ERR_NOENT (2), non NFS3ERR_STALE (70). macOS sonda i
sidecar ._<name> durante la normale risoluzione dei path.
Restituire STALE avvelena il mount.
Entrambi sono documentati in docs/ANALYSIS.md.
I valori out nell'output RACE hanno un pattern costante nei 48 bit
bassi (...7e0023297878) tra nfsnode diversi, variando solo nei 16 bit
alti. Questo conferma che il campo è PAC-signed o offuscato, non un
puntatore raw del kernel. Puoi osservare la transizione di stato (NULL →
popolato) dall'userland, ma non puoi decodificare o dereferenziare il
puntatore senza la chiave PAC per-boot del kernel.
Per lo stesso motivo, la simbolizzazione rispetto al KDK non è utile per questi valori — non sono indirizzi text-relative.
Questo repository è fornito esclusivamente per ricerca di sicurezza difensiva e scopi educativi.
MIT. Vedi LICENSE.
| Offset | macOS 26.6 | macOS 26.7 |
|---|
+0x158 | puntatore all'array della cache | lck_rw_t |
+0x160 | conteggio della cache | (parte del lock) |
+0x168 | (altro) | puntatore all'array della cache |
+0x170 | (altro) | conteggio della cache |