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

wb_rxeof double-fetch of the device-written RX status word: check/use TOCTOU enabling a ~12KB OOB read copied into received packets (kernel heap info leak)

Summary

wb_rxeof snapshots rxstat but then re-reads the device-DMA'd wb_status for both length checks and the length use. A device rewrite in the window makes total_len up to 16383 (14-bit extract), and m_devget then bcopy()s up to 16385 bytes from a 4096-byte static buffer - an ~12KB OOB read across neighbouring RX buffers and descriptor arrays inside the contigmalloc block, delivering kernel heap (other slots' frames, descriptor words containing physical addresses) into an mbuf handed to the stack/BPF. On this port the window is live even with an honest chip because DF-4266's inverted loop validates chip-owned descriptors the engine is concurrently writing. Use-the-snapshot fix. [VERIFIED DOWNGRADE: machine-code analysis shows gcc8.3 merged the check and use into one load in the shipped binary - the claimed TOCTOU cannot occur in this build; fix retained as hardening]

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-4268 Β· 18 files
FileTypeDescriptionSize
dfwb3_rig.c β€” 40.9 KB view raw
df8_toggle.c β€” 1.4 KB view raw
Makefile β€” 105 B ↓ download
kld_run.sh β€” 2.8 KB view raw
wb_rxeof.stock.disasm β€” 8.8 KB ↓ download
wb_rxeof_stock_disasm_marker.log β€” 904 B view raw
run.race_production.log β€” 97 B view raw
run.race_production.dmesg.log β€” 3.8 KB view raw
run.race_production_v1_miscomputed_huge.log β€” 366 B view raw
run.race_ctl_userland.log β€” 828 B view raw
run.race_ctl_userland.dmesg.log β€” 2.9 KB view raw
run.race_ctl_kernel.log β€” 791 B view raw
run.race_production_fixed.log β€” 907 B view raw
panic_rig_v1_togglethread.txt β€” 1.1 KB view raw
fix_validated.diff β€” 2.2 KB view raw
env.kld.txt β€” 491 B view raw
VERDICT.md β€” 5.5 KB ↓ raw
verdict.json β€” 5.7 KB view raw
VERDICT.md
↓ download raw

