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

Heap OOB write in NFS READDIR/READDIRPLUS reply XDR null-padding β€” missing nfsm_clget before pad bytes

Summary

nfsrv_readdir :3186-3188 and nfsrv_readdirplus :3525-3526: for(i=0;i<rem;i++) *bp++ = 0 WITHOUT calling nfsm_clget first. Name-copy loop :3174-3185 advances bp += tsiz where tsiz = min(xfer,be-bp). When filename fills remaining cluster space exactly (xfer == be-bp) bp becomes == be (cluster end MCLBYTES=2048). Subsequent pad loop writes 1-3 NUL bytes at bp==be one-past-cluster into adjacent kernel heap. nfsm_clget at :3189/:3527 called AFTER OOB already happened too late. rem = nfsm_rndup(nlen)-nlen in {1,2,3} for names not multiple of 4. Remote NFS client with read access to export triggers: create files with names crafted so one ends exactly at cluster boundary. Each v3 dirent entry = 28+nlen+rem bytes. ~60-255 files to align first 2048-byte boundary attacker controls alignment deterministically. Impact: 1-3 NUL bytes into adjacent mbuf cluster corrupt objcache freelist panic on next m_getcl or silently corrupt adjacent connection data. Fix: call nfsm_clget inside the pad loop before each *bp++.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0766 Β· 15 files
FileTypeDescriptionSize
reach.c trigger-source faithful cursor simulation + exhaustive/random brute force; proves OOB state unreachable (0 hits, both variants) and prints alignment invariant 6.6 KB view raw
nfs_pad_oob.c trigger-source structural proof the pad loop is unguarded (forced bp==be -> red-zone write); shows the code gap is real though unreachable live 10.9 KB view raw
sizes.c trigger-source verifies sizeof(struct flrep)=132 (mod4=0) and NFSX_V3FH=28 against kernel headers 790 B view raw
build.sh build-script cc -O2 -Wall -o reach reach.c 521 B view raw
run.sh run-script ./reach 183 B view raw
live_test.sh run-script NFS server setup + 800 listings of 720-file varied-name dir; no panic on #0 and #1 2.3 KB view raw
run.log run-log reach output: UNREACHABLE for both variants 1.2 KB view raw
fix_run.log run-log live_test.sh on single-fix kernel #1: 800 passes, no panic 364 B view raw
fix_build.log build-log full make -j6 nativekernel of single-fix kernel (rc=0) 5.6 MB ↓ download
fix.diff suggested-fix defense-in-depth: add nfsm_clget before each pad byte in READDIR + READDIRPLUS 731 B view raw
env.txt environment uname, cc version, kernel version 390 B view raw
VERDICT.md verdict full narrative: mechanism, why-unreachable, fix validation 5.9 KB ↓ raw
README.md readme pack overview + reproduce instructions 2.3 KB ↓ raw
../fix_build_combined.log build-log Combined 41-finding kernel build (rc=0, -Werror clean) 5.6 MB ↓ download
../fix_build_summary.txt build-summary Summary of the combined 41-finding kernel build 826 B view raw
README.md readme pack overview + reproduce instructions
↓ download raw

DF-0766 β€” NFS READDIR/READDIRPLUS reply XDR null-pad OOB (verification pack)

Result: NOT REPRODUCED β€” the claimed heap OOB write is UNREACHABLE. Impact: none.

The finding claims the NFS server's READDIR/READDIRPLUS reply encoder appends XDR null-pad bytes without first calling nfsm_clget, so a directory entry whose name fills a 2048-byte mbuf cluster exactly would cause 1..3 NUL bytes to be written past the cluster into adjacent kernel heap.

Verification (source trace + simulation + live test) shows this state is unreachable: the XDR 4-byte alignment invariant keeps every entry name from ever ending exactly on a cluster boundary when padding is needed, so the pad bytes always have room. The missing nfsm_clget is a real latent defense-in-depth gap (the only unguarded write site in the encoder) but has no achievable security impact on current code.

Files

