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

ucom_get_data TX path passes unclamped request length to clist_qtob β€” latent heap OOB write past USB page-cache segment (consumer-side defect found in tty_subr.c pass-2 API sweep)

Field Value
ID DF-2959
Status new
Severity Low
CVSS 3.1 CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:H
CWE CWE-787 / CWE-805
File sys/bus/u4b/serial/usb_serial.c
Lines 1898-1906 (sink: tty_subr.c:127-149)
Area bus/u4b + kern/tty
Confidence likely
Discovered 2026-09-02
Pass 2 (GLM 5.3 second pass β€” tty_subr.c row)
Bucket memcorrupt
Reported pending
Known CVE none
CVE match novel

Summary

ucom_get_data clamps the contiguous DMA segment returned by usbd_get_page to res.length (:1901) and then ignores the clamp, calling clist_qtob(&tp->t_outq, res.buffer, len) with the full driver buffer size (len, e.g. UPLCOM_BULK_BUF_SIZE). clist_qtob copies min(len, t_outq.c_cc) bytes β€” up to len bytes into a segment that usbd_get_page may size as low as USB_PAGE_SIZE βˆ’ offset == 1 byte (multiseg cache), or that may be a standalone per-page buffer. Sibling RX path uses res.length correctly (buf=res.buffer; cnt=res.length), proving the TX line is a defect. Local user with write access to a USB serial /dev/ttyU* on a system where the TX usb_page_cache is multi-segment or non-KVA: heap OOB write of attacker-supplied tty data (content fully chosen via prior write()), length up to ~1KB per TX callback, repeatable β€” a classic heap-corruption primitive with privesc potential. Masked in the common x86 single-segment configuration (res.length=(usb_size_t)-1 so clamp==len) and hardware-gated (needs a USB serial adapter attached); the audit guest has no USB devices β€” documented honestly as latent/untested. Fix: pass res.length (row diff, mirrors the RX path).

Timeline

  • 2026-09-02 Discovered during pass-2 audit of tty_subr.c (GLM 5.3); API-sweep consumer defect, hardware-gated.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2959 Β· 4 files
FileTypeDescriptionSize
README.md β€” 3.0 KB ↓ raw
VERDICT.md β€” 4.9 KB ↓ raw
verdict.json β€” 2.9 KB view raw
fix.diff β€” 551 B view raw

DF-2959 β€” clist_qtob called with unclamped len in ucom_get_data TX path (heap OOB write, latent)

What

sys/bus/u4b/serial/usb_serial.c ucom_get_data() (the tty→USB data pump):

1898        usbd_get_page(pc, offset, &res);
1899
1900        /* Buffer bigger than max requested data */
1901        if (res.length > len) {
1902            res.length = len;              /* segment clamped to request */
1903        }
1904        /* copy data directly into USB buffer */
1905        SET(tp->t_state, TS_BUSY);
1906        cnt = clist_qtob(&tp->t_outq, res.buffer, len);   /* BUG: len, not res.length */

usbd_get_page() (sys/bus/u4b/usb_busdma.c:80-125) returns the contiguous segment starting at offset:

  • single-segment DMA buffer (ismultiseg == 0): res.length = (usb_size_t)-1 β†’ after the clamp it equals len β†’ harmless;
  • multi-segment / page-scattered cache (ismultiseg != 0): res.length = USB_PAGE_SIZE - (offset % USB_PAGE_SIZE) β€” as short as 1 byte;
  • Case 1b (pc->buffer == NULL): res.buffer = page->buffer + offset β€” a per-USB-page buffer allocation, NOT part of one large KVA mapping.

