← WebKit Silent-Fix Report — 2026-W21

ed04ff4067  Don't call document->removeAudioProducer during Document destruction

severity medium class UAF confidence 0.60 WebCore WebAudio exploitable-grade
Anthony Tarbinian Wed May 20 20:16:28 2026 -0700 full: ed04ff40674d30791da2d18b27b33ed805b50a82 bug report ↗ view on GitHub ↗
Primitive: removeAudioProducer called during Document destruction
Triage note: Avoids reaching into a Document being destroyed from the AudioContext destructor, a lifetime/UAF hazard.
Contents

The bug at a glance

AudioContext’s destructor called document->removeAudioProducer(*this) while the owning Document was itself mid-destruction, reaching into a Document that may have already freed member state — a use-during-destruction / use-after-free reachable purely from script that creates a WebAudio AudioContext in a document that is then torn down. The added manual test is explicitly an ASAN-reproducible crash, confirming a real memory-safety defect rather than a benign ordering nit. It is rated high because Document teardown UAFs in the renderer are a classic exploit-primitive class (controlled free during a well-defined lifecycle event), though the patch itself demonstrates only a crash. The trigger is ordinary same-origin content, so reachability is trivial; weaponization beyond a crash is unproven from the diff.

The AudioContext outlived — or rather, was destroyed as part of — its Document, and its destructor tried to unregister itself from that same Document while the Document was already running its own destructor and had potentially freed the very members removeAudioProducer touches. The fix moves the unregistration to the ActiveDOMObject stop() hook (which runs while the Document is still alive) and guards the destructor with isStopped(), so the destructor only touches the Document on the path where teardown has not already begun.

Root cause

An AudioContext is both an ActiveDOMObject and a MediaProducer registered with its Document via document->addAudioProducer / removeAudioProducer. The registration must be undone before the Document goes away. The bug is a destruction-ordering inversion described precisely in the commit: during Document teardown the chain runs Document::~Document -> ScriptExecutionContext::~ScriptExecutionContext -> BaseAudioContext::deleteMarkedNodes -> AudioContext::~AudioContext -> Document::removeAudioProducer. That is, the AudioContext’s own destructor fires from inside the Document’s destructor (via ScriptExecutionContext teardown and audio-node cleanup), and then calls back into that half-destroyed Document.

Pre-patch, ~AudioContext unconditionally did: if (RefPtr document = this->document()) document->removeAudioProducer(*this);. When reached through the teardown chain, this->document() can still return a pointer to the Document that is currently executing ~Document; its member containers (including the audio-producer set) may already be destroyed or in an indeterminate state. removeAudioProducer then mutates freed/indeterminate Document state — a use-after-free / use-during-destruction. The commit is explicit: ’the Document is not in a well-defined state it may have already freed member variables.'

The fix relocates the unregistration to the correct lifecycle point. AudioContext now overrides ActiveDOMObject::stop(): void AudioContext::stop() { if (RefPtr document = this->document()) document->removeAudioProducer(*this); BaseAudioContext::stop(); }. stop() is invoked from Document::commonTeardown -> ScriptExecutionContext::stopActiveDOMObjects, which runs while the Document is still fully alive and well-defined — so removeAudioProducer touches valid state. To make BaseAudioContext::stop() overridable at this level, the header change moves its declaration into a protected section of BaseAudioContext.h and adds void stop() final; to AudioContext.h.

Second, the destructor is made defensive against the teardown path: if (!isStopped()) { if (RefPtr document = this->document()) document->removeAudioProducer(*this); }. BaseAudioContext::isStopped() reflects m_isStopScheduled, which stop()/BaseAudioContext::stop() sets true. Along the Document-teardown chain, stopActiveDOMObjects has already called stop() (setting isStopped() true and performing removeAudioProducer while alive), so the destructor’s guarded block is skipped and it never reaches into ~Document. On the normal path where the context is destroyed without teardown having stopped it, isStopped() is false and the destructor still performs the unregistration. The two changes together guarantee removeAudioProducer runs exactly once and only while the Document is valid.

Key code

AudioContext.cpp — guarded destructor + stop() unregistration

AudioContext::~AudioContext()
{
    m_mediaSession->invalidateClient();

    if (!isStopped()) {
        if (RefPtr document = this->document())
            document->removeAudioProducer(*this);
    }
}

