CVE-2026-28944
Overview
Background
- RTCRtpScriptTransform / Encoded Transform
- A WebRTC API letting a page process encoded audio/video RTP frames in a worker via a ReadableStream/WritableStream pair before send or after receive.
- RTCEncodedStreamProducer
- The WebCore glue that exposes incoming encoded frames as a ReadableStream and takes frames written back via writeFrame to hand to the native transform backend.
- RTCRtpTransformBackend
- The platform-side object (libwebrtc) that packetizes/decodes transformed frames; it expects frames of the media type it was created for (audio vs video).
- Media-type confusion
- Supplying a frame of the wrong media type (audio frame to a video pipeline) so native code misinterprets buffer layout/size, a form of type confusion leading to a crash.
- WeakPtr
- A non-owning smart pointer that automatically becomes null when its target is destroyed, used here so a frame’s remembered transformer can be checked without dangling.
Root Cause Analysis
The bug is in the WebRTC Encoded Transform (RTCRtpScriptTransform) write path in Source/WebCore/Modules/mediastream/RTCEncodedStreamProducer.cpp. A script transformer exposes a WritableStream; frames written into it are converted in RTCEncodedStreamProducer::writeFrame and then handed to the transform backend via transformBackend->processTransformedFrame(rtcFrame.get()). The intended invariant is that a frame written back into a given transformer’s writable must belong to that same transformer and match its media type (audio backend gets audio frames, video backend gets video frames). Before the patch nothing enforced this: script could obtain an RTCEncodedAudioFrame/RTCEncodedVideoFrame object produced by one transformer (e.g. the audio sender) and write() it into a different transformer’s writable (e.g. the video sender), or write a frame that originated from an unrelated transformer. writeFrame would still call processTransformedFrame on a backend whose media type or origin did not match the frame, causing the native backend to interpret an audio frame as video data (or use a frame tied to a foreign/torn-down transformer) and crash.
The fix computes the actual frame type (bool isVideo, set true in the video switchOn branch) and adds the guard ‘if (m_isVideo != isVideo || (m_hasTransformer && !rtcFrame->isFromTransformer(m_transformer.get()))) return { };’, silently dropping any frame whose media type does not match the producer or that did not originate from this producer’s transformer. To support that check the producer now records its owning transformer: start() gains an RTCRtpScriptTransformer* parameter (passed as ’this’ from RTCRtpScriptTransformer::start), stored as m_hasTransformer/m_transformer, and enqueueFrame stamps each outgoing frame via frame->setTransformer(m_transformer). RTCRtpTransformableFrame is hardened in parallel: setTransformer now takes a WeakPtr<RTCRtpScriptTransformer> (rather than a reference) and isFromTransformer takes a raw pointer, so a frame’s remembered transformer is a weak reference that safely reads null if the transformer was destroyed, preventing a stale comparison. The added WPT test writes audio frames on the video sender and vice-versa and expects the operation to be safely ignored (PASS) rather than crashing.
Attack Path
- Install two script transforms Malicious page sets up RTCRtpScriptTransform on two RTCRtpSenders (or a sender and receiver) of different media types — e.g. an audio track and a video track — so it holds two transformers with distinct backends.
- Capture frames from both readables In each transformer’s transform worker, read() a chunk (an RTCEncodedAudioFrame from the audio side, an RTCEncodedVideoFrame from the video side) and stash the frame objects in shared variables.
- Cross-write mismatched frames Write the captured video frame into the audio transformer’s writable (or the audio frame into the video transformer’s writable), or write a frame belonging to a different transformer, coordinating timing between the workers as the test harness does.
- Reach the unchecked backend call Pre-patch writeFrame performs no type/origin check and calls transformBackend->processTransformedFrame with the mismatched frame.
- Trigger the crash The native RTCRtpTransformBackend processes an audio payload as video (or a frame tied to a foreign/destroyed transformer), leading to an unexpected process crash (denial of service).
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
RTCEncodedStreamProducer::startSource/WebCore/Modules/mediastream/RTCEncodedStreamProducer.cpp |
modified | Adds an RTCRtpScriptTransformer* parameter and records m_hasTransformer/m_transformer so written frames can be validated against the owning transformer. |
RTCEncodedStreamProducer::enqueueFrameSource/WebCore/Modules/mediastream/RTCEncodedStreamProducer.cpp |
modified | Stamps each outgoing frame with frame->setTransformer(m_transformer) so a later write-back can be recognized as originating from this transformer. |
RTCEncodedStreamProducer::writeFrameSource/WebCore/Modules/mediastream/RTCEncodedStreamProducer.cpp |
modified | Computes the frame's real media type and adds the guard rejecting frames whose isVideo mismatches or that did not come from this transformer, returning early before processTransformedFrame. |
RTCRtpScriptTransformer::startSource/WebCore/Modules/mediastream/RTCRtpScriptTransformer.cpp |
modified | Passes 'this' to m_streamProducer->start so the producer knows its owning transformer. |
RTCRtpTransformableFrame::setTransformerSource/WebCore/Modules/mediastream/RTCRtpTransformableFrame.h |
modified | Signature changed to take WeakPtr<RTCRtpScriptTransformer> (was a reference), storing a weak reference to the originating transformer. |
RTCRtpTransformableFrame::isFromTransformerSource/WebCore/Modules/mediastream/RTCRtpTransformableFrame.h |
modified | Signature changed to take a raw pointer and compares against the weak m_transformer, safely handling a destroyed transformer. |
Files Changed
LayoutTests/http/wpt/webrtc/audio-video-transform.jsLayoutTests/http/wpt/webrtc/audiovideo-script-transform-expected.txtLayoutTests/http/wpt/webrtc/audiovideo-script-transform.htmlSource/WebCore/Modules/mediastream/RTCEncodedStreamProducer.cppSource/WebCore/Modules/mediastream/RTCEncodedStreamProducer.hSource/WebCore/Modules/mediastream/RTCRtpScriptTransformer.cppSource/WebCore/Modules/mediastream/RTCRtpTransformableFrame.h
Audit Directions
- Other frame entry points in RTCEncodedStreamProducerVerify every path that reaches processTransformedFrame or enqueue validates media type and transformer origin; grep the file for ‘processTransformedFrame’, ’m_isVideo’, and ‘isFromTransformer’.
- Frame provenance stamping completenessConfirm all producers/backends set a transformer before frames become script-visible so m_hasTransformer is never falsely false; grep for ‘setTransformer(’ and its callers, and check RTCRtpTransformBackend subclasses.
- Reference-to-WeakPtr hardening variantsLook for other cross-object ‘setX(Foo&)’ patterns in the mediastream/webrtc modules that store a reference to a script-controllable object and could dangle; grep for ‘WeakPtr<’ members initialized from references and ASSERT(!m_…) reset patterns.
- Type-tag validation on other transform pipelinesAudit analogous ReadableStream/WritableStream transform bridges (e.g. WebCodecs, MediaStreamTrack processors) for missing type/origin checks when script writes objects back into a native sink; grep for ‘writeFrame’, ‘switchOn’, and ‘MediaType::Video’.
Patch
diff --git a/LayoutTests/http/wpt/webrtc/audio-video-transform.js b/LayoutTests/http/wpt/webrtc/audio-video-transform.js
index 46f520b50530..565591b39f12 100644
--- a/LayoutTests/http/wpt/webrtc/audio-video-transform.js
+++ b/LayoutTests/http/wpt/webrtc/audio-video-transform.js
@@ -1,3 +1,7 @@
+var audioSenderTransformer, videoSenderTransformer;
+var audioReceiverTransformer, videoReceiverTransformer;
+var audioChunk, videoChunk;
+
class AudioVideoRTCRtpTransformer {
constructor(transformer) {
this.askKeyFrame = false;
@@ -17,7 +21,24 @@ class AudioVideoRTCRtpTransformer {
this.tryAccessingDataTwice = true;
else if (event.data === "tryAccessingMetadata")
this.tryAccessingMetadata = true;
+ else if (event.data === "tryWritingAudio")
+ this.tryWritingAudio = true;
+ else if (event.data === "tryWritingVideo")
+ this.tryWritingVideo = true;
};
+
+ if (this.context.options.side === "sender") {
+ if (this.context.options.mediaType === "audio")
+ audioSenderTransformer = this;
+ else if (this.context.options.mediaType === "video")
+ videoSenderTransformer = this;
+ } else {
+ if (this.context.options.mediaType === "audio")
+ audioReceiverTransformer = this;
+ else if (this.context.options.mediaType === "video")
+ videoReceiverTransformer = this;
+ }
+
this.start();
}
start()
@@ -29,10 +50,98 @@ class AudioVideoRTCRtpTransformer {
process()
{
- this.reader.read().then(chunk => {
+ this.reader.read().then(async chunk => {
if (chunk.done)
return;
+ if (audioSenderTransformer && audioSenderTransformer.tryWritingVideo) {
+ if (audioSenderTransformer === this) {
+ this.writer.write(chunk.value);
+ if (videoChunk !== undefined) {
+ this.writer.write(videoChunk.value);
+ audioSenderTransformer.tryWritingVideo = false;
+ this.context.options.port.postMessage("PASS");
+ }
+ this.process();
+ return;
+ }
+ if(videoSenderTransformer === this) {
+ videoChunk = chunk;
+ while (audioSenderTransformer.tryWritingVideo)
+ await new Promise(resolve => setTimeout(resolve, 50));
+ videoSenderTransformer.writer.write(videoChunk);
+ videoChunk = undefined;
+ this.process();
+ return;
+ }
+ }
+
+ if (videoSenderTransformer && videoSenderTransformer.tryWritingAudio) {
+ if (videoSenderTransformer === this) {
+ this.writer.write(chunk.value);
+ if (audioChunk !== undefined) {
+ this.writer.write(audioChunk.value);
+ videoSenderTransformer.tryWritingAudio = false;
+ this.context.options.port.postMessage("PASS");
+ }
+ this.process();
+ return;
+ }
+ if(audioSenderTransformer === this) {
+ audioChunk = chunk;
+ while (videoSenderTransformer.tryWritingAudio)
+ await new Promise(resolve => setTimeout(resolve, 50));
+ audioSenderTransformer.writer.write(audioChunk);
+ audioChunk = undefined;
+ this.process();
+ return;
+ }
+ }
+
+ if (audioSenderTransformer && audioSenderTransformer.tryWritingAudio) {
+ if (audioSenderTransformer === this) {
+ this.writer.write(chunk.value);
+ if (audioChunk !== undefined) {
+ this.writer.write(audioChunk.value);
+ audioSenderTransformer.tryWritingAudio = false;
+ this.context.options.port.postMessage("PASS");
+ }
+ this.process();
+ return;