# DF-0220 — VERDICT

**Finding:** Predictable RNG: `/dev/urandom`, `getrandom(2)`, and `kern.random`
return a deterministic ChaCha20 keystream (zero key) before the first reseed,
identical across independent boots. (Severity High, Confidence "certain".)

**Verdict: NOT REPRODUCED** (the exploitable condition does not manifest on
DragonFly master DEV; the finding's premise about pool[0] entropy is wrong).

---

## What is genuinely true in the claim (confirmed by source read)

1. **The readiness gate is bypassed for unlimited readers.**
   `sys/kern/subr_csprng.c:146`:
   ```c
   if ((flags & CSPRNG_UNLIMITED) == 0 && state->reseed_cnt == 0) {
       ssleep(state, &state->spin, 0, "csprngrsd", 0);
       goto again;
   }
   ```
   Only NON-unlimited readers block when `reseed_cnt == 0`. `/dev/urandom`,
   `getrandom(2)`, `kern.random`, and in-kernel `arc4random` all reach
   `csprng_get_random` with `CSPRNG_UNLIMITED` (`sys/kern/kern_nrandom.c:703,711`,
   reached from `read_random(...,1)` at `kern_nrandom.c:739,770` and
   `sys/libkern/arc4random.c:60`). So the gate genuinely does not protect them.

2. **The cipher context is never keyed at init.**
   `csprng_init` (`subr_csprng.c:84-85`) does
   `bzero(state->key, ...)` and `bzero(&state->cipher_ctx, ...)` and never calls
   `chacha_keysetup`. So IF `chacha_encrypt_bytes` (`subr_csprng.c:155`) ran
   while `reseed_cnt == 0`, it would run on an **all-zero** `chacha_ctx` (all 16
   input words zero, including the 4 sigma-constant words that `keysetup`
   normally writes at `chacha.c:74-77`).

3. **The all-zero chacha state is a fixed point → degenerate "keystream" is all zeros.**
   The chacha quarterround (`chacha.c:46-50`) uses only `PLUS`, `XOR`, `ROTATE`.
   With every input word 0, every operation yields 0 (0+0=0, 0^0=0, rotl(0,n)=0),
   so after the 20 rounds (`chacha.c:165-174`) and the final add (`chacha.c:175-190`)
   all 16 output words are still 0. `ref_keystream.c` reproduces the kernel's
   exact transform on the all-zero state and emits **64 bytes of 0x00**. (The
   finding's description "ChaCha20(key=0^32, counter=0^16)" is therefore
   inaccurate: that variant still has the non-zero sigma constants at
   `input[0..3]` and would be non-zero; the real degenerate output is all-zeros.)

So the **code pattern** the finding describes is real: an unlimited reader that
reached `csprng_get_random` while `reseed_cnt == 0` would receive all-zero bytes
from the csprng (XORed with IBAA in default `mixed` mode).

## Why it does NOT reproduce on this kernel

The finding's entire exploitability hinges on a window in which `reseed_cnt == 0`
**and** a reader obtains output. That window does not exist on master DEV,
because the cipher is keyed during `rand_initialize()` — a kernel SYSINIT that
runs **before** `init(8)`, i.e. before any userspace read is possible.

Trace (`sys/kern/kern_nrandom.c:488-560`, `SYSINIT(rand1, SI_BOOT2_POST_SMP, ...)`
at `:562`):
- For each CPU, after `csprng_init`/`IBAA_Init`/`L15_Init`, a timing loop
  (`:517-531`) injects `SIZE/2 = 128` csprng feeds round-robin across the 32
  pools (`csprng_add_entropy` at `subr_csprng.c:272-273`), giving pool[0] only
  ~4 feeds (≈32 B). **This is the only entropy the finding considered**, and on
  that basis alone its "pool[0] < 96 bytes" claim would hold.
- **But the finding omits the per-CPU `globaldata` feed** at `kern_nrandom.c:539-543`:
  ```c
  state->inject_counter[RAND_SRC_THREAD2] = 0;
  add_buffer_randomness_state(state, (void *)rgd, sizeof(*rgd), RAND_SRC_THREAD2);
  ```
  `RAND_SRC_THREAD2 = 0x0c` (`sys/sys/random.h:89`); `csprng_add_entropy` routes
  by `src_pool_idx[src_id & 0xff]++ & 0x1f` (`subr_csprng.c:272-273`), and
  `src_pool_idx[0x0c]` starts at 0, so this feed lands in **pool[0]**. Its size is
  `sizeof(struct globaldata)` (`sys/sys/globaldata.h:129-215`) — thousands of
  bytes (`gd_reserved02B[200]` alone is 1600 B, plus `gd_idlethread`, the slab
  caches, `gd_systimerq`, etc.). pool[0] therefore holds **>> 96 bytes**.
- The final `read_random(buf, sizeof(buf), 1)` at `kern_nrandom.c:558` enters
  `csprng_get_random`, whose `ratecheck` (`subr_csprng.c:134-135`) fires on the
  first call, invoking `csprng_reseed` (`:176`). The reseed guard
  `state->pool[0].bytes < MIN_POOL_SIZE` (`:188`, `MIN_POOL_SIZE = 96` at `:54`)
  **passes**, so the reseed succeeds: `reseed_cnt` becomes 1
  (`subr_csprng.c:201`), a new key is derived from the pools (`:228`) and
  `chacha_keysetup` + `chacha_ivsetup` finally key the cipher (`:231,235`).

From this point — still inside kernel SYSINIT, before userspace — every
subsequent read sees a properly-keyed csprng. There is no userspace-reachable
pre-reseed window. (Even the in-kernel `arc4random` first stir happens after
this, so it keys from good data.)

## Empirical proof (full data in leak_sample.txt, boot1_probe.txt, boot2_probe.txt)

Two independent `vm.sh reset` boots, probe run as soon as ssh comes up:

| Source        | Boot #1 vs Boot #2 (64 B) | vs reference all-zero |
|---------------|---------------------------|-----------------------|
| /dev/urandom  | 64/64 differ              | 64/64 differ          |
| getrandom(2)  | 64/64 differ              | 64/64 differ          |
| kern.random   | 64/64 differ              | 64/64 differ          |

Output is non-deterministic across boots and never matches the degenerate
keystream.

Decisive secondary test — isolate the csprng with `sysctl kern.rand_mode=csprng`
(raw csprng, no IBAA mixing): three consecutive reads return non-zero, distinct
bytes. If `reseed_cnt` were still 0, csprng-only mode would emit the all-zero
fixed-point keystream. It does not → the cipher is keyed before userspace.

## Classification

This is **case (a) false-premise / (d) not reachable on this kernel**: the
finding's threat model assumes a userspace-observable pre-reseed window, but the
per-CPU `globaldata` entropy feed (`kern_nrandom.c:539-543`) closes that window
during `rand_initialize` (kernel SYSINIT), before `init`. The cross-boot
determinism the finding predicts is empirically absent.

The underlying gate-bypass + never-keyed-cipher pattern is a **real
defense-in-depth gap** (if pool[0] routing or the `rgd` feed ever changed, the
all-zero leak would resurface for unlimited readers), so `fix.diff` provides a
targeted hardening — but it is hardening, not a fix for a reproduced vuln.

## PoC changes

No PoC existed; the runner authored `rand_probe.c` (multi-source RNG reader +
uptime) and `ref_keystream.c` (degenerate-keystream reference that reproduces the
kernel's exact chacha transform on the all-zero state). The finding's prose PoC
(`dd if=/dev/urandom | xxd`) was implemented faithfully and extended to also read
`getrandom(2)`, `/dev/random`, and `kern.random`, and to compute the reference
for direct comparison.
