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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5146 Β· 15 files
FileTypeDescriptionSize
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.c into the module
  • fixinc/linux/hash.h β€” fixed header used by make FIX=1 (the fix under test, byte-identical logic to fix.diff)
  • build.sh, run.sh β€” exact commands
  • build.log, run.log, run.2.log, env.txt β€” full untrimmed logs
  • fix.diff β€” git-apply-able fix against sys/dev/drm/include/linux/hash.h
  • manifest.json, verdict.json β€” machine-readable results
VERDICT.md
↓ download raw

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:

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_hash come from alloc_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/card0 access drives insertions via DRM_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_key is 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):

  1. Userspace arithmetic (hash64_collapse.c, verbatim replication of both headers): 6,400,000 key/bits combinations β€” hash_64 returned 0 for all of them (bits 1..32); i386-path hash_32 spreads.
  2. In-kernel, real code (htcollapse.ko compiles the unmodified /usr/src/sys/dev/drm/drm_hashtab.c via the Makefile .PATH, same include flags as the drm module): 8192 sequential-key inserts into a real drm_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.h identical to fix.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.
  3. 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 << order UB 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

fixed
baseline reproduced→ patch + rebuild →patched clean

fix.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
↓ fix.diffper-fix-DF-5146

Confirmed kernel references

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.