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

hammer_direntry_namekey ALG1 iteration-range overflow: crafted filenames make dirents permanently unfindable and unremovable (durable fs poisoning + unremovable space leak)

Summary

hammer_direntry_namekey() ALG1 (:963-1025) returns keys up to 0x7fffffffffff0000 while advertising max_iterations=0x00ffffff (:1024), with no guarantee that key+max_iterations stays in the positive key space. All name-lookup consumers compute the hash-chain scan end with an unchecked signed add - cursor.key_end.key += max_iterations at hammer_vnops.c:1234 (nlookup), :2014 (nrename), :3324 (dounlink/nremove/nlink) - which wraps key_end NEGATIVE for any namekey > 0x7fffffffff000000. hammer_btree_iterate (:216/:277) then rejects every chain element after the first, cutting the collision chain to a single element. CRC-32 over a 4-byte message is a GF(2) bijection, so an unpriv user can SOLVE for filenames in the wrap zone (force the ALG1 segment-crc sum to 0x7fffffff, brute 3 alphabet bytes for the whole-name crc zone) and even for two distinct names with an identical 47-bit key. Unpriv local user with write access to any directory on a HAMMER1 fs of version >= 6 (ALG1 selected per hammer_inode.c:858-862; newfs defaults to v7): create nameA at key K, then nameB with the identical namekey (create-time uniqueness loop bumps it to K+1) - from then on every lookup of nameB returns ENOENT although the dirent exists on disk and is listed by readdir, across remounts and reboots, forever. nameB can never be stat'ed, opened, renamed, or unlinked by any API; the containing directory can never become empty; every subsequent O_CREAT after a namecache miss leaks another unremovable dirent+inode at K+2, K+3, ... - unremovable disk-space exhaustion. No memory corruption (-fno-strict-overflow; wrap deterministic). VERIFIED guest uid 65534 on hammer1 v7 (solver.h inverts 4-byte CRC-32 over GF(2); crafted pair nameA/nameB both key 0x7fffffffff180000): post-remount stat(nameA)=OK, stat(nameB)=ENOENT, unlink(nameB)=ENOENT, readdir lists nameB, 5 re-creates leak a 2nd unremovable dirent (3x deterministic). Fix validated (clamp max_iterations so key+max cannot leave the positive key space, rebuilt kernel): both names resolve and unlink cleanly, no leak.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3111 Β· 15 files
FileTypeDescriptionSize
solver.h β€” 7.0 KB view raw
trig.c β€” 2.9 KB view raw
recheck.c β€” 2.8 KB view raw
build.sh β€” 138 B view raw
run.sh β€” 164 B view raw
build.log β€” 500 B view raw
run.log β€” 4.1 KB view raw
fix_run.log β€” 1.1 KB view raw
fix_run_stage1.log β€” 3.9 KB view raw
env.txt β€” 735 B view raw
VERDICT.md β€” 5.9 KB ↓ raw
README.md β€” 4.1 KB ↓ raw
fix.diff β€” 847 B view raw
verdict.json β€” 4.3 KB view raw
manifest.json β€” 1.2 KB view raw

DF-3111 evidence pack β€” HAMMER1 ALG1 namekey iteration-range overflow

Finding

hammer_direntry_namekey() (sys/vfs/hammer/hammer_subs.c:943-1035, ALG1 branch) returns namekeys up to 0x7fffffffffff0000 while advertising max_iterations = 0x00ffffff, without guaranteeing that key + max_iterations stays in the positive (signed) key space. Every consumer computes the directory hash-chain scan end with an unchecked signed addition:

  • cursor.key_end.key += max_iterations β€” sys/vfs/hammer/hammer_vnops.c:1234 (hammer_vop_nlookup), :2014 (hammer_vop_nrename), :3324 (hammer_dounlink via nremove/nlink/ncreate whiteout path)

For a crafted filename whose ALG1 namekey is in the "wrap zone" (key > 0x7fffffffff000000), the addition overflows int64 and key_end becomes negative. hammer_btree_iterate() (sys/vfs/hammer/hammer_btree.c:216, :277) then rejects every chain element after the first (hammer_btree_cmp(key_end, elm) < 0 β†’ ENOENT), and the in-memory record scan (hammer_mem_first/hammer_rec_scan_cmp) is truncated the same way. The collision chain is effectively cut to a single element.

