# VERDICT — DF-2232 (sys/libkern/linux_idr.c idr_grow signed-overflow growth loop)

**Status: REPRODUCED** — impact **dos** (hang / panic-sized kmalloc), confidence
**certain**. Fix authored and validated on a rebuilt kernel. No memory-unsafety
path exists (analysis below); no uid=0 chain is possible from this bug.

## Environment

* Guest: DragonFly 6.5-DEVELOPMENT, kernel X86_64_GENERIC (`options INVARIANTS`),
  6 vCPU, 4 GiB RAM, QEMU/KVM. Baseline kernel: `#0 Thu Jul 2 06:02:54 UTC 2026`.
  Fixed kernel: `#1 Sat Oct 17 10:36:44 UTC 2026`.
* Compiler: cc 8.3 (DragonFly base); module built with kernel flags
  `-O2 -fno-strict-overflow` (signed wrap deterministic, same as kernel).
* Verbatim gate: guest `/usr/src/sys/libkern/linux_idr.c` sha256
  `22d421c35bfd7ebc7ae2df137b52b7ece0a9cf349a8f9369b440389e033a95de` —
  equal to the audited tree; the rig includes that file byte-identical.

## What was run (evidence trail)

1. **Control (run.a.log):** phases 1+2 — normal wants 1..1000 round-trip on
   the verbatim KLD copy and on the kernel's own exported KPI. Both PASSED
   with identical results (grow 255→1023, pre_get/get_new_above protocol
   settles in 2 iterations, remove/realloc reuses slot 1000). This also
   demonstrates the `idr_maxwant` poisoning protocol the attack rides on:
   first `idr_get_new_above(sid)` with a fresh higher sid returns -EAGAIN
   after raising `idr_maxwant` (linux_idr.c:327-331).

2. **Giant kmalloc (run.b.log, panic.b.txt):** `idr_alloc(start=1<<29, end=0)`
   through the verbatim copy. The allocator-boundary hook (outside the
   verbatim file) captured the request **live**:

   ```
   [DF2232-B] ALLOCATOR BOUNDARY: kmalloc(17179869168 bytes [0x3fffffff0], M_IDR, flags 0x102) -> real allocator
   ```

   17179869168 = (2^30-1) * sizeof(struct idr_node=16) — exactly the
   predicted ~16 GiB M_ZERO|M_WAITOK request. On the 4 GiB guest the
   allocation **never returned** (page-by-page `vm_page_alloc` + `vm_wait()`
   in `kmem_slab_alloc`, sys/kern/kern_slaballoc.c:1706-1800; the per-type
   limit check in `_kmalloc` only trips on cumulative use, not on a single
   first request). The hard-IRQ watchdog panicked at 15 s with the captured
   request size in the marker; backtrace shows it fired from
   `systimer_intr → pcpu_timer_process_oncpu → splz_timer` (interrupt context,
   proving the design requirement: immune to the wedge).

3. **Infinite spin, verbatim copy (panic.c1.txt; repeat in panic.c1b.txt):**
   thread A: `idr_get_new_above(sid=INT_MAX)` → -EAGAIN,
   `idr_maxwant=2147483647` poisoned; then `idr_pre_get` → **never returns**
   (`a_done=0`, `a_stage=2`): the growth loop computed `2*INT_MAX+1 → -1`
   and spins forever holding the lwkt idr token. Thread B called
   `idr_remove` on the same idr and **never returned** (`b_started=1`,
   `b_acquired=0` in run 1; in the repeat run B hung already inside its
   pre-spin token check) — the livelock the finding predicted. Watchdog
   panic at 15 s with the state flags.

4. **Infinite spin, shipped binary (panic.c2.txt):** identical outcome with
   the kernel's OWN exported `idr_get_new_above` / `idr_pre_get`
   (`last_kmalloc_req=0`: the rig's kmalloc hook is not in this path — the
   spinning code is the stock kernel text at 0xffffffff809d5590). This
   closes the "your recompile behaves differently" objection.

## Root cause (line-accurate)

* sys/libkern/linux_idr.c:427-430 — `nf = 2*nf+1; while (nf <= want)` in
  signed int: want ≥ 2^29-1 drives the kmalloc size toward (2^30-1)*16 =
  16 GiB; want == INT_MAX makes `2*INT_MAX+1` wrap to -1 and the predicate
  `-1 <= INT_MAX` true forever.
