DF-0858 — VERDICT
================

**Verdict: REPRODUCED** (unbounded dive depth / no cycle detection → unkillable kernel CPU spin; root-only mount + unpriv read/stat trigger; no corruption → no escalation; valid hard blocker applies)

**Status:** reproduced (live kernel hang on the unpatched kernel + deterministic harness + live fix validation)
**Impact:** `dos` (unkillable kernel CPU spin from a crafted HPFS image; the spinning process cannot be killed because SIGKILL cannot be delivered while in kernel mode — only `vm.sh reset` recovers)
**Confidence:** certain

---

## Mechanism (root cause, traced path:line)

`hpfs_hpbmap()` (`sys/vfs/hpfs/hpfs_alsubr.c:62`) translates a file logical
block number `bn` to a disk offset by walking the on-disk allocation tree of
AlNodes (interior) and AlLeaves (extents).  The walk is driven by a `dive:`
label and a `goto dive` with **no depth counter, no visited-set, and no cycle
detection**:

| line | code | comment |
|------|------|---------|
| `sys/vfs/hpfs/hpfs_alsubr.c:78`  | `dive:` | label — entry and re-entry |
| `sys/vfs/hpfs/hpfs_alsubr.c:79`  | `if (abp->ab_flag & AB_NODES)` | interior-node branch |
| `sys/vfs/hpfs/hpfs_alsubr.c:80`  | `for (i=0; i<abp->ab_busycnt; i++, anp++)` | iterate AlNodes |
| `sys/vfs/hpfs/hpfs_alsubr.c:82`  | `if (bn < anp->an_nextoff)` | select the child AlSec |
| `sys/vfs/hpfs/hpfs_alsubr.c:89`  | `bread(hp->h_devvp, dbtodoff(anp->an_lsn), DEV_BSIZE, &bp)` | read child AlSec |
| `sys/vfs/hpfs/hpfs_alsubr.c:99`  | `if (asp->as_magic != AS_MAGIC) ... EINVAL` | magic check (passes for forged image) |
| `sys/vfs/hpfs/hpfs_alsubr.c:106` | `abp = &asp->as_ab;` | descend into child AlBlk |
| `sys/vfs/hpfs/hpfs_alsubr.c:110` | `goto dive;` | **NO depth counter, NO visited-set, NO cycle detection** |

There is **no count of how many times `dive:` is entered**.  A crafted HPFS
image with two AlSecs that point at each other (A→B→A) drives the loop into
an **infinite dive**:

```
  file fnode.fn_ab (AB_NODES, 1 alnode)  ->  AlSec A
  AlSec A.as_ab (AB_NODES, 1 alnode)     ->  AlSec B
  AlSec B.as_ab (AB_NODES, 1 alnode)     ->  AlSec A   *** CYCLE ***
```

For any file offset `bn` (with `an_nextoff = 0xFFFFFFFF` on every alnode),
each iteration:
1. finds the single alnode (`bn < 0xFFFFFFFF` is always true),
2. `brelse()`s the previous buffer,
3. `bread()`s the child AlSec — **after the first pass both AlSec buffers
   are `B_CACHE`, so `bread()` returns immediately = tight kernel CPU spin**,
4. descends into the child AlBlk and `goto dive`s.

The kernel never returns to userspace, so the process is **unkillable**
(`SIGKILL` cannot be delivered while in kernel mode) and one CPU pegs at
100 %.  The guest becomes unresponsive and only a hard `vm.sh reset`
recovers.

## Trigger path (realistic)

`vfs.usermount = 0` on this guest, so mounting requires root.  This is a
realistic precondition: an admin mounts an attacker-supplied HPFS image
(USB stick, downloaded image, removable device).  Once mounted, the
trigger is **fully unprivileged**:

```
ls /mnt                # directory readdir — does NOT hit the bug
cat /mnt/FILE          # regular file read → VOP_READ → hpfs_read
                       # → hpfs_hpbmap → infinite dive → CPU peg, unkillable
stat /mnt/FILE         # VOP_GETATTR → hpfs_getattr → hpfs_hpbmap → same
```

Even a `stat(2)` triggers the dive because `hpfs_getattr` consults the
allocation tree for the file size.

## Reproduction evidence

### Deterministic harness (`harness.c`)

