โฌข DragonFlyBSD Kernel Audit
โ† dashboard
DF-0220

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.

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 until CRNG_READY
  • FreeBSD random(4): /dev/urandom blocks until seeded

Timeline

  • 2026-06-30 Discovered during automated audit.

PoC verification

Evidence pack

findings/poc/DF-0220 ยท 12 files
FileTypeDescriptionSize
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
README.md readme build/run/expected + cross-boot reproduce procedure
โ†“ download 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.)

VERDICT.md verdict full narrative: gate-bypass is real code pattern, but window closed by rgd feed during rand_initialize
โ†“ download raw

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.

Confirmed kernel references

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