Exploit (unprivileged local user, any writable ALG1 directory β€” i.e. any

directory created on a HAMMER1 fs of version >= 6)

The ALG1 hash is crc32-based and CRC-32 over a 4-byte message is a GF(2) bijection, so a user can solve for filenames in the wrap zone (solver.h): force the segment-crc sum to 0x7fffffff (top hash bucket) via a 4-byte inverted suffix, brute-force 3 alphabet bytes to land the whole-name crc in the wrap zone. Two distinct names with the identical 47-bit key form a colliding pair.

  1. create nameA β†’ stored at key K (first chain element; still findable)
  2. create nameB (same key K) β†’ create-time uniqueness loop bumps it to K+1
  3. from then on, every lookup of nameB positions at nameA's record (first element β‰₯ K), bcmp fails, hammer_btree_iterate clamps the scan with the wrapped key_end β†’ ENOENT, although the dirent exists on disk
  4. unlink/rename of nameB fail forever (persist across unmount/reboot); ls keeps listing the phantom name (readdir scans to HAMMER_MAX_KEY)
  5. every re-create after a namecache miss leaks another unremovable dirent+inode at K+2, K+3, … β†’ unremovable disk-space exhaustion

Build (guest)

cc -O2 -o /root/trig /root/trig.c        # includes solver.h
cc -O2 -o /root/recheck /root/recheck.c

Run (guest, as root; both binaries drop to uid/gid 65534 themselves)

sh /root/run.sh

run.sh: makes a 4G image, newfs_hammer -f (version 7 β‡’ ALG1), mounts on /mnt, then: * STAGE 1 /root/trig /mnt/t β€” creates the pair as nobody; unlink nameB already returns ENOENT while readdir lists it * STAGE 2 β€” sync, umount, mount (flush + cold namecache) * STAGE 3 /root/recheck /mnt/t β€” durable proof: stat nameB β†’ ENOENT, unlink nameB β†’ ENOENT, readdir lists nameB, 5 re-creates leak a 2nd unremovable dirent

Expected output (vulnerable kernel)

persist stat   nameB : No such file or directory   <-- POISONED (dirent exists on disk)
persist unlink nameB : No such file or directory   <-- UNREMOVABLE
after 5 re-creates   : readdir nameB cnt = 2 (was 1)   <-- LEAK

(see run.log; note stat nameB right after create can succeed through the positive namecache entry β€” stage 1 documents this; stage 3 is cold-cache.)

Fix validation

fix.diff clamps *max_iterationsp in hammer_direntry_namekey() so key + max_iterations <= INT64_MAX. Apply to /usr/src in the guest, make nativekernel, reboot, re-run: nameB resolves normally and unlink nameB succeeds (see fix_run.log / VERDICT.md).

Files

  • solver.h β€” shared GF(2) CRC-32 inversion + exact ALG1 replica + pair search
  • trig.c β€” stage-1 trigger (unprivileged)
  • recheck.c β€” stage-3 durable/persistence check (unprivileged)
  • run.sh β€” end-to-end reproduction script
  • run.log β€” full untrimmed output of the decisive run
  • env.txt β€” guest uname, fs version, mount state
  • fix.diff β€” the verified fix (authored after reproduction; never applied to sys/)
VERDICT.md
↓ download raw

DF-3111 VERDICT β€” hammer_direntry_namekey ALG1 iteration-range overflow

Status: REPRODUCED (durable, unprivileged, persistent on disk)

Impact: local DoS / filesystem poisoning (unremovable dirents, unremovable

space leak) β€” no memory corruption; medium severity

Root cause (line-traced)

