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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5406 Β· 14 files
FileTypeDescriptionSize
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
VERDICT.md
↓ download 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):

  1. deterministic cross-object write into a neighbouring live heap object at a pre-computed offset with pre-chosen content (guard scan),
  2. controlled 32-bit write + read-back at +24,436 bytes past the object (cur[255], single Pop byte),
  3. 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 of c->loc.size through c = cur[255] β€” after ~32 hammer rounds with filesystem churn. Guest wedged in DDB β†’ vm.sh status = down.

Root cause chain (path:line)

  1. sys/bus/u4b/usb_hid.c:88 β€” uint8_t pushlevel in struct hid_data (state is per-hid_start_parse, private, no locking needed β€” the bug is arithmetic, not races).
  2. :430-443 case 10 Push β€” s->pushlevel++ unconditional; only the copy to s->cur[s->pushlevel] is gated by < MAXPUSH. Four or more Push items leave pushlevel ∈ [4..255] while parsing continues and items are returned (:278 top-block return, :377 Collection, :390 End collection).
  3. :444-461 case 11 Pop β€” s->pushlevel-- unconditional; one Pop at level 0 wraps 0 β†’ 255 (uint8_t), the else branch merely prints. Parsing continues with the still-valid c, and the next Input item returns.
  4. 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.
  5. 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_ID via hid_switch_rid, - :274/:376/:389 *h = *c β€” 100-byte OOB read into the caller's stack hid_item.
  6. 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 so cur[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), drive hid_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 1 phase A): descriptor B4 (Pop @ 0)
  • item bytes; the second hid_get_item() call writes 0xFEED0001/0xFEED0002 at cur[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 find walks 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 inlined hid_clear_local() zero store through c = 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 find churn: 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

fixed
baseline reproduced→ patch + rebuild →patched clean

fix.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
↓ fix.diffper-fix-DF-5406

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.