← Chrome — 2026-W41

c8af26985b09264fa1d5c0db3cfb4f2e864f5d14  [WebAudio] Enforce channel equality and fix SetChannelCount race

severity medium class Race confidence 0.65 signal score 8.57 third_party/blink/renderer/modules/ patch-now AI review PLAUSIBLE
Primitive: TOCTOU race between main-thread SetChannelCount and audio-thread render -> channel-count mismatch -> OOB in PushPullFIFO copy
Triage note: SetChannelCount now StopPlatformDestination() before mutating 'to avoid racing with the audio thread rendering', plus CHECK_EQ channel counts beside existing SECURITY_CHECKs in PushPullFIFO Push/Pull (Fixed: 493238463). Mismatch is an OOB primitive reachable via Web Audio setChannelCount.
Structural signals: smartptr_member_added (+4.87), null_guard (+1.2), area:third_party/blink/renderer/modules/ (+2.5)

The bug at a glance

A TOCTOU race between the main thread’s RealtimeAudioDestinationHandler::SetChannelCount and the audio rendering thread lets the handler’s channel count change while the audio thread is mid-render, producing an AudioBus whose channel count disagrees with PushPullFIFO’s internal fifo_bus_. The FIFO’s copy loops index per-channel, so a mismatch is an out-of-bounds read/write in the real-time audio path. setChannelCount on an AudioContext destination is directly web-reachable from script, making this a remotely-triggerable OOB — high severity, though the race window makes deterministic exploitation hard.

AudioDestinationNode.channelCount is settable from JS at any time. The main-thread setter mutated the handler’s channel count (and thus the shape of buses created from it) before stopping the running platform destination, so the audio thread — already pulling/pushing render quanta through PushPullFIFO — could observe a half-updated state where the input/output AudioBus channel count no longer matched fifo_bus_->NumberOfChannels().

Root cause

RealtimeAudioDestinationHandler::SetChannelCount previously created the new AudioDestination, then called AudioHandler::SetChannelCount (mutating the handler’s channel count) and only afterward called StopPlatformDestination() to stop the audio thread. Between the handler mutation and the stop, the still-running audio thread could render a quantum whose AudioBus channel count was derived from the new count while PushPullFIFO::fifo_bus_ still had the old channel count, violating the FIFO’s requirement that storage and I/O bus channel counts match. In PushPullFIFO::Push/Pull/PullAndUpdateEarmarkedFrames the per-channel memcpy loops would then index channels that do not exist in one of the buses, an OOB. The fix reorders the sequence: it stops the running platform destination (StopPlatformDestination(), after recording was_playing) BEFORE calling AudioHandler::SetChannelCount, so the audio thread is quiesced before the channel count changes; it adds an early return when channel_count == ChannelCount() to avoid needless teardown; it restarts the old destination if SetChannelCount throws; and it transfers elapsed frames via new_platform_destination->TransferElapsedFramesFrom(platform_destination_). Finally it adds defense-in-depth CHECK_EQ(bus->NumberOfChannels(), fifo_bus_->NumberOfChannels()) in all three FIFO entry points so any residual mismatch aborts instead of corrupting memory.

Key code

Stop the audio thread before mutating the handler channel count, closing the TOCTOU window

// Stop the old platform destination before mutating the channel count on
// the audio handler to avoid racing with the audio thread rendering.
const bool was_playing = platform_destination_->IsPlaying();
StopPlatformDestination();

AudioHandler::SetChannelCount(channel_count, exception_state);
if (exception_state.HadException()) {
  if (was_playing) {
    StartPlatformDestination();
  }
  return;
}

new_platform_destination->TransferElapsedFramesFrom(platform_destination_);

