CVE-2026-7980
Overview
Background
- `AudioNode` / `AudioHandler`
- the garbage-collected (Oilpan) JavaScript-facing WebAudio node object and its separately ref-counted (
scoped_refptr) processing handler that runs on the real-time audio rendering thread. - Orphan handler list
- a list owned by
DeferredTaskHandler(AddRenderingOrphanHandler) that keeps anAudioHandleralive while the audio graph is still pulling data, so the rendering thread never touches a freed handler. - Context interruption (`kInterrupted`)
- an
AudioContextstate (entered viaStartContextInterruption) distinct fromkSuspended, used when audio is temporarily halted but may be resumed. - Disposal path
- when an
AudioNodeis garbage-collected, it hands itshandler_off; the handler must be orphaned (retained) if the graph could still reference it.
Root Cause Analysis
When an AudioNode is disposed, the code in audio_node.cc decides whether to move its handler_ into the DeferredTaskHandler’s rendering-orphan list, which keeps the handler alive for the audio rendering thread. The pre-patch condition only retained the handler when context()->IsPullingAudioGraph() or the context state was V8AudioContextState::Enum::kSuspended; it did not account for the kInterrupted state. If a node was disposed (and then garbage-collected) while the context was interrupted, the handler was not added to the orphan list, so its last scoped_refptr dropped and the AudioHandler was freed even though the audio graph could still reference it upon resumption.
The fix adds || context()->ContextState() == V8AudioContextState::Enum::kInterrupted to the condition so an interrupted context also orphans (retains) the handler. The regression test DisposeOrphansHandlerWhenInterrupted confirms this by interrupting the context, creating and dropping a DelayNode, forcing GC, and asserting the handler is still referenced (EXPECT_FALSE(handler->HasOneRef())).
kInterrupted, so a handler could be destroyed while still reachable by the rendering thread; the fix closes the gap by treating kInterrupted the same as kSuspended/pulling.Attack Path
- Create and interrupt a context
Script creates an
AudioContextand drives it into thekInterruptedstate. - Spawn and drop a node
Script creates an
AudioNode(e.g. aDelayNode) and removes all JavaScript references so it becomes eligible for garbage collection. - Dispose without orphaning
During GC the node is disposed; because the state is
kInterrupted, the pre-patch condition skipsAddRenderingOrphanHandler, and theAudioHandler’s final reference is released and the object freed. - Dangling handler use
The audio rendering path (or context resumption) subsequently accesses the freed
AudioHandler, producing a use-after-free (inferred — the diff shows the missing retention, not the specific downstream dereference).
Impact Assessment
AudioHandler that the audio rendering thread may still reference, yielding a use-after-free in the Blink renderer process. The precondition is driving an AudioContext into the kInterrupted state and disposing a node during that window. The vendor advisory rates this medium severity and does not report in-the-wild exploitation; any escalation to a read/write primitive or code execution is (inferred) and not demonstrated by the diff.Changed Functions
| Function | Change | Notes |
|---|---|---|
TEST_Fthird_party/blink/renderer/modules/webaudio/audio_context_test.cc |
modified |
Files Changed
third_party/blink/renderer/modules/webaudio/audio_context_test.ccthird_party/blink/renderer/modules/webaudio/audio_node.cc
Audit Directions
- State-enumerating lifetime guardsAudit any retain/release decision that switches on a subset of
V8AudioContextStateor similar enums; newly added states (likekInterrupted) are easy to omit from older conditions. - GC object vs. ref-counted companionReview Oilpan objects that own
scoped_refptrmembers handed to another thread; confirm every disposal path transfers ownership to a keep-alive list before the GC object dies. - Rendering-thread reachabilityTrace every
AudioHandlerdestruction and verify the real-time audio thread cannot still hold or dereference it across suspension, interruption, and resume transitions.
Patch
From 469cda6c11c283a6b101ddacbd0351172a0bfbb9 Mon Sep 17 00:00:00 2001 From: Hongchan Choi <[email protected]> Date: Wed, 01 Apr 2026 11:11:33 -0700 Subject: [PATCH] [WebAudio] Correct AudioNode disposal state check Ensure AudioHandler objects are correctly orphaned when disposing an AudioNode during an interrupted state. This prevents potential memory lifetime issues. Bug: 497859275 Change-Id: I229c39171a6112ae4cd3aa9d34d311d90e53ab91 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7718446 Reviewed-by: Michael Wilson <[email protected]> Commit-Queue: Hongchan Choi <[email protected]> Cr-Commit-Position: refs/heads/main@{#1608668} --- 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 24c2a6f..cc4bdc69 100644 --- a/third_party/blink/renderer/modules/webaudio/audio_context_test.cc +++ b/third_party/blink/renderer/modules/webaudio/audio_context_test.cc @@ -3,6 +3,8 @@ // found in the LICENSE file. #include "third_party/blink/renderer/modules/webaudio/audio_context.h" +#include "third_party/blink/public/web/web_heap.h" +#include "third_party/blink/renderer/modules/webaudio/delay_node.h" #include <array> #include <memory> @@ -397,6 +399,34 @@ mock_media_devices_dispatcher_host_; }; +TEST_F(AudioContextTest, DisposeOrphansHandlerWhenInterrupted) { + AudioContextOptions* options = AudioContextOptions::Create(); + AudioContext* context = AudioContext::Create( + GetFrame().DomWindow(), options, ASSERT_NO_EXCEPTION); + + context->StartContextInterruption(); + + scoped_refptr<AudioHandler> handler; + { + DelayNode* node = context->createDelay(ASSERT_NO_EXCEPTION); + handler = &node->Handler(); + } + + // Before GC, handler should have 1 ref (held by us, as the node itself is + // still alive in Oilpan but we have no Persistent handle to it). + // In Oilpan tests, a garbage collection cycle must be forced to destroy + // the node. + WebHeap::CollectAllGarbageForTesting(); + + // If the node was orphaned, its handler will be added to the + // DeferredTaskHandler's orphan list, meaning it will have at least one ref + // there, plus our 'handler' ref. (2 refs) + // + // If it was NOT orphaned, it would have been destroyed if we didn't hold it, + // OR it has only our ref. (HasOneRef() == true) Thus this will fail. + EXPECT_FALSE(handler->HasOneRef()); +} + TEST_F(AudioContextTest, AudioContextOptions_WebAudioLatencyHint) { AudioContextOptions* interactive_options = AudioContextOptions::Create(); interactive_options->setLatencyHint( diff --git a/third_party/blink/renderer/modules/webaudio/audio_node.cc b/third_party/blink/renderer/modules/webaudio/audio_node.cc index 98fb23b4..92fec78 100644 --- a/third_party/blink/renderer/modules/webaudio/audio_node.cc +++ b/third_party/blink/renderer/modules/webaudio/audio_node.cc @@ -79,7 +79,8 @@ // the handler still needs to be added in case the context is resumed. DCHECK(context()); if (context()->IsPullingAudioGraph() || - context()->ContextState() == V8AudioContextState::Enum::kSuspended) { + context()->ContextState() == V8AudioContextState::Enum::kSuspended || + context()->ContextState() == V8AudioContextState::Enum::kInterrupted) { context()->GetDeferredTaskHandler().AddRenderingOrphanHandler( std::move(handler_)); }
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 24c2a6f..cc4bdc69 100644
--- a/third_party/blink/renderer/modules/webaudio/audio_context_test.cc
+++ b/third_party/blink/renderer/modules/webaudio/audio_context_test.cc
@@ -3,6 +3,8 @@
// found in the LICENSE file.
#include "third_party/blink/renderer/modules/webaudio/audio_context.h"
+#include "third_party/blink/public/web/web_heap.h"
+#include "third_party/blink/renderer/modules/webaudio/delay_node.h"
#include <array>
#include <memory>
@@ -397,6 +399,34 @@
mock_media_devices_dispatcher_host_;
};
+TEST_F(AudioContextTest, DisposeOrphansHandlerWhenInterrupted) {
+ AudioContextOptions* options = AudioContextOptions::Create();
+ AudioContext* context = AudioContext::Create(
+ GetFrame().DomWindow(), options, ASSERT_NO_EXCEPTION);
+
+ context->StartContextInterruption();
+
+ scoped_refptr<AudioHandler> handler;
+ {
+ DelayNode* node = context->createDelay(ASSERT_NO_EXCEPTION);
+ handler = &node->Handler();
+ }
+
+ // Before GC, handler should have 1 ref (held by us, as the node itself is
+ // still alive in Oilpan but we have no Persistent handle to it).
+ // In Oilpan tests, a garbage collection cycle must be forced to destroy
+ // the node.
+ WebHeap::CollectAllGarbageForTesting();
+
+ // If the node was orphaned, its handler will be added to the
+ // DeferredTaskHandler's orphan list, meaning it will have at least one ref
+ // there, plus our 'handler' ref. (2 refs)
+ //
+ // If it was NOT orphaned, it would have been destroyed if we didn't hold it,
+ // OR it has only our ref. (HasOneRef() == true) Thus this will fail.
+ EXPECT_FALSE(handler->HasOneRef());
+}
+
TEST_F(AudioContextTest, AudioContextOptions_WebAudioLatencyHint) {
AudioContextOptions* interactive_options = AudioContextOptions::Create();
interactive_options->setLatencyHint(
Original Bug Report
Use-After-Free in WebAudio AudioNode::Dispose() during kInterrupted state
Project Fortify, an experimental security project, has identified the following potential security issue.
Overview: A potential Use-After-Free (UAF) vulnerability exists in Blink’s WebAudio module when an AudioContext enters the kInterrupted state. During this state, garbage collection of an AudioNode can lead to the premature destruction of its AudioHandler and AudioNodeOutput, leaving a dangling pointer in the audio thread’s rendering graph that can be exploited for RCE.
Affected files:
third_party/blink/renderer/modules/webaudio/audio_node.ccthird_party/blink/renderer/modules/webaudio/audio_summing_junction.hthird_party/blink/renderer/modules/webaudio/audio_node_input.ccthird_party/blink/renderer/modules/webaudio/audio_context.cc
Estimated timestamp from git blame: 2024-10-10
Description
A potential Use-After-Free (UAF) vulnerability has been identified in the Blink WebAudio module. The issue arises because AudioNode::Dispose() fails to account for the kInterrupted state when deciding whether to keep an AudioHandler alive for the audio rendering thread.
When an AudioContext is interrupted (e.g., by hiding an iframe with a restrictive media-playback-while-not-visible policy), the context stops pulling the audio graph and its state changes to kInterrupted. If an AudioNode is garbage collected while the context is in this state, its underlying AudioHandler and associated AudioNodeOutput objects are destroyed immediately. However, downstream AudioNodeInput objects maintain a rendering_outputs_ vector containing raw pointers to these outputs. By contesting the audio graph lock from the main thread, an attacker can prevent the audio thread from updating this vector, causing it to dereference the freed AudioNodeOutput upon resuming the context.
Root Cause Analysis
In third_party/blink/renderer/modules/webaudio/audio_node.cc, the Dispose() method manages the lifetime of the underlying AudioHandler:
if (context()->IsPullingAudioGraph() ||
context()->ContextState() == V8AudioContextState::Enum::kSuspended) {
context()->GetDeferredTaskHandler().AddRenderingOrphanHandler(
std::move(handler_));
}
When the context is in the kInterrupted state, IsPullingAudioGraph() returns false and the state is not kSuspended. Consequently, handler_ is not added to the rendering orphan list. When the AudioNode’s destructor runs, handler_ is set to nullptr, dropping its refcount to 0.
This destroys the AudioHandler and its AudioNodeOutput (allocated via USING_FAST_MALLOC). Crucially, the audio thread’s AudioSummingJunction::rendering_outputs_ (a Vector<AudioNodeOutput*>) still contains a raw pointer to this freed object. This vector is only updated when DeferredTaskHandler::HandleDeferredTasks() is called, which requires acquiring the graph lock.
Potential Exploitation Steps
(Note: These are suggested steps based on code analysis; a working Proof of Concept has not yet been executed by our tooling.)
- Setup: An attacker creates an
AudioContextand connects anAudioNode(e.g., aGainNode) to the destination. - Trigger Interruption: The attacker hides the iframe containing the context, triggering
StartContextInterruption(). The state becomeskInterruptedandallow_pulling_audio_graph_becomesfalse. - Garbage Collection: The attacker removes JavaScript references to the
GainNodeand forces a garbage collection.AudioNode::Dispose()runs but fails to orphan the handler. TheAudioNodeOutputis freed. - Heap Spray: The attacker sprays the PartitionAlloc heap to reclaim the freed
AudioNodeOutputmemory. They overwrite thehandler_field (araw_ref<AudioHandler>) to point to a fake, attacker-controlledAudioHandlerobject with a fake vtable. (MiraclePtr/BRP checks pass because theraw_refpointer value itself was overwritten to point to a valid, attacker-controlled allocation). - Resumption and Race: The attacker makes the iframe visible again, which resumes the context and allows the audio thread to pull the graph. Concurrently, the attacker runs a tight loop on the main thread calling
connect()/disconnect()on dummy nodes to heavily contest the graph lock. - Execution: The audio thread’s
TryLock()inHandlePreRenderTasks()fails due to the lock contention. It skipsHandleDeferredTasks(), leavingrendering_outputs_un-updated. The audio thread then dereferences the dangling pointer inrendering_outputs_and callsoutput->Pull(). This invokesHandler().ProcessIfNecessary(), which performs a virtual method call using the attacker’s fake vtable, leading to Remote Code Execution (RCE) in the Renderer process.
Suggested Fix
Update the condition in AudioNode::Dispose() to explicitly include the kInterrupted state, ensuring the handler is properly orphaned if the context might be resumed later.
if (context()->IsPullingAudioGraph() ||
context()->ContextState() == V8AudioContextState::Enum::kSuspended ||
context()->ContextState() == V8AudioContextState::Enum::kInterrupted) {
context()->GetDeferredTaskHandler().AddRenderingOrphanHandler(
std::move(handler_));
}
Evaluated with Chrome root at commit: a9cbf6e8b275fe4147435aa905f3b7f5a656f5f0
Results from 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.