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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0796 Β· 15 files
FileTypeDescriptionSize
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
README.md readme file index + reproduce + expected output
↓ download 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)
VERDICT.md verdict full narrative: mechanism, reproduction, exploit-chain rationale, fix validation
↓ download raw

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-844 sets iroot->cluster.array[i].chain = NULL when a PFS slave's type is reset to HAMMER2_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 with clindex < 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-5683 checks if (*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

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED 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).
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Fri Jul 10 13:40:09 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC (sha256 /boot/kernel/kernel = b0b47f5500c8de9c3ddc0f5784c696cbab69b8974b558988405577529d8d972e)

Confirmed kernel references

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.