Stale RC_REPSTATUS survives dup-reply-cache slot reuse: rc_status aliases the cached rc_reply mbuf pointer (union type confusion) β kernel heap-pointer material and wrong status-only replies served to unauthenticated remote clients on retransmission
Summary
nfsrv_getcache's LRU slot-reuse path (:243-263) clears only RC_REPMBUF and RC_NAM; RC_REPSTATUS - set once at :337-338 and never cleared anywhere in the tree - survives into the slot's next life. When the reused slot stores a full reply via the v3/v2-non-repstat branch (:340-347, which also fails to clear it), the entry carries RC_REPSTATUS|RC_REPMBUF simultaneously while the rc_un union holds the mbuf pointer. On a cache hit the RC_REPSTATUS test at :212 runs first, so nfs_rephead is served the low 32 bits of rp->rc_reply: bit 30 set => MSG_DENIED/rpc_autherr carrying err & ~NFSERR_AUTH_ERR (0x40000000) raw on the wire - 31 bits of a kernel heap pointer to an unauthenticated UDP client; otherwise nfsrv_errmap clamps it to the per-proc default, producing a wrong status-only reply instead of the cached reply - the duplicate-reply cache misreports completed non-idempotent operations (REMOVE/RMDIR/RENAME/SYMLINK/MKNOD/MKDIR/WRITE/SETATTR/CREATE) to retransmitting clients. Any network peer that can send UDP to the NFS server - no AUTH_SYS credentials, no valid filehandle, no export access needed (entries created before authorization). Attacker fully controls slot poisoning (v2 repstat plants, NULL fillers age to LRU head, one v3 nonidempotent per slot captures the stale flag, retransmit triggers the confused read). VERIFIED 3x in-guest as unpriv local user against loopback nfsd (192+192+256 poisoned slots, 100% divergence, discrimination-proven via errmap whitelist: observed status 5-default where genuine stale status 13 is whitelisted); control resends byte-identically; fix validated on rebuilt kernel (0 corruptions, byte-identical resends). Over a LAN fully remote and spoofable.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-3213 Β· 13 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 5.0 KB | β raw | |
| VERDICT.md | β | 4.8 KB | β raw | |
| leakforger.c | β | 8.9 KB | view raw | |
| build.sh | β | 162 B | view raw | |
| run.sh | β | 190 B | view raw | |
| build.log | β | 57 B | view raw | |
| run.log | β | 18.6 KB | view raw | |
| run.2.log | β | 18.6 KB | view raw | |
| run.3.log | β | 24.5 KB | view raw | |
| run.fixed.log | β | 11.6 KB | view raw | |
| env.txt | β | 388 B | view raw | |
| fix.diff | β | 631 B | view raw | |
| verdict.json | β | 5.7 KB | view raw |
DF-3213 β stale RC_REPSTATUS survives dup-reply-cache slot reuse β rc_status/rc_reply union type confusion β kernel mbuf pointer material + wrong replies sent to an unauthenticated remote NFS client
File: sys/vfs/nfs/nfs_srvcache.c (pass 2)
What this is
The NFS server duplicate-reply cache (nfsrv_getcache / nfsrv_updatecache)
reuses LRU slots when the cache is full. Slot reuse clears only RC_REPMBUF
and RC_NAM:
} else {
rp = TAILQ_FIRST(&nfsrvlruhead);
...
rp->rc_flag |= RC_LOCKED;
LIST_REMOVE(rp, rc_hash);
TAILQ_REMOVE(&nfsrvlruhead, rp, rc_lru);
if (rp->rc_flag & RC_REPMBUF) { /* nfs_srvcache.c:253 */
m_freem(rp->rc_reply);
...
}
if (rp->rc_flag & RC_NAM) { ... }
}
RC_REPSTATUS (set at nfs_srvcache.c:338) is never cleared anywhere in the
tree (grep: the only clear sites for flags are REPMBUF/NAM). When the reused
slot later stores a full reply mbuf via the v3 (or v2-non-repstat) branch
(nfs_srvcache.c:340-347 β which also does not clear RC_REPSTATUS), the entry
ends up with both RC_REPSTATUS|RC_REPMBUF set and the union
rc_un/rc_haddr holding an mbuf *.
On a cache hit, nfsrv_getcache tests RC_REPSTATUS first
(nfs_srvcache.c:212), so the retransmission is served by:
nfs_rephead(0, nd, slp, rp->rc_status, repp, &mb, &bpos);
where rp->rc_status (int) aliases the low 32 bits of rp->rc_reply
(struct mbuf *) β struct nfsrvcache unions them (nfsrvcache.h:54-65).
nfs_rephead (nfs_socket.c:1614-1620) then either
- emits the raw value with only bit 30 masked to the wire
(
NFSERR_AUTHERR= 0x40000000, nfsproto.h:100 β MSG_DENIED / rpc_autherr /err & ~NFSERR_AUTHERR), when bit 30 of the pointer is set β a 31-bit kernel heap pointer disclosure, or - clamps it through
nfsrv_errmap(whitelist / NFSERR_IO default), producing a status-only reply that is not the cached reply at all β the duplicate reply cache answers retransmissions of non-idempotent NFS operations with a wrong, structurally different result.
Attacker: any UDP client that can reach the NFS server (no credentials, no filehandle, no export access needed β every request that parses creates a cache entry before authorization runs).
PoC
leakforger.c β raw RPC/UDP forger, run as unprivileged nobody from
127.0.0.1 against the in-guest kernel nfsd (UDP):
- Phase A β
MNFSv2 SYMLINK (wire proc 13 β generic 10) requests with distinct xids and an all-zero filehandle. v2 SYMLINK is nonidempotent and v2-repstat, so each reply is cached withRC_REPSTATUS,rc_status = 13(EACCES; the phase-A reply shows status0x0d). - Phase B β
2048 - Mv2 NULL fillers filldesirednfsrvcache(= min(nmbclusters/2, 2048) = 2048 on this guest, nmbclusters=33296); the M stale entries become the LRU-oldest. - Phase C β
MNFSv3 REMOVE (wire proc 12 β generic 12) requests with distinct xids; each MISS evicts one stale entry and reuses the slot (RC_REPSTATUS survives); the v3 completion stores the reply mbuf (RC_REPMBUF,rc_reply = m_copym(...)). - Phase D β retransmit each v3 REMOVE. The hit takes the RC_REPSTATUS
branch and answers from
rc_status= low-32 of the cached mbuf pointer. - Control β a fresh v3 REMOVE in a clean slot: its retransmit is
byte-identical (legit
RC_REPMBUFresend), proving the divergence above is the stale-flag bug and not normal behavior.
Build (in-guest)
sh build.sh # pushes leakforger.c, cc -O2 -Wall
Run (in-guest, root starts nfsd; forger runs as nobody)
sh run.sh # /sbin/nfsd -u -n 4 ; su -m nobody -c /tmp/leakforger 192 2048
Expected / observed (stock INVARIANTS kernel, DF 6.5-DEVELOPMENT)
- Phase-A replies: status 13 (
0x0d) β EACCES for the zero filehandle. - Phase-D retransmits: all 192/192 (and 192/192, 256/256 in repeats)
diverge from the original reply: instead of the cached v3 error reply the
server returns a 7-word status-only reply whose NFS status is 5
(NFSERR_IO) β the
nfsrv_errmapdefault for a non-whitelisted value. This is the discrimination proof: the genuine stale status would have been 13, which is whitelisted for REMOVE and would have been returned unchanged; only a non-small value (the aliasedrc_replymbuf pointer low-32) clamps to 5. The reply to a retransmission of a non-idempotent operation is therefore (a) wrong and (b) derived from a kernel pointer. - The raw 31-bit pointer escape (MSG_DENIED/auth_stat =
ptr & 0xBFFFFFFF) requires bit 30 of the mbuf address to be set; across 640 poisoned slots on this guest the mbuf zone never had bit 30 set, so the raw value was always clamped. The mechanism is placement-dependent β on systems whose mbuf zone crosses the bit-30 band the raw low-32 (minus bit 30, which is then known) is disclosed to any LAN client. - Control entry: byte-identical resend (expected).
Exit code 2 = corruptions reproduced; 0 = raw leak fired; 1 = nothing.
DF-3213 β VERDICT
Status: reproduced. Impact: leak (mechanism proven; raw pointer value gated on heap bit-30 placement) + duplicate-reply-cache integrity corruption. Confidence: certain.
Root cause (line-accurate)
sys/vfs/nfs/nfs_srvcache.c:337-338βrc_status = nd->nd_repstat; rp->rc_flag |= RC_REPSTATUS;is the only place RC_REPSTATUS is set.- No code path in the tree clears RC_REPSTATUS. Slot reuse
(
nfs_srvcache.c:243-263) clears onlyRC_REPMBUF(:253-257) andRC_NAM(:258-262). The mbuf-store branch ofnfsrv_updatecache(:340-347) clearsRC_REPMBUFbut notRC_REPSTATUS. sys/vfs/nfs/nfsrvcache.h:54-65βrc_reply(mbuf*) andrc_status(int) are one union (rc_un), so once the reused entry stores a reply mbuf,rc_statusreads the low 32 bits of the mbuf pointer.sys/vfs/nfs/nfs_srvcache.c:212-216β the cache-hit path testsRC_REPSTATUSbeforeRC_REPMBUFand callsnfs_rephead(0, nd, slp, rp->rc_status, ...).sys/vfs/nfs/nfs_socket.c:1614-1620βnfs_repheadcheckserr & NFSERR_AUTHERR(0x40000000,nfsproto.h:100); if bit 30 of the pointer is set it replies MSG_DENIED / rpc_autherr witherr & ~NFSERR_AUTHERRraw on the wire (31 pointer bits). Otherwise the value is clamped bynfsrv_errmap(nfs_subs.c:1481-1501) to the per-proc default (NFSERR_IO=5 for v3 REMOVE) β a structurally wrong, status-only reply that is not the cached reply.
How it was reproduced
In-guest (DragonFly dfbsd 6.5-DEVELOPMENT #0 β¦ X86_64_GENERIC,
INVARIANTS kernel): /sbin/nfsd -u -n 4, /etc/exports present but
irrelevant (no valid filehandle needed). leakforger run as nobody over
UDP loopback:
- 192 v2 SYMLINK (wire proc 13) requests, zero fh β 192 entries with
RC_REPSTATUS,
rc_status=13(reply status0x0dobserved). - 1856 v2 NULL fillers β cache full at
desirednfsrvcache=2048(nmbclusters=33296 β 16648 β capped 2048;nfsrvcache_size_change, nfs_srvcache.c:129-135). - 192 v3 REMOVE (wire proc 12) requests β each evicts one stale entry
(LRU head), reuses the slot with RC_REPSTATUS still set, and stores the
reply mbuf β
RC_REPSTATUS|RC_REPMBUFcoexist, union holds the pointer. - Retransmit each v3 REMOVE β 192/192 answered with a 7-word
status-only reply
[xid][1][0][0][0][0][5]instead of the cached reply. Repeats: 192/192 and 256/256 (run.log,run.2.log,run.3.log).
Why status=5 proves pointer aliasing (not merely a stale status)
The genuine stale value is 13 (EACCES). nfsrv_errmap for v3 REMOVE walks
nfsv3err_remove[] = {IO, NOENT, IO, ACCES(13), β¦, STALE, β¦} and returns an
exact match unchanged; 13 would have been returned as 13. The observed 5 is
the table default, reachable only for a value that matches no whitelist
entry β i.e. rc_status was no longer the small stale errno but the aliased
rc_reply mbuf pointer's low 32 bits. The control (fresh entry in a clean
slot) resends byte-identically, ruling out re-execution noise.
Impact assessment (honest)
- Proven on this guest (every run): the duplicate reply cache answers retransmissions of non-idempotent operations (REMOVE/RMDIR/RENAME/ SYMLINK/MKNOD/MKDIR/WRITE/SETATTR/CREATE) with a wrong, status-only reply derived from a kernel pointer. Client-visible incorrect NFS results; breaks the exactly-once semantics the cache exists to provide.
- Mechanism-proven, placement-gated: when the cached-reply mbuf lands at
an address whose bit 30 is set,
err & ~0x40000000puts 31 bits of the kernel heap pointer into the reply of an unauthenticated remote client. Across 640 poisoned slots on this guest the mbuf zone never had bit 30 set (always clamped), so the raw value was not extracted here; the disclosure is real but conditional on heap placement (memory size/layout). - Not memory corruption; no write primitive; not a panic. The
mreq == NULLno-reply path (nfs_syscalls.c:683-684) leaves INPROG entries that DROP retransmits, but that is cache semantics, not a defect beyond this one.
Fix validation
fix.diff clears RC_REPSTATUS on slot reuse (:253ff) and in the mbuf-store
branch of nfsrv_updatecache (:340ff). Applied to the guest's /usr/src,
kernel rebuilt with make nativekernel, rebooted, PoC re-run:
retransmits then return byte-identical cached replies (0 corruptions)
and the control behaves the same. See fix_baseline_reproduced /
fix_patched_reproduced fields and run.fixed.log.
Fix status (batch validation)
Applied with the 36 sibling fix.diffs of this batch in findings/poc/_batch41/all_fixes.patch; single in-guest make -j6 nativekernel KERNCONF=X86_64_GENERIC completed RC=0 with zero errors under -Werror (full log: findings/poc/_batch41/fix_build8.log). Behavior A/B intentionally skipped for this Info-tier item.
Fix verification
fixedfix.diff applied to in-guest /usr/src, kernel rebuilt (make nativekernel) and installed as #1. Baseline stock kernel: 192/192 corrupted status-only replies (status=5). Patched kernel: 0 corruptions, 0 leaks, 192/192 byte-identical cached-reply resends; control unchanged. Bad behavior eliminated.
['findings/poc/DF-3213/run.fixed.log (0 CORRUPT/LEAK, 192 identical)', 'findings/poc/DF-3213/fix.diff']
Confirmed kernel references
Detail
Exploit chain
unauth LAN/UDP client: (1) send M distinct NFSv2 SYMLINK (wire proc 13, zero fh) requests -> M cache entries with RC_REPSTATUS, rc_status=13; (2) send 2048-M v2 NULL fillers to fill desirednfsrvcache=2048 so the M entries are LRU-oldest; (3) send M distinct NFSv3 REMOVE (wire proc 12, zero fh) requests -> each miss evicts one stale entry and reuses the slot (RC_REPSTATUS retained), completion stores the reply mbuf -> both flags set, union holds mbuf ptr; (4) retransmit each v3 REMOVE -> RC_REPSTATUS branch -> nfs_rephead(0, nd, slp, rc_status=low32(rc_reply)) -> bit30 set: MSG_DENIED + 31 raw pointer bits; bit30 clear: status-only reply with errmap default (observed 192/192+192/192+256/256). Control entry in a clean slot resends byte-identically. No write primitive; escalation not applicable (info leak + integrity).
Evidence (decisive lines)
["findings/poc/DF-3213/run.log: phase A [0] reply status 0x0d (13/EACCES stored in rc_status); phase D 192/192 'CORRUPT ... status=5' with full reply words; control byte-identical", 'findings/poc/DF-3213/run.2.log, run.3.log: 192/192 and 256/256 reproductions on fresh nfsd restarts', 'findings/poc/DF-3213/run.fixed.log: patched kernel -> 0 CORRUPT/LEAK, 192 identical resends', 'findings/poc/DF-3213/fix.diff: clear RC_REPSTATUS in reuse path + mbuf-store branch', 'VERDICT.md: full discrimination argument (13 whitelisted vs 5 default)']
PoC changes
Original sketch had wrong wire proc numbers (v2 SYMLINK is wire 13 not 10 β v2 numbering skips 7/WRITECACHE; v3 REMOVE is wire 12 not 10 β the NFSPROC_* enum is the generic=v3 numbering) and truncated argument tails (missing the 8-word v2 filehandle / 16-word v3 fh), which triggered the NEGREPLYOUT rv==-1 path (nfsmout with mreq==NULL): no reply, no updatecache, entry stuck RC_INPROG dropping retransmits. Fixed arg shapes; added phase-A/C reply capture, the r[6] status-word discrimination print, and the clean-slot control.
Verified recommended fix
Clear RC_REPSTATUS when an LRU slot is reused for a new request and before storing a reply mbuf in nfsrv_updatecache (findings/poc/DF-3213/fix.diff).
Verdict
Stale RC_REPSTATUS survives LRU slot reuse in the NFS server duplicate-reply cache (set at nfs_srvcache.c:337-338, never cleared anywhere; the reuse path :253-262 clears only RC_REPMBUF|RC_NAM and the mbuf-store branch :340-347 does not clear it either). A reused slot that then stores a v3 (or v2-non-repstat) reply mbuf ends up with RC_REPSTATUS|RC_REPMBUF both set, and nfsrv_getcache tests RC_REPSTATUS first (:212), serving retransmissions from rc_status β which unions rc_reply (nfsrvcache.h:54-65), i.e. the low 32 bits of the cached reply mbuf pointer. nfs_rephead (nfs_socket.c:1614-1620) transmits err & ~0x40000000 raw (31 pointer bits) when bit 30 of the pointer is set, else nfsrv_errmap clamps it to the per-proc default. Reproduced 3x in-guest (192+192+256 poisoned slots, 100%): every retransmission of a non-idempotent v3 REMOVE is answered with a wrong status-only reply whose status is the errmap default (5), not the genuine stale status (13, whitelisted, would map to itself) β proving the value read was the aliased kernel pointer, not the stored status. Raw pointer bits did not escape on this guest (mbuf zone never had bit 30 set across 640 samples), so the disclosure content is heap-placement-dependent; the duplicate-reply-cache correctness break (wrong replies to retransmissions of non-idempotent ops) reproduces deterministically. Unauthenticated remote (UDP, no creds, no valid fh, no export access). fix.diff (clear RC_REPSTATUS on reuse and on mbuf-store) validated: rebuilt kernel, 0 corruptions, byte-identical resends.
No comments yet.