Overview

Low
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactSide-channel information leakage in WebAudio
DescriptionSide-channel information leakage in WebAudio
ComponentWebAudio
Bug ClassLogic Error
Tracker506143724
Fix commitf5e9786b4143 (chromium/src) +74/-10
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-30

Changed Functions

FunctionChangeNotes
if
third_party/blink/renderer/modules/webaudio/audio_context.cc
modified
TEST_F
third_party/blink/renderer/modules/webaudio/audio_context_test.cc
modified

Files Changed

  • third_party/blink/renderer/modules/webaudio/audio_context.cc
  • third_party/blink/renderer/modules/webaudio/audio_context_test.cc
From f5e9786b4143111b4e61b0db8d5d63819acf114d Mon Sep 17 00:00:00 2001
From: Hongchan Choi <[email protected]>
Date: Tue, 05 May 2026 15:35:29 -0700
Subject: [PATCH] [WebAudio] Discard playback stats collected while page is hidden

Discards audio playback statistics (glitches, latency) collected while
the document is hidden without audio capture permission. Previously,
these were accumulated and released on un-hide, failing the intent of
privacy mitigations.

StatsUpdateRestrictor is refactored to separate visibility checks from
rate limiting to avoid duplicate atomic reads. A unit test is added to
verify stats are discarded.

Bug: 506143724
Change-Id: I879f6d6e384ec784c5573ee1ceb1b33383654a3a
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7808857
Reviewed-by: Fredrik Hernqvist <[email protected]>
Commit-Queue: Hongchan Choi <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1625767}
---

diff --git a/third_party/blink/renderer/modules/webaudio/audio_context.cc b/third_party/blink/renderer/modules/webaudio/audio_context.cc
index e60e4548..dc501ff 100644
--- a/third_party/blink/renderer/modules/webaudio/audio_context.cc
+++ b/third_party/blink/renderer/modules/webaudio/audio_context.cc
@@ -173,14 +173,13 @@
   StatsUpdateRestrictor() : clock_(base::DefaultTickClock::GetInstance()) {}
 
   // Should only be called from the audio thread.
