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

ufm(4): ufm_do_req() loads uninitialized stack buf[0] into *retbuf before the error check β€” latent DF-1062-class one-byte kernel-stack disclosure via FM_GET_STAT, contained on the current stack

Summary

uint8_t buf[1] is never initialized and *retbuf = buf[0] executes BEFORE the error check: every failed/short EP0 request stores one byte of stale kernel stack into the caller's variable (FM_GET_STAT copyout / ufm_start branch). CONTAINED today: plain usbd_do_request = flags 0 and the default EP0 template has no short-ok flags, so a short DATA stage yields USB_ERR_SHORT_XFER -> ENXIO -> both callers discard the byte (runtime-proven 3/3: sentinel unchanged). One flag of drift (USB_SHORT_XFER_OK or an actlen=NULL refactor - the zero-pad-on-short only exists behind non-NULL actlen) turns the same site into a 1-byte-per-ioctl kernel-stack leak; the DF-1062/DF-5455 family shape. buf[1]={0} fix validated (-Werror clean, fixed twin 18/18 clean).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5578 Β· 18 files
FileTypeDescriptionSize
README.md β€” 4.7 KB ↓ raw
VERDICT.md β€” 8.5 KB ↓ raw
ufm5578_harness.c β€” 11.6 KB view raw
ufm5578_shim.c β€” 4.6 KB view raw
Makefile β€” 488 B ↓ download
prod_guard.diff β€” 819 B view raw
build.sh β€” 177 B view raw
run.sh β€” 153 B view raw
build.log β€” 7.7 KB view raw
build.werror_evidence.log β€” 7.7 KB view raw
run.log β€” 10.8 KB view raw
env.txt β€” 1.2 KB view raw
fix.diff β€” 264 B view raw
fix_build.log β€” 7.2 KB view raw
fix_run.log β€” 4.0 KB view raw
ufmunleak.ko β€” 15.0 KB ↓ download
manifest.json β€” 1.9 KB view raw
verdict.json β€” 6.0 KB view raw

DF-5578 β€” ufm(4): ufm_do_req() reads uninitialized stack buf[0] into

*retbuf before the error check β€” latent DF-1062-class disclosure,

CONTAINED on the current stack (Info)

Production file: sys/bus/u4b/misc/ufm.c (Cypress CY7C68xxx FM radio, /dev/ufm*). Evidence class: KLD-harness-with-production-code + synthetic usbd request capture (family standard, DF-5455): no Cypress 0x04b4:0x1002 radio exists on the QEMU guest, so the verbatim production driver is compiled into a KLD and its EP0 transport is rerouted to a capture shim reproducing the traced usbd_do_request_flags() completion semantics.

What the code does

ufm_do_req() (ufm.c:176-200):

uint8_t buf[1];                              /* 183: UNINITIALIZED      */
USETW(req.wLength, 1);                       /* 189                     */
error = usbd_do_request(sc->sc_udev, NULL, &req, buf);   /* 191        */
if (retbuf) { *retbuf = buf[0]; }            /* 193-194: BEFORE the
                                                error check            */
if (error) { return (ENXIO); }               /* 196-198                 */

buf[0] is loaded and stored to *retbuf even when the request failed β€” on every timeout/short/stall the caller's byte receives stale kernel stack. Two callers then matter: ufm_get_stat() copies ret into the FM_GET_STAT ioctl word (ufm.c:292 β†’ copyout at sys_generic.c:730), and ufm_start() branches on it (ufm.c:257).

Why it is contained today (and the one-flag counterfactual)

ufm uses the plain usbd_do_request macro = usbd_do_request_flags( udev, mtx, req, data, 0 /* flags */, NULL /* actlen */, …) (usbdi.h:511-512). With flags=0 and the default EP0 template carrying no short-ok flags (usb_transfer.c:76-88), a short control-IN DATA stage completes with USB_ERR_SHORT_XFER (usb_transfer.c:2979-2985) β€” an error β€” so ufm_do_req returns ENXIO and both callers discard ret (ufm.c:253-256, 288-291). err==0 without the 1-byte copy-out is provably impossible: success requires actlen == sumlen (usb_transfer.c:2952+), actlen sums completed frames (usb_transfer.c:2936-2940), so a full 1-byte DATA stage was DMA'd and copied (usb_request.c:680-681).

