CVE-2026-15899
Overview
Background
- `VideoCaptureDeviceApple`
- The macOS/iOS
media::VideoCaptureDeviceimplementation that owns the in-flightTakePhotocompletion callback (photo_callback_) and atask_runner_for the sequence it was created on. - `VideoCaptureDeviceAVFoundationFrameReceiver`
- The interface (implemented by
VideoCaptureDeviceApple) that AVFoundation calls back into viaOnPhotoTaken()/OnPhotoError()from an arbitrary background queue when a still photo completes or fails. - `photo_callback_`
- A single-shot Mojo
TakePhotoreply callback that isstd::move-run to deliver the encoded imageBloband then must not be touched again. - `weak_factory_` / `WeakPtr`
- A base primitive that lets a posted task be silently dropped if
thishas already been destroyed, valid only when checked on the owning sequence.
Root Cause Analysis
Prior to the patch, VideoCaptureDeviceApple::OnPhotoTaken ran std::move(photo_callback_).Run(std::move(blob)) and VideoCaptureDeviceApple::OnPhotoError ran photo_callback_.Reset() directly on whichever AVFoundation background queue invoked them, while the same photo_callback_ member is otherwise owned and mutated on the device’s task_runner_ sequence. Because these callbacks execute off-sequence, a concurrent StopAndDeAllocate()/destruction on the main thread — or concurrent access to photo_callback_ while it is being modified — can free or reassign the callback state underneath the background thread, producing the use-after-free / double-free described in the advisory (running or resetting a callback whose backing state is being torn down). The removed DCHECK(photo_callback_) also assumed the callback was live and untouched by another thread, an invariant that off-sequence execution does not guarantee.
The fix keeps all photo_callback_ access on task_runner_: both entry points now only build the mojom::BlobPtr and PostTask to the new OnPhotoResultOnMainThread, bound through weak_factory_.GetWeakPtr() so the task is dropped if the device is gone. OnPhotoResultOnMainThread asserts task_runner_->BelongsToCurrentThread(), null-checks photo_callback_ before use, and only then resets it (error/nullptr blob) or runs it, so the callback is always accessed on its owning sequence and never after destruction.
photo_callback_, and implicitly this) directly on an AVFoundation background queue instead of the owning task_runner_; the fix marshals the result back to that sequence via a weak-pointer-bound PostTask to OnPhotoResultOnMainThread, which re-checks liveness before any access.Attack Path
- Initiate a still capture
Web content with granted camera access issues an ImageCapture/
TakePhotorequest, armingphoto_callback_on the device’stask_runner_and starting an AVFoundation photo operation. - Off-sequence completion callback
AVFoundation completes (or errors) and invokes
VideoCaptureDeviceApple::OnPhotoTaken/OnPhotoErroron an arbitrary background queue, where the pre-patch code ranstd::move(photo_callback_).Run(...)/photo_callback_.Reset()with no synchronization to the owning sequence. - Concurrent teardown race
Roughly simultaneously the device is stopped/destroyed on the main thread (
StopAndDeAllocate()per the added comment), so the background thread accessesphoto_callback_(andthis) while it is being modified or freed — the use-after-free/double-free window, which the addedConcurrentOnPhotoErrorIsThreadSafetest reproduces by hammeringOnPhotoError()from two threads. - Memory corruption
Running or resetting a callback whose backing state has been reclaimed corrupts the freed callback/device state; controlling the timing and the freed-then-reallocated memory could allow this to be turned into a stronger primitive
(inferred). - Downstream escalation
Leveraging the corruption toward code execution in the capture/GPU or browser process would be a further step the diff does not demonstrate
(inferred).
Impact Assessment
photo_callback_ and the VideoCaptureDeviceApple instance, corrupting process memory in the context that hosts video capture. Preconditions are a granted camera stream and the ability to induce the completion/error callback while stopping capture, and successful exploitation is inherently timing-dependent. Any progression beyond memory corruption to a read/write primitive or code execution is (inferred); the advisory reports this as a use-after-free in CameraCapture and does not state exploited-in-the-wild status here.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifmedia/capture/video/apple/video_capture_device_apple.mm |
modified | |
TESTmedia/capture/video/apple/video_capture_device_apple_unittest.mm |
modified | |
formedia/capture/video/apple/video_capture_device_apple_unittest.mm |
modified | |
MockImageCaptureClientmedia/capture/video/apple/video_capture_device_apple_unittest.mm |
modified |
Files Changed
media/capture/video/apple/video_capture_device_apple.hmedia/capture/video/apple/video_capture_device_apple.mmmedia/capture/video/apple/video_capture_device_apple_unittest.mm
Audit Directions
- Off-sequence callback deliveryAudit every OS/framework callback that runs on a foreign thread or queue (AVFoundation, platform camera/audio, hardware event handlers) for direct access to sequence-affine members like reply callbacks; require marshaling via
PostTaskwith aWeakPtrinstead. - Single-shot Mojo reply callbacks under teardownLook for
std::move(callback_).Run()/callback_.Reset()invoked without a same-sequence guard, especially where aStop/DeAllocate/destructor can run concurrently, and confirm liveness is re-checked on the owning thread before use. - Locks vs. sequence affinity mismatchWhere an object relies on a lock (
_lock) to keepthisalive across a cross-thread call but its members are otherwise sequence-bound, verify the members touched inside that call are actually thread-safe, not merely protected against destruction.
Patch
From b2c70fcd2ebf3fef59465251277e6105c9f5f5a3 Mon Sep 17 00:00:00 2001
From: Sangwhan Moon <sxm@google.com>
Date: Sat, 04 Jul 2026 00:47:03 -0700
Subject: [PATCH] [mac] Fix use-after-free in VideoCaptureDeviceApple Mojo callback
AVFoundation invokes OnPhotoTaken and OnPhotoError on an arbitrary
background queue. VideoCaptureDeviceApple::OnPhotoTaken was previously
executing std::move(photo_callback_).Run(...) directly on that
background thread, which causes threading violations and potential
use-after-free/double-free issues if the VideoCaptureDeviceApple
instance is destroyed concurrently, or if photo_callback_ is accessed
while being modified on the main task runner.
Bug: 516987782
Change-Id: Ibefa689f22116a4e5e3d89da7a3476af36c55251
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7912588
Reviewed-by: Ilya Nikolaevskiy <ilnik@chromium.org>
Commit-Queue: Sangwhan Moon <sxm@google.com>
Auto-Submit: Sangwhan Moon <sxm@google.com>
Reviewed-by: Ted (Chromium) Meyer <tmathmeyer@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1656796}
---
diff --git a/media/capture/video/apple/video_capture_device_apple.h b/media/capture/video/apple/video_capture_device_apple.h
index 8b23902f4..0f0c210 100644
--- a/media/capture/video/apple/video_capture_device_apple.h
+++ b/media/capture/video/apple/video_capture_device_apple.h
@@ -114,6 +114,8 @@
VideoFrameMetadata GetVideoFrameMetadata();
+ void OnPhotoResultOnMainThread(mojom::BlobPtr blob);
+
// Flag indicating the internal state.
enum InternalState { kNotInitialized, kIdle, kCapturing, kError };
diff --git a/media/capture/video/apple/video_capture_device_apple.mm b/media/capture/video/apple/video_capture_device_apple.mm
index b7899c4..352803d 100644
--- a/media/capture/video/apple/video_capture_device_apple.mm
+++ b/media/capture/video/apple/video_capture_device_apple.mm
@@ -264,21 +264,43 @@
void VideoCaptureDeviceApple::OnPhotoTaken(const uint8_t* image_data,
size_t image_length,
const std::string& mime_type) {
- DCHECK(photo_callback_);
- if (!image_data || !image_length) {
- OnPhotoError();
- return;
+ // Note: While OnPhotoTaken() is called on an AVFoundation background queue by
+ // VideoCaptureDeviceAVFoundation, it is guaranteed that `this` is not deleted
+ // because VideoCaptureDeviceAVFoundation holds `_lock` while calling
+ // `_frameReceiver->OnPhotoTaken(...)`, and StopAndDeAllocate() acquires
+ // `_lock` when setting `_frameReceiver = nil` before destruction on the main
+ // thread. Therefore, accessing `task_runner_` here is safe.
+ mojom::BlobPtr blob;
+ if (image_data && image_length) {
+ blob = mojom::Blob::New();
+ blob->data.assign(image_data, UNSAFE_TODO(image_data + image_length));
+ blob->mime_type = mime_type;
}
-
- mojom::BlobPtr blob = mojom::Blob::New();
- blob->data.assign(image_data, UNSAFE_TODO(image_data + image_length));
- blob->mime_type = mime_type;
- std::move(photo_callback_).Run(std::move(blob));
+ task_runner_->PostTask(
+ FROM_HERE,
+ base::BindOnce(&VideoCaptureDeviceApple::OnPhotoResultOnMainThread,
+ weak_factory_.GetWeakPtr(), std::move(blob)));
}
void VideoCaptureDeviceApple::OnPhotoError() {
VLOG(1) << __func__ << " error taking picture";
- photo_callback_.Reset();
+ task_runner_->PostTask(
+ FROM_HERE,
+ base::BindOnce(&VideoCaptureDeviceApple::OnPhotoResultOnMainThread,
+ weak_factory_.GetWeakPtr(), nullptr));
+}
+
+void VideoCaptureDeviceApple::OnPhotoResultOnMainThread(mojom::BlobPtr blob) {
+ DCHECK(task_runner_->BelongsToCurrentThread());
+ if (!photo_callback_) {
+ return;
+ }
+ if (!blob) {
+ VLOG(1) << __func__ << " error taking picture";
+ photo_callback_.Reset();
+ return;
+ }
+ std::move(photo_callback_).Run(std::move(blob));
}
void VideoCaptureDeviceApple::ReceiveError(VideoCaptureError error,
diff --git a/media/capture/video/apple/video_capture_device_apple_unittest.mm b/media/capture/video/apple/video_capture_device_apple_unittest.mm
index 59554e2..0302c7e6 100644
--- a/media/capture/video/apple/video_capture_device_apple_unittest.mm
+++ b/media/capture/video/apple/video_capture_device_apple_unittest.mm
@@ -9,8 +9,10 @@
#import "base/memory/ref_counted.h"
#import "base/memory/scoped_refptr.h"
#import "base/run_loop.h"
+#include "base/synchronization/waitable_event.h"
#include "base/test/bind.h"
#include "base/test/gmock_callback_support.h"
+#include "base/threading/thread.h"
#include "media/capture/video/apple/test/fake_av_capture_device_format.h"
#import "media/capture/video/apple/test/video_capture_test_utils.h"
#include "media/capture/video/apple/video_capture_device_avfoundation.h"
@@ -136,6 +138,36 @@
EXPECT_EQ(result, fmt_640_480_2vuy_30);
}
+// OnPhotoTaken() and OnPhotoError() are documented as safe to call from any
+// thread. Exercise OnPhotoError() concurrently from the device task runner and
+// a background thread to ensure the in-flight TakePhoto callback is accessed
+// safely without data races or sequence checker violations.
+TEST(VideoCaptureDeviceMacTest, ConcurrentOnPhotoErrorIsThreadSafe) {
+ RunTestCase(base::BindOnce([] {
+ constexpr int kIterations = 1000;
+ VideoCaptureDeviceDescriptor descriptor;
+ auto device = std::make_unique<VideoCaptureDeviceApple>(descriptor);
+ VideoCaptureDeviceAVFoundationFrameReceiver* frame_receiver = device.get();
+
+ base::Thread other_thread("OnPhotoErrorTestThread");
+ ASSERT_TRUE(other_thread.Start());
+
+ base::WaitableEvent done;
+ other_thread.task_runner()->PostTask(
+ FROM_HERE, base::BindLambdaForTesting([&] {
+ for (int i = 0; i < kIterations; ++i) {
+ frame_receiver->OnPhotoError();
+ }
+ done.Signal();
+ }));
+ for (int i = 0; i < kIterations; ++i) {
+ frame_receiver->OnPhotoError();
+ }
+ done.Wait();
+ other_thread.Stop();
+ }));
+}
+
class MockImageCaptureClient
: public base::RefCountedThreadSafe<MockImageCaptureClient> {
public:
Regression Test / PoC
diff --git a/media/capture/video/apple/video_capture_device_apple_unittest.mm b/media/capture/video/apple/video_capture_device_apple_unittest.mm
index 59554e2..0302c7e6 100644
--- a/media/capture/video/apple/video_capture_device_apple_unittest.mm
+++ b/media/capture/video/apple/video_capture_device_apple_unittest.mm
@@ -9,8 +9,10 @@
#import "base/memory/ref_counted.h"
#import "base/memory/scoped_refptr.h"
#import "base/run_loop.h"
+#include "base/synchronization/waitable_event.h"
#include "base/test/bind.h"
#include "base/test/gmock_callback_support.h"
+#include "base/threading/thread.h"
#include "media/capture/video/apple/test/fake_av_capture_device_format.h"
#import "media/capture/video/apple/test/video_capture_test_utils.h"
#include "media/capture/video/apple/video_capture_device_avfoundation.h"
@@ -136,6 +138,36 @@
EXPECT_EQ(result, fmt_640_480_2vuy_30);
}
+// OnPhotoTaken() and OnPhotoError() are documented as safe to call from any
+// thread. Exercise OnPhotoError() concurrently from the device task runner and
+// a background thread to ensure the in-flight TakePhoto callback is accessed
+// safely without data races or sequence checker violations.
+TEST(VideoCaptureDeviceMacTest, ConcurrentOnPhotoErrorIsThreadSafe) {
+ RunTestCase(base::BindOnce([] {
+ constexpr int kIterations = 1000;
+ VideoCaptureDeviceDescriptor descriptor;
+ auto device = std::make_unique<VideoCaptureDeviceApple>(descriptor);
+ VideoCaptureDeviceAVFoundationFrameReceiver* frame_receiver = device.get();
+
+ base::Thread other_thread("OnPhotoErrorTestThread");
+ ASSERT_TRUE(other_thread.Start());
+
+ base::WaitableEvent done;
+ other_thread.task_runner()->PostTask(
+ FROM_HERE, base::BindLambdaForTesting([&] {
+ for (int i = 0; i < kIterations; ++i) {
+ frame_receiver->OnPhotoError();
+ }
+ done.Signal();
+ }));
+ for (int i = 0; i < kIterations; ++i) {
+ frame_receiver->OnPhotoError();
+ }
+ done.Wait();
+ other_thread.Stop();
+ }));
+}
+
class MockImageCaptureClient
: public base::RefCountedThreadSafe<MockImageCaptureClient> {
public:
Original Bug Report
Potential Use-After-Free/Double-Free in VideoCaptureDeviceApple due to Unsynchronized Callback
Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: An unsynchronized cross-thread data race exists in VideoCaptureDeviceApple on macOS and iOS, where photo_callback_ is accessed concurrently without mutual exclusion. The device thread reads and writes the callback in TakePhoto() while an AVFoundation background thread concurrently runs or resets it in OnPhotoTaken() or OnPhotoError(). This race can potentially lead to a Use-After-Free (UAF) or Double Free of the callback’s underlying BindState.
Affected files:
media/capture/video/apple/video_capture_device_apple.mmmedia/capture/video/apple/video_capture_device_apple.hmedia/capture/video/apple/video_capture_device_avfoundation.mm
Estimated timestamp from git blame: 2016-07-15
Summary
An unsynchronized cross-thread data race in VideoCaptureDeviceApple on macOS and iOS can potentially lead to a Use-After-Free (UAF) and Double Free of a callback’s underlying BindState. This vulnerability is reachable by a compromised renderer or a site with camera permissions spamming Mojo IPCs for image capture.
Root Cause Analysis
In VideoCaptureDeviceApple, the in-flight image capture callback is stored in the member variable photo_callback_, which is a base::OnceCallback<void(mojom::BlobPtr)> (aliased as TakePhotoCallback):
- File Reference:
media/capture/video/apple/video_capture_device_apple.h, line 132:TakePhotoCallback photo_callback_;
This member is accessed and modified across different threads without any lock protection or synchronization:
-
Device Thread (
task_runner_): InVideoCaptureDeviceApple::TakePhoto, the callback is read and assigned on the device thread:- File Reference:
media/capture/video/apple/video_capture_device_apple.mm, lines 129–139:void VideoCaptureDeviceApple::TakePhoto(TakePhotoCallback callback) { DCHECK(task_runner_->BelongsToCurrentThread()); DCHECK(state_ == kCapturing) << state_; if (photo_callback_) { return; } photo_callback_ = std::move(callback); [capture_device_ takePhoto]; }
- File Reference:
-
AVFoundation Delegate Thread: When a photo is successfully processed or encounters an error, the AVFoundation delegate thread (an arbitrary background thread / dispatch queue) directly invokes
OnPhotoTaken()orOnPhotoError()on the receiver. Unlike other receiver methods likeReceiveErrorwhich usetask_runner_->PostTaskto safely hop back to the device thread, these methods execute directly on the delegate’s thread:- File Reference:
media/capture/video/apple/video_capture_device_apple.mm, lines 264–282:void VideoCaptureDeviceApple::OnPhotoTaken(const uint8_t* image_data, size_t image_length, const std::string& mime_type) { DCHECK(photo_callback_); ... std::move(photo_callback_).Run(std::move(blob)); } void VideoCaptureDeviceApple::OnPhotoError() { VLOG(1) << __func__ << " error taking picture"; photo_callback_.Reset(); }
- File Reference:
Potential Concurrency / Exploitation Mechanism
Because OnceCallback relies on a non-atomic reference-counted BindState object (scoped_refptr<BindStateBase>), concurrent non-atomic accesses on two threads can lead to memory corruption:
- Thread B (AVFoundation background thread) executes
OnPhotoTaken()and performsstd::move(photo_callback_).Run(...). During the move, it reads the address of the underlyingBindState(BindState_A) into a CPU register. - Thread A (Device thread) concurrently processes a new
TakePhoto()request. It readsphoto_callback_before Thread B has writtennullptrto clear it. Thread A then executesphoto_callback_ = std::move(callback), which overwrites the slot with a new callback and callsRelease()on the oldBindState_A. - Since the reference count of
BindState_Adrops to0, the memory allocated forBindState_Ais freed. - Thread B completes its move operation and writes
nullptrtophoto_callback_(corrupting Thread A’s newly assigned callback), and then invokes the callback using the pointer in its register, which now points to the freedBindState_Aobject. This results in a Use-After-Free (UAF).
Potential Trigger Steps
Note: These are potential steps as our tooling does not have the ability to run code or verify a live exploit.
- A web page with camera permissions (or a compromised Renderer process) initiates connection to the
media.mojom.ImageCaptureMojo interface. - The Renderer spams the
media.mojom.ImageCapture.TakePhotoMojo IPC in a tight loop without waiting for the returned promises to resolve. - If timed correctly, the concurrent execution of a new
TakePhotocall on the Device Thread and the completion callback on the AVFoundation thread results in the data race, causing a heap corruption crash or potential control flow hijacking.
Impact
- On macOS, the Video Capture Service runs as
kNoSandbox(unsandboxed utility process). - On iOS, the service runs inside the browser process.
- Exploiting this UAF could potentially allow an attacker to escape the renderer sandbox and execute arbitrary code with full user or browser privileges.
Suggested Fix
Ensure that all callback access on the VideoCaptureDeviceApple instance occurs exclusively on the designated task_runner_ thread. The methods OnPhotoTaken and OnPhotoError must marshal execution back to the device thread using task_runner_->PostTask, rather than executing the callback directly on the AVFoundation background thread. For example:
void VideoCaptureDeviceApple::OnPhotoTaken(const uint8_t* image_data,
size_t image_length,
const std::string& mime_type) {
// Create a copy of the image data to safely pass across threads
std::vector<uint8_t> data(image_data, image_data + image_length);
task_runner_->PostTask(
FROM_HERE,
base::BindOnce(&VideoCaptureDeviceApple::OnPhotoTakenOnDeviceThread,
weak_factory_.GetWeakPtr(), std::move(data), mime_type));
}
Evaluated with Chrome root at commit: b1520ef4a76878853a31f0943b565e42060edec8
Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:
- If you are familiar with the severity guidelines, you may adjust the severity.
- If this is a false positive, and there’s no work to be done, please close as WAI.
- If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.
Data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.