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

nfs_readdirplusrpc_uio signed-overflow in nfsm_rndup(i) corrupts RPC reply cursor β€” kernel memory info leak via getdents or panic

Summary

nfs_readdirplusrpc_uio :2924-2928 attrflag==0 else branch: i=fxdr_unsigned(int,*tl) server-controlled 32-bit int. nfsm_adv(&info,nfsm_rndup(i)) where nfsm_rndup(a)=((a)+3)&~3 signed overflows for i>=0x7FFFFFFD producing INT_MIN(0x80000000). nfsm_adv (nfsm_subs.c:911) n>=len always true for negative len info->dpos += INT_MIN decrements dpos by 2GiB wild pointer. Subsequent nfsm_dissect reads 4 bytes from wild address nfsm_mtouio bcopy from corrupted dpos into bio buffer nfs_bioread uiomove to user getdents = kernel memory info leak KASLR bypass credential extraction. Unmapped = page fault panic DoS. Also semantically wrong vs RFC1813 treats handle_follows bool as length. Trigger: malicious NFSv3 server crafts READDIRPLUS entry attrflag=0 then 0x7FFFFFFD. Unprivileged local user getdents triggers. Fix: bound i to NFSX_V3FHMAX or parse post_op_fh3 handle_follows properly.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0768 Β· 14 files
FileTypeDescriptionSize
nfs_mal_server.c trigger-source malicious NFSv3 server stub; READDIRPLUS handler emits entry with attrflag=0 then i=0x7FFFFFFD + 8KiB trailing padding to force a multi-cluster reply 20.3 KB view raw
trigger.sh trigger-source unprivileged 'ls -f /mnt' (getdents) driven as maxx 1.0 KB view raw
build.sh build-script cc -O2 -Wall -o nfs_mal_server nfs_mal_server.c 151 B view raw
run.sh run-script full harness: build as maxx, start server, mount -o rdirplus, fire maxx trigger 1.6 KB view raw
fix.diff suggested-fix git-apply-able one-hunk fix: bound server-controlled handle length i to NFSX_V3FHMAX before nfsm_rndup(i) 1.2 KB view raw
VERDICT.md verdict full narrative: mechanism, escalation analysis, fix before/after 8.1 KB ↓ raw
README.md readme human-readable repro + bug summary 4.4 KB ↓ raw
run.log run-log decisive unpatched baseline run (panic signature) 1.2 KB view raw
fix_run.log run-log decisive single-fix kernel run (RC=0, guest up, 4/4) 1.1 KB view raw
fix_build.log build-log single-fix kernel nativekernel build tail (rc=0) 464 B view raw
panic.txt panic-signature Fatal trap 12 page fault at memmove+0x24f (bcopy from corrupted dpos) 3.0 KB view raw
env.txt environment uname, cc version, sysctls, pre-condition notes 706 B view 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 human-readable repro + bug summary
↓ download raw

DF-0768 β€” PoC and fix evidence

nfs_readdirplusrpc_uio signed-overflow in nfsm_rndup(i) corrupts the READDIRPLUS reply XDR cursor β†’ wild-pointer read β†’ kernel panic (local DoS).

Status: REPRODUCED (panic / local unprivileged DoS). Fix authored, built, and validated β€” see VERDICT.md and fix.diff.

What is here

file what
nfs_mal_server.c malicious NFSv3 server stub (the trigger)
trigger.sh the unprivileged ls -f /mnt (getdents) that fires the bug
build.sh/run.sh exact build/run commands
fix.diff the validated one-hunk fix (bound i to NFSX_V3FHMAX before nfsm_rndup)
VERDICT.md full writeup (mechanism, escalation analysis, fix before/after)
panic.txt the fatal trap 12 panic signature (unpatched)
run.log / fix_run.log decisive unpatched (panic) and patched (RC=0, alive) runs
fix_build.log single-fix kernel build log tail (rc=0)
env.txt guest uname, cc version, sysctls
manifest.json machine-readable artifact catalog

How to reproduce

# 1. build the malicious server (any user with cc)
sh build.sh                      # -> ./nfs_mal_server

# 2. admin pre-condition (root): start the malicious server and mount it WITH
#    -o rdirplus (the standard NFSv3 perf option that selects READDIRPLUS).
#    This mirrors "an admin mounted an NFS share" -- the realistic trigger
#    environment.
./nfs_mal_server &               # listens on 127.0.0.1:111 and :2049
mount_nfs -3 -T -o tcp,nfsv3,rdirplus 127.0.0.1:/export /mnt

