Overview

High
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in WebRTC
DescriptionUse after free in WebRTC
ComponentWebRTC
Sub-componentEncodedTransform
Bug ClassUAF
Tracker561997899
Fix commitdef00f1d39bb (chromium/src) +93/-0
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-13

Changed Functions

FunctionChangeNotes
if
third_party/blink/renderer/modules/peerconnection/rtc_encoded_audio_underlying_source.cc
modified
if
third_party/blink/renderer/modules/peerconnection/rtc_encoded_video_underlying_source.cc
modified

Files Changed

  • third_party/blink/renderer/modules/peerconnection/rtc_encoded_audio_underlying_source_test.cc
  • third_party/blink/renderer/modules/peerconnection/rtc_encoded_video_underlying_source_test.cc
  • third_party/blink/renderer/modules/peerconnection/rtc_encoded_video_underlying_source.h
  • third_party/blink/renderer/modules/peerconnection/rtc_encoded_audio_underlying_source.cc
  • third_party/blink/renderer/modules/peerconnection/rtc_encoded_video_underlying_source.cc
  • third_party/blink/renderer/modules/peerconnection/rtc_encoded_audio_underlying_source.h
From def00f1d39bb8692e64c167d2b8901e00648b6f2 Mon Sep 17 00:00:00 2001
From: Guido Urdaneta <[email protected]>
Date: Mon, 28 Sep 2026 04:24:06 -0700
Subject: [PATCH] [EncodedTransform] Disconnect RTCEncoded*UnderlyingSource on ContextDestroyed

Previously, RTCEncodedVideoUnderlyingSource and
RTCEncodedAudioUnderlyingSource only ran `disconnect_callback_` (which
resets the stream transformer callback) when explicitly closed or
canceled. When an encoded stream was transferred to a worker and that
worker's execution context was later destroyed without canceling the
stream, the transformer retained its callback referencing the underlying
source in the destroyed context.

Override `ContextDestroyed()` in both underlying source classes to
release any thread type lease and invoke `disconnect_callback_`. Also
reset `disconnect_callback_` and `realm_thread_type_lease_` in
`OnSourceTransferStartedOnTaskRunner()` so that destroying the original
context after a transfer does not disconnect the transferred source.


Bug: 561997899
Change-Id: Idc694901b64dbfbb286d1e5eeedf3d0d1404dd24
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8458644
Reviewed-by: Palak Agarwal <[email protected]>
Commit-Queue: Guido Urdaneta <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1705984}
---
diff --git a/third_party/blink/renderer/modules/peerconnection/rtc_encoded_audio_underlying_source.cc b/third_party/blink/renderer/modules/peerconnection/rtc_encoded_audio_underlying_source.cc
index 791ef0fe..2fefe1c 100644
--- a/third_party/blink/renderer/modules/peerconnection/rtc_encoded_audio_underlying_source.cc
+++ b/third_party/blink/renderer/modules/peerconnection/rtc_encoded_audio_underlying_source.cc
@@ -100,6 +100,15 @@
   return ToResolvedUndefinedPromise(script_state);
 }
 
