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

ukbd unplug vs kbd_switch consumer lifetime race: kbd_unregister frees the keyboard under in-flight console consumers -> lockmgr on freed lock and kbdsw[NULL]->ioctl NULL-deref panic (reproduced)

Summary

ukbd_detach unregisters (kbdsw[index]=NULL + lockuninit) then the softc is freed with no fencing of console consumers holding keyboard_t: syscons fetches sc->kbd with no lock across fetch->dispatch, kbd_ioctl lockmgr()s the freed lock then dispatches (*kbdsw[kb_index]->ioctl) through the NULL'd slot. REPRODUCED LIVE on the guest: 6-thread console ioctl spam + QEMU device_del usb-kbd cycling -> 'Fatal trap 12 fault va 0x58 = offsetof(ioctl), Stopped at kbd_ioctl+0x73', guest down. Ceiling: kbdsw[OOB] wild indirect call via groomed freed-softc kb_index (documented, window tens of ns). Trigger also reachable via the DF-5316 unpriv ugen re-enumeration. kbdsw_check-guard-every-accessor fix (kbdsw.c) validated: >150 identical cycles zero panics; full fix needs keyboard refcounting. DF-5320 family, kbd flavor.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5324 Β· 16 files
FileTypeDescriptionSize
kbdspam.c β€” 1.2 KB view raw
kbdsetup.c β€” 804 B view raw
rebind.sh β€” 486 B view raw
race.sh β€” 528 B view raw
race2.sh β€” 628 B view raw
panic.txt β€” 2.1 KB view raw
run.log.baseline β€” 2.1 KB ↓ download
run.2.log β€” 0 B ↓ download
build.log β€” 1.2 KB view raw
fix.diff β€” 5.3 KB view raw
fix_build.log β€” 1.2 KB view raw
fix_run.log β€” 1.2 KB view raw
env.txt β€” 188 B view raw
README.md β€” 2.6 KB ↓ raw
VERDICT.md β€” 6.8 KB ↓ raw
verdict.json β€” 5.2 KB view raw

DF-5324 β€” ukbd unplug vs kbd_switch consumer lifetime race (NULL-deref panic)

File audited: sys/bus/u4b/input/ukbd.c (pass 2)

What this is

ukbd_detach() (ukbd.c:1370-1442) tears the keyboard down via kbd_unregister(), which clears kbdsw[kbd->kb_index], lockuninit()s kb_lock, and then the USB bus frees the whole ukbd_softc. Console-side consumers (syscons) hold the keyboard_t * across this: e.g. syscons.c:1431 (kbd_ioctl(sc->kbd, KDSETREPEAT, ...)) reads sc->kbd and then dispatches with no lock held across the fetch. The dispatch in kbdsw.c:203-216 does kbdsw[kbd->kb_index]->ioctl(...) blind β€” after unregister this is NULL->ioctl (offset 0x58) or, if the slab was already freed and reused, a wild function-pointer call.

Trigger model: USB keyboard unplug (physical, or usbconfig detach, or the unprivileged ugen USB_SET_CONFIG re-enumeration path proven in DF-5316) racing any concurrent syscons keyboard ioctl / internal kbd_ioctl (root console activity: kbdcontrol, stty, VT switch, LED update).

Reproduce

Guest booted with a boot-time USB keyboard so ukbd attaches for real:

QEMU_EXTRA="-device piix3-usb-uhci,id=uhci0 -device usb-kbd,id=k1" \
    dfbsd-qemu/vm.sh reset with-src

Build the two tools in the guest (root):

cc -O2 -pthread -o /tmp/kbdspam  /tmp/kbdspam.c
cc -O2 -o         /tmp/kbdsetup /tmp/kbdsetup.c

