# 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:

```c
/* 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:

```c
/* 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:

```c
/*
 * 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).