A faithful userspace transcription of the exact `dive:` loop at
`hpfs_alsubr.c:78-110`, against the exact on-disk struct layouts from
`hpfs.h` (alblk_t 8 B, alnode_t 8 B, fnode.fn_abd[0x60], alsec.as_abd[0x1E0]).
The harness has a depth-counter escape hatch (`DEPTH_CAP`) so it can OBSERVE
the loop running forever instead of actually hanging.  Three cases:

- **Case 1** — cyclic image (A→B→A), UNPATCHED (`DEPTH_CAP=1000`): harness
  dives 1000 times A↔B before the cap trips → "WOULD LOOP FOREVER".  This
  proves the kernel code has no cap and would loop forever.
- **Case 2** — cyclic image (A→B→A), FIXED (`DEPTH_CAP=HPFS_DIVE_MAX=20`):
  harness trips at depth 20 → "EINVAL — dive cap tripped, loop broken".
  This proves the proposed fix closes the bug.
- **Case 3** — control image (A→leaf AlSec), UNPATCHED: harness dives
  exactly 2 levels (fnode→A→leaf) and resolves bn=0 normally.  This proves
  the CYCLE is the cause, not the image structure.

Output (`run.log`):
```
--- Case 1: cyclic image (A->B->A), UNPATCHED (no depth cap) ---
  [hpbmap] dive depth=0  node[0]: an_nextoff=0xffffffff an_lsn=0x60  (bn=0x0)
  [hpbmap] dive depth=1  node[0]: an_nextoff=0xffffffff an_lsn=0x80  (bn=0x0)
  [hpbmap] dive depth=2  node[0]: an_nextoff=0xffffffff an_lsn=0x60  (bn=0x0)
  ...A↔B bounce repeats...
  [hpbmap] dive depth=1000 node[0]: an_nextoff=0xffffffff an_lsn=0x60 (bn=0x0)
  [hpbmap] WOULD LOOP FOREVER: hit depth cap 1000 at bn=0x0
  result: DEPTH-CAP TRIPPED (kernel: would loop forever)

--- Case 2: cyclic image (A->B->A), FIXED (cap = HPFS_DIVE_MAX) ---
  result: EINVAL — dive cap tripped, loop broken (FIX WORKS)

--- Case 3: control image (A->leaf), UNPATCHED (proves cycle is cause) ---
  [hpbmap] dive depth=0  node[0]: an_nextoff=0xffffffff an_lsn=0x60  (bn=0x0)
  [hpbmap] dive depth=1  node[0]: an_nextoff=0xffffffff an_lsn=0x90  (bn=0x0)
  [hpbmap] dive depth=2  leaf[0]: al_off=0x0 al_len=0x80 al_lsn=0x91 (bn=0x0)
  [hpbmap] FOUND: bn=0x0 -> disk lsn 0x91 (depth=2)
  result: FOUND normally (loop terminated at leaf)

=== SUMMARY ===
DF_0858_BUG_CYCLIC_DIVE_LOOPS_FOREVER=1
DF_0858_FIX_DEPTH_CAP_BREAKS_LOOP=1
DF_0858_CONTROL_TERMINATES_NORMALLY=1
DF_0858_BUG_CONFIRMED=1
```

### Live (DragonFly 6.5-DEVELOPMENT #0, X86_64_GENERIC, INVARIANTS ON)

`craft_img.py` builds an 80 KB minimal HPFS image (SuperBlock + SpareBlock +
bitmap dir + bitmap band + root dir fnode + dirblk with one "FILE" dirent +
a regular-file fnode whose `fn_ab` is `AB_NODES` with one AlNode pointing
to AlSec A, plus AlSec A→B and AlSec B→A).  Root mounts it; unprivileged
user does `cat /mnt/FILE`:

```
mount_hpfs -o ro /dev/vn0 /mnt  → MOUNT_RC=0
(12-second timeout on `cat /mnt/FILE` as maxx → times out)
guest becomes unresponsive; vm.sh status ⇒ down
```

The guest wedged with **NO panic in `boot.log`** — exactly the signature of
an unkillable kernel CPU spin (vs a panic, which would leave a `fatal trap`
or `panic:` in `boot.log`).  Only `vm.sh reset with-src` recovered.  This is
the unambiguous live DoS.