# 3. unprivileged trigger (any non-root user).  On the UNPATCHED kernel
#    this panics the guest immediately:
ls -f /mnt                       # unpatched: panic; patched: empty dir, RC=0

The full harness (run.sh) does all three steps; run it as root: sh /abs/path/to/run.sh maxx.

Expected result

  • Unpatched audit-source kernel (6.5-DEVELOPMENT #0): kernel panic, Fatal trap 12: page fault, Stopped at memmove+0x24f: repe movsq (%rsi),%es:(%rdi) (the bcopy in nfsm_disct's pull-up branch reading from the corrupted dpos, ~2 GiB below the real mbuf data). Guest is down (DDB). Serial-console capture in panic.txt. (3/3 runs panicked.)
  • Single-fix kernel (6.5-DEVELOPMENT #1, sha256 458d4b05…): ls returns RC=0 (empty dir β€” the malicious entry's out-of-range i is rejected with EBADRPC); guest stays up; no panic. Reproducible over 4 consecutive triggers.

The bug in one paragraph

In nfs_readdirplusrpc_uio() (sys/vfs/nfs/nfs_vnops.c:2924-2928), the "else" branch taken when a READDIRPLUS entry's name_attributes has attributes_follow == 0 reads the next server-controlled u32 as a handle length i and calls nfsm_adv(&info, nfsm_rndup(i)). nfsm_rndup(a) = ((a)+3) & ~3 overflows signed int for a >= 0x7FFFFFFD, producing INT_MIN. nfsm_adv() then treats the negative length as "fits in the current cluster" (n >= INT_MIN is always true) and does info->dpos += INT_MIN, corrupting the XDR cursor into a wild kernel pointer ~2 GiB below the real data. The next nfsm_dissect() routes to nfsm_disct(); on a multi-cluster reply its pull-up branch runs bcopy(corrupted_dpos, fresh_mbuf, (size_t)negative ~= 2^64) and page-faults on the wild source β†’ panic. A malicious/compromised NFSv3 server (AUTH_SYS is cleartext) crafts the reply; any unprivileged local user who reads the mounted share triggers it.

Threat model

A malicious, compromised, or MITM'd NFSv3 server replies to a READDIRPLUS request with an entry whose name_attributes.attributes_follow == 0 is followed by 0x7FFFFFFD. Any local user who then issues getdents (ls) on the mounted share panics the kernel. Pre-condition: an admin has mounted the (malicious) share with -o rdirplus β€” ordinary for NFS-using deployments (rdirplus is the default NFSv3 readdir mode in the diskless-root path and a common perf mount option). Impact: local DoS / kernel panic. No write primitive β†’ no uid=0 escalation.

Fix

See fix.diff. Bound the server-controlled handle length i to NFSX_V3FHMAX (= 64) before nfsm_rndup(i), mirroring the bounds check nfsm_getfh already applies. Matches the finding's primary recommendation.

VERDICT.md verdict full narrative: mechanism, escalation analysis, fix before/after
↓ download raw

DF-0768 β€” Verdict

nfs_readdirplusrpc_uio signed-overflow in nfsm_rndup(i) corrupts XDR cursor β†’ wild-pointer read β†’ kernel panic (local DoS)

Verdict: REPRODUCED (panic / local unprivileged DoS). Fix authored, built, and VALIDATED.

The finding's core claim β€” that a server-controlled i in the nfs_readdirplusrpc_uio() "else" (attrflag==0) branch can drive nfsm_rndup(i) into a signed overflow that corrupts the XDR decode cursor into a wild kernel pointer β€” is real and exploitable by a malicious NFSv3 server to panic any local client that reads the share. Fix authored, built into a single-fix kernel, and validated before/after.

Mechanism (trigger β†’ primitive β†’ effect)

The buggy branch lives at sys/vfs/nfs/nfs_vnops.c:2924-2928:

} else {
    /* Just skip over the file handle */
    NULLOUT(tl = nfsm_dissect(&info, NFSX_UNSIGNED));
    i = fxdr_unsigned(int, *tl);              /* server-controlled int     */
    ERROROUT(nfsm_adv(&info, nfsm_rndup(i))); /* overflows for i>=0x7FFFFFFD */
}

The chain, every hop cited:

  1. Attacker controls i. i = fxdr_unsigned(int, *tl) where *tl is the next 4 bytes of the READDIRPLUS reply (sys/vfs/nfs/xdr_subs.h:52 β€” fxdr_unsigned(t,v) = (t)ntohl((int32_t)(v))). A malicious server places 0x7FFFFFFD here.

  2. nfsm_rndup overflows. nfsm_rndup(a) = ((a)+3) & ~3 (sys/vfs/nfs/nfsm_subs.h:203). For a = 0x7FFFFFFD: (0x7FFFFFFD + 3) & ~3 = 0x80000000 & ~3 = 0x80000000 = INT_MIN (signed overflow β†’ UB; in practice wraps to -2147483648).

  3. nfsm_adv treats the negative length as "fits in cluster" and corrupts the cursor. nfsm_adv (sys/vfs/nfs/nfsm_subs.c:911): c n = mtod(info->md, caddr_t) + info->md->m_len - info->dpos; /* small + */ if (n >= len) { /* n(+) , len = INT_MIN => TRUE */ info->dpos += len; /* dpos += INT_MIN => ~2 GiB BELOW real data */ error = 0; } info->dpos is now a wild kernel pointer ~2 GiB below the real mbuf data. No error is returned.

  4. The very next nfsm_dissect reads from the wild pointer. Back in the caller (nfs_vnops.c:2930): NULLOUT(tl = nfsm_dissect(&info, NFSX_UNSIGNED)). In nfsm_dissect (nfsm_subs.c:287): c n = mtod(info->md, caddr_t) + info->md->m_len - info->dpos; /* = (mbuf_end) - (real_dpos - 2GiB) = R + 0x80000000, truncated to int => NEGATIVE, so "bytes <= n" is FALSE */ if (bytes <= n) { ... } else { error = nfsm_disct(&info->md, &info->dpos, bytes, n, &cp2); So it routes to nfsm_disct (nfsm_subs.c:1313) with a negative left (= n).

  5. nfsm_disct page-faults on the wild source. Because the malicious reply is padded to span β‰₯2 mbuf clusters, mp->m_next != NULL, so nfsm_disct takes the mbuf pull-up branch: c mp->m_len -= left; /* left<0 => m_len grows huge */ ... bcopy(*dposp, p, left); /* (size_t)left ~= 2^64 => copies forever from the WILD *dposp */ The bcopy (= memmove) reads 8-byte words from the corrupted dpos (0xfffff7ff... / 0xfffff800..., 2 GiB below the real mbuf) and page-faults on the first unmapped page.

Observed panic (unpatched 6.5-DEVELOPMENT #0, 3/3 runs)

Fatal trap 12: page fault while in kernel mode
fault virtual address  = 0xfffff7ff8f7a3ff8        (β‰ˆ2 GiB below real mbuf data)
fault code             = supervisor read data, page not present
instruction pointer    = 0x8:0xffffffff80bcab4f
current process        = 870   (the ls/getdents driven by maxx)
Stopped at  memmove+0x24f:  repe movsq  (%rsi),%es:(%rdi)
db>

(A 4th run tripped a downstream panic: assertion "obj != NULL" failed in vm_object_hold_shared from the page-fault handler walking into the wild address β€” same root memmove wild read, different downstream KASSERT. Both are this bug.)

Trigger / threat model

  • Pre-condition (realistic, set up by root): an admin has mounted an NFS share with -o rdirplus (the standard NFSv3 performance option that selects the READDIRPLUS proc-17 path in nfs_doio, sys/vfs/nfs/nfs_bio.c:1137). The server is malicious/compromised (AUTH_SYS is cleartext; an on-path attacker or a owned server can craft any reply).
  • Unprivileged trigger: any local user issues a single getdents on the mount (ls /mnt). No privilege, no special device, no setuid helper.
  • Impact ceiling: kernel panic / local DoS. The wild pointer is read by bcopy (memmove), so there is no attacker-controlled write primitive and thus no escalation path to uid=0. (Per the run instructions, an OOB-read/wild-pointer panic from a server reply is characterised, not escalated.) Note the mp->m_len -= left does corrupt one mbuf's length field to a huge value, but that mbuf is on the ephemeral NFS-reply chain being torn down; no durable cross-object heap corruption was demonstrated and none is needed for the DoS.

Why a naive single-cluster reply does NOT panic (and how the PoC forces it)

A small READDIRPLUS reply that fits in one mbuf cluster (MCLBYTES=2048) does not panic: nfsm_disct sees mp->m_next == NULL and returns EBADRPC (clean error). The corruption is silent in that case. To reach the page-faulting pull-up branch, the PoC pads the reply to ~8 KiB so it spans β‰₯2 clusters; the corruption point (offset ~156) then sits in cluster 1, whose m_next is non-NULL, so nfsm_disct takes the bcopy(corrupted_dpos, fresh, huge) path and faults. (A real malicious server simply sends a large reply; no client cooperation is needed.)

Fix (fix.diff)

Bound the server-controlled handle length i to NFSX_V3FHMAX (= 64, sys/vfs/nfs/nfsproto.h:115) before nfsm_rndup(i), mirroring the bounds check nfsm_getfh already applies (nfsm_subs.c:453). This makes the signed overflow unreachable (max nfsm_rndup(64) = 64, no overflow) while preserving the legitimate i == 0 case (post_op_fh3 handle_follows == 0, "no handle"). An out-of-range i returns EBADRPC.

i = fxdr_unsigned(int, *tl);
if (i < 0 || i > NFSX_V3FHMAX) {
    error = EBADRPC;
    m_freem(info.mrep);
    info.mrep = NULL;
    goto nfsmout;
}
ERROROUT(nfsm_adv(&info, nfsm_rndup(i)));

This matches the finding's primary recommendation ("bound i to NFSX_V3FHMAX"). It does not attempt the larger semantic rewrite ("parse post_op_fh3 handle_follows properly") β€” the branch's pre-existing handle_follows-as-length misparse for honest servers is a separate, non-security issue and is left untouched (the i == 0 / i ∈ [1,64] cases behave exactly as before).

Fix validation (Phase 8)

kernel build same PoC result
6.5-DEVELOPMENT #0 (unpatched baseline) β€” panic β€” Fatal trap 12, memmove+0x24f, fault va 0xfffff7ff8f7a3ff8; guest DOWN (3/3 runs)
6.5-DEVELOPMENT #1 (single-fix, sha256 458d4b05…) make -j6 nativekernel rc=0 no panic β€” ls returns RC=0, guest UP, maxx alive (4/4 runs)

The fix closes the bug: the identical malicious READDIRPLUS reply that deterministically panics the unpatched kernel is cleanly rejected (the out-of-range i β†’ EBADRPC) on the single-fix kernel, with no panic and a live guest.

PoC changes from the seeded scaffold

There was no pre-existing PoC scaffold for DF-0768 in findings/poc/DF-0768/ (finding was status=new, unverified). This run authored the complete evidence pack from scratch, adapting the working malicious-NFSv3-server pattern from DF-0767:

  • nfs_mal_server.c β€” malicious NFSv3 server stub (rpcbind + MOUNTv3 + NFSv3 on loopback). The READDIRPLUS (proc 17) handler emits the crafted entry (attrflag=0 then i=0x7FFFFFFD) plus ~8 KiB of trailing padding to force a multi-mbuf-cluster reply so the page-faulting nfsm_disct pull-up branch is reached.
  • trigger.sh β€” the unprivileged ls -f /mnt (getdents) driven as maxx.
  • run.sh β€” full harness (build as maxx, start server as root, mount with -o rdirplus, fire maxx trigger).
  • build.sh β€” cc -O2 -Wall -o nfs_mal_server nfs_mal_server.c.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED the fix. The identical malicious READDIRPLUS reply (attrflag=0 then i=0x7FFFFFFD) that DETERMINISTICALLY panics the unpatched 6.5-DEVELOPMENT #0 baseline (Fatal trap 12 page fault at memmove+0x24f, fault va 0xfffff7ff8f7a3ff8, guest DOWN, 3/3 runs) does NOT panic the single-fix 6.5-DEVELOPMENT #1 kernel (sha256 458d4b05...): 'ls -f /mnt' returns RC=0, guest stays UP, maxx still runs commands, no Fatal trap in boot.log (4/4 runs). The fix bounds the server-controlled handle length i to NFSX_V3FHMAX before nfsm_rndup, making the signed overflow unreachable; out-of-range i is rejected with EBADRPC. fix.diff applies cleanly (git apply --check OK) and the single-fix kernel compiled rc=0.

BASELINE (#0 unpatched): Fatal trap 12: page fault, fault va 0xfffff7ff8f7a3ff8, Stopped at memmove+0x24f: repe movsq (%rsi),%es:(%rdi) -- guest DOWN (3/3). | PATCHED (#1 single-fix, sha256 458d4b05...): ls -f /mnt -> RC=0, guest UP, no panic (4/4); maxx id -> uid=1001(maxx) ALIVE.
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Thu Jul 9 14:46:24 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64 (sha256 458d4b059c9683d2099ed921860fc1e4e3c4ffdf2cf0fa89ddeb80888d081329)

Confirmed kernel references

Detail

Exploit chain

Memory-corruption class (XDR-cursor corruption / wild-pointer read) but NO write primitive: the wild dpos is read by bcopy/memmove (repe movsq from %rsi) and page-faults on an unmapped page before any attacker-shaped write lands. No slab grooming, victim object, or conversion is possible because the primitive is a wild-pointer READ, not a write; per the run rules for an OOB-read/wild-ptr panic from a server reply there is no escalation path to uid=0. Characterized impact ceiling = local unprivileged DoS / kernel panic (an unprivileged user who reads an admin-mounted rdirplus share against a malicious/compromised AUTH_SYS server panics the kernel). Moved to the fix. Chain analysis and the no-write conclusion are documented in VERDICT.md; no exploit.c/chain.c was authored because no write primitive exists.

Evidence (decisive lines)

Unpatched #0 baseline: Fatal trap 12: page fault while in kernel mode / fault virtual address = 0xfffff7ff8f7a3ff8 / fault code = supervisor read data, page not present / instruction pointer = 0x8:0xffffffff80bcab4f / current process = 870 / Stopped at memmove+0x24f: repe movsq (%rsi),%es:(%rdi) [db>]  (3/3 runs panicked; a 4th tripped a downstream vm_object_hold_shared KASSERT from the same memmove wild read). Patched #1: 'ls -f /mnt' returns RC=0, guest UP, no panic (4/4 runs).

PoC changes

Authored the entire evidence pack from scratch (finding was status=new, no prior PoC). nfs_mal_server.c: malicious NFSv3 server stub (rpcbind+MOUNTv3+NFSv3 on loopback) adapted from DF-0767's working pattern; its READDIRPLUS (proc 17) handler emits an entry with name_attributes.attributes_follow=0 followed by the u32 0x7FFFFFFD (the value that drives nfsm_rndup to INT_MIN), plus ~8 KiB of trailing padding to force a multi-mbuf-cluster reply so nfsm_disct's page-faulting pull-up branch (mp->m_next != NULL) is reached (a naive single-cluster reply only yields a silent EBADRPC). trigger.sh: unprivileged 'ls -f /mnt'. run.sh: full harness (build as maxx, start server, mount -o rdirplus, fire maxx trigger). VERDICT.md/README.md/manifest.json/fix.diff and all logs written.

Verified recommended fix

In sys/vfs/nfs/nfs_vnops.c (nfs_readdirplusrpc_uio, ~line 2927), bound the server-controlled handle length i to NFSX_V3FHMAX (=64) before nfsm_rndup(i), mirroring the bounds check nfsm_getfh already applies (nfsm_subs.c:453): reject i<0 || i>NFSX_V3FHMAX with EBADRPC (freeing info.mrep, goto nfsmout). This makes the signed overflow unreachable while preserving the legitimate i==0 case (post_op_fh3 handle_follows==0). Matches the finding's primary recommendation ('bound i to NFSX_V3FHMAX'). The larger semantic rewrite ('parse post_op_fh3 handle_follows properly') is intentionally not done -- the branch's pre-existing handle_follows-as-length misparse for honest servers is a separate non-security issue and i==0/[1,64] behave exactly as before. Full git-apply-able diff in findings/poc/DF-0768/fix.diff.

Verdict

REPRODUCED. The bug is real: in nfs_readdirplusrpc_uio() (sys/vfs/nfs/nfs_vnops.c:2924-2928) the 'else' branch (attrflag==0) reads a server-controlled u32 as a handle length i and calls nfsm_adv(&info, nfsm_rndup(i)). nfsm_rndup(a)=((a)+3)&~3 (nfsm_subs.h:203) signed-overflows to INT_MIN for a>=0x7FFFFFFD; nfsm_adv (nfsm_subs.c:911) then treats the negative length as 'fits in cluster' (n>=INT_MIN is always true) and does info->dpos += INT_MIN, corrupting the XDR cursor into a wild kernel pointer ~2 GiB below the real mbuf data. The next nfsm_dissect (nfsm_subs.c:287) routes to nfsm_disct (nfsm_subs.c:1313) with a negative left; on a multi-cluster reply its pull-up branch runs bcopy(corrupted_dpos, fresh_mbuf, (size_t)negative ~= 2^64) and page-faults on the wild source. Confirmed by a malicious NFSv3 server stub mounted with -o rdirplus: unprivileged 'ls -f /mnt' (getdents) as maxx deterministically panics the unpatched 6.5-DEVELOPMENT #0 kernel (3/3 runs: Fatal trap 12 page fault, fault va 0xfffff7ff8f7a3ff8, Stopped at memmove+0x24f = the bcopy reading the corrupted dpos).