file purpose
reach.c faithful cursor simulation + exhaustive/random brute force over directory layouts; prints the alignment-invariant proof. Definitive: 0 OOB-state entries ever, both variants.
nfs_pad_oob.c structural demonstration that the pad loop is unguarded (forced bp==be β†’ red-zone write); proves the code gap is real even though unreachable live.
sizes.c verifies sizeof(struct flrep)=132 (mod4=0) and NFSX_V3FH=28 against kernel headers.
build.sh / run.sh build & run reach.
live_test.sh sets up NFS server, exports, mounts, runs 800 listings of a 720-file dir (names β‰  0 mod 4); no panic on unpatched #0 or patched #1.
run.log reach output (unreachability proof).
fix_run.log live_test.sh on the single-fix kernel #1.
fix_build.log full make nativekernel log of the single-fix kernel (rc=0).
fix.diff defense-in-depth: add nfsm_clget before each pad byte (READDIR + READDIRPLUS). git-apply-able.
env.txt guest uname / cc / kernel version.
VERDICT.md full narrative + mechanism + why-it-fails + fix validation.
manifest.json machine-readable catalog.

Reproduce (on the guest)

sh build.sh && sh run.sh        # maxx: reachability analysis -> UNREACHABLE
cc -o sizes sizes.c && ./sizes  # maxx: struct-size verification
sh live_test.sh                 # root: live NFS readdir corroboration (no panic)
VERDICT.md verdict full narrative: mechanism, why-unreachable, fix validation
↓ download raw

DF-0766 β€” Heap OOB write in NFS READDIR/READDIRPLUS reply XDR null-padding (missing nfsm_clget before pad bytes)

Status: NOT REPRODUCED β€” claim is UNREACHABLE (false positive for the security impact). Impact: none. The missing nfsm_clget guard is a genuine latent defense-in-depth gap, but the claimed heap OOB write cannot occur because the NFS XDR 4-byte alignment invariant guarantees the entry name never ends exactly on a 2048-byte mbuf-cluster boundary when padding is needed.

What the finding claims

In sys/vfs/nfs/nfs_serv.c, both nfsrv_readdir (READDIR) and nfsrv_readdirplus (READDIRPLUS) finish each directory entry by null-padding the file name up to an int32_t boundary:

/* And null pad to a int32_t boundary */            // nfs_serv.c:3186 / 3524
for (i = 0; i < rem; i++)
    *bp++ = '\0';                                   // <-- claimed OOB write
tl = nfsm_clget(&info, mp1, mp2, bp, be);           // <-- called AFTER, "too late"

rem = nfsm_rndup(nlen) - nlen is 1..3 when the name length is not a multiple of 4. Every other write site in the encoder calls the nfsm_clget macro (nfsm_subs.h:200 β†’ _nfsm_clget, nfsm_subs.c:968) first, which β€” when the write cursor bp has reached the cluster end be β€” allocates a fresh MCLBYTES (=2048) cluster and resets bp/be. The pad loop does not, so the finding asserts that if a name fills the cluster exactly (bp == be after the name copy) the pad writes 1..3 NUL bytes one-past the cluster into adjacent kernel heap.

Why it does NOT reproduce β€” the alignment invariant

The encoder writes only XDR 4-byte-aligned units. Verified on this guest (sizes.c against the real kernel headers):

quantity value mod 4
MCLBYTES (cluster) 2048 (MCLSHIFT=11) 0
reply header H (RPC reply 28 + postopattr 88 + cookieverf 8) 124 0
READDIR entry header words (nfs_true/fileid_hi/fileid_lo/nlen) 16 0
READDIR entry cookie words 8 0
sizeof(struct flrep) (READDIRPLUS per-entry payload) 132 0
NFSX_V3FH = sizeof(fhandle_t) = 8 + 20 (fid is __packed) 28 0

Therefore every term that locates the name is a multiple of 4 except nlen itself. For entry i:

name_end_i = H + Ξ£(prior entry totals) + 16 + nlen_i
           ≑ nlen_i   (mod 4)

The pad loop reaches bp == be only when name_end_i falls exactly on a cluster boundary, i.e. name_end_i ≑ 0 (mod 2048) β‡’ name_end_i ≑ 0 (mod 4) β‡’ nlen_i ≑ 0 (mod 4) β‡’ rem_i = nfsm_rndup(nlen_i) βˆ’ nlen_i = 0. So whenever padding is actually needed (rem > 0), the name never ends on a cluster boundary, bp < be strictly, and the pad bytes always fit inside the current cluster. The OOB state is unreachable. This holds for NFSv2 and v3, and for both READDIR and READDIRPLUS.

