Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in WebCodecs
DescriptionUse after free in WebCodecs
ComponentWebCodecs
Bug ClassUAF
Tracker517129549
Fix commit9cad4244e460 (chromium/src) +15/-10
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-08

Files Changed

  • third_party/blink/renderer/modules/webcodecs/background_readback.cc
  • third_party/blink/renderer/modules/webcodecs/video_encoder.cc
  • third_party/blink/renderer/modules/webcodecs/video_frame.cc
From 9cad4244e460e6c8a5ff5daf4a033d97b345932d Mon Sep 17 00:00:00 2001
From: Eugene Zemtsov <[email protected]>
Date: Thu, 28 May 2026 14:27:17 -0700
Subject: [PATCH] media: Use cross-thread handles for WebCodecs callbacks

Replace WrapPersistent/WrapWeakPersistent with cross-thread handles in
VideoEncoder, VideoFrame, and BackgroundReadback to enable safe
cross-thread destruction.

Bug: 517129549
Change-Id: Ibaf32966cf9000d03265ffc3325c20ee063af4f4
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7881219
Commit-Queue: Eugene Zemtsov <[email protected]>
Reviewed-by: Dale Curtis <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1637957}
---

diff --git a/third_party/blink/renderer/modules/webcodecs/background_readback.cc b/third_party/blink/renderer/modules/webcodecs/background_readback.cc
index 3f167754..79aa07d 100644
--- a/third_party/blink/renderer/modules/webcodecs/background_readback.cc
+++ b/third_party/blink/renderer/modules/webcodecs/background_readback.cc
@@ -201,8 +201,8 @@
       texture_size, src_point, info, base::saturated_cast<GLuint>(rgba_stride),
       dst_pixels,
       blink::BindOnce(&BackgroundReadback::OnARGBPixelsFrameReadCompleted,
-                      WrapWeakPersistent(this), std::move(result_cb), txt_frame,
-                      std::move(result)));
+                      MakeUnwrappingCrossThreadWeakHandle(this),
+                      std::move(result_cb), txt_frame, std::move(result)));
   media::WaitAndReplaceSyncTokenClient client(ri, std::move(ri_access));
   txt_frame->UpdateReleaseSyncToken(&client);
 }
@@ -272,8 +272,9 @@
       texture_size, src_point, info, base::saturated_cast<GLuint>(stride),
       dst_pixels,
       blink::BindOnce(&BackgroundReadback::OnARGBPixelsBufferReadCompleted,
-                      WrapWeakPersistent(this), std::move(txt_frame), src_rect,
-                      dest_layout, dest_buffer, std::move(done_cb)));
+                      MakeUnwrappingCrossThreadWeakHandle(this),
+                      std::move(txt_frame), src_rect, dest_layout, dest_buffer,
+                      std::move(done_cb)));
   gpu::RasterScopedAccess::EndAccess(std::move(ri_access));
 }
 
diff --git a/third_party/blink/renderer/modules/webcodecs/video_encoder.cc b/third_party/blink/renderer/modules/webcodecs/video_encoder.cc
index 3fc61355..b2f043fb 100644
--- a/third_party/blink/renderer/modules/webcodecs/video_encoder.cc
+++ b/third_party/blink/renderer/modules/webcodecs/video_encoder.cc
@@ -1115,9 +1115,11 @@
     // resolve synchronously.
     blocking_request_in_progress_ = request;
 
-    auto readback_done_callback = blink::BindOnce(
-        &VideoEncoder::OnReadbackDone, WrapWeakPersistent(this),
-        WrapPersistent(request), frame, std::move(encode_done_callback));
+    auto readback_done_callback =
+        blink::BindOnce(&VideoEncoder::OnReadbackDone,
+                        MakeUnwrappingCrossThreadWeakHandle(this),
+                        MakeUnwrappingCrossThreadHandle(request), frame,
+                        std::move(encode_done_callback));
 
     if (StartReadback(std::move(frame), std::move(readback_done_callback))) {
       request->input->close();
@@ -1755,7 +1757,8 @@
           script_state);
   auto promise = resolver->Promise();
   auto find_any_callback = HeapBarrierCallback<VideoEncoderSupport>(
-      num_callbacks, BindOnce(&FindAnySupported, WrapPersistent(resolver)));
+      num_callbacks,
+      BindOnce(&FindAnySupported, MakeUnwrappingCrossThreadHandle(resolver)));
 
   if (parsed_config->hw_pref != HardwarePreference::kPreferSoftware ||
       media::MayHaveAndAllowSelectOSSoftwareEncoder(parsed_config->codec)) {
diff --git a/third_party/blink/renderer/modules/webcodecs/video_frame.cc b/third_party/blink/renderer/modules/webcodecs/video_frame.cc
index b11da456..80d17dc 100644
--- a/third_party/blink/renderer/modules/webcodecs/video_frame.cc
+++ b/third_party/blink/renderer/modules/webcodecs/video_frame.cc
@@ -1358,8 +1358,9 @@
           resolver->Reject();
         }
       };