## Exploit chain / impact ceiling

This bug produces a **control-flow** primitive (infinite kernel loop) but
**not a memory-corruption** primitive.  The dive loop only dereferences
AlSec buffers and reads `an_nextoff`/`an_lsn`/`al_off`/`al_len`/`al_lsn`
fields — it does not write attacker-controlled bytes anywhere.  Therefore
the Phase-6 escalation chain does not apply: **the valid hard blocker
"primitive is genuinely read-only / no write capability" applies** (Phase 6
hard blocker #1).

Realistic impact ceiling:

1. **Unkillable kernel CPU spin / hard DoS** — a single `cat` or `stat` on
   the mounted cyclic image pegs one CPU at 100 % forever and the process
   cannot be killed (`SIGKILL` cannot deliver in kernel mode).  The guest
   becomes unresponsive.  Deterministic on the default GENERIC kernel.
2. No memory corruption ⇒ no info leak, no privilege escalation.

## Fix (`fix.diff`)

Add a `depth` counter to `hpfs_hpbmap()` that is incremented at every
`dive:` iteration.  When the counter exceeds `HPFS_DIVE_MAX` (= 20 — far
above any legitimate allocation-tree depth; each AlSec fan-out holds up to
60 AlNodes, so even a maximally-fragmented volume reaches only a handful
of levels), log a `kprintf` and return `EINVAL`.  This rejects cyclic or
corrupt AlSec trees at the dive point instead of looping forever.

The fix is minimal (one logical change: depth counter + cap check at the
dive label) and targeted at the confirmed root cause.  It does **not**
conflict with the DF-0857 fix (which validates `ab_busycnt` against the
container max — an orthogonal issue); the two fixes are complementary.

## Fix validation (Phase 8)

Applied `fix.diff` to in-guest `/usr/src` (`patch -p1`), rebuilt only the
`hpfs.ko` KLD module (`make` in `/usr/src/sys/vfs/hpfs`), installed to
`/boot/kernel/hpfs.ko` (sha256 `d23a53566d6ddd0f8c80a1d261ddba7f554444a4f36748ef4b0153a243db6498`),
re-mounted the same cyclic image, re-ran `cat /mnt/FILE` (3× for
determinism):

| Kernel / module            | `cat /mnt/FILE`                                   | dmesg                                                           | guest |
|----------------------------|---------------------------------------------------|-----------------------------------------------------------------|-------|
| #0 + unpatched hpfs.ko     | **HANG → guest wedged (no panic)**                | (none — infinite spin, no panic signature)                      | DOWN  |
| #0 + PATCHED hpfs.ko       | `EINVAL: Invalid argument` (0.00 real, ×3)        | `hpfs_hpbmap: dive depth 22 exceeds 20 (cyclic or corrupt AlSec tree)` | UP    |

Fix closes the bug: the cyclic dive is now broken by the depth cap at
depth 22 (2 levels past `HPFS_DIVE_MAX=20` — fnode dive + AlSec A dive
before the cap is checked at the second entry to `dive:`), `hpfs_hpbmap`
returns `EINVAL`, `cat` returns immediately, the guest stays up, and the
dmesg message clearly identifies the cyclic-or-corrupt tree.  The fix is
deterministic across 3 runs.

## PoC changes

The runner created the entire evidence pack from scratch (the finding
folder did not exist).  Files authored:
- `README.md` — finding summary, build/run, expected vs fixed behaviour
- `harness.c` — deterministic dive-loop harness (faithful transcription +
  depth-counter escape + 3 cases: cyclic-bug, cyclic-fix, control)
- `craft_img.py` — HPFS image crafter (cyclic AlSec A↔B and control A→leaf)
- `df858.img` — crafted 80 KB HPFS image with cyclic A↔B (binary, for live test)
- `df858-control.img` — control image: A→leaf AlSec (loop terminates normally)
- `build.sh` / `run.sh` — exact repro scripts
- `fix.diff` — git-apply-able fix: dive-depth cap (`HPFS_DIVE_MAX=20`)
- `build.log`, `run.log`, `fix_build.log`, `fix_run.log`, `env.txt` — full logs
- `manifest.json` — artifact catalog
- `VERDICT.md` — this file
