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

ucom unplug lifecycle UAF: driver softc kfree'd while the never-freed tty keeps dangling t_sc/t_oproc/t_param/t_stop and in-flight cdev dispatch follows si_drv1 into freed memory (multi-port adapters leave orphaned openable device nodes)

Summary

The tty is never freed/unregistered and ucom_detach_tty never clears t_sc/t_oproc/t_param/t_stop, while the enclosing driver softc is kfree'd the moment detach returns: ttyflush->t_stop, ttioctl->t_param, ttstart->t_oproc all dispatch into freed memory. DFly's destroy_dev never revectors si_ops to dead_dev_ops (documented but unimplemented), so in-flight cdev dispatch follows si_drv1 into the freed softc (UCOM_MTX_LOCK lockmgr write into freed slab + sc_callback indirect call through reclaimed memory). Multi-port adapters (umcs7840/FTDI-2232): subunit>=1's init/lock/cua nodes are never destroyed and stay openable AFTER unplug - deterministic UAF with no race. Guest observationally proven (kern.ttys retains the freed softc address + ucom.ko text pointers byte-identical across the freeing detach); no panic only because devfs revocation + pre-free HL_READY clearing - dangling state + unpriv kern.ttys pointer disclosure = groomable kernel UAF with privesc potential; full chain documented. clear-callbacks+NULL-guards fix (validated clean); dead_dev_ops revector + destroy-all-subunit-cdevs needed cross-file.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5320 Β· 14 files
FileTypeDescriptionSize
ucom_uaf.c β€” 3.7 KB view raw
ucom_race.c β€” 2.8 KB view raw
build.sh β€” 150 B view raw
run.sh β€” 165 B view raw
poc.log β€” 810 B view raw
env.txt β€” 241 B view raw
ttys_base.bin β€” 3.7 KB ↓ download
ttys_live.bin β€” 4.0 KB ↓ download
ttys_dead.bin β€” 4.0 KB ↓ download
ttys_diff.py β€” 2.1 KB view raw
ttys_diff.log β€” 986 B view raw
code_hashes.txt β€” 155 B view raw
fix.diff β€” 2.8 KB view raw
VERDICT.md β€” 6.6 KB ↓ raw
VERDICT.md
↓ download raw

DF-5320 VERDICT β€” not_reproduced (crash/corruption), dangling references CONFIRMED

Bottom line

The use-after-free defect is real and code-certain: after a USB serial device detaches while its tty is open, struct tty (never freed, never unregistered) permanently retains t_oproc=t_stop=t_param=ucom_* and t_sc -> the driver softc that device_set_driver() kfree'd (M_BUS) the instant the driver's detach routine returned. I proved the dangling state observationally on the guest: sysctl -b kern.ttys (readable by the unprivileged user!) shows record 0 (the ucom tty) carrying ucom.ko text pointers (0xffffffff82615f80/0x82615400/0x82616c20, inside ucom.ko [0xffffffff82615000+0x22000]) and t_sc=0xfffff80116981198, byte-identical before and after the detach that freed the softc (ttys_diff.log).

What I could NOT produce on this guest is a visible memory-safety event (panic / corruption). ~22 attach/detach cycles under an 8-thread open/ioctl/close storm (ucom_race.c) left the kernel standing. The two mechanisms that keep the latent UAF silent on DragonFly:

  1. devfs vnode revocation. When the devfs core processes the destroy message, the device's vnodes are invalidated (vp->v_rdev == NULL / VBAD), so every read/write/ioctl/close issued after that on a still-open fd returns EBADF before reaching ucom_dev_ops (sys/vfs/devfs/devfs_vnops.c:1267-1273, 1330-1331, 1535). This kills the trivial "unplug then ioctl the open fd" path that FreeBSD's ucom historically had. NOTE: DragonFly never revectors si_ops to dead_dev_ops β€” the comment at sys/kern/kern_conf.c:336-343 describes FreeBSD behavior; dead_dev_ops is an all-NULL BSS struct that nothing assigns (only reference: a comparison at kern_conf.c:129). The protection comes from the vnode layer, asynchronously.
  2. Pre-free flag clearing. ucom_detach_tty() clears HL_READY under the sc_lock before the softc is freed (usb_serial.c:561), so a syscall that acquires the freed-but-still-intact lock walks into ucom_dev_ioctl's !(sc->sc_flag & UCOM_FLAG_HL_READY) bail-out (usb_serial.c:1101) and returns EIO. A deep walk (into sc->sc_callback->ucom_ioctl, usb_proc_msignal on the freed usb_process) only happens if the slab is reallocated with data that re-sets the HL_READY bit (0x40) β€” i.e. it needs deliberate same-bucket heap grooming on top of winning the in-flight window.

