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 | 281 B | view raw |
| run.sh | run-script | load fuse, mount daemon (root), getdents as maxx | 1.4 KB | 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.2 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 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.