void AudioContext::stop()
{
    if (RefPtr document = this->document())
        document->removeAudioProducer(*this);
    BaseAudioContext::stop();
}

Patch walkthrough

  • Source/WebCore/Modules/webaudio/AudioContext.cpp — ~AudioContext’s unconditional document->removeAudioProducer(*this) is wrapped in if (!isStopped()) so it no longer fires on the Document-teardown path (where stop() already handled it). A new AudioContext::stop() override performs removeAudioProducer while the Document is still alive and then chains BaseAudioContext::stop(); stop() is reached via Document::commonTeardown -> stopActiveDOMObjects.
  • Source/WebCore/Modules/webaudio/AudioContext.h — Adds void stop() final; to the ActiveDOMObject overrides in AudioContext’s private section so AudioContext participates in stopActiveDOMObjects with its own unregistration logic.
  • Source/WebCore/Modules/webaudio/BaseAudioContext.h — Moves the void stop() override; declaration from a private section into a protected // ActiveDOMObject. section so the derived AudioContext::stop() can call BaseAudioContext::stop().
  • ManualTests/webaudio/nocrash-audiocontext-reference-destroyed-document.html — Adds a DOMfuzz-style manual test (274 lines) that creates AudioContext/WebAudio nodes amid a large mutation soup and forces GC (window.gc()); it is annotated as requiring an ASAN build and may take minutes to trigger the use-after-free during document/AudioContext destruction.

Background

ActiveDOMObject stop() and document teardown ordering — An ActiveDOMObject represents script-observable machinery tied to a ScriptExecutionContext (Document/Worker). When the context is torn down, Document::commonTeardown calls ScriptExecutionContext::stopActiveDOMObjects, which invokes stop() on each registered object while the context is still fully alive. stop() is the designed, safe place to release context-dependent registrations; doing that work in a destructor is unsafe because destructors of context-owned objects can run after the context has begun freeing its own state.

MediaProducer / addAudioProducer / removeAudioProducer — A Document tracks ‘audio producers’ (objects that can emit audio) so it can manage media session, muting, and page-level audio policy. An AudioContext registers via document->addAudioProducer and must call removeAudioProducer to deregister. That call mutates a Document-owned collection, so it is only valid while the Document and that collection are alive and well-defined.

The offending destruction chain — The commit documents the exact path: Document::~Document -> ScriptExecutionContext::~ScriptExecutionContext -> BaseAudioContext::deleteMarkedNodes -> AudioContext::~AudioContext -> Document::removeAudioProducer. deleteMarkedNodes runs during ScriptExecutionContext destruction and can drop the last reference to the AudioContext, whose destructor then calls back into the Document that is partway through its own destructor. this->document() may still return a non-null but partially-freed Document.

isStopped() / m_isStopScheduled guard — BaseAudioContext::isStopped() returns m_isStopScheduled, set true by BaseAudioContext::stop(). Because stopActiveDOMObjects calls stop() before the destructors of context-owned objects run, isStopped() is a reliable signal inside ~AudioContext that teardown has already deregistered the producer. Guarding the destructor with !isStopped() ensures removeAudioProducer is performed exactly once and never from the destructor when the Document is mid-teardown.

Use-during-destruction as a UAF class — Calling a method on an object that is executing its own destructor (directly or via a callback) is a use-after-(partial-)free: base subobjects or member containers may already be destroyed, so reads/writes touch freed or reused memory. During Document teardown this is a controlled, script-reachable free-then-use, which is why the class is exploit-relevant even when the immediate observed effect is a crash under ASAN.

