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)
PoC verification
Evidence pack
findings/poc/DF-4297 Β· 15 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 ingenkbdread()(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):
- Blocked reader never woken β sleeps on a freed pointer forever until signalled.
- Write-after-free β on signal, the EINTR path at kbd.c:756-759 executes
sc->gkb_flags &= ~KB_ASLEEPon the freed (and, in our run, already re-allocated) chunk. Demonstrated byte-exact: groom pattern0x55555555became0x55555554at the freed address. - 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_unregisterdeliversKBDIO_UNLOADINGtogenkbd_eventwhose registeredargis the freed sc;genkbd_eventrunsKNOTE(&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).
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:
kbd_uafopens the freshly-minted/dev/kbd2and blocks inread(2)βpsshowsSTAT S2 WCHAN kbdrea(genkbdread tsleep, kbd.c:750). At this pointgkb_flags == 0x00000001(KB_ASLEEP set by the sleeping reader).hw.kbdkill.run=1βkbd_detach(kbd)(the identical call ukbd_detach makes):si_drv1=0xfffff8008bb98180freed, cdev minor removed β the reader is still asleep on the freed address (psagain: stillkbdrea). The helper immediately re-allocates the 536-byte chunk and fills it with0x55; the first groom chunk lands at the same address (first @ 0xfffff8008bb98180).hw.kbdkill.snap=1: freed scgkb_flags == 0x55555555.kill -USR1the reader: userland seesread(2) returned -1 errno=4(EINTR) β proof it executed the kbd.c:756-759 error path.hw.kbdkill.check2=1: freed scgkb_flags == 0x55555554β bit 0 (KB_ASLEEP) cleared inside a live, re-owned kernel object at the freed address: write-after-free, exactly thesc->gkb_flags &= ~KB_ASLEEPstatement at kbd.c:757 operating on freed memory.- Holder close β vkbd termination β
kbd_unregisterβKBDIO_UNLOADINGdelivered togenkbd_event(kbd, event, arg=sc)with the staleargregistered bykbd_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 atdb>.
Root cause line map:
- kbd.c:597-603 β
kbd_detachfreesdev->si_drv1with noKBD_IS_BUSYcheck, nowakeup, no knote purge, no callback release. - kbd.c:744-761 β
genkbdreadsleeps onscand only revalidateskbd(still valid:kbd_detachdoesn't unregister), neversi_drv1. - kbd.c:756-759 β EINTR path performs the stale write.
- kbd.c:267-278 (
kbd_unregister) βkb_callback.kc_func(KBDIO_UNLOADING)withkc_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
fixedfix.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).
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.
No comments yet.