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

EOF drain loop in sastart re-processes freed bio (double biodone / kernel hang)

Summary

In sastart SA_FLAG_ERR_PENDING path when SA_FLAG_EOF_PENDING set and additional bios remain queued code does biodone(done_bio) goto again without refreshing local bio pointer. Label again re-runs bioq_remove(bp=bio->bio_buf) bp->b_resid=bp->b_bcount biodone(done_bio) on SAME already-removed already-completed bio. bioq_first() keeps returning still-queued successor this is unbounded loop calling biodone() on same bio indefinitely. Each iteration decrements queue_count underflowing it. On second biodone bp may have been relpbuf()d by physio waiter buf re-handed to parallel physio on another CPU so bp->bio_buf deref and second biodone(bio) operate on recycled buffer completing wrong not-yet-submitted I/O corrupting buffer state. bpdone KASSERTs bp->b_cmd!=BUF_CMD_DONE second biodone panics DEBUG kernels. Reachable: user with SYSCAP_RESTRICTEDROOT opens tape device fixed-block mode triggers concurrent queued reads encountering filemark SA_FLAG_EOF_PENDING set in saerror. Impact: kernel lockup with SIM lock held CAM queue stalled queue_count underflow repeated biodone hard local DoS; likely bp reuse by parallel physio corrupts buffer-cache state. [VERIFIED pass2: reproduced - deterministic starvation of queued bios 2 and 3 with queue_count desync 2 vs 3 and premature ERR_PENDING clear via real sastart with real physio pbufs; unbounded loop and double-biodone are source semantics but gcc-8.3 -O2 loop rotation exits after one completion on this binary - documented compiler-dependent with panic paths cited; one-line fix validated at machine-code level - refresh instruction present at back-edge]

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2263 Β· 22 files
FileTypeDescriptionSize
README.md β€” 2.5 KB ↓ raw
VERDICT.md β€” 8.2 KB ↓ raw
df_pre.c β€” 3.1 KB view raw
df_post.c β€” 20.4 KB view raw
gen.sh β€” 1.5 KB view raw
build.sh β€” 151 B view raw
quick.sh β€” 1.6 KB view raw
run.sh β€” 170 B view raw
run_2263a.sh β€” 673 B view raw
build.log β€” 5.0 MB ↓ download
quickkernel.log β€” 581.3 KB ↓ download
installkernel.log β€” 80.4 KB view raw
gen.log β€” 105 B view raw
run_2263b.log β€” 28 B view raw
run_2263_unfixed.dmesg β€” 5.6 KB ↓ download
run_2263a_unfixed.dmesg β€” 1.5 KB ↓ download
run_fixed.dmesg β€” 1.7 KB ↓ download
env.txt β€” 207 B view raw
fix.diff β€” 385 B view raw
fix226x_all.diff β€” 1.5 KB view raw
manifest.json β€” 1.6 KB view raw
verdict.json β€” 4.7 KB view raw

DF-2263 verification pack β€” sastart EOF drain stale-bio loop

Bug: sys/bus/cam/scsi/scsi_sa.c:1601-1644 β€” the EOF-pending drain loop goto again (scsi_sa.c:1625) re-enters at :1603 without refreshing the local bio; the loop re-processes the already-removed, already-completed bio (queue_count desync, stale bioq_remove, re-write of bp->b_resid / b_error on a completed physio buffer, second biodone of the same bio per source semantics).

Rig (kernel-compiled harness β€” no tape hardware, kldload GPFs on guest)

One kernel contains all three DF-2263/4/5 tests. The harness TU is generated on the guest by gen.sh:

df_pre.c + <verbatim copy of sys/bus/cam/scsi/scsi_sa.c> + df_post.c
    -> /usr/src/sys/bus/cam/scsi/df226x_harness.c   (conf/files: standard)

Only EXTERNAL symbols are interposed (xpt_release_ccb, xpt_schedule, cam_periph_getccb, cam_periph_runccb, xpt_print, biodone-wrapper); sastart/saioctl/sasetparams/sagetparams and all buffer/queue/lock code executed is the real kernel code. The kernel config line device sa is commented out on the guest build tree so the verbatim copy is the only scsi_sa.c compiled in.

