ucom/uftdi TX close race: write(2) of more than one 64-byte transfer followed by close(2) wedges the closer in uninterruptible ttywait forever and silently drops all but the first bulk-OUT packet (deterministic with an evil FTDI that NAKs bulk-OUT)
Summary
close waits only for the cfg_close taskqueue message, not the TX drain, then clears HL_READY which turns ucom_start into a no-op - the ttywflush ttywait on TSA_OCOMPLETE has no timeout and no waker (ucom_get_data never calls ttwwakeup) and the WR chain goes idle at teardown: the closer sleeps 'ttywai' forever with only the first 64-byte packet transmitted (host-side verified). Reproduced 10+ live on the QEMU usb-serial (FTDI model) rig across 64..1MiB writes; settle-before-close control drains cleanly. Dialer-group user (/dev/cuaU* 0660); an evil NAK-ing device removes the race entirely. Bounded-drain + missing-ttwwakeup fix.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-5422 Β· 12 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 3.8 KB | β raw | |
| VERDICT.md | β | 5.1 KB | β raw | |
| build.sh | β | 80 B | view raw | |
| env.txt | β | 304 B | view raw | |
| fix.diff | β | 1.3 KB | view raw | |
| run.2.log | β | 386 B | view raw | |
| run.log | β | 220 B | view raw | |
| run.sh | β | 104 B | view raw | |
| termios_sweep.log | β | 559 B | view raw | |
| uftdi_live.c | β | 5.3 KB | view raw | |
| uftdi_probe2.c | β | 2.3 KB | view raw | |
| verdict.json | β | 4.2 KB | view raw |
DF-5422 β ucom/uftdi TX close race: write(2)+close(2) wedges the closer in ttywait forever, only the first 64-byte packet is transmitted
What (human-readable)
Writing more than one USB transfer's worth of data (UFTDI_OBUFSIZE = 64,
uftdi.c:82) to /dev/cuaU0 and closing the descriptor immediately makes
close(2) sleep forever (wchan ttywai, tty.c:1396-1398) β and the
data beyond the first 64-byte bulk-OUT packet is never transmitted
(verified host-side through the QEMU usb-serial chardev socket).
Trigger is a race: the closer's ttywait competes with the asynchronous
USB TX drain. Reproduced 10+ times on the live rig; hangs observed for every
size from 64/65 up to 1 MiB; with a settle delay (β₯1 s of ioctl polling or a
plain sleep) before close, the same writes drain and close cleanly.
Who can trigger
- Any local user able to open
/dev/cuaU*(mode 0660 uucp:dialer, usb_serial.c:483-488 β the standard serial-user group) β root trivially. - Amplified by an evil USB device: a physical FTDI-alike that NAKs the bulk-OUT endpoint (or stalls it) makes the race deterministic β the TX queue can never drain, so every write+close wedges, with no timing window needed.
- After one wedged close the port is typically left in a polluted state (subsequent sessions block on carrier / behave erratically) until replug.
Root cause (path:line)
ucom_close()β sys/bus/u4b/serial/usb_serial.c:938-978 β queues thecfg_closemessage and waits only for that message to run (usb_proc_mwait insideucom_queue_command, usb_serial.c:671-672, invoked at 955-957). It does not wait for the tty output queue to drain. The async WR-xfer completion chain (uftdi_write_callback β ucom_get_data, 64 bytes per transfer) is still mid-drain when the close proceeds.sc->sc_flag &= ~UCOM_FLAG_HL_READYat usb_serial.c:959 turnsucom_start()into a no-op (early return at usb_serial.c:1734-1739), so thet_oprocthatttywait()calls each iteration (tty.c:1392) can no longer submit transfers.(*linesw[tp->t_line].l_close)(tp, 0)(usb_serial.c:966) βttylclose()(tty.c:1562) βttywflush()βttywait()(tty.c:1384-1406) sleeps onTSA_OCOMPLETEwith no timeout (tp->t_timeout == 0). Nothing in the ucom TX-completion path ever callsttwwakeup(), so the sleeper is never woken even if the queue would eventually drain; QEMU tracing shows the bulk-OUT chain goes fully idle at close time (zero USB packets while hung), so the outq is frozen permanently.ucom_get_data()(usb_serial.c:1844-1942) β the only consumer oft_outqβ never signalsTSA_OCOMPLETE/TSA_OLOWATwaiters when it empties the queue (missingttwwakeup, contrast sio.c's TX-complete handler).
Reproduce (QEMU rig: qemu-xhci or piix3-usb-uhci + -device usb-serial)
# guest: cc -O2 -o /root/uftdi_live /root/uftdi_live.c # or ./build.sh timeout 10 /root/uftdi_live w 1024 # or ./run.sh # expect: no output, exit=124 (timeout had to SIGTERM it) # while hung: ps -ax -o pid,stat,wchan,command | grep uftdi_live # -> STAT I2/S*, WCHAN ttywai # host side (chardev socket byte counter): only +64 bytes ever arrived.
Control runs in run.2.log / VERDICT.md: sizes 64..1024 all hang;
uftdi_probe2 1024 0 1 0 1 (2 s settle + TIOCOUTQ polling) drains to 0
and closes in 0 ms; RX direction works (10/10 bytes delivered with the
2-byte FTDI status header stripped by uftdi_read_callback, uftdi.c:427-491).
Fix
fix.diff (usb_serial.c): (1) drain t_outq in ucom_close() while
t_oproc is still live, bounded by tp->t_timeout so an evil/dead device
cannot wedge close(2) indefinitely; (2) ttwwakeup(tp) from
ucom_get_data() when the TX queue empties, giving ttywait drain-waiters
their wakeup. git apply --check passes against sys/.
DF-5422 VERDICT β ucom/uftdi TX close race (ttywai hang)
Status: reproduced (live device traffic on the QEMU usb-serial rig bound to uftdi; 10+ hangs across runs). Impact: dos (local; hang of the closing process + TX data loss + port pollution until replug; deterministic with an evil/dead device). Confidence: certain.
Rig
QEMU 7.2 -device usb-serial (presents FTDI 0403:6001 FT232BM, including
the 2-byte FTDI IN status headers and the FTDI vendor control requests) on
a piix3-usb-uhci controller; host-side chardev socket daemon injects RX and
counts TX. Guest: DragonFly 6.5-DEVELOPMENT X86_64_GENERIC (env.txt).
uftdi0 attaches, /dev/cuaU0 (0660 uucp:dialer) appears.
Reproduction
run.logβtimeout 10 /root/uftdi_live w 1024: 10.05 s real, exit 124 β close(2) never returned; timeout(1) had to kill the process. Earlier live captures of the same state showpsSTATI2/S*WCHANttywaiβ thettysleep(..., "ttywai", tp->t_timeout)at sys/kern/tty.c:1396-1398 insidettywait().run.2.logβ size sweep: n=64/65/128/1024 all hit the 6 s timeout (rc=124) in the later (warmer) port state; in the fresh-boot state the boundary was >64 bytes (6/6 hangs at 65..1024, one-buffer 64 usually completing) β consistent with a close-vs-drain race whose window shifts with taskqueue/USB timing, not with a fixed size check.- Host-side TX counter (chardev socket): during a 1024-byte hang exactly +64 bytes arrived β one UFTDI_OBUFSIZE transfer (uftdi.c:82,403). The remaining 960 bytes never reach the wire.
- QEMU packet tracing (earlier boot,
-trace usb_packet_state_change): trace line count frozen across the hang window (118553 β 118553 over 6 s) β the bulk-OUT chain is fully idle while the closer sleeps; nothing is draining, nothing can wakeTSA_OCOMPLETE. - Controls:
uftdi_probe2 1024 0 1 0 1(write, 2 s settle, TIOCOUTQ polls, CLOCAL) βoutqdrains to 0,closed in 0 ms; 200-byte blocking variant also drained (nopoll outq=0, closed 0 ms). RX path verified good: 10 host-injected bytes delivered intact (414243..., 2-byte status header stripped per packet by uftdi_read_callback, uftdi.c:442-476). Baseline open/param/close on the good device: open β 0 ms, tcsetattr β 0 ms, close β 330-390 ms (the synchronous cfg-chain wait β see DF-5424).
Why it hangs (path:line)
- Writer:
write(2)queues N>64 bytes intotp->t_outq(ttwrite, tty.c:1988+); the WR xfer drains 64 bytes per completion (uftdi_write_callback β ucom_get_data, uftdi.c:390-424 / usb_serial.c:1844-1942) β but only once the open/param cfg tasks have setUCOM_FLAG_GP_DATA(usb_serial.c:727-728), ~350 ms after open. close(2)βucom_dev_closeβucom_close(usb_serial.c:938): - 955-957: queuescfg_closeandusb_proc_mwaits for the message only β not for the outq drain; the async drain is still running. - 959: clearsUCOM_FLAG_HL_READYβucom_start()no-ops (usb_serial.c:1734-1739) βt_oprocis dead forttywait. - 966:l_closeβttylclose(tty.c:1562) βttywflushβttywait(tty.c:1384-1406): sleeps onTSA_OCOMPLETEwithtp->t_timeout == 0(no timeout).- Nobody ever calls
ttwwakeup()on TX completion (ucom_get_data lacks it; ucom_start is dead), and the USB WR chain has gone idle (QEMU trace frozen) β the sleep is permanent. PCATCH makes SIGTERM/SIGKILL able to kill the process, but the port is left polluted (later sessions were observed blocking on carrierttydcd/ misbehaving until replug).
Not a uftdi.c-local defect: the driver's callback is the stock FreeBSD u4b code; the bug is the DragonFly ucom core's close/drain ordering (usb_serial.c:955-975) plus the missing TX-complete wakeup. It manifests through every ucom-family driver; uftdi is the one bindable on this guest.
Exploit chain (impact ceiling)
Local: user with dialer-group access (or root) runs the 3-line trigger β process wedged in close(2) forever, 15/16 of a 1 KiB write silently lost, port degraded until replug; repeatable. Physical/evil-USB: an FTDI-alike that NAKs bulk-OUT (or is unplugged mid-write in the right window) removes the race β every write+close wedges. No memory-safety primitive observed (the hang is a logic/lifecycle bug; the uftdi data path itself is memory-safe under the live rig β RX/TX contents verified byte-exact).
Fix validation
fix.diff reorders/bounds the close (drain while t_oproc is alive,
tp->t_timeout bounded; add the missing ttwwakeup on final drain).
Authored against read-only sys/, git apply --check passes. Not
boot-tested (would require a full GENERIC kernel rebuild; fix_status
not_testable) β mechanism argued line-by-line above.
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_testablefix.diff authored against read-only sys/ (usb_serial.c), git apply --check passes on the host repo. Not boot-tested: validating it requires a full GENERIC kernel rebuild on the guest (no /usr/obj tree present); mechanism argued line-by-line in VERDICT.md (bounded drain while t_oproc is live + the missing TSA_OCOMPLETE wakeup).
fix.diff; VERDICT.md 'Fix validation' section; baseline reproduction in run.log/run.2.log
Confirmed kernel references
Detail
Exploit chain
local user opens /dev/cuaU0 (uucp:dialer 0660), writes >64 bytes, closes -> close(2) never returns (killable, but TX payload silently lost and port left polluted until replug); evil-USB variant: FTDI-alike NAKs bulk-OUT -> every write+close wedges deterministically. No memory-safety primitive: data-path contents verified byte-exact both directions.
Evidence (decisive lines)
run.log (10.05s real / exit=124 on timeout 10 uftdi_live w 1024); run.2.log (sizes 64/65/128/1024 all rc=124; settle control); host-side TX counter grew by exactly +64 bytes per 1024-byte hang; QEMU trace line count frozen 118553->118553 during the hang; ps wchan 'ttywai'; termios_sweep.log (pre_param bounds live-verified); VERDICT.md full narrative
PoC changes
No seed PoC existed; authored from scratch (uftdi_live.c multi-mode trigger + uftdi_probe2.c isolation probes). Two iterations: added cfmakeraw to make RX visible (canonical mode was eating headerless test data), and split blocking vs nonblocking variants to isolate the close-race from carrier semantics.
Verified recommended fix
In ucom_close(), drain t_outq (bounded by tp->t_timeout) while HL_READY is still set so t_oproc stays live, and add the missing ttwwakeup(tp) in ucom_get_data() when the TX queue empties (full diff in fix.diff).
Verdict
Live-device reproduction on the QEMU usb-serial rig bound to uftdi: write(2) of >64 bytes (more than one 64-byte bulk-OUT xfer, uftdi.c:82) followed by immediate close(2) wedges the closer forever in ttywait (wchan 'ttywai', tty.c:1396-1398) while only the first 64-byte packet is transmitted (host-verified). Root cause is the DragonFly ucom core close path: ucom_close() waits only for the cfg_close message (usb_serial.c:955-957), not the t_outq drain, then clears UCOM_FLAG_HL_READY (959) which kills t_oproc (ucom_start early-return, 1734-1739), and the l_close->ttylclose->ttywflush->ttywait sleep on TSA_OCOMPLETE has no timeout and no waker (ucom_get_data never calls ttwwakeup); QEMU packet tracing confirms the bulk-OUT chain is fully idle while hung. 10+ hangs reproduced across sizes 64..1MiB; settle-before-close drains cleanly (control). Any dialer-group user (cuaU* 0660) can trigger; an evil device that NAKs bulk-OUT makes it deterministic.
No comments yet.