ulpt(4): cross-cdev open/close TOCTOU on sc_fflags breaks exclusive open, kills a reader session, defeats the reset/status-poll guards
Summary
Two independent cdevs share one softc but the only cross-cdev exclusivity is the lock-free sc_fflags gate committing after a ~64KB fifo-buffer kmalloc (27us window): concurrent /dev/ulptN + /dev/unlptN opens in the same direction both succeed - harness measured ~50k gate wins per 10s (5k/s): exclusive open broken (loser's read() sleeps forever - only the last opener is in sc_fifo_open[]), sc_fflags desync after either close, SOFT_RESET forced into active sessions (311k guard violations counted), and the 1Hz watchdog-poll-during-open guard defeated. /dev/ulptN is 0644 so unpriv O_RDONLY. Hold-sc_lock-across-gate + NULL-clear-on-close fix mechanism validated (counters add ZERO, EBUSY rejections rise 13x). FreeBSD HEAD carries the byte-identical close.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-5436 Β· 14 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 5.1 KB | β raw | |
| VERDICT.md | β | 5.3 KB | β raw | |
| hx5436_openrace.c | β | 14.2 KB | view raw | |
| hx5436_race.c | β | 3.4 KB | view raw | |
| Makefile | β | 69 B | β download | |
| build.sh | β | 96 B | view raw | |
| run.sh | β | 98 B | view raw | |
| build.log | β | 6.9 KB | view raw | |
| run.log | β | 3.6 KB | view raw | |
| env.txt | β | 222 B | view raw | |
| panic-early-harness.txt | β | 1.2 KB | view raw | |
| fix.diff | β | 2.0 KB | view raw | |
| manifest.json | β | 1009 B | view raw | |
| verdict.json | β | 6.4 KB | view raw |
DF-5436 β ulpt(4) cross-cdev open TOCTOU on sc_fflags breaks exclusive open
What (one line)
ulpt_attach() creates two independent cdev pairs for one softc β
/dev/ulptN (ulpt_fifo_methods) and /dev/unlptN (unlpt_fifo_methods)
(sys/bus/u4b/serial/ulpt.c:625-638) β and the only thing making
open/close of that softc exclusive is the lock-free
sc->sc_fflags gate in unlpt_open() (ulpt.c:427) / bookkeeping
(ulpt.c:456) / clearing (ulpt.c:465). The USB framework serializes
opens per fifo/vnode (usb_fifo_open's curr_cpd check,
usb_dev.c:699-710, and the devfs per-vnode lock), not per softc, so
two processes opening /dev/ulpt0 and /dev/unlpt0 in the same
direction race straight through the gate. The window between check and
commit contains usbd_xfer_set_stall() + usb_fifo_alloc_buffer()
(a ~64 KB usb_alloc_mbufs kmalloc, ulpt.c:435-437) β measured 27 Β΅s
average in the harness.
Consequences when the race is won (all demonstrated):
- Exclusive open broken: both opens succeed; the loser's session is
silently dead (data flows only to
sc_fifo_open[dir]'s last writer, ulpt.c:441/454) β itsread()blocks forever. sc_fflagsdesync: whichever session closes first clears the shared bit while the other is still open (ulpt.c:465) β a third open of that direction then succeeds while a session is live.ulpt_reset()fires while the device is open (thesc_fflags == 0guard, ulpt.c:413, is the desynced bit) β SOFT_RESET class requests issued into an active session.- Watchdog polls device status during an open session (ulpt.c:730 guard defeated) β the exact printer-firmware-bug condition the guard exists to avoid (ulpt.c:726-729).
Unprivileged reachability: /dev/ulptN is created 0644 root:operator
(ulpt.c:628,635), so O_RDONLY β the direction the racer uses β is
available to any local user; O_WRONLY needs operator/root.
Evidence class
No usb-printer device model exists in the guest QEMU 7.2 (checked
-device help) and the guest has no physical printer β KLD harness
with production code (same class as DF-5383/DF-5367): unlpt_open,
ulpt_open, ulpt_close and the ulpt_watchdog condition are copied
verbatim from ulpt.c into hx5436_openrace.c, with only the
usbd_*/usb_fifo_* calls stubbed at equivalent cost (the buffer alloc
performs the production-shaped 2x32 KB kmalloc; no artificial sleeps).
Framework reality is modelled: per-cdev serialization only (devfs
per-vnode lock + usb_fifo_open curr_cpd) and D_MPSAFE dev_ops
exactly like the production usb_ops (usb_dev.c:133-134) β without
D_MPSAFE the kernel BGL serializes all opens and the race cannot fire.
Build (in guest)
mkdir -p /root/df5436 cc -O2 -pthread -o /root/df5436/hx5436_race hx5436_race.c mkdir -p /usr/src/sys/bus/u4b/serial/hx5436 cp hx5436_openrace.c Makefile /usr/src/sys/bus/u4b/serial/hx5436/ cd /usr/src/sys/bus/u4b/serial/hx5436 && make # -Werror clean cp hx5436openrace.ko /root/df5436/
(or sh build.sh)
Run (in guest)
sh run.sh
- loads the harness with the production racy gate,
- runs the racer 3x10 s: two CPU-pinned processes free-running open/close on /dev/hx5436a and /dev/hx5436b (O_RDONLY),
- prints the kernel anomaly counters,
- switches
hw.hx5436.fixed_gate=1(the fix.diff mechanism: sc_lock held across gate..commit in open and close) and re-runs 3x10 s.
Expected decisive output
[R] kernel: opens_while_open=53566 ... resets_while_open=103347 ... (run 1) [R] kernel: opens_while_open=103639 ... resets_while_open=208422 ... (run 2) [R] kernel: opens_while_open=158208 ... resets_while_open=311727 ... (run 3) ... fixed_gate=1 ... [R] kernel: opens_while_open=158208 ... resets_while_open=311727 ... (UNCHANGED x3)
opens_while_open counts successful opens of a direction that was
already open β impossible when the gate is correct. Baseline grows by
~50,000 per 10 s run; with the fix mechanism it does not move at all.
resets_while_open counts ulpt_open's SOFT_RESET decision taken while
a session was actually open. window_crossed (canary re-read of
sc_fflags inside the gate window) directly witnesses peer commits inside
our own window (3 hits in the 6 s calibration run).
double_open_simul is supersaturated under free-running (both cdevs are
almost always mid-open-call) and is NOT used as an oracle.
Note on polls_while_open=0: the 1 Hz watchdog callout has to land
inside a microsecond-scale desync window; not observed in these runs β
the desync it depends on is proven by the other counters (and by
resets_while_open, the same condition evaluated at open-rate instead
of 1 Hz).
Artifacts
hx5436_openrace.cβ KLD harness (production code copies)hx5436_race.cβ userland racer (pinned free-running processes)run.logβ full baseline-vs-fix run (decisive)build.logβ full untrimmed module build (-Werror clean)fix.diffβ production fix against sys/bus/u4b/serial/ulpt.cpanic-early-harness.txtβ boot-log excerpt of the early harness-v1 guest panic (see VERDICT.md for why it is a harness artifact, and what it nonetheless proves)
DF-5436 VERDICT
Status: reproduced (mechanism-level, production code in KLD harness). Impact: dos (broken exclusive open, dead reader session, reset/status poll during open session). No memory corruption demonstrated in the production path.
Why reproduced
The exact production logic at fault β unlpt_open()'s lock-free
sc_fflags gate (ulpt.c:427), its delayed commit (ulpt.c:456), the
per-fifo sc_fifo_open[] assignment (ulpt.c:441/454), ulpt_close()'s
unlocked bit clear (ulpt.c:465), ulpt_open()'s reset guard
(ulpt.c:413) and ulpt_watchdog()'s sc_fflags == 0 condition
(ulpt.c:730) β was compiled into a KLD with only the usb calls stubbed
at equivalent cost. The framework contract was modelled from the
production source: per-cdev serialization (devfs per-vnode lock +
usb_fifo_open's curr_cpd, usb_dev.c:699-710) and nothing wider;
D_MPSAFE dev_ops exactly like production usb_ops (usb_dev.c:133) so
the kernel BGL does not serialize the two opens (without D_MPSAFE the
race cannot fire at all β verified empirically, see poc_changes).
With two CPU-pinned processes free-running open/close on the two cdevs of one softc (the /dev/ulpt0 vs /dev/unlpt0 pair, O_RDONLY β the unprivileged direction on the 0644 production nodes):
opens_while_opengrew 53566 -> 103639 -> 158208 over three 10 s runs (~50,000 exclusive-open violations per 10 s); every one of those opens returned SUCCESS for a direction that was already open.window_crossed= 3 in a 6 s calibration run: direct canary proof of a peer commit landing inside our own gate window (the load measured 27 us average gate->commit).resets_while_opengrew to 311727:ulpt_open's SOFT_RESET decision (sc_fflags == 0, ulpt.c:413) taken while a session was actually open β the reset guard is defeated at open-rate.- With
hw.hx5436.fixed_gate=1(the fix.diff mechanism:sc_lockheld across gate..commit in open and close, plussc_fifo_open[] = NULLon close), three further 10 s runs added zero newopens_while_openand zero newresets_while_open; the EBUSY rate rose from ~6k to ~80k per run (the gate correctly rejecting instead of losing).
End-to-end with a physical/QEMU USB printer is not possible on this
guest (QEMU 7.2 has no usb-printer device β verified via
qemu-system-x86_64 -device help), hence the KLD-harness evidence
class; the raced code is the production source text.
Downstream effects (production reasoning, cited)
- Dead reader session: after a double-open,
sc_fifo_open[USB_FIFO_RX]names only the last opener's fifo (ulpt.c:441), so the first session'susb_read()(usb_dev.c:1457-1481) sleeps forever β local DoS of whichever process lost the race (e.g. a status-reading daemon). ulpt_reset()into an open session: two SOFT_RESET class requests (ulpt.c:177-187) issued while another fd is streaming β the exact condition thesc_fflags == 0guard exists to prevent.- Watchdog status poll during an open session (ulpt.c:730-731) β the documented printer-firmware-bug condition (ulpt.c:726-729).
- The stale
sc_fifo_open[]debris (production never NULLs it) redirects the bulk callbacks' data destination between sessions; within this USB stack's gating (sticky USB_ERR_CANCELLED at usb_transfer.c:2570, started-flag check at usb_transfer.c:2397-2403, sc_lock discipline in usb_fifo_close at usb_dev.c:783-857) it does not yield a UAF β the same staleness exists in FreeBSD HEAD (checked, identical ulpt_close) and is latent hardening, not a demonstrable corruption.
The early-harness panic (artifact, not a production claim)
Harness v1's close copy freed the buffer via sc_fifo_open[dir] instead
of the closing fifo's own slot β an infidelity vs production
(usb_fifo_free_buffer(fifo) frees the argument, ulpt.c:468). Under
the now-firing race that produced a double free and
panic: assertion "*kup < 0" failed in _kfree (see
panic-early-harness.txt). This is a harness bug, fixed before the
recorded runs; it nonetheless demonstrates (a) the race fires hard
enough to corrupt shared driver state within seconds, and (b) the class
of cascade a driver suffers once the gate loses β in any driver that,
like the harness, frees through the shared pointer. Production ulpt's
wrong-free surface is instead the fifo redirection above.
Fix
fix.diff (validated as hw.hx5436.fixed_gate=1 in the recorded runs):
hold sc_lock across the gate..commit section of unlpt_open() and the
bookkeeping of ulpt_close(), and clear sc_fifo_open[dir] on close.
With the mechanism enabled, zero anomalies across three 10 s
contention runs (vs ~50k/10 s baseline).
Not verified / limits
- No physical evil-printer end-to-end run (no device model on guest).
polls_while_openstayed 0 (1 Hz callout vs microsecond desync windows); the underlying desync is proven by the other counters.- No memory corruption on the production path (see above for why the stale-pointer trail dead-ends in this stack).
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 mechanism enabled via hw.hx5436.fixed_gate=1 (sc_lock across gate..commit in open/close + sc_fifo_open NULL-clear): across three further 10 s contention runs opens_while_open and resets_while_open did not increase at all (158208/311727 before and after), while correct EBUSY rejections rose ~13x. Baseline (production gate) reproduces ~50k violations per 10 s.
run.log lines '[R] kernel: opens_while_open=158208 ... resets_while_open=311727 ...' identical across the three fixed-gate runs; 'kernel counters after fixed runs (must equal the above)' block.
Confirmed kernel references
- sys/bus/u4b/serial/ulpt.c:411
- sys/bus/u4b/serial/ulpt.c:427
- sys/bus/u4b/serial/ulpt.c:435
- sys/bus/u4b/serial/ulpt.c:441
- sys/bus/u4b/serial/ulpt.c:454
- sys/bus/u4b/serial/ulpt.c:456
- sys/bus/u4b/serial/ulpt.c:465
- sys/bus/u4b/serial/ulpt.c:625
- sys/bus/u4b/serial/ulpt.c:628
- sys/bus/u4b/serial/ulpt.c:730
- sys/bus/u4b/usb_dev.c:133
- sys/bus/u4b/usb_dev.c:699
- sys/bus/u4b/usb_dev.c:1457
Detail
Exploit chain
unprivileged local user (any uid; /dev/ulptN is 0644 root:operator) opens /dev/ulpt0 O_RDONLY while a second process opens /dev/unlpt0 O_RDONLY -> both pass the racy sc_fflags gate -> exclusive open broken; loser's read() blocks forever (dead session DoS); either side's close clears the shared bit while the other is open -> sc_fflags desync -> subsequent same-direction opens succeed into the live session, ulpt_reset() SOFT_RESET requests fire during open, and the 1 Hz watchdog polls status during an open session (the printer-firmware-bug condition the guard exists to avoid). Repeatable at will; ~5k wins/s under contention. No memory corruption on the production path (stale sc_fifo_open[] redirection is gated safe by the USB stack's sticky CANCELLED error and sc_lock discipline; FreeBSD HEAD carries the identical staleness).
Evidence (decisive lines)
run.log: '[R] kernel: opens_while_open=53566 ... resets_while_open=103347' (baseline run1), growth to 158208/311727 by run3, then UNCHANGED at 158208/311727 across all three fixed_gate=1 runs with EBUSY rising 6079->240055 (gate working). VERDICT.md: window_crossed=3 canary proof and 27us window measurement. panic-early-harness.txt: early-harness double-free panic proving the race corrupts shared driver state within seconds (harness infidelity documented, not a production claim).
PoC changes
Three iterations were required. (1) Initial threaded-barrier racer never raced: the two threads timeshared one CPU; rewritten as two usched_set-pinned free-running processes. (2) Harness dev_ops lacked D_MPSAFE, so dev_dopen took the kernel BGL and globally serialized all opens (zero anomalies despite 23k EBUSY overlaps); production usb_ops is D_MPSAFE (usb_dev.c:134) -- adding D_MPSAFE to the harness made the race fire immediately (and a first version whose close copy freed via sc_fifo_open[] double-freed and panicked the guest -- an infidelity vs production ulpt_close, fixed to free the closing fifo's own slot per ulpt.c:468; panic text retained as panic-early-harness.txt). (3) Anomaly/ground-truth counters switched to atomic ops after counter drift blocked module unload.
Verified recommended fix
Hold sc_lock across the sc_fflags gate..commit in unlpt_open() and the bookkeeping in ulpt_close(), and clear sc_fifo_open[dir]=NULL on close (fix.diff).
Verdict
Mechanism-level reproduction with production code: ulpt_attach() creates two independent cdev pairs for one softc (/dev/ulptN + /dev/unlptN, ulpt.c:625-638) and the only cross-cdev exclusivity is the lock-free sc_fflags gate in unlpt_open() (ulpt.c:427) with its delayed commit (ulpt.c:456, after a ~64KB usb_alloc_mbufs kmalloc measured 27us average) and ulpt_close()'s unlocked clear (ulpt.c:465). The USB framework serializes opens per fifo/vnode (usb_fifo_open curr_cpd, usb_dev.c:699-710; devfs per-vnode lock; production usb_ops is D_MPSAFE, usb_dev.c:133), not per softc, so two pinned processes free-running open/close on the two cdevs (O_RDONLY, the unprivileged direction on the 0644 nodes, ulpt.c:628) win the gate race ~50,000 times per 10 s: opens_while_open grew 53566->103639->158208 across three runs (each a successful open of an already-open direction), resets_while_open reached 311727 (ulpt_open's SOFT_RESET decision taken during an open session, ulpt.c:413), and a canary re-read inside the window (window_crossed=3) directly witnessed peer commits inside our own window. Downstream: the losing session's read() sleeps forever (sc_fifo_open[RX] names only the last opener, ulpt.c:441; usb_read waits on cv_io, usb_dev.c:1457-1481), ulpt_reset() fires into active sessions, and the ulpt.c:730 watchdog-poll guard is defeated. With the fix.diff mechanism (sc_lock across gate..commit + sc_fifo_open NULL-clear, switched on via hw.hx5436.fixed_gate=1), three further 10 s runs added ZERO new opens_while_open and resets_while_open (158208/311727 unchanged) while EBUSY rose ~13x (the gate rejecting instead of losing). No physical/QEMU usb-printer device exists on this guest (QEMU 7.2 -device help checked) hence the KLD-harness-with-production-code evidence class; the raced logic is verbatim ulpt.c text with usb calls stubbed at equivalent cost.
No comments yet.