High watch ✔︎ verified CVSS 8.8 chrome UAF 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in WebCodecs
DescriptionUse after free in WebCodecs
ComponentWebCodecs
Sub-componentBlink
Bug ClassUAF
Action tierwatch — corruption primitive but gated (uncommon) — not default-reachable — factors set by independent review by AI
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Independent review by AIConfirmed
A second, independent AI reviewer (not a human) re-derived this analysis from the fixing commit and the vendor advisory, looking for errors.
Tracker514715455
Fix commit6fd20a57fccf (chromium/src) +7/-5
CWECWE-416 (Use-after-free)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
CreditedGoogle
Reported2026-05-19 (per vendor advisory)
Fix landed2026-05-20 (public git)
Disclosed2026-05-27
Patch gap7 days — fix public in source before the advisory

Background

`HeapBarrierCallback`
a Blink helper that aggregates num_callbacks results into a GCedHeapVector and runs a done-callback once all arrive (third_party/blink/renderer/platform/heap/heap_barrier_callback.h:69).
`BarrierCallbackInfo`
the internal state object holding the result vector and the remaining-count, bound into a base::RepeatingCallback (heap_barrier_callback.h:27).
`Persistent`
a thread-affine cppgc/Oilpan smart handle whose creation and destruction must occur on its owning thread.
`UnwrappingCrossThreadHandle`
a handle that may be safely moved/destroyed across threads while only permitting dereference on its creation thread (added by the fix).

Root Cause Analysis

Pre-fix, BarrierCallbackInfo stored its aggregated results in Persistent<GCedHeapVector<Member<T>>> results_ (third_party/blink/renderer/platform/heap/heap_barrier_callback.h:49), initialized from MakeGarbageCollected<GCedHeapVector<Member<T>>>() in the constructor and dereferenced directly as results_->reserve(...), results_->push_back(...), and *results_.Get() (heap_barrier_callback.h:32-34, 39, 43). Because HeapBarrierCallback type-erases this state into a base::RepeatingCallback (heap_barrier_callback.h:77-80), the callback can be carried to another thread, but the embedded Persistent remains affine to the thread that created it. If that callback (or its closure) is destroyed on a different thread, the Persistent destructor touches the wrong thread’s Oilpan PersistentRegion, corrupting its free-list.

The fix replaces the member with UnwrappingCrossThreadHandle<GCedHeapVector<Member<T>>> results_ (diff @@ -29), constructs it via MakeCrossThreadHandle(MakeGarbageCollected<...>()), and routes all access through results_.GetOnCreationThread() (diff lines for the constructor and Run). This makes cross-thread destruction safe while still enforcing that the vector is only dereferenced on its creation thread, restoring the thread-affinity invariant.

Key insight
The single mistake was embedding a thread-affine Persistent in a callback that is designed to cross threads; the fix swaps it for UnwrappingCrossThreadHandle, which tolerates cross-thread destruction but confines dereference to the creation thread.

Attack Path

  1. Aggregate on a worker Code on a worker thread builds a HeapBarrierCallback, creating a BarrierCallbackInfo whose Persistent result vector is affine to that thread (third_party/blink/renderer/platform/heap/heap_barrier_callback.h:32).
  2. Move cross-thread The type-erased base::RepeatingCallback is passed to another thread (heap_barrier_callback.h:77-80). (inferred: the VideoEncoder.isConfigSupported path is the advisory’s claim and is not in the material)
  3. Destroy off-thread The worker shuts down so a later post/destruction runs the closure’s destructor on the wrong thread, invoking ~Persistent against another thread’s PersistentRegion. (inferred: the failed-PostTask mechanism is not shown)
  4. Free-list corruption The misdirected free of the persistent node corrupts the region’s free-list, a memory-safety primitive. (inferred)

Impact Assessment

A HeapBarrierCallback carrying a Persistent could have its handle destroyed on a thread other than its owner, corrupting that thread’s Oilpan PersistentRegion free-list in the renderer process. The advisory classifies this as a use-after-free, rated high severity with no in-the-wild exploitation; the worker-termination trigger and any escalation to RCE are reporter claims not demonstrated by the shown header (inferred).

Changed Functions

FunctionChangeNotes
if
third_party/blink/renderer/platform/heap/heap_barrier_callback.h
modified