* sys/libkern/linux_idr.c:437-438 — the wrapped/oversized `nf` flows into
  `kmalloc(nf * sizeof(struct idr_node), ..., M_ZERO|M_WAITOK)`.
* Reachability of `want`: idr_alloc start (linux_idr.c:358-375, only
  `start < 0` rejected), idr_get_new_above sid → idr_maxwant
  (linux_idr.c:327-331) → idr_pre_get grow (linux_idr.c:274-275). No upper
  bound anywhere. In-tree callers (drm_gem start=1; amdgpu PASID 16-bit)
  are bounded — latent KPI hazard, hence the rig drives the exported KPI.

## Why DoS and not memory corruption / privesc (team directive)

* The wrapped `nf = -1` cannot escape the loop into the size computation:
  the do-while only exits when `nf > want`; exiting with negative `nf`
  requires `want < -1`, and every reachable `want` is non-negative
  (idr_alloc rejects negative start at :365-366; idr_maxwant only rises via
  `max()` at :328; the forced-growth `want = idr_count` at :303 is > 0).
* Even in the impossible case, `nf * sizeof(struct idr_node)` promotes nf to
  size_t: (size_t)(-1) * 16 = 0xFFFFFFFFFFFFFFF0 — a gigantic
  OVER-allocation request (allocator refuses), never an under-allocation.
  The subsequent `bcopy` at :451-452 uses the OLD `idr_count`, and the
  race guard at :441 frees the new buffer if it is not larger. There is no
  path to a smaller-than-needed allocation, so no heap corruption, no
  forged-object primitive, no uid=0 chain. Impact ceiling: kernel-mode
  livelock holding an lwkt token, unbounded M_WAITOK draw (memory
  exhaustion of every page into wired zero pages), watchdog-observable
  hang — DoS.

## Fix validation (fix.diff, fix_run.log, fix_build.log)

`fix.diff`: `idr_grow()` becomes `static int`, rejecting
`want >= IDR_MAX_IDS ((1<<24)-1)` with ENOMEM; growth loop otherwise
unchanged (with the cap, max computed nf = 2^25-1 — no signed overflow is
reachable); `idr_pre_get` maps failure to result 0 (its documented failure
contract — drm-style callers then give up instead of looping), `idr_alloc`
maps it to -ENOMEM (both grow sites, token correctly released via the
existing `done:` path).

Cycle: guest reset to clean source → patch applied in guest `/usr/src`
(`patch -p1`, 6/6 hunks) → `make -j6 nativekernel KERNCONF=X86_64_GENERIC`
(320 s, -Werror clean) → installkernel → reboot into `#1 Sat Oct 17 10:36:44`.
Rig rebuilt against the FIXED file (sha 1c04da32…, recorded in build.log)
and all phases re-run:

* control phases: PASSED, output identical to baseline (fix/run-all.log,
  fix/control-fixed.log);
* phase 3: `idr_alloc RETURNED -12` — and the hook proves the largest
  request that reached the allocator was the 4080-byte idr_init allocation:
  **no giant kmalloc was ever issued**;
* phases 4/5: `pre_get RETURNED 0`, `a_done=1 b_acquired=1`,
  "no spin, no livelock" — on both the fixed verbatim copy and the fixed
  kernel functions;
* `grep -c panic fix_run.log` → 0; guest stayed up throughout.

Note on rig history: the first fixed-kernel validation run completed all
phases successfully but crashed AFTER success during `kldunload` — a defect
in my first rig (systimer teardown race + a leaked idr freed the module text
too early), not in the fix; corrected by keeping the module resident (EBUSY)
after crash-phases and destroying the leftover idr; the second run
(fix_run.log) is clean end-to-end. The crash and its analysis are part of
the serial console history and were re-verified to not involve linux_idr.c
code paths beyond malloc_uninit bookkeeping.

## Bottom line

The finding is fully confirmed on the stock kernel, at exact byte sizes,
with livelock proof, on both a verbatim recompile and the shipped binary;
the one-line-bound fix eliminates both manifestations with bit-identical
normal-path behavior. Class: latent-KPI kernel DoS (Medium) — no
privilege-escalation potential exists for this bug.

## 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.
