# DF-0870 — Unprivileged volume_list disclosure on HAMMER

## Verdict (one line)

**PARTIALLY REPRODUCED.** Two real, distinct code defects exist in
`sys/vfs/hammer/hammer_ioctl.c`, and ONE of them is demonstrated end-to-end
as an unprivileged disclosure, but **the finding's headline claim —
"kernel heap info leak via volume_list when device_name not NUL-terminated" —
is NOT exploitable as written.** The kstrdup OOB-read never persists into a
volume readable via `HAMMERIOC_LIST_VOLUMES`, because the volume install
always fails when the captured name has trailing heap bytes. The bug that
*does* fire is the missing `caps_priv_check` enforcement on the
`HAMMERIOC_LIST_VOLUMES` case (the top-of-function privilege check is
silently overwritten). That is reachable by an unprivileged user, who then
reads back each mounted volume's `vol_no` and `device_name` (the backing
block-device path) — minor info disclosure, not uninitialized kernel heap
bytes.

## What was actually demonstrated (unprivileged, default GENERIC `#0`)

`maxx` (uid 1001, not in wheel) opens a file on a live HAMMER mount and
issues `HAMMERIOC_LIST_VOLUMES`. Result (deterministic across 3 runs):

```
HAMMERIOC_LIST_VOLUMES: rc=0 errno=0 (Success) nvols=1
  [0] vol_no=0 device_name='/dev/vn0' len=8
HAMMERIOC_ADD_VOLUME (non-NUL device_name): rc=-1 errno=1 (Operation not permitted)
```

The same caller's `HAMMERIOC_ADD_VOLUME` correctly returns `EPERM`, proving
the discrepancy is the per-case overwrite, not a missing global check.

## Root-cause walkthrough (every hop cited `path:line`)

`hammer_ioctl()` begins with a top-level capability check that *should*
gate every case:

```c
/* sys/vfs/hammer/hammer_ioctl.c:72 */
error = caps_priv_check(cred, SYSCAP_NOVFS_IOCTL);
```

`SYSCAP_NOVFS_IOCTL` is `(__SYSCAP_GROUP_9 | 4)` = `0x94`
(`sys/sys/caps.h:219`) — it has **neither** `__SYSCAP_NOROOTTEST`
(`0x00040000`) **nor** `__SYSCAP_WHEELOK` (`0x00080000`).  Per
`caps_priv_check()` (`sys/kern/kern_caps.c:328-331`), a cred with
`cr_uid != 0` (and not in group 0) gets `EPERM` immediately.  So maxx
*should* be denied.

Most cases preserve that result with the canonical pattern
`if (error == 0) { error = hammer_ioc_xxx(...); }`.  But
`HAMMERIOC_LIST_VOLUMES` does **not** — it unconditionally overwrites
`error`:

```c
/* sys/vfs/hammer/hammer_ioctl.c:213-216 */
case HAMMERIOC_LIST_VOLUMES:
        error = hammer_ioc_volume_list(&trans, ip,
            (struct hammer_ioc_volume_list *)data);
        break;
```

The `EPERM` from line 72 is discarded; `hammer_ioc_volume_list()` runs and
returns its own (success) error code.  The duplicate `caps_priv_check` inside
`HAMMERIOC_ADD_VOLUME` at line 197 — which the finding flags as the
"privileged" sibling — is in fact redundant; it only fires when the
top-level check already passed (it is nested under `if (error == 0)`).

`hammer_ioc_volume_list()` (`sys/vfs/hammer/hammer_volume.c:291-330`) then
walks every mounted volume and `copyout`s `volume->vol_no` plus
`volume->vol_name` (the device path captured at mount or volume-add time):

```c
/* sys/vfs/hammer/hammer_volume.c:311-319 */
len = strlen(volume->vol_name) + 1;
KKASSERT(len <= MAXPATHLEN);
...
error = copyout(volume->vol_name,
                &ioc->vols[cnt].device_name[0], len);
```

`vol_name` is the string passed to `kstrdup()` at
`sys/vfs/hammer/hammer_ondisk.c:131`.  In normal operation it is the
NUL-terminated device path supplied by `mount_hammer` / `hammer volume-add`.

## Why the headline "kernel heap info leak" claim does NOT hold

