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

usb_quirk_get_entry() all-zero key unconditionally aliases the last dynamic-quirk slot; slot allocation never clears quirks[] β€” dynamic quirk-table state corruption (stale quirks inherited by real device entries; all-zero ADD/REMOVE mutate live entries)

Summary

The all-zero key returns slot 383 unconditionally even after it holds a real dynamic entry, and free-slot allocation never clears quirks[]: an all-zero ADD parks a quirk in a slot that still LOOKS free (real entry later inherits it) and all-zero ADD/REMOVE on a live table append/strip/free that entry's quirks. Guest-proven both scenarios deterministically via /dev/usbctl ioctls (SCENARIO_B: entry 383 vid 0xa02d silently carried UQ_HID_IGNORE; SCENARIO_A: all-zero ADD appended UQ_KBD_IGNORE to the live entry); unpriv runs all EPERM (root-gated, verified). A contaminated entry can silently disable class drivers (UQ_HID_IGNORE/KBD/MSC) for an unrelated real device. No memory-safety impact (writes stay inside quirks[]). Free-slot-only-reservation + memset-on-alloc fix validated on rebuilt kernel.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5380 Β· 13 files
FileTypeDescriptionSize
quirk_alias.c β€” 7.3 KB view raw
build.sh β€” 106 B view raw
run.sh β€” 188 B view raw
build.log β€” 9 B view raw
run.log β€” 876 B view raw
run.2.log β€” 884 B view raw
run.user.log β€” 798 B view raw
env.txt β€” 237 B view raw
fix.diff β€” 1.1 KB view raw
fix_build.log β€” 6.9 KB view raw
fix_run.log β€” 738 B view raw
VERDICT.md β€” 6.8 KB ↓ raw
verdict.json β€” 5.0 KB view raw
VERDICT.md
↓ download raw

DF-5380 β€” VERDICT

Finding: usb_quirk_get_entry() treats the all-zero key (vid|pid|lo_rev|hi_rev)==0 as "always return the LAST table slot" (sys/bus/u4b/quirk/usb_quirk.c:786-789) unconditionally β€” even after that slot has been allocated to a real dynamic entry β€” and the free-slot allocation path (usb_quirk.c:807-820) assigns vid/pid/lo_rev/hi_rev but never clears quirks[].

Reachability: /dev/usbctl β†’ usb_static_ioctl (usb_dev.c:1707-1711) β†’ usb_quirk_ioctl() USB_DEV_QUIRK_ADD/REMOVE (caps-gated, caps_priv_check_self(SYSCAP_NODRIVER) at usb_quirk.c:878/916 β€” verified EPERM for uid 1001, see run.user.log), and the boot-time kenv parser hw.usb.quirk.N β†’ usb_quirk_add_entry_from_str (usb_quirk.c:994-1060). Both surfaces are root-only.

Reproduced? YES β€” deterministically, both aliasing modes

Guest: DragonFly dfbsd 6.5-DEVELOPMENT #0: Thu Jul 2 06:02:54 UTC 2026 (usb + usb_quirk compiled into X86_64_GENERIC; /dev/usbctl 0644 root:operator).

The table boots with 338 static entries (counted from source: lines 92-587, commented Olympus entries excluded) β†’ 46 free slots (338..383).

  • Scenario B β€” stale quirk inherited by a real entry. 1. USB_DEV_QUIRK_ADD{0,0,0,0,"UQ_HID_IGNORE"} β†’ rc=0. The quirk parks in slot 383, which still looks free (vid|pid|lo|hi == 0 passes the free-scan test at usb_quirk.c:809-812). 2. 46 real ADDs (vid=0xA000+k, "UQ_CFG_INDEX_1"). The 46th allocates slot 383 without clearing quirks[]. 3. USB_DEV_QUIRK_GET shows entry[383]: vid=0xa02d carrying sub[0]=UQ_HID_IGNORE (never requested for that device) + sub[1]=UQ_CFG_INDEX_1. β†’ device 0xa02d/0001 would be IGNORED by uhid(4) had it been real hardware. (run.log, run.2.log: SCENARIO_B_REPRODUCED.)

  • Scenario A β€” all-zero ADD/REMOVE aliasing a live entry. With slot 383 holding the real entry, USB_DEV_QUIRK_ADD{0,0,0,0, "UQ_KBD_IGNORE"} β†’ rc=0 and the quirk is appended to the LIVE entry (sub[2]=UQ_KBD_IGNORE); the matching all-zero REMOVE strips it (run.log: SCENARIO_A_REPRODUCED). The REMOVE path can also memset the whole live entry free when its quirk list empties (usb_quirk.c:949-957).

