CVE-2026-15899
Overview
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
Patch
From b2c70fcd2ebf3fef59465251277e6105c9f5f5a3 Mon Sep 17 00:00:00 2001 From: Sangwhan Moon <[email protected]> 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 <[email protected]> Commit-Queue: Sangwhan Moon <[email protected]> Auto-Submit: Sangwhan Moon <[email protected]> Reviewed-by: Ted (Chromium) Meyer <[email protected]> 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.