+void RTCEncodedAudioUnderlyingSource::ContextDestroyed() {
+  DCHECK(task_runner_->BelongsToCurrentThread());
+  realm_thread_type_lease_ = std::nullopt;
+  if (disconnect_callback_) {
+    std::move(disconnect_callback_).Run();
+  }
+  UnderlyingSourceBase::ContextDestroyed();
+}
+
 void RTCEncodedAudioUnderlyingSource::Trace(Visitor* visitor) const {
   visitor->Trace(script_state_);
   visitor->Trace(override_controller_);
@@ -163,6 +172,8 @@
 
 void RTCEncodedAudioUnderlyingSource::OnSourceTransferStartedOnTaskRunner() {
   DCHECK(task_runner_->BelongsToCurrentThread());
+  realm_thread_type_lease_ = std::nullopt;
+  disconnect_callback_.Reset();
   // This can potentially be called before the stream is constructed and so
   // Controller() is still unset.
   if (GetController()) {
diff --git a/third_party/blink/renderer/modules/peerconnection/rtc_encoded_audio_underlying_source.h b/third_party/blink/renderer/modules/peerconnection/rtc_encoded_audio_underlying_source.h
index 30b330b..0b4846f 100644
--- a/third_party/blink/renderer/modules/peerconnection/rtc_encoded_audio_underlying_source.h
+++ b/third_party/blink/renderer/modules/peerconnection/rtc_encoded_audio_underlying_source.h
@@ -52,6 +52,7 @@
   ScriptPromise<IDLUndefined> Cancel(ScriptState*,
                                      ScriptValue reason,
                                      ExceptionState&) override;
+  void ContextDestroyed() override;
 
   void OnFrameFromSource(
       std::unique_ptr<webrtc::TransformableAudioFrameInterface>);
diff --git a/third_party/blink/renderer/modules/peerconnection/rtc_encoded_video_underlying_source.cc b/third_party/blink/renderer/modules/peerconnection/rtc_encoded_video_underlying_source.cc
index 7dc8e96..53f46396 100644
--- a/third_party/blink/renderer/modules/peerconnection/rtc_encoded_video_underlying_source.cc
+++ b/third_party/blink/renderer/modules/peerconnection/rtc_encoded_video_underlying_source.cc
@@ -92,6 +92,15 @@
   return ToResolvedUndefinedPromise(script_state);
 }
 
+void RTCEncodedVideoUnderlyingSource::ContextDestroyed() {
+  DCHECK(task_runner_->BelongsToCurrentThread());
+  realm_thread_type_lease_ = std::nullopt;
+  if (disconnect_callback_) {
+    std::move(disconnect_callback_).Run();
+  }
+  UnderlyingSourceBase::ContextDestroyed();
+}
+
 void RTCEncodedVideoUnderlyingSource::Trace(Visitor* visitor) const {
   visitor->Trace(script_state_);
   visitor->Trace(controller_override_);
@@ -163,6 +172,8 @@
 
 void RTCEncodedVideoUnderlyingSource::OnSourceTransferStartedOnTaskRunner() {
   DCHECK(task_runner_->BelongsToCurrentThread());
+  realm_thread_type_lease_ = std::nullopt;
+  disconnect_callback_.Reset();
   // This can potentially be called before the stream is constructed and so
   // Controller() is still unset.
   if (GetController()) {
diff --git a/third_party/blink/renderer/modules/peerconnection/rtc_encoded_video_underlying_source.h b/third_party/blink/renderer/modules/peerconnection/rtc_encoded_video_underlying_source.h
index 2c1ec0a2..28ee3ab 100644
--- a/third_party/blink/renderer/modules/peerconnection/rtc_encoded_video_underlying_source.h
+++ b/third_party/blink/renderer/modules/peerconnection/rtc_encoded_video_underlying_source.h
@@ -53,6 +53,7 @@
   ScriptPromise<IDLUndefined> Cancel(ScriptState*,
                                      ScriptValue reason,
                                      ExceptionState&) override;
+  void ContextDestroyed() override;
 
   void OnFrameFromSource(
       std::unique_ptr<webrtc::TransformableVideoFrameInterface>);
diff --git a/third_party/blink/renderer/modules/peerconnection/rtc_encoded_audio_underlying_source_test.cc b/third_party/blink/renderer/modules/peerconnection/rtc_encoded_audio_underlying_source_test.cc
index 62e4c63..8594917 100644
--- a/third_party/blink/renderer/modules/peerconnection/rtc_encoded_audio_underlying_source_test.cc
+++ b/third_party/blink/renderer/modules/peerconnection/rtc_encoded_audio_underlying_source_test.cc
@@ -20,6 +20,7 @@
 #include "third_party/blink/renderer/modules/peerconnection/rtc_encoded_audio_frame.h"
 #include "third_party/blink/renderer/platform/bindings/exception_state.h"
 #include "third_party/blink/renderer/platform/heap/garbage_collected.h"
+#include "third_party/blink/renderer/platform/heap/persistent.h"
 #include "third_party/blink/renderer/platform/testing/task_environment.h"
 #include "third_party/blink/renderer/platform/wtf/cross_thread_functional.h"
 #include "third_party/webrtc/api/frame_transformer_interface.h"
@@ -159,4 +160,38 @@
   EXPECT_FALSE(source->GetRealmThreadTypeLeasedForTesting().has_value());
 }
 
+TEST_F(RTCEncodedAudioUnderlyingSourceTest,
+       ContextDestroyedCallsDisconnectCallback) {
+  base::test::ScopedFeatureList feature_list;
+  feature_list.InitAndEnableFeature(features::kWebRtcUseMediaThreadTypes);
+  Persistent<RTCEncodedAudioUnderlyingSource> source;
+  {
+    V8TestingScope v8_scope;
+    ScriptState* script_state = v8_scope.GetScriptState();
+    source = CreateSource(script_state);
+    source->SetRealmIsBoostableContextForTesting(true);
+    ReadableStream::CreateWithCountQueueingStrategy(script_state, source, 0);
+
+    source->OnFrameFromSource(
+        std::make_unique<NiceMock<webrtc::MockTransformableAudioFrame>>());
+    ASSERT_EQ(source->GetRealmThreadTypeLeasedForTesting(),
+              base::ThreadType::kAudioProcessing);
+
+    EXPECT_CALL(disconnect_callback_, Run());
+  }
+  EXPECT_FALSE(source->GetRealmThreadTypeLeasedForTesting().has_value());
+}
+
+TEST_F(RTCEncodedAudioUnderlyingSourceTest,
+       TransferResetsDisconnectCallbackBeforeContextDestroyed) {
+  EXPECT_CALL(disconnect_callback_, Run()).Times(0);
+  V8TestingScope v8_scope;
+  ScriptState* script_state = v8_scope.GetScriptState();
+  auto* source = CreateSource(script_state);
+  ReadableStream::CreateWithCountQueueingStrategy(script_state, source, 0);
+
+  source->OnSourceTransferStarted();
+  task_environment_.RunUntilIdle();
+}
+
 }  // namespace blink
diff --git a/third_party/blink/renderer/modules/peerconnection/rtc_encoded_video_underlying_source_test.cc b/third_party/blink/renderer/modules/peerconnection/rtc_encoded_video_underlying_source_test.cc
index 9889d55..39083334 100644
--- a/third_party/blink/renderer/modules/peerconnection/rtc_encoded_video_underlying_source_test.cc
+++ b/third_party/blink/renderer/modules/peerconnection/rtc_encoded_video_underlying_source_test.cc
@@ -18,6 +18,7 @@
 #include "third_party/blink/renderer/core/streams/readable_stream_default_controller_with_script_scope.h"
 #include "third_party/blink/renderer/platform/bindings/exception_state.h"
 #include "third_party/blink/renderer/platform/heap/garbage_collected.h"
+#include "third_party/blink/renderer/platform/heap/persistent.h"
 #include "third_party/blink/renderer/platform/testing/task_environment.h"
 #include "third_party/blink/renderer/platform/wtf/cross_thread_functional.h"
 #include "third_party/blink/renderer/platform/wtf/functional.h"
@@ -150,4 +151,37 @@
   EXPECT_FALSE(source->GetRealmThreadTypeLeasedForTesting().has_value());
 }
 
+TEST_F(RTCEncodedVideoUnderlyingSourceTest,
+       ContextDestroyedCallsDisconnectCallback) {
+  base::test::ScopedFeatureList feature_list;
+  feature_list.InitAndEnableFeature(features::kWebRtcUseMediaThreadTypes);
+  Persistent<RTCEncodedVideoUnderlyingSource> source;
+  {
+    V8TestingScope v8_scope;
+    ScriptState* script_state = v8_scope.GetScriptState();
+    source = CreateSource(script_state);
+    source->SetRealmIsBoostableContextForTesting(true);
+    ReadableStream::CreateWithCountQueueingStrategy(script_state, source, 0);
+
+    source->OnFrameFromSource(std::make_unique<MockTransformableVideoFrame>());
+    ASSERT_EQ(source->GetRealmThreadTypeLeasedForTesting(),
+              base::ThreadType::kPresentation);
+
+    EXPECT_CALL(disconnect_callback_, Run());
+  }
+  EXPECT_FALSE(source->GetRealmThreadTypeLeasedForTesting().has_value());
+}
+
+TEST_F(RTCEncodedVideoUnderlyingSourceTest,
+       TransferResetsDisconnectCallbackBeforeContextDestroyed) {
+  EXPECT_CALL(disconnect_callback_, Run()).Times(0);
+  V8TestingScope v8_scope;
+  ScriptState* script_state = v8_scope.GetScriptState();
+  auto* source = CreateSource(script_state);
+  ReadableStream::CreateWithCountQueueingStrategy(script_state, source, 0);
+
+  source->OnSourceTransferStarted();
+  task_environment_.RunUntilIdle();
+}
+
 }  // namespace blink
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/third_party/blink/renderer/modules/peerconnection/rtc_encoded_audio_underlying_source_test.cc b/third_party/blink/renderer/modules/peerconnection/rtc_encoded_audio_underlying_source_test.cc
index 62e4c63..8594917 100644
--- a/third_party/blink/renderer/modules/peerconnection/rtc_encoded_audio_underlying_source_test.cc
+++ b/third_party/blink/renderer/modules/peerconnection/rtc_encoded_audio_underlying_source_test.cc
@@ -20,6 +20,7 @@
 #include "third_party/blink/renderer/modules/peerconnection/rtc_encoded_audio_frame.h"
 #include "third_party/blink/renderer/platform/bindings/exception_state.h"
 #include "third_party/blink/renderer/platform/heap/garbage_collected.h"
+#include "third_party/blink/renderer/platform/heap/persistent.h"
 #include "third_party/blink/renderer/platform/testing/task_environment.h"
 #include "third_party/blink/renderer/platform/wtf/cross_thread_functional.h"
 #include "third_party/webrtc/api/frame_transformer_interface.h"
@@ -159,4 +160,38 @@
   EXPECT_FALSE(source->GetRealmThreadTypeLeasedForTesting().has_value());
 }
 