Patch walkthrough

  • third_party/blink/renderer/modules/webaudio/realtime_audio_destination_handler.cc — The root-cause fix. (1) Removes the old ‘uint32_t old_channel_count = ChannelCount();’ bookkeeping. (2) Adds an early ‘if (channel_count == ChannelCount()) return;’ no-op fast path. (3) Reorders the critical section: records was_playing and calls StopPlatformDestination() BEFORE AudioHandler::SetChannelCount(…), so the audio thread is stopped before the channel count mutates — eliminating the window where the render thread sees a mismatched channel count. (4) On exception, restarts the destination if it was playing. (5) Replaces the old stop-recreate-start tail with new_platform_destination->TransferElapsedFramesFrom(platform_destination_) to preserve elapsed-frame continuity, then swaps in the new destination.
  • third_party/blink/renderer/platform/audio/push_pull_fifo.cc — Defense-in-depth. Adds CHECK_EQ(input_bus->NumberOfChannels(), fifo_bus_->NumberOfChannels()) in Push (under lock), and CHECK_EQ(output_bus->NumberOfChannels(), fifo_bus_->NumberOfChannels()) in Pull and PullAndUpdateEarmarkedFrames. These enforce the FIFO’s channel-count invariant at the exact points where mismatched buses would otherwise drive OOB per-channel copies, converting a potential memory-corruption race into a controlled crash.
  • third_party/blink/renderer/platform/audio/push_pull_fifo_test.cc — Adds PushPullFIFOChannelCountTest.MatchingChannelCount (a positive stereo push/pull roundtrip) and ChannelMismatchDeathTests, which use EXPECT_DEATH_IF_SUPPORTED to assert that Push/Pull/PullAndUpdateEarmarkedFrames abort when given mono(1) or multi(3) buses against a 2-channel FIFO — directly covering the new CHECK_EQ guards.

Background

PushPullFIFO — Lock-protected ring buffer bridging the WebAudio render thread (push) and the platform audio device callback (pull). It owns a fixed fifo_bus_ with a set channel count and copies per-channel between fifo_bus_ and the input/output AudioBus; it requires the I/O bus channel count to equal fifo_bus_->NumberOfChannels().

RealtimeAudioDestinationHandler::SetChannelCount — Main-thread handler backing AudioDestinationNode.channelCount. Changing it recreates the platform AudioDestination (the object that owns the audio-thread render callback and the FIFO) with a new channel count.

TOCTOU across render threads — The handler’s channel count (checked/used by the audio thread to size buses) is the shared state; mutating it on the main thread while the audio thread is still running is the time-of-check/time-of-use gap this patch closes by stopping the audio thread first.

TransferElapsedFramesFrom — Carries the elapsed-frame counter from the old AudioDestination to the new one so clock continuity (currentTime/played frames) is preserved across the reconfiguration now that stop happens earlier.

Vulnerability window

  1. Introduction — Latent in RealtimeAudioDestinationHandler::SetChannelCount’s ordering: handler channel-count mutation preceded StopPlatformDestination(), leaving a race window with the audio thread.
  2. Report — Tracked as Chromium issue 493238463 (Bug/Fixed: 493238463).
  3. Fix landed — Committed 2026-10-05 by Hongchan Choi (CL 8490918), Cr-Commit-Position refs/heads/main@{#1711766}, reviewed by Thomas Guilbert.

Proof of concept

The diff ships only unit/death tests for the FIFO CHECK_EQ guards, not a browser-level trigger, so poc.available=false. The conceptual PoC hammers AudioDestinationNode.channelCount from the main thread while the context renders, aiming to land a channel-count change in the window before the audio thread is stopped. It is web-facing and script-reachable with no special permissions. As a race it is NOT deterministic — hitting the window depends on scheduler/audio-callback timing, so reproduction is probabilistic and the new CHECK_EQ now converts a hit into a crash rather than corruption.

Exploitation

  1. Setup (inferred) — Create an AudioContext with a running graph (e.g. oscillator connected to destination) so the audio thread is actively pushing/pulling render quanta through PushPullFIFO.
  2. Trigger race (inferred) — Rapidly toggle ctx.destination.channelCount between differing values (e.g. 1 and 2) from the main thread to try to mutate the handler channel count while the audio thread renders a quantum, before the pre-fix StopPlatformDestination() ran.
  3. Channel-count mismatch (inferred) — The audio thread renders an AudioBus whose channel count differs from fifo_bus_->NumberOfChannels(); PushPullFIFO::Push/Pull per-channel copy loops index a channel absent in one bus, yielding OOB read and/or write in the real-time audio buffers.
  4. Escalation (inferred) — OOB read/write adjacency and any controllable corruption target are not shown by the patch; downstream exploitability beyond the OOB in the audio buffers is inferred and undemonstrated.

Detection & hunting

For defenders and detection engineers:

  • PushPullFIFO CHECK_EQ crashes —
  • Rapid channelCount churn on an active destination —
  • Audio-thread/main-thread reconfiguration overlap —

Audit directions

  • Ordering of handler mutation vs audio-thread stop —
  • PushPullFIFO channel-count invariants elsewhere —
  • Other render-thread-shared mutable state —

Before / after

Loading diff…