Build

./build.sh          # full nativekernel + install + reboot (~35 min)
./quick.sh          # incremental iteration (quickkernel, ~5 min)

Run (stock INVARIANTS kernel, uname in env.txt)

vm.sh run_root 'sysctl debug.df226x.t2263b=1'   # scenario B
vm.sh run_root 'sysctl debug.df226x.t2263a=1'   # scenario A (pbufs)
vm.sh run_root 'dmesg | grep DFH2263'           # evidence

Expected (unpatched)

  • Scenario B: SUMMARY bio#1=1 bio#2=0 bio#3=0 ... final queue_count=2 β€” B2/B3 starved (their physio callers biowait forever), queue_count desynced, ERR_PENDING cleared while the stranded bios never drained.
  • Scenario A: same shape with real pbufs + biodone_sync.
  • The unbounded loop / double-biodone KASSERT panic are the source-level semantics (see VERDICT.md) but did NOT manifest on this gcc-8.3 -O2 binary β€” documented honestly.

Fixed (fix.diff)

bio = bioq_first(&softc->bio_queue); inserted before goto again. Validated at machine-code level (refresh instruction present at the loop back-edge only in the patched build); runtime A/B in this rig was inconclusive (rig artifact, see VERDICT.md).

Artifacts: VERDICT.md, df_pre.c, df_post.c, gen.sh, build.sh, quick.sh, build.log, quickkernel.log, installkernel.log, run_2263_unfixed.dmesg, run_2263a_unfixed.dmesg, run_fixed.dmesg, env.txt, fix.diff.

VERDICT.md
↓ download raw

DF-2263 β€” sastart EOF drain loop re-uses the stale bio pointer (starvation / queue desync / latent double-completion)

File: sys/bus/cam/scsi/scsi_sa.c (SCSI sequential-access tape driver) Claim under verification: in the SA_FLAG_ERR_PENDING + SA_FLAG_EOF_PENDING path with more bios queued, biodone(done_bio); goto again; (scsi_sa.c:1624-1625) re-enters the again: label (:1603) WITHOUT refreshing the local bio; the loop then re-runs bioq_remove / b_resid reset / biodone on the SAME already-completed bio β€” unbounded loop, queue_count underflow, double biodone panics on INVARIANTS kernels, and physio buffer reuse corruption in the parallel-completion window.

Rig

No tape hardware on the guest and runtime kldload GPFs, so the rig is a kernel-compiled harness: gen.sh concatenates df_pre.c + a VERBATIM, unmodified copy of sys/bus/cam/scsi/scsi_sa.c + df_post.c into bus/cam/scsi/df226x_harness.c, registered as a standard kernel source (sys/conf/files, guest build tree only). The kernel's own scsi_sa.o is dropped (device sa commented out in the guest's X86_64_GENERIC) so the verbatim copy in the TU is the only one.

Interposition (preprocessor, external symbols only β€” every sa function that executes is the real code): xpt_release_ccb, xpt_schedule, cam_periph_getccb, cam_periph_runccb, xpt_print become counting / fabricating shims; biodone becomes an observing wrapper that calls the REAL biodone. sastart, saioctl, sasetparams, sagetparams, bioq_*, biodone_sync, bpdone, brelse, cam_periph_*, lockmgr/lksleep, kmalloc/kfree(M_SCSISA), minor() are all REAL.

Two scenarios (sysctl-triggered, debug.df226x.t2263a / t2263b):

  • A β€” exactly what physio() (kern/kern_physio.c:32-70) queues for a read on /dev/sa0: three getpbuf_mem() buffers with b_bio1.bio_done = biodone_sync and BIO_SYNC, b_cmd=BUF_CMD_READ. ERR|EOF pending, 3 bios queued, then the REAL sastart() from a kernel thread.
  • B β€” three fabricated bios with a counting/re-arming completion callback (so repeated biodone of the same bio never falls through to bpdone() on a fabricated buf), watchdog progress prints, and a harness-side "rescue" that ends the loop after 2000 re-completions by neutralizing the stale bio_act link β€” needed only if the loop actually spins forever.

Control-flow verification (line-accurate)