+TEST_F(RTCEncodedAudioUnderlyingSourceTest,
+       ContextDestroyedCallsDisconnectCallback) {
+  base::test::ScopedFeatureList feature_list;
+  feature_list.InitAndEnableFeature(features::kWebRtcUseMediaThreadTypes);
+  Persistent<RTCEncodedAudioUnderlyingSource> source;
+  {
+    V8TestingScope v8_scope;
+    ScriptState* script_state = v8_scope.GetScriptState();
+    source = CreateSource(script_state);
+    source->SetRealmIsBoostableContextForTesting(true);
+    ReadableStream::CreateWithCountQueueingStrategy(script_state, source, 0);
+
+    source->OnFrameFromSource(
+        std::make_unique<NiceMock<webrtc::MockTransformableAudioFrame>>());
+    ASSERT_EQ(source->GetRealmThreadTypeLeasedForTesting(),
+              base::ThreadType::kAudioProcessing);
+
+    EXPECT_CALL(disconnect_callback_, Run());
+  }
+  EXPECT_FALSE(source->GetRealmThreadTypeLeasedForTesting().has_value());
+}
+
+TEST_F(RTCEncodedAudioUnderlyingSourceTest,
+       TransferResetsDisconnectCallbackBeforeContextDestroyed) {
+  EXPECT_CALL(disconnect_callback_, Run()).Times(0);
+  V8TestingScope v8_scope;
+  ScriptState* script_state = v8_scope.GetScriptState();
+  auto* source = CreateSource(script_state);
+  ReadableStream::CreateWithCountQueueingStrategy(script_state, source, 0);
+
+  source->OnSourceTransferStarted();
+  task_environment_.RunUntilIdle();
+}
+
 }  // namespace blink
