HID report-descriptor Push/Pop items drive the parser's pushlevel out of range; hid_get_item() re-derives c = &s->cur[pushlevel] out of bounds on the next call β kernel heap read/write with device-chosen offset and content
Summary
Push increments pushlevel unconditionally (only the struct copy is gated) and Pop decrements with no underflow check on the uint8: >=4 Push or one Pop@0 leaves pushlevel 4..255 while the function returns items and re-derives c=&s->cur[pushlevel] past the 4-entry array - writes (hid_clear_local 48-byte zero, arbitrary 32-bit dvals, flags, collevel) and 100-byte reads at device-chosen offsets +424..+25524 past the ~1KB kmalloc'd hid_data, content chosen by descriptor bytes. Every HID driver attach probe loops this over the verbatim device-supplied descriptor. Guest-proven via KLD harness on production symbols: (1) deterministic chosen-offset/chosen-content CROSS-OBJECT write into a neighbouring live heap chunk (guard at +1152 hit at exactly the predicted offset), (2) far write read back at +24.4KB, (3) 40-round hammer -> Fatal trap 12 at hid_get_item+0x83 (OOB hid_clear_local via cur[255]) round ~32. Two-guard fix validated on rebuilt kernel: 0/9 guards, far-write in-bounds, hammer clean, guest up.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-5406 Β· 14 files| File | Type | Description | Size | |
|---|---|---|---|---|
| hidrig.c | β | 13.3 KB | view raw | |
| Makefile | β | 122 B | β download | |
| build.sh | β | 137 B | view raw | |
| run.sh | β | 238 B | view raw | |
| build.log | β | 5.7 KB | view raw | |
| run.log | β | 3.0 KB | view raw | |
| run.hammer.log | β | 439 B | view raw | |
| panic.txt | β | 669 B | view raw | |
| fix.diff | β | 645 B | view raw | |
| fix_run.log | β | 2.6 KB | view raw | |
| env.txt | β | 254 B | view raw | |
| VERDICT.md | β | 7.6 KB | β raw | |
| manifest.json | β | 1.8 KB | view raw | |
| verdict.json | β | 6.6 KB | view raw |
DF-5406 VERDICT β REPRODUCED (kernel heap OOB read/write via HID descriptor push/Pop level corruption; Fatal trap 12 inside hid_get_item)
Bottom line
A malicious USB device's report descriptor drives the HID parser's item
stack level (pushlevel) out of range; on the next hid_get_item() call
the parser derives its working pointer out of bounds (c =
&s->cur[s->pushlevel], usb_hid.c:248, cur[MAXPUSH=4] in a 1088-byte
kmalloc object) and then both writes device-supplied 32-bit values
and reads back 100-byte items through it. Reproduced three ways on the
QEMU guest against the production in-kernel functions (KLD harness calling
the kernel's own hid_start_parse/hid_get_item symbols):
- deterministic cross-object write into a neighbouring live heap object at a pre-computed offset with pre-chosen content (guard scan),
- controlled 32-bit write + read-back at +24,436 bytes past the object
(
cur[255], single Pop byte), - kernel panic:
Fatal trap 12, supervisor write data, page not present, Stopped at hid_get_item+0x83: movq $0,0x58(%r10)β the OOB zero-write ofc->loc.sizethroughc = cur[255]β after ~32 hammer rounds with filesystem churn. Guest wedged in DDB βvm.sh status= down.
Root cause chain (path:line)
sys/bus/u4b/usb_hid.c:88βuint8_t pushlevelinstruct hid_data(state is per-hid_start_parse, private, no locking needed β the bug is arithmetic, not races).:430-443 case 10 Pushβs->pushlevel++unconditional; only the copy tos->cur[s->pushlevel]is gated by< MAXPUSH. Four or more Push items leavepushlevel β [4..255]while parsing continues and items are returned (:278top-block return,:377Collection,:390End collection).:444-461 case 11 Popβs->pushlevel--unconditional; one Pop at level 0 wraps 0 β 255 (uint8_t), theelsebranch merely prints. Parsing continues with the still-validc, and the next Input item returns.- Re-entry: every caller loops
hid_get_item()βhid_locate(:617),hid_report_size(:571),hid_is_collection(:751),hid_is_mouse(:869), and all HID drivers at attach (ukbd.c:1070+, ums.c:468+, uhid.c:784+, wsp.c:733, ucycom.c:243-245, uaudio.c:6110+). At:248,c = &s->cur[pushlevel]with pushlevel β [4..255] β OOB. - Sinks through the OOB
c: -:289 hid_clear_local(c)β 12 fields zeroed (48 bytes), -:343/:369/:380 c->flags = dval,:345-346 c->loc.count/size,:402-405 logical_minimum/maximum = dval(arbitrary 32-bit, 4-byte items),:399 _usage_page = dval<<16,:374 collevel++,:147 report_IDviahid_switch_rid, -:274/:376/:389 *h = *cβ 100-byte OOB read into the caller's stackhid_item. - Threat: the report descriptor is fetched verbatim from the device
(
usbd_req_get_hid_desc, :801-843) and parsed at attach β a malicious USB device (or crafted device handed to the victim) controls every descriptor byte.
Primitive geometry (guest-verified, mode 0): sizeof(struct hid_data) =
1088, offsetof(cur) = 24, sizeof(struct hid_item) = 100 β index k
writes at s + 24 + 100k (+4..+52 zeroed, +0/_usage_page, +4/+8
arbitrary dvals, β¦), k β [4..255] chosen by Push/Pop bytes β offsets
+424..+25524, object ends at +1088. Same-zone neighbour chunks sit at
1152-byte stride (measured).
Reproduction (baseline, unpatched kernel #0 Thu Jul 2 06:02:54)
- Guard proof (deterministic cross-object corruption): harness
mode 3βhid_start_parse()a zeroed 600-byte buffer (parser chunk now live and known), allocate same-size-class guards until one lands above it (measured delta 1152 = adjacent chunk), compute k socur[k].logical_minimum(+24+100k+4) lands mid-guard, write the Push-spam trigger into the descriptor buffer the parser is anchored to (it has consumed nothing β exactly the device-attacker position), drivehid_get_item():
hidrig: live parser chunk s=0xfffff80089703200 (size 1088), 9 guards
hidrig: target guard[7]=0xfffff80089703680 delta=1152 above s
hidrig: target index k=16: marker 0xc0de0010 at s+1628 = guard[7]+476
hidrig: firing 30-byte descriptor
hidrig: parse done, 1 items returned
hidrig: GUARD 7 base=0xfffff80089703680 +0x1dc: 0xc0de0010 <-- CROSS-OBJECT CORRUPTION
The marker appears at the exact predicted guard offset (+0x1DC). (Earlier runs 0/24, 0/48: heap geometry β solved by allocating the target guard after the live parser chunk and rewriting the descriptor in place; no heap luck remains.)
- Far write + read-back (
mode 1phase A): descriptorB4(Pop @ 0) -
item bytes; the second
hid_get_item()call writes 0xFEED0001/0xFEED0002 atcur[255].logical_minimum/maximum(s+25528/s+25532) and the trailing Input item copies them back:item 2: logmin=0xfeed0001β OOB write and OOB read round-trip at +24.4 KB. -
Panic (hammer): 40 rounds of mode-1 (far write + free-list sweep + allocation churn) while two
findwalks generate kernel allocation traffic. Round ~32:Fatal trap 12, FVA 0xfffff80123ac008c,supervisor write data, page not present,Stopped at hid_get_item+0x83: movq $0,0x58(%r10)β the inlinedhid_clear_local()zero store throughc = cur[255]hitting an unmapped page. CPUs stopped, DDB, ssh dead,vm.sh statusβ down. (panic.txt, run.hammer.log.)
Exploit-chain assessment
The primitive is a relative kernel heap write: chosen element index
(byte-offset 24+100k, k device-chosen β [4..255]), chosen 32-bit content
(logical/physical min/max, unit, string_, designator_ β full dval via
4-byte items; plus fixed zeroing of 12 fields and _usage_page dval<<16),
repeatable per parse pass, plus a 100-byte OOB read surfaced in driver
stack items. At real attach time the parser shares M_TEMP/~1-2KB size
classes with driver softc-adjacent allocations; overwriting a neighbour's
pointer/refcount/function-pointer field is the natural next step (the
guard run proves neighbour co-location at +1152 and exact-offset hits).
On this intentionally unhardened guest (no KASAN; no SMAP/SMEP/KASLR) the
remaining work is grooming victim objects at device-attach time β
mechanical for an attacker controlling the descriptor and the device
timing (multiple attach/detach cycles). Physical/USB vector, hence
High/CERT-metric 7.6 rather than Critical.
Fix validation
fix.diff: reject Push when pushlevel >= MAXPUSH-1, reject Pop when
pushlevel == 0 (nesting levels 1..3 preserved β all that ever worked).
Applied inside the guest /usr/src, make -j6 nativekernel
KERNCONF=X86_64_GENERIC (BUILD_RC=0), make installkernel, reboot into
kernel #1 Sun Oct 4 08:32:42 UTC 2026. Identical harness + workload:
- mode 3: same k=16 trigger β
guard scan done: 0/9 guards corrupted(baseline: 1/9 at +0x1DC), parse completes; - mode 1: churn completes, no trap;
- the full 40-round hammer + dual
findchurn:round 1..40 done,HAMMER_ALL_DONE, guest up, no trap, no dmesg anomalies.
Baseline behavior (guard corruption, panic) is GONE on the patched kernel.
Attempts
7 trigger configurations (guard-placement strategy iterated 5Γ: batch guards, LIFO-reuse, descending-magazine adaptation, live-parser+in-place descriptor write β final: deterministic; then far-write, then hammerβpanic).
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
fixedfix.diff applied to guest /usr/src, nativekernel rebuilt (BUILD_RC=0), installed, rebooted; identical harness and 40-round hammer workload: guard corruption gone (0/9 vs 1/9 at +0x1DC), far-write parses in-bounds, all hammer rounds complete, guest up with no traps -- the corrupted/panicking behavior is gone on the patched kernel.
findings/poc/DF-5406/fix.diff; findings/poc/DF-5406/fix_run.log (patched-kernel run transcript); baseline comparison in run.log/panic.txt
Confirmed kernel references
Detail
Exploit chain
malicious USB device supplies report descriptor -> usbd_req_get_hid_desc fetch (usb_hid.c:801-843) -> driver attach probe loops hid_get_item (ukbd.c:1070+, ums.c:468+, uhid.c:784+, wsp.c:733) -> N Push bytes (or one Pop byte) leave pushlevel in [4..255] while an item returns -> next call: c = &cur[pushlevel] OOB (usb_hid.c:248) -> hid_clear_local zeros 48 bytes (inlined; the faulting instruction hid_get_item+0x83 'movq $0,0x58(%r10)') -> subsequent Global items write device-chosen 32-bit dvals at cur[k]+{0,4,8,...} (logical/physical min/max, unit, string_, designator_) and h = c reads 100 OOB bytes into the driver stack -> chosen-offset (s+24+100k, k=4..255 -> +424..+25524), chosen-content kernel heap write into neighbouring ~1-2KB M_TEMP-class objects (guard run proves exact-offset hits into the adjacent chunk at +1152); with device-attach grooming this is a neighbour-object pointer/refcount/function-pointer overwrite -> kernel code execution on an unhardened guest (no SMAP/SMEP/KASLR). uid0 chain not weaponized end-to-end: trigger requires a malicious USB device (physical vector), so the deliverable here is the corruption primitive + panic, fully characterized.
Evidence (decisive lines)
findings/poc/DF-5406/run.log (guard proof: 'GUARD 7 base=0xfffff80089703680 +0x1dc: 0xc0de0010 <-- CROSS-OBJECT CORRUPTION'; far write: 'item 2: logmin=0xfeed0001'), panic.txt ('Fatal trap 12 ... Stopped at hid_get_item+0x83: movq $0,0x58(%r10)'), run.hammer.log (40-round driver, died ~32), fix_run.log (patched kernel: 0/9 guards, HAMMER_ALL_DONE, guest up), build.log, env.txt
PoC changes
Built a KLD harness (hidrig.c) that calls the guest kernel's own compiled-in hid_start_parse/hid_get_item symbols (no source copy); duplicated the file-private struct hid_data/MAXPUSH definition verbatim (usb_hid.c:67-95) for sizeof/offsetof math; iterated guard placement 5x (batch guards around freed parsers failed 0/24 and 0/48 due to magazine allocation order; final design: hid_start_parse a zeroed buffer FIRST so the chunk is live, allocate same-size-class guards after it until one sits above (measured +1152), compute index k from measured deltas, then write the trigger descriptor into the same buffer in place before driving the parser -- fully deterministic). Modes: 0=geometry, 3=guard proof, 1=far write cur[255] + allocator churn; panic captured via 40-round hammer with concurrent find/ churn.
Verified recommended fix
Reject Push when pushlevel >= MAXPUSH-1 and Pop when pushlevel == 0 so pushlevel stays in [0,3] and the per-call c = &s->cur[pushlevel] re-derivation (usb_hid.c:248) is always in bounds (fix.diff).
Verdict
REPRODUCED: HID report-descriptor Push/Pop items drive the parser's uint8_t pushlevel out of [0,MAXPUSH) (unconditional ++/-- at usb_hid.c:431/:445); the next hid_get_item() call re-derives c = &s->cur[pushlevel] (usb_hid.c:248) with pushlevel in [4..255] and writes/reads through it past the 1088-byte kmalloc hid_data object. Proven on the guest against the production kernel symbols: deterministic cross-object write into an adjacent live heap chunk at the pre-computed offset (guard[7]+0x1DC = 0xc0de0010), arbitrary 32-bit write + 100-byte read-back at +24,436 bytes (cur[255], single Pop byte), and a kernel panic (Fatal trap 12, supervisor write, page not present, 'Stopped at hid_get_item+0x83: movq $0,0x58(%r10)' -- the OOB hid_clear_local zero of c->loc.size) after ~32 hammer rounds with filesystem churn; guest wedged in DDB and went down. Reachable by any malicious USB device at HID-driver attach (descriptor fetched verbatim by usbd_req_get_hid_desc and parsed by hid_locate/hid_report_size/driver loops). Fix (guard Push at MAXPUSH-1, guard Pop at 0) validated on a rebuilt kernel: same trigger corrupts nothing (0/9 guards), 40-round hammer completes, guest stays up.
No comments yet.