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

Unguarded hammer2_chain_dirent_test() calls in the xop backend lookup loops are the first dereference of a NULL-data chain β€” unprivileged post-mount kernel panic (survives DF-2617's fix)

Field Value
ID DF-2640
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
File sys/vfs/hammer2/hammer2_xops.c
Lines 282, 368, 610, 771 (sink chain.c:5768-5793)
Area vfs
Confidence certain
Discovered 2026-08-29
Pass 2 (GLM 5.3 second pass)
Bucket hammer2
Reported pending
Known CVE none
CVE match novel

Summary

hammer2_xop_nresolve/unlink/nrename pass chains straight from hammer2_chain_lookup() into hammer2_chain_dirent_test() checking only chain != NULL. dirent_test dereferences chain->data->ipdata (INODE, chain.c:5775) and chain->data->buf (names >64 bytes, chain.c:5784) with no chain->error and no chain->data guard. With the still-live DF-2617 primitive (data_off==0 β†’ data==NULL, error==0, chain.c:938-939), any name-test on a corrupted entry is a guaranteed NULL-deref. Names

64 bytes live in the dirent data block, making this backend call the FIRST touch of the corrupted chain β€” unshadowed by the frontend gdata/inode_get sinks (DF-2617's verified fix only guards vfsops/iocom consumers).

Threat model & preconditions

Root mounts a crafted hammer2 image (same trust boundary as verified DF-2616/DF-2617); then any unprivileged user with write permission on a directory (verified as nobody/uid 65534 in a mode-777 dir) panics the machine with one rm/mv/lookup of the corrupted >64-byte-name entry. Deterministic Fatal trap 12 @ VA 0x0 in the xop backend thread. DoS, no escalation (fixed-address NULL fault).

Proof of concept

VERIFIED on stock INVARIANTS kernel #0: forge_df2640.py (zero the long-named DIRENT bref's data_off, CHECK_NONE ancestors, volhdr CRC recompute) β†’ mount β†’ su -m nobody -c 'rm /mnt/h2/pub/<80*p>' β†’ Fatal trap 12, fault virtual address = 0x0, Stopped at bcmp+0xf0 (see findings/poc/DF-2640/panic.txt). Fix (dirent_test error/NULL-data guards keeping short embedded dirents matchable + xops.c site guards) validated on rebuilt kernel #1: clean ENOENT, guest up, full long/short-name create/cp/mv/ln/rm regression as nobody passes.

See findings/poc/DF-2640/fix.diff (validated) β€” guard hammer2_chain_dirent_test() (return 0 = no match on error/NULL data) plus chain->data/chain->error guards at the xops.c deref sites (also closes DF-2641).

References

  • DF-2617 (root primitive: data_off==0 β†’ data==NULL with error==0), DF-2641 (latent sibling sites), DF-2619 (namlen-bound sibling)

Timeline

  • 2026-08-29 Discovered during pass-2 audit of hammer2_xops.c (GLM 5.3); verified reproduced + fix validated same run.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2640 Β· 15 files
FileTypeDescriptionSize
README.md β€” 3.2 KB ↓ raw
VERDICT.md β€” 5.2 KB ↓ raw
build.sh β€” 890 B view raw
run.sh β€” 644 B view raw
mkbase2640.sh β€” 963 B view raw
forge_df2640.py β€” 11.7 KB view raw
trigger_U.sh β€” 789 B view raw
trigger_U2.sh β€” 491 B view raw
panic.txt β€” 14.0 KB view raw
panic_ls_variant.txt β€” 12.9 KB view raw
fix.diff β€” 3.5 KB view raw
fix_build.log β€” 5.7 MB ↓ download
fix_run.log β€” 1.0 KB view raw
env.txt β€” 788 B view raw
code_hashes.txt β€” 321 B view raw

DF-2640 β€” PoC evidence pack

Unguarded hammer2_chain_dirent_test() calls in the hammer2 xop lookup loops: the first dereference of a corrupted (NULL-data) chain on every resolve/unlink/rename β€” kernel NULL-deref panic, not remediated by DF-2617's fix.

sys/vfs/hammer2/hammer2_xops.c feeds freshly-locked chains from hammer2_chain_lookup() straight into hammer2_chain_dirent_test() at xops.c:282 (nresolve), :368 (unlink), :610 (nrename source), :771 (nrename target-overwrite), with only chain != NULL checked. hammer2_chain_dirent_test() (chain.c:5768-5793) dereferences chain->data->ipdata (INODE entries, :5775) and chain->data->buf (long-named DIRENT entries, :5784) with no chain->error and no chain->data check. With the (still live) DF-2617 primitive β€” bref.data_off == 0 on a data-requiring chain makes hammer2_chain_load_data() early-return success (chain.c:938-939), leaving chain->data == NULL, chain->error == 0 β€” this is a guaranteed NULL-deref. DF-2617's verified fix.diff only guards the mount-path consumers (vfsops label scan, iocom); it arms chain->error for these types but nothing on the xop lookup path reads it, so this crash survives DF-2617's fix.

Key reachability detail proven on the guest: entries with names > 64 bytes store their name in the dirent's data block, so dirent_test takes the bcmp(chain->data->buf, …) branch β€” this xop-backend deref is the first touch of the corrupted chain (short-named dirents are bref-embedded and never touch chain->data).

Reproduce

# host (pack dir): build base image in guest, forge on host, push
sh build.sh
# guest (root), stock INVARIANTS kernel #0:
sh /root/poc/df2640/trigger_U.sh   # mounts U.img, then:
                                  # su -m nobody -c 'rm /mnt/h2/pub/<80*p>'
# expected: Fatal trap 12, fault VA 0x0, "Stopped at bcmp+0xf0: movq (%rdi),%r8",
#           current process = Idle (xop backend thread), db> prompt, guest dead

With fix.diff applied (kernel #1, built in-guest with make -j6 nativekernel && make installkernel), the same trigger returns rm: …: No such file or directory (clean ENOENT), the guest stays up (fix_run.log), and a full regression on the uncorrupted image (long+short names, create/cp/mv/ln/rm as nobody, umount) passes.

Threat model: root mounts a crafted hammer2 image (USB stick / untrusted volume); any unprivileged user with write permission on a directory (here nobody, uid 65534, mode-777 pub/) then panics the machine with a single rm. Same class/triggers as the verified DF-2616/DF-2617 pack forgers (volhdr→sroot→PFS walk, CHECK_NONE ancestors, volhdr CRC32C recompute).

  • Full narrative: VERDICT.md
  • Machine verdict: verdict.json (schema: audit/persist_poc.py)
  • Verified fix: fix.diff (chain.c dirent_test guards + xops.c hardening for the DF-2641 latent sinks; validated on a rebuilt kernel)
  • Baseline crash: panic.txt (serial console, stock kernel #0); panic_ls_variant.txt β€” en-passant observation of the frontend readdir sink (vnops.c:727 hammer2_xop_gdata()->buf, "from ls", memcpy NULL-source) on the same forged image; that one belongs to hammer2_vnops.c, not this file.
VERDICT.md
↓ download raw

DF-2640 β€” VERDICT

Finding

sys/vfs/hammer2/hammer2_xops.c passes chains returned by hammer2_chain_lookup() directly to hammer2_chain_dirent_test() on four backend paths β€” nresolve (xops.c:282), unlink (xops.c:368), nrename source lookup (xops.c:610), nrename target-overwrite scan (xops.c:771) β€” checking only chain != NULL. hammer2_chain_dirent_test() (hammer2_chain.c:5768-5793) has no chain->error guard and no chain->data guard:

  • INODE entries: ripdata = &chain->data->ipdata; ripdata->meta.name_len (chain.c:5775-5776)
  • long-named DIRENT entries (>64 bytes, name lives in the dirent data block): bcmp(chain->data->buf, name, name_len) (chain.c:5783-5784)

Combined with the DF-2617 primitive (hammer2_chain_load_data() early-return at chain.c:938-939 for data_off == 0 β†’ data == NULL, error == 0 β€” still live in this tree), the very first name-test on such a chain faults.

Delta vs DF-2617 (why this is a separate finding): DF-2617 verified mount-path sinks (vfsops.c:1309/1560, iocom.c:311) and its fix.diff arms chain->error = EIO for data-requiring types. But neither the xop lookup loops nor dirent_test itself ever check chain->error, and pre-fix the error is 0 anyway β€” so rm/mv/name-lookup of a corrupted entry panics before and regardless of DF-2617's fix. This is also distinct from DF-2619 (dirent namlen vs chain->bytes OOB read inside dirent_test): here the chain has no data at all.

Reproduction (baseline, stock INVARIANTS kernel #0)

Forge (see forge_df2640.py): base image with /pub (mode 777) containing a file whose 80-byte name forces a data-block-backed DIRENT; zero that DIRENT bref's data_off; CHECK_NONE the covering ancestors (pub-inode bref, the INDIRECT bref covering it, PFS bref, sroot brefs); recompute volhdr CRC32Cs.

Trigger trigger_U.sh: mount, then su -m nobody -c 'rm /mnt/h2/pub/<80*p>'.

Observed (panic.txt, serial console):

Fatal trap 12: page fault while in kernel mode
cpuid = 4; lapic id = 4
fault virtual address   = 0x0                <-- chain->data == NULL (buf @ union offset 0)
fault code      = supervisor read data, page not present
instruction pointer = 0x8:0xffffffff80bcb8f0
current process     = Idle                <-- xop backend thread (h2xop-testvol)
Stopped at      bcmp+0xf0:      movq (%rdi),%r8
db>
  • bcmp with %rdi == 0 is exactly bcmp(chain->data->buf, name, 80) (chain.c:5784) called from hammer2_xop_nresolve (xops.c:282) β€” current process = Idle proves the crash is in the kernel xop backend thread, not a user process.
  • The rm executes as nobody (uid 65534) in a mode-777 directory β€” unprivileged post-mount trigger, identical threat model to DF-2616/2617.
  • En passant (panic_ls_variant.txt): a prior run that did ls first crashed in the frontend readdir consumer instead (memcpy NULL-source, "from ls", vnops.c:727 hammer2_xop_gdata()->buf) β€” same primitive, different file; recorded for the vnops.c owners.

Exploitability ceiling

The fault is a read at the fixed address NULL + 0 through a bcmp of kernel buffers β€” a deterministic kernel NULL-deref panic (local DoS). The write-side variants of the class (e.g. bzero(wipdata->filename) at xops.c:672 after a modify that chain.c:1467 lets "succeed" with NULL data) are likewise fixed-offset (0x100) faults; the NULL page is not mappable by userspace on DragonFly/x86_64 (VM_MIN_ADDRESS). No path from this primitive to attacker-addressed memory was found β€” impact ceiling is panic/DoS, matching DF-2617's rating (Medium).

Fix validation (kernel #1, make -j6 nativekernel, in-guest)

fix.diff (git-apply-able against the read-only tree):

  1. hammer2_chain_dirent_test(): return 0 (no match) for chain->error || chain->data == NULL in the INODE branch and in the

    64-byte DIRENT branch. Short-named dirents (name embedded in bref.check.buf, legitimately data_off == 0) remain matchable β€” this is required for the filesystem to keep working.

  2. hammer2_xops.c hardening (the DF-2641 latent sinks, same validated build): guards at unlink:402 (else if (chain->data) … error out), nrename:543 (ip4 type read β€” previously no error check at all), post-modify data == NULL checks in the nrename INODE/DIRENT adjust blocks, error == 0 && chain->data != NULL condition on the iparent update (nrename:755), and a post-modify parent->data check in hammer2_xop_inode_chain_sync (xops.c:1636).

Results (fix_run.log, fix_build.log):

  • Same trigger_U.sh on kernel #1: MOUNT_RC=0, rm: /mnt/h2/pub/pppp…: No such file or directory, RM_RC=1, no panic, guest up (baseline panic is gone).
  • Regression on the uncorrupted base image as nobody: create/cp/mv/ln/rm of long- (80-byte) and short-named files in the 777 dir, read-back, clean umount β€” all pass (REGRESSION_OK).
  • Guest left clean via vm.sh reset with-src.

Bottom line

reproduced / panic (Medium, crafted-image local DoS via unprivileged post-mount trigger). The finding is real, first-touch (unshadowed β€” for

64-byte names the xop-backend dirent_test call is the first dereference), and survives DF-2617's fix; fix.diff removes it cleanly with no regression.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

Rebuilt kernel #1 with fix.diff: the baseline panic is GONE (trigger returns clean ENOENT, guest stays up) and the unprivileged long/short-name create/cp/mv/ln/rm regression on the clean image passes (REGRESSION_OK). Logs: fix_run.log, fix_build.log.

['fix_run.log', 'fix_build.log', 'VERDICT.md (fix-validation + regression sections)']
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #1: Sat Aug 29 13:50:48 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

root mounts forged image (data_off=0 on a >64-byte-name DIRENT bref, CHECK_NONE ancestors, volhdr CRC32C recomputed) -> unprivileged user (nobody, 777 dir) runs rm -> namei VOP_NRESOLVE -> hammer2_xop_nresolve backend (xops.c:282) -> hammer2_chain_dirent_test -> bcmp(chain->data==NULL->buf, name, 80) -> Fatal trap 12 @ VA 0. No escalation path: fixed-address NULL fault, NULL page unmappable on DragonFly/x86_64.

Evidence (decisive lines)

["panic.txt: 'Fatal trap 12 ... fault virtual address = 0x0 ... current process = Idle ... Stopped at bcmp+0xf0: movq (%rdi),%r8' (stock kernel #0)", "panic_ls_variant.txt: adjacent FRONTEND sink observed en passant (vnops.c:727 gdata, 'from ls', memcpy NULL-source) - belongs to hammer2_vnops.c, recorded for its owners", "fix_run.log: same trigger on fixed kernel #1 -> 'rm: ...: No such file or directory', RM_RC=1, U_DONE, guest alive", 'fix_build.log: make -j6 nativekernel KERN_RC=0, installkernel INST_RC=0', 'VERDICT.md: full narrative incl. regression transcript (REGRESSION_OK)']

PoC changes

DF-2617's forger extended: (1) targets a >64-byte-name DIRENT bref (the original targeted INODE/INDIRECT brefs, whose first consumers are elsewhere); (2) walks the dirent+invisible-inode layout this kernel actually creates (DIRENT visible entries + inum-keyed INODE chains under INDIRECT); (3) CHECK_NONEs the covering INDIRECT ancestor chain; (4) trigger runs the rm as unprivileged nobody in a mode-777 subdir and contains no 'ls' (readdir's frontend gdata deref would crash first - that variant is captured separately in panic_ls_variant.txt).

Verified recommended fix

Guard hammer2_chain_dirent_test() against error/NULL-data chains (return 0) and add chain->data/chain->error checks at the xops.c deref sites (see fix.diff).

Verdict

Unguarded hammer2_chain_dirent_test() calls in the xop backend lookup loops (xops.c:282/368/610/771) are the FIRST dereference of a NULL-data chain (DF-2617 data_off==0 primitive) whenever the dirent name exceeds 64 bytes (name lives in the dirent data block; short names are bref-embedded). Verified on the stock INVARIANTS guest: mounting a forged image and running 'rm' as nobody (uid 65534) in a mode-777 directory panics with 'Fatal trap 12, fault VA 0x0, Stopped at bcmp+0xf0 movq (%rdi),%r8, current process = Idle' (xop backend thread; panic.txt). The crash is NOT remediated by DF-2617's verified fix (it only guards vfsops/iocom consumers; nothing on the xop lookup path checks chain->error, and pre-fix error is 0 anyway). Distinct from DF-2619 (namlen-vs-bytes OOB read): here the chain has no data at all. Impact ceiling: deterministic kernel NULL-deref panic (crafted-image local DoS; the write-side variants of the class fault at fixed NULL+offset addresses, not attacker-addressable). fix.diff (dirent_test error/NULL guards + xops.c hardening incl. the DF-2641 sinks) validated on an in-guest rebuilt kernel #1: same trigger returns clean ENOENT, guest stays up, and a full create/cp/mv/ln/rm regression with long and short names as nobody on the clean image passes.