diff --git a/third_party/blink/renderer/modules/peerconnection/rtc_encoded_video_underlying_source_test.cc b/third_party/blink/renderer/modules/peerconnection/rtc_encoded_video_underlying_source_test.cc
index 9889d55..39083334 100644
--- a/third_party/blink/renderer/modules/peerconnection/rtc_encoded_video_underlying_source_test.cc
+++ b/third_party/blink/renderer/modules/peerconnection/rtc_encoded_video_underlying_source_test.cc
@@ -18,6 +18,7 @@
 #include "third_party/blink/renderer/core/streams/readable_stream_default_controller_with_script_scope.h"
 #include "third_party/blink/renderer/platform/bindings/exception_state.h"
 #include "third_party/blink/renderer/platform/heap/garbage_collected.h"
+#include "third_party/blink/renderer/platform/heap/persistent.h"
 #include "third_party/blink/renderer/platform/testing/task_environment.h"
 #include "third_party/blink/renderer/platform/wtf/cross_thread_functional.h"
 #include "third_party/blink/renderer/platform/wtf/functional.h"
@@ -150,4 +151,37 @@
   EXPECT_FALSE(source->GetRealmThreadTypeLeasedForTesting().has_value());
 }
 
+TEST_F(RTCEncodedVideoUnderlyingSourceTest,
+       ContextDestroyedCallsDisconnectCallback) {
+  base::test::ScopedFeatureList feature_list;
+  feature_list.InitAndEnableFeature(features::kWebRtcUseMediaThreadTypes);
+  Persistent<RTCEncodedVideoUnderlyingSource> source;
+  {
+    V8TestingScope v8_scope;
+    ScriptState* script_state = v8_scope.GetScriptState();
+    source = CreateSource(script_state);
+    source->SetRealmIsBoostableContextForTesting(true);
+    ReadableStream::CreateWithCountQueueingStrategy(script_state, source, 0);
+
+    source->OnFrameFromSource(std::make_unique<MockTransformableVideoFrame>());
+    ASSERT_EQ(source->GetRealmThreadTypeLeasedForTesting(),
+              base::ThreadType::kPresentation);
+
+    EXPECT_CALL(disconnect_callback_, Run());
+  }
+  EXPECT_FALSE(source->GetRealmThreadTypeLeasedForTesting().has_value());
+}
+
+TEST_F(RTCEncodedVideoUnderlyingSourceTest,
+       TransferResetsDisconnectCallbackBeforeContextDestroyed) {
+  EXPECT_CALL(disconnect_callback_, Run()).Times(0);
+  V8TestingScope v8_scope;
+  ScriptState* script_state = v8_scope.GetScriptState();
+  auto* source = CreateSource(script_state);
+  ReadableStream::CreateWithCountQueueingStrategy(script_state, source, 0);
+
+  source->OnSourceTransferStarted();
+  task_environment_.RunUntilIdle();
+}
+
 }  // namespace blink
Loading diff…

Original Bug Report

reported by [email protected]

