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

BSP's Fortuna state permanently stops reseeding: first-boot ratecheck() stores garbage getmicrouptime() (~1.8e11 s) into last_reseed; all later deltas negative β†’ csprng_reseed() never runs again on that CPU for the whole uptime despite eligible pools

Summary

The first csprng_get_random runs on the BSP before the cputimer is coherent: getmicrouptime's delta underflows and ratecheck stores ~184467440735s into last_reseed - every later delta is negative, returns 0, never updates: the BSP state reseeds exactly once at boot and never again. Verified live across two fresh boots via /dev/kmem state dumps: reseed_cnt frozen at 1 through pinned 8MiB /dev/urandom reads at 142 MB/s while pool[0] sat at 11716-32176 bytes (the gate passed by 335x) and sibling CPUs reseeded normally. On ncpus==1 the machine's ONLY CSPRNG never mixes post-boot entropy, silently - every output is the deterministic forward-rekey chain rooted solely in the boot key (the DF-0220 entropy domain: VM clones predict all past/future urandom/getrandom/karc4 output incl. privileged secrets). Self-heal-the-baseline fix validated on a rebuilt kernel (cpu0 reaches reseed_cnt=37 in 74s).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5766 Β· 13 files
FileTypeDescriptionSize
csprng_dump.c β€” 3.3 KB view raw
build.sh β€” 144 B view raw
run.sh β€” 180 B view raw
run.1.log β€” 3.9 KB view raw
run.2.log β€” 2.0 KB view raw
build.log β€” 9 B view raw
env.txt β€” 836 B view raw
fix.diff β€” 1.2 KB view raw
fix_build.log β€” 2.1 KB view raw
fix_run.log β€” 2.4 KB view raw
VERDICT.md β€” 7.5 KB ↓ raw
README.md β€” 1.6 KB ↓ raw
verdict.json β€” 5.6 KB view raw

DF-5766 β€” Permanent Fortuna reseed suppression on the BSP (poisoned ratecheck baseline)

csprng_get_random() (sys/kern/subr_csprng.c:134-137) rate-limits reseeds with ratecheck(&state->last_reseed, ...). The first csprng_get_random() of the system runs inside rand_initialize() (SI_BOOT2_POST_SMP) on the BSP, before the uptime clock is coherent; ratecheck()'s first-call path stores the garbage getmicrouptime() value (~1.8e11 s) into state->last_reseed. From then on every delta is negative, ratecheck() returns 0 forever, and that CPU's CSPRNG never reseeds again β€” no entropy harvested after boot is ever mixed in, for the whole uptime, while pool[0] sits eligible at 15-32 KB. On ncpus==1 systems this is the only kernel CSPRNG.

Reproduce (guest, root, soon after boot)

./build.sh                     # cc -O2 -o /tmp/rngtest/csprng_dump csprng_dump.c
./run.sh                       # dump; usched :1 dd 8MiB urandom; dump again

Expected (vulnerable kernel): - state[0]: reseed_cnt=1, last_reseed=184467440735.xxxxxx, pool0>=96 β€” unchanged by the pinned read. - other states: reseed_cnt increasing, sane small-uptime last_reseed.

Patched (fix.diff): after the first read cpu0's reseed_cnt grows and last_reseed is sane.

Files

  • csprng_dump.c β€” /dev/kmem state dumper (+ poll mode)
  • run.1.log / run.2.log β€” boot #1 / boot #2 decisive runs (verbatim)
  • build.log β€” compile output
  • env.txt β€” guest, symbols, DWARF offsets
  • fix.diff, fix_build.log, fix_run.log β€” fix + validation
  • VERDICT.md, manifest.json, verdict.json
VERDICT.md
↓ download raw

DF-5766 β€” VERDICT

Finding: csprng_get_random() permanently suppresses reseeding on the BSP's Fortuna state (and on the state of uniprocessor systems) because the first ratecheck() at boot stores a garbage, astronomically large getmicrouptime() value into state->last_reseed; every later delta is negative, so ratecheck() returns 0 forever. All entropy harvested after boot is never mixed into that CPU's CSPRNG key for the entire uptime.

