DRM hash tables collapse to a single bucket on 64-bit: linux-compat hash_64() is identically zero for bits<=32 (O(nΒ²) drm_ht behavior, local DoS)
Summary
hash_64 casts hash32_buf's full 32-bit djb2 (3rd arg is a seed, not width) to u64 and shifts right by 64-bits = 0 for all bits<=32 - every drm_ht key lands in bucket 0 and the other 4095 buckets are never used. IN-KERNEL PROOF: the real unmodified drm_hashtab.c shows nonempty_buckets=1/4096, max_chain=8192, per-1024-insert time 581->11403us (O(n^2)); fix build shows 4096/4096, max_chain=2, flat 17-19us. Unpriv render user drives it via dumb-buffer create/map (sequential alloc_unr keys) stalling all GPU clients under the global DRM_LOCK; amdgpu chash degenerates by the same root. Fibonacci-mixer fix validated.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-5146 Β· 15 files| File | Type | Description | Size | |
|---|---|---|---|---|
| hash64_collapse.c | β | 3.0 KB | view raw | |
| htcollapse.c | β | 5.2 KB | view raw | |
| Makefile | β | 970 B | β download | |
| fixinc/linux/hash.h | β | 1.3 KB | view raw | |
| build.sh | β | 85 B | view raw | |
| run.sh | β | 109 B | view raw | |
| build.log | β | 14.3 KB | view raw | |
| run.log | β | 1.8 KB | view raw | |
| run.2.log | β | 1.8 KB | view raw | |
| env.txt | β | 360 B | view raw | |
| fix.diff | β | 1.3 KB | view raw | |
| code_hashes.txt | β | 163 B | view raw | |
| verdict.json | β | 5.6 KB | view raw | |
| VERDICT.md | β | 6.0 KB | β raw | |
| README.md | β | 2.5 KB | β raw |
DF-5146 β DRM hash tables collapse to a single bucket on 64-bit (local DoS)
What
sys/dev/drm/include/linux/hash.h:hash_64() misuses DragonFly's
hash32_buf() (sys/sys/hash.h:44): its third argument is a djb2
initval, not a bit-width, and it returns a full 32-bit hash.
hash_64() casts that to u64 and shifts right by (64 - bits) β
which is identically 0 for every bits <= 32. On amd64
(hash_long == hash_64), every key hashed by
sys/dev/drm/drm_hashtab.c (drm_ht_find_key:80, drm_ht_find_key_rcu:98,
drm_ht_insert_item:117, drm_ht_just_insert_please:148) lands in
bucket 0; the other 4095 buckets of the in-tree order-12 tables
(drm_drv.c:603 dev->map_hash, drm_gem.c:148 mm->offset_hash) stay
empty forever. All operations degrade to a linear walk of one sorted
hlist: inserts O(n) (append-to-tail for the sequential alloc_unr
keys used at drm_gem.c:576), lookups O(n) β and every GEM mmap lookup
runs under the global DRM_LOCK (drm_gem.c:1089). Not memory
corruption: the index is always in-bounds (0); correctness of
find/dup-reject/remove is preserved (proven below).
Reproduce (on the DragonFly guest)
# pack copied to /root/df5146 on the guest cd /root/df5146 sh build.sh # builds userspace proof + kld twice: baseline + FIX=1 sh run.sh # runs all three proofs, prints everything
Expected (baseline, stock kernel code):
RESULT: hash_long(key,bits)==0 for ALL 6400000 inputs ... HTCOLLAPSE: per-1024-insert wall time (us): 581 1978 3689 ... 11403 <- grows linearly HTCOLLAPSE: nonempty_buckets=1/4096 max_chain=8192 ... HTCOLLAPSE: VERDICT ... hash_long collapse CONFIRMED in-kernel
Expected (fixed header, fixinc/linux/hash.h):
HTCOLLAPSE: per-1024-insert wall time (us): 19 18 18 17 18 17 17 18 <- flat HTCOLLAPSE: nonempty_buckets=4096/4096 max_chain=2 ... HTCOLLAPSE: VERDICT ... hash distributed normally (fixed header in effect)
Files
hash64_collapse.cβ userspace arithmetic proof (verbatim replication of sys/sys/hash.h + sys/dev/drm/include/linux/hash.h)htcollapse.cβ kernel module harness; Makefile.PATHs the REAL unmodified/usr/src/sys/dev/drm/drm_hashtab.cinto the modulefixinc/linux/hash.hβ fixed header used bymake FIX=1(the fix under test, byte-identical logic tofix.diff)build.sh,run.shβ exact commandsbuild.log,run.log,run.2.log,env.txtβ full untrimmed logsfix.diffβ git-apply-able fix against sys/dev/drm/include/linux/hash.hmanifest.json,verdict.jsonβ machine-readable results
DF-5146 VERDICT β REPRODUCED (impact: local DoS; not memory corruption)
The bug
sys/dev/drm/include/linux/hash.h:32-39:
static inline u64 hash_64(u64 val, unsigned int bits)
{
u64 ret;
ret = (uint64_t)hash32_buf(&val, sizeof(val), bits);
return ret >> (64 - bits);
}
hash32_buf() (sys/sys/hash.h:44-53) is a djb2 hash:
hash = hash*33 + byte, third argument = initval (seed), returning a
full 32-bit value. The Linux-compat port treated that argument as a
bit-width and expected a pre-reduced result. It is not: the value is
always < 2^32, so ret >> (64 - bits) is 0 for every bits in [1,32]
(shift by >= 32 of a value that fits in 32 bits). bits == 0 would even be
UB (>> 64).
On amd64 BITS_PER_LONG == 64, so hash_long == hash_64
(hash.h:50-54). Every hash_long() call in the DRM tree therefore
returns 0:
sys/dev/drm/drm_hashtab.c:80(drm_ht_find_key)sys/dev/drm/drm_hashtab.c:98(drm_ht_find_key_rcu)sys/dev/drm/drm_hashtab.c:117(drm_ht_insert_item)sys/dev/drm/drm_hashtab.c:148(drm_ht_just_insert_please)- also
sys/dev/drm/amd/lib/chash.c:398,433(amdgpu concurrent hash, same root cause β out of scope for this finding, same fix).
Consequence for struct drm_open_hash (drm_hashtab.h:47): all items land
in table[0] as one sorted hlist; buckets 1..4095 unused. The tables in
tree are both created with order 12 (4096 buckets):
sys/dev/drm/drm_drv.c:603 (dev->map_hash) and
sys/dev/drm/drm_gem.c:148 (mm->offset_hash).
On i386, hash_long == hash_32 and ret >> (32 - bits) genuinely yields
a bits-wide value β the bug is 64-bit-only.
Why it matters (threat)
- Keys for
mm->offset_hashcome fromalloc_unr()(drm_gem.c:576) β sequential 1,2,3,... Insert of an increasing key walks to the tail of the single chain (drm_hashtab.c:120-126 keeps chains sorted, no early exit for the append case) β O(n) per insert, O(n^2) cumulative. - Every GEM mmap does
drm_ht_find_item(&mm->offset_hash, ...)under the global DRM_LOCK (drm_gem.c:1074, drm_gem.c:1089-1090); every legacy map mmap similarly (drm_vm.c:89-91). Lookups are O(n) with early-break, but held under the device-global lock. - Reachability: an unprivileged user with
/dev/dri/card0access drives insertions viaDRM_IOCTL_MODE_CREATE_DUMB+DRM_IOCTL_MODE_MAP_DUMB(drm_gem_dumb_map_offset, drm_gem.c:381-406, no master/auth required). Each object costs the attacker real memory (>= 1 page), so the quadratic CPU burn is bounded by that cost β a sustained CPU/stall vector (GPU subsystem lock contention), not an unbounded one. Hence severity Low/DoS, not higher. - NOT memory corruption:
hashed_keyis always 0 βtable[0]is always in-bounds (proven in kernel below: errors=0 across find/dup/remove cycles; the bucket index can never exceed the table size when it is constant 0). There is no uid=0 route through this bug.
Proof (guest: DragonFly 6.5-DEVELOPMENT x86_64, cc 8.3)
Three layers, all in run.log (stable across run.2.log):
- Userspace arithmetic (
hash64_collapse.c, verbatim replication of both headers): 6,400,000 key/bits combinations βhash_64returned 0 for all of them (bits 1..32); i386-pathhash_32spreads. - In-kernel, real code (
htcollapse.kocompiles the unmodified/usr/src/sys/dev/drm/drm_hashtab.cvia the Makefile.PATH, same include flags as the drm module): 8192 sequential-key inserts into a realdrm_ht_create(&ht, 12)table: - baseline:nonempty_buckets=1/4096 max_chain=8192, per-1024-insert time 581β11403 us (linear growth per chunk = O(n^2) total); - fixed header (make FIX=1,fixinc/linux/hash.hidentical tofix.diff):nonempty_buckets=4096/4096 max_chain=2, flat 17-19 us chunks (~450-600x faster at the tail). - correctness identical in both builds: find(present)=0, find(absent)=-22, dup-insert=-EINVAL, remove/double-remove/reinsert=0,errors=0. - Fix validated: same real kernel code, only the header differs β the collapse is gone and behavior is normal, i.e. the fix is both sufficient and minimal. (Full drm.ko rebuild not needed: the kld compiled the real drm_hashtab.c with the same flags the drm module uses, drm/Makefile:121-124.)
What I checked and killed on drm_hashtab.c itself (pass 2)
- No open-addressing probe/wrap: this is hlist chaining with sorted chains (drm_hashtab.c:120-131), not linear probing β the "probe wraps forever on full table" class does not exist here. There is no sentinel/tombstone machinery at all (removal is a guarded unlink).
- No infinite loop in drm_ht_just_insert_please: bits is the constant 17 (drm_bufs.c:126: 32 - PAGE_SHIFT - 3), mask nonzero; the do-while exits when unshifted_key wraps to first (drm_hashtab.c:150-155).
- order is caller-constant 12 everywhere (drm_drv.c:603,
drm_gem.c:148) β the
1 << orderUB for order>=32 (drm_hashtab.c:43) and the u8 truncation of order (drm_hashtab.h:49) are latent hardening gaps only, unreachable from any in-tree path. - Double-remove / remove-of-unhashed: hlist_del_init_rcu maps to hlist_del_init which is hlist_unhashed-guarded (list.h:341-348) β proven idempotent in the kld run (double drm_ht_remove_item + reinsert OK).
- drm_ht_remove double-call: NULL-guarded, sets table=NULL (drm_hashtab.c:200-206); items are embedded in caller objects, the table never owns/frees them β no double-free surface.
- Info leak: none; no copyout of table internals; keys are kernel offsets.
- Cross-file observation for the drm_gem.c audit (NOT this finding): drm_gem.c:397's insert path shows no obvious lock; the hashtab contract (drm_hashtab.h:64-72) requires external serialization β flag it there.
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 validated WITHOUT a full kernel rebuild, by compiling the same unmodified /usr/src/sys/dev/drm/drm_hashtab.c into htcollapse.ko with only the header changed (fixinc/linux/hash.h, logic identical to fix.diff): baseline build reproduces the collapse (1/4096 buckets, max_chain 8192, linearly growing insert times 581->11403 us per 1024); patched build eliminates it (4096/4096 buckets, max_chain 2, flat 17-19 us chunks) with identical functional correctness (find/dup/remove all OK, errors=0). fix_baseline_reproduced=true (bug present on baseline); fix_patched_reproduced=false (bug ABSENT on patched - desired outcome).
run.log sections '=== kernel baseline ===' vs '=== kernel fixed ==='; run.2.log same; fix.diff; fixinc/linux/hash.h
Confirmed kernel references
- sys/dev/drm/include/linux/hash.h:32-39
- sys/sys/hash.h:44-53
- sys/dev/drm/drm_hashtab.c:80
- sys/dev/drm/drm_hashtab.c:98
- sys/dev/drm/drm_hashtab.c:117
- sys/dev/drm/drm_hashtab.c:148
- sys/dev/drm/drm_hashtab.c:150-155
- sys/dev/drm/drm_drv.c:603
- sys/dev/drm/drm_gem.c:148
- sys/dev/drm/drm_gem.c:381-406
- sys/dev/drm/drm_gem.c:576-577
- sys/dev/drm/drm_gem.c:1074
- sys/dev/drm/drm_gem.c:1089-1090
- sys/dev/drm/drm_vm.c:89-91
- sys/dev/drm/drm_bufs.c:125-127
- sys/dev/drm/amd/lib/chash.c:398
Detail
Exploit chain
unprivileged user opens /dev/dri/card0 -> DRM_IOCTL_MODE_CREATE_DUMB + DRM_IOCTL_MODE_MAP_DUMB repeatedly (drm_gem.c:381-406, drm_gem.c:576-577): each object's sequential alloc_unr key appends to the single bucket-0 chain => O(n) walk per insert, O(n^2) cumulative kernel CPU; every subsequent GEM mmap (drm_gem.c:1074 under global DRM_LOCK at drm_gem.c:1089) and legacy map mmap (drm_vm.c:89-91) burns O(n) under the device-global lock, stalling all GPU clients. Bounded by per-object memory cost; no privilege boundary crossed.
Evidence (decisive lines)
run.log (decisive lines: 'nonempty_buckets=1/4096 max_chain=8192' baseline vs 'nonempty_buckets=4096/4096 max_chain=2' fixed; per-1024-insert wall time '581 1978 3689 4919 6354 8366 9381 11403' baseline vs flat '19 18 18 17 18 17 17 18' fixed; 'RESULT: hash_long(key,bits)==0 for ALL 6400000 inputs' userspace proof); run.2.log (same numbers, stability run); build.log; htcollapse.c compiles the REAL unmodified /usr/src/sys/dev/drm/drm_hashtab.c; VERDICT.md for the full narrative
PoC changes
No seed PoC existed (finding produced by this pass-2 audit); pack written from scratch. Iterations needed: (1) module_t -> moduledata_t for DF's DECLARE_MODULE; (2) initialize t0 for -Werror=maybe-uninitialized; (3) generate bus_if.h/device_if.h/pci_if.h via kmod.mk SRCS instead of empty stubs (drmP.h pci prototypes need them); (4-5) provide local drm_err/drm_dbg shims so drm_hashtab.o links without drm.ko; (6) prepend -Ifixinc BEFORE the stock drm include dir so the fixed header actually wins the include race (first 'fixed' run silently used the stock header).
Verified recommended fix
Rewrite hash_64() in sys/dev/drm/include/linux/hash.h with the canonical 64-bit Fibonacci-hashing mixer (and guard bits==0/>64) so it returns a genuinely bits-wide value; hash_32 can stay as-is.
Verdict
REPRODUCED in-kernel on DragonFly 6.5-DEVELOPMENT x86_64: the linux-compat hash_64() (sys/dev/drm/include/linux/hash.h:32-39) misuses hash32_buf()'s seed argument and returns identically 0 for every bit-width <= 32, so on 64-bit platforms every drm_ht hash table collapses to a single bucket (verified: 1/4096 buckets non-empty, chain 8192, per-insert time growing linearly => O(n^2) fills) while correctness (find/dup/remove) is preserved. Impact is a reachable local CPU/lock-contention DoS for any user with /dev/dri/card0 (GEM mmap-offset inserts via MODE_CREATE_DUMB/MAP_DUMB + O(n) lookups under the global DRM_LOCK), not memory corruption: the bucket index is constant 0 and always in-bounds, so there is no uid=0 route. Fixed-header rebuild of the same real drm_hashtab.c spreads items over all 4096 buckets with flat insert times (~450-600x faster at the tail), validating fix.diff.
No comments yet.