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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0781 Β· 15 files
FileTypeDescriptionSize
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
README.md readme how to reproduce + validate the fix
↓ download 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/fuse malicious daemon. Mounts a synthetic FUSE fs (/mnt/fuse) and answers every FUSE_READDIR with a single 48-byte reply containing one dirent with namelen = 32000 but only 8 actual name bytes.
  • read_trigger.c β€” unprivileged getdents consumer. Pre-fills its 64 KB buffer with 0x5a, calls getdents(2), then analyzes the buffer to prove the OOB read (kernel-touch high-water mark, honored d_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.c
  • run.sh β€” loads fuse, starts the daemon (root), then runs the getdents trigger as maxx.
  • fix.diff β€” consumer-side bound check in fuse_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.

VERDICT.md verdict full narrative: mechanism, primitive, leak proof, fix before/after
↓ download raw

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 in vop_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 getdents buffer 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 -= freclen underflows and buf += freclen advances to a wild pointer. Whether the next iteration's (struct fuse_dirent*)buf deref 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 β€” #0 kernel + stock fuse.ko (size 0xa9000): === 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 leaked kernel pointers (0xfffff8008d622XXX) and ASCII kernel strings (rcng_dhcpd\0disabled) visible in the buffer.

  • after β€” #0 kernel + patched fuse.ko (size 0xd000, sha256 9f19b420...e7a11a, built from /usr/src/sys/vfs/fuse with -Werror): bound check fires, dirent rejected before any bcopy / 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's 0x5a marker 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/fuse malicious daemon; replies to FUSE_READDIR with dirent.namelen = 32000 but only 8 real name bytes. Modelled on the DF-0780 daemon (same threat model).
  • read_trigger.c β€” unprivileged getdents consumer that pre-fills its buffer with 0x5a, calls getdents(2), and analyzes the buffer to prove the OOB read (high-water mark of kernel-touched bytes, honored d_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) in fuse_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

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED 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).
↓ fix.diffDragonFly 6.5-DEVELOPMENT #0: Thu Jul 2 06:02:54 UTC 2026 (kernel unchanged -- the bug lives in the loadable fuse.ko module, optional fuse in sys/conf/files, NOT compiled into X86_64_GENERIC). Patched fuse.ko built from /usr/src/sys/vfs/fuse with the fix applied, sha256 9f19b4202978cfc24e3e8fd4a91330f565603907a18444754b43baaab5e7a11a, size 0xd000.

Confirmed kernel references

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.