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

Shared MINSTD state broadcast: observer-visible consumers (ipfw O_PROB, dummynet plr, syscons savers) let an unprivileged local user or on-path observer recover the kernel's global PRNG state and predict every other consumer's values (UFS i_gen/NFS file handles, NAT alias ports, CHAP challenges, IPv6 frag-ID base)

Summary

randseed is one global state shared by every kernel consumer with zero domain separation - DF-2251 established MINSTD is reversible given one output but left open HOW an unprivileged party obtains one; this pass proves the missing half: consumers that PUBLISH draws to untrusted observers (ipfw prob decisions wire-observable, dummynet drop patterns, syscons savers rendering hundreds of raw draws/sec to the shared console). 60 loopback echo outcomes uniquely determined the state via exhaustive 2^31 scan in ~4s - four independent recoveries, each bit-exact against /dev/mem control; from the recovered state the on-disk UFS i_gen of a subsequently created file was predicted EXACTLY (0x359714fe, draw<->gen mapping verified on 3 pairs). Same state yields NAT alias ports, CHAP challenges, frag-ID base - cross-domain compromise from one observable consumer. migrate-to-karc4random fix for the publishing consumers.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5707 Β· 35 files
FileTypeDescriptionSize
README.md β€” 3.3 KB ↓ raw
VERDICT.md β€” 7.5 KB ↓ raw
attack.c β€” 9.1 KB view raw
probe.c β€” 4.0 KB view raw
recover2.c β€” 2.9 KB view raw
minstd.c β€” 5.0 KB view raw
devmem.c β€” 1.4 KB view raw
mkstate.c β€” 673 B view raw
watch.c β€” 1.0 KB view raw
probe2.c β€” 2.8 KB view raw
strike2.c β€” 1.9 KB view raw
strike3.c β€” 1.1 KB view raw
zerogen.c β€” 2.3 KB view raw
inc/ufs/ β€” 4.0 KB ↓ download
build.sh β€” 65 B view raw
run.sh β€” 89 B view raw
fix.diff β€” 3.3 KB view raw
logs/recover1.log β€” 249 B view raw
logs/attack2.log β€” 569 B view raw
logs/attack3.log β€” 1015 B view raw
logs/finalgen.log β€” 133 B view raw
logs/strike3.log β€” 498 B view raw
logs/rawgen.log β€” 282 B view raw
logs/calib7.log β€” 108 B view raw
logs/calib8.log β€” 129 B view raw
logs/calib9.log β€” 166 B view raw
logs/calib10.log β€” 85 B view raw
logs/landmark4.log β€” 111 B view raw
logs/verify1.log β€” 79 B view raw
logs/rate.log β€” 29 B view raw
logs/backseed4.log β€” 0 B ↓ download
logs/backseed6.log β€” 23 B view raw
logs/disass.log β€” 1.6 KB view raw
logs/env.log β€” 1.5 KB view raw
logs/code-hashes.txt β€” 528 B view raw

DF-5707 β€” Global MINSTD state broadcast: unprivileged-oracle recovery of randseed

File audited (pass 2): sys/libkern/random.c Class: state-recovery oracle / cross-domain PRNG coupling (CWE-200/CWE-340) Severity: Medium Β· Confidence: certain (mechanism, triple-verified on guest)

