NULL deref in hammer2_inode_chain_and_parent when cluster has NULL chain slot β sibling hammer2_inode_chain handles correctly this does not
Summary
hammer2_inode.c:436-448 reads cluster chain under cluster_spin. :439 chain=NULL if clindex>=nchains OR :441 array[clindex].chain==NULL (explicitly permitted gap per hammer2_vfsops.c:838 "Gaps may remain" from repoint/incompletely-synchronized/degraded PFS). :453 parent=chain->parent derefs NULL unconditionally. Sibling hammer2_inode_chain :407-427 handles this correctly returns NULL when chain is NULL. Caller hammer2_chain.c:5678-5683 if(*chainp) return(*chainp)->error proves function CONTRACTUALLY EXPECTED to tolerate NULL returns. Trigger: multi-master/master-slave HAMMER2 PFS syncing thread hammer2_sync_slaves (synchro.c:417/:687) calls hammer2_inode_chain_and_parent on inode with NULL chain at slave index = panic. Also reachable from frontend XOPs (readdir/lookup/resolve hammer2_xops.c:96/298/472). Fix: return NULL when chain slot empty mirror hammer2_inode_chain() semantics + caller NULL guard.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-0796 Β· 15 files| File | Type | Description | Size | |
|---|---|---|---|---|
| df0796_harness.c | trigger-source | KLD harness: synthesises hammer2_inode with NULL cluster slot, calls hammer2_inode_chain_and_parent -> NULL deref | 4.9 KB | view raw |
| Makefile | build-script | bsd.kmod.mk build against /usr/src/sys | 261 B | β download |
| build.sh | build-script | wrapper: make SYSDIR=/usr/src/sys | 287 B | view raw |
| run.sh | run-script | wrapper: kldload ./df0796_harness.ko | 772 B | view raw |
| fix.diff | suggested-fix | one-hunk early NULL return mirroring hammer2_inode_chain(); git-apply-able, validated on #1 | 619 B | view raw |
| VERDICT.md | verdict | full narrative: mechanism, reproduction, exploit-chain rationale, fix validation | 6.7 KB | β raw |
| README.md | readme | file index + reproduce + expected output | 2.5 KB | β raw |
| build.log | build-log | full harness build output (final successful build) | 5.6 KB | view raw |
| run.log | run-log | baseline #0 run: NULL-deref panic signature | 1.1 KB | view raw |
| panic.txt | panic-signature | Fatal trap 12 / fault VA 0x118 / Stopped at hammer2_inode_chain_and_parent.cold.9+0x15 movq 0x118,%rax | 1.3 KB | view raw |
| fix_build.log | build-log | full single-fix nativekernel build output (rc=0) | 5.6 MB | β download |
| fix_run.log | run-log | before/after contrast: #0 panics, #1 SURVIVED (loaded 2x determinism) | 1.4 KB | view raw |
| env.txt | environment | uname, cc version, mount, HAMMER2 in GENERIC, kern.version | 598 B | view raw |
| ../fix_build_combined.log | build-log | Combined 41-finding kernel build (rc=0, -Werror clean) | 5.6 MB | β download |
| ../fix_build_summary.txt | build-summary | Summary of the combined 41-finding kernel build | 826 B | view raw |
DF-0796 β NULL deref in hammer2_inode_chain_and_parent
Finding: sys/vfs/hammer2/hammer2_inode.c:453 β
hammer2_inode_chain_and_parent() dereferences chain->parent
unconditionally, but chain can be NULL when the inode's cluster has a NULL
chain slot at clindex (an interior "gap" explicitly permitted by
hammer2_vfsops.c:838). β kernel NULL-deref panic (local DoS).
Severity: Medium (CVSS 3.1 AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H,
CWE-476).
Files
| file | purpose |
|---|---|
df0796_harness.c |
KLD harness: synthesises a hammer2_inode with a NULL cluster slot and calls the vulnerable function |
Makefile |
bsd.kmod.mk build against /usr/src/sys |
build.sh |
builds df0796_harness.ko |
run.sh |
loads the module (panic on #0, SURVIVED on #1) |
fix.diff |
the verified one-hunk fix (early NULL return mirroring hammer2_inode_chain()) |
VERDICT.md |
full narrative: mechanism, reproduction, fix validation |
build.log |
full compiler output of the harness build |
run.log |
baseline (#0) run: panic signature |
panic.txt |
panic excerpt from dfbsd-qemu/boot.log |
fix_build.log |
full output of the single-fix nativekernel build |
fix_run.log |
before/after contrast on #0 vs #1 |
env.txt |
guest environment |
manifest.json |
machine-readable artifact catalog |
Reproduce
The PoC must run as root inside the DragonFly guest (it loads a kernel module). Triggering the underlying bug from the unprivileged syscall surface additionally requires a mounted HAMMER2 PFS with a gapped cluster (degraded multi-volume slave); the harness reproduces that exact cluster state deterministically without needing a crafted multi-volume image.
# on the guest, as root:
cd findings/poc/DF-0796
./build.sh # -> df0796_harness.ko
./run.sh # #0 kernel: panic (guest dies in DDB)
# #1 kernel: "SURVIVED -- chain=0 parent=0"
Expected output
Bug present (unpatched #0):
DF-0796: invoking hammer2_inode_chain_and_parent on inode with NULL chain slot (clindex=0, nchains=2) Fatal trap 12: page fault while in kernel mode fault virtual address = 0x118 Stopped at hammer2_inode_chain_and_parent.cold.9+0x15: movq 0x118,%rax
(0x118 = offsetof(hammer2_chain, parent) from the NULL chain.)
Fixed (#1):
DF-0796: SURVIVED -- chain=0 parent=0 (bug is fixed or not present)
DF-0796 β NULL deref in hammer2_inode_chain_and_parent
Verdict
REPRODUCED (NULL-deref panic / DoS). FIX VALIDATED.
The bug is real and deterministic: hammer2_inode_chain_and_parent()
dereferences a chain pointer that can legitimately be NULL on a HAMMER2
cluster with an interior NULL chain slot (a "gap" β the cluster code
explicitly permits this state, hammer2_vfsops.c:838 "Cleanup trailing
chains. Gaps may remain."). A KLD harness that synthesises exactly that
cluster state and calls the function panics the unpatched #0 kernel
immediately, and is handled cleanly (returns NULL) on the single-fix #1
kernel built from the fix.diff in this folder.
Severity is a pure local DoS (kernel NULL deref at a fixed offset). No memory-corruption primitive exists (the fault is a read of a fixed offset from a NULL base; the kernel traps before any attacker-controlled write), so there is no escalation chain β see Exploit chain below.
Mechanism (trigger β primitive β effect)
sys/vfs/hammer2/hammer2_inode.c:436-453:
hammer2_chain_t *
hammer2_inode_chain_and_parent(hammer2_inode_t *ip, int clindex,
hammer2_chain_t **parentp, int how)
{
hammer2_chain_t *chain;
hammer2_chain_t *parent;
for (;;) {
hammer2_spin_sh(&ip->cluster_spin);
if (clindex >= ip->cluster.nchains)
chain = NULL; /* :439 NULL (case A) */
else
chain = ip->cluster.array[clindex].chain; /* :441 may be NULL (case B) */
if (chain) {
hammer2_chain_ref(chain);
hammer2_spin_unsh(&ip->cluster_spin);
hammer2_chain_lock(chain, how);
} else {
hammer2_spin_unsh(&ip->cluster_spin);
}
parent = chain->parent; /* :453 *** DEREF chain UNCONDITIONALLY *** */
...
When chain is NULL (case A: clindex >= nchains; case B: an interior gap
where array[clindex].chain == NULL but clindex < nchains), the function
falls through to parent = chain->parent and faults.
- Interior gaps are a legal runtime state:
hammer2_vfsops.c:820-844setsiroot->cluster.array[i].chain = NULLwhen a PFS slave's type is reset toHAMMER2_PFSTYPE_NONE, then trims only trailing NULLs (nchains = last_non_null + 1), so a slave removed from the middle of a multi-volume cluster leaves a NULL slot withclindex < nchains. - The sibling
hammer2_inode_chain()(hammer2_inode.c:407-427) handles the NULL case correctly (returns NULL), proving the intended API contract. - The caller
hammer2_chain.c:5678-5683checksif (*chainp) return (*chainp)->error;after the call β i.e. it contractually expects the function to tolerate returning NULL. The other callers (hammer2_synchro.c:417,687) are in the HAMMER2 syncer thread, invoked on every inode touched by user I/O on a multi-master / degraded PFS mount.
Reproduction (deterministic KLD harness)
df0796_harness.c is a loadable module that, on MOD_LOAD, kmallocs a
zeroed hammer2_inode, initialises its cluster_spin, sets
cluster.nchains = 2 with array[0].chain = array[1].chain = NULL (the
interior-gap state), and invokes hammer2_inode_chain_and_parent(ip, 0,
&parent, HAMMER2_RESOLVE_SHARED).
Result on #0 (unpatched):
DF-0796: invoking hammer2_inode_chain_and_parent on inode with NULL chain slot (clindex=0, nchains=2) Fatal trap 12: page fault while in kernel mode fault virtual address = 0x118 Stopped at hammer2_inode_chain_and_parent.cold.9+0x15: movq 0x118,%rax
fault VA 0x118 is exactly offsetof(hammer2_chain, parent) from a NULL
base β i.e. the unchecked chain->parent read at line 453. Guest dies in
DDB.
Exploit chain
None β this is a pure NULL-deref DoS, not memory corruption. The fault
is a supervisor read of a fixed kernel offset (0x118) from a NULL base.
The MMU traps before any attacker-controlled value is dereferenced or
written; there is no write primitive, no UAF, no type confusion, no
corruption to groom or convert. The only achievable effect is denying
service to the mounted HAMMER2 filesystem (kernel panic). Per the run
profile, no escalation chain is applicable; the deliverable is the
characterized panic + the validated fix.
Realistic impact ceiling: a mounted HAMMER2 PFS with a degraded/gapped
cluster (multi-volume master/slave where a mid-cluster slave is removed)
panics on the next syncer pass over any touched inode. Reachable from the
unprivileged syscall surface (file I/O triggers the syncer XOPs at
hammer2_synchro.c:417,687 and the backend lookup at
hammer2_chain.c:5678). Precondition is a specific (but legitimate, and
explicitly-supported) cluster configuration, hence Medium severity.
The fix (fix.diff)
Adds an early-return mirroring hammer2_inode_chain()'s NULL handling,
immediately after the if (chain) {β¦} else {β¦} block and before the
parent = chain->parent dereference:
if (chain == NULL) {
*parentp = NULL;
return NULL;
}
This is minimal and targeted at the root cause; it does not change the
locking protocol or the retry loop. It makes
hammer2_inode_chain_and_parent() honor the NULL-return contract that its
sibling and all three callers already assume.
Fix validation (Phase 8)
Built a single-fix kernel from /usr/src with only fix.diff applied
(make -j6 nativekernel KERNCONF=X86_64_GENERIC, rc=0), overwrote the
bare /boot/kernel/kernel with the stripped build (sha256
b0b47f55β¦), rebooted into 6.5-DEVELOPMENT #1, and re-ran the same
harness:
| Kernel | Result of kldload ./df0796_harness.ko |
|---|---|
#0 unpatched baseline |
Fatal trap 12, fault VA 0x118, Stopped at hammer2_inode_chain_and_parent.cold.9+0x15: movq 0x118,%rax β guest dead in DDB |
#1 single-fix |
KLDLOAD_RC=0, DF-0796: SURVIVED -- chain=0 parent=0, guest up (uptime healthy). Loaded twice for determinism. |
fix_status = fixed. Before/after contrast captured in fix_run.log
and panic.txt.
PoC changes
No prior PoC existed (the evidence pack was empty on spawn). Authored:
* df0796_harness.c β deterministic KLD harness that reproduces the
NULL-cluster-slot state and invokes the vulnerable function.
* Makefile β bsd.kmod.mk build against /usr/src/sys.
* build.sh / run.sh β runnable repro scripts.
* fix.diff β the verified one-hunk fix.
Reproduce
# inside the guest, as root:
cd findings/poc/DF-0796
./build.sh # produces df0796_harness.ko
./run.sh # on #0: panic (guest dies); on #1: SURVIVED
Fix verification
fixedVALIDATED the fix. Applied ONLY fix.diff to /usr/src (patch -p1, hunk #1 succeeded at 448), built single-fix kernel make -j6 nativekernel KERNCONF=X86_64_GENERIC (rc=0), overwrote bare /boot/kernel/kernel with kernel.stripped, rebooted to #1 (kern.version ts 13:40:09, symbol shifted 0x...f040->0x...f020). Re-ran the SAME kldload df0796_harness.ko: baseline #0 panics (Fatal trap 12, fault VA 0x118, Stopped at hammer2_inode_chain_and_parent.cold.9+0x15: movq 0x118,%rax, guest dead in DDB); patched #1 returns KLDLOAD_RC=0, prints 'DF-0796: SURVIVED -- chain=0 parent=0 (bug is fixed or not present)', guest stays up. Loaded twice (kldunload/kldload) for determinism, both survived. => fix closes the bug.
BEFORE (baseline #0, unpatched): Fatal trap 12: page fault while in kernel mode / fault virtual address = 0x118 / Stopped at hammer2_inode_chain_and_parent.cold.9+0x15: movq 0x118,%rax / db> (guest dead). AFTER (single-fix #1): KLDLOAD_RC=0 / DF-0796: SURVIVED -- chain=0 parent=0 (bug is fixed or not present) / 1:48PM up 1 min, load averages: 0.07, 0.04, 0.01 (guest healthy).
Confirmed kernel references
- sys/vfs/hammer2/hammer2_inode.c:436
- sys/vfs/hammer2/hammer2_inode.c:439
- sys/vfs/hammer2/hammer2_inode.c:441
- sys/vfs/hammer2/hammer2_inode.c:453
- sys/vfs/hammer2/hammer2_inode.c:407
- sys/vfs/hammer2/hammer2_vfsops.c:820
- sys/vfs/hammer2/hammer2_vfsops.c:838
- sys/vfs/hammer2/hammer2_chain.c:5678
- sys/vfs/hammer2/hammer2_synchro.c:417
Detail
Exploit chain
none -- pure NULL-deref DoS, not memory corruption. The fault is a supervisor READ of fixed kernel offset 0x118 from a NULL base; the MMU traps before any attacker-controlled value is dereferenced or written. No write/UAF/type-confusion primitive exists to groom or convert, so no escalation chain is possible (valid hard blocker: read-only-deref primitive). Realistic impact ceiling: a mounted HAMMER2 PFS with a degraded/gapped cluster (multi-volume master/slave with a mid-cluster slave removed) panics on the next syncer pass; reachable from the unprivileged syscall surface via hammer2_synchro.c:417,687 and hammer2_chain.c:5678 which run on inodes touched by user I/O. Hence Medium severity (specific cluster precondition). No exploit.c written -- none applicable.
Evidence (decisive lines)
BASELINE #0 (bug present): kldload df0796_harness.ko -> DF-0796: invoking hammer2_inode_chain_and_parent on inode with NULL chain slot (clindex=0, nchains=2) Fatal user address access from kernel mode from kldload at ffffffff802aad04 Fatal trap 12: page fault while in kernel mode fault virtual address = 0x118 fault code = supervisor read data, page not present Stopped at hammer2_inode_chain_and_parent.cold.9+0x15: movq 0x118,%rax db> (guest dead; vm.sh status => down)
PoC changes
Evidence pack was empty on spawn (no prior PoC). Authored from scratch: df0796_harness.c (KLD module that kmallocs a hammer2_inode, sets cluster.nchains=2 with array[0].chain=array[1].chain=NULL to reproduce the legal interior-gap state, and calls hammer2_inode_chain_and_parent(ip,0,&parent,HAMMER2_RESOLVE_SHARED)); Makefile (bsd.kmod.mk build against /usr/src/sys); build.sh/run.sh repro wrappers; fix.diff (the one-hunk early-NULL-return fix).
Verified recommended fix
In sys/vfs/hammer2/hammer2_inode.c:hammer2_inode_chain_and_parent(), add an early if (chain == NULL) { *parentp = NULL; return NULL; } immediately after the if(chain){ref;lock;}else{spin_unsh;} block and before parent = chain->parent; -- mirroring the sibling hammer2_inode_chain() (:407-427) and the NULL-tolerant contract all three callers (hammer2_chain.c:5678, hammer2_synchro.c:417,687) already assume. This finding had no pre-existing finding-markdown Recommended-fix proposal to supersede; the fix was authored from the line-accurate verification. Full git-apply-able diff in findings/poc/DF-0796/fix.diff.
Verdict
REPRODUCED (NULL-deref panic / local DoS). hammer2_inode_chain_and_parent() at sys/vfs/hammer2/hammer2_inode.c:453 executes parent = chain->parent; UNCONDITIONALLY, but chain is assigned NULL at :439 (clindex>=nchains) or :441 (interior cluster gap, array[clindex].chain==NULL). Such gaps are a legal runtime state per hammer2_vfsops.c:820-838 ('Gaps may remain') when a PFS slave type is reset to PFSTYPE_NONE. The sibling hammer2_inode_chain() (:407-427) handles NULL correctly (returns NULL) and the caller hammer2_chain.c:5678-5683 checks if(*chainp) return (*chainp)->error proving the API contractually tolerates a NULL return. A deterministic KLD harness synthesising the gapped-cluster state and calling the function panics the unpatched #0 kernel: Fatal trap 12, fault VA 0x118 (= offsetof(hammer2_chain,parent) from NULL base), Stopped at hammer2_inode_chain_and_parent.cold.9+0x15: movq 0x118,%rax. Confirmed on a fresh with-src reset twice.
No comments yet.