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

Use-after-free of the UVC control graph in uvc_query_v4l2_ctrl()/uvc_query_v4l2_menu(): detach frees sc->ctrl before destroy_dev, giving a racing unprivileged VIDIOC_QUERYCTRL/QUERYMENU a 1-byte write into freed kernel heap plus a 31-byte-per-query kernel-heap disclosure

Summary

uvc_drv_detach frees the entire control graph with no lock while the 0666 /dev/videoN cdev is still alive (destroy_dev comes 4 lines later): an unpriv user holding the node open issues QUERYCTRL/QUERYMENU during unplug/self-disconnect and the production handlers run on freed chunks - lockmgr on freed ctrl, STAILQ walk of freed topo_nodes, a 1-byte write (cached_sub_info_idx) into a freed ~184B M_UVC chunk, and strlcpy of freed sub_info->v4l2_name into the user-visible field (31B reclaimed-heap per query, repeatable). KLD-harness-verified (production sources compiled in): controlled same-address reclaim of all 11 chunks demonstrated the exact-offset write 0xAA->0x01 and both disclosure paths (31x0x5A/0x5B markers); uncontrolled variant panics (Fatal trap 9, rax=0x5a5a...). Distinct from DF-2353 (sc->data, format paths). destroy_dev-first + NULL-out-under-lock + ENODEV guards + locked destroy fix validated (ENODEV, guest up).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5378 Β· 16 files
FileTypeDescriptionSize
README.md β€” 2.9 KB ↓ raw
VERDICT.md β€” 6.9 KB ↓ raw
uvcharness.c β€” 19.3 KB view raw
Makefile β€” 151 B ↓ download
build.sh β€” 163 B view raw
run.sh β€” 212 B view raw
build.log β€” 1.3 KB view raw
fix_build.log β€” 846 B view raw
run.log β€” 2.0 KB view raw
fix.log β€” 3.1 KB view raw
panic.txt β€” 750 B view raw
leak_sample.txt β€” 744 B view raw
env.txt β€” 241 B view raw
fix.diff β€” 2.9 KB view raw
manifest.json β€” 1.8 KB view raw
verdict.json β€” 6.9 KB view raw

DF-5378 β€” Use-after-free of the UVC control graph in uvc_query_v4l2_ctrl()/uvc_query_v4l2_menu()

Where

  • sys/bus/u4b/uvc/uvc_ctrls.c:1102-1119 β€” uvc_query_v4l2_ctrl() (reached from unprivileged ioctl(VIDIOC_QUERYCTRL) on the 0666 /dev/videoN)
  • sys/bus/u4b/uvc/uvc_ctrls.c:1122-1157 β€” uvc_query_v4l2_menu() (VIDIOC_QUERYMENU)
  • Victim sites: uvc_ctrls.c:1065 (write ctrl->info.cached_sub_info_idx = j), uvc_ctrls.c:1001 and :1150 (strlcpy of freed sub_info->v4l2_name into the user-visible struct), :1090 (walk of freed topo_nodes), :1107 (lockmgr on freed ctrl->mtx)
  • Free site (no lock, no cdev drain first): uvc_drv.c:2507 β†’ uvc_drv_destroy_ctrl() (uvc_drv.c:2453-2474) β†’ uvc_ctrl_destroy_mappings() (uvc_ctrls.c:872-897) + uvc_free_topo_node() (uvc_drv.c:1992-2011); destroy_dev() only at uvc_drv.c:2511 β€” AFTER the free.

Attack model

/dev/videoN is created 0666 (uvc_v4l2.c:783-785). An unprivileged local user keeps it open and issues VIDIOC_QUERYCTRL/VIDIOC_QUERYMENU. The evil USB webcam (or a plain unplug) disconnects β†’ uvc_drv_detach() frees the entire control graph while the ioctl is in flight (or before destroy_dev() drains it). The ioctl then writes 1 byte into a freed chunk and copies up to 31 bytes of reclaimed kernel heap out to userspace.

Build / run (as verified)

./build.sh                  # KLD harness that #includes the REAL uvc sources
./run.sh                    # mode 1 at kldload; mode 2 via sysctl

Expected (baseline)

mode 1 (controlled reclaim, guest stays up):

HARNESS DF-5378[baseline]: ret=0 name=<Tilt (Absolute)>
HARNESS DF-5378: production uvc_drv_destroy_ctrl() done - ... DANGLING
HARNESS DF-5378: all 11 freed chunks reclaimed at their original addresses
HARNESS DF-5378[UAF query]: ret=0 name_len=31   (q.name = 0x5A*31)
HARNESS DF-5378: *** UAF READ/LEAK CONFIRMED: 31 bytes ... ***
HARNESS DF-5378: *** UAF WRITE CONFIRMED: uvc_ctrls.c:1065 wrote
                  cached_sub_info_idx=1 ... (was 0xAA) ***