(The name-copy loop can cross a cluster boundary mid-name, but it is correctly guarded per chunk; after such a crossing bp is reset to the start of a fresh cluster and then advanced only by the remaining ≀255 name bytes, so it ends far inside the new cluster, not at be.)

Evidence

  1. reach.c β€” faithful cursor simulation across 2048-byte clusters plus an exhaustive + 400 000-trial random brute force over directory layouts, for both READDIR and READDIRPLUS. Max OOB-state entries found: 0 in every variant. Also prints the invariant proof. (build.sh && run.sh.)
  2. nfs_pad_oob.c β€” demonstrates that the pad loop is structurally unguarded (if you forcibly place bp == be, the pad writes into a red zone), confirming the code-quality gap is real even though the state is unreachable in the live encoder.
  3. live_test.sh β€” sets up the guest's NFS server (rpcbind/mountd/ nfsd), exports /export, mounts over loopback, and runs 800 directory listings of a 720-file directory whose name lengths are all β‰  0 (mod 4) (rem ∈ {1,2,3}). Run on the unpatched #0 kernel: no panic, no slab corruption, guest stays up. This corroborates the unreachability proof.
  4. sizes.c β€” confirms sizeof(struct flrep)=132 (mod4=0) and NFSX_V3FH=28 against the actual kernel headers.

Exploit chain

Not applicable. No memory corruption is reachable β€” not even a kernel panic β€” so there is no primitive to groom or convert. The "fixed-content NUL write" characterization in the finding is moot because the write never happens.

Defense-in-depth fix (fix.diff) β€” validated

Although the bug is unreachable, the pad loop is the only write site in the encoder that lacks a nfsm_clget guard, which is an inconsistency and a latent robustness hazard (a future change to XDR alignment, header size, or cluster size would make it live). fix.diff adds the guard before each pad byte in both READDIR and READDIRPLUS, matching the pattern used everywhere else:

for (i = 0; i < rem; i++) {
    tl = nfsm_clget(&info, mp1, mp2, bp, be);
    *bp++ = '\0';
}