Verdict: REPRODUCED (live, kernel-state introspection via /dev/kmem; three reproductions across two independent boots).


Root cause (path:line)

  1. sys/kern/kern_nrandom.c:562 β€” rand_initialize runs at SI_BOOT2_POST_SMP. Per CPU it seeds the pools and then calls read_random(buf, 64, 1) (:558) on the CPU executing the SYSINIT β€” the BSP (cpu0). This is the first csprng_get_random() call in the system for csprng_pcpu[0] (and on ncpus == 1 systems, for the only state, csprng_boot β€” kern_nrandom.c:495-496).
  2. sys/kern/subr_csprng.c:134-137 β€” c if (!state->callout_based_reseed && ratecheck(&state->last_reseed, &csprng_reseed_interval)) { csprng_reseed(state); } ratecheck() (sys/kern/kern_time.c:1140-1159) has a first-call special case (lasttime == {0,0}) that returns 1 and stores the current getmicrouptime() into last_reseed. At SI_BOOT2_POST_SMP the uptime machinery is not yet coherent β€” getmicrouptime() (sys/kern/kern_clock.c:1372-1393) computes delta = gd->gd_hardclock.time - gd->gd_cpuclock_base in unsigned 64-bit (sysclock_t is __uint64_t, sys/sys/systimer.h:51); at this boot stage the subtraction underflows and delta / sys_cputimer->freq explodes. Observed stored value: tv_sec = 184467440735 with a well-formed tv_usec (~646-670 ms) β€” reproduced on two independent boots (.646387 and .670544), i.e. a deterministic early-boot artifact, not stale memory.
  3. sys/kern/kern_time.c:1151-1157 β€” every subsequent call computes delta = sane_uptime βˆ’ 184467440735 β†’ negative β†’ timevalcmp(&delta, mininterval, >=) is false β†’ returns 0. lasttime is only updated on success, so the poisoned baseline never self-corrects. csprng_reseed() is never called again on that state β€” ever.
  4. The boot reseed itself (reseed_cnt = 1) did happen (the {0,0}-special-case fires regardless of clock sanity, and pool[0] is pre-fed with the 14976-byte struct globaldata blob during rand_initialize β€” see DF-0220's verification). So the state looks "keyed" and never blocks β€” the failure is silent.

Live proof (guest: DragonFly 6.5-DEVELOPMENT #0, 6 vCPU KVM)

csprng_dump.c reads csprng_pcpu[] through /dev/kmem (symbol 0xffffffff81199140; offsets from kernel.debug DWARF: reseed_cnt@32, pool[i].bytes@104+112i, last_reseed@4256, sizeof=6656).

Boot #1 (uptime ~1130 s, run.1.log): - state[0]: reseed_cnt=1, pool0=29944 (311x over the 96-byte MIN_POOL_SIZE reseed gate), last_reseed=184467440735.670544. - states 1-5: reseed_cnt=7..13, sane last_reseed (~1004-1009 s uptime). - 8 MiB of /dev/urandom drained pinned to cpu0 (usched :1 dd, 142 MB/s β€” hundreds of csprng_get_random entries): reseed_cnt still 1, last_reseed unchanged. In the same window cpus 1-5 reseeded repeatedly (their last_reseed advanced to ~1227 s).

Boot #2 (fresh vm.sh reset with-src, uptime 21 s, run.2.log): - state[0]: already poisoned β€” reseed_cnt=1, last_reseed=184467440735.646387, pool0=11716 (eligible). - states 1-5: reseed_cnt=4..8, sane last_reseed=21.3-21.8. - After a pinned 8 MiB cpu0 read (214 MB/s): reseed_cnt=1 still, last_reseed untouched; cpu1 had reseeded twice more (7β†’9, sane 40.2 s).

The pool selection arithmetic itself is fine β€” the dumps show exactly the Fortuna drain pattern (pool i reset to 0 when reseed_cnt % 2^i == 0, deep pools accumulating 20-30 KB between uses) on the healthy CPUs.

Impact

  • On the BSP state, for the whole uptime, the ChaCha key chain evolves only through the per-request forward rekey (subr_csprng.c:158-161) β€” a pure PRF chain rooted in the boot-time key alone. None of the entropy continuously harvested into that state's pools (interrupt timing, TSC, virtio-rng/RDRAND when present, /dev/random seeding writes) is ever mixed in. An attacker who can estimate the boot key (VM clones / deterministic boots / thin entropy at SI_BOOT2_POST_SMP, the DF-0220 domain) gets lifetime prediction of all of that CPU's output β€” including root-owned consumers' (sshd session keys via libcrypto, karc4rand stirs, TCP ISNs, kern_uuid, argv/stack randomization on that CPU).
  • Fortuna's compromise-recovery property (fast pool-0 reseeds every >=100 ms absorb fresh entropy; deep pools recover from state compromise) is structurally dead on the BSP β€” the state most heavily used early in boot.
  • Uniprocessor systems are fully exposed: ncpus == 1 β†’ csprng_pcpu = &csprng_boot (kern_nrandom.c:495-496) β€” the single kernel CSPRNG never reseeds after boot, for the machine's lifetime, while appearing healthy (reseed_cnt >= 1 keeps /dev/random non-blocking).
  • No memory-safety impact; no panic/DoS (output keeps flowing); the damage is silent, permanent, cryptographic.

Impact class: leak (RNG output predictability β†’ disclosure of everything derived from it). Not memory corruption β€” no uid0 chain attempted.

Exploit chain (sketch, why this matters)

read boot-entropy estimate (VM snapshot / deterministic appliance boot) β†’ brute/guess first-reseed key K1 = SHA256(0^256 || digest_0) β†’ replay the per-request forward rekeys (deterministic given K1 and the sequence of request sizes) β†’ predict /dev/urandom|getrandom output on cpu0 for the entire uptime β†’ recover session keys/nonces minted by privileged consumers. Each step is the DF-0220 family's economics; the new defect is that the system can never recover by mixing fresh entropy on that CPU.

Fix

fix.diff (in-file, self-healing): before calling ratecheck(), read getmicrouptime(); if the stored baseline is in the future (time warp β€” only possible from the boot garbage, uptime is otherwise monotonic), zero it so ratecheck()'s {0,0} path fires, the reseed attempt proceeds, and a sane baseline is stored. Normal operation is byte-identical to before.

Fix validation (Phase 8)

Built the single-fix kernel in the guest (patch to /usr/src, incremental make buildkernel KERNCONF=X86_64_GENERIC, install, reboot):

  • Baseline (unpatched): cpu0 reseed_cnt=1 frozen, last_reseed=184467440735.x, pool0 eligible β€” reproduced again on the pre-fix boot.
  • Patched: after the first post-boot read, cpu0 reseed_cnt >= 2 and last_reseed holds a sane small-uptime value; reseeds continue at

    =100 ms spacing while pool[0] is eligible; cpus 1-5 unchanged. Fixed. Full transcripts: fix_build.log, fix_run.log.

PoC changes vs seed

No seed existed (new pass-2 finding). csprng_dump.c written for this pack; offsets extracted from the guest's kernel.debug via gdb, verified against 5 healthy states reading sane values at the same offsets.

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

fixed
baseline reproduced→ patch + rebuild →patched clean

Single-fix kernel (fix.diff applied to guest /usr/src, nativekernel build, -Werror clean) boots and self-heals: cpu0 reseed_cnt reached 37 within 74s of boot and 39 after pinned reads, last_reseed=73.831166/76.851196 (sane uptime), pools cycling normally; cpus 1-5 unchanged. The frozen-state bad behavior is gone. Baseline re-reproduced on the pre-fix boot of the same session (run.2.log).

findings/poc/DF-5766/fix_run.log (before/after dumps on patched kernel), fix_build.log (build+install transcript), fix.diff
↓ fix.diffper-fix-DF-5766

Confirmed kernel references

Detail

Exploit chain

estimate/guess the boot-time first-reseed key K1 = SHA256(0^256 || pool digests at SI_BOOT2_POST_SMP) (thin entropy: timing deltas + largely deterministic rgd blob on no-KASLR/VM systems) -> the per-request forward rekeys (subr_csprng.c:158-161) are a deterministic PRF chain from K1 -> replay them given the request-size sequence -> predict all future and derive all past cpu0 /dev/urandom, getrandom(2), kern.random, karc4rand output for the entire uptime -> recover secrets minted by privileged consumers on that CPU (session keys, nonces, ISNs, uuids). The new defect is the never-recovery: harvested entropy can never displace a weak K1 on the BSP (on UP systems, on the whole machine).

Evidence (decisive lines)

run.2.log (fresh boot, uptime 21s: state[0] reseed_cnt=1 last_reseed=184467440735.646387 pool0=11716 eligible; after pinned 8MiB read: unchanged; states 1-5 reseeding at sane 21.3-21.8s); run.1.log (boot #1: pool0=32176 eligible, frozen through pinned 8MiB @142MB/s; healthy cpus at ~1227s); fix_run.log (patched kernel: state[0] reseed_cnt=37@74s, 39 after reads, last_reseed=73.8/76.9 sane); csprng_dump.c (/dev/kmem dumper, DWARF offsets); env.txt (symbols, offsets); fix.diff + fix_build.log (validated fix)

PoC changes

No seed existed (new pass-2 finding). Authored csprng_dump.c (/dev/kmem reader; static symbols cannot be linked from a KLD); struct offsets extracted from the guest's kernel.debug via gdb and cross-validated by 5 healthy states reading sane timestamps at the same offsets.

Verified recommended fix

In csprng_get_random, before ratecheck(), bzero last_reseed when it lies in the future of getmicrouptime() so the poisoned boot baseline self-heals (see fix.diff); optionally also clamp getmicrouptime consumers against early-boot garbage.

Verdict

Live-reproduced on two independent boots: the BSP's (cpu0's) Fortuna state permanently stops reseeding after the single boot-time reseed. The first csprng_get_random() of the system runs inside rand_initialize (SI_BOOT2_POST_SMP, kern_nrandom.c:558) on the BSP; ratecheck()'s {0,0} first-call path (kern_time.c:1154) stores a garbage, astronomically large getmicrouptime() value (observed tv_sec=184467440735, a deterministic early-boot artifact of the unsigned sysclock_t delta underflow in getmicrouptime, kern_clock.c:1379) into state->last_reseed. Every later ratecheck computes a negative delta and returns 0 forever, so csprng_reseed() is never called again on that state: no entropy harvested after boot is ever mixed into that CPU's CSPRNG key for the whole uptime, while pool[0] sits eligible at 15-32KB (observed frozen at reseed_cnt=1 through 8MiB pinned /dev/urandom reads at 134-214 MB/s). On ncpus==1 systems csprng_pcpu = &csprng_boot (kern_nrandom.c:495-496): the single kernel CSPRNG never reseeds after boot. Root-cause line: subr_csprng.c:134-137 trusts ratecheck/getmicrouptime sanity with no backwards-time defense. Impact: silent permanent entropy starvation of the kernel RNG on the most-used CPU; anyone who estimates the boot-time key (DF-0220 domain: VM clones, deterministic boots) predicts that CPU's entire /dev/urandom, getrandom(2), kern.random and karc4rand output lifetime. Fix validated: single-line-guard kernel (fix.diff) self-heals the baseline on the first sane call; patched kernel shows cpu0 reseed_cnt=37 within 74s of boot and sane last_reseed, behavior gone.