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.
Recommended fix
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)
PoC verification
Evidence pack
findings/poc/DF-2640 Β· 15 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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.
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>
bcmpwith%rdi == 0is exactlybcmp(chain->data->buf, name, 80)(chain.c:5784) called fromhammer2_xop_nresolve(xops.c:282) βcurrent process = Idleproves the crash is in the kernel xop backend thread, not a user process.- The
rmexecutes 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
lsfirst crashed in the frontend readdir consumer instead (memcpyNULL-source, "from ls", vnops.c:727hammer2_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):
hammer2_chain_dirent_test(): return 0 (no match) forchain->error || chain->data == NULLin the INODE branch and in the64-byte DIRENT branch. Short-named dirents (name embedded in
bref.check.buf, legitimatelydata_off == 0) remain matchable β this is required for the filesystem to keep working.hammer2_xops.chardening (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-modifydata == NULLchecks in the nrename INODE/DIRENT adjust blocks,error == 0 && chain->data != NULLcondition on the iparent update (nrename:755), and a post-modifyparent->datacheck inhammer2_xop_inode_chain_sync(xops.c:1636).
Results (fix_run.log, fix_build.log):
- Same
trigger_U.shon 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_testcall is the first dereference), and survives DF-2617's fix;fix.diffremoves it cleanly with no regression.
Fix verification
fixedRebuilt 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)']
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
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.
No comments yet.