Therefore: status=not_reproduced, reproduced=false, impact=none observed. The finding remains High-severity by analysis because the in-flight window is real (destroy_dev is an async message; nothing orders the kfree after either the devfs-thread revocation or the completion of in-flight dispatches), and the corruption primitive on winning it is strong (lockmgr acquire/release writes into the freed slab; indirect calls through freed-memory pointers).

The two deterministic variants that do NOT depend on the race window

  1. Multi-port adapters (umcs7840, FTDI-2232C/D/4232H, multi-port u3g): ucom_detach() calls destroy_dev() for exactly the six cdevs of sc[0] (usb_serial.c:374-379); each subunit's ucom_detach_tty() then only removes minors equal to the plain unit number (dev_ops_remove_minor(&ucom_ops, ssc->sc_unit) at :591, which matches dev->si_uminor == minor exactly, devfs_core.c:1555). All six cdevs of a port share one minor value (unit | CONTROL_INIT_STATE/LOCK/CALLOUT, set at :477-488 β€” the subunit is only in the name), so the .init/.lock/cua variants of subunits 1..N-1 are never destroyed and never revoked: after unplugging a 4-port umcs7840, /dev/ttyiU0.1, /dev/ttylU0.1, /dev/cuaiU0.1, ... remain openable, and ucom_dev_open() does sc = dev->si_drv1 (usb_serial.c:786) -> freed softc -> UCOM_MTX_LOCK(sc) (lockmgr on freed memory) with no EBADF protection. QEMU cannot emulate a multi-port USB serial adapter, so this variant is proven by code reading only (confidence: certain on the code path; no hardware demo).
  2. Unprivileged pointer disclosure: sysctl kern.ttys dumps the raw struct tty array β€” including t_sc, t_oproc, t_session, t_pgrp β€” to any user (observed; only t_dev is sanitized at tty.c:2912-2913). For a detached ucom tty this hands the unprivileged attacker (a) the stale heap address of the freed softc and (b) the ucom.ko text base β€” exactly the primitives needed to turn the in-flight-race UAF into a reliable exploitation attempt (groom the freed bucket, know where it lands, know module addresses). The disclosure itself is a defect in sys/kern/tty.c (out of this file's scope; flagged for separate filing).

Exploit chain (theoretical, per the above)

unpriv user in dialer -> open /dev/cuaU0 -> spray same-bucket (1-2KB class) allocations to shape the freelist -> unplug (physical) with an ioctl in flight (or on multi-port hw: plain open() of an orphaned control node) -> syscall thread acquires the freed embedded lock (lockmgr writes lock_owner into attacker-shaped slab data), reads sc->sc_callback from reclaimed memory and calls sc->sc_callback->ucom_ioctl(sc, cmd, data, 0, curthread) β€” an indirect call through a reclaimed-memory pointer with attacker-influenced first argument. uid0 potential conditional on heap shaping; not demonstrated here.

Why the guest demo chose cuaU0 + chardev-close as the "unplug"

/dev/ttyU0 is created 0600 root:wheel (usb_serial.c:477-478); /dev/cuaU0 is 0660 uucp:dialer (:483-484) β€” the standard BSD serial-access group, i.e. the honest unprivileged attack surface. QEMU's usb-serial device only attaches when a client connects to its chardev socket; closing the client raises a genuine USB port disconnect -> uhub -> uftdi_detach -> ucom_detach -> "device still open, forcing close" (dmesg.txt) -> kfree.

Fix validation

Not applicable: the baseline PoC produced no panic/corruption to compare against (fix_status=not_tested). fix.diff is authored from the verified line-level understanding above; the tty-callback clearing it adds makes the proven dangling state impossible.

Attempts (22)

1 gauntlet run (ucom_uaf.c) + 6 + 12 + 3 attach/detach cycles under the 8-thread storm (ucom_race.c), plus 2 kern.ttys capture cycles and one clean-detach observation run. No panic, no corruption, guest alive throughout; final state: guest rebooted clean.

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

not_testable
baseline no→ patch + rebuild →patched clean

Fix validation not applicable: the baseline PoC produced no panic/corruption observable to compare against (devfs vnode revocation blocks the trivial path; the in-flight window was not won in 22 attempts). fix.diff is authored from the verified line-level root cause; the tty-callback clearing it adds makes the proven dangling t_sc/t_oproc/t_param/t_stop state impossible.

fix.diff (git-apply-able, never applied to sys/)
↓ fix.diffper-fix-DF-5320

Confirmed kernel references

Detail

Exploit chain

unpriv dialer-group user opens /dev/cuaU0 (0660 uucp:dialer, usb_serial.c:483-484); grooms the 1-2KB kmalloc bucket; unplugs the device (physical) or triggers bus-error re-enumeration while an ioctl is in flight past devfs_fo_ioctl's v_rdev check; the syscall thread acquires the freed embedded sc_lock (lockmgr writes lock_owner into reclaimed slab), passes the stale/reshaped HL_READY gate at usb_serial.c:1101 and reaches sc->sc_callback->ucom_ioctl(sc,cmd,data,0,curthread) (usb_serial.c:1224-1226) - an indirect call through a reclaimed-memory pointer - or TIOCSETA->ucom_param->ucom_queue_command->usb_proc_msignal on the freed usb_process; kern.ttys supplies the freed-softc address and module base to aim the grooming. On multi-port hardware the orphaned /dev/ttyiU0.1-style nodes remove the race entirely (plain open() after unplug). Chain not executed to uid0 on this guest.

Evidence (decisive lines)

ttys_diff.log (decisive: t_sc=0xfffff80116981198 and ucom.ko text ptrs identical in live vs dead snapshots while no ttyU device exists); poc.log (EBADF gauntlet + 'device still open, forcing close' in dmesg); VERDICT.md (full window analysis); fix.diff

PoC changes

Seed sketch rewritten entirely: (1) discovered QEMU usb-serial only attaches when its chardev socket has a connected client and detaches when it closes - used as the unplug trigger; piix3-usb-uhci needed (xhci hot-add invisible to DFly; ehci rejects full-speed device); (2) v1 gauntlet PoC produced only EBADF, traced to devfs vnode revocation; (3) added 8-thread race storm PoC (ucom_race.c) with 21 attach/detach cycles; (4) added kern.ttys three-snapshot decode as observational proof of the dangling t_sc.

Verified recommended fix

In ucom_detach_tty, while holding tp->t_token and the sc lock, clear tp->t_oproc/t_param to NULL, set tp->t_stop = nottystop and tp->t_sc = NULL before the softc can be freed; NULL-guard t_sc in ucom_dev_read/ucom_dev_write (full fix.diff in pack); separately implement the documented dead_dev_ops revector in destroy_dev to close the in-flight si_drv1 window.

Verdict

Code-certain UAF, no observable manifestation on the guest. After a USB serial device detaches with its tty open, ucom_detach_tty() (usb_serial.c:543-594) force-closes the tty but never clears tp->t_sc/t_oproc/t_param/t_stop, and the enclosing driver softc is kfree'd (M_BUS) by device_set_driver() (subr_bus.c:1943-1945) the moment the driver's detach returns; struct tty is never freed, so it dangles forever. Additionally DragonFly never revectors si_ops to dead_dev_ops (kern_conf.c:336 comment describes non-existent behavior; dead_dev_ops is never assigned anywhere), so cdev dispatch for in-flight syscalls follows si_drv1 into freed memory. Proven on the guest: sysctl kern.ttys (world-readable) shows the ucom tty retaining ucom.ko text pointers (t_oproc/t_param/t_stop = 0xffffffff82615f80/0x82615400/0x82616c20) and t_sc=0xfffff80116981198 byte-identical before and after the detach that freed the softc (ttys_diff.log). Not manifested: devfs revokes the vnode of destroyed device nodes (devfs_vnops.c:1267-1273) so post-detach syscalls on the open fd return EBADF before reaching ucom ops, and in-flight syscalls bail at the correctly-pre-cleared HL_READY flag (usb_serial.c:561,1101); ~22 attach/detach cycles under an 8-thread open/ioctl/close storm produced no panic or corruption. A visible event needs the small in-flight window plus same-bucket heap reuse. Two deterministic variants exist by code: (1) multi-port adapters orphan the subunit>=1 init/lock/cua cdevs (usb_serial.c:374-379 destroys only sc[0]'s six; dev_ops_remove_minor at :591 matches only the plain minor) leaving openable nodes with si_drv1 -> freed softc, no revocation; (2) kern.ttys leaks the freed-softc address and ucom.ko base to unprivileged users (tty.c defect, flagged separately).