Validated on a single-fix kernel (make -j6 nativekernel, #1 build): fix.diff applies cleanly (both hunks), the kernel compiles and boots, and the NFS READDIR/READDIRPLUS path works with no regression (live_test.sh: 800 ls passes, no panic). Because the bug is unreachable there is no behavior delta to measure; the patch is defense-in-depth. This matches the finding's recommended fix (add nfsm_clget before the pad bytes).

Reproduce

# on the guest as maxx (unprivileged):
sh build.sh && sh run.sh          # reachability analysis -> "UNREACHABLE"
cc -o sizes sizes.c && ./sizes    # struct-size verification
# as root (live NFS corroboration):
sh live_test.sh

Suggested severity change

The finding is filed High as a heap OOB write. Verification shows the OOB is unreachable; the realistic impact is none. Recommend downgrading to Info (defense-in-depth / code-quality) and applying fix.diff for consistency and robustness.

Fix verification

fixed
baseline no→ patch + rebuild →patched clean

DEFENSE-IN-DEPTH, validated. fix_status=fixed refers to the code-quality gap (missing guard), NOT a reachable vulnerability. The claimed OOB was UNREACHABLE so fix_baseline_reproduced=false -- there was no bad behavior on the unpatched #0 baseline to begin with (proven unreachable). fix.diff applies cleanly (both hunks at :3184 and :3524), compiles (make -j6 nativekernel rc=0), boots as #1, and NFS READDIR/READDIRPLUS works with no regression (live_test.sh: 800 ls passes over 720-file dir, no panic). There is no before/after behavior delta because the underlying claim cannot manifest; the patch adds the missing guard for robustness/consistency and to future-proof against any change to XDR alignment, header size, or cluster size.

baseline #0 (unpatched): live_test.sh 400 ls passes / 630 files -> no panic, guest up (unreachable, as proven). patched #1 (fix.diff applied): fix_run.log -> 'kernel: 6.5-DEVELOPMENT #1' / 'created 720 files' / 'READDIR stress: 800 ls passes over 720-file dir -- DONE, no panic' / 'RESULT: guest still up, no kernel panic / OOB / slab corruption observed'. reach.c on both kernels: 'max OOB-state entries found = 0'.
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Wed Jul 8 07:01:14 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64 (sha256 bbaff3141b16099800a8993f75cc3f3cab27d546c5d53bef327273b0e1f633e1)

Confirmed kernel references

Detail

Exploit chain

not applicable -- no memory corruption is reachable (not even a panic), so there is no primitive to groom or convert. The finding's 'fixed-content NUL write' premise is moot because the write never executes in the OOB state. The pad loop's missing guard is a latent defense-in-depth gap only.

Evidence (decisive lines)

reach.c (definitive): '=== NFSv3 READDIR === exhaustive+random brute force over directory layouts: max OOB-state entries found = 0 / CONCLUSION: the claimed OOB state (bp==be at pad loop with rem>0) is UNREACHABLE.' (identical for READDIRPLUS). sizes.c: 'sizeof(struct flrep)=132 mod4=0', 'sizeof(fhandle_t)=28'. Live NFS on unpatched #0: 400 ls passes over 630 varied-name files, no panic, guest up. Live NFS on patched #1 (fix_run.log): 'READDIR stress: 800 ls passes over 720-file dir -- DONE, no panic / RESULT: guest still up, no kernel panic / OOB / slab corruption observed'.

PoC changes

Created findings/poc/DF-0766/ from scratch (no prior pack existed). Added reach.c (faithful 2048-byte-cluster cursor simulation + exhaustive/random brute force + alignment-invariant proof), nfs_pad_oob.c (structural proof the pad loop is unguarded -- forced bp==be writes into a red zone), sizes.c (struct-size verification), live_test.sh (NFS server setup + readdir stress), build.sh/run.sh, VERDICT.md, README.md, manifest.json, fix.diff (defense-in-depth), and full logs (run.log, fix_build.log, fix_run.log, env.txt).

Verified recommended fix

Defense-in-depth only (the bug is unreachable, so no security fix is strictly needed): in sys/vfs/nfs/nfs_serv.c add tl = nfsm_clget(&info, mp1, mp2, bp, be); before *bp++ = 0; inside the pad loop of both nfsrv_readdir (~:3187) and nfsrv_readdirplus (~:3525), matching the guard pattern used by every other write site in the encoder. Matches the finding's recommended proposal. Full git-apply-able diff in findings/poc/DF-0766/fix.diff.

Verdict

NOT REPRODUCED -- the claimed heap OOB write is UNREACHABLE. The pad loop at nfs_serv.c:3187 (READDIR) and 3525 (READDIRPLUS) does genuinely lack the nfsm_clget guard that every other write site in the encoder has (real code-quality gap), BUT the OOB state can never be reached because the NFS XDR encoder writes only 4-byte-aligned units: header H=124 (28 RPC + 88 postopattr + 8 cookieverf), entry header words=16, cookie words=8, and sizeof(struct flrep)=132 (verified mod4=0) are ALL multiples of 4. Therefore name_end_i = H + Sum(prior entry totals) + 16 + nlen_i == nlen_i (mod 4). For bp==be at the pad loop the name must end exactly on a 2048-byte cluster boundary, i.e. name_end == 0 (mod 2048) => name_end == 0 (mod 4) => nlen == 0 (mod 4) => rem = nfsm_rndup(nlen)-nlen = 0, so the pad loop writes nothing. Whenever padding is actually needed (rem>0) the name never ends on a cluster boundary and the pad bytes always fit. Confirmed four ways: (1) the mathematical invariant, (2) reach.c exhaustive + 400,000-trial random brute force over directory layouts -> 0 OOB-state entries for BOTH READDIR and READDIRPLUS, (3) live NFS server test (rpcbind/mountd/nfsd, 720 files with name lengths != 0 mod 4, 800 listings) -> no panic/corruption on the unpatched #0 kernel, (4) sizes.c verification of struct sizes against kernel headers. Holds for NFSv2/v3 and both READDIR/READDIRPLUS.