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.
Recommended fix
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) tsleepsink)
Timeline
- 2026-08-28 Discovered during automated audit (pass 2, GLM 5.3).
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-2617 Β· 22 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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.
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)
- 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 downstreamchain->error/parent->errorguard (chain.c:2473, vfsops.c:1285/1427/1555). - hammer2_vfsops.c:1390 label scan: skip errored chains instead of
strcmp()-ingchain->data->ipdata.filename(caught as Fatal trap 12 at VA 0x100,strcmp+0x10, during fix-validation iteration v2). - 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+0x273on 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
fixedValidated 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)']
Confirmed kernel references
- sys/vfs/hammer2/hammer2_chain.c:938
- sys/vfs/hammer2/hammer2_chain.c:2496
- sys/vfs/hammer2/hammer2_chain.c:2524
- sys/vfs/hammer2/hammer2_chain.c:2473
- sys/vfs/hammer2/hammer2_vfsops.c:1285
- sys/vfs/hammer2/hammer2_vfsops.c:1309
- sys/vfs/hammer2/hammer2_vfsops.c:1392
- sys/vfs/hammer2/hammer2_vfsops.c:1555
- sys/vfs/hammer2/hammer2_iocom.c:313
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).
No comments yet.