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

evdev driver teardown frees evdev_dev under open clients and in-flight writes: use-after-free with cross-object event injection and kernel wedge

Summary

evdev_unregister/evdev_free revoke clients and kfree(struct evdev_dev) without draining open fds or in-flight writes; dev_ops_remove_minor is async (no destroy_dev draining), so dtor locks freed memory, a multi-MB write keeps injecting through the dangling pointer (evdev=si_drv1 cached at entry, ec_revoked never re-checked), and open races the free across a sleeping kmalloc. VERIFIED on guest with -device usb-kbd: detach mid-write then re-attach -> events written to the deleted fd appeared on the NEW device's reader (cross-object injection, tagged values), 64MB write returned success through the dangling pointer, 6-cycle churn hard-wedged the guest (vCPU livelock). Fix validated on rebuilt kernel: zero cross-injection over 4 rounds + churn, writes abort cleanly, keystroke delivery unregressed. Requires root-held fd + device removal (physical unplug while Xorg holds node, or kldunload) - corruption/DoS, not unpriv escalation. Refcount + master-lock + per-event revoke-check fix.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-4454 Β· 13 files
FileTypeDescriptionSize
evdev_uaf.c β€” 4.9 KB view raw
build.sh β€” 114 B view raw
run.sh β€” 169 B view raw
build.log β€” 290 B view raw
run.log β€” 3.1 KB view raw
serial-full.log β€” 15.0 KB view raw
wedge_serial_tail.txt β€” 2.9 KB view raw
fix_iter1_panic.txt β€” 1.4 KB view raw
fix.diff β€” 6.1 KB view raw
fix_run.log β€” 1.8 KB view raw
env.txt β€” 348 B view raw
VERDICT.md β€” 5.8 KB ↓ raw
verdict.json β€” 5.8 KB view raw
VERDICT.md
↓ download raw

DF-4454 VERDICT β€” evdev client/evdev lifetime UAF across driver teardown

Status: reproduced. Impact: kernel memory corruption via dangling `struct evdev_dev *' (demonstrated cross-object write) + system wedge (DoS). Confidence: certain.

Root cause (line-accurate)

evdev_unregister() (sys/dev/misc/evdev/evdev.c:366-406) revokes all clients and destroys the cdev, but nothing waits for the open file descriptors (each holding a struct evdev_client' withclient->ec_evdev' pointing back at the evdev) to go away. evdev_free() (evdev.c:113-121) then immediately `kfree(evdev, M_EVDEV)'.

Three use-after-free arms:

  1. evdev_dtor (cdev.c:160-173): every later close(2) of a stale fd executes `EVDEV_LOCK(client->ec_evdev)' β€” lockmgr on freed memory.
  2. evdev_write (cdev.c:233-265): evdev' is cached at syscall entry and the event loop never re-checksclient->ec_revoked', so an in-flight multi-megabyte write keeps calling `evdev_inject_event()' on the freed struct for the whole buffer (uiomove sleeps make the window seconds wide).
  3. evdev_open (cdev.c:98-158): evdev = dev->si_drv1' is read before a sleepingkmalloc(M_WAITOK)'; the re-check under EVDEV_LOCK operates on an evdev that may already be freed (the comment "Avoid race with evdev_unregister" is insufficient β€” the lock itself is in freed memory).

