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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5542 Β· 14 files
FileTypeDescriptionSize
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_len pinned at 1, xfer resubmitted 11 times β€” the device is polled forever, every byte is dropped. Permanent input stall until unplug/replug (only re-attach zeroes buf_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)
VERDICT.md
↓ download raw

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

  1. 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.
  2. Every subsequent transfer takes the continuation path (216). For a non-DIAG stash byte, get_pkt_len() reads only sc->buf[0] and returns 0 -> goto tr_setup (222-223) without clearing sc->buf_len. The entire new transfer is discarded (it lives in the stack local buf).
  3. Goto 2, forever. submits=11 in the run log proves the xfer keeps being resubmitted (uep.c:261-264) while queued_reports=0 proves no data ever reaches the FIFO. The only code that ever writes 0 to sc->buf_len after 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 into ueph.ko; synthetic TRANSFERRED callbacks into the real uep_intr_callback; shadow FIFO records the exact usb_fifo_put_data_linear() records that /dev/uep0 readers 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: T2 queued_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

fixed
baseline reproduced→ patch + rebuild →patched clean

Patched 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')
↓ fix.diffper-fix-DF-5542

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