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

kbd_detach frees the live genkbd_softc under open /dev/kbdN descriptors: blocked reader never woken, write-after-free at :757, fatal KNOTE-on-freed-memory trap via stale kb_callback.kc_arg at :905

Summary

kbd_detach kfrees dev->si_drv1 with no KBD_IS_BUSY check, no wakeup of readers blocked in the tsleep, no knote purge, and no release of the client callback whose kc_arg is that softc. A subsequent signal drives the EINTR path's flag-clear into freed (re-allocated) memory, and a later kbd_unregister delivers KBDIO_UNLOADING to genkbd_event with the stale sc, whose KNOTE walks a knote list inside freed memory - observed as Fatal trap 9 at knote+0x2c. This is the exact sequence a USB keyboard unplug performs. LIVE-REPRODUCED on the guest via the vkbd path (fresh keyboard mint, blocked reader S2/kbdrea, detach+re-alloc KLD, byte-exact 0x55555555->0x55555554 mutation into a live object, deterministic panic); fix validated on a rebuilt kernel (reader wakes ENXIO, chunk unchanged, no panic). Quiesce-before-free + re-fetch-si_drv1 fix.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-4297 Β· 15 files
FileTypeDescriptionSize
kbdkill.c β€” 8.8 KB view raw
kbd_uaf.c β€” 1.4 KB view raw
kbdrel.c β€” 1.1 KB view raw
kbdprobe.c β€” 696 B view raw
build.sh β€” 151 B view raw
run.sh β€” 121 B view raw
build.log β€” 5.6 KB view raw
run.log β€” 970 B view raw
run.fix.log β€” 4.3 KB view raw
panic.txt β€” 970 B view raw
env.txt β€” 551 B view raw
fix.diff β€” 1.6 KB view raw
README.md β€” 4.5 KB ↓ raw
VERDICT.md β€” 4.9 KB ↓ raw
verdict.json β€” 4.9 KB view raw

DF-4297 β€” kbd.c genkbd_softc lifetime bug on kbd_detach (stuck reader + write-after-free + panic)

What

sys/dev/misc/kbd/kbd.c kbd_detach() (lines 581-607) frees the cdev's si_drv1 (genkbd_softc, ~536 bytes, M_DEVBUF) while the /dev/kbdN file descriptor that owns it may still be open:

  • no KBD_IS_BUSY() check,
  • no wakeup() of readers blocked in genkbdread() (tsleep "kbdrea", kbd.c:750),
  • no purge/invalidations for the kqueue knote list embedded in the sc (sc->gkb_rkq.ki_note, kbd.c:70),
  • no release of the client callback registered by kbd_allocate() (kb_callback.kc_arg == sc, kbd.c:402-403).

Consequences (all demonstrated):

  1. Blocked reader never woken β€” sleeps on a freed pointer forever until signalled.
  2. Write-after-free β€” on signal, the EINTR path at kbd.c:756-759 executes sc->gkb_flags &= ~KB_ASLEEP on the freed (and, in our run, already re-allocated) chunk. Demonstrated byte-exact: groom pattern 0x55555555 became 0x55555554 at the freed address.
  3. Kernel panic (UAF read) β€” when the keyboard is later unregistered (holder closes / driver terminates, exactly the ukbd unplug sequence: ukbd_detach β†’ kbd_detach, ukbd.c:1408, then term β†’ kbd_unregister), kbd_unregister delivers KBDIO_UNLOADING to genkbd_event whose registered arg is the freed sc; genkbd_event runs KNOTE(&sc->gkb_rkq.ki_note, 0) (kbd.c:905) walking a knote list inside freed memory β†’ Fatal trap 9: general protection fault at knote+0x2c.

Threat model

Reaching genkbdread requires opening /dev/kbdN (0600 root:wheel, plus caps_priv_check_self(SYSCAP_RESTRICTEDROOT) in genkbdopen, kbd.c:672), and kbd_detach requires driver detach β€” module unload, or a physical USB keyboard unplug (ukbd_detach calls kbd_detach unconditionally while the keyboard stays registered for replug). So the trigger is privileged or physical-console; impact is kernel memory corruption / panic β‡’ Low severity, memory-corruption class.

