# DF-0815 — VERDICT

## Verdict: REPRODUCED → FIX VALIDATED

**Missing privilege check on HAMMER2 `BULKFREE_SCAN` / `BULKFREE_ASYNC` / `DEBUG_DUMP` ioctls.**
Unprivileged local user → kernel-address info leak (DEBUG_DUMP) + DoS vector
(BULKFREE_SCAN/ASYNC). CWE-862 Missing Authorization. Medium severity.

## Mechanism (trigger → primitive → effect)

`hammer2_ioctl()` computes the privilege gate once at the top:

```c
/* sys/vfs/hammer2/hammer2_ioctl.c:83 */
error = caps_priv_check(cred, SYSCAP_NOVFS_IOCTL);
```

For an unprivileged caller, `error` becomes non-zero (would-be `EPERM`).
**Every** admin ioctl case then guards its handler with `if (error == 0)` —
e.g. `DESTROY` (`:151`), `GROWFS` (`:162`), `EMERG_MODE` (`:158`),
`PFS_CREATE/DELETE/SNAPSHOT` (`:126/131/134`), `VOLUME_LIST` (`:166`).

But **three** cases assign to `error` directly, discarding the privilege
result and running the handler regardless:

```c
/* sys/vfs/hammer2/hammer2_ioctl.c:144-156 */
case HAMMER2IOC_BULKFREE_SCAN:
    error = hammer2_ioctl_bulkfree_scan(ip, data);      /* :145  NO guard */
    break;
case HAMMER2IOC_BULKFREE_ASYNC:
    error = hammer2_ioctl_bulkfree_scan(ip, NULL);      /* :148  NO guard */
    break;
...
case HAMMER2IOC_DEBUG_DUMP:
    error = hammer2_ioctl_debug_dump(ip, *(u_int *)data);/* :155 NO guard */
    break;
```

### Reachability (unprivileged)
Root fs on the audit guest is **hammer2** (`vbd0s1d on / (hammer2, local)`).
Any fd on `/` routes ioctls through `vn_ioctl` (`vfs_vnops.c:983`, VDIR/VREG
falls through to `VOP_IOCTL` at `:1029`) → `hammer2_vop_ioctl`
(`hammer2_vnops.c:2264`) → `hammer2_ioctl`. So `open("/etc", O_RDONLY)` as
uid 1001 is sufficient — no device node, no mount privilege, no `vfs.usermount`.

### Effect
- **DEBUG_DUMP** (`flags` user-controlled via `*(u_int*)data`): calls
  `hammer2_dump_chain` (`hammer2_chain.c:5799`) which `kprintf`s kernel `%p`
  pointers — chain (`:5813`), parent (`:5827`), and (with `flags=0xFFFFFFFF`)
  recurses up to 100000 lines. With `security.unprivileged_read_msgbuf=1`
  (default), the unprivileged user harvests these via `sysctl kern.msgbuf`.
  Confirmed leak on the unpatched kernel: `i-chain 0xfffff80116981080`,
  `p=0xfffff80116980f00` (live kernel heap addresses of `hammer2_chain_t`).
  → **kernel address leak / KASLR bypass**.
- **BULKFREE_SCAN** (real `data`): takes `hmp->bflock` EXCLUSIVE (`:1110`),
  syncs **every** PFS on the media (`:1118-1130`), then runs a full-media
  `hammer2_bulkfree_pass` (`:1164`). Sustained kernel work → **DoS** from an
  unprivileged user. (Not exercised live — would wedge the guest; identical
  missing guard at `:144-145` proves the same bypass.)
- **BULKFREE_ASYNC**: same handler; with `NULL` data it returns `EINVAL` at
  `:1103` without the scan — a safe probe that returns `EINVAL` (not `EPERM`),
  proving the priv check was bypassed.

## Reproduction evidence (unpatched `#0` kernel)
```
uid=1001(maxx) ...  ./df0815 /etc
[DEBUG_DUMP    ] ioctl rc=0 errno=0 (success)        ← priv check BYPASSED, handler ran
[BULKFREE_ASYNC] ioctl rc=-1 errno=22 (Invalid arg)  ← EINVAL not EPERM => priv check bypassed
```
msgbuf (read by maxx, `unprivileged_read_msgbuf=1`):
```
i-chain 0xfffff80116981080 inode.0  ...   ← leaked kernel pointer
      p=0xfffff80116980f00 [pflags 00046102 prefs 0]   ← leaked parent pointer
```

## Why this is NOT memory corruption — no escalation chain
This is a privilege-check / authorization logic bug (CWE-862), not a memory-
corruption primitive. There is no slab write/UAF/double-free to convert. The
exploit chain deliverable here is the **impact ceiling**: unprivileged info
leak of kernel heap addresses (KASLR defeat) + an unprivileged sustained-DoS
vector (bulkfree scan). Demonstrated fully.

## Fix (authored, validated)
Add the same `if (error == 0)` guard the other admin ioctls already use, to
all three cases — see `fix.diff`. Minimal, targeted, matches the established
in-file pattern. Matches the finding markdown's `## Recommended fix` proposal
verbatim ("if(error==0) error=handler() for all 3 cases").

## Fix validation (Phase 8) — VALIDATED
Built single-fix kernel (`make -j6 nativekernel`, warm obj, ~6 min), installed
over bare `/boot/kernel/kernel` (+ `.debug`), rebooted:

| | unpatched `#0` (bug) | patched `#1` (fix) |
|---|---|---|
| `kern.version` | `#0: Thu Jul 2 06:02:54 UTC 2026` | `#1: Fri Jul 10 21:30:29 UTC 2026` |
| DEBUG_DUMP (maxx) | `rc=0 errno=0` (success + leak) | **`rc=-1 errno=1` (EPERM)** |
| BULKFREE_ASYNC (maxx) | `rc=-1 errno=22` (EINVAL) | **`rc=-1 errno=1` (EPERM)** |
| DEBUG_DUMP (root sanity) | n/a | `rc=0` (still works — privileged use preserved) |

Clean before/after. Root can still legitimately use the ioctls; only the
unprivileged path is now blocked. Fix is determinism-confirmed (2 runs).

## PoC changes
The seeded PoC folder did not exist (no markdown/PoC pre-seeded for DF-0815).
Authored fresh: `df0815.c` (trigger), `build.sh`, `run.sh`, `README.md`.
Key correctness fix during iteration: the `_IOWR` macro encodes
`sizeof(arg-type)` into the ioctl number, so `BULKFREE_SCAN/ASYNC` (which take
`struct hammer2_ioc_bulkfree`, 64 bytes) MUST use a struct of that exact size,
not `int` — otherwise the number mismatches and the kernel returns `EOPNOTSUPP`
(default case) instead of reaching the handler. Also: the target path must be
on the hammer2 mount (`/etc`, not `/tmp` which is tmpfs).

## Files
- `df0815.c` — trigger (DEBUG_DUMP safe probe + BULKFREE_ASYNC NULL probe)
- `build.sh` / `run.sh` — exact repro
- `build.log` / `run.log` / `run.2.log` / `run.3.log` — full untrimmed logs
- `leak_sample.txt` — harvested kernel `%p` pointers from msgbuf
- `env.txt` — guest environment
- `fix.diff` — git-apply-able fix
- `fix_build.log` — full single-fix kernel build output
- `fix_run.log` / `baseline_run.log` — before/after contrast
- `manifest.json` — catalog
