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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5794 Β· 14 files
FileTypeDescriptionSize
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 runs
  • run.fixed.log β€” patched-kernel run (fix validated)
  • fix.diff β€” minimal reorder: move the TS_TBLOCK unblock after the final output flush
  • fix_build.log, fix_run.log β€” single-fix kernel build + rerun evidence
  • env.txt, build.log, code_hash.txt
VERDICT.md
↓ download raw

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:

  1. tty.c:1437-1440 β€” FLUSHQ(&tp->t_outq) (1st), clear TS_TTSTOP;
  2. tty.c:1441 β€” driver t_stop;
  3. tty.c:1442-1449 β€” FREAD: flush t_canq/t_rawq, clear PENDIN, ttwakeup;
  4. tty.c:1450-1485 β€” if TS_TBLOCK (input flow-blocked with IXOFF): tty.c:1451-1452 flushes t_outq a 2nd time to make room for the START char; tty.c:1453 ttyunblock(tp) queues VSTART into the now-empty t_outq (tty_subr.c clist_putc via tty.c:1541-1542), clears TS_TBLOCK (tty.c:1540) and ttstart()s it; tty.c:1462 clears TS_TBLOCK again;
  5. 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 β†’ ttread out-path ttyunblock β†’ 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

fixed
baseline reproduced→ patch + rebuild →patched clean

fix.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).
↓ fix.diffper-fix-DF-5794

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).