The counterfactual (harness mode 3) is exactly the DF-1062 delta: with USB_SHORT_XFER_OK (uchcom.c:398) or via usbd_do_request_proc with actlen == NULL (usb_serial.h:220 β€” the stack's zero-pad-on-short only exists behind a non-NULL actlen, usb_request.c:782-787), the same call site returns err==0 with nothing copied and FM_GET_STAT hands one stale kernel-stack byte to the ioctl caller. The finding is filed so a one-flag drift (or a caller that starts logging ret on error) does not silently turn ufm into DF-1062/#5455.

Exposure ceiling even in the counterfactual: /dev/ufm* is created 0644 root:operator (ufm.c:153) and ufm_ioctl requires O_RDWR (ufm.c:304-306), so the only default opener is root β€” matching the Info severity.

Build

sh build.sh    # in-tree kmod build on the guest:
               # /usr/src/sys/bus/u4b/misc/df5578 + make
               # (guarded copy ufm_prod_guarded.c = production ufm.c
               #  sha256 845738f2... + ONE guard hunk, prod_guard.diff)

Run

sh run.sh      # kldload ufmunleak.ko x3, kldunload, dmesg | grep UFM5578

Expected (decisive) output

UFM5578: gate fflags=FREAD        : rc=13 (EACCES) out=0x12345678
UFM5578: FULL FM_GET_STAT short   : rc=5 (EIO) out=0x000000cc -- LEAK BLOCKED (discarded)
UFM5578: FULL FM_GET_STAT silent  : rc=5 (EIO) out=0x000000cc -- LEAK BLOCKED (discarded)
UFM5578: FULL FM_GET_STAT full    : rc=0 out=0x0000005c -- device byte delivered
UFM5578: FULL FM_GET_STAT shortok : rc=0 out=0x.. -- STALE STACK BYTE REACHES IOCTL CALLER
UFM5578: calibrate: poison depth 1 MATCHES buf[0] slot
UFM5578: pat=0xff rep=0 mode=short-ok   : PROD ret=0xff MATCH          (18/18 total)
UFM5578: pat=0xff rep=0 FIXED(buf={0}) : ret=0x00 (clean)              (18/18 total)
UFM5578: RESULT: counterfactual short-ok: 18/18 pattern-match; fixed twin clean 18/18

plus the request capture (EP0 composition audit):

SET_FREQ 100700000 Hz -> SET_FREQ bReq=1 wVal=0x0022 wIdx=0x22d0 ; CMD0 bReq=0 wVal=0x0096 wIdx=0x00b7 (bm=0xc0 wLen=1)
SET_FREQ 2147483647 Hz -> ... wVal=0xfd64 wIdx=0x6442   (signed-overflow wrap, device-bound only)
FM_START rc=0: [0] bReq=0 0x0000/0x00c7  [1] bReq=2 0x0001/0x0000
FM_STOP  rc=0: [0] bReq=0 0x0016/0x001c  [1] bReq=2 0x0000/0x0000

Fix

fix.diff β€” initialize the buffer (uint8_t buf[1] = {0};, one line, same shape as the DF-5455 umoscom fix). Validated: in-harness fixed twin under identical poisoning returns 0x00 in every mode (18/18), and the patched production ufm.ko builds -Werror-clean (fix_build.log, rc=0).

VERDICT.md
↓ download raw

DF-5578 VERDICT β€” reproduced (latent pattern + containment + counterfactual + fix); Info severity; impact none on the current stack

Executive summary

sys/bus/u4b/misc/ufm.c ufm_do_req() (ufm.c:176-200) declares uint8_t buf[1] uninitialized, hands it to a 1-byte EP0 vendor control-IN (UT_READ_VENDOR_DEVICE, wLength=1, ufm.c:185-191) and then executes *retbuf = buf[0] (ufm.c:193-194) before the error check β€” so on every failed/short request the caller's byte receives stale kernel stack. ufm_get_stat() (ufm.c:277-295) copies that byte into the FM_GET_STAT ioctl word (ufm.c:292) that the generic ioctl layer copyouts to userspace (sys/kern/sys_generic.c:730).

On the current stack this never reaches userspace β€” the plain usbd_do_request macro passes flags=0 (no USB_SHORT_XFER_OK, sys/bus/u4b/usbdi.h:511-512), the shared default EP0 transfer template carries no short-ok flags (sys/bus/u4b/usb_transfer.c:76-88), so a short DATA stage completes with USB_ERR_SHORT_XFER (sys/bus/u4b/usb_transfer.c:2979-2985) which ufm_do_req maps to ENXIO, and both callers return EIO discarding the byte (ufm.c:253-256, 288-291). err==0 without the copy-out is provably impossible: success requires actlen == sumlen (usb_transfer.c:2952+), actlen is summed over completed frames (usb_transfer.c:2936-2940), and only then is the byte DMA'd+copied (usb_request.c:680-681). The hr_func shortcut (usb_request.c:477-523) is unreachable for a real host-mode device (usbd_get_hr_func returns NULL, usb_request.c:335-345; ufm's probe requires USB_MODE_HOST, ufm.c:123-124).