Contrast with FreeBSD, whose evdev_unregister' usesdestroy_dev()', which drains open references before the caller proceeds to free. The DragonFly port replaced that with `dev_ops_remove_minor()' (cdev.c:733-742 β†’ kern_device.c:649 β†’ devfs async message), which does NOT wait.

Reproduction on the audit guest

Guest: DragonFly 6.5-DEVELOPMENT #0 Thu Jul 2 06:02:54 UTC 2026 (X86_64_GENERIC), QEMU/KVM. ukbd (compiled in, EVDEV_SUPPORT) attached via -device piix3-usb-uhci -device usb-kbd.

Sequence: open /dev/input/event4 β†’ start 8-64 MB write of {EV_KEY,KEY_Q,v}/{EV_SYN,SYN_REPORT,0x1337} pairs β†’ QEMU monitor device_del kbd1' (ukbd_detach β†’ evdev_free, write still in flight) β†’device_add usb-kbd' (new evdev_dev re-uses the freed block) β†’ reader on the re-created event4.

Observed (see run.log):

  • 3 of 3 well-timed rounds delivered magic events to the NEW device's client ring (19, 5, 2 events with value=0x1337) β€” cross-object event injection through the dangling pointer. Nobody wrote 0x1337 to the new fd.
  • Full 64 MB write(2) returned success although the device was deleted mid-call (~2.79M events injected through freed/reused memory).
  • One in-flight write failed with EINVAL once the reallocated evdev's flag bitmap changed under the stale loop β€” direct evidence the loop was reading foreign memory.
  • 6-cycle device_del/device_add churn with a 256 MB write in flight: hard system wedge β€” ssh unresponsive, serial console silent (wedge_serial_tail.txt), QEMU at 87-394 %CPU (vCPU livelock on corrupted lockmgr state). The guest recovered only after several minutes.
  • Closing stale fds (dtor arm) exercised lockmgr-on-freed-memory; on the baseline kernel this stayed silent (freed block retains stale lock values), it is the same UAF write primitive.

Why impact is not uid=0

Opening /dev/input/eventN requires passing `caps_priv_check_self(SYSCAP_RESTRICTEDROOT)' (cdev.c:110) β€” i.e. root β€” and the teardown trigger is a privileged unload or physical USB unplug. So this is a root/physical-triggered kernel UAF, not an unprivileged escalation. The demonstrated primitive is a stale-fd write into re-allocated kernel memory (attacker-controlled {type,code,value} triples against whichever object re-uses the block; M_EVDEV size class is shared by all evdev providers and client rings), plus lockmgr corruption of freed slab memory on close. Realistic ceiling on default configs: kernel memory corruption / hard DoS from device removal while a root compositor holds the node.

Fix validation

