uep(4) reassembly state machine wedges permanently: sc->buf_len is never cleared on the bad-header continuation path, and the 1-byte stash that arms it is unvalidated
Summary
A device-supplied 1-byte transfer with a header byte outside the report/DIAG classes is stashed unvalidated; every subsequent transfer then takes the continuation path where get_pkt_len==0 does goto tr_setup WITHOUT resetting buf_len - the absorbing drop-everything state kills touch input until replug (close/open does not clear it; only re-attach zeroes buf_len). Production-code KLD harness (sha256-verified unmodified uep.c): one 0x00 attack transfer then 10 valid transfers -> zero reports ever (PERMANENT_STALL, deterministic x2); control 3/3 reports. Drop-fragment+resync fix validated.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-5542 Β· 14 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 3.7 KB | β raw | |
| VERDICT.md | β | 5.0 KB | β raw | |
| uep_harness.c | β | 8.6 KB | view raw | |
| Makefile | β | 177 B | β download | |
| build.sh | β | 178 B | view raw | |
| run.sh | β | 184 B | view raw | |
| build.log | β | 5.6 KB | view raw | |
| run.log | β | 1.2 KB | view raw | |
| run.2.log | β | 409 B | view raw | |
| fix_build.log | β | 5.6 KB | view raw | |
| fix_run.log | β | 389 B | view raw | |
| env.txt | β | 764 B | view raw | |
| fix.diff | β | 791 B | view raw | |
| verdict.json | β | 5.0 KB | view raw |
DF-5542 β uep(4) reassembly state machine wedges permanently on a 1-byte interrupt transfer with a non-report header byte
What this is
Evidence pack for the pass-2 finding against sys/bus/u4b/input/uep.c
(DragonFly 6.5-DEVELOPMENT, stock uep.c sha256
748a677218497beb8eb95d58197280299ff6ecf743fe1bfa3f419392c0ed34ab).
No eGalax touch-panel hardware exists on the QEMU guest, so β per the
input-family evidence standard β this is a production-code KLD harness:
the real, unmodified /usr/src/sys/bus/u4b/input/uep.c is compiled into a
module (ueph.ko) with only its module boilerplate neutered and its
USB/FIFO externals renamed to deterministic fakes. Synthetic interrupt
transfers (state USB_ST_TRANSFERRED, attacker-chosen length/payload) are
fed to the real uep_intr_callback(); the real uep_put_queue() ->
usb_fifo_put_data_linear() calls (the only path from the parser into the
/dev/uep0 FIFO; 5 bytes per report, framing what=1) are captured in a
shadow FIFO log.
Build (guest, as root)
/path/to/run.sh # or manually: # cp /usr/src/sys/bus/u4b/input/uep.c uep.c # production file, byte-for-byte # make # bsd.kmod.mk, -Werror # kldload ./ueph.ko # scenarios run at MOD_LOAD # kldunload ueph
build.sh automates it (copies the live production file, sha256-verifies,
builds). Full compiler output: build.log.
Run
run.sh loads the module, captures dmesg, and asserts the verdict markers.
Expected output (stock kernel source β the bug)
UEPH: T1 control queued_reports=3 queued_bytes=15 buf_len=0 submits=1 UEPH: T2 stall queued_reports=0 queued_bytes=0 buf_len=1 submits=11 verdict=PERMANENT_STALL UEPH: T3 stash queued_reports=1 report0=81:bb:cc:dd:ee aa_dropped=1 verdict=WRONG_BYTE_STASHED UEPH: T4 cascade queued_reports=0 queued_bytes=0 buf_len=1 verdict=PERMANENT_STALL
- T1 control: aligned 15-byte stream of 3 valid reports -> 3 reports queued (parser healthy on well-formed input).
- T2 (this finding): one 1-byte transfer
{0x00}(non-report header:0x00 & 0xe0 == 0), then 10 further valid 15-byte transfers -> 0 reports ever queued,buf_lenpinned at 1, xfer resubmitted 11 times β the device is polled forever, every byte is dropped. Permanent input stall until unplug/replug (only re-attach zeroesbuf_len, uep.c:338;uep_close()never does). - T3/T4 exercise sibling finding DF-5543 (same harness).
Reproduced identically on a second run (run.2.log).
Fix validation
fix.diff (3 hunks, also covers DF-5543 + close-path hardening) applied to
the guest's /usr/src copy, harness and the real uep.ko module rebuilt
(fix_build.log), same scenarios re-run:
UEPH: T2 stall queued_reports=27 queued_bytes=135 buf_len=0 submits=11 verdict=no_stall
(one transfer lost to the resync drop, then 9x3 reports flow; buf_len
returns to 0). T1 unchanged. See VERDICT.md for the full analysis.
Guest source restored to stock afterwards (sha256 in env.txt).
Files
uep_harness.c harness source (includes production uep.c) Makefile bsd.kmod.mk module build build.sh/run.sh exact build/run commands build.log full untrimmed build output (stock) run.log decisive run (stock) β bug markers present run.2.log repeat run (stock) β identical fix_build.log full build output (patched harness + real uep.ko) fix_run.log patched run β markers gone env.txt guest uname, compiler, production-file sha256, kldstat fix.diff git-apply-able unified diff (git apply from repo root) manifest.json machine-readable catalog verdict.json machine verdict (persist_poc.py schema)
DF-5542 VERDICT β permanent input stall in uep(4) reassembly (sc->buf_len never cleared on bad-header path)
Status: reproduced (deterministic, 2/2 identical runs; fix validated). Impact: dos β permanent loss of the touch-panel input stream (device remains polled but every byte is dropped) until unplug/replug. No memory unsafety involved.
Root cause (path:line)
uep_intr_callback() reassembly continuation path, stock
sys/bus/u4b/input/uep.c:
216: if (sc->buf_len > 0) {
217: int res;
218:
219: if (sc->buf_len == 1)
220: sc->buf[1] = buf[0];
221:
222: if ((pkt_len = get_pkt_len(sc->buf)) == 0)
223: goto tr_setup; /* <-- sc->buf_len stays > 0 forever */
and the unvalidated single-byte stash that arms it:
235: if (len == 1) {
236: sc->buf[0] = buf[0]; /* header byte NOT validated */
237: sc->buf_len = 1;
238: goto tr_setup;
239: }
get_pkt_len() (uep.c:121-147) returns 0 for any first byte with
buf[0] != 0x0a (DIAG) and buf[0] & 0xe0 not in {0x80, 0xa0, 0xc0,
0xe0} β i.e. 96 of 255 possible bytes (0x00β0x7f minus 0x0a).
Attack sequence (all bytes device-supplied; interrupt transfers are
short_xfer_ok, uep.c:283, so the device chooses each transfer's length):
- Device delivers a 1-byte interrupt transfer
00. uep.c:235-239 stashes it (sc->buf[0]=0x00,sc->buf_len=1) without parsing it. - Every subsequent transfer takes the continuation path (216). For a
non-DIAG stash byte,
get_pkt_len()reads onlysc->buf[0]and returns 0 ->goto tr_setup(222-223) without clearingsc->buf_len. The entire new transfer is discarded (it lives in the stack localbuf). - Goto 2, forever.
submits=11in the run log proves the xfer keeps being resubmitted (uep.c:261-264) whilequeued_reports=0proves no data ever reaches the FIFO. The only code that ever writes 0 tosc->buf_lenafter this point is uep.c:338 (attach) β so only unplug/replug recovers.uep_close()(uep.c:413-422) does not reset the reassembly state either, so close(2)/open(2) of /dev/uep0 does not clear the wedge (and stale fragment bytes bleed across sessions).
Contrast with the Linux eGalax driver this code mimics, which resets the fragment length before dropping a bad header (usbtouchscreen.c, egalax process path), and with FreeBSD uep.c, which carries the same two defects (imported code) β the fix applies upstream verbatim.
Threat model
- Malicious/reprogrammed USB device presenting an eGalax VID/PID (0x3823/ 0x0001, 0x3823/0x0002, 0x0123/0x0001 β uep.c:289-293): a single 1-byte transfer kills the input stream permanently.
- Flaky cable/EMI garble on a legitimate panel: same effect from noise.
- Consequence: touches stop being reported (a "frozen" touchscreen while the device node stays open and blocks in read(2)); availability only β no confidentiality or integrity impact, no memory unsafety.
Evidence
- Harness: production
uep.c(sha256-verified byte-for-byte, env.txt) compiled intoueph.ko; synthetic TRANSFERRED callbacks into the realuep_intr_callback; shadow FIFO records the exactusb_fifo_put_data_linear()records that/dev/uep0readers would receive. Deviation from the family's "cloned fd" style: uep has no QEMU-emulatable hardware (unlike ums/ukbd), and the finding lives entirely in the parser state machine, for which the shadow FIFO is a faithful record of the only producer->FIFO call site (uep.c:391-392). run.log/run.2.log: T2queued_reports=0 ... buf_len=1 ... verdict=PERMANENT_STALL(stock, deterministic across runs); T1 control proves the parser is healthy on well-formed input; T4 shows sibling DF-5543 arming this same wedge.
Fix validation
fix.diff hunk 1 adds sc->buf_len = 0; on the bad-header continuation
path (drop stale fragment, resync next transfer); hunk 2 (DF-5543) makes
the stash store the remaining stream byte p[0]; hunk 3 resets buf_len
in uep_close() so no fragment state crosses sessions.
Patched run (fix_run.log): T2 queued_reports=27 buf_len=0
verdict=no_stall β the first post-fragment transfer is dropped (resync),
then 9x3 valid reports flow; T1 unchanged; T4 no_stall (13 reports).
The patched production file also builds as the real uep.ko module with
-Werror (fix_build.log). Guest /usr/src restored to stock afterwards
(sha256 match in env.txt); harness module unloaded.
Bottom line
Deterministic, hardware-free reproduction against the unmodified production parser; availability-only impact (device-input DoS); two-line core fix validated on the production file in both harness and real-module builds.
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
fixedPatched production file (fix.diff applied to guest /usr/src copy, harness + real uep.ko rebuilt): T2 no_stall, buf_len returns to 0, 9x3 valid reports flow after a single resync drop; T1 control unchanged. Stock behavior restored afterwards (sha256 match).
fix.diff; fix_build.log (harness + real uep.ko build, -Werror clean); fix_run.log ('T2 stall queued_reports=27 queued_bytes=135 buf_len=0 submits=11 verdict=no_stall')
Confirmed kernel references
Detail
Exploit chain
malicious/reprogrammed USB device presenting eGalax VID/PID (uep.c:289-293) or EMI garble on a real panel -> single 1-byte interrupt transfer with header byte outside {0x0a} U {x : x&0xe0 in 0x80/0xa0/0xc0/0xe0} -> unvalidated stash (uep.c:235-237) -> get_pkt_len()==0 with sc->buf_len preserved (uep.c:222-223) -> all further device input silently dropped forever while /dev/uep0 stays open and reads block -> touch panel dead until replug (DoS). No route to uid=0: the primitive is pure input availability.
Evidence (decisive lines)
run.log/run.2.log (stock, identical): 'T2 stall queued_reports=0 queued_bytes=0 buf_len=1 submits=11 verdict=PERMANENT_STALL'; T1 control healthy; fix_run.log: 'T2 ... queued_reports=27 buf_len=0 verdict=no_stall'; fix_build.log (real uep.ko builds with fix, -Werror); env.txt (guest uname + production-file sha256 748a6772...); VERDICT.md (full path:line trace)
PoC changes
No seed PoC existed (new pass-2 finding). Harness authored from scratch: production uep.c compiled into ueph.ko via #include with DRIVER_MODULE/MODULE_DEPEND/MODULE_VERSION neutered and usbd_/usb_fifo_ externals #define-renamed to fakes; one identifier corrected against the tree (usb_fifo_put_bytes_max, lowercase b). Guest build needed the production file copied into the module dir (bsd.kmod.mk include-path limitation), sha256-verified byte-for-byte.
Verified recommended fix
Clear sc->buf_len before the bad-header 'goto tr_setup' (drop stale fragment, resync on next transfer); also stash p[0] not buf[0] (DF-5543) and reset sc->buf_len in uep_close() - see fix.diff.
Verdict
REPRODUCED (deterministic, 2/2 runs, hardware-free). uep_intr_callback() stashes a 1-byte interrupt transfer without validating its header (uep.c:235-237); when the stashed byte is a non-report header (96/255 values, e.g. 0x00), the continuation path's get_pkt_len()==0 branch does 'goto tr_setup' WITHOUT clearing sc->buf_len (uep.c:222-223), so every subsequent transfer is consumed and discarded while the xfer keeps resubmitting (uep.c:261-264) - permanent loss of the touch input stream until unplug/replug (only re-attach zeroes buf_len, uep.c:338; uep_close() at uep.c:413-422 never resets the reassembly state, so close/open of /dev/uep0 does not clear the wedge). Production-code KLD harness (ueph.ko compiles the unmodified /usr/src uep.c, sha256-verified): T2 shows 10 valid transfers post-attack -> queued_reports=0, buf_len=1, submits=11, verdict=PERMANENT_STALL; T1 control shows the parser healthy on aligned streams. Availability only: no memory unsafety, no info leak, no privilege impact. fix.diff (buf_len=0 on the bad-header path + p[0] stash fix for DF-5543 + buf_len reset in uep_close) validated: patched production file shows no_stall (9x3 reports flow after one resync drop), builds as the real uep.ko under -Werror; guest source restored to stock (sha256 match).
No comments yet.