hammer_direntry_namekey() β€” sys/vfs/hammer/hammer_subs.c:943-1035:

  • ALG1 (fs version >= 6, hammer_inode.c:858-862) builds key = (crcx & 0x7fffffff) << 32 | ((crc ^ crc<<16) & 0xffff0000) and returns *max_iterationsp = 0x00ffffff (subs.c:1002-1024).
  • Nothing enforces key + max_iterations <= INT64_MAX: the maximal key is 0x7fffffffffff0000.
  • Consumers compute the hash-chain scan end with an unchecked signed add:
  • hammer_vnops.c:1233-1234 (hammer_vop_nlookup)
  • hammer_vnops.c:2013-2014 (hammer_vop_nrename)
  • hammer_vnops.c:3323-3324 (hammer_dounlink β†’ nremove/nlink/whiteout) cursor.key_end.key += max_iterations;
  • For a namekey > 0x7fffffffff000000 this wraps key_end NEGATIVE (int64 overflow). hammer_btree_cmp is signed, so hammer_btree_iterate() (hammer_btree.c:216 internal, :277 leaf) rejects every element after the first with r < 0 β†’ ENOENT; the in-memory chain scan (hammer_mem_first β†’ hammer_rec_scan_cmp) is truncated the same way. The directory hash chain is cut to its first element.
  • btree_search()/hammer_btree_extract position/extract by key_beg only, so the FIRST chain element (and exact-key matches) still resolve β€” this is why nameA (slot K) keeps working while the collision-bumped nameB (slot K+1) becomes invisible.

Why a user can craft such names (no kernel primitives needed)

ALG1's crcx is an integer sum of CRC-32s over separator-split segments, and CRC-32 over a 4-byte message is a GF(2) bijection. solver.h inverts it to force the segment sum to 0x7fffffff (top bucket) and brute-forces 3 alphabet bytes to land the whole-name crc in the wrap zone ((crc ^ crc<<16) & 0xffff0000 >= 0xff010000). 1722 wrap-zone names exist in that tiny search space alone; two distinct names with the identical 47-bit key form the colliding pair (deterministic): * nameA 6161616146712e0702dfbf key=0x7fffffffff180000 * nameB 61616c6a33712e0a09aabf key=0x7fffffffff180000 * key_end = key + 0xffffff = 0x800000000017ffff (negative)

Reproduction (full log: run.log; guest DragonFly 6.5-DEVELOPMENT,

HAMMER1 version 7 image via newfs_hammer -f, /mnt/t mode 1777,

all PoC steps run as uid/gid 65534)

  1. open(nameA, O_CREAT) β†’ OK, stored at K.
  2. open(nameB, O_CREAT) β†’ OK; create-time uniqueness loop (hammer_object.c:705-716 hammer_ip_lookup + ++key) bumps it to K+1.
  3. unlink(nameB) β†’ ENOENT although readdir lists the entry (both pre-flush and post-flush).
  4. sync + umount + mount (cold namecache, dirents on disk): * stat(nameA) β†’ OK (first chain element) * stat(nameB) β†’ ENOENT β€” POISONED * unlink(nameB) β†’ ENOENT β€” permanently UNREMOVABLE * readdir lists nameB (readdir scans to HAMMER_MAX_KEY, hammer_vnops.c:1715-1716) β€” phantom entry * 5 more open(nameB, O_CREAT) β†’ leaks a 2nd unremovable dirent+inode (each namecache-missed re-create appends K+2, K+3, … forever)
  5. ls -la shows two identical phantom names that stat ENOENT.

Persistence: the state is durable on disk β€” every future lookup of nameB on any mount computes the same wrapped key_end β†’ the entry can never be removed by any API (rm, rename, mv -i, undelete tools) for the life of the filesystem; the containing directory can never become empty. An unprivileged user can repeat this at will (one ~11-byte filename each) to consume unremovable dirent+inode space. Note: a stat immediately after create can succeed through the positive namecache entry β€” the durable cold-cache state is what matters (stage 3 of run.log).

Non-issues checked and killed in this file (pass 2)

  • hammer_btree_cmp (hammer_btree.c:~3995 family): pure relational compares, no subtraction/shift arithmetic β€” no signed-wrap family. Clean.
  • hammer_time_to_timespec / hammer_timespec_to_time (subs.c:795-809): on x86_64 (time_t/unsigned long 64-bit) no 2038/2106 truncation; nsec math bounded < 1e9. Negative tv_sec via utimensat only produces a garbage uint64 mtime (representation, no OOB). Clean.
  • Lock helpers: hammer_lock_downgrade (:244-268) / hammer_unlock (:271-302) clear lowner while still holding EXCLUSIVE β€” benign by construction (competing hammer_lock_ex/sh paths check EXCLUSIVE first); upgrade EDEADLK discipline already verified by the cursor audit (:215-234); hammer_ref/ref_interlock overflow needs 2^28 refs (KKASSERT-guarded).
  • hammer_str_to_tid (:1044-1092): lax ":%05d" parse (strtoul skips whitespace/sign) is self-inflicted name parsing; pfs_to_lo() masks (hammer_btree.h:147-149). No memory safety impact.
  • hammer_blockdemarc hpanic (:1119-1129): all callers (vnops.c:411-413, :836, :3121-3127) guard offset1 < HAMMER_XDEMARC by construction.
  • hammer_to_unix_xid unaligned/aliasing access (:781-785): benign on x86.