Files Changed

  • third_party/blink/renderer/platform/heap/heap_barrier_callback.h

Audit Directions

  • Thread-affine handles in cross-thread callbacks
    Flag any Persistent/Member captured in a callback that may be posted or destroyed on another thread; prefer CrossThreadHandle/CrossThreadPersistent.
  • Type erasure hiding affinity
    Be wary of base::RepeatingCallback/OnceCallback wrappers that erase the thread-affinity static checks of their captured GC types.
  • Destruction thread of closures
    Confirm where a closure is ultimately destroyed (including failed-PostTask cases), not just where it runs.
From 6fd20a57fccfaf70f2b472c7f1246033011af527 Mon Sep 17 00:00:00 2001
From: Eugene Zemtsov <[email protected]>
Date: Wed, 20 May 2026 12:25:14 -0700
Subject: [PATCH] blink: Make HeapBarrierCallback thread-safe by default

HeapBarrierCallback was using Persistent to store aggregated results.
Since Persistent handles are thread-affine, destroying or clearing them
on another thread (e.g. when a PostTask fails due to worker thread
shutdown) results in free-list corruption of the calling thread's
PersistentRegion.

This change replaces Persistent with UnwrappingCrossThreadHandle to
ensure safe cross-thread destruction while strictly enforcing that the
underlying vector is only dereferenced on its creation thread,
preventing data races.

Bug: 514715455
Change-Id: I44930a8d0d742a6194c6380c7b94abc8cf03dab7
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7860937
Reviewed-by: Nate Chapin <[email protected]>
Commit-Queue: Eugene Zemtsov <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1633752}
---

diff --git a/third_party/blink/renderer/platform/heap/heap_barrier_callback.h b/third_party/blink/renderer/platform/heap/heap_barrier_callback.h
index 80a9f738..54c8c24 100644
--- a/third_party/blink/renderer/platform/heap/heap_barrier_callback.h
+++ b/third_party/blink/renderer/platform/heap/heap_barrier_callback.h
@@ -18,6 +18,7 @@
 #include "base/notreached.h"
 #include "base/thread_annotations.h"
 #include "third_party/blink/renderer/platform/heap/collection_support/heap_vector.h"
+#include "third_party/blink/renderer/platform/heap/cross_thread_handle.h"
 
 namespace blink {
 
@@ -29,24 +30,25 @@
   BarrierCallbackInfo(wtf_size_t num_callbacks,
                       base::OnceCallback<void(DoneArg)> done_callback)
       : num_callbacks_left_(num_callbacks),
-        results_(MakeGarbageCollected<GCedHeapVector<Member<T>>>()),
+        results_(MakeCrossThreadHandle(
+            MakeGarbageCollected<GCedHeapVector<Member<T>>>())),
         done_callback_(std::move(done_callback)) {
-    results_->reserve(num_callbacks);
+    results_.GetOnCreationThread()->reserve(num_callbacks);
   }
 
   void Run(T* t) {
     DCHECK_NE(num_callbacks_left_, 0U);
-    results_->push_back(std::move(t));
+    results_.GetOnCreationThread()->push_back(std::move(t));
     --num_callbacks_left_;
 
     if (num_callbacks_left_ == 0) {
-      std::move(done_callback_).Run(*results_.Get());
+      std::move(done_callback_).Run(*results_.GetOnCreationThread());
     }
   }
 
  private:
   wtf_size_t num_callbacks_left_;
-  Persistent<GCedHeapVector<Member<T>>> results_;
+  UnwrappingCrossThreadHandle<GCedHeapVector<Member<T>>> results_;
   base::OnceCallback<void(DoneArg)> done_callback_;
 };
 
Loading diff…

Original Bug Report

reported by [email protected]

Potential cross-thread Use-After-Free in VideoEncoder via HeapBarrierCallback

Flapjack, 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 thread-affinity violation occurs when VideoEncoder.isConfigSupported() is called from a Web Worker that is subsequently terminated. A garbage-collected Persistent handle is inadvertently destroyed on the main thread when a PostTask fails, potentially corrupting the main thread’s PersistentRegion free-list if the worker’s memory pages have been recycled. This memory corruption can potentially be exploited for Remote Code Execution (RCE) in the renderer process.

