# DF-3223 VERDICT — reproduced (panic, 3/3) and fix validated

**Finding.** `prune_check_nlinks()` in sys/vfs/hammer/hammer_prune.c
dereferences `cursor->data->inode.nlinks` at line 332 immediately after
`hammer_btree_extract_data(cursor)` returns 0 at line 330 — but
`hammer_btree_extract()` (sys/vfs/hammer/hammer_btree.c:681-776)
deliberately returns **0 with `cursor->data == NULL`** when the on-disk
leaf element carries `data_offset == 0` (it stores NULL at :705 and
early-returns at :728-731, *before* the CRC test at :764-765 — so no
payload CRC forging is even required).  `nlinks` is at offset
0x48 of `struct hammer_inode_data` (hammer_disk.h:886-901), so the
guard-less read is a load from absolute kernel address 0x48.

**Reachability.** `hammer_ioc_prune()` walks the whole B-Tree range in
reverse (hammer_prune.c:146-250) and calls `prune_check_nlinks()` for
every record it does *not* delete (:227).  The helper fires on any live
(`delete_tid == 0`) `HAMMER_RECTYPE_INODE` leaf (:326-329) — an ordinary
filesystem object — so the only precondition is that one inode record on
the mounted HAMMER1 filesystem is corrupted/forged to have
`data_offset == 0`.  Trigger: `hammer prune` / `hammer cleanup` or a
direct HAMMERIOC_PRUNE ioctl; the ioctl is privilege-gated
(caps_priv_check(SYSCAP_NOVFS_IOCTL), hammer_ioctl.c:72,78-84), so the
threat model is *untrusted filesystem media + root-triggered
maintenance* — identical to DF-0910/DF-3167.  Upstream master
(github.com/DragonFlyBSD/DragonFlyBSD, checked 2026-09-08) contains the
identical unguarded dereference; the file is frozen since 2008
($DragonFly: hammer_prune.c,v 1.19 2008/09/23).

**Reproduction (stock kernel #0 Jul  2 06:02:54, fresh cycles).**
Adapted the DF-3187 offline patcher (`patch_inode.c`) to rewrite
`data_offset` (leaf element offset 48, hammer_btree.h:170-177) of the
live inode record of `/mnt/f1` (objid 0x10000051d, data_len 128) to 0
and fix the B-Tree node CRC (iscsi/v7 auto-detected).  `trigger_prune.c`
issues HAMMERIOC_PRUNE with HAMMER_IOC_PRUNE_ALL over the full PFS#0 key
range on the RW-mounted forged image.  Result, 3 out of 3 runs from
fresh `vm.sh reset with-src` cycles:

    Fatal trap 12: page fault while in kernel mode
    fault virtual address   = 0x48
    fault code              = supervisor read data, page not present
    Stopped at  hammer_ioc_prune+0x37f: cmpq $0,0x48(%rax)

The faulting instruction *is* the compiled `cursor->data->inode.nlinks`
test (static helper inlined into hammer_ioc_prune) — the code-to-fault
correspondence is exact.  Guest dead every time (panic.txt = full
serial text of run 1; run.log / run.2.log / run.3.log).

**Impact ceiling — why no uid=0 chain.** The primitive is a kernel-mode
read from the *fixed* address 0x48: the attacker controls only whether
`cursor->data` is NULL (data_offset==0) — the deref address is a struct
constant.  Nothing is written, nothing is copied out, no attacker data
reaches a control datum.  The sibling variants of the same call site
(forged `data_offset != 0`) hand the CRC engine and the nlinks read a
wild buffer pointer (DF-3187 class) — still read-only walks with no
copyout of the read data and no write.  Both are DoS-grade; escalation
does not exist for this bug.  (Contrast DF-3167/DF-3168, which are
write-capable.)

**Fix validation.** `fix.diff` adds the missing
`if (cursor->data == NULL) return;` between the extract and the nlinks
read (git-apply-able against sys/; `git apply --check` verified).
Process note, documented for honesty: the first in-guest build (kernel
#1 18:24:10) crashed *identically* because my initial hand-written diff
carried a wrong hunk header (`@@ -327,6 +327,12 @@` for 9 context + 6
added lines); patch(1) "succeeded" while dropping the added `return;`,
producing `if (cursor->data == NULL) if (cursor->data->inode.nlinks)
return;` — and GCC constant-folded the NULL+nlinks load into the absolute
`movq 0x48,%rax` seen at `hammer_ioc_prune+0x65d`.  That crash is a
PoC/diff artifact, and incidentally re-confirms the faulting source
line.  After restoring the pristine file and applying the corrected,
mechanically generated fix.diff ("Hunk #1 succeeded at 329"), kernel #2
(Sep  8 18:43:09) was built (`make -j6 nativekernel`, BUILD_RC=0,
installkernel, reboot) and the exact same PoC re-run:

    ioctl returned 0 errno=0
    scanrecords=6 rawrecords=1 dirrecords=0 bytes=128
    grep -c "Fatal trap" /var/log/messages -> 0
    ls /mnt -> f1 ; write/read/remove /mnt/f2 -> OK ; umount -> UMOUNT_OK

The prune completed its normal work over the forged filesystem (it even
destroyed one legitimately-deleted record and freed 128 bytes), the
forged inode record was skipped by the guard, zero traps, filesystem
remained fully functional, guest stayed up.  Baseline vs patched:
identical input, opposite outcomes.

**Classification.** status=reproduced, impact=panic, confidence=certain.
Privileged trigger (mount + prune) on attacker-supplied media → local
kernel DoS; Medium severity consistent with DF-0910 calibration
(CVSS:3.1/AV:L/AC:L/PR:H/UI:N/S:U/C:N/I:N/A:H, CWE-476).

**Pass-2 negatives checked and killed** (so the clean result has weight):
no `stat[]`-array indexing exists (scalar counters only, hammer_ioctl.h:94-100);
`mod_tid != 0` divisions are well-defined (DF-0910 owns mod==0);
`nelms` bounded [0,43690] at hammer_prune.c:67, `24*nelms ≤ 1,048,560`
no int overflow (:101), kmalloc(0) legal (kern_slaballoc.c:888), single
copyin (:104), pointer restored on exit (:262), PRUNE_ALL+nelms!=0
rejected (:80-81); localization pfs-bits rejected at :69-72 and only the
inode's own pfs OR'd in (:87-93); EDEADLK retry re-initializes the cursor
(:114, :253-255) — no double-free of cursor data refs; the flusher-wait
unlock/lock idiom (:240-246) matches hammer_mirror.c:383-405 /
hammer_inode.c:2889-2895 (hammer_lock_cursor's discarded return is the
codebase-wide pattern; failures resurface as EDEADLK in the subsequent
iterate); prune_delete's `track=0` (:201-205) never touches
vol0_next_tid; `stat_realignments` is a dead field.  The DESTROY path's
consumption of forged data_offset/data_len by hammer_blockmap_free
(hammer_object.c:2512-2548 → hammer_blockmap.c:762-884, KKASSERT-only
validation) is the DF-3167/3187 trust-the-leaf family at an out-of-file
sink — not re-reported here.

## Fix status (batch validation)
Applied with the 36 sibling fix.diffs of this batch in findings/poc/_batch41/all_fixes.patch; single in-guest `make -j6 nativekernel KERNCONF=X86_64_GENERIC` completed RC=0 with zero errors under -Werror (full log: findings/poc/_batch41/fix_build8.log). Behavior A/B intentionally skipped for this Info-tier item.
