Kernel heap info leak via unvalidated namelen in fuse_vop_readdir β bcopy past reply buffer + len underflow wild ptr
Summary
fuse_vop_readdir :1058 only checks len<FUSE_NAME_OFFSET(24) not FUSE_NAME_OFFSET+namelen<=len. :1080 vop_write_dirent receives fde->namelen (daemon uint32 truncated to uint16 d_namlen) bcopy(d_name, dp->d_name, d_namlen) reads d_namlen bytes from fde->name past reply buffer into kernel heap = info leak via uiomove to user getdents. :1091 len-=freclen where freclen=FUSE_DIRENT_SIZE(24+namelen) if freclen>len size_t underflow ~SIZE_MAX :1090 buf+=freclen advances past buffer next iteration derefs wild ptr = panic. fuse_audit_length failure doesnt propagate (DF-0780 root cause). Trigger: malicious FUSE daemon FUSE_READDIR reply namelen=60000 actual data 16 bytes = ~59984 bytes heap leak. Fix: if(namelen>FUSE_NAME_MAX||FUSE_NAME_OFFSET+namelen>len||freclen>len) error=EINVAL break.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-0781 Β· 15 files| File | Type | Description | Size | |
|---|---|---|---|---|
| evil_daemon.c | trigger-source | raw /dev/fuse malicious daemon; FUSE_READDIR reply with dirent.namelen=32000 but only 8 real name bytes | 13.6 KB | view raw |
| read_trigger.c | trigger-source | unprivileged getdents consumer; analyzes user buffer for OOB read proof (kernel-pointer scan, leaked-window hexdump) | 6.8 KB | view raw |
| build.sh | build-script | cc -O0 -g -o evil_daemon/read_trigger | 65 B | view raw |
| run.sh | run-script | load fuse, mount daemon (root), getdents as maxx | 89 B | view raw |
| build.log | build-log | evil_daemon + read_trigger compile output | 371 B | view raw |
| run.log | run-log | decisive unpatched baseline run: kernel writes 32024 bytes, leaked kernel pointers + rcng strings | 6.8 KB | view raw |
| leak_sample.txt | leak-sample | decoded kernel pointers (0xfffff8008d62XXXX) and kernel ASCII strings leaked across runs | 3.6 KB | view raw |
| env.txt | environment | uname, cc version, /dev/fuse perms, maxx groups, kldstat | 630 B | view raw |
| fix.diff | suggested-fix | consumer-side bound check (namelen>NAME_MAX || FUSE_NAME_OFFSET+namelen>len || freclen>len) -> EINVAL break | 899 B | view raw |
| fix_build.log | build-log | patched fuse.ko build from /usr/src/sys/vfs/fuse (rc=0, -Werror) | 11.1 KB | view raw |
| fix_run.log | run-log | patched fuse.ko: 3/3 runs EINVAL, 0 bytes written, no leak | 1.2 KB | view raw |
| VERDICT.md | verdict | full narrative: mechanism, primitive, leak proof, fix before/after | 14.6 KB | β raw |
| README.md | readme | how to reproduce + validate the fix | 5.0 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 |
DF-0781 β Kernel heap info leak via unvalidated namelen in fuse_vop_readdir
Reproduction + fix-validation evidence pack.
What this proves
fuse_vop_readdir() (sys/vfs/fuse/fuse_vnops.c:1012) walks a FUSE
daemon's FUSE_READDIR reply as a sequence of struct fuse_dirent. Its
only bounds check is len < FUSE_NAME_OFFSET (24) β it never validates
that fde->namelen fits in the remaining reply. It then passes the
daemon-chosen fde->namelen (truncated to uint16 d_namlen) and
fde->name to vop_write_dirent(), which does
bcopy(d_name, dp->d_name, d_namlen) reading d_namlen bytes from
fde->name regardless of how many name bytes actually exist in the
reply. When d_namlen exceeds the real name size, the bcopy reads past
the daemon's kmalloc'd M_FUSE_BUF reply buffer into adjacent kernel
heap, and uiomove then ships those bytes to the user's getdents
buffer β a kernel heap info leak.
After the leak, len -= freclen underflows (both size_t) and
buf += freclen advances to a wild pointer β the "wild ptr" half of the
title. Whether the next iteration faults depends on slab layout; on
this INVARIANTS-ON guest with fde->namelen = 32000 it did not fault
(mapped slab) so the guest survived, but on a system with a different
slab layout it would page-fault β panic (DoS).
The existing fuse_audit_length() in fuse_device_write() only checks
len <= fri->size for FUSE_READDIR (fuse_util.c:132, where
fri->size = FUSE_BLKSIZE * 10 = 40960) β it does NOT validate
dirent.namelen against the actual reply content, so the audit passes
and the IPC completes normally.
Files
evil_daemon.cβ raw/dev/fusemalicious daemon. Mounts a synthetic FUSE fs (/mnt/fuse) and answers everyFUSE_READDIRwith a single 48-byte reply containing one dirent withnamelen = 32000but only 8 actual name bytes.read_trigger.cβ unprivilegedgetdentsconsumer. Pre-fills its 64 KB buffer with0x5a, callsgetdents(2), then analyzes the buffer to prove the OOB read (kernel-touch high-water mark, honoredd_namlen, non-zero leaked-byte count, kernel-pointer scan, leaked- window hexdump).build.shβcc -O0 -g -o evil_daemon evil_daemon.c && cc -O0 -g -o read_trigger read_trigger.crun.shβ loadsfuse, starts the daemon (root), then runs thegetdentstrigger asmaxx.fix.diffβ consumer-side bound check infuse_vop_readdir(fde->namelen > NAME_MAX || FUSE_NAME_OFFSET + fde->namelen > len || freclen > lenβEINVAL).build.log/run.log/fix_run.log/fix_build.log/leak_sample.txt/env.txt/VERDICT.md/manifest.json.
Reproduce (info leak)
On the audit guest (unprivileged maxx exists; root needed only to load
the module, open /dev/fuse, and call mount -t fuse):
# as root, on the guest:
cp /usr/obj/usr/src/sys/X86_64_GENERIC/usr/src/sys/vfs/fuse/fuse.ko /root/fuse.ko
kldload /root/fuse.ko
# as maxx:
cd poc/DF-0781 && sh build.sh
# as root:
sh run.sh # maxx getdents /mnt/fuse -> leak fires
Expected on the unpatched kernel + stock fuse.ko:
=== kernel wrote through user-buffer offset 32023 === === honored d_namlen = 32000 (daemon claimed 32000) === === 72 non-zero non-marker bytes in window [24..32024) === [LEAK-PROOF] kernel wrote 32024 bytes; ... => 32000 bytes were read from past the daemon's reply buffer
with kernel pointers (0xfffff8008d62XXXX) and ASCII kernel strings
(rcng_dhcpd\0disabled, rcng_lvm, rcng_cryptdisks, rcng_initrandom,
rcng_fsck, running, disabled) visible in the leaked window.
Validate the fix
Because fuse is a loadable module (not compiled into X86_64_GENERIC),
the fix lives in fuse.ko. Build the patched module, install, reload,
re-run:
scp fix.diff dfbsd:/root/fix.diff
vm.sh run_root 'cd /usr/src && patch -p1 --forward < /root/fix.diff'
vm.sh run_root 'cd /usr/src/sys/vfs/fuse && make obj && make && \
cp /usr/obj/usr/src/sys/vfs/fuse/fuse.ko /root/fuse.ko'
vm.sh run_root 'kldunload fuse; kldload /root/fuse.ko'
sh run.sh # maxx getdents -> EINVAL, NO leak, guest up
Expected on the patched fuse.ko: the bound check fires, the
dirent is rejected, and the user buffer's 0x5a marker is fully
preserved:
=== kernel wrote through user-buffer offset -1 (bytes [0..-1]) === === kernel did not write a full dirent header -> no leak ===
Threat model / scope (read VERDICT.md for detail)
The trigger is root-only on default DragonFly (kldload fuse +
/dev/fuse root:operator 0660 + mount -t fuse capability check all
require root), so the daemon author must be root or operator+helper.
The readdir consumer (the user whose getdents trips the leak) can be
unprivileged (maxx here). This is a root-controlled malicious
daemon β kernel info leak β unprivileged consumer threat model β a
hardening gap / insider-abuse vector on a default system, and a real
info leak where unprivileged users are permitted to mount FUSE. It is
not an unprivilegedβroot escalation on a default system.
DF-0781 β Verdict
Kernel heap info leak via unvalidated namelen in fuse_vop_readdir
(+ adjacent len -= freclen underflow β wild pointer deref)
Verdict: REPRODUCED (kernel heap info leak); fix VALIDATED
| field | value |
|---|---|
| status | reproduced |
| reproduced | yes (deterministic structural proof; non-zero leaked bytes vary run-to-run) |
| impact | leak of kernel heap past the daemon's FUSE_READDIR reply buffer (CWE-125/CWE-200) |
| confidence | certain |
| class | out-of-bounds read / information exposure |
| severity (audit) | High |
The "wild ptr" / panic variant named in the title is also reachable (the
loop's len -= freclen underflows and buf is advanced past the reply
buffer), but on this INVARIANTS-ON with-src guest iteration-2 deref
landed in mapped slab memory and did not fault in the runs observed β so
the primary reproduced impact is the info leak, not panic.
1. The bug β confirmed at source and at runtime
fuse_vop_readdir() (sys/vfs/fuse/fuse_vnops.c:1012) parses the daemon's
FUSE_READDIR reply buffer as a sequence of struct fuse_dirent records:
/* fuse_vnops.c:1057-1091 */
while (1) {
if (len < FUSE_NAME_OFFSET) { /* 24-byte header only */
...
break;
}
...
fde = (const struct fuse_dirent*)buf;
if (!fde->namelen) { error = EINVAL; break; }
freclen = FUSE_DIRENT_SIZE(fde); /* = ALIGN(24 + namelen) */
/* MISSING: any check that namelen (or freclen) fits in `len`. */
if (cur_offset >= uio->uio_offset) {
error = 0;
if (vop_write_dirent(&error, uio, fde->ino, fde->type,
fde->namelen, fde->name)) /* passes daemon namelen */
break; /* and fde->name pointer */
...
}
cur_offset += _DIRENT_RECLEN(fde->namelen);
buf += freclen;
len -= freclen; /* underflows if freclen>len */
}
The guard at line 1058 only checks len < FUSE_NAME_OFFSET (24) β it
ensures the 24-byte dirent header fits, but never that
FUSE_NAME_OFFSET + namelen <= len. fde->namelen is the daemon-chosen
uint32 from the reply; it is then passed unvalidated as d_namlen (truncated
to uint16) to vop_write_dirent(), which does:
/* sys/kern/vfs_subr.c:2560 */
int vop_write_dirent(int *error, struct uio *uio, ino_t d_ino,
uint8_t d_type, uint16_t d_namlen, const char *d_name)
{
struct dirent *dp;
size_t len;
len = _DIRENT_RECLEN(d_namlen);
if (len > uio->uio_resid)
return(1);
dp = kmalloc(len, M_TEMP, M_WAITOK | M_ZERO);
dp->d_ino = d_ino;
dp->d_namlen = d_namlen;
dp->d_type = d_type;
bcopy(d_name, dp->d_name, d_namlen); /* <-- reads d_namlen bytes
* from d_name (= fde->name)
* with NO bound vs the
* daemon reply buffer */
*error = uiomove((caddr_t)dp, len, uio); /* <-- leaks to userspace */
...
}
So a daemon that replies with a small payload but a large namelen makes
the kernel bcopy namelen bytes from fde->name (a pointer into the
kmalloc'd M_FUSE_BUF reply buffer), reading namelen - actual_name_bytes
bytes past the reply buffer into adjacent kernel heap, then uiomove those
bytes into the user's getdents buffer. The existing fuse_audit_length()
in fuse_device_write() does NOT catch this: for FUSE_READDIR it only
checks len <= fri->size (fuse_util.c:132), where fri->size =
FUSE_BLKSIZE * 10 = 40960 β so any reply up to 40 960 bytes passes audit
regardless of how its dirent namelen fields lie about the actual content.
After the first iteration, len -= freclen underflows when freclen > len
(both size_t, unsigned), and buf += freclen advances buf past the
reply buffer. The next iteration then dereferences a wild pointer β the
"wild ptr" half of the title. Whether that deref faults depends on the
slab layout (on the default INVARIANTS-ON guest with fde->namelen = 32000,
the wild deref landed in mapped slab memory in the runs observed, so the
guest survived but getdents returned EINVAL from the post-leak
iteration's fde->namelen == 0 check).
2. Live reproduction β structural proof + leaked kernel content
evil_daemon.c is a raw /dev/fuse protocol daemon that opens /dev/fuse,
mounts a synthetic FUSE filesystem at /mnt/fuse, and answers every
FUSE_READDIR with a single 48-byte reply:
fuse_out_header.len = 48 (16 header + 32 data)
fuse_dirent { ino=2, off=0, namelen=32000, type=DT_REG }
fuse_dirent.name[] = "ABCDEFGH" /* only 8 actual bytes */
I.e. the daemon claims namelen=32000 but only emits 8 bytes of name.
read_trigger.c is an unprivileged consumer that opens the mountpoint and
calls getdents(fd, buf, 65536) with a 64 KB buffer pre-filled with 0x5a
markers.
Result on the unpatched #0 kernel + stock fuse.ko (deterministic):
[trigger] getdents returned -1 (errno 22: Invalid argument)
=== kernel wrote through user-buffer offset 32023 (bytes [0..32023]) ===
=== expected write size if namelen=32000 honored: _DIRENT_RECLEN(32000) = 32016 bytes ===
=== honored d_namlen = 32000 (daemon claimed 32000) ===
=== 72 non-zero non-marker bytes in window [24..32024) (past the 8-byte real name) ===
[LEAK-PROOF] kernel wrote 32024 bytes; d_namlen honored = 32000;
real name bytes from daemon = 8;
=> 32000 bytes were read from past the daemon's reply buffer
The kernel wrote 32 024 bytes to the user buffer (one full
_DIRENT_RECLEN(32000) dirent + 8 bytes of the next iteration's partial
header), honoring the daemon's d_namlen = 32000 even though the daemon
only sent 8 bytes of name. 31 992 bytes were read from past the
daemon's reply buffer = out-of-bounds read = info leak.
The leaked bytes vary run-to-run as the slab accumulates state. After a
few warm-up getdents calls (which leave stale M_FUSE_BUF slab content),
the leaked window contains recognizable kernel data:
=== leaked window hexdump (first 256 bytes past real name) === a821628d00f8ffff 0800000000000000 0200000000000000 0000000000000000 007d000008000000 4142434445464748 010000000a000000 7023628d00f8ffff 7b23628d00f8ffff 72636e675f646863 7064006469736162 6c65640000000000 8820628d00f8ffff 1400000000000000 0200000000000000 ...
Decoded (little-endian):
| bytes (hex) | meaning |
|---|---|
a821628d00f8ffff |
kernel pointer 0xfffff8008d6221a8 |
7023628d00f8ffff |
kernel pointer 0xfffff8008d622370 |
7b23628d00f8ffff |
kernel pointer 0xfffff8008d62237b |
8820628d00f8ffff |
kernel pointer 0xfffff8008d622088 |
72636e675f646863 7064006469736162 6c6564 |
ASCII rcng_dhcpd\0disabled (kernel string) |
4142434445464748 |
ASCII ABCDEFGH (the daemon's real 8-byte name, appearing in the next assembled dirent from garbage) |
Across 5 consecutive runs (same mount) the non-zero leaked-byte count
varied: 281, 366, 385, 404, 0, 262, 72, 796 β confirming the leak content
is genuine heap residue (varies with slab state), not deterministic
zero-fill. Earlier runs also caught rcng_lvm, rcng_cryptdisks,
rcng_initrandom, rcng_fsck, rcng_dhcpd, running, disabled and
many 0xfffff8008d62XXXX kernel pointers β see leak_sample.txt.
3. Primitive characterization
- what the attacker controls:
fde->namelen(uint32, daemon-chosen; effectively uint16 after truncation invop_write_dirent). Controls how many bytes the kernel reads past the daemon's reply buffer. - what the attacker does NOT control: the content of the leaked bytes (those are adjacent slab heap β kernel pointers, kernel strings, refcounts, etc. β depending on what's been allocated/freed nearby).
- read size: up to ~65 535 bytes per call (limited by uint16 d_namlen
and the user's
getdentsbuffer size). Repeatable per call. - impact: kernel heap info leak. Concrete leaked content observed
includes kernel virtual addresses in the
0xfffff800...KVA range (useful for KASLR-defeat β though this guest has KASLR off; on a real KASLR-enabled system these leaked pointers would defeat it) and kernel ASCII strings revealing internal service names. - secondary effect: after the leak,
len -= freclenunderflows andbuf += freclenadvances to a wild pointer. Whether the next iteration's(struct fuse_dirent*)bufderef faults depends on slab layout. On this guest (INVARIANTS ON, fde->namelen=32000) it did not fault (slab was mapped); on a system where the wild pointer reaches unmapped KVA it would page-fault β panic (DoS).
4. Exploit chain to uid=0 β NOT APPLICABLE (read-only primitive)
Per Phase 6, escalation only applies to write-capable primitives. The
primary reproduced impact here is a read-only OOB read β there is no
write, no corruption of any kernel object, no UAF, no function-pointer
clobber. bcopy(d_name, dp->d_name, d_namlen) reads FROM the daemon
buffer (the OOB source) and writes INTO a freshly kmalloc'd dp
(M_TEMP, sized exactly to _DIRENT_RECLEN(d_namlen)), then uiomove
copies that dp out to userspace. No kernel object is overwritten by
attacker bytes. Therefore there is no escalation chain to develop;
the realistic impact ceiling is the info leak itself (KASLR defeat,
kernel pointer disclosure, internal-string disclosure).
A separate threat-model caveat applies regardless: the trigger is
root-only on default DragonFly (the FUSE module must be kldload'd,
/dev/fuse is root:operator 0660, and mount -t fuse requires
caps_priv_check(SYSCAP_NOMOUNT_FUSE)). So the daemon author must be
root (or operator + a mount-helper); the readdir consumer (the user
whose getdents triggers the leak) can be unprivileged (maxx here).
This is a root-controlled malicious daemon β kernel info leak β
unprivileged consumer threat model β i.e. a hardening gap / insider-
abuse vector on a default system, becoming a real info leak where
unprivileged users are permitted to mount FUSE (setuid fusermount-style
helper, or vfs.usermount+capability grants).
5. The fix (validated)
fix.diff adds three consumer-side bound checks in fuse_vop_readdir
right after computing freclen, before the dirent is honored:
/*
* DF-0781: a malicious/buggy daemon may claim a name length that
* exceeds the remaining reply buffer (or is absurdly large).
* ...
*/
if (fde->namelen > NAME_MAX ||
FUSE_NAME_OFFSET + fde->namelen > len ||
freclen > len) {
error = EINVAL;
break;
}
These reject any dirent whose name does not fit in the remaining reply
buffer (closing the OOB read), whose claimed name exceeds the BSD
filename limit (NAME_MAX = 255, sys/sys/syslimits.h:54), or whose
aligned record length would underflow len (closing the wild-pointer
path). Minimal and targeted at the root cause; the unchecked bcopy
and len -= freclen can no longer execute with an out-of-range namelen.
This matches the spirit of the finding markdown's ## Recommended fix
proposal (if (namelen > FUSE_NAME_MAX || FUSE_NAME_OFFSET + namelen > len
|| freclen > len) error = EINVAL break). The only substitution is
NAME_MAX (a universally-defined sys/sys/syslimits.h constant, value
255) for the not-defined-in-tree FUSE_NAME_MAX β semantically identical.
Before / after (single-fix build)
Because fuse is a loadable module (optional fuse in
sys/conf/files, NOT compiled into X86_64_GENERIC), the fix lives in
fuse.ko. The validation builds just the patched fuse.ko from
/usr/src/sys/vfs/fuse, installs it, reloads, and re-runs the same PoC.
-
before β
#0kernel + stockfuse.ko(size0xa9000):=== kernel wrote through user-buffer offset 32023 === === honored d_namlen = 32000 (daemon claimed 32000) === === 72 non-zero non-marker bytes in window [24..32024) === [LEAK-PROOF] kernel wrote 32024 bytes; ... => 32000 bytes were read from past the daemon's reply bufferwith leaked kernel pointers (0xfffff8008d622XXX) and ASCII kernel strings (rcng_dhcpd\0disabled) visible in the buffer. -
after β
#0kernel + patchedfuse.ko(size0xd000, sha2569f19b420...e7a11a, built from/usr/src/sys/vfs/fusewith-Werror): bound check fires, dirent rejected before anybcopy/uiomove:[trigger] getdents returned -1 (errno 22: Invalid argument) === kernel wrote through user-buffer offset -1 (bytes [0..-1]) === === kernel did not write a full dirent header -> no leak ===3/3 deterministic patched runs wrote zero bytes (the user buffer's0x5amarker is fully preserved β no kernel data touched it).
fix_status = fixed.
6. PoC changes vs. the as-filed package
There was no prior PoC package for DF-0781 (the finding folder did not exist; only the DB row was present). I authored the entire evidence pack from scratch:
evil_daemon.cβ raw/dev/fusemalicious daemon; replies toFUSE_READDIRwithdirent.namelen = 32000but only 8 real name bytes. Modelled on the DF-0780 daemon (same threat model).read_trigger.cβ unprivilegedgetdentsconsumer that pre-fills its buffer with0x5a, callsgetdents(2), and analyzes the buffer to prove the OOB read (high-water mark of kernel-touched bytes, honoredd_namlen, non-zero leaked-byte count, kernel-pointer scan, leaked-window hexdump).build.sh/run.shβ exact, runnable build and run scripts.fix.diffβ consumer-side bound check (fde->namelen > NAME_MAX || FUSE_NAME_OFFSET + fde->namelen > len || freclen > len) infuse_vop_readdir.VERDICT.md(this file),manifest.json, and the full untrimmed logs.
The only iterations during testing were:
1. fde->namelen was bumped from 600 β 32000 to ensure _DIRENT_RECLEN
fit comfortably in the 64 KB getdents buffer and to traverse more
slab chunks (maximizing the chance of catching non-zero leaked
bytes).
2. The read_trigger initially used a hand-rolled inline-asm syscall
stub; this returned a confusing n=22 (the syscall convention
differs from what I'd assumed). Switching to the libc getdents()
wrapper fixed it.
3. The kernel-pointer detector was broadened from 0xffffffff... only
to also recognize 0xfffff8XX... (the actual KVA range of the
leaked pointers).
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
fixedVALIDATED the fix: same PoC, same #0 kernel. BASELINE (unpatched fuse.ko, kldstat size 0xa9000): kernel wrote 32024 bytes to user buffer honoring d_namlen=32000, leaked window contains 72 non-zero non-marker bytes including kernel pointer 0xfffff8008d6221a8 and ASCII kernel string 'rcng_dhcpd\0disabled'. PATCHED (patched fuse.ko, size 0xd000): bound check fires, dirent rejected, kernel wrote 0 bytes (offset -1 = full 0x5a marker preserved), 'did not write a full dirent header -> no leak', 3/3 deterministic. Fix closes the info leak. The wild-ptr panic variant was not exercised in either direction on this guest (the iteration-2 wild deref landed in mapped slab on both unpatched runs and did not fault); the fix's freclen>len clause also closes that path defensively.
BASELINE (unpatched): kernel wrote through user-buffer offset 32023 (bytes [0..32023]) / honored d_namlen = 32000 (daemon claimed 32000) / 72 non-zero non-marker bytes in window [24..32024) / [LEAK-PROOF] kernel wrote 32024 bytes; d_namlen honored = 32000; real name bytes from daemon = 8; => 32000 bytes were read from past the daemon's reply buffer / leaked window: a821628d00f8ffff ... 72636e675f646863 7064006469736162 6c6564. PATCHED: kernel wrote through user-buffer offset -1 (bytes [0..-1]) / kernel did not write a full dirent header -> no leak (3/3).
Confirmed kernel references
- sys/vfs/fuse/fuse_vnops.c:1058
- sys/vfs/fuse/fuse_vnops.c:1066
- sys/vfs/fuse/fuse_vnops.c:1071
- sys/vfs/fuse/fuse_vnops.c:1080
- sys/vfs/fuse/fuse_vnops.c:1090
- sys/vfs/fuse/fuse_vnops.c:1091
- sys/vfs/fuse/fuse_abi.h:733
- sys/vfs/fuse/fuse_abi.h:741
- sys/vfs/fuse/fuse_abi.h:744
- sys/kern/vfs_subr.c:2560
- sys/kern/vfs_subr.c:2566
- sys/kern/vfs_subr.c:2575
- sys/vfs/fuse/fuse_ipc.c:74
- sys/vfs/fuse/fuse_device.c:182
- sys/vfs/fuse/fuse_device.c:212
- sys/vfs/fuse/fuse_util.c:87
- sys/vfs/fuse/fuse_util.c:132
- sys/sys/syslimits.h:54
Detail
Exploit chain
none (pure OOB read / info leak, not a write-capable primitive). bcopy reads FROM the daemon reply buffer (the OOB source) and writes INTO a freshly kmalloc'd M_TEMP dp sized exactly to _DIRENT_RECLEN(d_namlen) -- no kernel object is overwritten by attacker bytes. The realistic impact ceiling is the leak itself: kernel-pointer disclosure (KASLR defeat on systems with KASLR; this guest has KASLR off) and kernel-string disclosure. The trigger is root-only on default DragonFly (kldload fuse + /dev/fuse root:operator 0660 + mount -t fuse cap check all require root), so the daemon author must be root; the readdir consumer can be unprivileged (maxx here). This is a root-controlled malicious daemon -> kernel info leak -> unprivileged consumer threat model (hardening gap / insider abuse on default systems; real info leak where unprivileged users are permitted to mount FUSE).
Evidence (decisive lines)
BASELINE (#0 + stock fuse.ko): [trigger] getdents returned -1 (errno 22: Invalid argument) / === kernel wrote through user-buffer offset 32023 (bytes [0..32023]) === / === honored d_namlen = 32000 (daemon claimed 32000) === / === 72 non-zero non-marker bytes in window [24..32024) (past the 8-byte real name) === / [LEAK-PROOF] kernel wrote 32024 bytes; d_namlen honored = 32000; real name bytes from daemon = 8; => 32000 bytes were read from past the daemon's reply buffer / leaked window: a821628d00f8ffff ... 72636e675f646863 7064006469736162 6c6564 (=0xfffff8008d6221a8 + 'rcng_dhcpd\0disabled'). PATCHED (#0 + patched fuse.ko sha256 9f19b420...e7a11a): === kernel wrote through user-buffer offset -1 (bytes [0..-1]) === / === kernel did not write a full dirent header -> no leak === (3/3 deterministic).
PoC changes
No prior PoC folder existed (only the DB row); authored the entire evidence pack from scratch. evil_daemon.c: raw /dev/fuse malicious daemon that replies to FUSE_READDIR with dirent.namelen=32000 but only 8 real name bytes (modelled on DF-0780's daemon, same threat model). read_trigger.c: unprivileged getdents consumer that pre-fills its 64KB buffer with 0x5a and analyzes the result to prove the OOB read. Iterations: (1) bumped fde->namelen 600 -> 32000 so _DIRENT_RECLEN fits the 64KB buffer and traverses more slab chunks; (2) switched inline-asm syscall stub to libc getdents() wrapper; (3) broadened the kernel-pointer detector from 0xffffffff-only to also catch 0xfffff8XX.
Verified recommended fix
In fuse_vop_readdir (sys/vfs/fuse/fuse_vnops.c) immediately after computing freclen = FUSE_DIRENT_SIZE(fde) at line 1071, add: if (fde->namelen > NAME_MAX || FUSE_NAME_OFFSET + fde->namelen > len || freclen > len) { error = EINVAL; break; }. This rejects any dirent whose name does not fit in the remaining reply buffer (closes the OOB read/info leak), whose name exceeds the BSD NAME_MAX (255) limit, or whose aligned record length would underflow len (closes the wild-pointer path). Matches the finding markdown's proposal; the only substitution is the universally-defined NAME_MAX (sys/sys/syslimits.h:54, value 255) for the not-in-tree FUSE_NAME_MAX. Full git-apply-able diff in findings/poc/DF-0781/fix.diff.
Verdict
REPRODUCED. The bug is real: fuse_vop_readdir (sys/vfs/fuse/fuse_vnops.c:1012) only checks len < FUSE_NAME_OFFSET (24-byte header, line 1058) and never validates that fde->namelen fits in the remaining reply buffer. It passes the daemon-chosen fde->namelen (uint32, truncated to uint16 d_namlen) straight to vop_write_dirent (line 1080), which does bcopy(d_name, dp->d_name, d_namlen) at sys/kern/vfs_subr.c:2575 reading d_namlen bytes from fde->name (a pointer into the kmalloc'd M_FUSE_BUF reply buffer) regardless of how many name bytes actually exist. fuse_audit_length only checks total len <= fri->size for FUSE_READDIR (fuse_util.c:132), not per-dirent namelen, so the audit passes. Confirmed at runtime: daemon replied to FUSE_READDIR with a 48-byte payload (24-byte dirent header + 8 name bytes) but dirent.namelen=32000; maxx's getdents received 32024 bytes in its user buffer (the kernel honored d_namlen=32000), reading 31992 bytes past the 8-byte real name. The leaked bytes contain kernel pointers (0xfffff8008d62XXXX, a KASLR-defeat-class disclosure) and kernel ASCII strings (rcng_dhcpd, rcng_lvm, rcng_cryptdisks, rcng_initrandom, rcng_fsck, running, disabled) -- see run.log and leak_sample.txt. After the leak, len -= freclen underflows (size_t) and buf is advanced to a wild pointer (the title's wild-ptr half); on this INVARIANTS-ON guest that deref landed in mapped slab and did not fault, so the guest survived but getdents returned EINVAL.
No comments yet.