Affected files:

  • third_party/blink/renderer/modules/webcodecs/video_encoder.cc
  • third_party/blink/renderer/platform/heap/heap_barrier_callback.h
  • third_party/blink/renderer/modules/webcodecs/gpu_factories_retriever.cc
  • media/mojo/clients/mojo_codec_factory.cc

Estimated timestamp from git blame: 2024-02-29

Summary

A potential memory corruption vulnerability exists in VideoEncoder::isConfigSupported due to the improper cross-thread handling of cppgc::Persistent handles.

When isConfigSupported is called from a Web Worker, it utilizes HeapBarrierCallback to aggregate results. This callback captures a thread-affine Persistent handle but is wrapped in a type-erased base::RepeatingCallback. This circumvents CrossThreadBindOnce’s static safety checks, allowing the callback to be safely passed to the main thread via RetrieveGpuFactoriesWithKnownEncoderSupport.

The callback is registered in MojoCodecFactory using base::BindPostTaskToCurrentDefault. If the originating worker thread is terminated before the GPU factory response is received, the worker’s task runner is invalidated. When the main thread attempts to execute the BindPostTask trampoline, the underlying PostTask fails. In Chromium, a failed PostTask results in the synchronous destruction of the passed closure on the calling thread. Consequently, the Persistent handle created on the worker thread is destroyed on the main thread.

Technical Details

  1. HeapBarrierCallback (third_party/blink/renderer/platform/heap/heap_barrier_callback.h) stores results in a BarrierCallbackInfo which holds a Persistent<GCedHeapVector>.
  2. This BarrierCallbackInfo is captured by value in a base::RepeatingCallback and sent across threads to MojoCodecFactory::NotifyEncoderSupportKnown, bypassing Blink’s thread-affinity checks.
  3. MojoCodecFactory wraps this in a BindPostTaskTrampoline to ensure it runs on the worker thread.
  4. If the worker terminates, its task runner shuts down and its Oilpan HeapBase and PersistentRegion are destroyed. The backing memory for PersistentNodeSlots is freed to the system allocator.
  5. When the main thread calls .Run() on the trampoline, task_runner_->PostTask fails and immediately destroys the closure on the main thread.
  6. The destruction sequence calls Persistent::Clear(), which invokes WeaknessPolicy::GetPersistentRegion(GetValue()).FreeNode(GetNode()).
  7. GetPersistentRegion locates the region by inspecting the BasePage of the pointee (GetValue()). If PartitionAlloc has recycled the worker’s freed 128KB page into the main thread’s Oilpan heap, BasePage::FromPayload will return the main thread’s HeapBase.
  8. FreeNode executes on the main thread’s PersistentRegion. Because the thread-check is a debug-only CPPGC_DCHECK, execution proceeds without locks in release builds.
  9. FreeNode blindly inserts the dangling GetNode() pointer (which belonged to the worker’s freed memory) into the main thread’s free_list_head_.
  10. Subsequent Persistent allocations on the main thread will pop this attacker-controlled pointer, leading to arbitrary memory writes (Write-Where primitive) when InitializeAsUsedNode is called.

Potential Steps to Reproduce

Note: These are suggested steps; our tooling agent does not have the ability to run code to verify them.

  1. Spawn a Web Worker from a malicious webpage.
  2. In the worker, call VideoEncoder.isConfigSupported(config) with a configuration that requires hardware detection (e.g., leaving hw_pref unset or setting it to prefer-hardware).
  3. Immediately terminate the worker (self.close() or worker.terminate()).
  4. Perform heap grooming on the main thread to encourage Oilpan page recycling.
  5. When the hardware detection completes asynchronously, the main thread attempts to post the callback, fails, and destroys the Persistent handle, corrupting its own free-list.

Suggested Fix

HeapBarrierCallback should not encapsulate Persistent handles if the resulting callback is intended to be moved across threads.

  1. Modify BarrierCallbackInfo in third_party/blink/renderer/platform/heap/heap_barrier_callback.h to use CrossThreadPersistent instead of Persistent.
  2. Alternatively, ensure that VideoEncoder::isConfigSupported utilizes CrossThreadHandle and explicitly unwraps the handle only when safely back on the worker thread, avoiding the capture of garbage-collected pointers or Persistent handles inside the HeapBarrierCallback that is passed across thread boundaries.

Evaluated with Chrome root at commit: b7d0c4d810da1b31400f198c70d9720fc8f0e5a0


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