Predictable RNG: /dev/urandom+getrandom+kern.random return deterministic ChaCha20 keystream (zero key) before first reseed
| Field | Value |
|---|---|
| ID | DF-0220 |
| Status | new |
| Severity | High |
| CVSS 3.1 | CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:C/C:H/I:H/A:N |
| CWE | CWE-338 Use of Cryptographically Weak PRNG |
| File | sys/kern/subr_csprng.c |
| Lines | 84-166 |
| Area | kern |
| Confidence | certain |
| Discovered | 2026-06-30 |
| Reported | pending |
Summary
csprng_init() zeroizes both the key and cipher context (:84-85) but never
calls chacha_keysetup(). The only readiness gate (:146) blocks non-unlimited
readers when reseed_cnt == 0, but /dev/urandom, getrandom(2), and
kern.random all pass CSPRNG_UNLIMITED, bypassing it. Before the first
successful Fortuna reseed, all three interfaces emit ChaCha20 keystream from
the all-zero (key, counter) pair โ a byte-for-byte reproducible sequence
identical on every booted machine. This makes all boot-time cryptographic
material (SSH keys, TCP ISNs, IP IDs, UUIDs, MAC addresses) predictable.
Root cause
Initialization (csprng_init, :80-90):
bzero(state->key, sizeof(state->key)); // :84 โ all zeros
bzero(&state->cipher_ctx, sizeof(state->cipher_ctx)); // :85 โ never keyed
state->reseed_cnt = 0; // :89
Readiness gate (csprng_get_random, :146):
if ((flags & CSPRNG_UNLIMITED) == 0 && state->reseed_cnt == 0) {
ssleep(state, &state->spin, 0, "csprngrsd", 0);
goto again;
}
Only blocks when CSPRNG_UNLIMITED is NOT set.
Output (:152):
chacha_encrypt_bytes(&state->cipher_ctx, NULL, out, cnt);
Runs on the still-all-zero cipher context.
Callers passing CSPRNG_UNLIMITED:
- /dev/urandom: sys/kern/kern_memio.c:352
- getrandom(2): sys/kern/kern_nrandom.c:770
- kern.random sysctl: sys/kern/kern_nrandom.c:739
- arc4random(): keys from read_random unlimited (sys/libkern/arc4random.c:60)
Reseed trigger (csprng_reseed, :188-191):
if (state->pool[0].bytes < MIN_POOL_SIZE) {
state->failed_reseeds++;
return;
}
Pool[0] needs โฅ 96 bytes of entropy AND ratecheck() must fire. Entropy is
scattered round-robin across 32 pools (:272-274), so pool[0] commonly
receives < 96 bytes during early boot.
Threat model & preconditions
- Attacker position: Local or partially remote (via early-boot network services). Anyone who can predict the boot window.
- Impact: ALL cryptographic material derived from the kernel RNG during the pre-reseed window is 100% predictable:
- SSH/SSL session keys (via arc4random)
- TCP initial sequence numbers (
sys/netinet/tcp_subr.c:1702,1705,1741) - IP IDs (
sys/netinet/ip_id.c:122) - UUIDs (
sys/kern/kern_uuid.c:89,126) - Generated NIC MAC addresses (
sys/dev/netif/re/re.c:3521) - Required config: Default kernel. Entropy-poor systems (embedded, virtio without virtio-rng, headless) may have an indefinite window.
Proof of concept
# On a freshly booted DragonFlyBSD: dd if=/dev/urandom bs=64 count=1 | xxd # Compare against ChaCha20(key=0^32, counter=0^16, 64 bytes) # โ they match exactly when captured before first reseed. # Repeating on a second identical boot yields the identical bytes.
Recommended fix
Gate unlimited output on readiness, or key the cipher at init:
--- a/sys/kern/subr_csprng.c
+++ b/sys/kern/subr_csprng.c
@@ -144,6 +144,11 @@
again:
/*
* If no reseed has occurred yet, we can't possibly give out
* any random data.
+ * Even unlimited readers should block until the first reseed,
+ * because the cipher context is all-zero until keyed.
*/
- if ((flags & CSPRNG_UNLIMITED) == 0 && state->reseed_cnt == 0) {
+ if (state->reseed_cnt == 0) {
+ if (flags & CSPRNG_UNLIMITED) {
+ /* Non-blocking: return 0 or fall back */
+ return 0;
+ }
ssleep(state, &state->spin, 0, "csprngrsd", 0);
goto again;
}
Additionally: csprng_init should key the cipher with at least hashed boot
timestamp data rather than leaving it all-zero, so any leak through the gate
is not trivially reproducible.
References
- Fortuna algorithm: Ferguson, Schneier, "Practical Cryptography"
- Linux
getrandom(2): blocks untilCRNG_READY - FreeBSD
random(4):/dev/urandomblocks until seeded
Timeline
- 2026-06-30 Discovered during automated audit.
PoC verification
Evidence pack
findings/poc/DF-0220 ยท 12 files| File | Type | Description | Size | |
|---|---|---|---|---|
| rand_probe.c | trigger-source | reads 64B from /dev/urandom, /dev/random, getrandom(2), kern.random + uptime; prints hex | 2.6 KB | view raw |
| ref_keystream.c | reference | computes the degenerate all-zero pre-reseed csprng keystream (kernel's exact chacha on zeroed ctx) | 3.2 KB | view raw |
| build.sh | build-script | cc -O2 both programs | 283 B | view raw |
| run.sh | run-script | prints reference keystream + live RNG probe | 459 B | view raw |
| boot1_probe.txt | run-log | full probe output, fresh boot #1 (uptime 58s) | 1.0 KB | view raw |
| boot2_probe.txt | run-log | full probe output, fresh boot #2 (uptime 18s) | 1.0 KB | view raw |
| final_verify.txt | run-log | end-to-end run of shipped build.sh+run.sh confirming pack reproducibility | 1.4 KB | view raw |
| leak_sample.txt | leak-sample | side-by-side cross-boot byte comparison: 64/64 differ, none match all-zero ref | 6.1 KB | view raw |
| env.txt | environment | uname, cc version, dmesg RNG lines (no rdrand), sysctl rand_mode/boottime | 1.4 KB | view raw |
| VERDICT.md | verdict | full narrative: gate-bypass is real code pattern, but window closed by rgd feed during rand_initialize | 6.7 KB | โ raw |
| fix.diff | suggested-fix | defense-in-depth: gate unlimited readers too when reseed_cnt==0 (return 0, don't emit degenerate keystream); supersedes finding proposal | 1.1 KB | view raw |
| README.md | readme | build/run/expected + cross-boot reproduce procedure | 3.2 KB | โ raw |
DF-0220 โ PoC: Predictable RNG pre-reseed claim (verification)
Finding: findings/DF-0220-csprng-predictable-rng-pre-reseed.md
Claim: /dev/urandom, getrandom(2), and kern.random return a deterministic
all-zero-key ChaCha20 keystream before the first Fortuna reseed โ identical
across independent boots.
Files
| File | Purpose |
|---|---|
rand_probe.c |
Reads 64 B from /dev/urandom, /dev/random, getrandom(2), kern.random; prints hex + uptime |
ref_keystream.c |
Computes the degenerate pre-reseed csprng keystream (all-zero chacha input[16]) for comparison |
build.sh |
Builds both programs |
run.sh |
Runs the probe (single boot) |
boot1_probe.txt |
Full probe output, boot #1 (fresh vm.sh reset) |
boot2_probe.txt |
Full probe output, boot #2 (fresh vm.sh reset) |
leak_sample.txt |
Side-by-side cross-boot byte comparison + analysis |
env.txt |
Guest uname, dmesg RNG lines, sysctl state |
VERDICT.md |
Full narrative verdict |
fix.diff |
Defense-in-depth fix (gate-bypass is real, window is closed) |
manifest.json |
Machine-readable artifact catalog |
Build
./build.sh
(equivalent to cc -O2 -o rand_probe rand_probe.c && cc -O2 -o ref_keystream ref_keystream.c)
Run
./run.sh # single-boot probe; prints hex of 4 RNG sources + uptime ./ref_keystream 64 # prints the degenerate all-zero pre-reseed keystream
Expected (claim TRUE / bug present)
On two independent fresh boots, the 64-byte /dev/urandom (and getrandom,
kern.random) outputs would be byte-for-byte identical to each other AND
identical to ref_keystream's output (the degenerate pre-reseed stream). With
sysctl kern.rand_mode=csprng, /dev/urandom would return all zero bytes.
Actual (on DragonFly master DEV 6.5-DEVELOPMENT)
On two independent fresh vm.sh reset boots the outputs are completely
different (64/64 bytes differ) and never match the all-zero reference.
With rand_mode=csprng, /dev/urandom returns non-zero, varying bytes โ proving
the csprng cipher context is already keyed (reseeded) before any userspace read.
The claim does not reproduce. See VERDICT.md and leak_sample.txt for the
root cause (the per-CPU globaldata entropy feed reseeds pool[0] during
rand_initialize, before init).
Reproduce across boots (the decisive test)
dfbsd-qemu/vm.sh reset
dfbsd-qemu/vm.sh run_user 'cd poc/DF-0220 && ./rand_probe' > boot1.txt
dfbsd-qemu/vm.sh reset
dfbsd-qemu/vm.sh run_user 'cd poc/DF-0220 && ./rand_probe' > boot2.txt
diff <(sed -n '/\/dev\/urandom/,/^$/p' boot1.txt) \
<(sed -n '/\/dev\/urandom/,/^$/p' boot2.txt) # differs => not reproducible
(Note: vm.sh reset reverts the disk, so the PoC must be re-deployed โ see
build.sh/run.sh which re-copy and rebuild as needed.)
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)
-
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 whenreseed_cnt == 0./dev/urandom,getrandom(2),kern.random, and in-kernelarc4randomall reachcsprng_get_randomwithCSPRNG_UNLIMITED(sys/kern/kern_nrandom.c:703,711, reached fromread_random(...,1)atkern_nrandom.c:739,770andsys/libkern/arc4random.c:60). So the gate genuinely does not protect them. -
The cipher context is never keyed at init.
csprng_init(subr_csprng.c:84-85) doesbzero(state->key, ...)andbzero(&state->cipher_ctx, ...)and never callschacha_keysetup. So IFchacha_encrypt_bytes(subr_csprng.c:155) ran whilereseed_cnt == 0, it would run on an all-zerochacha_ctx(all 16 input words zero, including the 4 sigma-constant words thatkeysetupnormally writes atchacha.c:74-77). -
The all-zero chacha state is a fixed point โ degenerate "keystream" is all zeros. The chacha quarterround (
chacha.c:46-50) uses onlyPLUS,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.creproduces 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 atinput[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.
Confirmed kernel references
- sys/kern/subr_csprng.c:84
- sys/kern/subr_csprng.c:85
- sys/kern/subr_csprng.c:146
- sys/kern/subr_csprng.c:155
- sys/kern/subr_csprng.c:176
- sys/kern/subr_csprng.c:188
- sys/kern/subr_csprng.c:201
- sys/kern/subr_csprng.c:272
- sys/kern/kern_nrandom.c:488
- sys/kern/kern_nrandom.c:539
- sys/kern/kern_nrandom.c:543
- sys/kern/kern_nrandom.c:558
- sys/kern/kern_nrandom.c:562
- sys/kern/kern_nrandom.c:703
- sys/kern/kern_nrandom.c:770
- sys/sys/random.h:89
- sys/sys/ibaa.h:3
- sys/sys/globaldata.h:129
- sys/crypto/chacha20/chacha.c:46
- sys/crypto/chacha20/chacha.c:74
- sys/libkern/arc4random.c:60
Detail
Evidence (decisive lines)
VERDICT.md (full line-cited analysis); leak_sample.txt (decisive cross-boot comparison: boot1 vs boot2 = 64/64 bytes differ for all 3 sources; both vs all-zero reference = 64/64 differ); boot1_probe.txt + boot2_probe.txt (full probe outputs from two fresh resets); final_verify.txt (end-to-end run of shipped build.sh+run.sh: ref_keystream=all-zeros, live RNG=non-zero non-deterministic); env.txt (uname 6.5-DEVELOPMENT, no rdrand, rand_mode=mixed); fix.diff (defense-in-depth, git-apply --check passes).
PoC changes
No PoC existed in the folder; authored rand_probe.c (reads 64B from /dev/urandom, /dev/random, getrandom(2) via libc, kern.random sysctl + uptime, prints hex) and ref_keystream.c (computes the degenerate pre-reseed keystream by reproducing the kernel's exact chacha20 transform โ CHACHA_NONCE0_CTR128 + KEYSTREAM_ONLY โ on the all-zero cipher ctx). Also wrote build.sh, run.sh, VERDICT.md, leak_sample.txt, env.txt, fix.diff, manifest.json. The finding's prose 'dd if=/dev/urandom | xxd' PoC was implemented faithfully and extended to all four cited interfaces plus a reference comparator and a cross-boot procedure.
Verified recommended fix
Defense-in-depth only (window already closed on this kernel by the rgd feed). fix.diff gates CSPRNG_UNLIMITED readers too when reseed_cnt==0, returning 0 bytes instead of emitting the degenerate all-zero keystream (mirrors getrandom(GRND_NONBLOCK)); supersedes the finding's proposal (same intent, cleaner nesting). NOTE the finding's proposal would make in-kernel arc4random (arc4random.c:60) receive 0 bytes pre-reseed and fall back to stack garbage โ acceptable since that path is unreachable post-rand_initialize, but worth flagging to maintainers.
Verdict
NOT REPRODUCED. The gate-bypass code pattern is real (subr_csprng.c:146 skips CSPRNG_UNLIMITED readers; :155 runs chacha on the never-keyed all-zero cipher ctx, whose keystream is the trivial all-zero fixed point since 0+0=0, 0^0=0, rotl(0,n)=0). But the exploitable pre-reseed window does not exist on master DEV: rand_initialize() (kern_nrandom.c:488-562, SYSINIT SI_BOOT2_POST_SMP, runs before init) feeds sizeof(struct globaldata) โ thousands of bytes (gd_reserved02B[200] alone is 1600B) โ into csprng pool[0] via RAND_SRC_THREAD2 (kern_nrandom.c:539-543), far exceeding the 96-byte reseed threshold (subr_csprng.c:188), so the first read_random at kern_nrandom.c:558 triggers a successful csprng_reseed that keys the cipher before any userspace read. The finding's 'pool[0] < 96 bytes during early boot' premise omits this per-CPU globaldata feed entirely. Empirically: two fresh vm.sh reset boots yield 64/64 differing bytes for /dev/urandom, getrandom(2), and kern.random, none matching the all-zero reference; and rand_mode=csprng reads are non-zero (cipher already keyed).