Why file it anyway (Info): this is exactly the delta that separated DF-5448/5438/5489 (no consumer β€” Info) from DF-1062/DF-5455 (real leak): the sibling uchcom driver makes the SAME 1-byte vendor read with USB_SHORT_XFER_OK (uchcom.c:398) and usbd_do_request_proc with actlen == NULL (usb_serial.h:220) β€” one flag of drift in ufm (or a caller that starts using ret on the error path, or the driver being copied as a template) and FM_GET_STAT becomes a 1-byte-per-call kernel stack disclosure. Additionally the exposure is bounded: /dev/ufm* is 0644 root:operator (ufm.c:153) and ufm_ioctl requires O_RDWR (ufm.c:304-306) β€” root-only by default.

What ran (KLD harness with production code, DF-5455 family standard)

  • ufm5578_harness.c #includes ufm_prod_guarded.c β€” a byte-identical copy of the guest's /usr/src/sys/bus/u4b/misc/ufm.c (sha256 845738f2..., == the audited tree) plus ONE guard hunk (prod_guard.diff): the usbd_do_request macro is rerouted after its usbdi.h definition to the separate-TU shim ufm_shim_request() (ufm5578_shim.c), mirroring production's opaque cross-module call (same-TU inlining let GCC fold the provably-uninitialized load in the DF-5455 incident β€” run.1.fold.log there).
  • The real ufm_ioctl() is driven through a fabricated struct usb_fifo (public, usb_dev.h:102; usb_fifo_softc() just returns priv_sc0, usb_dev.c:2499-2502); ufm_do_req() / ufm_get_stat() etc. are the verbatim statics, reachable because the production .c is compiled into the TU. DRIVER_MODULE(ufm, uhub, ...) stays registered and inert (no Cypress radio on the guest; ufm is not in X86_64_GENERIC and not kldloaded).
  • Shim modes reproduce the traced completion semantics of usbd_do_request_flags() for this 1-byte control-IN: mode 0 = short ZLP DATA stage β†’ USB_ERR_SHORT_XFER (error); mode 1 = silence β†’ USB_ERR_TIMEOUT (error); mode 2 = full 1-byte DATA stage β†’ success + 1 byte copied; mode 3 = counterfactual short-ok success (what USB_SHORT_XFER_OK or usbd_do_request_proc-with-NULL-actlen yields) β†’ success, buffer untouched. Every call also captures the request tuple.
  • Deterministic stale-stack stand-in: recursive 256-byte poison frames; matching depth calibrated at load (depth 1).

Results (3 loads, deterministic)

