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)
PoC verification
Evidence pack
findings/poc/DF-5378 Β· 16 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 unprivilegedioctl(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(writectrl->info.cached_sub_info_idx = j),uvc_ctrls.c:1001and:1150(strlcpy of freedsub_info->v4l2_nameinto the user-visible struct),:1090(walk of freedtopo_nodes),:1107(lockmgr on freedctrl->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 atuvc_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
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 viauvc_drv_destroy_ctrl(sc->ctrl)at 2507 and only callsuvc_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 takingc->mtxand freessub_infos[],menus[],uvc_data,controls[], theuvc_topo_nodelist (uvc_free_topo_node, uvc_drv.c:1992-2011) andsc->ctrlitself (uvc_drv.c:2472)./dev/videoNis 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 freeduvc_drv_ctrlchunk;uvc_ctrls.c:1090β STAILQ walk of freedtopo_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 freedstruct uvc_controlarray chunk;uvc_ctrls.c:1001/uvc_ctrls.c:1150βstrlcpy()of the freedsub_info->v4l2_nameinto the user-visiblequery->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 ofuvc_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
fixedfix.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)
Confirmed kernel references
- sys/bus/u4b/uvc/uvc_ctrls.c:1065
- sys/bus/u4b/uvc/uvc_ctrls.c:1090
- sys/bus/u4b/uvc/uvc_ctrls.c:1102
- sys/bus/u4b/uvc/uvc_ctrls.c:1107
- sys/bus/u4b/uvc/uvc_ctrls.c:1001
- sys/bus/u4b/uvc/uvc_ctrls.c:1150
- sys/bus/u4b/uvc/uvc_drv.c:2507
- sys/bus/u4b/uvc/uvc_drv.c:2453
- sys/bus/u4b/uvc/uvc_drv.c:2511
- sys/bus/u4b/uvc/uvc_v4l2.c:783
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.
No comments yet.