β¬’ DragonFlyBSD Kernel Audit
← triage Β· dashboard
DF-3223

prune_check_nlinks() NULL-deref: forged on-disk inode record with data_offset==0 makes HAMMERIOC_PRUNE read cursor->data->inode.nlinks from absolute kernel address 0x48

Summary

prune_check_nlinks() treats a 0 return from hammer_btree_extract_data() as 'data available' and immediately reads cursor->data->inode.nlinks, but hammer_btree_extract() returns 0 with cursor->data left NULL when the leaf element's on-disk data_offset == 0 (early return BEFORE the CRC gate, so no payload CRC forging needed). nlinks sits at offset 0x48 of struct hammer_inode_data, so the guard-less read is a supervisor load from fixed address 0x48. The helper runs for every live (delete_tid==0) HAMMER_RECTYPE_INODE record the reverse prune scan does not delete - any HAMMER1 fs whose inode record was corrupted or forged panics the kernel the moment root runs hammer prune / hammer cleanup (nightly on configured PFSs). VERIFIED 3/3 on stock INVARIANTS guest with fresh reset cycles: offline-patch the live inode leaf's data_offset to 0 (node CRC recomputed), mount RW, ioctl HAMMER_IOC_PRUNE_ALL -> Fatal trap 12 fault VA 0x48 'Stopped at hammer_ioc_prune+0x37f: cmpq $0,0x48(%rax)', guest dead. Fix validated (kernel #2 with NULL guard): identical PoC returns ioctl=0 with normal prune stats, zero traps, fs verified. Privileged ioctl (SYSCAP_NOVFS_IOCTL, same gate as DF-0910 Medium); untrusted-media + maintenance trigger; read-only fixed-address deref - DoS ceiling, no uid=0 chain. The data_offset!=0 variants hand the CRC engine a wild pointer (DF-3187-class) - covered by DF-3187's extract hardening. Verified identical unguarded deref in upstream master (file frozen since 2008).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3223 Β· 15 files
FileTypeDescriptionSize
README.md β€” 3.6 KB ↓ raw
VERDICT.md β€” 6.5 KB ↓ raw
patch_inode.c β€” 4.9 KB view raw
trigger_prune.c β€” 2.4 KB view raw
build.sh β€” 157 B view raw
run.sh β€” 212 B view raw
build.log β€” 1.8 KB view raw
run.log β€” 1.5 KB view raw
run.2.log β€” 604 B view raw
run.3.log β€” 668 B view raw
fixrun.log β€” 1022 B view raw
panic.txt β€” 1020 B view raw
env.txt β€” 138 B view raw
fix.diff β€” 646 B view raw
verdict.json β€” 5.6 KB view raw

DF-3223 β€” HAMMER1 prune_check_nlinks() NULL-deref: forged on-disk

inode record with data_offset == 0 β†’ cursor->data->inode.nlinks

kernel panic on hammer prune / HAMMERIOC_PRUNE

Root cause (two-part)

  1. hammer_btree_extract() sets cursor->data = NULL (sys/vfs/hammer/hammer_btree.c:705) and then, when the leaf's data_offset == 0, returns success (0) leaving the NULL in place (hammer_btree.c:728-731) β€” the early return happens before the CRC gate, so no CRC forging of the payload is needed.
  2. prune_check_nlinks() (sys/vfs/hammer/hammer_prune.c:321-350) treats hammer_btree_extract_data(cursor) == 0 as "data available" and immediately reads cursor->data->inode.nlinks (hammer_prune.c:330-332). With data_offset == 0 that is a load from absolute address 0x48 (nlinks sits at offset 72 of struct hammer_inode_data, hammer_disk.h:886-901).

Reached for every live (delete_tid == 0) HAMMER_RECTYPE_INODE record that the prune scan does not delete β€” i.e. on any HAMMER1 filesystem whose on-disk inode record was corrupted or forged, the moment anyone runs hammer prune / hammer cleanup (or issues HAMMERIOC_PRUNE directly).

Build (guest, root; cc 8.3 in-base)

cc -O -o patch_inode patch_inode.c
cc -O -I/usr/src/sys/vfs/hammer -o trigger_prune trigger_prune.c

Setup (root, once; DF-3187 harness style)

dd if=/dev/zero of=/home/maxx/h1.img bs=32k count=0 seek=327680   # 10G sparse
vnconfig -c -S 10g /dev/vn0 /home/maxx/h1.img
newfs_hammer -f -L test /dev/vn0          # version 7 (iscsi crc)
mount_hammer /dev/vn0 /mnt
echo hello > /mnt/f1
hammer synctid /mnt quick
umount /mnt && vnconfig -u vn0

./patch_inode /home/maxx/h1.img
# found live inode leaf @off=554205184: objid=000000010000051d loc=00000001
#   data_off=9000000021800250 data_len=128 -> 0
# patched node @off=554205184 (crc algo iscsi/v7)

vnconfig -c -S 10g /dev/vn0 /home/maxx/h1.img
mount -t hammer /dev/vn0 /mnt              # RW mount (prune rejects ronly)

Run (root β€” HAMMERIOC_PRUNE is caps_priv_check-gated)

./trigger_prune /mnt

Expected / observed (stock kernel #0 Jul 2 2026)

Fatal trap 12, fault virtual address 0x48, supervisor read data, page not present:

Stopped at hammer_ioc_prune+0x37f: cmpq $0,0x48(%rax)

β€” the literal compiled form of cursor->data->inode.nlinks (prune_check_nlinks is static and inlined). Reproduced 3/3 from fresh vm.sh reset with-src cycles; guest dead every time (panic.txt, run.log, run.2.log, run.3.log).

Expected / observed (kernel #2 + fix.diff)

ioctl returned 0, scanrecords=6 rawrecords=1 bytes=128, 0 Fatal trap in /var/log/messages, filesystem still fully functional (create/read/remove on /mnt, clean umount), guest up (fixrun.log).

Threat model

Privileged trigger + untrusted media: the forged image is the attacker input (removable disk / forensic image / mirrored stream); mounting it is root, and hammer cleanup runs hammer prune on configured PFSs nightly. Same gate as DF-0910 (Medium). No write primitive: the deref address is the fixed struct offset 0x48 β€” DoS only. Forged data_offset != 0 variants of this same call site additionally give the DF-3187-class wild kernel-read walk (CRC engine reads data_len bytes at the forged buffer pointer) β€” read-only as well.

Path

sys/vfs/hammer/hammer_prune.c:330-332 (missing NULL check), sys/vfs/hammer/hammer_btree.c:705+728-731 (extract returns 0 with cursor->data == NULL), sys/vfs/hammer/hammer_disk.h:886-901 (nlinks @ 0x48), sys/vfs/hammer/hammer_ioctl.c:72+78-84 (SYSCAP_NOVFS_IOCTL gate on HAMMERIOC_PRUNE).

VERDICT.md
↓ download raw

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.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

kernel #2 built in-guest from pristine tree + corrected fix.diff ('Hunk #1 succeeded at 329'); exact PoC re-run on an identically forged fresh image: ioctl returned 0, scanrecords=6 rawrecords=1 bytes=128, zero 'Fatal trap' in /var/log/messages, filesystem write/read/remove and clean umount verified, guest stayed up. Baseline (stock #0) panicked 3/3 at the same input.

fixrun.log (patched-kernel run: rc=0, 0 traps, UMOUNT_OK); run.log/run.2.log/run.3.log + panic.txt (baseline 3/3 fatal trap 12 @ 0x48); build.log (both kernel builds)
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #2: Tue Sep 8 18:43:09 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Evidence (decisive lines)

['panic.txt β€” full serial console of baseline run 1: Fatal trap 12, fault virtual address = 0x48, Stopped at hammer_ioc_prune+0x37f: cmpq $0,0x48(%rax)', 'run.log / run.2.log / run.3.log β€” 3/3 identical baseline panics on stock kernel #0, guest down each time', "fixrun.log β€” kernel #2 + fix.diff: ioctl returned 0, scanrecords=6 rawrecords=1 bytes=128, grep -c 'Fatal trap' -> 0, fs functional, UMOUNT_OK, guest up", 'build.log β€” both in-guest kernel builds (incl. the documented mangled-patch artifact on kernel #1)', 'patch_inode.c β€” offline forger: rewrites live inode leaf data_offset (elm+48) to 0 and fixes the node CRC (iscsi/v7)', 'trigger_prune.c β€” HAMMERIOC_PRUNE (HAMMER_IOC_PRUNE_ALL, full PFS#0 range) trigger']

PoC changes

Adapted the DF-3187 offline image patcher to target live PFS#0 inode records (rec_type=0x0001, delete_tid==0, obj_id>1, localization PFS-bits==0) rewriting data_offset (leaf elm offset 48) to 0 instead of data_len; wrote a new trigger issuing HAMMERIOC_PRUNE with HAMMER_IOC_PRUNE_ALL and a localization 0..2 / full obj_id range (kernel takes localization/obj_id only). Image built per DF-3187 harness (newfs_hammer v7, f1, synctid). RW mount required (prune rejects ronly).

Verified recommended fix

Add a cursor->data == NULL guard between hammer_btree_extract_data() and the nlinks read in prune_check_nlinks() (hammer_prune.c:331); see fix.diff.

Verdict

REPRODUCED and FIX VALIDATED. prune_check_nlinks() (sys/vfs/hammer/hammer_prune.c:330-332) calls hammer_btree_extract_data() and, on a 0 return, immediately dereferences cursor->data->inode.nlinks β€” but hammer_btree_extract() returns 0 with cursor->data == NULL when the on-disk leaf has data_offset == 0 (hammer_btree.c:705 + 728-731, early return BEFORE the CRC gate), making the read a load from absolute kernel address 0x48 (nlinks offset in struct hammer_inode_data). Any live (delete_tid==0) HAMMER_RECTYPE_INODE record forged/corrupted to data_offset=0 panics the kernel the moment HAMMERIOC_PRUNE scans it (hammer prune / hammer cleanup / direct ioctl; SYSCAP_NOVFS_IOCTL-gated at hammer_ioctl.c:72 β€” untrusted-media + root-maintenance threat model, same gate as DF-0910). 3/3 baseline runs on stock kernel #0 from fresh resets: Fatal trap 12, fault VA 0x48, 'Stopped at hammer_ioc_prune+0x37f: cmpq $0,0x48(%rax)' β€” the literal compiled form of the nlinks test β€” guest dead each time. fix.diff (NULL guard) built as kernel #2: identical PoC returns ioctl=0 with normal prune stats (6 scanned, 1 destroyed, 128 bytes), 0 Fatal traps, fs read/write/umount verified, guest up. No escalation: the deref address is the fixed struct offset 0x48 and both this and the forged-offset variants are read-only β€” no write primitive, no copyout.