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)
PoC verification
Evidence pack
findings/poc/DF-2263 Β· 22 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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.
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: threegetpbuf_mem()buffers withb_bio1.bio_done = biodone_syncandBIO_SYNC,b_cmd=BUF_CMD_READ. ERR|EOF pending, 3 bios queued, then the REALsastart()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 precedesqueue_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; :1623if (bioq_first(&softc->bio_queue) != NULL) {:1624biodone(done_bio);:1625goto again;βbiois 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_countdecrements again (desync/underflow),bioq_removeruns on a bio that is no longer in the queue (stalebio_actlinks),bp->b_resid/bp->b_errorare re-written on a buffer whose completion already ran (for a physio pbuf: afterbiodone_sync+biowaitreturns, physio mayrelpbufβ a write-after-free window), andbiodone(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 secondbpdone(bp,1)reachesbrelse()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:
- bio starvation: B2 and B3, validly queued (link dump captured),
are NEVER biodone'd by the drain. On a real system their
physiocallers sleep inbiowait()until some LATERsastart(via the real xpt scheduling the shim counted) happens to run β withSA_FLAG_ERR_PENDINGalready cleared and the EOF drain semantics skipped for them. queue_countdesync: 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).SA_FLAG_ERR_PENDINGcleared 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
fixeddisassembly 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)
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.
No comments yet.