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

Zero-offset data_off on data-requiring bref types silently yields chain->data==NULL with error==0 β€” NULL panic or unkillable while(1) tsleep hang

Field Value
ID DF-2617
Status new
Severity Medium
CVSS 3.1 CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H
CWE CWE-476 NULL Pointer Dereference / CWE-835 Loop with Unreachable Exit Condition
File sys/vfs/hammer2/hammer2_chain.c
Lines 938-939
Area vfs
Confidence certain
Discovered 2026-08-28
Pass 2 (GLM 5.3 second pass)
Bucket hammer2
Reported pending
Known CVE none
CVE match novel

Summary

hammer2_chain_load_data() treats any data_off whose non-radix bits are zero as "no media reference" and returns silently β€” even when the bref type (INODE, INDIRECT, FREEMAP_NODE/LEAF, DATA) absolutely requires a data block. The chain is left with chain->data == NULL and chain->error == 0, defeating every downstream error check. A crafted INDIRECT parent then hits the debugging placeholder while (1) tsleep(parent, 0, "xxx", 0) in hammer2_chain_lookup β€” a permanent, non-interruptible hang of the kernel thread while it holds the chain exclusively locked plus the whole topology lock stack, wedging the mount; an INODE parent is NULL-dereferenced at chain.c:2496.

Root cause

chain.c:938-939: if ((chain->bref.data_off & ~HAMMER2_OFF_MASK_RADIX) == 0) return; β€” no error set, no type check (legitimate only for embedded DIRENTs with radix 0). Reachable sinks with data==NULL/error==0: (a) hammer2_chain_lookup INODE case derefs parent->data->ipdata.meta.op_flags at chain.c:2496 after the parent->error check at 2473 passes β†’ NULL-page read panic; (b) INDIRECT case (parent installed at chain.c:2682-2688) reaches chain.c:2524-2529: kprintf + while (1) tsleep(parent, 0, "xxx", 0) β€” flags 0 means no PCATCH, timeout 0 means forever; the xop backend thread is unkillable and holds the chain lock; repeatable triggers strand one backend thread each until the xop pool is exhausted; (c) hammer2_chain_create (chain.c:3243-3247), hammer2_chain_base_and_count (chain.c:1244-1247) and hammer2_chain_indirect_maintenance (chain.c:4084-4088, 4177) compute base = &parent->data->npdata[0] (small offset from NULL) and hammer2_chain_countbrefs dereferences base[count-1].type at chain.c:1297 β†’ wild near-NULL read panic. Freemap-allocated offsets are never 0 on legitimate images.

Threat model & preconditions

  • Attacker position: crafted filesystem image, mounted; unprivileged user triggers via ordinary path resolution (ls, stat, readdir) descending through the crafted indirect block.
  • Privileges gained or impact: unkillable mount-wide deadlock (all filesystem operations block forever in D-state; repeated triggers exhaust the xop worker pool) or kernel NULL-page panic.
  • Required config or capabilities: mount of crafted image (root or vfs.usermount=1 + owned device).
  • Reachability: single path-resolution syscall.

Proof of concept

Build & run

craft image: INDIRECT bref with data_off = 0x0000000000000010 (radix 16,
offset 0), methods = CHECK_NONE, in a directory inode's blockset; recompute
parent check; mount; ls /mnt/crafted_dir

Expected output

ls never returns; kill -9 fails; ps shows the hammer2 xop thread asleep on
channel 'xxx'. INODE variant (data_off=0x0A): stat /mnt/x -> NULL deref at
chain.c:2496 -> kernel panic.

Impact

Permanent unkillable hang (mount-wide wedge, repeatable to strand the xop pool) or deterministic kernel panic from one unprivileged syscall after a crafted-image mount.

Only the embedded-DIRENT case may return without error; every other type with a zero-offset data_off must be errored so the existing parent->error checks (chain.c:2473, 2876) terminate lookups cleanly. Additionally guard the dereferences that run before any error check (chain_create at 3226-3238, indirect_maintenance at 4084).