HARNESS DF-5378[UAF menu]: ret=0   (qm.name = 0x5B*31)  -> QUERYMENU too

mode 2 (wild, guest dies):

HARNESS DF-5378[wild]: ctrl chunk poisoned; expect fatal trap ...
Fatal trap 12: page fault while in kernel mode
fault virtual address = 0x5a5a5a5a5a5a5aXX
-> db> prompt, vm.sh status down  (panic.txt)

After fix.diff (applied to guest /usr/src, harness rebuilt)

sysctl hw.uvcharness=3
HARNESS DF-5378[fix baseline]: ret=0 name=<Tilt (Absolute)>
HARNESS DF-5378[fix post-detach]: query ret=19 (ENODEV), guest up

Files

uvcharness.c   harness (real driver sources compiled in)
run.log        mode 1 output (controlled UAF proof)
panic.txt      mode 2 serial console (wild-pointer panic)
fix.log        post-fix output
leak_sample.txt raw leaked-name bytes
VERDICT.md
↓ download raw

DF-5378 VERDICT

Status: reproduced (deterministic, production-code UAF with both a 1-byte controlled write into a freed kernel chunk and a 31-byte kernel-heap disclosure per query through the unprivileged VIDIOC_QUERYCTRL/VIDIOC_QUERYMENU handlers; plus an uncontrolled variant that panics the kernel). Impact: memcorrupt (UAF write + kheap leak; panic variant), confidence: certain.

Root cause (path:line)

  • sys/bus/u4b/uvc/uvc_drv.c:2503-2511 β€” uvc_drv_detach() frees the whole control graph via uvc_drv_destroy_ctrl(sc->ctrl) at 2507 and only calls uvc_v4l2_unreg(sc->video) (β†’ destroy_dev(), uvc_v4l2.c:756-769) at 2511, after the free. uvc_drv_destroy_ctrl() (uvc_drv.c:2453-2474) walks the graph without taking c->mtx and frees sub_infos[], menus[], uvc_data, controls[], the uvc_topo_node list (uvc_free_topo_node, uvc_drv.c:1992-2011) and sc->ctrl itself (uvc_drv.c:2472).
  • /dev/videoN is created 0666 (uvc_v4l2.c:783-785), so any local user can hold it open. A device disconnect (USB unplug β€” or an evil webcam whose firmware disconnects itself on demand) triggers that detach while a VIDIOC_QUERYCTRL/VIDIOC_QUERYMENU is in flight (or enters before destroy_dev() drains it).
  • The in-flight ioctl then executes, on freed memory, inside the audited file:
  • uvc_ctrls.c:1107 β€” UVC_LOCK(&video->ctrl->mtx) lockmgr on the freed uvc_drv_ctrl chunk;
  • uvc_ctrls.c:1090 β€” STAILQ walk of freed topo_nodes;
  • uvc_ctrls.c:1065 β€” ctrl->info.cached_sub_info_idx = j; a 1-byte write (value 0..4, attacker-influenced through the v4l2 id it searches) at a fixed offset of the freed struct uvc_control array chunk;
  • uvc_ctrls.c:1001 / uvc_ctrls.c:1150 β€” strlcpy() of the freed sub_info->v4l2_name into the user-visible query->name/qm->name: 31 bytes of whatever now occupies the freed chunk are copied to userspace, repeatable at will.