-  auto done_cb = BindOnce(readback_done_handler, std::move(contents),
-                          WrapPersistent(resolver), dest_layout);
+  auto done_cb =
+      BindOnce(readback_done_handler, std::move(contents),
+               MakeUnwrappingCrossThreadHandle(resolver), dest_layout);
 
   auto buffer = AsSpan<uint8_t>(destination);
   background_readback->ReadbackTextureBackedFrameToBuffer(
Loading diff…

Original Bug Report

reported by [email protected]

Potential off-thread destruction of same-thread cppgc::Persistent in VideoEncoder::isConfigSupported

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: A potential thread-safety issue in VideoEncoder::isConfigSupported allows a same-thread cppgc::Persistent handle to be destructed on the media thread instead of its origin worker thread. This occurs when the originating worker thread is terminated, causing subsequent task posting to fail and triggering inline destruction of transitively bound callback states. The resulting off-thread call to PersistentRegionBase::FreeNode() executes without synchronization in release builds, potentially leading to heap metadata corruption or a use-after-free.

Affected files:

  • third_party/blink/renderer/modules/webcodecs/video_encoder.cc
  • third_party/blink/renderer/platform/heap/heap_barrier_callback.h

Estimated timestamp from git blame: 2024-02-29

Root Cause Analysis

In VideoEncoder::isConfigSupported() (third_party/blink/renderer/modules/webcodecs/video_encoder.cc), a same-thread cppgc::Persistent<ScriptPromiseResolver> handle is allocated using WrapPersistent(resolver) and embedded within the done_callback_ member of HeapBarrierCallback. The resulting find_any_callback is subsequently passed to CrossThreadBindOnce to establish gpu_retrieved_callback for retrieving GPU factories.

CrossThreadBindOnce utilizes static assertions (CheckGCedTypeRestrictions and kCheckNoThreadUnsafeRefCounted in third_party/blink/renderer/platform/wtf/functional_internal.h) to prevent thread-unsafe or garbage-collected types from crossing thread boundaries. However, these compile-time checks only inspect the direct parameters of the top-level callback wrapper; they do not recursively inspect the base::BindState or bound arguments of nested callbacks (such as the base::RepeatingCallback returned by HeapBarrierCallback). Consequently, the same-thread Persistent handle successfully bypasses the cross-thread static safety assertions.

Potential Trigger Scenario

(Note: These are potential steps based on source-code tracing, as our current verification environment does not have the ability to run live exploit payloads.)

  1. A page instantiates a Web Worker that calls VideoEncoder.isConfigSupported() with hardwareAcceleration set to 'prefer-hardware'.
  2. The implementation enters VideoEncoder::isConfigSupported(), creates a same-thread Persistent<ScriptPromiseResolver>, wraps it inside HeapBarrierCallback, and transfers the callback across the thread boundary via RetrieveGpuFactoriesWithKnownEncoderSupport() to query hardware capabilities.
  3. Since capabilities are not yet fully initialized, the callback is registered inside MojoCodecFactory::NotifyEncoderSupportKnown() (media/mojo/clients/mojo_codec_factory.cc:120). During registration, base::BindPostTaskToCurrentDefault(std::move(callback)) wraps it with a BindPostTaskTrampoline referencing the worker thread’s task runner.
  4. While the GPU process is probing capabilities, the parent page calls worker.terminate(), which shuts down and inactivates the worker thread’s task runner.
  5. Once capability probing completes, the media thread receives the reply and invokes MojoCodecFactory::Notifier::Notify() on the media thread.
  6. The media thread executes BindPostTaskTrampoline::Run(), which attempts to post the callback to the dead worker’s task runner. This fails (PostTask returns false).
  7. Because the post-task fails, the trampoline’s destructor ~BindPostTaskTrampoline() executes immediately on the media thread.
  8. The destructor attempts a fallback post-task to clean up, which also fails, forcing immediate inline destruction of the trampoline’s captured callback (callback_) on the media thread.
  9. This triggers a cascading destruction chain on the media thread: ~OnceCallback() -> ~BarrierCallbackInfo() -> ~OnceCallback (the done callback) -> ~BasicPersistent().

Consequences and Security Impact

When ~BasicPersistent() executes on the wrong thread, it calls Clear(), which invokes PersistentRegionBase::FreeNode() to return the node to the heap’s persistent region.

  1. Heap Metadata Corruption: In PersistentRegion::FreeNode(), the thread check is guarded by CPPGC_DCHECK(IsCreationThread()) (v8/include/cppgc/internal/persistent-node.h:162). Since DCHECK macros are compiled out in non-debug (official/release) builds, the destruction proceeds on the media thread without synchronizations or locks. This results in an unsynchronized data race on the free_list_head_ pointer, potentially corrupting the allocator’s free list and enabling subsequent use-after-free or type confusion of garbage-collected objects.
  2. Use-After-Free: If the worker thread’s heap has already been destroyed, BasePage::FromPayload() will mask the payload address to find the page header, referencing a freed page and leading to wild memory reads/writes.

Both cases can result in exploitable memory corruption inside the sandboxed renderer process.

Suggested Fix

To prevent the same-thread Persistent handle from being destroyed on a different thread, we should avoid binding same-thread persistent handles directly into callbacks that cross thread boundaries. Instead, the ScriptPromiseResolver should be wrapped using a thread-safe handle or cross-thread persistent handle, such as MakeCrossThreadHandle or CrossThreadPersistent, when passed through the HeapBarrierCallback chain.

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.

View on issue tracker