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)
PoC verification
Evidence pack
findings/poc/DF-5380 Β· 13 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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 == 0passes the free-scan test atusb_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_GETshowsentry[383]: vid=0xa02dcarryingsub[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 alsomemsetthe 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. BUTstrcmp(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 β_IOWRcopyin echoes only the user's own bytes back; no kernel leak.) usb_strquirkquirk[len]OOB (usb_quirk.c:720-721): evaluated only whenstrncmp(str, quirk, len)==0; sincestr's segment is NUL-terminated atend(comma or EOS) and strncmp stops at the first NUL pair, the condition implieslen == strlen(quirk)exactly βquirk[len]reads at most the terminator. No OOB.USB_DEV_QUIRK_GETindex math (usb_quirk.c:847-851):x=index%8,y=index/8withy>=384 β EINVAL; uint16 index can't overflow. Safe.- Lock protocol: single private leaf lock (
usb_quirk_lock), taken EXCLUSIVE on every reader (probe matchusb_quirk.c:743, GET:852) and writer (ADD:894, REMOVE:932, kenv:1049);usb_quirk_get_entryasserts ownership (:784). No nesting β no inversion. Safe. - Probe-path device data:
usbd_lookup_infofields 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+1only on a matchable separator. Safe.envkeysizing (usb_quirk.c:1065): 16 bytes for"hw.usb.quirk." + 2 digits + NULβ exact fit. Safe.- Module-unload grace (
usb_dynamic.c:102-114tsleep(hz) + this file'susb_quirk_uninit:1088-1095): acknowledgedXXX tradeoff; root-triggered (kldunload) with a 1s grace; usb_quirk is compiled into GENERIC here. Root cause lives inusb_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
fixedApplied 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'
Confirmed kernel references
- sys/bus/u4b/quirk/usb_quirk.c:786
- sys/bus/u4b/quirk/usb_quirk.c:788
- sys/bus/u4b/quirk/usb_quirk.c:807
- sys/bus/u4b/quirk/usb_quirk.c:815
- sys/bus/u4b/quirk/usb_quirk.c:895
- sys/bus/u4b/quirk/usb_quirk.c:933
- sys/bus/u4b/quirk/usb_quirk.c:949
- sys/bus/u4b/quirk/usb_quirk.c:1050
- sys/bus/u4b/usb_dev.c:1707
- sys/kern/sys_generic.c:655
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.
No comments yet.