CVE-2026-110137
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifthird_party/blink/renderer/modules/peerconnection/rtc_encoded_audio_underlying_source.cc |
modified | |
ifthird_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.ccthird_party/blink/renderer/modules/peerconnection/rtc_encoded_video_underlying_source_test.ccthird_party/blink/renderer/modules/peerconnection/rtc_encoded_video_underlying_source.hthird_party/blink/renderer/modules/peerconnection/rtc_encoded_audio_underlying_source.ccthird_party/blink/renderer/modules/peerconnection/rtc_encoded_video_underlying_source.ccthird_party/blink/renderer/modules/peerconnection/rtc_encoded_audio_underlying_source.h
Patch
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
Regression Test / PoC
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
Original Bug Report
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:
third_party/blink/renderer/modules/peerconnection/rtc_encoded_video_underlying_source.h:23-91— noContextDestroyed()override; no worker-teardown paththird_party/blink/renderer/modules/peerconnection/rtc_encoded_video_underlying_source.cc:68-76,136-145—disconnect_callback_(sole route toResetTransformerCallback) runs only from JS-drivenCancel()/Close()third_party/blink/renderer/modules/peerconnection/rtc_encoded_audio_underlying_source.cc:76-84,136-143— audio counterpart, identical gapthird_party/blink/renderer/modules/peerconnection/rtc_rtp_receiver.cc:377-397,523-543— rebinds transformer callback to a strong CTP of the worker-heap source, then switches the source task runner only afterwardsthird_party/blink/renderer/modules/peerconnection/rtc_rtp_sender.cc:1097-1127,1217-1237— sender variants of the same wiringthird_party/blink/renderer/platform/peerconnection/rtc_encoded_video_stream_transformer.cc:97-135,331-361— WebRTC threads post frame tasks to the pre-switch runner; drained tasks invoke the rebound callback (audio:rtc_encoded_audio_stream_transformer.cc:61-72,182-189)v8/src/heap/cppgc-internal/heap-base.cc:230-299—Terminate()clears strong cross-thread persistents (plain non-atomic store) then sweeps, racing the unlocked reader
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
- 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)); - No cleanup on worker termination:
rtc_encoded_video_underlying_source.h:23-91has noContextDestroyed()override (audio identical);disconnect_callback_runs only fromCancel()(rtc_encoded_video_underlying_source.cc:73-74) orClose()(:139-140). No caller ofBroker::ResetTransformerCallbackfires 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. - 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 postBroker::TransformFrameOnSourceTaskRunnerto the current runner (rtc_encoded_video_stream_transformer.cc:97-135). Tasks queued before the switch survive it; when drained,TransformFramereads the callback at drain time and invokes the new worker-bound one (:360; audiortc_encoded_audio_stream_transformer.cc:188). - The strong-CTP unwrap is unsynchronized:
CrossThreadBindRepeatingis plainbase::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 eachRun()dereferences the strong CTP viaGet()— a plain load documented “Not thread-safe” (v8/include/cppgc/cross-thread-persistent.h:260-272,313-319). - 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 (plainraw_ = nullptr,v8/include/cppgc/cross-thread-persistent.h:34-38), runs pre-finalizers, and sweeps the object — racing the main thread’s lock-freeGet(). - First dereference is the exploitable access: a straddling drain enters
OnFrameFromSource(this = <swept memory>); its first operation loads thetask_runner_scoped_refptrfrom the freed object and makes a virtual callBelongsToCurrentThread()(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
- Create a loopback pair:
pc1 = new RTCPeerConnection(),pc2 = new RTCPeerConnection({encodedInsertableStreams: true}); add an audio track fromAudioContextoscillator →createMediaStreamDestination()(the audio rendering thread keeps producing ~50 frames/s while the main thread is blocked); complete offer/answer + ICE. - In
pc2.ontrack, callreceiver.createEncodedStreams()and keep the returned readable without attaching a reader. - Create a DedicatedWorker, pre-spun so the transfer message is processed late and ~35 stale
Broker::TransformFrameOnSourceTaskRunnertasks accumulate on the main thread. worker.postMessage(readable, [readable]), then enter a synchronous main-thread busy-loop; from within it, after the worker has deserialized and rebound the callback, callworker.terminate(). (self.close()cannot complete while the parent is blocked — the close handshake routes through the parent.)- 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. - 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()returnsnullptr→OnFrameFromSource(this=nullptr)→ SEGV atthis+0x28(seecalib-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 (seeasan-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):
- park the worker thread at
cppgc::internal::HeapBase::Terminate(pre-clear); - run only the main thread until a stale
TransformFrametask entersOnFrameFromSource(the strong-CTPGet()inBindState::Runhas executed; no member ofthistouched yet); - run only the worker until
Terminatereturns (CTP cleared, heap swept: the object is freed and ASAN-poisoned — the choreography log dumps the object bytes before/after); - resume the main thread: its next instruction loads
this->task_runner_from the swept object → ASANuse-after-poison, READ of size 8 atthis+0x28on thread T0 inRTCEncodedAudioUnderlyingSource::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) showsWorkerThread::PrepareForShutdownOnWorkerThread → RTCEncodedAudioUnderlyingSource::ContextDestroyed → Broker::ResetTransformerCallbackall completing beforeHeapBase::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.
- https://crbug.com/1506631
- https://source.chromium.org/chromium/chromium/src/+/main:core/workers/worker_backing_thread.cc;l=181
- https://source.chromium.org/chromium/chromium/src/+/main:core/workers/worker_thread.cc;l=824
- https://source.chromium.org/chromium/chromium/src/+/main:platform/heap/cross_thread_persistent.h;l=74
- https://source.chromium.org/chromium/chromium/src/+/main:platform/wtf/cross_thread_functional.h;l=79
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/streams/transferable_streams.cc;l=1083
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/streams/underlying_source_base.cc;l=58
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/modules/breakout_box/transferred_frame_queue_underlying_source.cc;l=51
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/modules/peerconnection/rtc_encoded_audio_underlying_source.cc;l=76
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/modules/peerconnection/rtc_encoded_video_receiver_source_optimizer.cc;l=19
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/modules/peerconnection/rtc_encoded_video_underlying_source.cc;l=68
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/modules/peerconnection/rtc_encoded_video_underlying_source.h;l=23
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/modules/peerconnection/rtc_rtp_receiver.cc;l=377
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/modules/peerconnection/rtc_rtp_sender.cc;l=1097
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/platform/heap/cross_thread_persistent.h;l=20
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/platform/peerconnection/rtc_encoded_video_stream_transformer.cc;l=97
- https://source.chromium.org/chromium/chromium/src/+/main:v8/include/cppgc/cross-thread-persistent.h;l=260
- https://source.chromium.org/chromium/chromium/src/+/main:v8/include/cppgc/cross-thread-persistent.h;l=34
- https://source.chromium.org/chromium/chromium/src/+/main:v8/src/heap/cppgc-internal/heap-base.cc;l=230