β¬’ DragonFlyBSD Kernel Audit
← triage Β· dashboard
DF-3213

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3213 Β· 13 files
FileTypeDescriptionSize
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):

  1. Phase A β€” M NFSv2 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 with RC_REPSTATUS, rc_status = 13 (EACCES; the phase-A reply shows status 0x0d).
  2. Phase B β€” 2048 - M v2 NULL fillers fill desirednfsrvcache (= min(nmbclusters/2, 2048) = 2048 on this guest, nmbclusters=33296); the M stale entries become the LRU-oldest.
  3. Phase C β€” M NFSv3 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(...)).
  4. 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.
  5. Control β€” a fresh v3 REMOVE in a clean slot: its retransmit is byte-identical (legit RC_REPMBUF resend), 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_errmap default 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 aliased rc_reply mbuf 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.

VERDICT.md
↓ download raw

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 only RC_REPMBUF (:253-257) and RC_NAM (:258-262). The mbuf-store branch of nfsrv_updatecache (:340-347) clears RC_REPMBUF but not RC_REPSTATUS.
  • sys/vfs/nfs/nfsrvcache.h:54-65 β€” rc_reply (mbuf*) and rc_status (int) are one union (rc_un), so once the reused entry stores a reply mbuf, rc_status reads the low 32 bits of the mbuf pointer.
  • sys/vfs/nfs/nfs_srvcache.c:212-216 β€” the cache-hit path tests RC_REPSTATUS before RC_REPMBUF and calls nfs_rephead(0, nd, slp, rp->rc_status, ...).
  • sys/vfs/nfs/nfs_socket.c:1614-1620 β€” nfs_rephead checks err & NFSERR_AUTHERR (0x40000000, nfsproto.h:100); if bit 30 of the pointer is set it replies MSG_DENIED / rpc_autherr with err & ~NFSERR_AUTHERR raw on the wire (31 pointer bits). Otherwise the value is clamped by nfsrv_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:

  1. 192 v2 SYMLINK (wire proc 13) requests, zero fh β†’ 192 entries with RC_REPSTATUS, rc_status=13 (reply status 0x0d observed).
  2. 1856 v2 NULL fillers β†’ cache full at desirednfsrvcache=2048 (nmbclusters=33296 β†’ 16648 β†’ capped 2048; nfsrvcache_size_change, nfs_srvcache.c:129-135).
  3. 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_REPMBUF coexist, union holds the pointer.
  4. 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 & ~0x40000000 puts 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 == NULL no-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

fixed
baseline reproduced→ patch + rebuild →patched clean

fix.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']
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #1: Tue Sep 8 15:01:06 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

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.