--- a/sys/vfs/hammer2/hammer2_chain.c
+++ b/sys/vfs/hammer2/hammer2_chain.c
@@ -936,7 +936,23 @@ hammer2_chain_load_data(hammer2_chain_t *chain)
    if ((chain->bref.data_off & ~HAMMER2_OFF_MASK_RADIX) == 0) {
-       return;
+       /*
+        * No media reference.  Only legal for embedded dirents
+        * (bytes == 0).  Anything else read from media is
+        * corrupt; error the chain so lookups terminate instead
+        * of dereferencing NULL data or stalling forever.
+        */
+       if (chain->bref.type == HAMMER2_BREF_TYPE_DIRENT &&
+           chain->bytes == 0) {
+           return;
+       }
+       chain->error = HAMMER2_ERROR_CHECK;
+       krateprintf(&krate_h2chk,
+               "chain %016jx.%02x zero data_off\n",
+               chain->bref.data_off, chain->bref.type);
+       return;
    }

    hmp = chain->hmp;

References

  • chain.c:2524-2529 (the placeholder while(1) tsleep sink)

Timeline

  • 2026-08-28 Discovered during automated audit (pass 2, GLM 5.3).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2617 Β· 22 files
FileTypeDescriptionSize
README.md β€” 3.2 KB ↓ raw
VERDICT.md β€” 7.3 KB ↓ raw
forge_df2617.py β€” 8.8 KB view raw
mkbase2617.sh β€” 648 B view raw
trigger_P1.sh β€” 478 B view raw
trigger_P2.sh β€” 474 B view raw
trigger_H.sh β€” 1.3 KB view raw
build.sh β€” 900 B view raw
run.sh β€” 885 B view raw
run_H.log β€” 3.2 KB view raw
dmesg_H.txt β€” 1.3 KB view raw
panic.txt β€” 750 B view raw
panic2.txt β€” 743 B view raw
fix.diff β€” 2.9 KB view raw
fix_build.log β€” 5.7 MB ↓ download
fix_run.log β€” 3.6 KB view raw
env.txt β€” 1.3 KB view raw
code_hashes.txt β€” 476 B view raw
base2617.img β€” 64.0 MB ↓ download
P1.img β€” 64.0 MB ↓ download
P2.img β€” 64.0 MB ↓ download
H.img β€” 64.0 MB ↓ download

DF-2617 β€” PoC evidence pack

hammer2_chain_load_data() (sys/vfs/hammer2/hammer2_chain.c:938-939) treats bref.data_off == 0 as an early SUCCESS return for every blockref type, so data-requiring types (INODE, INDIRECT, DATA, media DIRENT, FREEMAP_*) end up with chain->data == NULL and chain->error == 0. Every consumer that trusts the (absent) error then dereferences NULL or wedges forever in the debugging loop at chain.c:2524-2529.

Verified sinks (all from mount/ls of a crafted image, root):