Build (in guest, as root)

cd /root/kbduaf
cc -O -o kbd_uaf kbd_uaf.c
cc -O -o kbdrel kbdrel.c
cc -O -o kbdprobe kbdprobe.c
# module needs kbdreg.h next to the source (uses -I.)
cp /usr/src/sys/dev/misc/kbd/kbdreg.h .
printf 'KMOD=kbdkill\nSRCS=kbdkill.c\n\n.include <bsd.kmod.mk>\n' > Makefile
make
kldload vkbd            # mint fresh keyboards
kldload ./kbdkill.ko    # detach/groom/check helper module

kbdkill.ko does exactly what a driver detach does (kbd_detach(kbd)), plus detection sysctls: hw.kbdkill.index, .run (kbd_detach + immediate re-alloc of the freed chunk), .snap (gkb_flags at the freed address, pre-signal), .check2 (ggb_flags at the freed address, post-signal).

Run (in guest, as root)

sh /root/kbduaf/run.sh

The script: 1. pre-cleans stale vkbd children from kbdmux (KBRELKBD via /dev/ttyv0), 2. mints a fresh vkbd keyboard (hold /dev/vkbdctl open) β†’ new /dev/kbdN, releases it from kbdmux, 3. runs kbd_uaf < /dev/kbdN which blocks in read(2) (kbdrea), 4. sysctl hw.kbdkill.run=1 β†’ kbd_detach() frees the sc and immediately re-allocates the chunk filled with 0x55, 5. sysctl hw.kbdkill.snap=1 β†’ freed sc gkb_flags == 0x55555555, 6. kill -USR1 the reader β†’ its EINTR path clears bit0 of the freed/re-owned object β†’ hw.kbdkill.check2=1 prints 0x55555554, 7. closes the vkbd holder β†’ kbd_unregister β†’ KBDIO_UNLOADING β†’ KNOTE on the freed sc β†’ kernel panic (serial console: Fatal trap 9 ... knote+0x2c).

Expected output

See run.log / panic.txt (serial console capture). Key lines:

kbdkill: run: before kbd_detach: kbd2 (vkbd0) si_drv1=0xfffff8008bb98180 gkb_flags=0x00000001
kbdkill: run: kbd_detach done - genkbd_softc freed at 0xfffff8008bb98180 ...
kbdkill: run: ... groomed 256 x 536 chunks (first @ 0xfffff8008bb98180)   <- same address
kbdkill: snap:  freed sc gkb_flags = 0x55555555 (pre-signal)
kbdkill: check2: freed sc gkb_flags now 0x55555554  <- WRITE-AFTER-FREE
Fatal trap 9: general protection fault while in kernel mode
Stopped at      knote+0x2c:     movq    0x30(%r15),%rbx

With fix.diff applied (patched kernel), the same script shows: the reader waking at detach (read returned -1 errno=6 No such device or address), the freed chunk staying 0x55555555, and no panic at holder close.

Fix

fix.diff β€” release the busy callback, KNOTE, wakeup(), then free; readers re-fetch dev->si_drv1 after every wakeup and bail ENXIO. Validated by rebuild + re-run (see VERDICT.md).

VERDICT.md
↓ download raw

DF-4297 VERDICT β€” genkbd_softc lifetime bug in kbd_detach (sys/dev/misc/kbd/kbd.c)

Bottom line