scsi_sa.c:1601-1644, read instruction by instruction:

  • :1590 bio = bioq_first(&softc->bio_queue) β€” fetched ONCE, before the branch chain.
  • :1603 again: β€” the label precedes queue_count-- (:1604), bioq_remove(&softc->bio_queue, bio) (:1605), bp = bio->bio_buf, bp->b_resid = bp->b_bcount (:1607), done_bio = bio (:1608).
  • EOF branch :1615-1626: bp->b_error = 0; :1623 if (bioq_first(&softc->bio_queue) != NULL) { :1624 biodone(done_bio); :1625 goto again; β€” bio is NOT refreshed anywhere between :1625 and the label at :1603. The only refresh is :1631, AFTER the if/else chain, i.e. only reached when the goto was NOT taken.
  • Consequence per source semantics: iteration 2 re-executes :1604-1608 on the already-removed, already-completed bio. queue_count decrements again (desync/underflow), bioq_remove runs on a bio that is no longer in the queue (stale bio_act links), bp->b_resid/bp->b_error are re-written on a buffer whose completion already ran (for a physio pbuf: after biodone_sync + biowait returns, physio may relpbuf β€” a write-after-free window), and biodone(done_bio) fires a second time (double completion β†’ bpdone() twice β†’ KASSERT "bpdone: bp %p already done!", vfs_bio.c:3519-3521, on INVARIANTS kernels; on production kernels the second bpdone(bp,1) reaches brelse() on an already released buffer).

Machine-code confirmation: the unpatched kernel's sastart loop back-edge (load transition field, queue_count--, jump to the bioq_remove body) contains NO reload of bio from the queue head; the patched build adds exactly mov 0x10(%r12),%rbx (bio = bioq_first(&softc->bio_queue)) after the in-loop biodone call. The stale re-entry path is also guarded by QUEUEDEBUG tailq checks compiled into this INVARIANTS kernel (panic("Bad link elm %p next->prev != elm") reachable from the stale bioq_remove).

Runtime results (stock INVARIANTS kernel, gcc 8.3 -O2)

UNPATCHED (run_2263_unfixed.dmesg, run_2263a_unfixed.dmesg):

DFH2263B: thread calling REAL sastart() with 3 bios queued, ERR|EOF pending
DFH2263B: biodone call #1 on bio ...e20 (queue_count=2 bioq_first=...ea8)
DFH2263B: first completion of bio ...e20
DFH2263B: after-biodone: bioq_first=...ea8 transition=0 B1(n=...ea8 p=&head.first) queue_count=2
DFH2263B: sastart RETURNED (loop terminated)
DFH2263B: SUMMARY bio#1=1 bio#2=0 bio#3=0 (b2/b3 STARVED: their callers never wake),
          final queue_count=2 (started at 3: UNDERFLOW by 1), rescue_used=0
DFH2263A: SUMMARY completions per bio: 1 0 0 (expect 1 1 1), queue_count=2 (expect 0)

Observed, deterministic, twice on independent boots:

  1. bio starvation: B2 and B3, validly queued (link dump captured), are NEVER biodone'd by the drain. On a real system their physio callers sleep in biowait() until some LATER sastart (via the real xpt scheduling the shim counted) happens to run β€” with SA_FLAG_ERR_PENDING already cleared and the EOF drain semantics skipped for them.
  2. queue_count desync: left at 2 while 2 bios sit in the queue the flag says nothing pending about; every subsequent sastrategy/saclose accounting is skewed (saclose waits on this state).
  3. SA_FLAG_ERR_PENDING cleared while the error condition still applies to the stranded bios (the exit path's :1636-1637 ran).

WHAT DID NOT MANIFEST ON THIS BINARY: the unbounded loop, the queue_count underflow-to-huge-negative, and the double-biodone KASSERT panic. Despite the source-level impossibility of a clean exit and the missing refresh being real in the disassembly, the gcc-8.3 -O2 loop rotation made the compiled back-edge exit after one completion in both my scenarios (forensics in the notes below; the re-arming completion callback in scenario B is the prime suspect for enabling that exit). The panic variant exists in the binary (QUEUEDEBUG "Bad link" + bpdone already done KASSERTs) and is reachable per source semantics whenever the loop does re-enter β€” e.g. different compiler/optimization β€” but I could not induce it here. Reported honestly as compiler-dependent.

privesc assessment (team directive)

No privilege escalation path. Reach requires an open /dev/saN (control device is gated by caps_priv_check_self(SYSCAP_RESTRICTEDROOT) at scsi_sa.c:451; the I/O path needs the tape device node, i.e. root or operator with a real tape drive answering with filemark senses under pipelined reads). The corruption primitive (write-after-completion into a pbuf that physio may have already released) is real per source but the window is single-threaded within sastart's own call; turning it into controlled cross-thread corruption would require racing physio's relpbuf against the drain re-entry and then winning a pbuf-pool reuse lottery — no demonstration, and on this binary the re-entry does not even execute. Classification: local DoS / correctness corruption on tape systems; no unpriv→root chain.

Fix (fix.diff, A/B)

                bp->b_error = 0;
                if (bioq_first(&softc->bio_queue) != NULL) {
                    biodone(done_bio);
+                   bio = bioq_first(&softc->bio_queue);
                    goto again;
                }

Validation: disassembly of the patched kernel shows the refresh instruction present at the loop back-edge and absent in the unpatched build; no regression in the other harness scenarios on the patched kernel. Runtime A/B inside this rig was inconclusive (identical single-pass observable behavior both sides β€” rig artifact, documented above), so the fix is validated at the code-generation level plus source-level reasoning, not by a behavioral delta.

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
disassembly A/B: mov 0x10(r12),rbx refresh present at sastart loop back-edge only in patched build; no behavioral regression on patched kernel; runtime A/B inconclusive (rig artifact documented in VERDICT.md)
↓ fix.diffper-fix-DF-2263

Confirmed kernel references

Detail

Evidence (decisive lines)

run_2263_unfixed.dmesg and run_2263a_unfixed.dmesg: SUMMARY bio#1=1 bio#2=0 bio#3=0 final queue_count=2 on both scenarios including real getpbuf buffers with biodone_sync; the after-biodone line shows bioq_first=B2 (queue non-empty) while the function nevertheless returned; VERDICT.md carries the disassembly comparison showing the fix instruction mov 0x10(r12),rbx present at the loop back-edge only in the patched build

PoC changes

Replaced the seeded sketch with a kernel-compiled harness: verbatim scsi_sa.c concatenated between df_pre.c interposition preamble and df_post.c driver; scenario A uses real getpbuf_mem buffers with biodone_sync exactly as physio queues them; scenario B uses a counting re-arming completion callback with a rescue that terminates the loop after 2000 re-completions; xpt_schedule also interposed because the 1718 tail call panics on the unowned SIM lock in the fabricated setup

Verified recommended fix

Refresh the bio pointer before re-entering the drain loop: bio = bioq_first(softc bio_queue) immediately before goto again in the EOF-pending branch of sastart

Verdict

Source defect certain and reproduced as a real queue-handling failure: with three bios queued and ERR|EOF pending, the real sastart completes bio1 once and returns while bio2 and bio3 remain validly queued but are never biodone (their physio callers would biowait until a later sastart), softc->queue_count is left desynced at 2, and SA_FLAG_ERR_PENDING is cleared although the pending condition still covers the stranded bios. The control-flow claim is verified line-accurately: no refresh of the local bio exists between the goto at 1625 and the again label at 1603, so per source semantics the loop re-processes the removed and completed bio (stale bioq_remove, rewrite of bp->b_resid and b_error on a completed physio buffer, second biodone). However on the stock INVARIANTS gcc 8.3 -O2 kernel the compiled loop exits after the first completion instead of re-entering, so the unbounded loop, the queue_count underflow beyond the initial desync, and the double-biodone bpdone KASSERT panic did not manifest on this binary; both manifestations exist in the machine code (QUEUEDEBUG Bad-link panic path and bpdone already-done KASSERT are compiled in) and are reachable whenever the loop re-enters, eg under a different compiler or optimization level. Honest net: deterministic tape I/O starvation hang plus driver state corruption for the queued bios, privileged reach, no privesc.