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)
PoC verification
Evidence pack
findings/poc/DF-3223 Β· 15 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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)
hammer_btree_extract()setscursor->data = NULL(sys/vfs/hammer/hammer_btree.c:705) and then, when the leaf'sdata_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.prune_check_nlinks()(sys/vfs/hammer/hammer_prune.c:321-350) treatshammer_btree_extract_data(cursor) == 0as "data available" and immediately readscursor->data->inode.nlinks(hammer_prune.c:330-332). Withdata_offset == 0that is a load from absolute address 0x48 (nlinkssits at offset 72 ofstruct 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).
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
fixedkernel #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)
Confirmed kernel references
- sys/vfs/hammer/hammer_prune.c:330
- sys/vfs/hammer/hammer_prune.c:332
- sys/vfs/hammer/hammer_prune.c:321-350
- sys/vfs/hammer/hammer_prune.c:227
- sys/vfs/hammer/hammer_btree.c:705
- sys/vfs/hammer/hammer_btree.c:726-731
- sys/vfs/hammer/hammer_btree.c:736-738
- sys/vfs/hammer/hammer_disk.h:886-901
- sys/vfs/hammer/hammer_ioctl.c:72
- sys/vfs/hammer/hammer_ioctl.c:78-84
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.
No comments yet.