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)
PoC verification
Evidence pack
findings/poc/DF-0768 Β· 14 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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)(thebcopyinnfsm_disct's pull-up branch reading from the corrupteddpos, ~2 GiB below the real mbuf data). Guest is down (DDB). Serial-console capture inpanic.txt. (3/3 runs panicked.) - Single-fix kernel (
6.5-DEVELOPMENT #1, sha256458d4b05β¦):lsreturnsRC=0(empty dir β the malicious entry's out-of-rangeiis rejected withEBADRPC); 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.
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:
-
Attacker controls
i.i = fxdr_unsigned(int, *tl)where*tlis 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 places0x7FFFFFFDhere. -
nfsm_rndupoverflows.nfsm_rndup(a) = ((a)+3) & ~3(sys/vfs/nfs/nfsm_subs.h:203). Fora = 0x7FFFFFFD:(0x7FFFFFFD + 3) & ~3 = 0x80000000 & ~3 = 0x80000000 = INT_MIN(signed overflow β UB; in practice wraps to-2147483648). -
nfsm_advtreats 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->dposis now a wild kernel pointer ~2 GiB below the real mbuf data. No error is returned. -
The very next
nfsm_dissectreads from the wild pointer. Back in the caller (nfs_vnops.c:2930):NULLOUT(tl = nfsm_dissect(&info, NFSX_UNSIGNED)). Innfsm_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 tonfsm_disct(nfsm_subs.c:1313) with a negativeleft(= n). -
nfsm_disctpage-faults on the wild source. Because the malicious reply is padded to span β₯2 mbuf clusters,mp->m_next != NULL, sonfsm_discttakes 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 */Thebcopy(=memmove) reads 8-byte words from the corrupteddpos(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 innfs_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
getdentson 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 touid=0. (Per the run instructions, an OOB-read/wild-pointer panic from a server reply is characterised, not escalated.) Note themp->m_len -= leftdoes 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=0theni=0x7FFFFFFD) plus ~8 KiB of trailing padding to force a multi-mbuf-cluster reply so the page-faultingnfsm_disctpull-up branch is reached.trigger.shβ the unprivilegedls -f /mnt(getdents) driven asmaxx.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
fixedVALIDATED 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.
Confirmed kernel references
- sys/vfs/nfs/nfs_vnops.c:2924
- sys/vfs/nfs/nfs_vnops.c:2926
- sys/vfs/nfs/nfs_vnops.c:2927
- sys/vfs/nfs/nfs_vnops.c:2928
- sys/vfs/nfs/nfsm_subs.h:203
- sys/vfs/nfs/nfsm_subs.c:911
- sys/vfs/nfs/nfsm_subs.c:287
- sys/vfs/nfs/nfsm_subs.c:1313
- sys/vfs/nfs/nfsm_subs.c:453
- sys/vfs/nfs/xdr_subs.h:52
- sys/vfs/nfs/nfsproto.h:115
- sys/vfs/nfs/nfs_bio.c:1137
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).
No comments yet.