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)
PoC verification
Evidence pack
findings/poc/DF-5324 Β· 16 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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.
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)
sys/bus/u4b/input/ukbd.c:1370-1442βukbd_detach():usbd_transfer_unsetup+usb_callout_drain, thenkbd_unregister(&sc->sc_kbd)(ukbd.c:1426-1427) while holdingUKBD_LOCK.sys/dev/misc/kbd/kbd.c:250-289βkbd_unregister(): sendsKBDIO_UNLOADING(syscons setssc->kbd = NULLundersyscons_lock, syscons.c:891-896), thenkeyboard[index]=NULL; kbdsw[index]=NULL(kbd.c:281-282),lockuninit(&kbd->kb_lock)(kbd.c:285). ukbd_detach returns; the USB devclass frees theukbd_softcslab.- A consumer thread that fetched
sc->kbdbefore step 2 (e.g. syscons.c:1431kbd_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 tokbdsw.c:209-210βKBD_LOCK(kbd)=lockmgron the lockuninit'ed / freed lock (a use-after-free WRITE), thenkbdsw[kbd->kb_index]->ioctlreadskb_indexfrom freed memory and dispatches throughNULL(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_unsignedare byte-bounded (rpos < lenat 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:
iclamped to UKBD_NKEYCODE and len;lenclamped 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_pollwait=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
fixedfix.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/)
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.
No comments yet.