# DF-0817 — VERDICT

## Verdict: REPRODUCED → FIX VALIDATED

**NULL pointer dereference in `h2_bulkfree_sync()` freemap lookup error path.**
CWE-476.  Medium severity (local DoS via fixed-offset NULL deref).

The bug is real, the trigger is reachable by an unprivileged user via the
DF-0815 privilege bypass on BULKFREE_SCAN, and the one-line fix is verified
to close it cleanly (panic on baseline #0 → clean EDOM return on patched #1).

## Mechanism (trigger → primitive → effect)

`h2_bulkfree_sync()` walks the in-memory bulkfree bitmap and, for each 4MB
chunk with activity, looks up the corresponding live freemap leaf:

```c
/* sys/vfs/hammer2/hammer2_bulkfree.c:1039 */
live_chain = hammer2_chain_lookup(&live_parent, &key_dummy,
                                  key, key + HAMMER2_FREEMAP_LEVEL1_MASK,
                                  &error, HAMMER2_LOOKUP_ALWAYS);
if (error) {                                            /* :1046 */
    kprintf("hammer2_bulkfree: freemap lookup "
            "error near %016jx, error %s\n",
            (intmax_t)data_off,
            hammer2_error_str(live_chain->error));      /* :1050  <-- BUG */
    break;
}
```

`hammer2_chain_lookup` descends the freemap tree from `live_parent` (the
`hmp->fchain` volume-freemap root) down through any FREEMAP_NODE levels to
the FREEMAP_LEAF.  The descent is in `sys/vfs/hammer2/hammer2_chain.c`:

```c
/* sys/vfs/hammer2/hammer2_chain.c:2473-2476 */
if (parent->error) {
    *errorp = parent->error;
    return NULL;
}
```

When ANY FREEMAP_NODE in the descent path has its on-disk data fail the
`check.freemap.icrc32` CRC (`hammer2_chain.c:1070-1072`, which sets
`chain->error = HAMMER2_ERROR_CHECK`), the lookup takes this branch on the
next iteration: `parent` is the corrupted FREEMAP_NODE, `parent->error` is
set, the function returns NULL with `*errorp` populated.  `error` is now
non-zero AND `live_chain` is NULL — exactly the precondition for the
deref at `:1050` to fault.

The fault address is fixed: `live_chain == NULL`, so `live_chain->error` is
the read at `&NULL + offsetof(hammer2_chain_t, error)` = `0x170`
(verified against `struct hammer2_chain` in `sys/vfs/hammer2/hammer2.h:324-342`;
the `error` field sits at byte offset 0x170 after `lock`, `core`, `rbnode`,
`bref`, `parent`, `hmp`, `pmp`, `diolk`, `dio`, `data`, `bytes`, `flags`,
`refs`, `lockcnt`).

`live_chain == NULL` is explicitly contemplated by the very next statement:

```c
/* sys/vfs/hammer2/hammer2_bulkfree.c:1054 */
if (live_chain == NULL) {
    /*
     * XXX if we implement a full recovery mode we need
     * to create/recreate missing freemap chains ...
     */
```

so the missing guard at `:1050` is plainly an oversight — the kprintf just
uses the wrong variable.  The local `error` already holds exactly the value
the message wants to print.

### Trigger path on the audit guest
- `HAMMER2IOC_BULKFREE_SCAN` reaches `hammer2_ioctl_bulkfree_scan()`
  (`hammer2_ioctl.c:1088`) which calls `hammer2_bulkfree_pass()`
  (`hammer2_bulkfree.c:513`) which calls `h2_bulkfree_sync()` (`:702`,
  inlined into the caller by gcc).
- Per **DF-0815**, `hammer2_ioctl()` does NOT guard the BULKFREE_SCAN case
  with `if (error == 0)` after the `caps_priv_check(cred,
  SYSCAP_NOVFS_IOCTL)` privilege check at `hammer2_ioctl.c:83`.  An
  unprivileged user holding any fd on a hammer2 mount (the audit guest's
  root fs is hammer2) reaches the handler directly.

### Effect
Fixed-virtual-address NULL read from kernel mode → fatal trap 12, page
fault, kernel panic, guest down.  Pure DoS — there is no write primitive,
no UAF, no controlled content, so there is no escalation chain.  (A
fixed-offset kernel NULL read does not give an attacker a controlled write
or a pointer-leak on this guest, so per Phase 6 of the procedure this is
documented as DoS and there is no `exploit.c`.)

## Reproduction evidence — unpatched `#0` baseline
Run as unprivileged uid 1001 (maxx) against a crafted hammer2 image
(8 GB image, freemap populated with 6 GB of incompressible data so the
freemap tree has a FREEMAP_NODE level, then the FREEMAP_NODE block's CRC
broken on disk by flipping 4 bytes inside it; mounted read-only at
`/mnt/h2t`):
```
$ ./df0817 /mnt/h2t
[*] uid=1001 euid=1001  opening '/mnt/h2t' on hammer2 mount
[*] issuing HAMMER2IOC_BULKFREE_SCAN
<guest panics here; ssh dies>
```
Serial log (`dfbsd-qemu/boot.log`):
```
hammer2: bulkfree buf=1M
hammer2: pass 0000000000000000-0000000200000000 (all media)
hammer2_bulkfree: Scanning DATA
hammer2_bulkfree: Scanning LOCAL
bulkfree lastdrop 1 0
hammer2_bulkfree - range 0000000014400c00-0000000200000000
chain 000000000020000f.05 meth=50 CHECK FAIL                          <- CRC fail on FREEMAP_NODE
freemap.icrc 276fe33c icrc32 1dd2a150 (32768)
dio 0xfffff80119548500 buf 0000000000200000,65536 bdata 0xfffff80055a76000/0xfffff80055a76000
Fatal user address access from kernel mode from df0817 at ffffffff8096410e
Fatal trap 12: page fault while in kernel mode
fault virtual address = 0x170                                      <- NULL + offsetof(hammer2_chain_t, error)
Stopped at hammer2_bulkfree_pass+0xdbe:  movl 0x170(%rax),%edi       <- reads *(NULL + 0x170) = live_chain->error
db>
```

The fault address `0x170` is the smoking gun — it is exactly
`offsetof(hammer2_chain_t, error)`, i.e. `live_chain->error` with
`live_chain == NULL`.

## Why this is NOT memory corruption — no escalation chain
The primitive is a fixed-virtual-address NULL **read** (the kernel reads
`*(NULL + 0x170)`), which on this guest immediately page-faults.  There is
no write, no UAF re-claim, no controlled content, no freed-object reuse,
and the faulting instruction is a `movl` (load) not a store.  Nothing the
attacker does between the trigger and the fault can convert this into a
controlled write or pointer forge — the read is the entire primitive, and
it always misses (NULL page is unmapped, page not present).  Per Phase 6
this is a "valid hard blocker: read-only primitive" — no escalation chain
exists, by construction.  Documented as DoS.

## PoC changes
The seeded PoC folder did not exist.  Authored fresh:
- `df0817.c` — unprivileged trigger (issues `HAMMER2IOC_BULKFREE_SCAN` via
  the DF-0815 privilege bypass on a hammer2 mount).
- `setup_image.sh` — root-side image preparation: newfs_hammer2 on an 8 GB
  sparse image, populate with 6 GB of `/dev/urandom` data (NOT `/dev/zero` —
  hammer2's default lz4 compression collapses zeros and the freemap tree
  ends up with no FREEMAP_NODE level, making the bug unreachable), unmount,
  corrupt the FREEMAP_NODE block's data CRC in every rotation slot of every
  2GB zone (the active slot is whichever the last sync picked), remount
  read-only.
- Key correctness fixes during iteration:
  1. **Image must use random data, not zeros.** Zeros compress to ~0 bytes;
     the freemap never grows a FREEMAP_NODE level and the bug is unreachable.
  2. **Image must be >4 GB.**  The FREEMAP root's blockset has only 4
     `blockref` slots (`HAMMER2_SET_COUNT=4`); each direct FREEMAP_LEAF
     covers 1 GB.  <=4 GB of allocations fits in direct leaves under the
     FREEMAP root with no FREEMAP_NODE indirection, and the bug's
     `parent->error` path is unreachable.
  3. **The FREEMAP_NODE block location is not fixed.**  hammer2 rotates the
     freemap on every modifying sync across 8 rotation slots per 2GB zone
     (`hammer2_freemap_reserve`, `hammer2_freemap.c:97-110`), so the
     "current" FREEMAP_NODE block's device offset varies between runs.  The
     setup script corrupts byte 0x100 of all 8 rotation slots of all zones
     that exist within the 8GB image — guaranteeing the active FREEMAP_NODE
     block is hit regardless of which rotation the kernel picked.
  4. **DragonFly `/bin/sh` `printf` does NOT understand `\xAA` hex escapes**
     (it emits the literal characters "xaa").  The setup script uses octal
     `\252\253\254\255` instead.

## Fix (authored, validated)
`fix.diff` — one-line change at `hammer2_bulkfree.c:1050`:

```diff
-                   hammer2_error_str(live_chain->error));
+                   hammer2_error_str(error));
```

Use the local `error` variable (already populated by the lookup) instead of
dereferencing the NULL `live_chain`.  This matches what the developer
clearly intended (the `if (live_chain == NULL)` check at `:1054`
demonstrates they expected NULL), and matches the established pattern in
the sibling `hammer2_freemap_try_alloc` error kprintf at
`hammer2_freemap.c:349-351` which uses `hammer2_error_str(chain->error)`
only after explicitly checking `chain != NULL && chain->error` at `:345`.

(The line at `:1073` — `hammer2_error_str(live_chain->error)` — is left
untouched; it is guarded by the `if (live_chain->error)` check at `:1069`,
so `live_chain` is guaranteed non-NULL there.)

## Fix validation (Phase 8) — VALIDATED

| | unpatched `#0` (bug) | patched `#1` (fix) |
|---|---|---|
| `kern.version` | `#0: Thu Jul  2 06:02:54 UTC 2026` | `#1: Fri Jul 10 22:35:22 UTC 2026` |
| `/boot/kernel/kernel` sha256 | (audit baseline) | `ba6d3e27b4908b3fb2562595a584484b9cf02148c2c1053db3def853e00ab13e` |
| PoC `BULKFREE_SCAN` rc | (panic, no return) | `rc=-1 errno=33 (EDOM)` |
| Guest after PoC | **down** (DDB panic prompt) | **up** (responsive) |
| dmesg signature | `Fatal trap 12 / fault virtual address = 0x170 / Stopped at hammer2_bulkfree_pass+0xdbe` | `chain ... CHECK FAIL / hammer2_bulkfree: freemap lookup error near ..., error Check Error / bulkfree pass statistics (100.00% storage processed)` |

Clean before/after.  Both runs used the **same** crafted image and the
**same** `df0817` binary — only the kernel changed.  On the patched kernel
the bulkfree scan still detects and reports the CRC error (so the file-
system-defect handling is preserved), it just no longer crashes on it.
Deterministic across 2 runs.

## Files
- `df0817.c` — trigger (unprivileged BULKFREE_SCAN via DF-0815 bypass)
- `setup_image.sh` — root-side image setup
- `build.sh` / `run.sh` — exact repro
- `panic.txt` — panic signature (proof)
- `baseline_run.log` / `baseline_boot.log` — full unpatched-#0 run
- `fix_run.log` / `fix_kernel_info.txt` — patched-#1 run + kernel identity
- `fix_build.log` — full single-fix kernel build (rc=0)
- `fix.diff` — git-apply-able fix
- `env.txt` — guest environment
- `manifest.json` — artifact catalog
