CVE-2026-118358
Overview
Background
- `RealtimeAudioDestinationHandler`
- the Blink WebAudio node that bridges the audio graph to the platform audio device (
AudioDestination), drivingRender()on the real-time audio thread. - `AudioDestination` states
- the platform destination has
kRunning,kPaused, andkStopped. Freezing a frame pauses it (kPaused,IsPlaying() == false); thawing resumes it. - `StopPlatformDestination()`
- tears down platform audio when the context is closed; it is supposed to transition the destination to
kStoppedso a later lifecycleResume()cannot restart it. - `ProcessAutomaticPullNodes()`
- pulls “automatic” nodes (e.g.
AnalyserNode) that are not connected to the destination. It iteratesrendering_automatic_pull_handlers_, a list that can retain upstream handlers. - Oilpan / disposed nodes
- WebAudio nodes are garbage-collected (
Member<>/GarbageCollected). When an upstream node (e.g.DelayNode) is collected, its handler is “disposed”; touching it afterward is a use-after-free of the disposed object’s resources. - `DisablePullingAudioGraph()`
- marks the graph as not-to-be-pulled;
IsPullingAudioGraph()then returns false.
Root Cause Analysis
The advisory says “Memory corruption in WebAudio” (MemoryCorruption); the diff shows the specific flavor is a use-after-free of disposed Oilpan audio handlers, driven by two cooperating defects in realtime_audio_destination_handler.cc. First, StopPlatformDestination() guarded the Stop() call with if (platform_destination_->IsPlaying()). When an AudioContext is closed while its frame is frozen, the platform destination is already kPaused (not playing), so Stop() was skipped and the destination stayed in kPaused rather than moving to kStopped. Second, Render() unconditionally called context->GetDeferredTaskHandler().ProcessAutomaticPullNodes(number_of_frames) after the graph-pulling branch, i.e. even when pulling the audio graph was disabled.
The combination is fatal: closing the frozen context calls DisablePullingAudioGraph() but leaves the destination merely paused; when the frame later thaws, the lifecycle Resume() restarts the audio thread on the now-closed context because the destination was never stopped. Render() then runs with pulling disabled and still invokes ProcessAutomaticPullNodes(), which walks rendering_automatic_pull_handlers_. As the new test demonstrates (it connects a DelayNode into an AnalyserNode, flushes the automatic-pull list, drops the DelayNode, and forces WebHeap::CollectAllGarbageForTesting()), that list can still reference an upstream handler whose backing node has been garbage-collected and disposed. Pulling the disposed node is the use-after-free. The fix makes StopPlatformDestination() call platform_destination_->Stop() whenever platform_destination_ is present (so kPaused → kStopped, and a later Resume() cannot restart a stopped destination), and moves ProcessAutomaticPullNodes() inside the active graph-pulling branch of Render() so it never runs once pulling is disabled.
kPaused instead of kStopped, so a thaw-driven Resume() restarted rendering on the closed context, and Render() processed automatic pull nodes even with graph-pulling disabled — pulling a disposed, garbage-collected upstream handler: a use-after-free.Attack Path
- Web attacker
script creates an
AudioContextand wires an automatic-pull node (e.g.AnalyserNode) fed by an upstream node such asDelayNode(inferred: standard WebAudio API, web-reachable). - Freeze the frame
the page is frozen (bfcache/lifecycle freeze), putting the platform destination into
kPaused. - Close while frozen
audioContext.close()runsStopPlatformDestination(), which (pre-patch) skipsStop()because the destination is not playing, and disables graph pulling. - Drop the upstream node
release references to the upstream node so Oilpan GC disposes its handler while it remains in
rendering_automatic_pull_handlers_. - Thaw
the frame resumes; lifecycle
Resume()restarts the audio thread on the closed context because the destination was never stopped. - Primitive
Render()callsProcessAutomaticPullNodes()with pulling disabled and dereferences the disposed handler: a use-after-free on the real-time audio thread (freed-memory access; escalation to controlled corruption is (inferred)).
Impact Assessment
DelayNode and then runs Render() under the graph lock); it does not demonstrate a concrete read/write-what-where, so weaponization to RCE is (inferred). Preconditions (freeze → close → GC → thaw) are all script- and lifecycle-reachable, making this a credible renderer UAF rather than a mere crash.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifthird_party/blink/renderer/modules/webaudio/realtime_audio_destination_handler.cc |
modified | |
TEST_Fthird_party/blink/renderer/modules/webaudio/audio_context_test.cc |
modified |
Files Changed
third_party/blink/renderer/modules/webaudio/realtime_audio_destination_handler.ccthird_party/blink/renderer/modules/webaudio/audio_context_test.cc
Audit Directions
- State-gated teardown guardslook for other
if (IsPlaying())/if (running)guards aroundStop()/cleanup that skip teardown for paused/frozen states, leaving objects restartable. - Unconditional post-render workreview other handlers that run graph/post-processing (
ProcessAutomaticPullNodes,HandlePostRenderTasks) outside the “is the graph active” check. - Automatic pull node lifetimeaudit
rendering_automatic_pull_handlers_/UpdateAutomaticPullNodes()for windows where the list retains handlers whose nodes can be collected (graph lock held, context closed). - Freeze/close/thaw interplayexamine other media and worklet subsystems for lifecycle
Resume()paths that can restart activity on an already-closed object.
Patch
From c26b38a137c5f813e0bc01d3781be5a16cb25638 Mon Sep 17 00:00:00 2001
From: Hongchan Choi <hongchan@google.com>
Date: Tue, 13 Oct 2026 15:16:57 -0700
Subject: [PATCH] [WebAudio] Stop paused destination and guard automatic pull on close
When an AudioContext is closed while its frame is frozen, the platform
audio destination is in the kPaused state (`IsPlaying()` is false), so
`StopPlatformDestination()` previously skipped calling `Stop()`. When
the frame later thawed, `Resume()` restarted the audio thread on the
closed context, and `Render()` invoked `ProcessAutomaticPullNodes()`
even when pulling the audio graph was disabled, causing an object
lifetime issue with disposed upstream nodes.
Changes:
- Call `platform_destination_->Stop()` in `StopPlatformDestination()`
whenever `platform_destination_` is present so `kPaused` destinations
transition to `kStopped`.
- Move `ProcessAutomaticPullNodes()` inside the active graph-pulling
branch of `RealtimeAudioDestinationHandler::Render()`.
- Add `AudioContextTest.CloseWhileFrozenStopsDestination` to verify
closing a frozen context stops the destination and guards automatic
pull nodes.
Bug: 572191610
Test: blink_unittests --gtest_filter=AudioContextTest.CloseWhileFrozen*
Change-Id: I3126957a0a43ffdce2f541908ad90359e8f0fd6e
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8561977
Commit-Queue: Hongchan Choi <hongchan@google.com>
Reviewed-by: Mahesh Kannan <kmaheshb@google.com>
Cr-Commit-Position: refs/heads/main@{#1717977}
---
diff --git a/third_party/blink/renderer/modules/webaudio/realtime_audio_destination_handler.cc b/third_party/blink/renderer/modules/webaudio/realtime_audio_destination_handler.cc
index 532926c..d83f42950 100644
--- a/third_party/blink/renderer/modules/webaudio/realtime_audio_destination_handler.cc
+++ b/third_party/blink/renderer/modules/webaudio/realtime_audio_destination_handler.cc
@@ -279,14 +279,15 @@
// given `destination_bus` buffer.
destination_bus->CopyFrom(*rendered_bus);
}
+
+ // Processes "automatic" nodes that are not connected to anything. This can
+ // be done after copying because it does not affect the rendered result.
+ context->GetDeferredTaskHandler().ProcessAutomaticPullNodes(
+ number_of_frames);
} else {
destination_bus->Zero();
}
- // Processes "automatic" nodes that are not connected to anything. This can
- // be done after copying because it does not affect the rendered result.
- context->GetDeferredTaskHandler().ProcessAutomaticPullNodes(number_of_frames);
-
context->HandlePostRenderTasks();
// Handle audibility before handling the volume multiplier since the volume
@@ -478,7 +479,10 @@
// for a while. (It may take a bit of time for the destination to stop.)
DisablePullingAudioGraph();
- if (platform_destination_->IsPlaying()) {
+ // `AudioDestination::Stop()` is a no-op when already stopped, and transitions
+ // both `kRunning` and `kPaused` destinations to `kStopped` so a subsequent
+ // lifecycle `Resume()` cannot restart a stopped destination.
+ if (platform_destination_) {
platform_destination_->Stop();
}
}
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 e429d5c..3cd6230 100644
--- a/third_party/blink/renderer/modules/webaudio/audio_context_test.cc
+++ b/third_party/blink/renderer/modules/webaudio/audio_context_test.cc
@@ -51,6 +51,7 @@
#include "third_party/blink/renderer/core/workers/worker_thread.h"
#include "third_party/blink/renderer/modules/mediastream/sub_capture_target.h"
#include "third_party/blink/renderer/modules/peerconnection/peer_connection_dependency_factory.h"
+#include "third_party/blink/renderer/modules/webaudio/analyser_node.h"
#include "third_party/blink/renderer/modules/webaudio/audio_playback_stats.h"
#include "third_party/blink/renderer/modules/webaudio/audio_worklet.h"
#include "third_party/blink/renderer/modules/webaudio/audio_worklet_global_scope.h"
@@ -389,6 +390,7 @@
}
void SetUp() override {
+ web_audio_device_paused_ = false;
PageTestBase::SetUp(gfx::Size());
CoreInitializer::GetInstance().ProvideModulesToPage(GetPage(),
std::string());
@@ -657,6 +659,80 @@
EXPECT_FALSE(web_audio_device_paused_);
}
+TEST_F(AudioContextTest, CloseWhileFrozenStopsDestination) {
+ AudioContextOptions* options = AudioContextOptions::Create();
+ AudioContext* audio_context = AudioContext::Create(
+ GetFrame().DomWindow(), options, ASSERT_NO_EXCEPTION);
+ ExpectContextBecomesRunningAsync(audio_context);
+ ExpectContextRunning(audio_context);
+
+ RealtimeAudioDestinationHandler* dest_handler =
+ &audio_context->destinationNode()->GetAudioDestinationHandler();
+
+ GetFrame().DomWindow()->SetLifecycleState(
+ mojom::FrameLifecycleState::kFrozen);
+ EXPECT_TRUE(web_audio_device_paused_);
+ EXPECT_FALSE(dest_handler->get_platform_destination_is_playing_for_testing());
+
+ {
+ V8TestingScope scope;
+ audio_context->closeContext(scope.GetScriptState(),
+ scope.GetExceptionState());
+ }
+ EXPECT_EQ(audio_context->ContextState(), V8AudioContextState::Enum::kClosed);
+ EXPECT_FALSE(audio_context->IsPullingAudioGraph());
+
+ // Thawing the frame after closing must not resume the stopped destination.
+ GetFrame().DomWindow()->SetLifecycleState(
+ mojom::FrameLifecycleState::kRunning);
+ EXPECT_FALSE(dest_handler->get_platform_destination_is_playing_for_testing());
+ EXPECT_TRUE(web_audio_device_paused_);
+
+ // Verify that if Render() runs while pulling the audio graph is disabled,
+ // automatic pull nodes do not pull disposed upstream nodes.
+ FakeAudioThread audio_thread(ThreadType::kRealtimeAudioWorkletThread);
+ Persistent<AnalyserNode> analyser =
+ audio_context->createAnalyser(ASSERT_NO_EXCEPTION);
+ {
+ DelayNode* node = audio_context->createDelay(ASSERT_NO_EXCEPTION);
+ node->connect(analyser.Get(), 0, 0, ASSERT_NO_EXCEPTION);
+
+ // Flush the automatic pull handler list to
+ // `rendering_automatic_pull_handlers_` while the upstream `DelayNode` is
+ // still alive.
+ audio_thread.RunOnAudioThreadWithContext(
+ audio_context, CrossThreadBindOnce(
+ [](RealtimeAudioDestinationHandler* handler) {
+ const AudioIOPosition pos{0, 0, 0};
+ const AudioCallbackMetric metric;
+ scoped_refptr<AudioBus> bus =
+ AudioBus::Create(2, 128, /*allocate=*/true);
+ handler->Render(bus.get(), 128, pos, metric,
+ base::TimeDelta(), {});
+ },
+ CrossThreadUnretained(dest_handler)));
+ }
+
+ WebHeap::CollectAllGarbageForTesting();
+
+ {
+ // Hold the graph lock so `UpdateAutomaticPullNodes()` is skipped and
+ // `rendering_automatic_pull_handlers_` retains `AnalyserHandler` after its
+ // upstream `DelayNode` has been collected and disposed.
+ DeferredTaskHandler::GraphAutoLocker locker(
+ audio_context->GetDeferredTaskHandler());
+ audio_thread.RunOnAudioThread(CrossThreadBindOnce(
+ [](RealtimeAudioDestinationHandler* handler) {
+ const AudioIOPosition pos{0, 0, 0};
+ const AudioCallbackMetric metric;
+ scoped_refptr<AudioBus> bus =
+ AudioBus::Create(2, 128, /*allocate=*/true);
+ handler->Render(bus.get(), 128, pos, metric, base::TimeDelta(), {});
+ },
+ CrossThreadUnretained(dest_handler)));
+ }
+}
+
// Test that the AudioContext correctly handles audibility state transitions
// and implements the 2-second silence hysteresis.
TEST_F(AudioContextTest, AudioContextAudibilityHysteresis) {
Regression Test / PoC
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 e429d5c..3cd6230 100644
--- a/third_party/blink/renderer/modules/webaudio/audio_context_test.cc
+++ b/third_party/blink/renderer/modules/webaudio/audio_context_test.cc
@@ -51,6 +51,7 @@
#include "third_party/blink/renderer/core/workers/worker_thread.h"
#include "third_party/blink/renderer/modules/mediastream/sub_capture_target.h"
#include "third_party/blink/renderer/modules/peerconnection/peer_connection_dependency_factory.h"
+#include "third_party/blink/renderer/modules/webaudio/analyser_node.h"
#include "third_party/blink/renderer/modules/webaudio/audio_playback_stats.h"
#include "third_party/blink/renderer/modules/webaudio/audio_worklet.h"
#include "third_party/blink/renderer/modules/webaudio/audio_worklet_global_scope.h"
@@ -389,6 +390,7 @@
}
void SetUp() override {
+ web_audio_device_paused_ = false;
PageTestBase::SetUp(gfx::Size());
CoreInitializer::GetInstance().ProvideModulesToPage(GetPage(),
std::string());
@@ -657,6 +659,80 @@
EXPECT_FALSE(web_audio_device_paused_);
}
+TEST_F(AudioContextTest, CloseWhileFrozenStopsDestination) {
+ AudioContextOptions* options = AudioContextOptions::Create();
+ AudioContext* audio_context = AudioContext::Create(
+ GetFrame().DomWindow(), options, ASSERT_NO_EXCEPTION);
+ ExpectContextBecomesRunningAsync(audio_context);
+ ExpectContextRunning(audio_context);
+
+ RealtimeAudioDestinationHandler* dest_handler =
+ &audio_context->destinationNode()->GetAudioDestinationHandler();
+
+ GetFrame().DomWindow()->SetLifecycleState(
+ mojom::FrameLifecycleState::kFrozen);
+ EXPECT_TRUE(web_audio_device_paused_);
+ EXPECT_FALSE(dest_handler->get_platform_destination_is_playing_for_testing());
+
+ {
+ V8TestingScope scope;
+ audio_context->closeContext(scope.GetScriptState(),
+ scope.GetExceptionState());
+ }
+ EXPECT_EQ(audio_context->ContextState(), V8AudioContextState::Enum::kClosed);
+ EXPECT_FALSE(audio_context->IsPullingAudioGraph());
+
+ // Thawing the frame after closing must not resume the stopped destination.
+ GetFrame().DomWindow()->SetLifecycleState(
+ mojom::FrameLifecycleState::kRunning);
+ EXPECT_FALSE(dest_handler->get_platform_destination_is_playing_for_testing());
+ EXPECT_TRUE(web_audio_device_paused_);
+
+ // Verify that if Render() runs while pulling the audio graph is disabled,
+ // automatic pull nodes do not pull disposed upstream nodes.
+ FakeAudioThread audio_thread(ThreadType::kRealtimeAudioWorkletThread);
+ Persistent<AnalyserNode> analyser =
+ audio_context->createAnalyser(ASSERT_NO_EXCEPTION);
+ {
+ DelayNode* node = audio_context->createDelay(ASSERT_NO_EXCEPTION);
+ node->connect(analyser.Get(), 0, 0, ASSERT_NO_EXCEPTION);
+
+ // Flush the automatic pull handler list to
+ // `rendering_automatic_pull_handlers_` while the upstream `DelayNode` is
+ // still alive.
+ audio_thread.RunOnAudioThreadWithContext(
+ audio_context, CrossThreadBindOnce(
+ [](RealtimeAudioDestinationHandler* handler) {
+ const AudioIOPosition pos{0, 0, 0};
+ const AudioCallbackMetric metric;
+ scoped_refptr<AudioBus> bus =
+ AudioBus::Create(2, 128, /*allocate=*/true);
+ handler->Render(bus.get(), 128, pos, metric,
+ base::TimeDelta(), {});
+ },
+ CrossThreadUnretained(dest_handler)));
+ }
+
+ WebHeap::CollectAllGarbageForTesting();
+
+ {
+ // Hold the graph lock so `UpdateAutomaticPullNodes()` is skipped and
+ // `rendering_automatic_pull_handlers_` retains `AnalyserHandler` after its
+ // upstream `DelayNode` has been collected and disposed.
+ DeferredTaskHandler::GraphAutoLocker locker(
+ audio_context->GetDeferredTaskHandler());
+ audio_thread.RunOnAudioThread(CrossThreadBindOnce(
+ [](RealtimeAudioDestinationHandler* handler) {
+ const AudioIOPosition pos{0, 0, 0};
+ const AudioCallbackMetric metric;
+ scoped_refptr<AudioBus> bus =
+ AudioBus::Create(2, 128, /*allocate=*/true);
+ handler->Render(bus.get(), 128, pos, metric, base::TimeDelta(), {});
+ },
+ CrossThreadUnretained(dest_handler)));
+ }
+}
+
// Test that the AudioContext correctly handles audibility state transitions
// and implements the 2-second silence hysteresis.
TEST_F(AudioContextTest, AudioContextAudibilityHysteresis) {