fix.diff (authored after reproduction; applied to a guest /usr/src copy, kernel rebuilt with `make nativekernel KERNCONF=X86_64_GENERIC', installed, rebooted β€” kernel md5 54c1a462e7f1c6bc303929ac0cd99ac3):

  • refcount ev_refs' on evdev_dev: 1 alloc ref (dropped in evdev_free), 1 cdev ref (dropped under the newevdev_master_lock' when si_drv1 is NULLed in evdev_unregister), 1 ref per open fd (dropped in evdev_dtor); kfree deferred to the last unref.
  • `evdev_master_lock' (master β†’ state lock order) closes the evdev_open TOCTOU: si_drv1 is published/cleared and ref'd under it.
  • evdev_write re-checks `client->ec_revoked' every event.
  • evdev_dtor drops its reference before kfree(client)'. Two earlier fix iterations of mine were themselves buggy (the compiler sinks theclient->ec_evdev' load past kfree β€” UB β€” causing NULL unref panics); documented in fix_iter1_panic.txt. Lesson recorded for the maintainer: keep the unref strictly before the client free.

Patched-kernel results (fixrun3/4/5/fixstress):

  • in-flight writes abort at the first revoke (ENODEV, or EBADF from the devfs layer on the detached cdev) β€” no injection through freed memory;
  • zero magic events on the re-created device across 4 rounds + the identical 6-cycle churn that wedged the baseline β€” guest stayed up;
  • stale fds close with no panic; the dtor runs against reference-pinned memory;
  • functional smoke test: QEMU `sendkey a/b' events flow to a reader on event4 unchanged (correct 24-byte input_event records).

fix_status: fixed (baseline_reproduced=1, patched_reproduced=0).

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

Validated on a rebuilt+rebooted guest kernel: the exact PoC that delivered 2-19 magic events per round on baseline delivered ZERO across 4 rounds on the patched kernel; in-flight writes abort with ENODEV (revoked re-check) or EBADF (devfs detached cdev) instead of running to completion through freed memory; the identical 6-cycle detach/attach churn that hard-wedged the baseline left the patched guest fully responsive; stale-fd closes are clean; keystroke delivery is unregressed (sendkey smoke test). Two earlier iterations of the fix itself panicked in evdev_dtor (compiler-sunk load past kfree) and are documented in fix_iter1_panic.txt.

fix_run.log (patched-kernel runs fixrun3/4/5/fixstress: UAF-NOT-CONFIRMED, magic_hits=0, guest up; smoke-test hexdump of KEY_A down/up); run.log + wedge_serial_tail.txt (baseline: UAF-CONFIRMED, wedge); fix.diff (v3); fix_iter1_panic.txt
↓ fix.diffDragonFly 6.5-DEVELOPMENT #0 (patched build, /boot/kernel/kernel md5 54c1a462e7f1c6bc303929ac0cd99ac3)

Confirmed kernel references

Detail

Exploit chain

root fd on /dev/input/event4 (or physical unplug while Xorg holds it) -> ukbd_detach -> evdev_unregister (clients revoked, not drained) -> evdev_free kfree's evdev_dev -> (a) in-flight write(2) continues evdev_inject_event() on freed memory for the whole buffer; (b) re-attach re-allocates the same M_EVDEV block -> stale writer injects attacker-chosen {type,code,value} events into the new device (demonstrated with value=0x1337) and races lockinit on the block -> lockmgr state corruption -> kernel livelock/wedge (demonstrated); (c) close(2) of stale fd -> evdev_dtor EVDEV_LOCK on freed memory. No unprivileged entry path on default config.

Evidence (decisive lines)

run.log (assembled guest outputs: 'UAF-CONFIRMED ... magic_hits=19/5/2', writer_bytes=67108848 writer_err=0 through a deleted device); wedge_serial_tail.txt + serial-full.log (baseline 6-cycle churn wedge); fix_run.log + fix_iter1_panic.txt (patched-kernel validation incl. two documented buggy fix iterations); evdev_uaf.c/build.sh/run.sh (reproduce); fix.diff (validated fix); env.txt

PoC changes

Fixed the seed PoC concept into a runnable program: rounded the write buffer down to whole input_events (24 B) or the kernel rejects with EINVAL at cdev.c:252; added an event stream that survives sparse filtering ([KEY_Q down/up][SYN_REPORT MAGIC] pairs); final version opens the reader with O_NONBLOCK (baseline runs used a blocking reader since events were flowing); added writer errno/byte accounting and magic detection. Host-side orchestration (device_del/device_add racing the write) driven via the QEMU monitor socket since the guest cannot hot-remove its own USB device.

Verified recommended fix

Reference-count evdev_dev (alloc/cdev/per-open refs, final kfree deferred to last unref), serialize si_drv1 publish/teardown behind a global evdev_master_lock so evdev_open cannot lock freed memory, and re-check client->ec_revoked every event in the evdev_write loop (fix.diff)

Verdict

Reproduced on the QEMU guest (DragonFly 6.5-DEVELOPMENT X86_64_GENERIC, ukbd on UHCI). Driver teardown (ukbd_detach -> evdev_free) frees struct evdev_dev while fds are open and a write(2) is in flight; the in-flight write kept injecting through the dangling pointer (full 64 MB write returned success), and after the freed block was re-allocated by a re-attached keyboard, magic-tagged events written to the DELETED device's fd appeared on the NEW device's reader (19/5/2 hits across rounds) - a demonstrated cross-object kernel write. The 6-cycle detach/attach churn hard-wedged the guest (ssh dead, serial silent, vCPUs spinning at 394% CPU). Trigger requires a root-held fd (evdev_open enforces SYSCAP_RESTRICTEDROOT; nodes are root:input) plus device removal (physical unplug / kldunload), so this is a root/physical-triggered kernel UAF with corruption+DoS impact, not an unpriv->root chain. Fix.diff v3 (ev_refs refcounting + evdev_master_lock serialization of si_drv1 publication/teardown + per-event revoked re-check in evdev_write + unref-before-kfree in the dtor) validated on a rebuilt kernel: writes abort with ENODEV/EBADF at revoke, zero cross-injection across 4 rounds and the identical churn stress, clean stale-fd closes, keystroke delivery unregressed.