Distinct from DF-2353 (that finding's victim is sc->data, freed at uvc_drv.c:2506 and consumed by the format/frame paths in uvc_drv.c); the victim here is sc->ctrl's control graph (freed at 2507), consumed by the two query handlers of uvc_ctrls.c, with different primitives (write + name disclosure).

Reproduction (KLD harness with production code, the DF-5305/5306 pattern)

The harness #includes the four real uvc driver sources, builds a real control graph with the real uvc_drv_init_ctrl() + uvc_drv_parse_ctrl() + uvc_ctrl_init_dev() from a crafted VideoControl-only descriptor blob (camera terminal with bits 1 and 11: AE_MODE + PANTILT_ABSOLUTE), runs the real uvc_query_v4l2_ctrl(), then executes the real uvc_drv_destroy_ctrl() and reclaims every freed chunk at its exact address (what a heap-grooming attacker does in the guaranteed race window).

mode 1 (controlled; guest stays up) β€” decisive lines from run.log:

HARNESS DF-5378[baseline]: ret=0 name=<Tilt (Absolute)>
HARNESS DF-5378: production uvc_drv_destroy_ctrl() done - ... DANGLING
HARNESS DF-5378: all 11 freed chunks reclaimed at their original addresses
HARNESS DF-5378[UAF query]: ret=0 name_len=31
  q.name = 5a 5a ... 5a 00      (31 marker bytes + NUL)
*** UAF READ/LEAK CONFIRMED: 31 bytes of attacker-marked FREED-chunk
    content copied into the user-visible queryctrl.name ***
*** UAF WRITE CONFIRMED: uvc_ctrls.c:1065 wrote cached_sub_info_idx=1
    into the freed-and-reclaimed controls[1] chunk (was 0xAA) ***
HARNESS DF-5378[UAF menu]: ret=0, qm.name = 5b*31
*** QUERYMENU disclosure confirmed (strlcpy from freed sub_info at
    uvc_ctrls.c:1150) ***

mode 2 (uncontrolled; guest dies) β€” panic.txt:

HARNESS DF-5378[wild]: ctrl chunk poisoned; expect fatal trap reading
    ~0x5a5a5a5a5a5a5a5a+112 (topo_node->controls_num) ...
Fatal trap 9: general protection fault while in kernel mode
Stopped at uvc_search_control.isra.3+0x20: movl 0x70(%rax),%ecx

i.e. the production walk dereferenced 0x5a5a5a5a5a5a5a5a + 0x70 β€” the
freed ctrl chunk's poisoned `topo_nodes.stqh_first`. `vm.sh status`:
down.

Exploitability

  • The window is not a narrow race in practice: the ioctl handlers are reachable until destroy_dev() drains (uvc_drv.c:2511), i.e. for the whole duration of uvc_drv_destroy_ctrl() plus unbound-open-fd time; an unprivileged user loops QUERYCTRL while the evil device disconnects.
  • Demonstrated primitives: (a) fully attacker-controlled fake graph after same-address zone reclaim (the walk follows attacker pointers), (b) 1-byte write (value 0..4) at a fixed offset of a freed ~184-byte M_UVC chunk, (c) 31 bytes of kernel heap disclosed per query, (d) lockmgr on freed memory (contention on attacker-crafted lock words β†’ further state corruption).
  • uid=0 was not completed: the standard audit guest has no UVC device, so the real /dev/videoN path cannot be driven end-to-end here; the harness proves the primitives deterministically. On hardware, grooming the M_UVC size-class zones from the same 0666 device (other /dev/videoN ioctls allocate M_UVC: e.g. open/REQBUFS paths, cdev priv) is the route to turning the fake-graph control into arbitrary-pointer walk β†’ write.

Fix validation

fix.diff (applies with git apply -p0 from /usr/src): 1. uvc_drv_detach(): destroy the cdev first (uvc_v4l2_unreg() drains in-flight ioctls) and NULL video->ctrl under the ctrl lock before uvc_drv_destroy_data/destroy_ctrl; 2. uvc_query_v4l2_ctrl()/uvc_query_v4l2_menu() (uvc_ctrls.c): return ENODEV when video->ctrl == NULL; 3. uvc_drv_destroy_ctrl(): hold c->mtx during teardown + lockuninit().

Both patched files compile with -Werror in the harness build (fix_build.log). Rebuilt against the patched sources and re-run (fix.log):

HARNESS DF-5378[fix baseline]: ret=0 name=<Tilt (Absolute)>
HARNESS DF-5378[fix post-detach]: query ret=19 (ENODEV), NO freed-memory
access, guest up

i.e. with the fixed detach sequence (NULL-out under lock, then destroy), the production (patched) query handler refuses the dangling pointer instead of walking freed memory, and the live-graph path is unaffected. Full-kernel rebuild not run (harness pattern as in DF-5305/5306; the patch only touches the two driver files, both compile-checked).

Impact

Unprivileged local user + device disconnect β‡’ kernel use-after-free with write primitive and repeatable kernel-heap disclosure (High). Panic variant confirmed. Root-cause class matches the project's memcorrupt bucket.

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 (patch -p0, both hunks clean); harness rebuilt against the patched sources with -Werror (fix_build.log). Baseline re-confirmed on the patched build (query ret=0, name='Tilt (Absolute)'). With the fixed detach sequence (video->ctrl NULLed under its lock, then production uvc_drv_destroy_ctrl), the patched production uvc_query_v4l2_ctrl() returns ENODEV(19) with no freed-memory access and the guest stays up (fix.log). The uncontrolled-panic behavior is therefore unreachable through the fixed ordering: after uvc_v4l2_unreg()/destroy_dev no ioctl can be in flight against the freed graph, and the guard rejects any straggler. Full-kernel rebuild not run (harness pattern as DF-5305/5306; patch touches only the two driver files, both compile-checked).

fix.log lines: 'HARNESS DF-5378[fix baseline]: ret=0 name=<Tilt (Absolute)>' and 'HARNESS DF-5378[fix post-detach]: query ret=19 (want ENODEV=19, NO freed-memory access, guest up)'; fix_build.log: 'FIXBUILD-OK'; fix.diff applies clean (patch -p0 dry-run verified on the read-only tree)
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #0: Thu Jul 2 06:02:54 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64 (patched-source harness build)

Confirmed kernel references

Detail

Exploit chain

unprivileged user opens 0666 /dev/videoN (uvc_v4l2.c:783) and loops VIDIOC_QUERYCTRL; evil USB webcam disconnects itself (or is unplugged) -> uvc_drv_detach frees sc->ctrl graph at uvc_drv.c:2507 BEFORE destroy_dev at 2511 -> in-flight ioctl walks the freed graph: attacker grooms the M_UVC size-class zones (same-driver allocations reachable from the 0666 node) so the freed uvc_drv_ctrl/topo_node/controls/sub_info chunks are reclaimed under attacker control -> full fake-graph walk (uvc_ctrls.c:1090-1067), 1-byte write cached_sub_info_idx=j (uvc_ctrls.c:1065) into a freed chunk, 31 bytes of kernel heap per QUERYCTRL/QUERYMENU via strlcpy (uvc_ctrls.c:1001,1150); without grooming the walk panics (trap 9 at 0x5a5a5a5a5a5a5a5a+0x70). On real hardware the fake-graph control plus zone grooming is the escalation route; not driven to uid=0 on this guest (no UVC device).

Evidence (decisive lines)

run.log: 'all 11 freed chunks reclaimed at their original addresses', '*** UAF WRITE CONFIRMED ... (was 0xAA) ***', q.name/qm.name hexdumps = 31x 0x5A/0x5B markers; panic.txt: 'Fatal trap 9 ... Stopped at uvc_search_control.isra.3+0x20: movl 0x70(%rax),%ecx'; fix.log: '[fix baseline]: ret=0 name=<Tilt (Absolute)>', '[fix post-detach]: query ret=19 (ENODEV) ... guest up'; leak_sample.txt raw bytes

PoC changes

Seed was a finding-level sketch only; harness written fresh following the DF-5305/5306 KLD pattern: real driver sources #included, real parse+init+query+destroy functions driven on a crafted VideoControl-only blob; added same-address zone-reclaim engine (LIFO order matching the production free sequence), a second control (AE_MODE) so the QUERYMENU disclosure path (menus[0] non-NULL) is exercised, a wild-pointer panic mode, and a fixed-detach simulation mode for fix validation. Fixed one harness bug found during fix-validation (mode 3 initially re-evaluated &video.ctrl->mtx after NULLing -> harness-side NULL deref, corrected to unlock via saved pointer exactly as fix.diff does).

Verified recommended fix

In uvc_drv_detach() call uvc_v4l2_unreg() (destroy_dev drains in-flight ioctls) and NULL video->ctrl under the ctrl lock BEFORE uvc_drv_destroy_data/destroy_ctrl; add video->ctrl==NULL -> ENODEV guards to uvc_query_v4l2_ctrl()/uvc_query_v4l2_menu(); take c->mtx and lockuninit in uvc_drv_destroy_ctrl().

Verdict

Reproduced deterministically with a KLD harness that compiles the real uvc driver sources. uvc_drv_detach() frees the entire control graph via uvc_drv_destroy_ctrl() (uvc_drv.c:2507 -> 2453-2474, no lock held) while the 0666 /dev/videoN cdev is still live (destroy_dev only at uvc_drv.c:2511), so an unprivileged VIDIOC_QUERYCTRL/VIDIOC_QUERYMENU racing a device disconnect executes uvc_query_v4l2_ctrl()/uvc_query_v4l2_menu() (uvc_ctrls.c:1102/1122) over freed memory. Controlled same-address reclaim of all 11 freed chunks proved (1) the UAF WRITE at uvc_ctrls.c:1065 (ctrl->info.cached_sub_info_idx changed 0xAA->0x01 in a freed chunk), (2) the kernel-heap DISCLOSURE at uvc_ctrls.c:1001 and :1150 (31 attacker-marked freed-chunk bytes copied into user-visible queryctrl/querymenu names), and (3) lockmgr on the freed ctrl chunk (uvc_ctrls.c:1107). The uncontrolled variant panics: Fatal trap 9, Stopped at uvc_search_control+0x20 movl 0x70(%rax),%ecx with rax=0x5a5a5a5a5a5a5a5a (freed ctrl chunk's poisoned topo_nodes.stqh_first), guest down (panic.txt). Primary classification is memory corruption (bucket memcorrupt): the write primitive + fully-controlled fake-graph walk dominate; 'leak' recorded as the userspace-visible artifact per the verdict enum. uid=0 not completed on this guest (no UVC hardware for the real /dev/videoN path; harness proves the primitives). Fix validated: with fix.diff applied, the fixed production query handler returns ENODEV after the fixed detach sequence and the live path still works; guest stays up.