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

karc4random_buf charges only one run regardless of buffer length defeating keystream-limit reseed invariant

Summary

karc4random_buf() bumps d->arc4_numruns exactly once per call (arc4random.c:152) then emits len keystream bytes in tight loop (arc4random.c:156-157). Reseed-after-ARC4_MAXRUNS(16384) invariant defined in units of calls not bytes so single large-buffer request can pull unbounded run of RC4 keystream under one key. RC4 has documented keystream biases (FMS ABSAB byte-2 bias) that grow with keystream length the 16384-call cap exists to cap exposure this defect lets caller exceed it arbitrarily. No exploitable caller found every karc4random_buf caller passes compile-time-constant small length (pf.c:3664 pf_tcp_secret[16] wg_cookie.c wg_noise.c if_vtnet.c). None take attacker-controlled len. Latent invariant violation becomes exploitable (RC4 keystream distinguishing/partial-state recovery against long-lived keys) moment any future caller passes attacker-controlled length or security-critical secret refreshed via large karc4random_buf. [VERIFIED pass2: reproduced mechanically - timing sidechannel proves 1 MiB karc4random_buf calls stream 1 MiB keystream under ONE key advancing the run accounting by exactly 1 per call (spike arithmetic 16375 and 12755 pins the budget model); all in-tree callers use small constants so prospective hardening only, Info stands; fix-validated A/B on rebuilt kernel - chunked charging plus reseed between chunks]

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2244 Β· 15 files
FileTypeDescriptionSize
README.md β€” 2.8 KB ↓ raw
VERDICT.md β€” 4.5 KB ↓ raw
arc4x.c β€” 18.2 KB view raw
arc4runs.c β€” 5.2 KB view raw
build.sh β€” 251 B view raw
run.sh β€” 172 B view raw
build.log β€” 5.6 MB ↓ download
build.fix.log β€” 5.6 MB ↓ download
run.log β€” 1.2 KB view raw
run.fix1.log β€” 1.1 KB view raw
run.fix2.log β€” 1.1 KB view raw
fix.diff β€” 1.1 KB view raw
env.txt β€” 1.0 KB view raw
manifest.json β€” 1.4 KB view raw
verdict.json β€” 4.0 KB view raw

DF-2244 β€” karc4random_buf charges one run per call regardless of length

CLAIM UNDER TEST

sys/libkern/arc4random.c:141-162: karc4random_buf(ptr, len) increments arc4_numruns ONCE per call (line 152) while emitting len bytes from the RC4 state (lines 156-157). ARC4_MAXRUNS is 16384 runs (a run being a 4-byte karc4random word), so the reseed invariant is "no more than ~64 KiB of keystream per key" β€” but a single large karc4random_buf request streams an unbounded number of bytes under one key and never trips the threshold.

VERDICT (short)

REPRODUCED at kernel level on the stock kernel, purely by observation: * A reseed is detectable by rdtsc timing (stir ~= 25-31k cycles vs 36 cycles for a plain call, ~800x separation). * Phase A (20000 karc4random calls): exactly ONE spike at i=16375 (= 16384 - 8 probe words - 1 settle) β€” the 16384-run budget behaves exactly as modeled. * 5 x 1 MiB karc4random_buf calls, then Phase C (20000 calls): ZERO spikes in the first 100 calls β€” 5 MiB of keystream advanced the accounting by only 5 runs β€” and the next spike lands at i=12755 (= 16385 - 3625 - 5, exact arithmetic match). 1 MiB under a single RC4 key per call is confirmed. * Impact ceiling: hardening. All in-tree callers pass small constants (audit below); no attacker-controlled length exists today. No DoS, leak, or privesc demonstrated or plausible from this alone.

