# DF-0792 — VERDICT

**Status:** REPRODUCED (Low-severity local DoS / kernel panic via NULL-pointer fetch)
**Fix:** VALIDATED (panic on `#0`, clean error return + guest stays up on `#1`)
**Class:** NULL-pointer dereference (CWE-476). No memory corruption, no info leak, no privesc.

## Mechanism (trigger → primitive → effect)

Three direct-I/O paths in `sys/vfs/hammer/hammer_io.c` follow the same
shape: look up the volume by `vol_no`, do the I/O if no error, and
**unconditionally** release the volume reference afterwards — even when
the lookup just returned NULL with an error set.

### Call site 1 — `hammer_io_direct_read` (`sys/vfs/hammer/hammer_io.c:1487-1501`)

```c
vol_no = HAMMER_VOL_DECODE(zone2_offset);
volume = hammer_get_volume(hmp, vol_no, &error);     /* may return NULL */
if (error == 0 && zone2_offset >= volume->maxbuf_off) /* guarded */
    error = EIO;
if (error == 0) {
    /* ...uses volume... */                            /* guarded */
}
hammer_rel_volume(volume, 0);                         /* BUG: unconditional */
```

### Call site 2 — `hammer_io_indirect_read` (`sys/vfs/hammer/hammer_io.c:1561-1600`)

Same shape; same trailing `hammer_rel_volume(volume, 0)` at line 1600.

### Call site 3 — `hammer_io_direct_write` (`sys/vfs/hammer/hammer_io.c:1726-1760`)

Same shape; trailing release at line 1760.

### Why NULL is reachable

`hammer_get_volume` (`sys/vfs/hammer/hammer_ondisk.c:421-448`) returns
NULL with `*errorp = ENOENT` whenever `vol_no` is not in the in-memory
volume RB tree:

```c
volume = RB_LOOKUP(hammer_vol_rb_tree, &hmp->rb_vols_root, vol_no);
if (volume == NULL) {
    *errorp = ENOENT;
    return(NULL);
}
```

It also returns NULL when `hammer_load_volume` fails (`*errorp` set,
`volume = NULL` at line 442).

`vol_no` is decoded from a zone-2 offset (`HAMMER_VOL_DECODE` extracts
bits 52-59) that came from `hammer_blockmap_lookup`
(`sys/vfs/hammer/hammer.h:1508-1526`). The default
`vfs.hammer.verify_zone == 0` (verified on this guest) skips
`hammer_blockmap_lookup_verify` and just translates the zone-X offset
to zone-2 verbatim, so an attacker-controlled `data_offset` in a B-Tree
leaf entry delivers an arbitrary `vol_no` straight into
`hammer_get_volume`. The leaf entry's `data_offset` lives on disk and
is attacker-controllable in a crafted image.

### Why the deref crashes

`hammer_rel_volume` (`sys/vfs/hammer/hammer_ondesk.c:528-542`) immediately
dereferences `volume`:

```c
void
hammer_rel_volume(hammer_volume_t volume, int locked)
{
    struct buf *bp;

    if (hammer_rel_interlock(&volume->io.lock, locked)) {   /* NULL deref */
        ...
    }
}
```

The first instruction of `hammer_rel_interlock` is a load from
`&volume->io.lock`, which at `volume == NULL` is a small positive offset
from zero. The kernel faults; on this build it lands in DDB:

```
Fatal user address access from kernel mode from cat at ffffffff80951940
Fatal trap 12: page fault while in kernel mode
fault virtual address	= 0x0
Stopped at      hammer_rel_interlock+0x20:      movl    (%r12),%ebx
```

### The `if (error == 0)` guards are correct but incomplete

The reviewer's claim that the volume is dereferenced via
`volume->maxbuf_off` / `volume->ondisk` / `volume->devvp` only inside
`if (error == 0)` blocks is correct — those are properly guarded. The
bug is *only* the trailing, unconditional release.

## Confirmed reproduction

`run.sh` builds a 2 GiB HAMMER v1 (`-V 6`) image, writes a 256 KiB file,
takes it offline, flips the `vol_no` byte of every zone-10 leaf entry
from `0` to `7` (recomputing the B-Tree node CRC so
`hammer_crc_test_btree` at `hammer_ondisk.c:1315` doesn't reject the
node), re-mounts, and `cat`s the file.

* **Unpatched `#0` kernel:** kernel panic, guest dies in DDB. See
  `panic.txt`.
* **Patched `#1` kernel (with `fix.diff`):** `cat` returns
  `No such file or directory` (the ENOENT from the missing volume,
  propagated up), guest stays up. `dmesg` shows
  `hammer_io_direct_read: failed @ 2070000022000000` (the
  `hdkprintf("failed @ %016jx\n", ...)` at `hammer_io.c:1504`, now
  reached only after the guarded release is skipped). See `fix_run.log`.

## Exploit chain

Not applicable. This is a NULL-pointer fetch — it crashes the kernel
cleanly with no write primitive, no info leak, no privilege change.
There is no escalation chain to develop. The realistic impact ceiling
is **local DoS** of an admin who mounts an untrusted HAMMER image.

## Fix

`fix.diff` makes the release conditional at all three call sites and
also adds a defense-in-depth NULL check inside `hammer_rel_volume`
itself (so any other caller that ever passes NULL cannot reintroduce
the panic). This matches the existing correct pattern in
`hammer_recover.c:1082` (`if (volume == NULL) ... break;`).

* `sys/vfs/hammer/hammer_io.c:1501` — `if (volume != NULL) hammer_rel_volume(volume, 0);`
* `sys/vfs/hammer/hammer_io.c:1600` — same guard
* `sys/vfs/hammer/hammer_io.c:1760` — same guard
* `sys/vfs/hammer/hammer_ondesk.c:533` — `if (volume == NULL) return;`

The fix is minimal and targeted at the root cause; it does not change
the error handling or volume lookup, only the unconditional release.

## PoC changes

There was no pre-existing PoC source for DF-0792 in the repo, so I
authored `corrupt_hammer.c`, `build.sh`, and `run.sh` from scratch.
The corruptor recomputes the B-Tree node CRC (crc32 for vol_version
<= 6) so the kernel does not reject the node before reaching the buggy
call site — without this step the kernel's `hammer_crc_test_btree`
check at `hammer_ondisk.c:1315` would return EIO before the bug could
fire, masking the bug entirely.

## Notes for the maintainer

* The trigger requires root to mount the image (`vfs.usermount=0`).
  This is consistent with the Low severity rating. The realistic threat
  is an admin mounting an untrusted HAMMER image (USB, downloaded
  snapshot, restore of unknown provenance).
* HAMMER is no longer the default filesystem (HAMMER2 is), but HAMMER
  v1 is still fully supported, so admins can still be presented with
  such images.
* The same defensive NULL check is independently useful in
  `hammer_rel_volume` itself — it converts any future bug of this
  shape into a silent no-op rather than a panic, at zero cost.