What this pack proves (all on the QEMU guest, DragonFly 6.5-DEVELOPMENT x86_64)

  1. Calibrated consumer model (calib7-10.log): under ipfw rule prob 0.5 deny icmp from any to any icmptypes 8 via lo0 (O_PROB at sys/net/ipfw/ip_fw2.c:3952-3955, threshold encoding at sbin/ipfw/ipfw2.c:3396-3402), every echo request consumes exactly: - 1 draw (<T) β†’ sendto: Permission denied ("D") - 2 draws (β‰₯T,<T) β†’ silent loss ("T") - 4 draws (β‰₯T,β‰₯T) β†’ reply ("R")

  2. Full state recovery (recover1.log, attack2.log, attack3.log, landmark4.log): a 60-packet burst (unprivileged-observable decisions) plus a 6-thread exhaustive scan of the 2^31-1 MINSTD space recovers the kernel's global randseed in ~4 s. Four independent recoveries, each bit-exact against the /dev/mem control read (MATCH start == RS-before-burst in attack2/attack3, landmark4; end→RS1 dist=1 in verify1.log).

  3. Cross-subsystem prediction (attack3.log + finalgen.log + strike3.log + rawgen.log): from the recovered state alone, the on-disk UFS inode generation number of a file created after the burst is predicted exactly (PREDICTED-IGEN … k=19:0x359714fe == on-disk di_gen = 0x359714fe for ino 10). i_gen is the NFS file-handle unguessability component (ffs_alloc.c:641-642, ffs_vfsops.c:1174-1175). Draw↔gen mapping verified bit-exact on three pairs: zero-gen path stores draw/2+2 (vget assigns /2+1, valloc's ++i_gen adds 1), wrap path stores draw/2+1.

  4. Ambient characterization (rate.log, ambient*.log, bisect.log): idle draw rate 1.23/s = 6 per-CPU loadav callouts (kern_synch.c:1425-1432); DF-2250 races observed live twice (documented in VERDICT.md).

Build

./build.sh            # cc on the guest (gcc 8.3, base system)

Run (summary; full choreography in run.sh / VERDICT.md)

# as root (oracle setup only):
kldload ipfw
ipfw -q add 65000 allow ip from any to any
ipfw -q add 50 skipto 65000 ip from any to any not via lo0
ipfw -q add 100 prob 0.5 deny icmp from any to any icmptypes 8 via lo0
./attack <bootsec> 8      # burst -> recover -> strike -> predict

Expected decisive output (attack3.log)

RECOVERED start=998920631 end=781784740      (== RS-before-burst)
STRIKE file=/mnt/final ino=10 ...
CONTROL ra-to-rb dist=1 (1 expected: the i_gen draw)
β†’ on-disk di_gen(ino10) = 0x359714fe == attacker-table k=19 prediction

Contents

  • attack.c flagship PoC (observeβ†’recoverβ†’strikeβ†’predict), fork-free
  • probe.c burst generator + outcome classifier
  • recover2.c 2^31 exhaustive state recovery (pthreads)
  • minstd.c step/ainv/dist/igen/backseed2 utilities
  • devmem.c /dev/mem read/write control tool (root-side only)
  • strike2/3.c, zerogen.c, mkstate.c, watch.c, probe2.c supporting
  • logs/ full untrimmed guest logs for every phase
  • fix.diff consumer-migration fix (never applied to sys/)
VERDICT.md
↓ download raw

DF-5707 VERDICT β€” reproduced (impact: leak)

One-paragraph bottom line. The single global MINSTD state (sys/libkern/random.c:36 randseed) is shared by every consumer in the kernel, and several of those consumers publish raw or near-raw draws to parties with no privilege: ipfw prob rules and dummynet plr pipes export per-packet decision bits to anyone who can observe the traffic, and the syscons graphical screensavers (rain_saver.c:87, fire_saver.c:89, warp_saver.c:121, star_saver.c:86,90,93-95, dragon_saver.c:126-131, snake_saver.c:103,107) render hundreds of consecutive draws per second on the shared console. Because MINSTD's output is its state (DF-2251's reversibility), one burst of ~60 observable decisions uniquely determines the global state β€” demonstrated four times on the guest, each recovery bit-exact against the /dev/mem control (MATCH start == RS-before-burst in logs/attack2.log, logs/attack3.log, logs/landmark4.log). From the recovered state, values consumed by other security domains are predicted exactly: the on-disk UFS di_gen of a subsequently created file (NFS file-handle component) matched the attacker-side prediction table at k=19 to the bit (0x359714fe, logs/attack3.log + logs/finalgen.log).

Reproduction narrative (what exactly ran)