UAF in RTCEncodedVideo/AudioUnderlyingSource during worker termination

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: RTCEncodedVideoUnderlyingSource and RTCEncodedAudioUnderlyingSource (all four receiver/sender instances) perform no cleanup when the worker context that owns them is destroyed, so after an encoded-streams readable is transferred to a DedicatedWorker, the main-thread-owned RTCEncoded*StreamTransformer keeps a callback holding a strong CrossThreadPersistent into the worker’s Oilpan heap. Frame-delivery tasks already queued on the main thread before the runner switch later unwrap that persistent with an unsynchronized Get() while HeapBase::Terminate() on the dying worker clears it and sweeps (frees) the object. The result is a use-after-free in the renderer process, reachable from ordinary web content, with the first dereference being a virtual call through a pointer loaded from freed memory. Demonstrated end-to-end on a Linux ASan build (use-after-poison in RTCEncodedAudioUnderlyingSource::OnFrameFromSource).

Affected files:

Estimated timestamp from git blame: 2024-06-18

Summary

A page creates a loopback RTCPeerConnection with encodedInsertableStreams: true, calls receiver.createEncodedStreams(), and postMessage-transfers the readable to a DedicatedWorker. Deserialization runs on the worker thread and allocates a new RTCEncoded*UnderlyingSource on the worker’s cppgc heap, binding it as a strong CrossThreadPersistent into the transformer callback — before the source task runner is switched, so frame tasks queued on the (attacker-busy-looped) main thread stay queued there and will invoke the new worker-bound callback when drained.

Neither underlying-source class overrides ContextDestroyed(); the base implementation only deactivates the stream controller (third_party/blink/renderer/core/streams/underlying_source_base.cc:58-67). Nothing resets the transformer callback on worker termination — an exhaustive search of ResetTransformerCallback callers confirms it fires only from JS Cancel()/Close(), and RTCRtpReceiver::ContextDestroyed / RTCRtpSender::ContextDestroyed run only for the main context. Worker termination therefore leaves a strong CTP into the terminating heap inside the live, main-owned transformer.

HeapBase::Terminate() on the worker clears that CTP with a plain store under a lock the main-thread reader never takes, then sweeps the heap. Each drained stale frame task unwraps the CTP via a lock-free Get() (documented “Not thread-safe”); a drain straddling the clear→sweep window passes a stale pointer to freed memory as this into OnFrameFromSource. Blink’s own header documents this exact pattern as a use-after-free (third_party/blink/renderer/platform/heap/cross_thread_persistent.h:20-24), and the identical bug in breakout_box was fixed via a ContextDestroyed() override (third_party/blink/renderer/modules/breakout_box/transferred_frame_queue_underlying_source.cc:51-56).