Reproduced twice with identical results (deterministic, no race involved). PoC cleans up after itself; final dump verified slots 338..383 free again.

Impact

Dynamic quirk-table state corruption: a real device entry can silently acquire quirks nobody requested for it (UQ_HID_IGNORE, UQ_KBD_IGNORE, UQ_MSC_IGNORE disable class drivers for the device) or lose/free its quirks. No memory-safety consequence: all writes stay inside quirks[8] (indices bounded by the first-UQ_NONE scan at usb_quirk.c:901-906 / the exact-match REMOVE loop at usb_quirk.c:939-944); the stored codes are plain uint16_t compared by equality. Attacker = root only (verified EPERM for unprivileged). Severity: Low. impact field: none (no memory corruption, no independent unprivileged DoS/leak).

Fix validated on the guest

fix.diff (2 hunks, git apply --check clean against the read-only tree): 1. all-zero key returns the reserved last slot only while it is still free, else NULL (callers already handle NULL: ADD β†’ EINVAL, kenv β†’ "table full"); 2. allocation clears quirks[] so a slot can never inherit stale sub-quirks.

Applied to the guest's /usr/src (pre-patch source sha256 matched this tree), make nativekernel KERNCONF=X86_64_GENERIC (fix_build.log contains the -c .../usb_quirk.c -Werror line and the completion banner), installed, rebooted into #1: Sat Oct 3 20:32:25 UTC 2026, and the identical PoC re-run (fix_run.log):

[+] all-zero ADD UQ_HID_IGNORE -> rc=0 (ok)          <- still allowed while scratch free
  entry[383].sub[0]: UQ_CFG_INDEX_1                  <- STALE quirk GONE
[-] SCENARIO_B_NOT_REPRODUCED (vid=0xa02d stale=0 real=1)
[+] all-zero ADD UQ_KBD_IGNORE on live table -> rc=-1 (Invalid argument)   <- alias refused
[-] SCENARIO_A_NOT_REPRODUCED
[+] cleanup verified: slots 338..383 all free again

Normal quirk add/get/remove behavior unchanged (46 real ADDs all rc=0, GET correct, REMOVE cleanup works). fix_status: fixed.