Guest: DragonFly 6.5-DEVELOPMENT (clean-install snapshot kernel, v6.5.0.1712.g89e6a-DEVELOPMENT #1, Jun 29 2026 build), x86_64, 6 vCPU, QEMU/KVM. randseed located via nm /boot/kernel/kernel at 0xffffffff81120c80; read/written through /dev/mem at phys va-KERNBASE (KERNBASE = 0xFFFFFFFF80000000). All root-side steps (firewall setup, /dev/mem reads, vnconfig/newfs UFS test image, zerogen) are demonstration controls; the attacker-side steps (packet outcomes β†’ recovery β†’ prediction) use no privilege beyond sending pings/observing loss, which any local user can do (and any on-path party can do for forwarded traffic).

Phase 1 β€” model calibration (logs/calib7..calib10.log)

mkstate K T fabricates a state whose k-th next draw crosses threshold T = 0x3FFFFFFF (prob 0.5 β†’ d[0] = (int32_t)(0.5*0x7fffffff), sbin/ipfw/ipfw2.c:3402). Forced-state single pings established the exact consumption model: D=(1 draw), T=(2 draws), R=(4 draws) β€” and that the icmptypes-8 restriction makes replies undenyable, so outcomes are a deterministic function of consecutive draws.

Phase 2 β€” recovery (logs/recover1.log, attack2.log, attack3.log, landmark4.log)

60-packet bursts at 2 ms spacing; recover2 scans all states 1..2^31-2 with 6 pthreads (~4 s). Results: - burst DTTTRRDTDDD...: 1 match: start=306818006, end=258553457; control: dist(end→RS1)=1 (verify1.log). - attack2: 1 match start=1150107239 == RS-before-burst exactly. - attack3: 1 match start=998920631 == RS-before-burst exactly; STRIKE immediately after (see phase 4). - landmark4 (fresh boot): 1 match start=1093760145 == RS0=0x41317491. Two other bursts produced 0 matches (ambient interleave during the burst) and were retried — the retry logic is part of attack.c. 6 bursts total, 4 exact recoveries.

Phase 3 β€” draw↔i_gen mapping (logs/zerogen4.log, strike3.log, rawgen.log)

newfs pre-templates free-inode gens from unseeded userland random() (sbin/newfs/mkfs.c:836,1140), so fresh-FS creations take the ++i_gen no-draw path (12 creations consumed 0 draws β€” logs/strike2run.log). Zeroing di_gen of chosen free inodes on the unmounted image (zerogen.c, simulating legacy zero-gen filesystems / the natural wrap case) re-activates the krandom path: ffs_vget assigns krandom()/2+1 (ffs_vfsops.c:1174-1175) and valloc's ++i_gen (ffs_alloc.c:641) adds one β†’ on-disk di_gen = draw/2+2, verified bit-exact on three (draw, gen) pairs: draw=0x3d6ab456 β†’ ino8 di_gen=0x1eb55a2d βœ“ (0x3d6ab456/2+2) draw=0x2865999a β†’ ino9 di_gen=0x1432cccf βœ“ draw=0x507887dd β†’ ino6 di_gen=0x283c43f0 βœ“ (genmap.log) (fsdb's GEN= output is served from a stale buffer on mounted filesystems and cannot be trusted β€” logs/fsdb3.log vs raw reads; use zerogen/raw reads.)

Phase 4 β€” end-to-end strike (logs/attack3.log + finalgen.log)

After recovery (end=781784740), /mnt/final was created (zero-gen ino 10): exactly 1 draw consumed (CONTROL ra-to-rb dist=1), on-disk di_gen(ino10) = 0x359714fe. The attacker-view prediction table (PREDICTED-IGEN from recovered end) contains k=19: 0x359714fe β€” dist(endβ†’ra)=18 ambient draws (per-CPU loadav at 1.23/s, rate.log) separated the recovery from the strike; the k=19 entry is the realized value, to the bit. In a live attack the adversary re-syncs with a second mini-burst immediately before the victim event, collapsing k to ≀2.

What the attacker gets (impact)

  • i_gen of every subsequently created inode on zero-gen/legacy or gen-wrapping UFS/ext2 filesystems β†’ forgeable NFS file handles (fh = fsid + ino + gen; ffs_fhtovp validates exactly these fields).
  • NAT alias ports (ip_fw3_nat.c:439), CHAP challenge words (if_spppsubr.c:4265-4268), IPv6 fragment-ID base (frag6.c:107), nfs_xid base (nfsm_subs.c:159) β€” all future outputs of the shared state, predictable from any single recovery.
  • Oracles that require no admin firewall config: any loaded graphical screensaver broadcasts draws on the console (fire_saver draws a full bottom line of krandom()%160+96 every frame).