All four instances share the defect: receiver audio/video and sender audio/video. The in-code TODO(https://crbug.com/1506631) at rtc_encoded_video_underlying_source.cc:94-102 (and audio :94-102) already acknowledges stale-runner frame delivery during transfer.


Root Cause Analysis

  1. Transfer rebinds the callback to a worker-heap object before switching runners: stream deserialization on the worker thread (third_party/blink/renderer/core/streams/transferable_streams.cc:1083-1084 → third_party/blink/renderer/modules/peerconnection/rtc_encoded_video_receiver_source_optimizer.cc:19-34) allocates the new source on the worker’s cppgc heap and calls:
    // rtc_rtp_receiver.cc:532-542 (SetVideoUnderlyingSource; audio at :386-396)
    video_from_depacketizer_underlying_source_ = new_underlying_source;   // CrossThreadPersistent field
    encoded_video_transformer_->SetTransformerCallback(CrossThreadBindRepeating(
        &RTCEncodedVideoUnderlyingSource::OnFrameFromSource,
        video_from_depacketizer_underlying_source_));   // strong CTP copied into BindState
    ...
    encoded_video_transformer_->SetSourceTaskRunner(    // runner switched only AFTER the rebind
        std::move(new_source_task_runner));
    
  2. No cleanup on worker termination: rtc_encoded_video_underlying_source.h:23-91 has no ContextDestroyed() override (audio identical); disconnect_callback_ runs only from Cancel() (rtc_encoded_video_underlying_source.cc:73-74) or Close() (:139-140). No caller of Broker::ResetTransformerCallback fires on worker death, so the transformer — refcounted and owned by the main-thread receiver/sender, which JS keeps alive — retains the worker-heap CTP indefinitely.
  3. Stale frame tasks sit on the main thread: the initial source runner is the main thread (rtc_rtp_receiver_impl.cc:153-166), and WebRTC threads post Broker::TransformFrameOnSourceTaskRunner to the current runner (rtc_encoded_video_stream_transformer.cc:97-135). Tasks queued before the switch survive it; when drained, TransformFrame reads the callback at drain time and invokes the new worker-bound one (:360; audio rtc_encoded_audio_stream_transformer.cc:188).
  4. The strong-CTP unwrap is unsynchronized: CrossThreadBindRepeating is plain base::BindRepeating (platform/wtf/cross_thread_functional.h:79-91); weak-unwrap traits exist only for the weak CTP variant (platform/heap/cross_thread_persistent.h:74-93), so each Run() dereferences the strong CTP via Get() — a plain load documented “Not thread-safe” (v8/include/cppgc/cross-thread-persistent.h:260-272,313-319).
  5. Worker termination clears and frees concurrently: worker.terminate() → WorkerThread::PrepareForShutdownOnWorkerThread (core/workers/worker_thread.cc:824) → V8PerIsolateData::Destroy (core/workers/worker_backing_thread.cc:181-196) → ~CppHeap → HeapBase::Terminate() (v8/src/heap/cppgc-internal/heap-base.cc:230-299), which null-clears the strong-CTP region (plain raw_ = nullptr, v8/include/cppgc/cross-thread-persistent.h:34-38), runs pre-finalizers, and sweeps the object — racing the main thread’s lock-free Get().
  6. First dereference is the exploitable access: a straddling drain enters OnFrameFromSource(this = <swept memory>); its first operation loads the task_runner_ scoped_refptr from the freed object and makes a virtual call BelongsToCurrentThread() (rtc_encoded_audio_underlying_source.cc:98, video :98) — control flow through a pointer read from reclaimable heap. If a groomed fake passes the check, execution continues deeper into the freed object (disconnect_callback_, GetController()->Enqueue(...)).

Reproduction

  1. Create a loopback pair: pc1 = new RTCPeerConnection(), pc2 = new RTCPeerConnection({encodedInsertableStreams: true}); add an audio track from AudioContext oscillator → createMediaStreamDestination() (the audio rendering thread keeps producing ~50 frames/s while the main thread is blocked); complete offer/answer + ICE.
  2. In pc2.ontrack, call receiver.createEncodedStreams() and keep the returned readable without attaching a reader.
  3. Create a DedicatedWorker, pre-spun so the transfer message is processed late and ~35 stale Broker::TransformFrameOnSourceTaskRunner tasks accumulate on the main thread.
  4. worker.postMessage(readable, [readable]), then enter a synchronous main-thread busy-loop; from within it, after the worker has deserialized and rebound the callback, call worker.terminate(). (self.close() cannot complete while the parent is blocked — the close handshake routes through the parent.)
  5. The worker thread shuts down through HeapBase::Terminate() (CTP clear + heap sweep) while the busy-loop ends and the main thread drains the stale frame tasks.
  6. Expected: worker teardown severs the transformer callback (as breakout_box does), so stale drains are dropped.

Observed: on a Linux ASan build of the unmodified tree, the straddling drain produces a deterministic (2/2) ASan use-after-poison — READ of size 8 at this+0x28 (the task_runner_ load, rtc_encoded_audio_underlying_source.cc:98) on the main thread, stack RTCEncodedAudioUnderlyingSource::OnFrameFromSource ← BindState<..., BasicCrossThreadPersistent<RTCEncodedAudioUnderlyingSource, StrongCrossThreadPersistentPolicy>>::Run ← TransformFrame ← main-thread task drain — when the two racing threads are held to one legal interleaving (a debugger used purely as an adversarial scheduler on the stock binary; equivalent to an unlucky preemption between the CTP Get() and the first member load). Fully unassisted on the stock binary, the same stale-drain path crashes with SEGV_MAPERR at address 0x28 (post-Terminate drain: cleared CTP → this == nullptr + task_runner_ offset), same call chain — proving the backlog, callback swap, missing cleanup, and CTP clearing all occur naturally; only the sub-microsecond straddle phase itself needs scheduling luck, and unassisted straddle attempts did not hit within bounded runs. Attempts are repeatable without limit (fresh transceiver+worker pair per cycle, many per page; every stale frame task is one unlocked read) and the window widens under attacker-created CPU oversubscription.

Severity

high — use-after-free memory corruption in the sandboxed renderer process, reachable from ordinary web content with no permission prompts, no flags (createEncodedStreams() is ungated in rtc_rtp_receiver.idl:20-22; encodedInsertableStreams is a plain RTCConfiguration member), and no unusual interaction (one routine click for autoplay in the audio variant). The dangling reference is a cppgc CrossThreadPersistent inside a base::BindState, not a raw_ptr<T>, so MiraclePtr/BackupRefPtr does not apply, and the freed range is groomable worker-heap memory with the first access being a virtual dispatch. The race is a single timing condition (main-thread drain straddling the worker heap’s clear→sweep) with unbounded free retries and attacker-tunable scheduling pressure, so the demotion for multiple independent timing conditions does not apply; the per-attempt probability is low, but the identical race structure in breakout_box was treated as a high-severity class and fixed.

Suggested Fix Direction

Override ContextDestroyed() in both RTCEncodedAudioUnderlyingSource and RTCEncodedVideoUnderlyingSource to run disconnect_callback_ (the thread-safe Broker::ResetTransformerCallback) during worker-context teardown, before heap termination — mirroring TransferredFrameQueueUnderlyingSource::ContextDestroyed(); this shape was verified to close the race (patched build survives the previously crashing interleaving, and the crash returns on revert). Longer term, make the transfer-time callback/runner update atomic as contemplated by crbug.com/1506631.


POC: UAF — RTCEncodedVideo/AudioUnderlyingSource lack worker-termination cleanup; stale main-thread TransformFrame tasks unwrap a strong CrossThreadPersistent into the worker heap racing HeapBase::Terminate()

Strategy: web-content Bug Type: heap use-after-free (ASAN use-after-poison on a swept cppgc/Oilpan worker-heap object; NOT MiraclePtr-protected — the dangling reference is a strong cppgc::internal::BasicCrossThreadPersistent inside a base::BindState, not a raw_ptr<T>)

Summary

RTCRtpReceiver.createEncodedStreams() builds a readable stream whose frames are delivered by RTCEncodedAudioStreamTransformer::Broker::TransformFrameOnSourceTaskRunner tasks posted to the renderer main thread. When the readable is postMessage-transferred to a DedicatedWorker, deserialization on the worker thread runs RtcEncodedAudioReceiverSourceOptimizer::PerformInProcessOptimization, which allocates a new RTCEncodedAudioUnderlyingSource on the worker’s cppgc heap and rebinds the transformer callback to a strong CrossThreadPersistent of it — and only then switches the source task runner. Frame tasks already queued on the (attacker-busy-looped) main thread stay there.

RTCEncoded{Audio,Video}UnderlyingSource has no ContextDestroyed() override (the disconnect callback only runs from JS-driven Cancel()/ Close()), so worker termination never resets the transformer callback. HeapBase::Terminate() on the dying worker null-clears the CrossThreadPersistent (plain store, no lock shared with readers) and sweeps (frees + ASAN-poisons) the object. The stale main-thread tasks then Run() the callback, unwrapping the CTP with an unsynchronized Get():

  • drain before the clear → wrong-thread check drops the frame (benign);
  • drain after Terminate completes → Get() returns nullptr → OnFrameFromSource(this=nullptr) → SEGV at this+0x28 (see calib-null-deref-full.log, produced UNASSISTED on the stock binary);
  • drain straddling the clear→sweep window → Get() returns the stale pointer and the first member load (task_runner_->BelongsToCurrentThread(), rtc_encoded_audio_underlying_source.cc:98) reads the freed, poisoned object → ASAN use-after-poison (see asan-uaf-report.log).

All four instances share the defect (receiver/sender × audio/video); this POC demonstrates the receiver-audio instance.

Files

file purpose
poc.html / poc.js / worker.js / spin-worker.js the web content POC (modes: smoke, probe, calib, gdb, race)
server.py HTTP server (COOP/COEP for SharedArrayBuffer signaling)
stun_server.py minimal local STUN responder (this sandbox yields no host ICE candidates; a real attacker page uses its own STUN/TURN the same way)
run_gdb_poc.sh + gdb_master.py deterministic reproduction driver (gdb as thread scheduler, unmodified binary)
run_calib.sh unassisted drivers: calib (stock null-this SEGV variant), race (stochastic hunt for the natural straddle)
asan-uaf-report.log ASAN use-after-poison report + offline-symbolized stack
gdb-choreography.log scheduler log: object memory before sweep (live vtable) and after (zapped+poisoned), interleaving steps
gdb-attack-chrome-full.log full Chrome stderr of the UAF run
calib-null-deref-full.log stock-binary, debugger-free crash of the same stale-drain path (SEGV_MAPERR 000000000028 = task_runner_ offset with this==nullptr)
bandaid-fix.patch minimal fix: ContextDestroyed() overrides severing the CTP before heap termination
bandaid-verification-trace.log fix verification: ContextDestroyed → Broker::ResetTransformerCallback now runs BEFORE HeapBase::Terminate

Prerequisites

  • Linux ASAN build of Chrome at /chromium_src/src/out/asan/chrome (component build; no source modifications — the POC runs against the stock binary).
  • X display, gdb, python3.

Reproduction

A. Deterministic UAF (primary)

cd /poc-artifacts
./run_gdb_poc.sh attack 3        # typically succeeds on iteration 1
# UAF report:   /tmp/poc-run-gdb/chrome-1.log
# schedule log: /tmp/poc-run-gdb/gdb-choreo-1.log

The page performs the whole attack from ordinary web content (loopback RTCPeerConnection pair, encodedInsertableStreams:true, oscillator → MediaStreamAudioDestinationNode track, createEncodedStreams() in ontrack, transfer of the readable to a DedicatedWorker, main-thread busy-loop, worker.terminate() issued from inside the busy-loop, then drain). gdb attaches to the renderer and acts purely as an adversarial thread scheduler on the unmodified binary — it enforces one legal interleaving of the two racing threads (equivalent to an unlucky preemption):

  1. park the worker thread at cppgc::internal::HeapBase::Terminate (pre-clear);
  2. run only the main thread until a stale TransformFrame task enters OnFrameFromSource (the strong-CTP Get() in BindState::Run has executed; no member of this touched yet);
  3. run only the worker until Terminate returns (CTP cleared, heap swept: the object is freed and ASAN-poisoned — the choreography log dumps the object bytes before/after);
  4. resume the main thread: its next instruction loads this->task_runner_ from the swept object → ASAN use-after-poison, READ of size 8 at this+0x28 on thread T0 in RTCEncodedAudioUnderlyingSource::OnFrameFromSource (rtc_encoded_audio_underlying_source.cc:98).

YAMA note: ptrace_scope=1 in this sandbox, so the driver launches Chrome as a child of the shell that execs gdb, making gdb an ancestor of the renderer.

B. Unassisted stock-binary crash of the same path (no debugger)

./run_calib.sh calib 1
# -> renderer crash: "Received signal 11 SEGV_MAPERR 000000000028"
#    in OnFrameFromSource <- BindState(strong CTP) <- TransformFrame
#    <- Broker::TransformFrameOnSourceTaskRunner <- main-thread task drain

Here the drain happens after HeapBase::Terminate has completed, so the cleared CTP yields this==nullptr and the same first member load faults at offset 0x28. This proves — with zero assistance — that worker termination clears the still-registered transformer callback’s CTP while stale frame tasks are still queued and later executed. Only the drain phase differs from the UAF interleaving.

C. Natural-race lottery (optional, unassisted UAF hunt)

./run_calib.sh race 400   # loops Chrome; many (receiver,worker) pairs/attempt,
                          # staggered worker.terminate(), dwell phase sweep,
                          # CPU oversubscription; stops on use-after-poison

The straddle window is sub-microsecond wide per stale task (Get() → first member load) unless the OS preempts the main thread exactly inside it, so unassisted hits are rare; modes A+B together already prove every element of the bug on the real code paths.

Expected Result

Mode A ends with (full report + offline symbolization in asan-uaf-report.log):

==1==ERROR: AddressSanitizer: use-after-poison on address 0x7eb4021c12a0 ...
READ of size 8 at 0x7eb4021c12a0 thread T0 (chrome)
    #0 libblink_modules.so+0x5063ec4  RTCEncodedAudioUnderlyingSource::OnFrameFromSource
                                      (scoped_refptr::operator->, task_runner_ load, :98)
    #1 libblink_modules.so+0x5069ef8  BindState<..., BasicCrossThreadPersistent<
                                      RTCEncodedAudioUnderlyingSource,
                                      StrongCrossThreadPersistentPolicy>>::Run
    #2-5 libblink_platform.so         TransformFrame / Broker::TransformFrameOnSourceTaskRunner

with the whole object range in shadow state f7 (cppgc’s poisoning of swept memory), and gdb-choreography.log showing the object’s vtable before the sweep and zapped memory after.

Environment accommodations (do not affect the vulnerable path)

  • --autoplay-policy=no-user-gesture-required — sandbox has no input; a real page needs one user click anywhere to start the AudioContext.
  • Local STUN responder + iceServers:[{urls:'stun:127.0.0.1:3478'}] — this sandbox produces no host ICE candidates (empty network-list enumeration); srflx candidates via one’s own STUN server is exactly what a real attacker uses.
  • --no-proxy-server, --user-data-dir, --no-first-run, logging flags — lab plumbing only. No Chrome feature defaults were changed.

Bandaid Fix (bandaid-fix.patch)

Adds ContextDestroyed() overrides to RTCEncodedAudioUnderlyingSource and RTCEncodedVideoUnderlyingSource (covering receiver+sender instances) that run the disconnect_callback_ — i.e. the thread-safe Broker::ResetTransformerCallback() — on the owning thread during context teardown, BEFORE the worker heap is terminated. This mirrors the fix of the identical bug in breakout_box (TransferredFrameQueueUnderlyingSource::ContextDestroyed()).

Verified: with the patch applied,

  • mode B runs clean (attempt 0 survived, no crash — previously a guaranteed SEGV), and
  • bandaid-verification-trace.log (gdb trace of the patched build) shows WorkerThread::PrepareForShutdownOnWorkerThread → RTCEncodedAudioUnderlyingSource::ContextDestroyed → Broker::ResetTransformerCallback all completing before HeapBase::Terminate, closing the race window;
  • after reverting the patch and rebuilding, both crashes reproduce again.

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
Links in the report