check result
ioctl gate fflags=FREAD (ufm.c:304-306) rc=13 EACCES, word untouched β€” gate present
FULL chain FM_GET_STAT, short (mode 0) rc=5 EIO, out=0xcc sentinel unchanged β€” LEAK BLOCKED
FULL chain FM_GET_STAT, silent (mode 1) rc=5 EIO, sentinel unchanged β€” LEAK BLOCKED
FULL chain FM_GET_STAT, full (mode 2) rc=0, out=0x5c β€” device byte delivered correctly
FULL chain FM_GET_STAT, short-ok (mode 3) rc=0, stale stack byte handed to the ioctl caller
direct ufm_do_req matrix, mode 3 18/18 pattern MATCH (6 bytes Γ— 3 reps)
fixed twin (buf[1] = {0}), mode 3 18/18 clean (0x00)
request capture FM_SET_FREQ 100.7 MHz bReq=1 wVal=0x0022 wIdx=0x22d0, then CMD0 0x96/0xb7 β€” matches ufm.c:218-229 math ((100700000+10700001)/12500 = 8912 = 0x22D0, >>8 = 0x22)
FM_SET_FREQ INT_MAX / INT_MIN wVal=0xfd64 wIdx=0x6442 both β€” signed-overflow wrap; device-bound garbage only, no memory-safety effect (negative note)
FM_START / FM_STOP tuples CMD0 0x00/0xc7 + CMD2 0x01/0x00; CMD0 0x16/0x1c + CMD2 0x00/0x00 β€” matches ufm.c:249-273
FM_GET_FREQ state returns last SET value exactly for all inputs incl. extremes β€” sc_freq lifecycle under sc_lock correct
-Wuninitialized-armed production-shaped build rc=0, zero diagnostics (build.werror_evidence.log) β€” the bug ships silently
patched production ufm.ko (fix.diff) builds -Werror-clean, rc=0 (fix_build.log)

Why not higher severity / no uid0 chain

  • No user-visible manifestation on the current stack (proven above and at runtime: modes 0/1 β†’ EIO + discarded byte).
  • Even the counterfactual leak is 1 stale byte per FM_GET_STAT, gated behind an O_RDWR open of a 0644 root:operator node β†’ root only by default. No corruption primitive, no write path, nothing to groom.
  • This is not a memory-corruption finding; no kernel rebuild/reboot validation required. Fix validated at module level (family standard: in-harness fixed twin + patched module compile).

Pass-2 negative results (checked and killed, with path:line)

  • DF-1062-family short-control-IN leak: contained β€” full proof above (flags=0 macro usbdi.h:511-512; template usb_transfer.c:76-88; SHORT_XFER usb_transfer.c:2979-2985; discard ufm.c:253-256/288-291; hr_func unreachable usb_request.c:335-345).
  • DF-5320-family open/close/detach races: ufm defines no f_open/f_close (dummies installed, usb_dev.c:1824-1828); in-flight ioctls hold a fifo refcount (usb_dev.c:1094, 279-286) and usb_fifo_detach β†’ usb_fifo_free drains on cv_drain (usb_dev.c:593-618) before ufm_detach returns to lockuninit (ufm.c:169-171) and softc free; stale-fd ioctls after detach fail in usb_ref_device (udev->fifo[] NULLed under usb_ref_lock, usb_dev.c:582-591, 251-254 β†’ ENXIO). No UAF window.
  • ioctl buffer handling: _IOWR('U',20x,int) β†’ 4-byte kernel buffer copyin/copyout by sys_generic.c:668-730; no usermem deref in the driver.
  • privilege gates: O_RDWR requirement ufm.c:304-306 present and correct; node perms ufm.c:153.
  • freq math: (freq + 10700001) signed-overflow UB for root-supplied extremes (runtime-captured wVal=0xfd64/wIdx=0x6442) β€” garbage to the device only; no kernel memory effect.
  • attach error path: usb_fifo_attach NULLs f_sc->fp[] first (usb_dev.c:1869-1870), usb_fifo_free(NULL) is safe (usb_dev.c:571-574), usb_destroy_dev(NULL) is safe (usb_device.c:2025-2026).
  • names/locking: ksnprintf bounded (ufm.c:143-144); sc_lock only held transiently for sc_freq get/set (ufm.c:214-216, 238-240); no lock-order inversion with the drain path (usb_dev.c:596-607 drops usb_ref_lock before taking priv_lock).
  • radio-state lifecycle: newbus zeroed softc β†’ sc_freq starts 0; get/set guarded; GET returns last SET exactly (captured).

Fix

fix.diff (one line, DF-5455 shape): uint8_t buf[1] = {0}; in ufm_do_req(). Also worth considering: move *retbuf = buf[0] under !error β€” the value is meaningless on failure.

Guest state: dirtied by the harness dirs (/usr/src/sys/bus/u4b/misc/ df5578{,_fix}, /root/df5578, kldload/kldunload x3); reset via vm.sh reset with-src after evidence collection β€” next spawn starts clean.

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 reproduced

Fix validated at the family-standard module level (Info finding, no kernel rebuild required): baseline (unfixed production code, counterfactual short-ok mode) reproduces the stale-byte flow 18/18; the fixed twin (verbatim ufm_do_req + the one-line init, same poisoning, same mode) returns 0x00 18/18; the patched production ufm.ko builds -Werror-clean on the guest (fix_build.log, rc=0).