Variant Corrupted bref Observed (stock INVARIANTS kernel #0)
P1 volhdr sroot INODE data_off=0 Fatal trap 12 at hammer2_vfs_mount+0x1118: movq 0x90(%r8),%rax, fault VA 0x90 (vfsops.c:1309/1311 super-root ipdata.meta read, right after the useless schain->error check at vfsops.c:1285) β€” panic.txt
P2 PFS "testvol" INODE data_off=0 (in sroot's blockset) Fatal trap 12 at hammer2_pfsalloc+0x5ef: movzbl 0x87(%r13),%eax, fault VA 0x87 = ripdata->meta.pfs_type (vfsops.c:1560-1561 PFS-label scan, guarded by a chain->error check that load_data never arms) β€” panic2.txt
H first INDIRECT under PFS root data_off=0 mount succeeds; ls wedges in the while (1) tsleep(parent, 0, "xxx", 0) loop at chain.c:2524-2529: serial shows hammer2: unexpected NULL data on 0xfffff80119504500; ls PID in state D5, wchan h2coll, kill -9 has no effect; xop backend thread h2xop-testvol.23 permanently asleep on wchan xxx β€” run_H.log, dmesg_H.txt

Reproduce

# guest (root): build the base image (newfs_hammer2 -L testvol + 33 files)
sh mkbase2617.sh                                   # -> base2617.img

# host: forge the three malformed images
python3 forge_df2617.py base2617.img P1 P1.img     # sroot data_off = 0
python3 forge_df2617.py base2617.img P2 P2.img     # PFS inode data_off = 0
python3 forge_df2617.py base2617.img H  H.img      # INDIRECT data_off = 0
# (push images + trigger_*.sh to guest:/root/poc/df2617/)

# guest (root), stock kernel:
sh trigger_P1.sh    # expect Fatal trap 12 in hammer2_vfs_mount  (panic.txt)
sh trigger_P2.sh    # expect Fatal trap 12 in hammer2_pfsalloc   (panic2.txt)
sh trigger_H.sh     # expect MOUNT_RC=0, then unkillable ls + kthread wchan 'xxx'

Success criteria (stock INVARIANTS kernel #0, uname in env.txt): * P1/P2: Fatal trap 12: page fault while in kernel mode on the serial console with the addresses above. * H: hammer2: unexpected NULL data on <chain> on console; ls process remains in D5/h2coll after kill -9; a h2xop-* kernel thread sits on wchan xxx forever; the mount is unusable.

With fix.diff applied (kernel #1) all three triggers fail cleanly (kprintf + EINVAL mount failure / EIO readdir, no panic, no hang) and the uncorrupted base image still mounts and lists normally (fix_run.log).

  • Full narrative: VERDICT.md
  • Machine verdict: verdict.json (schema: audit/persist_poc.py)
  • Verified fix: fix.diff (git-apply-able; validated by guest rebuild)

Forger technique (from DF-2616/DF-2620): CHECK_NONE (methods=0x00) every ancestor bref whose media block contains edited bytes, recompute the three volume-header CRC32Cs of every on-disk volhdr copy.

VERDICT.md
↓ download raw

DF-2617 VERDICT β€” zero data_off silent NULL in hammer2_chain_load_data

Status: REPRODUCED (panic x2 + unkillable hang) Β· Impact: dos (kernel NULL dereference panic from mount; permanent unkillable wedge from ls) Β· Fix: validated on guest (kernel #1 v3) β€” all three triggers fail cleanly, legit filesystem still mounts and lists.

Root cause (source trace)

hammer2_chain_load_data() (sys/vfs/hammer2/hammer2_chain.c:920) short-circuits when the blockref has no media offset:

938:    if ((chain->bref.data_off & ~HAMMER2_OFF_MASK_RADIX) == 0)
939:        return;

For blockref types that REQUIRE a media data block β€” INODE, INDIRECT, DATA, media DIRENT (>64-byte names), FREEMAP_NODE, FREEMAP_LEAF β€” this leaves chain->data == NULL and chain->error == 0. hammer2_chain_lock() returns 0 (chain.c:881), so every chain->error guard downstream of a lock/lookup is disarmed. (Contrast hammer2_chain_alloc() chain.c:185-192, which computes chain->bytes = 0 for radix 0 and documents the embedded case β€” the only legitimate radix-0 users are embedded dirents, whose names live in the bref itself (hammer2_chain_dirent_test() chain.c:5786-5795 reads bref.check.buf for names <= 64 bytes), and freshly allocated (HAMMER2_CHAIN_INITIAL) chains whose data_off is 0 until hammer2_chain_modify() allocates.)

Sinks verified on the guest (stock INVARIANTS kernel #0)

# Sink Trigger Observation
1 vfsops.c:1309-1324 β€” after if (schain->error) at 1285 passes silently: ripdata = &schain->data->ipdata; then ripdata->meta.pfs_clid (+0x90) / pfs_type P1: sroot INODE bref data_off=0 in volhdr, mount -o ro /dev/vn0@testvol Fatal trap 12, fault VA 0x90, Stopped at hammer2_vfs_mount+0x1118: movq 0x90(%r8),%rax (panic.txt)
2 vfsops.c:1555-1561 (hammer2_update_pmps PFS-label scan) β€” chain->error guard disarmed, ripdata = &chain->data->ipdata; β†’ hammer2_pfsalloc() reads meta.pfs_type (+0x87) P2: PFS "testvol" INODE bref data_off=0 inside sroot's blockset, mount Fatal trap 12, fault VA 0x87, Stopped at hammer2_pfsalloc+0x5ef: movzbl 0x87(%r13),%eax (panic2.txt)
3 chain.c:2524-2529 β€” hammer2_chain_lookup() INDORECT-parent case: kprintf("hammer2: unexpected NULL data") then while (1) tsleep(parent, 0, "xxx", 0); (no PCATCH, no timeout) H: first INDIRECT under PFS root data_off=0, mount OK, then ls /mnt/h2 (readdir uses HAMMER2_LOOKUP_ALWAYS, descends into the indirect, re-enters lookup with it as parent) mount RC=0; console: hammer2: unexpected NULL data on 0xfffff80119504500; ls PID 864 state D5, wchan h2coll, kill -9 ineffective (UNKILLABLE=yes); xop backend thread h2xop-testvol.23 asleep forever on wchan xxx; mount unusable (run_H.log, dmesg_H.txt)

The finding's cited sink chain.c:2496 (parent->data->ipdata.meta.op_flags for INODE lookup parents) is the same deref family as sinks 1/2 β€” in the mount flows it is shadowed by the vfsops derefs (which run first), and it is reached generically whenever a corrupt INODE is a lookup parent (e.g. a corrupted subdirectory). Both chain.c:2496 and 2524 are protected by the fix because hammer2_chain_lookup() checks parent->error at chain.c:2473 before the blockref-array switch at 2482 β€” once load_data arms the error.

PoC

forge_df2617.py (host, python3) builds three malformed images from base2617.img (guest mkbase2617.sh: newfs_hammer2 -L testvol, 33 files, sync, umount β€” forces INDIRECTs in the PFS root blockset):

  • P1 volhdr sroot INODE bref data_off = 0
  • P2 PFS "testvol" INODE bref data_off = 0 (CHECK_NONE the sroot bref)
  • H first INDIRECT bref under the PFS root data_off = 0 (CHECK_NONE sroot + PFS inode brefs)

Ancestor CHECK_NONE (methods=0x00) + volume-header CRC32C recompute per DF-2616/DF-2620 technique; only volhdr copy #0 carries the magic in these images (the forger patches every copy that does).

Threat model: mount of a crafted hammer2 image β€” root, or unprivileged with vfs.usermount=1 and an owned device (per the finding).

Fix (fix.diff β€” 3 hunks, validated by in-guest rebuild)

  1. hammer2_chain.c load_data: in the zero-data_off early return, set chain->error = HAMMER2_ERROR_EIO + kprintf for every type except embedded DIRENT and CHAIN_INITIAL chains. This arms every downstream chain->error / parent->error guard (chain.c:2473, vfsops.c:1285/1427/1555).
  2. hammer2_vfsops.c:1390 label scan: skip errored chains instead of strcmp()-ing chain->data->ipdata.filename (caught as Fatal trap 12 at VA 0x100, strcmp+0x10, during fix-validation iteration v2).
  3. hammer2_iocom.c:313 hammer2_update_spans: skip errored chains instead of reading ripdata->meta.pfs_clid (caught as Fatal trap 12 at VA 0x90, hammer2_autodmsg+0x273 on the iocom thread, during fix-validation iteration v2).

Coordination with DF-2616: orthogonal, not subsuming. DF-2616's hammer2_bad_data_off() treats (data_off & ~MASK_RADIX) == 0 as the legal "embedded, no media block" case and returns 0; DF-2617 covers exactly that case for data-requiring types. Both hunks can coexist: DF-2616 rejects bad geometry when an offset IS present, DF-2617 rejects the absent offset for types that require one.

Residual (documented, not fixed here): other chain->data->ipdata consumers reachable only from other operation paths (e.g. xops.c:403/539 on unlink, vfsops.c:2251 recovery scan) still deref without checking chain->error β€” with hunk 1 they now fail only if individually guarded; upstream should sweep all ->data-> derefs of lookup results for error checks.

Fix validation (guest, kernel #1 v3, uname in env.txt)

Trigger Stock kernel #0 Patched kernel #1
P1 mount Fatal trap 12 @ vfs_mount+0x1118 (VA 0x90) hammer2_chain_load_data: illegal zero data_off on bref type 1 β†’ hammer2_mount: error I/O Error reading super-root β†’ mount: Invalid argument (EINVAL), guest up (fix_run.log)
P2 mount Fatal trap 12 @ pfsalloc+0x5ef (VA 0x87) kprintf + I/O error scanning PFS labels + PFS label I/O error β†’ EINVAL, guest up
H mount+ls unkillable D5/h2coll ls, kthread wchan xxx mount OK, ls returns (empty/short listing), no hang, no xxx wchan, umount clean
control base2617.img (baseline: mounts, 33 files) mounts, 33 files listed, umount clean (fix_control.log)
root fs (vbd0s1d@ROOT) boots boots, hammer2 root fs normal across 3 rebuild/reboot cycles

Honest classification notes

  • The panic and the hang both require mounting a crafted image. On the stock guest vfs.usermount=0, so root is needed; the finding's threat model (vfs.usermount=1 + owned device) applies unchanged.
  • Sink 3 is a permanent, unkillable kernel-side wedge: the sleeping xop thread holds the chain topology lock, so every subsequent operation on the mount piles up; the mount can never be unmounted; the only recovery is a reboot. That is a full local DoS of the machine's storage subsystem, not just of one directory.
  • Sink 1/2 are read-only NULL derefs at fixed near-NULL VAs (0x90/0x87) β€” not directly exploitable for code execution on this kernel config (no way to map page 0), hence DoS classification, matching the finding's Medium severity.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

Validated end-to-end on the guest: baseline stock kernel #0 reproduces all three failure modes (2 panics + unkillable hang); with fix.diff (3 hunks: chain.c load_data error-arming, vfsops.c label-scan guard, iocom.c update_spans guard) built via make -j6 nativekernel + installkernel, P1 mount fails cleanly (EINVAL, 'illegal zero data_off on bref type 1' + 'error I/O Error reading super-root'), P2 mount fails cleanly (EINVAL, 'I/O error scanning PFS labels' + 'PFS label not found'), H ls returns with clean per-entry ENOENT, no 'xxx' wchan / no stuck D-state threads, umount clean, and the uncorrupted control image mounts and lists all 33 files with clean umount. Two intermediate iterations (v1, v2) each caught a further unguarded consumer (vfsops.c:1392 strcmp crash VA 0x100; iocom.c:313 autodmsg crash VA 0x90) and added the corresponding guard - both crashes documented in fix_run.log history/VERDICT.md.

['fix.diff', 'fix_build.log (37912 lines, full untrimmed nativekernel build, PATCH_RC=0 BUILD_RC=0 INSTALL_RC=0)', 'fix_run.log (P1/P2 EINVAL + kprintf; H no-hang ls output, NO_STUCK_THREADS, UMOUNT_OK, control 33 files)']
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #1: Fri Aug 28 19:06:12 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Evidence (decisive lines)

['panic.txt: P1 Fatal trap 12, fault VA 0x90, Stopped at hammer2_vfs_mount+0x1118: movq 0x90(%r8),%rax', 'panic2.txt: P2 Fatal trap 12, fault VA 0x87, Stopped at hammer2_pfsalloc+0x5ef: movzbl 0x87(%r13),%eax', "run_H.log: MOUNT_RC=0; ls PID 864 state D5 wchan h2coll; UNKILLABLE=yes after kill -9; kthread h2xop-testvol.23 wchan xxx; dmesg_H.txt: 'hammer2: unexpected NULL data on 0xfffff80119504500'", "fix_run.log: patched kernel #1 - P1/P2 mount: Invalid argument with 'illegal zero data_off on bref type 1' + 'error I/O Error reading super-root'/'PFS label not found'; H ls returns (clean per-entry ENOENT), NO_STUCK_THREADS, UMOUNT_OK, control image lists 33 files", 'VERDICT.md: full narrative incl. two intermediate fix iterations (strcmp+0x10 VA 0x100 crash via vfsops.c:1392; hammer2_autodmsg+0x273 VA 0x90 crash via iocom.c:313) that motivated fix hunks 2 and 3']

PoC changes

Seed sketch had no runnable forger. Wrote forge_df2617.py (walks volhdr->sroot->PFS inode incl. INDIRECT arrays, sets data_off=0 on the chosen bref, CHECK_NONE ancestors per DF-2616/DF-2620 technique, recomputes all volhdr CRC32Cs - note: an early version wrote CRCs to a python slice copy and had to be fixed to patch the bytearray in place); wrote mkbase2617.sh (newfs + 33 files to force INDIRECTs in the PFS root blockset); triggers P1/P2/H capture panic/hang evidence in-guest. Fix evolved over 3 iterations: v1 (load_data error only) left vfsops.c:1392 strcmp crash (VA 0x100); v2 added label-scan guard but hit hammer2_autodmsg+0x273 (iocom.c:313 update_spans, VA 0x90); v3 added the iocom guard - all triggers then clean.

Verified recommended fix

In hammer2_chain_load_data's zero-data_off early return, set chain->error = HAMMER2_ERROR_EIO (+kprintf) for all bref types except embedded DIRENT and HAMMER2_CHAIN_INITIAL chains, and add chain->error guards at the consumers that deref chain->data immediately after lookup (vfsops.c:1390 label scan, iocom.c:313 update_spans); see fix.diff.

Verdict

CONFIRMED on the QEMU guest (stock INVARIANTS kernel #0). hammer2_chain_load_data (chain.c:938-939) early-returns SUCCESS when bref.data_off has no media offset, leaving chain->data==NULL and chain->error==0 for data-requiring bref types; every downstream chain->error/parent->error guard is disarmed. Three sinks demonstrated from mount/ls of crafted images: (P1) sroot INODE data_off=0 -> Fatal trap 12, fault VA 0x90, Stopped at hammer2_vfs_mount+0x1118 movq 0x90(%r8),%rax (vfsops.c:1309-1324 ipdata read after the useless schain->error check at 1285); (P2) PFS INODE data_off=0 -> Fatal trap 12, fault VA 0x87, Stopped at hammer2_pfsalloc+0x5ef movzbl 0x87(%r13),%eax (vfsops.c:1555-1561 PFS-label scan); (H) INDIRECT data_off=0 -> mount succeeds, ls descends into the indirect and wedges forever in the debugging loop at chain.c:2524-2529 while(1) tsleep(parent,0,"xxx",0): console 'hammer2: unexpected NULL data on 0xfffff80119504500', ls PID stuck D5/wchan h2coll, kill -9 ineffective, xop backend thread h2xop-testvol.23 asleep on wchan 'xxx' forever, mount permanently unusable. Both cited sink families in the finding are real; the chain.c:2496 parent->data->ipdata deref specifically is shadowed in the mount flows by the vfsops derefs (same silent-NULL class) and is protected by the fix via the parent->error check at chain.c:2473. Fix (3 hunks: load_data arms HAMMER2_ERROR_EIO for zero data_off on non-embedded/non-INITIAL types; vfsops label-scan skips errored chains before strcmp; iocom hammer2_update_spans skips errored chains) validated by in-guest nativekernel rebuild: P1/P2 mounts fail cleanly with EINVAL + kprintf, H ls returns with clean ENOENT/EIO errors, no panic, no hang, no 'xxx' wchan, umount clean, and the uncorrupted control image still mounts and lists all 33 files. Impact dos (not privesc): the NULL derefs read fixed near-NULL VAs (0x90/0x87) and the hang is a permanent unkillable wedge; all require mounting a crafted image (root, or unprivileged with vfs.usermount=1 + owned device).