ttyflush(FREAD|FWRITE) queues the IXOFF resume character via ttyunblock() and then discards it with the trailing output flush β the peer that honoured XOFF is permanently stopped while the kernel believes it is unblocked
Summary
In the TS_TBLOCK state a FREAD|FWRITE flush (TIOCFLUSH/tcflush(TCIOFLUSH), ttyclose's last-close flush, ttyrevoke, the pty TIOCREMOTE/TIOCSIG paths) runs ttyunblock BEFORE the trailing FWRITE flush: the queued VSTART/^Q is deleted after being counted as sent, TS_TBLOCK is already clear so no later path re-sends it - the remote side that honoured the earlier XOFF is never told to resume (input wedged until the peer out-of-band sends ^Q). Reproduced deterministically 6/6 as uid 1001 on a ptmx/pts pair (XOFF observed emitted, control arm drains+XON observed, test arm TIOCFLUSH -> XON never arrives in 2s while post-flush input flows); reorder-after-flush fix validated on a single-fix kernel. 4.4BSD heritage; per-tty input DoS only.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-5794 Β· 14 files| File | Type | Description | Size | |
|---|---|---|---|---|
| df5794.c | β | 6.1 KB | view raw | |
| build.sh | β | 85 B | view raw | |
| run.sh | β | 89 B | view raw | |
| build.log | β | 229 B | view raw | |
| run.log | β | 462 B | view raw | |
| run.2.log | β | 409 B | view raw | |
| run.fixed.log | β | 420 B | view raw | |
| fix.diff | β | 2.0 KB | view raw | |
| fix_build.log | β | 734 B | view raw | |
| fix_run.log | β | 573 B | view raw | |
| env.txt | β | 349 B | view raw | |
| code_hash.txt | β | 75 B | view raw | |
| README.md | β | 2.4 KB | β raw | |
| VERDICT.md | β | 4.4 KB | β raw |
DF-5794 β ttyflush(FREAD|FWRITE) discards the IXOFF resume (VSTART/XON) character
What
sys/kern/tty.c ttyflush() (lines 1430-1492). When a tty is input
flow-blocked (TS_TBLOCK set because the raw+canonical input queues passed
t_ihiwat with IXOFF/CRTS_IFLOW) and a FREAD|FWRITE flush runs
(ioctl(TIOCFLUSH, FREAD|FWRITE), tcflush(TCIOFLUSH), last-close
ttyclose() tty.c:254, ttyrevoke() tty.c:2822, pty TIOCREMOTE/
TIOCSIG/master-TIOCSETA* paths in tty_pty.c), the FREAD branch calls
ttyunblock() (tty.c:1453), which queues the resume character VSTART
(default 0x11/^Q) into t_outq (tty.c:1542) and clears TS_TBLOCK
(tty.c:1540). The trailing if (rw & FWRITE) FLUSHQ(&tp->t_outq)
(tty.c:1487-1488) then deletes that character from the freshly emptied
output queue.
Result: the peer that honoured the earlier XOFF (0x13/^S) β a terminal
emulator, a remote tty server, a serial device β is never told to resume
transmission, while the kernel state claims it is unblocked (TS_TBLOCK
cleared, so neither ttread's low-water ttyunblock (tty.c:1936-1939) nor
any other path will re-send XON). The tty input channel is wedged until the
peer independently emits VSTART (e.g. a human presses ^Q at the remote end).
Reproduce (guest, unprivileged)
cd findings/poc/DF-5794 cc -O -o df5794 df5794.c ./df5794
Expected (stock kernel):
CONTROL: XOFF (0x13) received by master CONTROL: XON (0x11) after slave read-drain: SEEN (expected) <- rig sanity TEST: XOFF (0x13) received by master; TS_TBLOCK set TEST: TIOCFLUSH(FREAD|FWRITE) done (master pre-drained) TEST: XON (0x11) after TCIOFLUSH: NOT SEEN => XON discarded ... <- BUG RESULT: BUG REPRODUCED (XON discarded by ttyflush)
CONTROL arm proves the kernel does emit XON via the same ttyunblock()
when reached through ttread's low-water path; the TEST arm shows the
flush-ordered call's character is destroyed. 6/6 stock runs reproduced;
with fix.diff applied and a rebuilt kernel the TEST arm sees the XON
(run.fixed.log), i.e. the defect is gone.
Files
df5794.cβ self-contained pty-pair PoC (no privileges, no pts grant games)run.log,run.2.logβ decisive stock-kernel runsrun.fixed.logβ patched-kernel run (fix validated)fix.diffβ minimal reorder: move the TS_TBLOCK unblock after the final output flushfix_build.log,fix_run.logβ single-fix kernel build + rerun evidenceenv.txt,build.log,code_hash.txt
DF-5794 β VERDICT
Status: reproduced (stock), fixed (patched-kernel validation passed). Impact: dos β per-terminal input-channel wedge via lost IXOFF resume char. Confidence: certain.
Root cause (line-accurate)
ttyflush() in sys/kern/tty.c executes, for rw == FREAD|FWRITE:
- tty.c:1437-1440 β
FLUSHQ(&tp->t_outq)(1st), clearTS_TTSTOP; - tty.c:1441 β driver
t_stop; - tty.c:1442-1449 β FREAD: flush
t_canq/t_rawq, clearPENDIN,ttwakeup; - tty.c:1450-1485 β if
TS_TBLOCK(input flow-blocked with IXOFF): tty.c:1451-1452 flushest_outqa 2nd time to make room for the START char; tty.c:1453ttyunblock(tp)queues VSTART into the now-emptyt_outq(tty_subr.c clist_putc via tty.c:1541-1542), clearsTS_TBLOCK(tty.c:1540) andttstart()s it; tty.c:1462 clearsTS_TBLOCKagain; - tty.c:1487-1488 β trailing
if (rw & FWRITE) { FLUSHQ(&tp->t_outq); ttwwakeup; }β this deletes the VSTART queued in step 4.
After the flush the kernel believes the line is unblocked (TS_TBLOCK
clear), so the only re-send trigger in the tree β ttread's low-water
if (ISSET(tp->t_state, TS_TBLOCK) && ... ) ttyunblock(tp);
(tty.c:1936-1939) β can never fire. A peer that honoured XOFF is stranded.
The file's own TODO (tty.c:48 "Fix races for sending the start char in ttyflush()") shows the START-char handling was known-fragile; the discard ordering inherited from 4.4BSD was never corrected. This finding is the demonstrated discard, distinct from the TODO's "races" framing.
Reachability
Unprivileged: any open fd of a tty with IXOFF (or CRTS_IFLOW) and
input past t_ihiwat-3 (tty.c:427-431), then ioctl(fd, TIOCFLUSH,
&(int){FREAD|FWRITE}) β i.e. tcflush(TCIOFLUSH). Implicit triggers:
last device close (ttyclose tty.c:254 β every session teardown of a
flow-blocked terminal), ttyrevoke (tty.c:2822), pty TIOCREMOTE,
TIOCSIG, and master-side TIOCSETA* (tty_pty.c:1163-1255, all call
ttyflush(tp, FREAD|FWRITE)). No privilege, no race, deterministic.
Reproduction narrative
Rig: ptmx/pts pair (the rig's own console serial is not touched). Slave in non-canonical mode with IXOFF, echo/OPOST off so the master observes the kernel's flow-control bytes verbatim.
- Fill slave input with 768 bytes >
t_ihiwat(512 for the pty old-driver watermark case, tty.c:2447) β kernel sends XOFF to master (observed 0x13). - CONTROL arm: slave
read()s the queue empty βttreadout-pathttyunblockβ XON (0x11) observed at master. Proves the emit machinery works and the watermark math of the rig is right. - TEST arm:
ioctl(slave, TIOCFLUSH, FREAD|FWRITE)β XON never arrives (2 s watch, repeated); subsequent masterβslave traffic still flows, proving the kernel considers itself unblocked while the resume byte was destroyed.
Stock kernel #0: Thu Jul 2 06:02:54 UTC 2026: 6/6 runs "BUG REPRODUCED"
(3 with the pre-drain-corrected PoC, 3 with the final PoC; see run.log,
run.2.log and the earlier session logs).
Fix validation
fix.diff moves the TS_TBLOCK unblock block after the final output
flush (semantics for FREAD-only and FWRITE-only flushes are unchanged; the
old inner make-room flush becomes redundant). Applied to the guest's
/usr/src (Hunk #1 succeeded at 1447), make nativekernel + installkernel
(DragonFly 6.5-DEVELOPMENT #1: Mon Oct 12 20:09:42 UTC 2026), rebooted,
reran the identical PoC as uid 1001:
- baseline (stock):
XON after TCIOFLUSH: NOT SEENβ BUG REPRODUCED; - patched:
XON after TCIOFLUSH: SEENβ BUG NOT REPRODUCED (defect GONE).
Guest then reset to the clean-source snapshot and left up.
Impact ceiling
Per-tty input denial of service (the terminal's input direction stalls silently until the remote side manually sends ^Q or the tty is reconfigured with IXOFF off/on). Not memory corruption; no escalation path β the primitive is a missing byte in an output queue. Severity Low (DoS, self/peer-recoverable only with out-of-band action; requires IXOFF-enabled terminals which is not the default).
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
fixedfix.diff (reorder unblock after final output flush) applied to guest /usr/src, nativekernel built+installed (#1 Mon Oct 12 20:09:42), rebooted, identical PoC rerun as uid 1001: baseline stock kernel reproduced the XON discard; patched kernel delivered the XON ('SEEN'), so the defect is gone. FREAD-only and FWRITE-only flush semantics unchanged by the reorder.
fix_build.log (patch Hunk #1 succeeded at 1447; kernel build+install rc=0); fix_run.log / run.fixed.log (patched run: 'TEST: XON (0x11) after TCIOFLUSH: SEEN (bug NOT reproduced)'); run.log (baseline stock: NOT SEEN / BUG REPRODUCED).
Confirmed kernel references
Detail
Evidence (decisive lines)
run.log / run.2.log: stock runs show 'XON after TCIOFLUSH: NOT SEEN' with CONTROL arm 'XON after slave read-drain: SEEN'; run.fixed.log + fix_build.log: patched kernel (#1 Mon Oct 12 20:09:42) shows 'XON after TCIOFLUSH: SEEN'; VERDICT.md has the line-accurate trace (tty.c:1450-1462 vs 1487-1488).
PoC changes
Seed was written fresh for this pass. One PoC defect found and fixed during fix-validation: the original test drained the master AFTER the TIOCFLUSH, which would have swallowed the XON delivered by the fixed kernel; corrected to pre-drain before the flush (commit of record is the final df5794.c, sha256 in code_hash.txt).
Verified recommended fix
In ttyflush(), move the TS_TBLOCK/ttyunblock() handling after the final 'if (rw & FWRITE) FLUSHQ(outq); ttwwakeup' block so the queued VSTART survives the flush (see fix.diff).
Verdict
ttyflush(FREAD|FWRITE) on an IXOFF input-flow-blocked tty queues the resume char VSTART via ttyunblock() (tty.c:1453 -> 1542) and then discards it with the trailing FLUSHQ at tty.c:1487-1488 while TS_TBLOCK is already clear, so no later path re-sends it: any peer that honoured the earlier XOFF is permanently stopped. Deterministic, unprivileged (TIOCFLUSH / tcflush(TCIOFLUSH)); also hit on last-close (ttyclose tty.c:254), ttyrevoke, and the pty TIOCREMOTE/TIOCSIG/master-TIOCSETA* paths. Reproduced 6/6 on the stock guest kernel as uid 1001 via a pty pair with a read-drain CONTROL arm that proves the XON machinery itself works; single-fix kernel (fix.diff: move the TS_TBLOCK unblock after the final output flush) rebuilt and rerun -> XON delivered, defect gone. Not memory corruption; impact is a per-terminal input-channel DoS (Low).
No comments yet.