-  bool StatUpdateAllowed() {
-    // Stats should only be updated if the page is visible or the application
-    // has audio capture permission.
-    if (!(visible_.load(std::memory_order_relaxed) ||
-          has_capture_permission_.load(std::memory_order_relaxed))) {
-      return false;
-    }
+  bool IsVisibleOrHasPermission() const {
+    return visible_.load(std::memory_order_relaxed) ||
+           has_capture_permission_.load(std::memory_order_relaxed);
+  }
 
+  // Should only be called from the audio thread.
+  bool CheckAndConsumeRateLimit() {
     static const base::TimeDelta kMinTimeBetweenStatUpdates = base::Seconds(1);
     base::TimeTicks now_time = clock_->NowTicks();
     if (now_time - last_update_time_ < kMinTimeBetweenStatUpdates) {
@@ -1412,8 +1411,13 @@
     const media::AudioGlitchInfo& glitch_info) {
   DCHECK(IsAudioThread());
 
-  pending_audio_frame_stats_.Update(frames_to_process, sampleRate(),
-                                    playout_delay, glitch_info);
+  bool is_stat_collection_allowed =
+      stats_update_restrictor_->IsVisibleOrHasPermission();
+
+  if (is_stat_collection_allowed) {
+    pending_audio_frame_stats_.Update(frames_to_process, sampleRate(),
+                                      playout_delay, glitch_info);
+  }
 
   // At the beginning of every render quantum, try to update the internal
   // rendering graph state (from main thread changes).  It's OK if the tryLock()
@@ -1440,7 +1444,8 @@
 
     callback_metric_ = *metric;
 
-    if (stats_update_restrictor_->StatUpdateAllowed()) {
+    if (is_stat_collection_allowed &&
+        stats_update_restrictor_->CheckAndConsumeRateLimit()) {
       audio_frame_stats_.Absorb(pending_audio_frame_stats_);
     }
 
diff --git a/third_party/blink/renderer/modules/webaudio/audio_context_test.cc b/third_party/blink/renderer/modules/webaudio/audio_context_test.cc
index 32823e7..09a7dd3 100644
--- a/third_party/blink/renderer/modules/webaudio/audio_context_test.cc
+++ b/third_party/blink/renderer/modules/webaudio/audio_context_test.cc
@@ -1395,6 +1395,65 @@
                                        /*expect_change=*/true);
 }
 
+TEST_F(AudioContextStatsTest, PlaybackStatsVisibilityDataDiscard) {
+  blink::WebRuntimeFeatures::EnableFeatureFromString(
+      "AudioContextPlaybackStats", true);
+  AudioContextOptions* options = AudioContextOptions::Create();
+  AudioContext* audio_context = AudioContext::Create(
+      GetFrame().DomWindow(), options, ASSERT_NO_EXCEPTION);
+  audio_context->set_clock_for_testing(this);
+  FlushPermissionService(audio_context);
+
+  ScriptState* script_state = ToScriptStateForMainWorld(&GetFrame());
+  ContextRenderer* renderer =
+      MakeGarbageCollected<ContextRenderer>(audio_context);
+  renderer->Init();
+
+  // 1. Page is visible by default. Render some baseline audio.
+  RenderAndCheckIfPlaybackStatsChanged(
+      base::Seconds(1), renderer, script_state, /*expect_change=*/true);
+
+  AudioPlaybackStats* playback_stats = audio_context->playbackStats();
+  int glitches_before = playback_stats->underrunEvents(script_state);
+  double duration_before = playback_stats->totalDuration(script_state);
+
+  // 2. Hide the page.
+  GetPage().SetVisibilityState(mojom::blink::PageVisibilityState::kHidden,
+                               /*is_initial_state=*/false);
+
+  // 3. Render audio with glitches while hidden.
+  fake_time_now_ += base::Seconds(1);
+  renderer->Render(
+      1000, base::Milliseconds(50),
+      media::AudioGlitchInfo{.duration = base::Milliseconds(10), .count = 1});
+  ToEventLoop(script_state).PerformMicrotaskCheckpoint();
+
+  // 4. Make the page visible again.
+  GetPage().SetVisibilityState(mojom::blink::PageVisibilityState::kVisible,
+                               /*is_initial_state=*/false);
+
+  // 5. Render one quantum without glitches to trigger stats update.
+  fake_time_now_ += base::Seconds(1);
+  renderer->Render(1000, base::Milliseconds(50), media::AudioGlitchInfo{});
+  ToEventLoop(script_state).PerformMicrotaskCheckpoint();
+
+  // 6. Verify that stats did NOT increase by the glitches or the duration from
+  // the hidden period.
+  int glitches_after = playback_stats->underrunEvents(script_state);
+  EXPECT_EQ(glitches_before, glitches_after);
+  double duration_after =
+      duration_before + media::AudioTimestampHelper::FramesToTime(
+                            1000, audio_context->sampleRate())
+                            .InSecondsF();
+  // We use EXPECT_NEAR with a 10 microseconds tolerance to allow for
+  // sub-microsecond rounding errors from integer time conversion in
+  // AudioTimestampHelper::FramesToTime. The tolerance is smaller than 1 audio
+  // frame (~20.8 microseconds at 48kHz), ensuring any actually processed frame
+  // would still trigger a failure.
+  EXPECT_NEAR(playback_stats->totalDuration(script_state),
+              duration_after, 0.00001);
+}
+
 TEST_F(AudioContextStatsTest, PlaybackStatsMicrophoneRestrictionStartsDenied) {
   blink::WebRuntimeFeatures::EnableFeatureFromString(
       "AudioContextPlaybackStats", true);
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/third_party/blink/renderer/modules/webaudio/audio_context_test.cc b/third_party/blink/renderer/modules/webaudio/audio_context_test.cc
index 32823e7..09a7dd3 100644
--- a/third_party/blink/renderer/modules/webaudio/audio_context_test.cc
+++ b/third_party/blink/renderer/modules/webaudio/audio_context_test.cc
@@ -1395,6 +1395,65 @@
                                        /*expect_change=*/true);
 }
 
+TEST_F(AudioContextStatsTest, PlaybackStatsVisibilityDataDiscard) {
+  blink::WebRuntimeFeatures::EnableFeatureFromString(
+      "AudioContextPlaybackStats", true);
+  AudioContextOptions* options = AudioContextOptions::Create();
+  AudioContext* audio_context = AudioContext::Create(
+      GetFrame().DomWindow(), options, ASSERT_NO_EXCEPTION);
+  audio_context->set_clock_for_testing(this);
+  FlushPermissionService(audio_context);
+
+  ScriptState* script_state = ToScriptStateForMainWorld(&GetFrame());
+  ContextRenderer* renderer =
+      MakeGarbageCollected<ContextRenderer>(audio_context);
+  renderer->Init();
+
+  // 1. Page is visible by default. Render some baseline audio.
+  RenderAndCheckIfPlaybackStatsChanged(
+      base::Seconds(1), renderer, script_state, /*expect_change=*/true);
+
+  AudioPlaybackStats* playback_stats = audio_context->playbackStats();
+  int glitches_before = playback_stats->underrunEvents(script_state);
+  double duration_before = playback_stats->totalDuration(script_state);
+
+  // 2. Hide the page.
+  GetPage().SetVisibilityState(mojom::blink::PageVisibilityState::kHidden,
+                               /*is_initial_state=*/false);
+
+  // 3. Render audio with glitches while hidden.
+  fake_time_now_ += base::Seconds(1);
+  renderer->Render(
+      1000, base::Milliseconds(50),
+      media::AudioGlitchInfo{.duration = base::Milliseconds(10), .count = 1});
+  ToEventLoop(script_state).PerformMicrotaskCheckpoint();
+
+  // 4. Make the page visible again.
+  GetPage().SetVisibilityState(mojom::blink::PageVisibilityState::kVisible,
+                               /*is_initial_state=*/false);
+
+  // 5. Render one quantum without glitches to trigger stats update.
+  fake_time_now_ += base::Seconds(1);
+  renderer->Render(1000, base::Milliseconds(50), media::AudioGlitchInfo{});
+  ToEventLoop(script_state).PerformMicrotaskCheckpoint();
+
+  // 6. Verify that stats did NOT increase by the glitches or the duration from
+  // the hidden period.
+  int glitches_after = playback_stats->underrunEvents(script_state);
+  EXPECT_EQ(glitches_before, glitches_after);
+  double duration_after =
+      duration_before + media::AudioTimestampHelper::FramesToTime(
+                            1000, audio_context->sampleRate())
+                            .InSecondsF();
+  // We use EXPECT_NEAR with a 10 microseconds tolerance to allow for
+  // sub-microsecond rounding errors from integer time conversion in
+  // AudioTimestampHelper::FramesToTime. The tolerance is smaller than 1 audio
+  // frame (~20.8 microseconds at 48kHz), ensuring any actually processed frame
+  // would still trigger a failure.
+  EXPECT_NEAR(playback_stats->totalDuration(script_state),
+              duration_after, 0.00001);
+}
+
 TEST_F(AudioContextStatsTest, PlaybackStatsMicrophoneRestrictionStartsDenied) {
   blink::WebRuntimeFeatures::EnableFeatureFromString(
       "AudioContextPlaybackStats", true);
Loading diff…

Original Bug Report

reported by [email protected]

XS-Leak bypass in AudioContext.playbackStats via hidden-period data accumulation

Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports without the Chrome Security team. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: The AudioContext playback statistics API fails to discard audio glitch and latency data collected while a page is hidden. This allows a backgrounded page to read accumulated, system-wide audio stress metrics once it returns to the foreground, bypassing intended WICG XS-Leak privacy mitigations.

Affected files:

  • third_party/blink/renderer/modules/webaudio/audio_context.cc
  • third_party/blink/renderer/platform/audio/audio_frame_stats_accumulator.cc
  • third_party/blink/renderer/platform/audio/audio_frame_stats_accumulator.h

Estimated timestamp from git blame: 2025-11-27

Summary

The AudioContext.playbackStats API contains a bypass of its intended privacy mitigations. These mitigations are designed to prevent background tabs from using audio performance metrics as a side-channel to infer user activity in other browser tabs or applications. However, the current implementation only delays the disclosure of this information rather than suppressing it, allowing a page to observe an aggregate of all audio glitches and latency extremes that occurred while it was hidden.

Root Cause Analysis

In third_party/blink/renderer/modules/webaudio/audio_context.cc, the HandlePreRenderTasks method executes on the audio thread during every render quantum. It unconditionally updates an internal accumulator with the latest audio frame statistics:

// Data is accumulated unconditionally every render quantum.
pending_audio_frame_stats_.Update(frames_to_process, sampleRate(),
                                  playout_delay, glitch_info);

Following this update, a privacy check determines if these pending stats should be transferred to the visible statistics object (audio_frame_stats_):

if (stats_update_restrictor_->StatUpdateAllowed()) {
  audio_frame_stats_.Absorb(pending_audio_frame_stats_);
}

When a page is hidden and lacks audio capture permission, StatUpdateAllowed() correctly returns false. This prevents the immediate transfer of data to audio_frame_stats_.

Crucially, however, pending_audio_frame_stats_ is never reset or cleared when this update is denied. It continues to silently accumulate monotonic counters (glitch event count, duration) and interval extremes (minimum/maximum latency) for the entire duration the page remains hidden.

When the user returns to the tab, the page becomes visible, and StatUpdateAllowed() returns true. The audio_frame_stats_.Absorb(pending_audio_frame_stats_) method is then called. Inside AudioFrameStatsAccumulator::Absorb, the entire backlog of hidden-period interval extremes and accumulated glitches is copied and merged directly into the visible statistics object, effectively rendering the visibility mitigation useless.

Impact

An attacker can use this behavior as a cross-origin activity inference side-channel (XS-Leak). By backgrounding an AudioContext and monitoring the statistics once the tab returns to the foreground, the attacker can measure the total system-wide audio subsystem stress that occurred while the tab was hidden. This can reveal when a user engaged in audio-intensive activity in other origins (e.g., video calls) or performed CPU-heavy tasks.

Potential Reproduction Steps

  1. Serve a page over HTTPS that creates an AudioContext and connects a silent node to the destination to ensure the audio thread continues running.
  2. Record baseline values for ctx.playbackStats.underrunEvents and ctx.playbackStats.minimumLatency.
  3. Switch to a different tab and perform activity that causes system stress or audio playback.
  4. Return to the original tab.
  5. Observe that ctx.playbackStats now reflects the aggregate count of underruns and latency extremes that occurred during the entire hidden period.

Suggested Fix

To align with WICG privacy mitigations, data from periods when the page is hidden should be discarded entirely, not delayed. The call to pending_audio_frame_stats_.Update() in HandlePreRenderTasks should either be conditionally gated behind stats_update_restrictor_->StatUpdateAllowed(), or pending_audio_frame_stats_ should be explicitly cleared/reset when StatUpdateAllowed() returns false to prevent the backlog from building up.

Evaluated with Chrome root at commit: 3acbde3302da0cb19488c22c0eb007c791207b4b


Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.

View on issue tracker