CVE-2026-28944
Overview
Impact:
Description: Processing maliciously crafted web content may lead to an unexpected process crash
Researchers: Kenneth Hsu of Palo Alto Networks, Jérôme DJOUDER, dr3dd
Key insight. The write-back path of an encoded-media transform trusted script to return only well-typed frames belonging to that transformer; the fix enforces that trust boundary by tagging each frame with its originating transformer and rejecting any written frame whose media type or origin does not match.
Patch Details
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;
Background
Advisory: Apple Security Advisory
| Attribute | Value |
|---|---|
| CVE | CVE-2026-28944 |
| Bugzilla | 311131 |
| Component | WebCore |
| Bug Class | LogicError |
| Severity | medium |
| Commit | 8384c8455e7b5bc4… |
| Advisory | Apple Advisory |
Terminology
- 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
1. 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.
2. 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.
3. 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.
4. Reach the unchecked backend call
Pre-patch writeFrame performs no type/origin check and calls transformBackend->processTransformedFrame with the mismatched frame.
5. 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
The commit establishes a missing validation that allowed a cross-type/cross-origin frame to reach the native transform backend; the CVE describes the realistic result as an unexpected process crash (denial of service), consistent with a controlled crash rather than a demonstrated read/write primitive. The processing happens in the WebContent process’s WebRTC stack, so any corruption or crash is confined to the sandboxed renderer. The WeakPtr change additionally forecloses a stale/dangling transformer comparison; whether the underlying media-type mismatch could be pushed beyond a crash toward memory corruption is not shown by the diff and would be inference, but the guarded call site (processTransformedFrame on a mistyped buffer) is the kind of primitive that can sometimes be escalated. Severity medium, no sandbox escape indicated.
Audit Directions
Other frame entry points in RTCEncodedStreamProducer
Verify 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 completeness
Confirm 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 variants
Look 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 pipelines
Audit 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’.
Changed Functions
| Function | File | Change | Note |
|---|---|---|---|
RTCEncodedStreamProducer::start |
Source/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::enqueueFrame |
Source/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::writeFrame |
Source/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::start |
Source/WebCore/Modules/mediastream/RTCRtpScriptTransformer.cpp |
modified | Passes ’this’ to m_streamProducer->start so the producer knows its owning transformer. |
RTCRtpTransformableFrame::setTransformer |
Source/WebCore/Modules/mediastream/RTCRtpTransformableFrame.h |
modified | Signature changed to take WeakPtr<RTCRtpScriptTransformer> (was a reference), storing a weak reference to the originating transformer. |
RTCRtpTransformableFrame::isFromTransformer |
Source/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
Source Files
Source/WebCore/Modules/mediastream/RTCEncodedStreamProducer.cppSource/WebCore/Modules/mediastream/RTCEncodedStreamProducer.hSource/WebCore/Modules/mediastream/RTCRtpScriptTransformer.cppSource/WebCore/Modules/mediastream/RTCRtpTransformableFrame.h
Test Files
LayoutTests/http/wpt/webrtc/audio-video-transform.jsLayoutTests/http/wpt/webrtc/audiovideo-script-transform-expected.txtLayoutTests/http/wpt/webrtc/audiovideo-script-transform.html
Regression Test
The fix ships this proof-of-concept / regression test:
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;
+ }
+ if(audioReceiverTransformer === this) {
+ audioChunk = chunk;
+ while (audioSenderTransformer.tryWritingAudio)
+ await new Promise(resolve => setTimeout(resolve, 50));
+ audioReceiverTransformer.writer.write(audioChunk);
+ audioChunk = undefined;
+ this.process();
+ return;
+ }
+ }
+
+ if (videoSenderTransformer && videoSenderTransformer.tryWritingVideo) {
+ if (videoSenderTransformer === this) {
+ this.writer.write(chunk.value);
+ if (videoChunk !== undefined) {
+ this.writer.write(videoChunk.value);
+ videoSenderTransformer.tryWritingVideo = false;
+ this.context.options.port.postMessage("PASS");
+ }
+ this.process();
+ return;
+ }
+ if (videoReceiverTransformer === this) {
+ videoChunk = chunk;
+ while (videoSenderTransformer.tryWritingVideo)
+ await new Promise(resolve => setTimeout(resolve, 50));
+ videoReceiverTransformer.writer.write(videoChunk);
+ videoChunk = undefined;
+ this.process();
+ return;
+ }
+ }
+
if (this.tryAccessingDataTwice) {
this.tryAccessingDataTwice = false;
const data1 = chunk.value.data;
diff --git a/LayoutTests/http/wpt/webrtc/audiovideo-script-transform-expected.txt b/LayoutTests/http/wpt/webrtc/audiovideo-script-transform-expected.txt
index 8c185caca2af..7c263d7fdbae 100644
--- a/LayoutTests/http/wpt/webrtc/audiovideo-script-transform-expected.txt
+++ b/LayoutTests/http/wpt/webrtc/audiovideo-script-transform-expected.txt
@@ -10,4 +10,8 @@ PASS Check data after write
PASS Check modified data after write
PASS Check data getter
PASS Check metadata getter
+PASS Try writing receiver audio data on audio sender
+PASS Try writing receiver video data on video sender
+PASS Try writing video data on audio sender
+PASS Try writing audio data on video sender
diff --git a/LayoutTests/http/wpt/webrtc/audiovideo-script-transform.html b/LayoutTests/http/wpt/webrtc/audiovideo-script-transform.html
index a829632f4da3..6f8ab5537fd2 100644
--- a/LayoutTests/http/wpt/webrtc/audiovideo-script-transform.html
+++ b/LayoutTests/http/wpt/webrtc/audiovideo-script-transform.html
@@ -160,9 +160,33 @@ promise_test(async (test) => {
videoSenderTransform.port.postMessage("tryAccessingMetadata");
metadata =await waitForMessage(videoSenderTransform.port);
assert_array_equals(Object.keys(metadata), ["contributingSources", "dependencies", "frameId", "height", "mimeType", "payloadType", "rtpTimestamp", "spatialIndex", "synchronizationSource", "temporalIndex", "width"]);
+}, "Check metadata getter");
+
+promise_test(async (test) => {
+ audioSenderTransform.port.postMessage("tryWritingAudio");
+ let result = await waitForMessage(audioSenderTransform.port);
+ assert_equals(result, "PASS");
+}, "Try writing receiver audio data on audio sender");
+
+promise_test(async (test) => {
+ videoSenderTransform.port.postMessage("tryWritingVideo");
+ let result = await waitForMessage(videoSenderTransform.port);
+ assert_equals(result, "PASS");
+}, "Try writing receiver video data on video sender");
+
+promise_test(async (test) => {
+ audioSenderTransform.port.postMessage("tryWritingVideo");
+ let result = await waitForMessage(audioSenderTransform.port);
+ assert_equals(result, "PASS");
+}, "Try writing video data on audio sender");
+
+promise_test(async (test) => {
+ videoSenderTransform.port.postMessage("tryWritingAudio");
+ result = await waitForMessage(videoSenderTransform.port);
+ assert_equals(result, "PASS");
test.add_cleanup(() => localStream.getTracks().forEach(t => t.stop()));
-}, "Check metadata getter");
+}, "Try writing audio data on video sender");
</script>
</body>
</html>