The finding's mechanism is: an attacker triggers
`hammer_ioc_volume_add()` (`sys/vfs/hammer/hammer_volume.c:62-141`) with
`ioc->device_name[MAXPATHLEN]` filled with non-NUL bytes; that buffer is
passed verbatim to `hammer_install_volume()` at line 107, which calls
`kstrdup(volname, ...)` at `hammer_ondisk.c:131`; `kstrdup`
(`sys/kern/kern_slaballoc.c:1296-1308`) does `strlen(str)+1` and `bcopy`,
so it walks past the 1024-byte buffer into adjacent heap and captures the
OOB bytes into the freshly allocated `volume->vol_name`.  So far the code
description is accurate.

The chain then **breaks** at the next steps:

1. **Unprivileged users cannot even reach `hammer_ioc_volume_add()`'s
   body.**  Its case arm preserves the top-level caps check via
   `if (error == 0)` (line 196); maxx's `EPERM` from line 72 short-circuits
   the whole arm.  Verified: `HAMMERIOC_ADD_VOLUME` returns `EPERM` for
   maxx in every run.

2. **Root *can* issue `ADD_VOLUME`, but the install always fails when the
   name has trailing heap bytes.**  After the leaky `kstrdup`,
   `hammer_install_volume()` calls `nlookup_init()`/`nlookup()` on
   `volume->vol_name` (the long garbage string).  The kernel rejects it
   with `ENAMETOOLONG` (errno 63):

   ```
   HAMMERIOC_ADD_VOLUME (non-NUL device_name): rc=-1 errno=63 (File name too long)
   ```

   The failure path then calls `hammer_free_volume()` (hammer_ondisk.c:401),
   which `kfree`s `volume->vol_name` (line 406) and the volume struct.
   The captured OOB bytes are **never persisted** to any volume visible to
   `LIST_VOLUMES`.  A subsequent `LIST_VOLUMES` call still returns exactly
   the original mount's clean device path (verified).

3. Even if the install somehow succeeded, the resulting `vol_name` would
   be a string of *length > MAXPATHLEN*, and `hammer_ioc_volume_list:312`
   has `KKASSERT(len <= MAXPATHLEN)` — on the default GENERIC kernel
   (INVARIANTS ON) this would panic before the `copyout` ever ran.

So: the kstrdup OOB read is a *real code defect* (defense-in-depth worth
fixing by NUL-terminating `device_name` on input and/or using `strnlen` on
output, as the finding suggests), but it does **not** produce a
user-observable heap leak.  The user-observable unprivileged disclosure is
the volume **device path** + `vol_no`, which is normal filesystem metadata,
not uninitialized kernel heap.

## What this finding is, honestly

* A **real privilege-check bypass** in `hammer_ioctl.c:213-216` (and the
  same overwrite pattern appears in several sibling read-only cases —
  `GETHISTORY`, `SYNCTID`, `GET_PSEUDOFS`, `WAI_PSEUDOFS`,
  `GET_VERSION`, `GET_INFO`, `GET_SNAPSHOT`, `GET_CONFIG`,
  `SCAN_PSEUDOFS` — which the maintainers may or may not consider
  intentional).  The bypass lets any user with a file descriptor on a
  HAMMER mount enumerate the volumes backing it.  That is *information
  disclosure*, but of the device-path string, not of kernel heap.
* A **real but non-exploitable** code defect: `kstrdup` of an
  attacker-supplied non-NUL-terminated `device_name` does walk past the
  buffer.  It is not reachable by unprivileged users (the ADD path is
  privileged), and even when reached by root it cannot persist because the
  install always fails on the long garbage name.

Severity as filed: **Low** (info leak).  Actual demonstrated severity:
**Info** — disclosure of the volume device path to any holder of a file
descriptor on a HAMMER mount.  The "kernel heap info leak" framing is
inaccurate.

## Files in this evidence pack

| file | what it is |
|---|---|
| `poc.c`         | unprivileged probe (LIST_VOLUMES + ADD_VOLUME) — run as `maxx` |
| `poc_root.c`    | privileged probe — proves root's ADD_VOLUME w/ non-NUL name fails (ENAMETOOLONG), so the leak never persists |
| `build.sh`      | exact `cc -I/usr/src/sys` build line |
| `run.sh`        | exact run invocation |
| `run.log`       | decisive maxx run on the unpatched `#0` kernel |
| `run.root.log`  | root run proving install fails |
| `env.txt`       | guest uname / cc / sysctls / mount table |
| `fix.diff`      | the validated one-line fix: gate LIST_VOLUMES on `error == 0` |
| `fix_build.log` | full build of the single-fix kernel |
| `fix_run.log`   | re-run of the PoC on the patched `#1` kernel — `EPERM` |
| `manifest.json` | catalog |