REPRODUCED on the stock guest (kernel 6.5-DEVELOPMENT #0 Thu Jul 2 2026, snapshot clean-source): the bug gives (1) a permanently-blocked reader after driver detach, (2) a byte-exact write-after-free into a re-allocated heap object, and (3) a fatal kernel trap (panic) through the stale kb_callback.kc_arg β†’ genkbd_event β†’ KNOTE path. The full chain is triggered by nothing more than the sequence a real driver detach performs (ukbd_detach β†’ kbd_detach, ukbd.c:1408), exercised by a root-loaded helper module that makes exactly that call, plus an ordinary open/read/close of /dev/kbdN and a vkbd console keyboard.

Reproduction narrative (baseline, unpatched)

Serial-console evidence in run.log / panic.txt:

  1. kbd_uaf opens the freshly-minted /dev/kbd2 and blocks in read(2) β€” ps shows STAT S2 WCHAN kbdrea (genkbdread tsleep, kbd.c:750). At this point gkb_flags == 0x00000001 (KB_ASLEEP set by the sleeping reader).
  2. hw.kbdkill.run=1 β†’ kbd_detach(kbd) (the identical call ukbd_detach makes): si_drv1=0xfffff8008bb98180 freed, cdev minor removed β€” the reader is still asleep on the freed address (ps again: still kbdrea). The helper immediately re-allocates the 536-byte chunk and fills it with 0x55; the first groom chunk lands at the same address (first @ 0xfffff8008bb98180).
  3. hw.kbdkill.snap=1: freed sc gkb_flags == 0x55555555.
  4. kill -USR1 the reader: userland sees read(2) returned -1 errno=4 (EINTR) β€” proof it executed the kbd.c:756-759 error path. hw.kbdkill.check2=1: freed sc gkb_flags == 0x55555554 β€” bit 0 (KB_ASLEEP) cleared inside a live, re-owned kernel object at the freed address: write-after-free, exactly the sc->gkb_flags &= ~KB_ASLEEP statement at kbd.c:757 operating on freed memory.
  5. Holder close β†’ vkbd termination β†’ kbd_unregister β†’ KBDIO_UNLOADING delivered to genkbd_event(kbd, event, arg=sc) with the stale arg registered by kbd_allocate (kbd.c:402-403) β†’ KNOTE(&sc->gkb_rkq.ki_note, 0) (kbd.c:905) walks a knote list embedded in the freed-and-rewritten chunk β†’ Fatal trap 9: general protection fault, Stopped at knote+0x2c: movq 0x30(%r15),%rbx (r15 = 0x5555555555555555-derived garbage kn pointer). Guest dies at db>.

Root cause line map:

  • kbd.c:597-603 β€” kbd_detach frees dev->si_drv1 with no KBD_IS_BUSY check, no wakeup, no knote purge, no callback release.
  • kbd.c:744-761 β€” genkbdread sleeps on sc and only revalidates kbd (still valid: kbd_detach doesn't unregister), never si_drv1.
  • kbd.c:756-759 β€” EINTR path performs the stale write.
  • kbd.c:267-278 (kbd_unregister) β†’ kb_callback.kc_func(KBDIO_UNLOADING) with kc_arg = freed sc β†’ kbd.c:898-907 (genkbd_event) β†’ KNOTE UAF read β†’ panic.

Why the guest-panic is honest

The helper module only performs kbd_detach() β€” a public function every keyboard driver calls on detach β€” under the same conditions (cdev open, keyboard registered) a USB keyboard unplug produces. The groom/check sysctls are read/write probes on the freed chunk; the panic trigger (step 5) is the kernel's own kbd_unregister β†’ genkbd_event path with no module involvement.

Impact classification

panic (kernel memory corruption, privileged/physical trigger). Not an unprivileged escalation: opening /dev/kbdN requires SYSCAP_RESTRICTEDROOT (kbd.c:672) and the detach requires driver detach (module unload = root, or physically unplugging a USB keyboard on a console with /dev/kbdN open).

Fix validation

fix.diff (this pack): kbd_detach releases the busy callback first (kbd_release), KNOTEs and wakeup()s the sleepers, then frees; genkbdread re-fetches dev->si_drv1 after every wakeup and returns ENXIO once it is NULL. Applied inside the guest's /usr/src, kernel rebuilt (make nativekernel KERNCONF=X86_64_GENERIC, kernel #1 Sat Sep 19 06:32:43), rebooted, identical PoC re-run (run.fix.log):

observation baseline (kernel #0) patched (kernel #1)
reader after kbd_detach still S2 kbdrea (never woken) wakes immediately, read returned -1 errno=6
freed chunk after signal 0x55555555 -> 0x55555554 (write-after-free) 0x55555555 unchanged
holder close (kbd_unregister) Fatal trap 9 at knote+0x2c, guest dead "still alive (no panic)", guest up

Fix status: fixed. Guest reset to the clean-source snapshot afterwards.

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, kernel rebuilt via make nativekernel KERNCONF=X86_64_GENERIC (kernel #1 Sat Sep 19 06:32:43), rebooted, identical run.sh re-executed: reader now wakes at detach with ENXIO (errno=6) instead of sleeping on the freed pointer, the freed-and-groomed chunk stays 0x55555555 (stale write gone), and the holder-close kbd_unregister path no longer panics (guest alive). All three baseline symptoms eliminated.

run.fix.log (patched run: 'reader gone' at step 3, 'read(2) returned -1 errno=6', 'gkb_flags now 0x55555555 ... unchanged', 'still alive (no panic)', uname -v shows kernel #1) versus run.log/panic.txt (baseline: stuck kbdrea, 0x55555554, Fatal trap 9 knote+0x2c).
↓ fix.diffper-fix-DF-4297

Confirmed kernel references

Detail

Exploit chain

root opens fresh /dev/kbdN (vkbd minted via /dev/vkbdctl clone + KBRELKBD from kbdmux) -> reader blocks in genkbdread tsleep(kbdrea) with gkb_flags=KB_ASLEEP and kb_callback.kc_arg=sc registered by kbd_allocate -> driver detach path kbd_detach(kbd) frees sc with no busy check/wakeup/note purge -> (a) reader stuck on freed pointer; (b) SIGUSR1 wakes reader: kbd still valid so EINTR path write-after-frees bit0 at freed address into re-owned object; (c) closing the vkbd holder -> kbd_unregister -> KBDIO_UNLOADING -> genkbd_event -> KNOTE on freed klist -> fatal trap 9 kernel panic.

Evidence (decisive lines)

run.log/panic.txt: serial console with 'si_drv1=... gkb_flags=0x00000001', 'groomed 256 x 536 chunks (first @ <same address>)', 'snap: 0x55555555', 'check2: 0x55555554', 'Fatal trap 9 ... Stopped at knote+0x2c'. run.fix.log: patched kernel wakes reader (errno=6), chunk stays 0x55555555, no panic.

PoC changes

Seed concept fully reworked: (1) all kbdN cdevs are KBD_IS_BUSY (syscons owns kbdmux, mux owns atkbd), so a fresh unclaimed keyboard is minted by holding a /dev/vkbdctl clone open (vkbd_dev_open registers + kbd_attach) and released from the mux via KBRELKBD through /dev/ttyv0; (2) the trigger is a KLD that performs exactly the kbd_detach() call ukbd_detach makes, plus groom(0x55)/snap/check2 detection sysctls; (3) SIGUSR1 with no SA_RESTART forces the EINTR stale-write path instead of a read restart; (4) groom pattern 0x55 (bit0 set) because the stale write is an AND-mask that only clears bit0.

Verified recommended fix

kbd_detach: if KBD_IS_BUSY release the client callback (kbd_release), KNOTE the sc knote list, wakeup(sc), then kfree; genkbdread: re-fetch dev->si_drv1 after every tsleep wakeup and return ENXIO when NULL (see fix.diff).

Verdict

REPRODUCED on the stock clean-source guest. kbd_detach (kbd.c:597-603) frees the cdev's genkbd_softc while a /dev/kbdN reader sleeps on it inside genkbdread (tsleep kbdrea, kbd.c:750): the reader is never woken (stuck across detach, proven via ps), and when signalled its EINTR path executes sc->gkb_flags &= ~KB_ASLEEP (kbd.c:757) on the freed chunk - demonstrated byte-exact by re-allocating the freed 536-byte chunk with 0x55 and observing 0x55555555 -> 0x55555554. When the keyboard is subsequently unregistered (holder close, the same sequence a ukbd unplug takes via ukbd.c:1408 kbd_detach then term->kbd_unregister), KBDIO_UNLOADING is delivered to genkbd_event with the stale kb_callback.kc_arg and KNOTE(&sc->gkb_rkq.ki_note,0) (kbd.c:905) walks a knote list inside the freed memory -> Fatal trap 9 general protection fault, Stopped at knote+0x2c; guest dies. Trigger requires an open /dev/kbdN (root-only: 0600 + SYSCAP_RESTRICTEDROOT, kbd.c:672) plus driver detach (module unload or physical USB unplug), hence Low severity memory-corruption/panic, not an unprivileged escalation.