Fix

fix.diff clamps *max_iterationsp in hammer_direntry_namekey() so key + max_iterations <= INT64_MAX (both ALG0 and ALG1 covered; ALG0 could not wrap on its own β€” max key 0x7fffffff00000000 + 0xffffffff lands exactly on INT64_MAX β€” but the clamp is cheap defense in depth).

Fix validation: applied to /usr/src in the guest, make nativekernel, rebooted into the patched kernel, re-ran the exact PoC β€” see fix section below (fix_run.log): nameB resolves normally and unlink nameB succeeds; no poisoning, no leak.

Fix status (batch validation)

Applied with the 36 sibling fix.diffs of this batch in findings/poc/_batch41/all_fixes.patch; single in-guest make -j6 nativekernel KERNCONF=X86_64_GENERIC completed RC=0 with zero errors under -Werror (full log: findings/poc/_batch41/fix_build8.log). Behavior A/B intentionally skipped for this Info-tier item.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

fix.diff applied to /usr/src in guest, make nativekernel + installkernel + reboot (kernel #1); exact PoC re-run: stage1 'unlink nameB : OK' + readdir cnt 0; focused durable check (pair created, remounted): 'persist stat nameB : OK', 'persist unlink nameB : OK', no leak after 5 re-creates β€” poisoning gone

['fix_run.log', 'fix_run_stage1.log', 'fix.diff']
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #1: Sun Sep 6 12:02:37 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

solve wrap-zone name pair via 4-byte CRC-32 GF(2) inversion (segment-sum -> 0x7fffffff top bucket) + 3 alphabet brute bytes (whole-name crc zone) -> create nameA at key K -> create nameB (collision-bumped to K+1) -> lookup(nameB): btree_search returns nameA's record (first element >= K), bcmp fails, hammer_btree_iterate rejects K+1 vs wrapped negative key_end -> ENOENT forever; readdir still lists it; each further O_CREAT after a namecache miss appends another unremovable dirent at K+2, K+3, ...

Evidence (decisive lines)

["run.log: stage1 'unlink nameB : No such file or directory <-- BUG' + 'readdir nameB cnt : 1'", "run.log: stage3 'persist stat nameB : No such file or directory <-- POISONED', 'persist unlink nameB : No such file or directory <-- UNREMOVABLE', 'after 5 re-creates ... cnt = 2 (was 1) <-- LEAK'", "fix_run.log: same pair on fix.diff kernel: 'persist stat nameB : OK', 'persist unlink nameB : OK', cnt stays 1 after 5 re-creates"]

PoC changes

seed concept was 'image-crafted namekeys at wrap'; actual unprivileged vector required (1) a deterministic identical-key PAIR solver (4-byte CRC-32 GF(2) inversion for the segment-sum + 3 alphabet brute bytes for the whole-name crc wrap zone; an append-4-bytes-to-state inversion is rank-deficient and was discarded), (2) create-order discipline (nameA keeps slot K occupied so nameB is bumped to K+1), (3) remount between create and probe to flush dirents to disk and cold the positive namecache that otherwise masks stat/open; trigger+recheck run as uid/gid 65534

Verified recommended fix

clamp *max_iterationsp in hammer_direntry_namekey() so key + max_iterations <= INT64_MAX

Verdict

hammer_direntry_namekey() ALG1 returns namekeys up to 0x7fffffffffff0000 with max_iterations=0xffffff and no guarantee that key+max_iterations stays positive; the nlookup/nrename/dounlink consumers do an unchecked signed 'cursor.key_end.key += max_iterations' that wraps negative for crafted filenames, and hammer_btree_iterate() then truncates the directory hash-chain scan to its first element. An unprivileged user (GF(2)-inverting CRC-32 to craft two distinct names with an identical wrap-zone namekey) creates a permanently unfindable, permanently unremovable dirent that persists across remounts/reboots, plus an unremovable dirent+inode leak on every re-create: durable local filesystem poisoning / disk-space exhaustion. No memory corruption.