Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in WebAudio
DescriptionUse after free in WebAudio
ComponentWebAudio
Bug ClassUAF
Tracker497859275
Fix commit469cda6c11c2 (chromium/src) +32/-1
CISA KEVNot listed
CreditedGoogle
Disclosed2026-05-05

Changed Functions

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

Files Changed

  • third_party/blink/renderer/modules/webaudio/audio_context_test.cc
  • third_party/blink/renderer/modules/webaudio/audio_node.cc
From 469cda6c11c283a6b101ddacbd0351172a0bfbb9 Mon Sep 17 00:00:00 2001
From: Hongchan Choi <hongchan@chromium.org>
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 <mjwilson@chromium.org>
Commit-Queue: Hongchan Choi <hongchan@chromium.org>
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_));
   }
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 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(
Loading diff…

Original Bug Report

reported by vm...@google.com

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.cc
  • third_party/blink/renderer/modules/webaudio/audio_summing_junction.h
  • third_party/blink/renderer/modules/webaudio/audio_node_input.cc
  • third_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.)

  1. Setup: An attacker creates an AudioContext and connects an AudioNode (e.g., a GainNode) to the destination.
  2. Trigger Interruption: The attacker hides the iframe containing the context, triggering StartContextInterruption(). The state becomes kInterrupted and allow_pulling_audio_graph_ becomes false.
  3. Garbage Collection: The attacker removes JavaScript references to the GainNode and forces a garbage collection. AudioNode::Dispose() runs but fails to orphan the handler. The AudioNodeOutput is freed.
  4. Heap Spray: The attacker sprays the PartitionAlloc heap to reclaim the freed AudioNodeOutput memory. They overwrite the handler_ field (a raw_ref<AudioHandler>) to point to a fake, attacker-controlled AudioHandler object with a fake vtable. (MiraclePtr/BRP checks pass because the raw_ref pointer value itself was overwritten to point to a valid, attacker-controlled allocation).
  5. 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.
  6. Execution: The audio thread’s TryLock() in HandlePreRenderTasks() fails due to the lock contention. It skips HandleDeferredTasks(), leaving rendering_outputs_ un-updated. The audio thread then dereferences the dangling pointer in rendering_outputs_ and calls output->Pull(). This invokes Handler().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.

View on issue tracker