clist_qtob() (sys/kern/tty_subr.c:127-149) copies n = min(len, c_cc) bytes of tty output data to res.buffer and returns the count. Passing the uncapped len (the driver's whole bulk-out buffer size, e.g. UPLCOM_BULK_BUF_SIZE at uplcom.c:838) instead of the clamped res.length makes the copy run past the contiguous segment whenever t_outq.c_cc > res.length β€” writing attacker-supplied tty bytes (whatever the user write()d to /dev/ttyU*) past the end of a USB page buffer (heap OOB write, length up to len - res.length, content and timing attacker-controlled). The RX twin in the same file (usb_serial.c:2004-2032) does it correctly: buf = res.buffer; cnt = res.length; clist_btoq(buf, cnt, …), confirming the TX line is a defect, not a design choice.

Reachability (honest)

  • Requires a USB serial adapter attached and its /dev/ttyU* open (driver runs ucom_get_data from the bulk-out TX callback). No privileged ioctl is involved β€” any user able to write the device node triggers the copy.
  • In the common x86 configuration the serial drivers allocate a single-segment (≀1 page) bulk buffer β†’ ismultiseg == 0 β†’ res.length = -1 β†’ clamp makes res.length == len β†’ bug masked. The overflow needs a multi-segment (page-scattered / multi-page) TX page cache or a Case-1b (non-KVA) buffer, which the stock serial drivers do not normally produce. Treated as a latent heap-overflow / correctness defect (the clamp exists and is then ignored), not a demonstrated exploit path.
  • QEMU guest: ucom.ko/uplcom.ko/uftdi.ko are present but no USB hardware is attached (ls /dev/ttyU* β†’ No such file or directory), so the path cannot be exercised on the audit guest.

Fix

Pass the clamped segment length (see fix.diff β€” mirrors the RX path).

Reproduce

Static-analysis finding; no runnable trigger was possible on the audit guest (no USB device). See VERDICT.md.

VERDICT.md
↓ download raw

VERDICT β€” DF-2959

Status: untested (out of Phase-V scope: Low severity, latent reachability, and the audit QEMU guest has no USB serial hardware). This pack documents a static-analysis-confirmed defect found during the pass-2 consumer sweep of sys/kern/tty_subr.c (the clist API): a caller passes an unclamped length to clist_qtob, whose documented contract (tty_subr.c:127-149) trusts n as the destination bound.

Why this is a real defect (code-level certainty)

  1. sys/bus/u4b/serial/usb_serial.c:1898 obtains the contiguous DMA segment (res.buffer, res.length) via usbd_get_page.
  2. usb_serial.c:1900-1903 clamps res.length to the request β€” proving the author's intent that at most res.length bytes may touch res.buffer.
  3. usb_serial.c:1906 then ignores the clamp and calls clist_qtob(&tp->t_outq, res.buffer, len) with the full request length len (e.g. UPLCOM_BULK_BUF_SIZE, uplcom.c:838).
  4. clist_qtob writes exactly min(len, t_outq.c_cc) bytes to res.buffer (tty_subr.c:138-144) β€” the only bound it enforces is the queue count.
  5. usbd_get_page (usb_busdma.c:96-108) yields res.length = USB_PAGE_SIZE - (offset % USB_PAGE_SIZE) for multi-segment caches (as low as 1), and for Case 1b returns res.buffer = page->buffer + offset, a standalone per-page allocation β€” so a segment overrun is a heap OOB write into foreign memory, not merely into slack of one big mapping.
  6. The sibling RX path in the same function family (ucom_put_data, usb_serial.c:2004-2032) uses res.length correctly (buf = res.buffer; cnt = res.length; … clist_btoq((char *)buf, cnt, …)), so the TX line is an asymmetry/bug, not a subtlety.

Overflow preconditions: multi-segment (or non-KVA) TX page cache AND t_outq.c_cc > res.length. Both are satisfiable by the API contract but are not produced by the stock serial drivers on x86 (single-segment ≀1-page bulk buffers β†’ res.length = (usb_size_t)-1 β†’ clamp == len). Hence "latent".

Impact ceiling (if preconditions met)

Kernel heap out-of-bounds write: contents = bytes the user previously write()d to the /dev/ttyU* device (fully attacker-chosen), length = min(len, queued bytes) - res.length (up to ~1KB), repeatable per TX callback. That is a classic heap-corruption primitive β†’ potential local privilege escalation on machines with USB serial adapters; not demonstrated here because no such hardware exists on the guest.

Why not reproduced on the guest

  • vm.sh run_user 'ls /dev/ttyU*' β†’ No such file or directory; ucom.ko, uplcom.ko, uftdi.ko, u3g.ko exist in /boot but there is no USB device for the drivers to bind, so ucom_get_data never executes.
  • Severity Low (hardware-gated, masked in common config) β†’ per contract, Phase V is conditional and was not warranted for this finding.

Negative results recorded during the same pass-2 sweep

(everything hunted in tty_subr.c itself and its consumers, all killed)

  • Ring arithmetic clist_getc/putc/btoq/qtob/ndflush/unputc: single subtraction i -= c_ccmax is sufficient under the invariants 0 <= c_cchead < c_ccmax, 0 <= c_cc <= c_ccmax, which every function preserves for non-negative inputs β€” tty_subr.c:113-116,140-141,161-163, 179-181,205-207,282-284.
  • clist_alloc_cblocks shrink/grow copy math (tty_subr.c:64-76): proven in-bounds (count <= ccmax, n <= c_ccmax-c_cchead, count-n <= c_cchead).
  • Blocking-kmalloc(M_INTWAIT) TOCTOU (tty_subr.c:61-80): killed β€” LWKT tokens are re-acquired before the blocked thread resumes, so the post-kmalloc field reads (lines 64-80) run under the caller's tty token; the "NOTE: cl fields may now be different" comment is handled by the fresh re-read.
  • sio interrupt vs tty-softcall concurrency: killed β€” sio touches clists only via comstart/comparam/comstop, each reached with tp->t_token held (ttstart tty.c:1549-1556, ttyinput tty.c:404, ttyflush tty.c:1433, ttioctl tty.c:1056); sioinputβ†’ttyinput takes the token itself (tty.c:404).
  • Huge clist_alloc_cblocks size via user termios.c_ispeed (tty.c:1082 β†’ ttsetwater:2439,2456): killed for reachable devices β€” sio's comparam rejects c_ispeed != c_ospeed and invalid divisors (sio.c:2275-2283); ptys have t_ispeedwat == 0 β†’ clamped legacy path (tty.c:2444-2449).
  • Quoted-character sentinel collision (clist_getc returning TTY_QUOTE-flagged shorts): killed β€” TTY_QUOTE == 0x0100 (sys/sys/tty.h:208), stored values ≀ 0x1ff are always positive as int, so >= 0 loops (e.g. ttypend, tty.c:1665) and != -1 loops are equivalent.
  • ccmax * sizeof(short) signed overflow for ccmax ∈ (2^30, 2^31): same unvalidated-ccmax root cause and same fix as known DF-0211 β€” folded into that finding's remediation, not re-filed.
  • Info leak from clist_qtob/clist_getc: killed β€” every readable element is written by putc/btoq/alloc-copy; new buffers are M_ZERO (tty_subr.c:61).

Fix verification

not_testable
↓ fix.diffper-fix-DF-2959

Confirmed kernel references

Detail

Evidence (decisive lines)

['sys/bus/u4b/serial/usb_serial.c:1898-1906 β€” clamp of res.length ignored by clist_qtob(..., len)', 'sys/bus/u4b/serial/usb_serial.c:2004-2032 β€” RX path uses res.length correctly (asymmetry proof)', 'sys/bus/u4b/usb_busdma.c:80-125 β€” usbd_get_page segment semantics (multiseg: USB_PAGE_SIZE - offset; Case 1b: per-page buffer)', 'sys/bus/u4b/serial/uplcom.c:838 β€” caller passes len = UPLCOM_BULK_BUF_SIZE', 'sys/kern/tty_subr.c:127-149 β€” clist_qtob enforces only min(n, c_cc) as the copy count']

PoC changes

No PoC exists to fix: the defect is reachable only with USB serial hardware; the audit guest has none (/dev/ttyU* absent, ucom.ko present but unbound).

Verified recommended fix

usb_serial.c:1906: pass res.length instead of len to clist_qtob (mirrors the RX path at 2004-2032)

Verdict

Static-analysis-confirmed consumer-side defect found during the pass-2 consumer sweep of sys/kern/tty_subr.c: ucom_get_data (sys/bus/u4b/serial/usb_serial.c:1898-1906) clamps the contiguous DMA segment to res.length but then calls clist_qtob with the unclamped request length len, so the copy can run up to len bytes past a segment as short as one USB page byte when the TX page cache is multi-segment (usbd_get_page, sys/bus/u4b/usb_busdma.c:96-108) β€” a latent kernel heap OOB write with attacker-supplied tty data. The sibling RX path (usb_serial.c:2004-2032) uses res.length correctly. Masked in the common single-segment x86 configuration (res.length = -1 -> clamp == len) and hardware-gated (needs a USB serial adapter); not runnable on the audit guest which has no USB devices (/dev/ttyU* absent), so left untested per the conditional Phase-V policy for Low findings.