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)
PoC verification
Evidence pack
findings/poc/DF-5766 Β· 13 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 (+pollmode)run.1.log/run.2.logβ boot #1 / boot #2 decisive runs (verbatim)build.logβ compile outputenv.txtβ guest, symbols, DWARF offsetsfix.diff,fix_build.log,fix_run.logβ fix + validationVERDICT.md,manifest.json,verdict.json
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)
sys/kern/kern_nrandom.c:562βrand_initializeruns atSI_BOOT2_POST_SMP. Per CPU it seeds the pools and then callsread_random(buf, 64, 1)(:558) on the CPU executing the SYSINIT β the BSP (cpu0). This is the firstcsprng_get_random()call in the system forcsprng_pcpu[0](and onncpus == 1systems, for the only state,csprng_bootβkern_nrandom.c:495-496).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 currentgetmicrouptime()intolast_reseed. AtSI_BOOT2_POST_SMPthe uptime machinery is not yet coherent βgetmicrouptime()(sys/kern/kern_clock.c:1372-1393) computesdelta = gd->gd_hardclock.time - gd->gd_cpuclock_basein unsigned 64-bit (sysclock_tis__uint64_t,sys/sys/systimer.h:51); at this boot stage the subtraction underflows anddelta / sys_cputimer->freqexplodes. Observed stored value:tv_sec = 184467440735with a well-formedtv_usec(~646-670 ms) β reproduced on two independent boots (.646387and.670544), i.e. a deterministic early-boot artifact, not stale memory.sys/kern/kern_time.c:1151-1157β every subsequent call computesdelta = sane_uptime β 184467440735β negative βtimevalcmp(&delta, mininterval, >=)is false β returns 0.lasttimeis only updated on success, so the poisoned baseline never self-corrects.csprng_reseed()is never called again on that state β ever.- 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-bytestruct globaldatablob duringrand_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/randomseeding writes) is ever mixed in. An attacker who can estimate the boot key (VM clones / deterministic boots / thin entropy atSI_BOOT2_POST_SMP, the DF-0220 domain) gets lifetime prediction of all of that CPU's output β including root-owned consumers' (sshdsession keys via libcrypto,karc4randstirs, 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 >= 1keeps/dev/randomnon-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=1frozen,last_reseed=184467440735.x, pool0 eligible β reproduced again on the pre-fix boot. - Patched: after the first post-boot read, cpu0
reseed_cnt >= 2andlast_reseedholds 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
fixedSingle-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
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.
No comments yet.