fix.diff (sha256 473be17d...); fix_build.log (patched production ufm.ko, RC=0); run.log FIXED(buf={0}) lines (18/18 clean).
↓ fix.diffper-fix-DF-5578

Confirmed kernel references

Detail

Evidence (decisive lines)

run.log (160 lines, 3 loads): 'gate fflags=FREAD : rc=13 (EACCES)', 'FULL FM_GET_STAT short/silent : rc=5 (EIO) out=0x000000cc -- LEAK BLOCKED (discarded)', 'FULL FM_GET_STAT full : rc=0 out=0x0000005c -- device byte delivered', 'FULL FM_GET_STAT shortok : rc=0 -- STALE STACK BYTE REACHES IOCTL CALLER', 'calibrate: poison depth 1 MATCHES buf[0] slot', 'RESULT: counterfactual short-ok: 18/18 pattern-match; fixed twin clean 18/18', plus the EP0 request capture (SET_FREQ 100.7MHz -> bReq=1 wVal=0x0022 wIdx=0x22d0 then CMD0 0x96/0xb7; INT_MAX/INT_MIN -> 0xfd64/0x6442 wrap; START/STOP tuples match ufm.c:249-273). build.werror_evidence.log: -Wuninitialized-armed production-shaped build rc=0 with ZERO diagnostics. fix_build.log: patched production ufm.ko builds -Werror-clean rc=0. env.txt: guarded copy == production + 1 guard hunk (diff shown), guest tree == audited tree.

PoC changes

No seed PoC existed. Authored the family-standard harness from scratch: guarded production copy + cross-TU shim (modes: short->SHORT_XFER error, silent->TIMEOUT error, full 1-byte, counterfactual short-ok success), fabricated struct usb_fifo (public in usb_dev.h:102) to drive the REAL ufm_ioctl dispatch, recursive stack-poison with load-time depth calibration (depth 1), in-harness fixed twin, EP0 request-tuple capture. Build iterations: (1) -Wmissing-prototypes in shim TU, (2) prototype placement before struct def -- both fixed; final build clean.

Verified recommended fix

Initialize the EP0 reply buffer in ufm_do_req(): uint8_t buf[1] = {0}; (full diff in fix.diff; optionally move *retbuf = buf[0] under !error).

Verdict

Reproduced via the DF-5455-family KLD harness (verbatim production ufm.c, sha256 845738f2..., one guard hunk rerouting the EP0 transport to a traced-semantics capture shim; no Cypress radio exists on the guest): ufm_do_req() loads uninitialized stack buf[0] into *retbuf BEFORE the error check (ufm.c:183,191,193-194), so every failed/short EP0 request hands stale kernel stack to the caller. On the CURRENT stack this never reaches userspace -- the plain usbd_do_request macro passes flags=0 (usbdi.h:511-512), the default EP0 template has no short-ok flags (usb_transfer.c:76-88), a short DATA stage completes with USB_ERR_SHORT_XFER (usb_transfer.c:2979-2985), ufm_do_req returns ENXIO and both callers discard the byte (ufm.c:253-256,288-291); err==0 without the 1-byte copy-out is provably impossible (success requires actlen==sumlen, summed over completed frames, usb_transfer.c:2936-2940,2952+; copy at usb_request.c:680-681; hr_func path unreachable for host-mode devices, usb_request.c:335-345). Runtime proof: FULL FM_GET_STAT chain with short/timeout semantics -> EIO with the output word's sentinel UNCHANGED (leak blocked) in 3/3 loads. The one-flag DF-1062 counterfactual (USB_SHORT_XFER_OK / usbd_do_request_proc with actlen=NULL, as uchcom.c:398 + usb_serial.h:220 do) was exercised explicitly: 18/18 deterministic pattern-match of the stale byte through the production ufm_do_req, and the one-line fixed twin (buf[1]={0}) is 18/18 clean. Exposure ceiling even in the counterfactual: 1 byte per FM_GET_STAT, behind an O_RDWR open of a 0644 root:operator node (ufm.c:153,304-306) -> root-only by default. Info severity, filed so drift does not silently turn ufm into DF-1062/DF-5455.