Arm: release ukbd0 from kbdmux and bind syscons to it directly (/tmp/rebind.sh = kbdsetup's KBRELKBD + kbdcontrol -k /dev/kbd2 < /dev/ttyv0), then start the spammer: /tmp/kbdspam 6 &.

Race: run race.sh (host side) β€” cycles QEMU monitor device_del k1 / device_add usb-kbd,id=k1 with per-cycle rebind.

Expected (baseline, unpatched 6.5-DEVELOPMENT #0)

Within ~100 cycles (~10 minutes) the guest dies:

Fatal user address access from kernel mode from kbdspam at ffffffff80b7d273
Fatal trap 12: page fault while in kernel mode
fault virtual address = 0x58
Stopped at kbd_ioctl+0x73: movq 0x58(%rax),%rax
db>

That is exactly kbdsw.c:210 ((*kbdsw[kbd->kb_index]->ioctl)(...)) with kbdsw[index] == NULL β€” 0x58 = offsetof(keyboard_switch_t, ioctl). Full serial-console capture: panic.txt.

Fix validation

fix.diff extends the defensive dispatch check that kbd_intr() (kbdsw.c:93-109) already uses to every accessor (kbdsw_check() helper: bounds + NULL check). Applied to the guest's /usr/src, kernel rebuilt (make nativekernel), installed, rebooted, same race workload re-run for

150 unplug cycles: no panic, console keeps working, stale ioctls return ENODEV. See fix_build.log / fix_run.log.

VERDICT.md
↓ download raw

DF-5324 VERDICT β€” REPRODUCED (panic / local DoS on USB keyboard unplug race)

Bottom line

The kbd_switch consumer lifetime race across ukbd unplug is real and was reproduced live on the QEMU guest as a kernel NULL-deref panic:

Stopped at kbd_ioctl+0x73: movq 0x58(%rax),%rax
fault virtual address = 0x58

kbd_ioctl is kbdsw.c:203-216; +0x73 is the dispatch (*kbdsw[kbd->kb_index]->ioctl)(kbd, cmd, data) with kbdsw[index] == NULL (cleared by kbd_unregister, kbd.c:282) β€” 0x58 is offsetof(keyboard_switch_t, ioctl) (11th pointer of the struct). The faulting process was kbdspam, the console-ioctl spammer, i.e. a syscons sc->kbd consumer that lost the race against device_del of the USB keyboard.

Root cause chain (path:line)

  1. sys/bus/u4b/input/ukbd.c:1370-1442 β€” ukbd_detach(): usbd_transfer_unsetup + usb_callout_drain, then kbd_unregister(&sc->sc_kbd) (ukbd.c:1426-1427) while holding UKBD_LOCK.
  2. sys/dev/misc/kbd/kbd.c:250-289 β€” kbd_unregister(): sends KBDIO_UNLOADING (syscons sets sc->kbd = NULL under syscons_lock, syscons.c:891-896), then keyboard[index]=NULL; kbdsw[index]=NULL (kbd.c:281-282), lockuninit(&kbd->kb_lock) (kbd.c:285). ukbd_detach returns; the USB devclass frees the ukbd_softc slab.
  3. A consumer thread that fetched sc->kbd before step 2 (e.g. syscons.c:1431 kbd_ioctl(sc->kbd, KDSETREPEAT, ...); also syscons.c:750-752 GIO_KEYMAPENT, syscons.c:885-886, update_kbd_state/save_kbd_state paths) holds no lock across the fetch; it proceeds to kbdsw.c:209-210 β†’ KBD_LOCK(kbd) = lockmgr on the lockuninit'ed / freed lock (a use-after-free WRITE), then kbdsw[kbd->kb_index]->ioctl reads kb_index from freed memory and dispatches through NULL (or a wild pointer if the slab was reused) β†’ panic.

There is no reference counting on keyboard_t; nothing fences in-flight kbd_switch consumers before the softc is freed. This is the DF-5320 (ucom/tty vs unplug) family, kbd flavor.

Reproduction (baseline)

  • Guest: DragonFly 6.5-DEVELOPMENT #0 Thu Jul 2 06:02:54 (X86_64_GENERIC, 6 vCPU), booted with -device piix3-usb-uhci -device usb-kbd; ukbd0 attached at boot, cdev /dev/kbd2 created (KBD_INSTALL_CDEV is in X86_64_GENERIC:40).
  • Bound syscons to ukbd0: KBRELKBD (release from kbdmux0) + kbdcontrol -k /dev/kbd2 < /dev/ttyv0 (tools: kbdsetup.c, rebind.sh).
  • 6-thread spam of KDSETREPEAT/KDSKBMODE/KDSETLED on /dev/consolectl and /dev/ttyv0 (kbdspam.c).
  • Host cycled QEMU monitor device_del k1 / device_add usb-kbd,id=k1 (~4-6 s/cycle) with per-cycle rebind (race.sh).
  • Panic hit on the 2nd batch, β‰ˆ45-100 total unplug events. Serial console capture in panic.txt (Fatal trap 12, RIP kbd_ioctl+0x73, FVA 0x58, CPUs stopped, ddb prompt; guest wedged β†’ vm.sh status β‡’ down).

Attempts: 2 armed batches (first batch partially un-armed due to a rebind timing bug β€” fixed with retries in rebind.sh β€” the hit occurred with rebind working).

Impact ceiling (why Medium, not just the observed panic)

Observed: deterministic NULL-deref panic (system halted in ddb) — local DoS. Ceiling: the same window first executes lockmgr(&kbd->kb_lock, LK_EXCLUSIVE) on freed slab memory (UAF write inside the freed ukbd_softc) and then reads kb_index from freed memory; with slab reuse (grooming via the same unprivileged ugen re-enumeration primitive proven in DF-5316 for the unplug side) kbdsw[garbage_index] is an OOB read of the static kbdsw[] followed by an indirect call through attacker-influenced memory → RIP control. Turning that into uid=0 requires winning the fetch→dispatch window against a groomed slab; not demonstrated here (the window is tens of nanoseconds wide; the NULL-deref manifestation is what fires in practice). Trigger-side realism: unplug is physical (or monitor/devctl); the unpriv ugen USB_SET_CONFIG path (DF-5316) can force the same detach without physical access; the ioctl victim thread can be any root console activity or syscons's own internal kbd_ioctl calls (VT switch / LED / key event servicing) — no attacker-owned thread needed on that side.

Fix validation

fix.diff (against sys/, never applied in sys/): adds kbdsw_check() to kbdsw.c β€” the bounds+NULL validation pattern kbd_intr() already uses (kbdsw.c:93-109) β€” applied at every accessor dispatch. Applied in the guest's /usr/src, make nativekernel + installkernel + reboot (6.5-DEVELOPMENT #1 Fri Oct 2 18:46:22), same workload re-run for >150 unplug/replug cycles across 3 batches: zero panics, guest up, console functional, stale ioctls return ENODEV.

Residual (documented in the diff header): kbdsw_check() closes the dispatch-through-NULL/cleared-slot path (the reproduced crash); the fully general fix is keyboard reference counting (kbd_get_keyboard/kbd_put_keyboard style) so the softc cannot be freed while a consumer holds the pointer β€” out of scope for a single-file hardening diff.

What was checked and killed in ukbd.c itself (negative results)

  • HID report parsing: hid_get_data/hid_put_data_unsigned are byte-bounded (rpos < len at usb_hid.c:669 and usb_hid.c:730) β†’ no descriptor-offset OOB read/write via sc_loc_* locations, regardless of malicious descriptor.
  • Events array loop ukbd.c:799-811: i clamped to UKBD_NKEYCODE and len; len clamped to UKBD_BUFFER_SIZE (ukbd.c:716-717); ID-byte strip keeps offset+len ≀ wMaxPacketSize β†’ sc_buffer accesses in-bounds.
  • LED path ukbd.c:941-956: sc_led_size clamped to 63, +1 ID byte = exact 64-byte fit; hid_put_data writes bounded to sc_buffer[1..63].
  • Keycode translation: KEY_INDEX masks to 0xFF (ukbd.c:245) against ukbd_trtab[256]; ukbd_key2scan guarded by code < 89 + NELEM(scan) (ukbd.c:2113) β†’ no table OOB.
  • Ring buffer ukbd_put_key/ukbd_get_key: head/tail wrapped after each bump, count-gated β†’ in-bounds.
  • ukbd_do_poll wait=1 infinite loop when GONE: dead code β€” no in-tree caller passes wait=1 to kbd_read_char (grep over sys/).
  • EVDEV paths: sc_evdev freed after transfers unsetup'd/callout drained; put_key cannot run post-free.
  • ukbd.c:1292 ukbd_ioctl(KDSETLED) at attach returns EDEADLK (lockowned check, ukbd.c:1974) β€” functional quirk, not security.
  • Residual benign defect (filed Info, DF-5325): ukbd_detach mutates sc_ndata/sc_odata and runs ukbd_interrupt() (ukbd.c:1391-1394) WITHOUT kb_lock while ukbd_timeout (holding kb_lock via callout_init_lk) may run concurrently β†’ torn input-ring accounting at unplug; in-bounds only.

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 (kbdsw.c kbdsw_check() at every accessor dispatch) applied to guest /usr/src, kernel rebuilt via make nativekernel, installed, rebooted (6.5-DEVELOPMENT #1 Fri Oct 2 18:46:22). Identical race workload re-run for >150 unplug/replug cycles across 3 batches: zero panics, guest up, console functional, stale-pointer ioctls return ENODEV. The reproduced NULL-deref crash is gone. Residual TOCTOU (freed-slab index read between check and call) documented in the diff header; complete fix needs kbd refcounting.

fix_run.log (patched-kernel batches, no panic), fix_build.log (build/install/reboot transcript), fix.diff (git-apply clean against sys/)
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #1: Fri Oct 2 18:46:22 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

unplug (physical / usbconfig / unpriv ugen USB_SET_CONFIG per DF-5316) -> ukbd_detach (ukbd.c:1370) -> kbd_unregister: kbdsw[i]=NULL + lockuninit(kb_lock) + softc freed; concurrent console ioctl thread that fetched sc->kbd (syscons.c:1431) resumes -> lockmgr on freed kb_lock (UAF write) -> kbdsw[kbd->kb_index] read from freed memory -> dispatch through NULL (observed panic) or through attacker-groomed slab content (kb_index garbage -> kbdsw[OOB] -> wild indirect call = RIP control; not demonstrated, window is tens of ns). Observed impact: reliable local panic/DoS of the console subsystem and whole machine.

Evidence (decisive lines)

panic.txt lines 40-59: 'Fatal user address access from kernel mode from kbdspam', 'fault virtual address = 0x58', 'Stopped at kbd_ioctl+0x73: movq 0x58(%rax),%rax', CPUs stopped at db> prompt. fix_run.log: >150 cycles on patched kernel, no panic. VERDICT.md: full path:line trace and negative results.

PoC changes

Original audit had no runnable PoC (finding derived in this run). Built from scratch: kbdspam.c (multi-thread console ioctl spam), kbdsetup.c (KBRELKBD release of ukbd0 from kbdmux0 + CONS_SETKBD bind), rebind.sh (per-cycle re-arm with retries after discovering ukbd is auto-claimed by kbdmux at attach), race.sh/race2.sh (QEMU monitor device_del/device_add cycling with armed-cycle counting). Learned: /dev/kbdN genkbd path is safe (holds kbd_token across kbd_ioctl) - the vulnerable consumer is the syscons path; ukbd must be un-claimed from the mux before syscons can bind it.

Verified recommended fix

Add kbdsw_check() dispatch validation (bounds + NULL) to all kbdsw.c accessors like kbd_intr() already does; full fix requires keyboard reference counting so the softc cannot be freed while consumers hold keyboard_t pointers.

Verdict

Reproduced live on the QEMU guest: cycling QEMU monitor device_del/device_add of a usb-kbd while syscons is bound to ukbd0 and a 6-thread root process spams console keyboard ioctls (KDSETREPEAT/KDSKBMODE/KDSETLED on /dev/consolectl and /dev/ttyv0) panics the kernel within ~100 unplug events: 'Fatal trap 12 ... fault virtual address = 0x58 ... Stopped at kbd_ioctl+0x73: movq 0x58(%rax),%rax' - exactly kbdsw.c:210 (*kbdsw[kbd->kb_index]->ioctl)() with kbdsw[index]==NULL after ukbd_detach->kbd_unregister (kbd.c:282) cleared the slot and freed the softc while the syscons consumer (syscons.c:1431 fetch of sc->kbd, no lock across fetch->dispatch) was in flight. Guest halted in ddb (CPUs stopped). Baseline kernel 6.5-DEVELOPMENT #0; patched kernel (fix.diff: kbdsw_check() dispatch validation extended from the existing kbd_intr() pattern) survived >150 identical unplug cycles under load with zero panics.