VERDICT β€” DF-4268 (wb_rxeof double-fetch of the DMA'd RX status word)

Status: not_reproduced β€” false positive (on the audited artifact β€” the shipped X86_64_GENERIC kernel, gcc 8.3/CCVER=gcc80) β€” impact none; confidence certain (static proof, dynamically corroborated). The source-level pattern is real and the fix is recommended hardening; it is validated to remove even the residual flags/length double-fetch from the machine code.

Why the claimed OOB read is unreachable in this binary

The finding's chain: rxstat snapshot at :973; length checks re-read the device word at :983 (< 60) and :984 (> 1536); the length USE re-reads it again at :1004; a device rewrite between :984 and :1004 yields total_len up to 16383, and m_devget (uipc_mbuf.c:2235, chained m_getl+bcopy, no cap) then copies up to 16381 bytes from the 4096-byte wb_rxbufs[0] β†’ ~12 KB OOB read into a received packet.

The shipped binary does not contain that chain. wb_rxeof @ 0xffffffff808dae30 loads the device word exactly twice per iteration:

+0x1c  mov (%rax),%eax        ; :973 snapshot β€” feeds OWN/MIIERR/LASTFRAG/
                               ; RXCMP/RXERR flag tests (register reuse)
+0x44  mov (%rdx),%esi        ; ONE re-read β€” feeds the MERGED range check
+0x4d  shr $0x10,%esi         ;   (len-60) > 1476 unsigned  == :983 AND :984
+0x53  sub $0x3c,%edx / cmp $0x5c4,%edx / ja error
+0x86  and $0x3fff,%esi       ; the SAME +0x44 fetch feeds the USE
+0x8d  sub $0x2,%esi          ; total_len-4+2 folded
+0x98  callq m_devget

The value that reaches m_devget is the same fetch that was range-checked; a device rewrite between "check" and "use" has no window β€” there is no second length fetch. (GCC legally merged :983/:984/:1004 because the -fno-strict-aliasing kernel build still lets it keep one load across the intervening flag arithmetic.) wb_intr calls out to wb_rxeof β€” there is no inlined second copy (checked). The only surviving double-fetch is flags-vs-length (snapshot :973 vs the single length load), which cannot produce an out-of-range length: the length is self-consistent.

Dynamic corroboration (production handler raced)

Rig: DF-4266 KLD dispatch method; "chip" simulated by rewriting rx_list[0].wb_status between GOOD = 0xC0400300 (OWN|RXCMP|LAST|FIRST, len 64) and HUGE = 0xFFFF0300 (same flags, len 16383) β€” both OWN=1, i.e. exactly the mid-write-back transient the stock inverted loop (DF-4266) wrongly accepts; GOOD/HUGE 4:1 duty; toggler pinned to CPU 3 (usched bsd4:8), dispatch pinned to CPU 0 (usched bsd4:1).

  • Stock kernel, 250 dispatches (~17 s), 221 frames delivered: maxplen=60, WON=0 (run.race_production.dmesg.log). The handler processed frames the whole time under an actively adversarial length field; no delivered length ever exceeded 1536.
  • Earlier run with the toggle value mis-set to RXLEN 1023 (still legal): 276 deliveries, maxplen 1019 β€” every length it delivered was one that passed the merged check at its own fetch (run.race_production_v1_...log).
  • Control: an EXACT-SEMANTICS transcription of the source (four distinct volatile fetches at :973/:983/:984/:1004) raced identically also produced ZERO wins across ~130 000 dispatches (userland Β΅s chip Γ—1, Γ—3 streams, and a kernel ns-rate toggle thread β€” run.race_ctl_*.log): a software writer cannot land a store inside the reader's ~tens-of-ns fetch window (the reader acquires the cache line once and completes its loads locally). Only an in-flight DMA write could split the fetches of code that actually fetches twice β€” and the shipped binary's length path fetches once. Note the control could not be made to win even theoretically-honestly, which is why it is supporting, not primary, evidence; the disassembly is the proof.

Fix assessment

fix.diff (use rxstat for the checks and the use) is the right hardening: on the rebuilt kernel (fix_validated.diff, kernel #1) the disassembly shows exactly ONE mov (%rax),%eax per iteration (the snapshot) β€” the :983/ :984/:1004 re-fetch no longer exists in machine code β€” and the race again delivers 188 frames, maxplen 60, WON 0 (run.race_production_fixed.log). Because no divergent behaviour exists on this build either before or after the patch, there was no behavioural delta to A/B: fix_status is not_testable in the strict sense (nothing broken to observe being fixed); the machine-code delta (2 loads β†’ 1 load) is recorded instead.

Rig incidents (documented, not the finding)

  1. v1 toggle thread exited via lwkt_exit() β†’ run-queue corruption β†’ panic: assertion "(ntd->td_flags & TDF_RUNNING) == 0" failed in lwkt_switch AFTER the race had completed and printed its result (panic_rig_v1_togglethread.txt). DFly lwkt_create()d threads are not meant to exit; replaced by a userland sysctl-driven chip.
  2. A later never-exiting (parked) kernel toggle thread triggered the same assertion class from softclock after ~10 s of remote spinning β€” race data again captured first (run.race_ctl_kernel.log tail).
  3. An earlier toggle constant was mis-computed (RXLEN 1023 instead of 16383) β€” the run is kept as the mid-range-length datapoint.

None of these involve the code under test.

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_testable behaviourally: the OOB was never reachable on this build (baseline already clean), so no pre/post behavioural delta exists; the patch's effect is nevertheless verified at the machine-code level β€” the shipped 2-loads-per-iteration pattern drops to 1 load on the rebuilt kernel, eliminating the double-fetch entirely β€” and the fixed-kernel race stays at 0 wins / 188 deliveries / maxplen 60

run.race_production_fixed.log (objdump section: single 'mov (%rax),%eax' per iteration, no second (%rdx) reload; race summary 'delivered=188 maxplen=60 WON=0'); fix_validated.diff; VERDICT.md 'Fix assessment'
↓ fix.diffper-fix-DF-4268

Confirmed kernel references

Detail

Evidence (decisive lines)

wb_rxeof.stock.disasm (per-iteration loads at +0x1c and +0x44; range check and m_devget length both derive from the +0x44 fetch); run.race_production.dmesg.log ('DF-4268 PRODUCTION race over after 250 dispatches (~17 s): delivered=221 maxplen=60 WON=0'); run.race_production_v1_miscomputed_huge.log (276 deliveries, maxplen 1019 <= 1536 with mid-range toggle values); run.race_ctl_userland.log/.dmesg + run.race_ctl_kernel.log (control transcription, 0 wins in ~130k dispatches); run.race_production_fixed.log (fixed kernel: single-load pattern + 'delivered=188 maxplen=60 WON=0'); VERDICT.md

PoC changes

Replaced the seed's 'no runnable PoC' with: (a) scripted objdump of the production wb_rxeof fetch pattern (kld_run.sh disasm); (b) a two-CPU race harness β€” userland 'chip' writing rx_list[0].wb_status via a rig sysctl (kern.dfwb3.tog) with GOOD/HUGE 4:1 duty, usched-pinned away from the dispatching sysctl, racing the REAL production handler (go4268); (c) an exact-semantics volatile transcription control (go4268ctl) raced with userland x1/x3 and kernel ns-rate togglers. Seed's mis-set HUGE constant (RXLEN 1023 vs 16383) corrected; two rig incidents (toggle-thread lwkt_exit panic; spinning remote kernel thread panic) documented β€” both after race data capture, neither involving the code under test.

Verified recommended fix

Use the :973 rxstat snapshot for the length checks and the length use (WB_RXBYTES(rxstat)) instead of re-reading cur_rx->wb_ptr->wb_status three times β€” validated in fix_validated.diff hunk 2/3 (machine code: one status load per iteration)

Verdict

Not reproduced β€” and traced to a dead end in the audited artifact: the shipped X86_64_GENERIC kernel's wb_rxeof (gcc 8.3) merged the length checks (:983/:984) and the length use (:1004) into ONE load of the device-written status word (objdump: mov (%rdx),%esi @ +0x44 feeds both the merged range check 'sub $0x3c; cmp $0x5c4; ja' and, via and $0x3fff,%esi, m_devget's length) β€” the value copied into the received packet is the same fetch that was range-checked, so the claimed check/use divergence to 16383 and the ~12KB OOB read from the 4096-byte wb_rxbufs[0] cannot occur in this binary; effectively a false positive on the compiled artifact this audit targets. Only a flags-vs-length split (snapshot :973 vs the single length load) survives, which is not memory-unsafe. Dynamically corroborated: the PRODUCTION handler raced against an actively adversarial 'chip' (GOOD len64 <-> HUGE len16383, both OWN=1, 4:1 duty, separate CPUs) for 250 dispatches/~17s delivered 221 frames with max length 60 and ZERO oversized packets; an exact-semantics volatile transcription (four distinct fetches) also never diverged across ~130k dispatches (software writers cannot land stores inside the reader's ~30ns fetch window; only in-flight DMA could, and the binary's length path fetches once). The source-level four-fetch pattern is still a genuine double-fetch of device memory and the fix is validated as hardening: on the rebuilt kernel the machine code drops to ONE status load per iteration and the race stays at 0 wins. Recommend keeping the fix (defense against future compiler changes), downgrading/removing the info-leak impact for this build.