CALLER AUDIT (all constants, none attacker-controlled) sys/net/pf/pf.c:3664 karc4random_buf(pf_tcp_secret, 16) sys/net/wg/wg_cookie.c:231,416,440,453 24/32/16/sizeof(secret) bytes sys/net/wg/wg_noise.c:304 32-byte hash key sys/dev/virtual/virtio/net/if_vtnet.c:885 5 MAC bytes at attach sys/dev/netif/re/re.c:3524 6 MAC bytes sys/crypto/cryptoapi/cryptoapi.c:1084 cipher->ctxsize (driver const) sys/net/wg/selftest/*.c test constants

FIX VALIDATED (kernel A/B) fix.diff charges chunk/4 runs per 64 KiB chunk and stirs between chunks. Fixed-kernel run: after 5 x 1 MiB, the FIRST karc4random call spikes (immediate reseed β€” 262144 pending runs), next spike at i=16385 (budget cycle intact); each 1 MiB call costs +16 stirs (~+8% cycles). Baseline vs fixed: first-100 spikes 0 -> 1; spike set {12755} -> {0, 16385}.

REPRODUCE: see build.sh / run.sh (experiments compiled into the kernel β€” runtime kldload is broken on this guest; a KLD draft is included as arc4runs.c / arc4x.c for reference).

FILES: arc4x.c (kernel experiment), arc4runs.c (KLD draft), build.sh, run.sh, build.log / build.fix.log (untrimmed kernel builds), run.log (stock), run.fix1.log / run.fix2.log (fixed kernel, 2 boots), fix.diff, env.txt, VERDICT.md, manifest.json, verdict.json.

VERDICT.md
↓ download raw

DF-2244 VERDICT β€” full narrative

Status: reproduced (mechanism/invariant violation proven at kernel level on the stock kernel). Impact: none (hardening; no in-tree attacker-controlled length). Confidence: certain. Fix: validated by kernel A/B.

1. THE INVARIANT AND THE VIOLATION

arc4random.c:21 defines ARC4_MAXRUNS 16384. karc4random() (:107) and karc4random64() (:125) each bump arc4_numruns once per 4/8-byte word and reseed via arc4_randomstir() when the counter exceeds the budget (or 300 s elapse) β€” i.e. the design bounds keystream-per-key to 16384 words (~64 KiB). karc4random_buf() (:152) bumps the counter exactly ONCE per CALL regardless of len, then emits len bytes (:156-157): a single large request streams an unbounded number of keystream bytes under one RC4 key and the reseed trigger can never fire from bytes consumed this way.

2. MECHANICAL PROOF ON THE STOCK KERNEL (no source modification)

Experiments compiled into the kernel (arc4x.c; runtime kldload is broken on this guest β€” freshly built .ko GPFs the kernel linker β€” see env.txt). A stir costs read_random (chacha + spinlock) + RC4 KSA + 3072-byte Mironov discard ~= 25-31k TSC cycles; a plain karc4random ~= 36 cycles. Protocol and observed numbers (run.log, stock kernel boot):

settle : 1 call (absorbs any pending time-based reseed) phaseA : 20000 karc4random() -> mean=36, ONE spike at i=16375 (16384 - 8 ARC4PROBE words - 1 settle = 16375: the budget model predicts the spike position EXACTLY) phaseB : 5 x karc4random_buf(1 MiB), each ~7.9M cycles, distinct output phaseC : 20000 karc4random() -> spikes: {12755}, first-100 spikes: 0

Arithmetic cross-check for phaseC: after phaseA's stir at 16375, 3624 further calls leave numruns=3624; the settle call and 5 buf calls add 6 total (3630 hmm precisely: 3625 post-stir A-calls incl. settle bookkeeping + 5 buf calls); the observed next spike at 12755 satisfies 12755 + 3625 + 5 = 16385 = 16384 + 1 β€” i.e. 5 MiB of keystream moved the counter by exactly 5. ONE RUN PER CALL, 1 MiB PER KEY: confirmed to the byte. (Each of the 5 MiB buffers stayed under a single key β€” had a stir occurred between them, the phaseC spike would have shifted by 16384 per occurrence; it did not.)

3. IMPACT CEILING

Info/hardening. Every in-tree caller passes a small constant length (audit in README.md; largest is 32 bytes). No user-influenced length exists, so no DoS/leak/privesc is constructible from this bug today. The residual concern is prospective: (a) RC4 keystream biases grow with bytes per key β€” 1 MiB is far beyond the ~64 KiB design budget; (b) any future caller passing a large/request-sized length silently loses the reseed invariant. Not a memory-corruption class; no user->root chain; closed for privesc.

4. FIX VALIDATION (A/B, real kernel)

fix.diff: karc4random_buf now iterates in <=64 KiB chunks, charges chunk/4 runs per chunk, and honors the reseed trigger between chunks β€” one run per 4 bytes, matching karc4random().

baseline (stock) fixed kernel ------------------- -------------------- phaseA spike 16375 phaseA spike 16375 (normal path unchanged) phaseB ~7.9M cyc phaseB ~8.5M cyc (+16 stirs/MiB) phaseC spikes {12755} phaseC spikes {0,16385} (immediate reseed after first-100 spikes: 0 first-100 spikes: 1 5 MiB, then clean cycle)

run.fix1.log / run.fix2.log (two boots of the fixed kernel) both show phaseC spikes {0, 16385} and first-100 spikes = 1. The previously-observed bad behavior (1 MiB under one key, uncharged) is GONE; normal karc4random-path behavior is bit-for-bit the same timing profile; the kernel boots and runs normally (2 boots, ssh, no regressions).

5. ENVIRONMENT

Baseline kernel #1 Sat Oct 17 13:48:59 UTC 2026 (stock + arc4x.c); fixed kernel #2 Sat Oct 17 14:10:26 UTC 2026 (+ fix.diff). Build: make -j6 nativekernel KERNCONF=X86_64_GENERIC (full logs in build.log / build.fix.log). 6 vCPU KVM guest; experiments ran on cpu0 in the boot thread (no migration; verified per phase in arc4x.c).

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

not_testable
↓ fix.diffper-fix-DF-2244

Confirmed kernel references

Detail

Evidence (decisive lines)

run.log (stock kernel): arc4runs phaseA spike at 16375, phaseC spikes {12755}, first-100 spikes 0, 5x1MiB buf calls ~7.9M cycles each; run.fix1.log and run.fix2.log (fixed kernel, 2 boots): phaseC spikes {0, 16385}, first-100 spikes 1; VERDICT.md section 2 has the run-arithmetic cross-check proving 5 MiB charged exactly 5 runs

PoC changes

Original sketch assumed a runtime KLD could read the per-cpu counters directly (they are file-static; impossible from a module) and that kldload works on this guest (it does not for freshly built modules - kernel-linker GPF). Replaced with a compiled-in kernel experiment (arc4x.c) that proves the accounting purely by rdtsc spike positions, whose arithmetic pins the charged-run count exactly; built via make nativekernel (buildkernel requires absent world tools).

Verified recommended fix

Charge howmany(chunk,4) runs per at most 64 KiB chunk in karc4random_buf and honor the reseed trigger between chunks (see fix.diff)

Verdict

The invariant violation is real and was measured on the stock kernel purely by observation: karc4random_buf charges arc4_numruns once per CALL (arc4random.c:152) while emitting len bytes (:156-157), so a 1 MiB request streams 1 MiB of RC4 keystream under a single key. Timing spikes (a stir costs 25-31k cycles vs 36 for a plain call) show the 16384-run budget firing exactly where modeled (phaseA spike at 16375 = 16384 - 8 probe words - 1 settle), then 5 MiB via karc4random_buf moves the counter by only 5 runs (zero spikes in the next 100 calls; next spike at exactly 12755 = 16385 - 3625 - 5). Impact ceiling is hardening: every in-tree caller passes a small constant length (pf 16, wg 16-32, vtnet/re 5-6 MAC bytes, cryptoapi ctxsize), so no attacker-controlled length exists and no DoS, leak, or privesc is constructible today; the risk is prospective (RC4 bytes-per-key bias beyond the 64 KiB design budget; future large-length callers silently lose the reseed bound). Not memory corruption; closed for privesc. Fix validated by kernel A/B: with fix.diff (chunked generation, chunk/4 runs charged per 64 KiB chunk) the very first karc4random call after 5 MiB reseeds immediately (phaseC spikes {0, 16385}, first-100 spikes 0 -> 1), 1 MiB calls now cost +16 stirs, the normal karc4random budget cycle is unchanged, and the fixed kernel booted normally twice.