Honest limits / negatives

  • The boot-seed recovery (walk back to tv_sec^tv_nsec, init_main.c:495-497, disassembly-confirmed at proc0_post+0x29 in logs/disass.log) did not close on the guest: across 4 attempts on 3 boots with two-landmark verification (backseed2, logs/backseed*.log) no sec^nsec-form ancestor was found within plausible draw counts. Suspected causes: (a) DF-2250 lost-update races scrambling branch lineage (races were observed live twice this session β€” see below), and/or (b) nanotime at proc0_post not reflecting kern.boottime wall time. The ≀30-bit arithmetic bound of the seed remains code-certain; the live seed walk is unproven.
  • DF-2250's race was observed live: burst RS0 was disconnected from the recovered start in one run (logs/attack1 session; also CONTROL end-to-rsafter dist=-1 events) β€” a concurrent consumer's read-modify-write interleaved with the probe. Evidence only; the race itself is DF-2250 (not re-filed).
  • Two guest panics during the session were unrelated to random.c: vm_object_hold_shared: assertion "obj != NULL" at sys/vm/vm_object.c:330 from vm_fault/std_copyout under process churn (serial logs captured; flagged to the orchestrator for a sys/vm pass-2 audit β€” the file is in the Dillon priority list).
  • ipfw-prob/dummynet-plr oracles require admin-configured rules; the screensaver oracle requires a graphical saver module + console visibility (common on desktops, absent on this headless guest β€” verified by code for the savers, live-proven for the ipfw variant).

Fix

See fix.diff: migrate the publishing consumers (O_PROB, dummynet plr, syscons savers) to karc4random(), which is per-CPU-state CSPRNG not readable from these oracles, and document krandom() as non-security-grade. The architectural cure (no single shared PRNG state across security domains) is the same direction as DF-2251's fix.

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

Architectural consumer-migration fix; not a memory-corruption class so no kernel-rebuild validation was mandated. Baseline (oracle recovery) reproduced 4x pre-fix; patched behavior not exercised.

N/A - no rebuild performed (see VERDICT.md 'Fix').
↓ fix.diffper-fix-DF-5707

Confirmed kernel references

Detail

Exploit chain

1) root/admin once loads ipfw with any prob rule (or dummynet plr, or a graphical saver is loaded); 2) unprivileged local user sends 60 pings to 127.0.0.1 and records EACCES/timeout/reply outcomes (~1s); 3) offline exhaustive MINSTD scan (4s) yields the global randseed; 4) re-sync with a mini-burst immediately before the target event; 5) predicted future outputs: next zero-gen/wrap inode's NFS-visible i_gen (demonstrated bit-exact), NAT alias ports, CHAP challenges, IPv6 frag-ID base; 6) NFS file-handle forgery on exported filesystems (fh = fsid+ino+gen, ffs_fhtovp check).

Evidence (decisive lines)

logs/attack3.log: RECOVERED start=998920631 == RS-before-burst, STRIKE ra-to-rb dist=1, PREDICTED-IGEN tables; logs/finalgen.log: on-disk di_gen(ino10) = 0x359714fe; logs/attack2.log + logs/landmark4.log: two more exact recoveries; logs/verify1.log: end->RS1 dist=1; logs/strike3.log+rawgen.log: draw<->gen /2+2 mapping; logs/calib7-10: D=1/T=2/R=4 model; VERDICT.md for the full narrative.

PoC changes

Seed sketch had no syscall-level model; built from scratch: calibrated the O_PROB draw-consumption model on the live kernel (D=1/T=2/R=4 draws with icmptypes-8-restricted rule), wrote the exhaustive 2^31 pthread scanner, the fork-free attack driver, and raw-dinode zerogen tooling (fsdb GEN output is stale on mounted filesystems and cannot be trusted).

Verified recommended fix

Migrate observer-visible consumers (O_PROB, dummynet plr, syscons savers) to karc4random(); document krandom() as non-security-grade (fix.diff).

Verdict

Unprivileged-observable ipfw prob decisions (60 loopback echo requests, outcomes D/T/R) uniquely determine the kernel's shared MINSTD state: a 6-thread exhaustive scan of the 2^31-1 state space recovers it in ~4s. Four independent recoveries on the guest were bit-exact against /dev/mem controls (MATCH start == RS-before-burst). From the recovered state alone, the on-disk UFS inode generation number of a file created after the burst was predicted exactly (attacker table k=19 entry 0x359714fe == di_gen(ino10)); draw<->gen mapping verified bit-exact on three pairs (zero-gen path stores draw/2+2: ffs_vfsops.c:1175 assigns krandom()/2+1 then ffs_alloc.c:641 ++i_gen). Same recovered state predicts NAT alias ports, CHAP challenge words, the IPv6 frag-ID base and nfs_xid base. Console screensavers are a second, admin-free oracle (fire_saver renders krandom()%160+96 per cell per frame). Boot-seed walk-back (tv_sec^tv_nsec, init_main.c:497) did not close on this guest (suspected DF-2250 lineage races); the <=30-bit arithmetic bound remains code-certain.