Negative results (pass-2 candidates hunted and killed)

  • Non-NUL-terminated quirkname β†’ strcmp OOB read: mapped_ioctl (sys/kern/sys_generic.c:655-683) copies exactly 64 bytes into an unzeroed 128-byte stack buffer (ubuf.stkbuf); quirkname[50] is the last field, so a user CAN hand the kernel a non-NUL-terminated name. BUT strcmp(pgq->quirkname, usb_quirkstr(y)) (usb_quirk.c:884, :922) compares against constants ≀ 24 chars and stops at the constant's NUL after ≀ 25 bytes β€” never reaches offset 64. No OOB. (Same for the GET path β€” _IOWR copyin echoes only the user's own bytes back; no kernel leak.)
  • usb_strquirk quirk[len] OOB (usb_quirk.c:720-721): evaluated only when strncmp(str, quirk, len)==0; since str's segment is NUL-terminated at end (comma or EOS) and strncmp stops at the first NUL pair, the condition implies len == strlen(quirk) exactly β†’ quirk[len] reads at most the terminator. No OOB.
  • USB_DEV_QUIRK_GET index math (usb_quirk.c:847-851): x=index%8, y=index/8 with y>=384 β†’ EINVAL; uint16 index can't overflow. Safe.
  • Lock protocol: single private leaf lock (usb_quirk_lock), taken EXCLUSIVE on every reader (probe match usb_quirk.c:743, GET :852) and writer (ADD :894, REMOVE :932, kenv :1049); usb_quirk_get_entry asserts ownership (:784). No nesting β†’ no inversion. Safe.
  • Probe-path device data: usbd_lookup_info fields are only compared (==, <, >) β€” never used as indexes. Safe.
  • usb_quirk_strtou16 (usb_quirk.c:972-986): boot-time kenv only; error path derefs a valid in-string pointer; *pptr=end+1 only on a matchable separator. Safe.
  • envkey sizing (usb_quirk.c:1065): 16 bytes for "hw.usb.quirk." + 2 digits + NUL β€” exact fit. Safe.
  • Module-unload grace (usb_dynamic.c:102-114 tsleep(hz) + this file's usb_quirk_uninit :1088-1095): acknowledged XXX tradeoff; root-triggered (kldunload) with a 1s grace; usb_quirk is compiled into GENERIC here. Root cause lives in usb_dynamic.c (different file). Not filed.
  • DF-1047 (ADD/REMOVE name-lookup loop bound 384 vs 92) β€” known, not re-reported; its only reachable effect here ("UQ_UNKNOWN" stored as code 92) has no memory-safety consequence.

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

Applied fix.diff to the guest's /usr/src (pre-patch usb_quirk.c sha256 matched the audit tree), rebuilt with make nativekernel KERNCONF=X86_64_GENERIC (fix_build.log: patched usb_quirk.c compiled -Werror, build completed), installed, rebooted into kernel #1 Sat Oct 3 20:32:25 UTC 2026, re-ran the identical PoC: Scenario B no longer reproduces (slot 383 carries only UQ_CFG_INDEX_1, stale UQ_HID_IGNORE gone), all-zero ADD/REMOVE against the live table now fail with EINVAL instead of mutating it, and all 46 normal real-entry ADDs plus GET/REMOVE cleanup behave as before. Baseline (unpatched) reproduced both scenarios in the same run harness.

fix_run.log: 'entry[383].sub[0]: UQ_CFG_INDEX_1' with 'SCENARIO_B_NOT_REPRODUCED (vid=0xa02d stale=0 real=1)' and 'all-zero ADD UQ_KBD_IGNORE on live table -> rc=-1 (Invalid argument)'; fix_build.log: cc -c ... /usr/src/sys/bus/u4b/quirk/usb_quirk.c -Werror + '>>> Kernel build for X86_64_GENERIC completed on Sat Oct 3 21:12:00 UTC 2026'
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #1: Sat Oct 3 20:32:25 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Evidence (decisive lines)

run.log / run.2.log: 'SCENARIO_B_REPRODUCED: slot 383 holds real vid=0xa02d with STALE UQ_HID_IGNORE + UQ_CFG_INDEX_1' and 'SCENARIO_A_REPRODUCED: all-zero ADD appended UQ_KBD_IGNORE to LIVE entry'; run.user.log: unprivileged ADD/REMOVE all EPERM; fix_run.log (kernel #1 Sat Oct 3 20:32:25): entry[383].sub[0]=UQ_CFG_INDEX_1 only (stale gone), all-zero ADD on live table rc=-1 EINVAL; fix_build.log: patched usb_quirk.c compiled with -Werror, 'Kernel build completed'; VERDICT.md: full narrative + negative results.

PoC changes

No seed PoC existed (pass-2 finding authored during verification). quirk_alias.c discovers the first free slot dynamically (expects 338), fills all 46 free slots with distinct vids, then proves stale-quirk inheritance (Scenario B) and live-entry aliasing (Scenario A) via USB_DEV_QUIRK_GET dumps, and cleans up; struct usb_gen_quirk and the _IOW/_IOWR commands were re-declared locally to match usb_ioctl.h exactly (7x uint16 + quirkname[50] = 64 bytes, group 'Q' num 0-3).

Verified recommended fix

In usb_quirk_get_entry(): return the reserved last slot for the all-zero key only while it is still free (else NULL), and memset quirks[] to zero when allocating a free slot.

Verdict

usb_quirk_get_entry() returns the last dynamic-quirk slot unconditionally for the all-zero (vid|pid|lo|hi)==0 key and the slot-allocation path never clears quirks[], so the dynamic USB quirk table can be corrupted in two deterministic ways by a root ioctl (or boot kenv): (B) an all-zero ADD parks a quirk in the reserved slot while it still looks free, and that stale quirk is silently inherited by the real device entry that later allocates the slot (proven on guest: vid 0xa02d entry carried never-requested UQ_HID_IGNORE); (A) once the slot holds a real entry, all-zero ADD/REMOVE alias and mutate that live entry (proven: UQ_KBD_IGNORE appended to live vid 0xa02d; REMOVE can strip/free it). No memory-safety impact (all writes bounded inside quirks[8]); surface is root-only (verified EPERM for uid 1001). Severity Low. fix.diff validated on a rebuilt guest kernel: both scenarios gone, all-zero ADD against a live entry now EINVAL, normal add/get/remove unchanged.