β¬’ DragonFlyBSD Kernel Audit
← triage Β· 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.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0220 Β· 27 files
FileTypeDescriptionSize
rand_probe.c trigger-source unprivileged 4-source RNG probe (unchanged from prior pass) 2.6 KB view raw
zero_scan.c trigger-source unprivileged early-boot window scanner: 3 interfaces, all-zero-block detector 2.7 KB view raw
ref_keystream.c reference degenerate all-zero + zero-key chacha20 references (zerokey mode == RFC 7539 vector) 3.8 KB view raw
csprng_force_zero.c trigger-source root /dev/kmem rig: info/force/check/restore/peekctx/window/window2 on per-CPU csprng_state 8.3 KB view raw
chain_model.py reference offline model of the manufactured window; matches live kernel 4/4 blocks + 4/4 loaded keys 2.7 KB view raw
boot_probe.sh run-script one-shot fresh-boot probe: build, unprivileged reads+scan, kmem state dump at earliest ssh 1.3 KB view raw
window_experiment.sh run-script manufactured-window driver v1 (usched-mask pitfall documented) 2.3 KB view raw
window_experiment2.sh run-script manufactured-window driver v2 (correct :4 pinning) 3.1 KB view raw
build.sh build-script cc -O2 rand_probe + ref_keystream 151 B view raw
run.sh run-script single-boot probe run 208 B view raw
boot1_probe.txt run-log fresh boot 1: full probe (uptime 17-33 s), all states keyed, 0 zero blocks 3.0 KB view raw
boot2_probe.txt run-log fresh boot 2: same 3.0 KB view raw
boot3_probe.txt run-log fresh boot 3: same 3.0 KB view raw
leak_sample.txt leak-sample cross-boot comparison: 59-60/64 B differ on all pairs/interfaces; 0/6624 zero blocks; per-boot state dumps 3.8 KB view raw
kmem_window_decisive.log run-log DECISIVE: sigma offset proof, 5/5 identical manufactured windows (0x00*32 first), mixed-mode mask, self-heal 4.8 KB view raw
kmem_window_chain.log run-log per-read live ctx dumps matching chain_model.py exactly 2.4 KB view raw
kmem_window_attempt1.log run-log attempt 1: usched :3 = {cpu0,cpu1} mis-pin; one background burn observed on state[2] 7.9 KB view raw
kmem_window_attempt2.log run-log attempt 2: usched :2 = cpu1 mis-pin; reads never touched forced state 3.9 KB view raw
kmem_window_attempt3.log run-log attempt 3: correct pin, exec-race burns window (deterministic post-burn chain) 3.2 KB view raw
final_verify.txt run-log end-to-end sequence + result of this pass 4.9 KB view raw
final_state.log run-log guest left healthy: all states keyed, rand_mode=mixed restored 904 B view raw
env.txt environment uname/build, symbols, struct offsets, usched mask semantics, no-rdrand entropy-poor guest 2.5 KB view raw
fix_apply_compile.log build-log fix.diff dry-run + apply + exact-kernel-CFLAGS compiles (pristine + patched objects) + pristine restore 846 B view raw
fix.diff suggested-fix defense-in-depth: gate CSPRNG_UNLIMITED readers too when reseed_cnt==0 (return 0, don't emit degenerate keystream) 1.2 KB view raw
VERDICT.md verdict full narrative: Q1 first-reseed timing, Q2 window content (proven), Q3 unprivileged observability (none), DF-5766 overlap 6.0 KB ↓ raw
README.md readme build/run/expected + cross-boot reproduce procedure 4.9 KB ↓ raw
archive-2026-07/ archive prior July verification evidence (boot probes, leak_sample, env, final_verify, manifest) 4.0 KB ↓ download
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.

Verdict (re-verified 2026-10-12, VERIFY mode): NOT REPRODUCED. No unprivileged user can observe the pre-reseed window on this kernel β€” but the emit mechanism itself is now PROVEN live and fully deterministic via a root-manufactured state (/dev/urandom returns literal 0x00 bytes, then a keystream reproduced offline by chain_model.py, 5/5 identical trials). See VERDICT.md for the full line-cited trace and the DF-5766 overlap.

Files

File Purpose
rand_probe.c Reads 64 B from /dev/urandom, /dev/random, getrandom(2), kern.random + uptime
zero_scan.c Unprivileged early-boot window scanner (3 interfaces, all-zero-block detector)
ref_keystream.c Degenerate + zero-key chacha20 references (zerokey mode validated vs RFC 7539 vector)
csprng_force_zero.c Root /dev/kmem rig: dump/force/check/restore per-CPU csprng state; in-process manufactured window (window, window2 = per-read ctx dumps)
chain_model.py Offline model of the manufactured window β€” matches live kernel 4/4 blocks + 4/4 keys
boot_probe.sh One-shot fresh-boot probe (build + unprivileged reads + scan + kmem state dump)
window_experiment.sh / window_experiment2.sh Manufactured-window drivers (v1/v2 β€” see attempt logs for the pinning pitfalls)
build.sh / run.sh Build + single-boot run of the probe pair
boot{1,2,3}_probe.txt Fresh-boot outputs (2026-10-12): 4-source hex + zero_scan + per-CPU state at earliest ssh
leak_sample.txt Cross-boot comparison: all pairs differ 59-60/64 B; 0 zero blocks / 6624 samples
kmem_window_decisive.log THE decisive run: sigma proof, 5/5 identical manufactured windows, mixed-mode mask, self-heal
kmem_window_chain.log Per-read live ctx dumps matching chain_model.py exactly
kmem_window_attempt{1,2,3}.log Earlier attempts (usched-mask pinning pitfalls + background-burn evidence)
final_verify.txt End-to-end re-verification sequence + result
final_state.log Guest left healthy (all states keyed, rand_mode restored)
env.txt Guest uname/build, symbols, struct offsets, usched semantics
fix_apply_compile.log fix.diff: applies cleanly; compiles with exact kernel CFLAGS
VERDICT.md Full narrative verdict
fix.diff Defense-in-depth fix (gate unlimited readers when reseed_cnt==0)
manifest.json Machine-readable artifact catalog
archive-2026-07/ Prior (July) verification evidence, preserved

Build

./build.sh                 # rand_probe + ref_keystream
cc -O2 -o zero_scan zero_scan.c
cc -O2 -o csprng_force_zero csprng_force_zero   # root tool

Run

./run.sh                          # single-boot probe (unprivileged)
./boot_probe.sh                   # full fresh-boot probe (root; su's down to maxx)
usched :4 ./csprng_force_zero <sym> window 2   # manufactured window (root, csprng mode)
python3 chain_model.py            # offline model -> matches the above

(sym = nm /boot/kernel/kernel | awk '/ csprng_pcpu$/ {print "0x"$1}')

Expected (claim TRUE / bug present)

Across independent fresh boots the 64-byte outputs would be byte-for-byte identical AND identical to the degenerate reference; zero_scan would see all-zero blocks; rand_mode=csprng would return 0x00 bytes.

Actual (DragonFly 6.5-DEVELOPMENT #0, 2026-10-12)

Three fresh vm.sh reset with-src boots: outputs differ 59-60/64 bytes on every interface and every boot pair, never all-zero; 0/6624 all-zero blocks in the first 33 s of boot; every per-CPU state already keyed (reseed_cnt>=1) at the earliest ssh. The window CAN be manufactured with root /dev/kmem β€” and then behaves exactly as the finding predicted (deterministic 0x00… chain) β€” but the precondition (reseed_cnt==0 ∧ pool[0]<96 at an UNLIMITED read) is unreachable from userspace because rand_initialize() pre-feeds every state's pool[0] with ~15 KB before its first possible read, and the reseed attempt precedes the emit loop. See VERDICT.md.

Reproduce across boots (the decisive test)

for N in 1 2 3; do
  dfbsd-qemu/vm.sh reset with-src
  scp -F dfbsd-qemu/config {rand_probe,ref_keystream,zero_scan,csprng_force_zero}.c boot_probe.sh dfbsd:/tmp/rng220/
  dfbsd-qemu/vm.sh run_root 'sh /tmp/rng220/boot_probe.sh' > boot${N}_probe.txt
done
# then compare the hex sections (see leak_sample.txt) β€” they must differ
VERDICT.md verdict full narrative: Q1 first-reseed timing, Q2 window content (proven), Q3 unprivileged observability (none), DF-5766 overlap
↓ download raw

DF-0220 VERDICT -- not_reproduced (impact: none; confidence: certain)

Bottom line

NOT REPRODUCED (3rd verification, 2026-10-12, source unchanged since 2026-06-27). No unprivileged user can observe the pre-reseed zero-key window on this kernel: rand_initialize() (kern_nrandom.c:485-560, SYSINIT SI_BOOT2_POST_SMP :562, before init(8)) pre-feeds EVERY per-CPU state's pool[0] with ~15 KB (sizeof(struct globaldata)=14976 B via RAND_SRC_THREAD2 :539-543), and csprng_get_random() always attempts a reseed BEFORE its emit loop (subr_csprng.c:134-149 precede :151-166) with ratecheck's {0,0} first-call guarantee (kern_time.c:1153-1157) -- so each state's first read keys the cipher before emitting a byte. Live proof: on 3 fresh boots every state shows reseed_cnt>=1/key!=0 at the earliest ssh (uptime 17-33 s); the BSP is frozen at reseed_cnt=1 with last_reseed=184467440735.6xxxxx (the garbage getmicrouptime captured AT its rand_initialize-time reseed, = DF-5766's frozen baseline -- pinning the first reseed pre-userland). Cross-boot outputs differ 59-60/64 bytes on all interfaces/pairs; 0 all-zero blocks in 6624 unprivileged early-boot samples (urandom+getrandom+kern.random); default mixed mode additionally XORs IBAA (kern_nrandom.c:708-713), masking even a manufactured window. NEW this pass: the mechanism itself is now PROVEN live and fully deterministic -- with a root /dev/kmem-manufactured state (reseed_cnt=0 AND key=0 AND ctx=0 AND pool[0].bytes=0 AND last_reseed={0,0}), /dev/urandom in rand_mode=csprng returns literal 0x00*32 and then a keystream chain_model.py reproduces offline exactly (4/4 blocks + 4/4 internally-loaded keys, 5/5 byte-identical trials; per-read live ctx dumps match). The manufactured window is consumed by the first background reader within ~0.2 s and self-heals via reseed within seconds of pool[0] refilling -- it is unreachable and unstable, never userspace-observable. Overlap with DF-5766: DF-5766's frozen-BSP bug is what makes the BSP's reseed_cnt permanently 1 (never 0), reinforcing closure; the entropy quality of the BSP's single forever-key is DF-5766's subject, not DF-0220's zero-key window.

Reproduce

build: cc -O2 -o rand_probe rand_probe.c && cc -O2 -o ref_keystream ref_keystream.c && cc -O2 -o zero_scan zero_scan.c && cc -O2 -o csprng_force_zero csprng_force_zero.c
run:   sh boot_probe.sh (per fresh vm.sh reset with-src boot, 3x); SYM=0x$(nm /boot/kernel/kernel | awk '/ csprng_pcpu$/ {print $1}'); sysctl kern.rand_mode=csprng; usched :4 ./csprng_force_zero $SYM window 2 (x5); python3 chain_model.py

Evidence in this pack

kmem_window_decisive.log (THE decisive run: sigma-at-offset-40 proof; 5/5 identical manufactured windows with blk1=0x00*32; mixed-mode non-zero; self-heal pool0 0->372 -> reseed_cnt 1); kmem_window_chain.log + chain_model.py (per-read live ctx dumps equal the offline model 4/4 blocks + 4/4 keys 523b05d3/4b57cfe4/8b5a7c87/22589bb5); boot{1,2,3}_probe.txt (fresh boots: unprivileged 4-source reads + zero_scan 0/6624 zero blocks + all 6 states keyed at earliest ssh, BSP frozen at reseed_cnt=1 with 184467440735.6xx); leak_sample.txt (cross-boot pairwise 59-60/64 differ, never all-zero); final_verify.txt (full sequence); fix_apply_compile.log (applies @138 + compiles with exact kernel CFLAGS); env.txt; VERDICT.md

Kernel references

Fix validation (A/B)

Defense-in-depth only (window already closed by the rgd pool-pre-feed): fix.diff gates CSPRNG_UNLIMITED readers too when reseed_cnt==0, returning 0 bytes instead of emitting the degenerate keystream (mirrors getrandom(GRND_NONBLOCK)); applies cleanly (hunk @138) and compiles warning-free with exact kernel CFLAGS (fix_apply_compile.log).

Baseline vs post-fix behavior is in run.log / run_fixed*.log (userspace) or panic.txt/hang.txt vs run_fixed_kernel.log (kernel); the identical rig ran both sides.

poc_changes

Added csprng_force_zero.c (root /dev/kmem rig: info/force/check/restore/peekctx/window/window2 -- force manufactures the exact precondition tuple reseed_cnt=0 ^ key=0 ^ ctx=0 ^ pool0.bytes=0 ^ last_reseed={0,0}), zero_scan.c (unprivileged 3-interface window scanner; fixed missing sys/random.h include flagged by guest cc), chain_model.py (offline model of the degenerate emit chain, validated 4/4 blocks + 4/4 keys vs live ctx dumps), boot_probe.sh + window_experiment{,2}.sh drivers, and 6 new evidence logs. ref_keystream.c gained a zerokey mode (validated against the RFC 7539 zero-key vector 76b8e0ad...). rand_probe.c unchanged. Key rig lesson: usched :N is a CPU MASK (:4=cpu2), not an index -- attempts 1-2 mis-pinned; and a separate-process read loses the manufactured window to a background consumer within ~0.2 s, so the window action forces+reads in-process.

Guest: DragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #0: Thu Jul 2 06:02:54 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64 Attempts: 8 Runtime: ~2400s

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
baseline no→ patch + rebuild →patched clean

Not applicable: no reproduced behavior exists to contrast. The finding is not reproducible from userspace; a patched kernel behaves identically for every observable workload (reseed_cnt>0 before any userspace read in both variants). The fix was validated to the maximum meaningful extent: patch -p1 --dry-run clean (hunk @138), patched TU compiles with the exact kernel build CFLAGS into a distinct object (64df3fc0... vs pristine dac9843d..., zero warnings), tree restored pristine afterwards. fix.diff stands as hardening against future regressions of the pool-pre-feed invariant (e.g. if the RAND_SRC_THREAD2 rgd feed or pool routing ever changes).

fix_apply_compile.log (dry-run + apply + exact-kernel-CFLAGS compiles of pristine and patched TUs + pristine-restore md5); fix.diff
↓ fix.diffper-fix-DF-0220

Confirmed kernel references

Detail

Evidence (decisive lines)

kmem_window_decisive.log (THE decisive run: sigma-at-offset-40 proof; 5/5 identical manufactured windows with blk1=0x00*32; mixed-mode non-zero; self-heal pool0 0->372 -> reseed_cnt 1); kmem_window_chain.log + chain_model.py (per-read live ctx dumps equal the offline model 4/4 blocks + 4/4 keys 523b05d3/4b57cfe4/8b5a7c87/22589bb5); boot{1,2,3}_probe.txt (fresh boots: unprivileged 4-source reads + zero_scan 0/6624 zero blocks + all 6 states keyed at earliest ssh, BSP frozen at reseed_cnt=1 with 184467440735.6xx); leak_sample.txt (cross-boot pairwise 59-60/64 differ, never all-zero); final_verify.txt (full sequence); fix_apply_compile.log (applies @138 + compiles with exact kernel CFLAGS); env.txt; VERDICT.md

PoC changes

Added csprng_force_zero.c (root /dev/kmem rig: info/force/check/restore/peekctx/window/window2 -- force manufactures the exact precondition tuple reseed_cnt=0 ^ key=0 ^ ctx=0 ^ pool0.bytes=0 ^ last_reseed={0,0}), zero_scan.c (unprivileged 3-interface window scanner; fixed missing sys/random.h include flagged by guest cc), chain_model.py (offline model of the degenerate emit chain, validated 4/4 blocks + 4/4 keys vs live ctx dumps), boot_probe.sh + window_experiment{,2}.sh drivers, and 6 new evidence logs. ref_keystream.c gained a zerokey mode (validated against the RFC 7539 zero-key vector 76b8e0ad...). rand_probe.c unchanged. Key rig lesson: usched :N is a CPU MASK (:4=cpu2), not an index -- attempts 1-2 mis-pinned; and a separate-process read loses the manufactured window to a background consumer within ~0.2 s, so the window action forces+reads in-process.

Verified recommended fix

Defense-in-depth only (window already closed by the rgd pool-pre-feed): fix.diff gates CSPRNG_UNLIMITED readers too when reseed_cnt==0, returning 0 bytes instead of emitting the degenerate keystream (mirrors getrandom(GRND_NONBLOCK)); applies cleanly (hunk @138) and compiles warning-free with exact kernel CFLAGS (fix_apply_compile.log).

Verdict

NOT REPRODUCED (3rd verification, 2026-10-12, source unchanged since 2026-06-27). No unprivileged user can observe the pre-reseed zero-key window on this kernel: rand_initialize() (kern_nrandom.c:485-560, SYSINIT SI_BOOT2_POST_SMP :562, before init(8)) pre-feeds EVERY per-CPU state's pool[0] with ~15 KB (sizeof(struct globaldata)=14976 B via RAND_SRC_THREAD2 :539-543), and csprng_get_random() always attempts a reseed BEFORE its emit loop (subr_csprng.c:134-149 precede :151-166) with ratecheck's {0,0} first-call guarantee (kern_time.c:1153-1157) -- so each state's first read keys the cipher before emitting a byte. Live proof: on 3 fresh boots every state shows reseed_cnt>=1/key!=0 at the earliest ssh (uptime 17-33 s); the BSP is frozen at reseed_cnt=1 with last_reseed=184467440735.6xxxxx (the garbage getmicrouptime captured AT its rand_initialize-time reseed, = DF-5766's frozen baseline -- pinning the first reseed pre-userland). Cross-boot outputs differ 59-60/64 bytes on all interfaces/pairs; 0 all-zero blocks in 6624 unprivileged early-boot samples (urandom+getrandom+kern.random); default mixed mode additionally XORs IBAA (kern_nrandom.c:708-713), masking even a manufactured window. NEW this pass: the mechanism itself is now PROVEN live and fully deterministic -- with a root /dev/kmem-manufactured state (reseed_cnt=0 AND key=0 AND ctx=0 AND pool[0].bytes=0 AND last_reseed={0,0}), /dev/urandom in rand_mode=csprng returns literal 0x00*32 and then a keystream chain_model.py reproduces offline exactly (4/4 blocks + 4/4 internally-loaded keys, 5/5 byte-identical trials; per-read live ctx dumps match). The manufactured window is consumed by the first background reader within ~0.2 s and self-heals via reseed within seconds of pool[0] refilling -- it is unreachable and unstable, never userspace-observable. Overlap with DF-5766: DF-5766's frozen-BSP bug is what makes the BSP's reseed_cnt permanently 1 (never 0), reinforcing closure; the entropy quality of the BSP's single forever-key is DF-5766's subject, not DF-0220's zero-key window.