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)
PoC verification
Evidence pack
findings/poc/DF-3111 Β· 15 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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_dounlinkvia 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.
- create
nameAβ stored at key K (first chain element; still findable) - create
nameB(same key K) β create-time uniqueness loop bumps it to K+1 - from then on, every lookup of
nameBpositions at nameA's record (first element β₯ K), bcmp fails,hammer_btree_iterateclamps the scan with the wrapped key_end β ENOENT, although the dirent exists on disk unlink/renameofnameBfail forever (persist across unmount/reboot);lskeeps listing the phantom name (readdirscans toHAMMER_MAX_KEY)- 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 searchtrig.cβ stage-1 trigger (unprivileged)recheck.cβ stage-3 durable/persistence check (unprivileged)run.shβ end-to-end reproduction scriptrun.logβ full untrimmed output of the decisive runenv.txtβ guest uname, fs version, mount statefix.diffβ the verified fix (authored after reproduction; never applied to sys/)
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 is0x7fffffffffff0000. - 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 >
0x7fffffffff000000this wraps key_end NEGATIVE (int64 overflow).hammer_btree_cmpis signed, sohammer_btree_iterate()(hammer_btree.c:216 internal, :277 leaf) rejects every element after the first withr < 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_extractposition/extract bykey_begonly, so the FIRST chain element (and exact-key matches) still resolve β this is whynameA(slot K) keeps working while the collision-bumpednameB(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)
open(nameA, O_CREAT)β OK, stored at K.open(nameB, O_CREAT)β OK; create-time uniqueness loop (hammer_object.c:705-716hammer_ip_lookup+++key) bumps it to K+1.unlink(nameB)β ENOENT although readdir lists the entry (both pre-flush and post-flush).- sync + umount + mount (cold namecache, dirents on disk):
*
stat(nameA)β OK (first chain element) *stat(nameB)β ENOENT β POISONED *unlink(nameB)β ENOENT β permanently UNREMOVABLE *readdirlists nameB (readdir scans to HAMMER_MAX_KEY, hammer_vnops.c:1715-1716) β phantom entry * 5 moreopen(nameB, O_CREAT)β leaks a 2nd unremovable dirent+inode (each namecache-missed re-create appends K+2, K+3, β¦ forever) ls -lashows 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
lownerwhile 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
fixedfix.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']
Confirmed kernel references
- sys/vfs/hammer/hammer_subs.c:943
- sys/vfs/hammer/hammer_subs.c:963
- sys/vfs/hammer/hammer_subs.c:1024
- sys/vfs/hammer/hammer_vnops.c:1234
- sys/vfs/hammer/hammer_vnops.c:2014
- sys/vfs/hammer/hammer_vnops.c:3324
- sys/vfs/hammer/hammer_btree.c:216
- sys/vfs/hammer/hammer_btree.c:277
- sys/vfs/hammer/hammer_object.c:705
- sys/vfs/hammer/hammer_inode.c:858
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.
No comments yet.