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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5422 Β· 12 files
FileTypeDescriptionSize
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 the cfg_close message and waits only for that message to run (usb_proc_mwait inside ucom_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_READY at usb_serial.c:959 turns ucom_start() into a no-op (early return at usb_serial.c:1734-1739), so the t_oproc that ttywait() 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 on TSA_OCOMPLETE with no timeout (tp->t_timeout == 0). Nothing in the ucom TX-completion path ever calls ttwwakeup(), 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 of t_outq β€” never signals TSA_OCOMPLETE/TSA_OLOWAT waiters when it empties the queue (missing ttwwakeup, 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/.

VERDICT.md
↓ download raw

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 show ps STAT I2/S* WCHAN ttywai β€” the ttysleep(..., "ttywai", tp->t_timeout) at sys/kern/tty.c:1396-1398 inside ttywait().
  • 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 wake TSA_OCOMPLETE.
  • Controls: uftdi_probe2 1024 0 1 0 1 (write, 2 s settle, TIOCOUTQ polls, CLOCAL) β€” outq drains 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)

  1. Writer: write(2) queues N>64 bytes into tp->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 set UCOM_FLAG_GP_DATA (usb_serial.c:727-728), ~350 ms after open.
  2. close(2) β†’ ucom_dev_close β†’ ucom_close (usb_serial.c:938): - 955-957: queues cfg_close and usb_proc_mwaits for the message only β€” not for the outq drain; the async drain is still running. - 959: clears UCOM_FLAG_HL_READY β†’ ucom_start() no-ops (usb_serial.c:1734-1739) β†’ t_oproc is dead for ttywait. - 966: l_close β†’ ttylclose (tty.c:1562) β†’ ttywflush β†’ ttywait (tty.c:1384-1406): sleeps on TSA_OCOMPLETE with tp->t_timeout == 0 (no timeout).
  3. 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 carrier ttydcd / 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_testable

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

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.