Vulnerability window

  1. Original lifecycle — AudioContext deregistered its audio-producer role only in its destructor via document->removeAudioProducer(*this).
  2. Latent UAF — When a Document is destroyed, ScriptExecutionContext teardown deletes marked audio nodes and destroys the AudioContext, whose destructor calls removeAudioProducer on the Document that is already inside ~Document with possibly-freed members.
  3. Symptom — Under ASAN this manifests as a use-after-free during teardown; reproduction is timing/GC-dependent and can take minutes, motivating a manual rather than layout test.
  4. Diagnosis (bug 309708 / rdar://172168772) — The destructor-to-Document callback during ~Document is identified as the unsafe path.
  5. Fix — removeAudioProducer is moved to AudioContext::stop() (run by stopActiveDOMObjects while the Document is alive), BaseAudioContext::stop() is made protected/overridable, and the destructor is guarded with !isStopped().
  6. Regression coverage — A manual ASAN test (nocrash-audiocontext-reference-destroyed-document.html) is added to exercise the teardown path via WebAudio node churn plus window.gc().

Proof of concept

The patch adds a manual test rather than a deterministic PoC; it is a 274-line DOMfuzz-generated page annotated ‘Requires an ASAN build to reproduce crash. Crash may take a few minutes to trigger.’ It creates WebAudio nodes on a document’s AudioContext amid heavy DOM mutation and calls window.gc() to force collection during teardown, driving the Document::~Document -> … -> AudioContext::~AudioContext -> removeAudioProducer chain. The excerpt above is verbatim from that test. Because it is not a compact self-contained trigger, treat the documented destruction stack as the authoritative repro description: instantiate an AudioContext in a document, then tear the document down (frame removal / navigation) so ScriptExecutionContext teardown destroys the context during ~Document.

// Excerpt from the added ManualTests/webaudio/nocrash-audiocontext-reference-destroyed-document.html
// ("Requires an ASAN build to reproduce crash. Crash may take a few minutes to trigger")
function freememory() {
  try { window.gc(); } catch(err) { }
}
// ... DOMfuzz-style mutation soup creating WebAudio nodes on the document's context ...
try { var tmp063 = tmp049.createOscillator(); } catch(e) { }
try { var tmp064 = elem00030.play(); } catch(e) { }
// <body onload=runTest()> ... <audio id="elem00030" ...> ...
freememory();

Exploitation

  1. Setup — Script (e.g. in an iframe) creates a WebAudio AudioContext and audio nodes, registering the context as an audio producer with its Document.
  2. Trigger teardown — The document is destroyed — frame removal, navigation, or GC of a detached document — driving Document::~Document -> ScriptExecutionContext::~ScriptExecutionContext -> deleteMarkedNodes -> ~AudioContext -> removeAudioProducer while ~Document is in progress.
  3. Use-after-free — removeAudioProducer mutates a Document collection whose backing state may already be freed by the in-flight ~Document, giving a controlled free-then-use during a deterministic lifecycle event.
  4. Feasibility — Reachable from ordinary same-origin script, but the diff demonstrates only an ASAN crash; turning the write-into-freed-Document-state into a controlled corruption primitive is unproven here and would require heap-grooming of the freed Document subobject. Honest classification: script-reachable teardown UAF, crash-demonstrated.

Detection & hunting

For defenders and SOC / detection engineers:

  • Renderer crashes in removeAudioProducer during Document/AudioContext destruction — Flag crash reports whose stack shows Document::removeAudioProducer called from AudioContext::~AudioContext / BaseAudioContext::deleteMarkedNodes within Document::~Document or ScriptExecutionContext::~ScriptExecutionContext; that exact chain is the pre-patch UAF.
  • AudioContext churn in short-lived / detached documents — Watch for content that repeatedly creates AudioContexts in iframes that are then rapidly removed or navigated, especially combined with forced GC, which is the pattern that drives destruction-time deregistration.

Audit directions

  • Other ActiveDOMObject destructors touching their context — Grep WebCore for destructors that call this->document()/scriptExecutionContext() and then mutate context-owned state; each should move such work to stop() and/or guard with a stopped/teardown flag, as AudioContext now does.
  • MediaProducer registrants — Audit every addAudioProducer/addVideoProducer registrant (media elements, WebRTC, WebAudio) to confirm deregistration happens in stop() while the Document is alive, not from a destructor reachable during ~Document.
  • deleteMarkedNodes / ScriptExecutionContext teardown callbacks — Review BaseAudioContext::deleteMarkedNodes and similar teardown-time node destruction for other callbacks that re-enter the dying Document or ScriptExecutionContext.
  • isStopped()/m_isStopScheduled invariants — Confirm that stop() is always invoked before context-owned object destructors on every teardown path so the !isStopped() destructor guard cannot be bypassed, leaving a residual destructor-time callback.

Before / after

Loading diff…