Root cause: a stripped lock in DeferredTaskHandler

WebAudio's processing model deserves a quick review before diving into the bug. Every AudioNode exposed to JavaScript is backed by an internal AudioHandler that performs the actual DSP work. Rendering happens on a dedicated audio thread in fixed-size blocks of 128 frames, called quantums. To keep the main thread responsive, once a quantum starts it must run to completion—including through nodes that may have been removed from the graph concurrently.

The lifetime of handlers is managed through a DeferredTaskHandler owned by the AudioContext. Normally, when an AudioNode is destroyed, its handler is either freed immediately or, if a quantum is in flight (checked via IsPullingAudioGraph), handed to the DeferredTaskHandler for later cleanup. That deferred deletion is what prevents a handler from being freed while still being read on the audio thread.

The flaw is in the exception path for frame teardown. If the JavaScript context that owns the audio graph is destroyed—say, by removing an iframe—the DeferredTaskHandler is allowed to flush its pending deletions immediately by calling ClearHandlersToBeDeleted, even if a quantum is still being processed. Previously, a mutex protected this window. A commit removed that mutex, and the consequence is that orphaned AudioHandler objects can be freed while they are still being traversed and processed by the audio thread. That is the use-after-free behind CVE-2020-15972.

The race window is extremely narrow in practice. A single quantum through a simple ConvolverHandler may finish in microseconds, and the attacker has little control over exactly where in the handler's code the deletion lands. The practical solution is to make a quantum take long enough that the destruction can be reliably interleaved—which means building a large audio graph in which many handlers must process sequentially for the same set of 128 frames.

Graph topology and the race primitive

An audio graph is a chain from a source (or several, if branches are used via nodes like ChannelMergerNode) to a destination, where each node applies processing to its input. A simple linear graph—an AudioBufferSourceNode feeding a ConvolverNode feeding the destination—requires only a few milliseconds of work per quantum. That is insufficient for a reliable race.

To stretch the processing time, you can attach a long series of nodes such that a single quantum must traverse them all. Consider the following construction:

// Example node chain used to lengthen per-quantum processing
let src = new AudioBufferSourceNode(ctx);
let prev = src;
for (let i = 0; i < 200; i++) {
  let g = ctx.createGain();
  prev.connect(g);
  prev = g;
}
prev.connect(ctx.destination);
src.start();

Each GainNode adds a small but measurable amount of work. With hundreds of such nodes, the quantum processing time climbs to a level where the main thread can interleave deletion. The graph layout also matters: the processing order is roughly topologically sorted from sources to the destination, so nodes in a long chain are visited sequentially.

Making the UAF deterministic

For a viable exploit, you want to delete a handler that is known to be far along in the current quantum's processing. A node that appears early in the graph will be processed first, so you need to have the deletion happen while its handler is still alive but already traversed past the point of use. In practice, this means picking a target node that sits in the middle of a long chain, so that by the time the deletion request reaches the audio thread, the target has either been processed or is about to be.

The specifics of the scheduling are tricky. The main thread can call ClearHandlersToBeDeleted while the audio thread is between nodes, so where the UAF manifests depends on how far the audio thread has iterated. A practical approach is to set up two graphs reading from the same AudioBufferSourceNode, where one branch is long and the other is short. The shorter branch will complete first, and you can trigger deletion while the longer branch is still mid-flight.

Using ConvolverNode as the target handler is useful because its processing path touches a lot of members and has a longer execution time than simple scalar operations. Empirically, deleting a ConvolverNode that is roughly at the middle of a 200-node chain yields a fairly reproducible crash—confirming that the freed object is accessed while the audio thread still holds a reference to its memory.

Exploitation constraints on Android

The target environment is Chrome 86.0.4240.30 (beta) on Android, running a 64-bit renderer. The renderer uses Android's isolatedProcess sandbox, meaning it has far fewer privileges than the browser process itself. That distinction is essential because the exploit described here only grants code execution inside the renderer—no further privilege separation is bypassed. To reach the Android kernel from a website, you must chain this bug with a separate sandbox escape (1125614, GHSL-2020-165) that runs in the browser process and that can load the kernel exploit. The kernel bug and its exploitation are covered in separate write-ups.

Although the code and object layout differ between 32- and 64-bit builds, the attack primitives—spraying, object faking, and partial handler control—are largely identical. You only need to adapt heap spray sizes and member offsets; the JavaScript-facing API surface is the same.

Achieving write-what-where

The freed object is a ConvolverHandler that occupies a known size in the Blink heap. The first step after the UAF is to reclaim that slot with controlled data. In Chrome, you can use JavaScript allocations that are backed by ArrayBuffers to spray the heap with predictable content. Landing a controlled buffer over the freed handler gives you a handler whose virtual table pointer and internal fields you control.

For a code-execution primitive, you typically corrupt a function pointer or a vtable entry to redirect execution into gadgets you have prepared in the renderer's address space. Since Chrome enables kArbitraryCodeGadgets only in specific ways, you often need to JIT-spray code that you can jump into. By controlling a vtable pointer on the reclaimed handler, you can make the audio thread call a function whose address you set, which can be the address of a JIT-sprayed stub. The audio thread then executes your machine code with whatever registers are live at the call site, which you can set up through carefully chosen JavaScript types held by the handler's members.

Care must be taken because the audio thread runs at low priority; heavy contention can cause your spray to be overwritten by GC or by other allocations. Keep the heap stable and avoid allocating large objects after the spray.

Verifying code execution

Once the JIT-sprayed code runs, the first thing to do is check that you are actually in the intended context—not deadlocked in a spinlock or in the middle of a partially initialized object. A common verification is to write a marker to a global address and then read it back from the main thread, since the renderer and audio thread share the same address space. In practice, you will want to transition to a higher-level exploit that calls the browser process via Mojo, but that step requires a working sandbox escape and is outside the scope of this part.

If everything goes well, you end up with a crash-free run that gives you remote code execution in the renderer process on Android—with a JavaScript-triggered entry point from any website. The same primitives are available on desktop, but the sandbox situation differs, so the chain's subsequent links must be chosen accordingly.

A full chain would combine this initial RCE with the aforementioned sandbox escape to reach the browser process and, through it, the kernel exploit. But even on its own, this bug demonstrates a subtle concurrency issue: removing a protection lock from a critical path, without auditing every caller, can reintroduce a race that seemed fully mitigated. The fix was shipped in Chrome 86.0.4240.75 in October 2020.

Winning the race with an AudioWorkletNode

To control the timing of the race, I can use an AudioWorkletNode whose user-supplied JavaScript processing function sleeps for an arbitrary amount of time before returning:

class AutoProcessor extends AudioWorkletProcessor {
  process (inputs, outputs, parameters) {
    sleep(5000);
    return true
  }
}

Because a worklet node placed before convolver in the graph delays the convolver's processing by however long the worklet sleeps, that window is enough to delete and replace the ConvolverNode. The deletion itself happens from an iframe, e.g.:

  await audioContext.audioWorklet.addModule('tear-down.js');
  let worklet = new AudioWorkletNode(audioContext, 'tear-down');
  let convolver = audioContext.createConvolver();
  soundSource.connect(worklet).connect(convolver).connect(audioContext.destination);
  audioContext.startRendering();
  sleep(200);
  worklet.disconnect();
  convolver = null;
  gc();
  parent.removeFrame(); //<-------- Get parent frame to delete outselves;

This simplified example is not itself reliable: the convolver must be garbage collected before the iframe is torn down, so in practice the actual deletion would need to happen from a separate function scope that doesn't call parent.removeFrame. A more useful property is that nodes still connected to an input survive the iframe removal, and are only freed when processing completes:

  let convolver = audioContext.createConvolver();
  let gain = audioContext.createGain();
  soundSource.connect(worklet).connect(convolver).connect(gain).connect(audioContext.destination);
  audioContext.startRendering();
  sleep(200);
  convolver.disconnect();
  gain = null;
  gc();
  parent.removeFrame(); //<-------- Get parent frame to delete outselves;

In that snippet, after worklet finishes, the convolver stays alive while gain is freed. Selective lifetime control like this is the key building block for the exploit.

Where the dangling code paths lead

Each AudioHandler owns a set of AudioNodeInputs and AudioNodeOutputs that reference each other non-owningly:

class MODULES_EXPORT AudioHandler : public ThreadSafeRefCounted<AudioHandler> {
  ...
  Vector<std::unique_ptr<AudioNodeInput>> inputs_;
  Vector<std::unique_ptr<AudioNodeOutput>> outputs_;
class AudioSummingJunction {
  ...
  // m_renderingOutputs is a copy of m_outputs which will never be modified
  // during the graph rendering on the audio thread.  This is the list which
  // is used by the rendering code.
  // Whenever m_outputs is modified, the context is told so it can later
  // update m_renderingOutputs from m_outputs at a safe time.  Most of the
  // time, m_renderingOutputs is identical to m_outputs.
  // These raw pointers are safe. Owner of this AudioSummingJunction has
  // strong references to owners of these AudioNodeOutput.
  Vector<AudioNodeOutput*> rendering_outputs_;
};
class MODULES_EXPORT AudioNodeOutput final {
  ...
  // This HashSet holds connection references. We must call
  // AudioNode::makeConnection when we add an AudioNodeInput to this, and must
  // call AudioNode::breakConnection() when we remove an AudioNodeInput from
  // this.
  HashSet<AudioNodeInput*> inputs_;

For a node chain worklet → convolver → gain, the ConvolverHandler's AudioNodeInput points to worklet's output and its AudioNodeOutput points to gain's input:

  soundSource.connect(worklet).connect(convolver).connect(gain).connect(audioContext.destination);

Rendering an audio graph walks backwards from the destination, calling AudioNodeInput::Pull on each input; each input then calls AudioNodeOutput::Pull on the attached output, which invokes AudioHandler::ProcessIfNecessary on the owning AudioHandler. That chain recurses back through AudioNodeInput::Pull until reaching a source node with no inputs, at which point processing begins with AudioHandler::Process. When processing completes, control unwinds back through the same stack:

graph pull

In that trace, the red region marks calls made using objects that have already been freed. Once the AudioWorkletHandler's Process call returns, the next AudioHandler in the chain may have been replaced. There are three distinct branch points to consider:

  1. After Process returns to ProcessIfNecessary and then AudioNodeOutput::Pull, if the call came via AudioNodeInput::Pull (not SumAllConnections), execution jumps back into AudioHandler::PullInputs of the freed handler. The inputs_ vector will have been freed while the loop over it is still running.
  2. If that inputs_ vector has exactly one element, the loop exits after one iteration, and ProcessIfNecessary continues from the point right after PullInputs:
void AudioHandler::ProcessIfNecessary(uint32_t frames_to_process) {
  ...
  PullInputs(frames_to_process);
  ...
  bool silent_inputs = InputsAreSilent();
  if (silent_inputs && PropagatesSilence()) {
    SilenceOutputs();
    ProcessOnlyAudioParams(frames_to_process);
  } else {
    UnsilenceOutputs();
    Process(frames_to_process);
  }

At that point the AudioHandler is already freed. The code then calls either the virtual PropagatesSilence or Process, depending on InputsAreSilent.

  1. If the freed AudioHandler was replaced by another valid AudioHandler, so the virtual call doesn't crash, the function returns to AudioNodeOutput::Pull. But the AudioNodeOutput itself is still freed: it is smaller than AudioHandler, so heap replacement via the latter doesn't touch it. AudioNodeOutput::Pull then calls Bus on that freed object, and returns a pointer to its owned AudioBus, which is likewise freeable and replaceable with controlled data. This matters only on the SumAllConnections path, since the Pull path ignores the return value.

The second branch — one input and a hijacked virtual call — would give full control flow but needs a working info leak to defeat ASLR and a heap pointer for the fake vtable, so it can't be used yet. The first branch is potentially the most interesting: it could let the freed inputs_ vector point to an array of arbitrary objects once replaced, meaning a type confusion between AudioNodeInput and whatever else gets placed there. A simple CodeQL query can enumerate candidate types:

from Field f, PointerType t, Type c
where f.getType().getName().matches("Vector<%") and
    f.getType().(ClassTemplateInstantiation).getTemplateArgument(0) = t and
    t.refersTo(c)
select f, c, f.getDeclaringType(), f.getLocation()

However, the code path through Pull involves many dereferences and is complex enough that turning it into a usable primitive still requires considerable work.

Turning the UAF into an info leak

With SumAllConnections passing the freed output pointer into SumFrom, the number of channels between summing_bus and connection_bus selects one of several call paths. The simplest branch invokes AudioChannel::SumFrom:

void AudioBus::SumFrom(const AudioBus& source_bus,
                       ChannelInterpretation channel_interpretation) {
  ...
  if (number_of_source_channels == number_of_destination_channels) {
    for (unsigned i = 0; i < number_of_source_channels; ++i)
      Channel(i)->SumFrom(source_bus.Channel(i));

    return;
  }

That method duplicates data from source_bus (the connection_bus) into summing_bus, sized by the length of summing_bus:

void AudioChannel::SumFrom(const AudioChannel* source_channel) {
  ...
  if (IsSilent()) {
    CopyFrom(source_channel);
  } else {
    //Copies using the length of `summing_bus` (`length()`)
    vector_math::Vadd(Data(), 1, source_channel->Data(), 1, MutableData(), 1,
                      length());
  }
}

If the freed AudioNodeOutput can be replaced with a Bus shorter than summing_bus, the copy overruns the source and produces an out-of-bounds read. Arranging the heap so the over-read lands on useful data — a vtable pointer or heap address — gives the address leak needed to turn the type-confused virtual call into code execution.

Replacing the Bus

Two constraints complicate the replacement. First, connection_bus must resolve to a valid AudioBus even after the AudioNodeOutput is freed. Since AudioNodeOutput and AudioBus are allocated from different PartitionAlloc buckets (104 and 32 bytes respectively), the two objects can be managed independently: replace the Bus without disturbing the freed AudioNodeOutput. PartitionAlloc scrambles the first eight bytes of a freed object, but that does not corrupt the pointer returned by AudioNodeOutput::Bus, so connection_bus still references the substituted object.

Second, every legitimate AudioNodeOutput carries a fixed-length 128-sample Bus. An arbitrary shorter allocation must come from elsewhere. AudioBus::Create in WebAudioBus::Initialize is reachable from decodeAudioData, where the length of the produced AudioBus is governed by the input ArrayBuffer size. Encoding MP3 files with ffmpeg at differing durations yields AudioBus objects of corresponding lengths.

The harder obstacle is reading the leaked bytes out. Triggering the UAF requires deleting the iframe containing the audio graph; afterwards, all nodes in that graph are unreachable from script, so the copied data in summing_bus cannot be inspected. Replacing summing_bus itself with an AudioBus from a live node is not viable:

void AudioNodeInput::SumAllConnections(scoped_refptr<AudioBus> summing_bus,
                                       uint32_t frames_to_process) {
  ...
  for (unsigned i = 0; i < NumberOfRenderingConnections(); ++i) {
    ...
    summing_bus->SumFrom(*connection_bus, interpretation);
  }
}

The ownership is shared via scoped_refptr, so the summing_bus survives the graph teardown until SumAllConnections returns, and any substitution would still be pinned to the dead frame’s memory.

Running the parent’s graph

The iterator invalidation from the previous section provides a way around the reachability problem. After deleting the child iframe during AudioHandler::PullInputs, the loop walks beyond the freed inputs_ backing store:

void AudioHandler::PullInputs(uint32_t frames_to_process) {
  ...
  for (auto& input : inputs_)
    input->Pull(nullptr, frames_to_process);
}

Replacing that freed backing store with a new Vector of identical size turns the loop into a type-confused traversal. Rather than aiming for a complex object, the expedient choice is to place another ChannelMergerNode - one living in the parent frame’s audio graph - at the same offset. The loop then blindly continues processing, effectively switching execution into the parent graph midway:

object replacement

Dashed lines mark the original child graph’s edges before deletion; the green nodes execute in the parent graph once the substitution lands. At first glance this seems unremarkable — the parent graph can be rendered directly from the parent. What it unlocks is the garbage-collection path for AudioNode. When an AudioNode is collected, it protects its AudioHandler from deletion during processing by checking IsPullingAudioGraph:

void AudioNode::Dispose() {
  ...
  if (context()->IsPullingAudioGraph()) {
    context()->GetDeferredTaskHandler().AddRenderingOrphanHandler(
        std::move(handler_));
  }

The check succeeds only when the graph is in kRunning state:

bool OfflineAudioContext::IsPullingAudioGraph() const {
  ...
  return ContextState() == BaseAudioContext::kRunning;
}

In the interleaved processing scenario, the parent graph is being pulled as part of the child graph’s render. Since the parent graph was never started from its own frame — it may have been started and then suspended, leaving it in kSuspended — the graph’s state does not match kRunning. The ownership-transfer branch is skipped and the AudioHandler is deleted mid-render.

This produces the same UAF without removing the iframe that owns the parent graph. The affected nodes remain script-accessible after the corruption, so the out-of-bounds read payload stored in summing_bus can finally be retrieved and examined, yielding the addresses needed for the final code-execution step.

Building a real info leak

With the primitives established, the info leak is assembled through a sequence of deliberate heap manipulations. The core object graph uses two ScriptProcessorNodes sandwiching a GainNode in the parent frame, chosen because ScriptProcessorNode executes its processing script in the DOM window context, making node access straightforward.

replace graph

  1. Trigger the use-after-free in a child iframe and leverage the loop iterator invalidation primitive to route an audio graph branch into the parent frame.
  2. Inside the audio processing script for the second ScriptProcessorNode, remove and garbage-collect the subsequent GainNode, freeing its AudioInputNode and AudioOutputNode.

replace graph

Replacing the deleted GainNode prevents crashes from virtual function calls, but the freed AudioOutputNode must remain unoccupied. Extra AudioOutputNodes are generated via an additional ChannelMergerNode. Because the merger node created in createSource is only removed during garbage collection—after the GainNode—enough free entries sit at the head of the freelist.

function createSource() {
let s = audioCtx.createChannelMerger(3);
}
//The audio processor of the first ScriptProcessorNode
function scriptProcess2(audioProcessingEvent) {
//Need to use for creating holes for AudioOutputNode, so they don't get reclaim
createSource();
...
script2.disconnect();  //<--- remove reference to the `GainNode`
gc();                  //<--- first deletes `GainNode`, then `ChannelMergerNode` created in `createSource`.
//Needs to wait for the small objects allocated by GC to clear
sleep(4000);
let gain = audioCtx2.createGain();  //<---- replace gain to get virtual function calls through
let src0 = audioCtx2.createChannelMerger(1);  //<--- To arrange heap for AudioBus
...
} 

garbage detector delete order

The replacement GainNode then occupies the space but leaves the old AudioOutputNode dangling, which in turn provides access to a freed AudioBus. The figure below marks freed objects in green and newly created ones in red.

heap

The AudioBus itself is a 32-byte allocation, so the corresponding bucket needs similar grooming, again using a ChannelMergerNode. Care is taken to avoid reclaiming the AudioNodeOutput of the deleted GainNode.

heap

Operation Wolf

The AudioBus allocation bucket is noisy, but this is manageable: the renderer is an isolated process with exclusive heap ownership, so precise spraying is possible from a fresh renderer context, such as one opened from a logged-in session link.

Once the heap state places the freed AudioBus at the correct freelist position, AudioContext::decodeAudioData is called with an ArrayBuffer containing compressed audio. The decode occurs on a background thread and produces an AudioBus sized to hold the decoded samples.

void AsyncAudioDecoder::DecodeOnBackgroundThread(
    DOMArrayBuffer* audio_data,
    float sample_rate,
    V8DecodeSuccessCallback* success_callback,
    V8DecodeErrorCallback* error_callback,
    ScriptPromiseResolver* resolver,
    BaseAudioContext* context,
    scoped_refptr<base::SingleThreadTaskRunner> task_runner) {
  ...
  scoped_refptr<AudioBus> bus = CreateBusFromInMemoryAudioFile(
      audio_data->Data(), audio_data->ByteLength(), false, sample_rate);  //<----- AudioBus created here
  ...
  if (context) {
    PostCrossThreadTask(
        *task_runner, FROM_HERE,
        CrossThreadBindOnce(&AsyncAudioDecoder::NotifyComplete,
                            WrapCrossThreadPersistent(audio_data),
                            WrapCrossThreadPersistent(success_callback),
                            WrapCrossThreadPersistent(error_callback),
                            WTF::RetainedRef(std::move(bus)),            //<------ passed to `NotifyComplete`
                            WrapCrossThreadPersistent(resolver),
                            WrapCrossThreadPersistent(context)));
  }
}

The created AudioBus is dispatched to the main thread via NotifyComplete, which deletes it when finished.

void AsyncAudioDecoder::NotifyComplete(
    DOMArrayBuffer*,
    V8DecodeSuccessCallback* success_callback,
    V8DecodeErrorCallback* error_callback,
    AudioBus* audio_bus,
    ScriptPromiseResolver* resolver,
    BaseAudioContext* context) {
  ...
  AudioBuffer* audio_buffer = AudioBuffer::CreateFromAudioBus(audio_bus);

  // If the context is available, let the context finish the notification.
  if (context) {
    context->HandleDecodeAudioData(audio_buffer, resolver, success_callback,
                                   error_callback);
  }
}

Because this AudioBus is temporary, it must survive until the out-of-bounds read occurs in AudioNodeInput::SumAllConnections on the audio thread. Jamming the task queue with setInterval delays NotifyComplete execution, keeping the AudioBus alive through the read.

Using ffmpeg to generate a silent mp3 yields an AudioBus with a minimal length of 47. An AudioBus from an AudioNodeInput has length 128, and the backing store uses float format with padding of size 16 (16 on Android, 32 on x86). The out-of-bounds read can therefore reach objects between 204 and 528 bytes. A CodeQL query—similar to the one used for CVE-2020-6449—identifies candidate objects and tunes the file length.

class FastMallocClass extends Class {
    FastMallocClass() {
        exists(Operator op, Function fastMalloc | op.hasName("operator new") and
          fastMalloc.hasName("FastMalloc") and op.calls(fastMalloc) and
          op.getDeclaringType() = this.getABaseClass*()
        )
    }
}

from FastMallocClass c
where c.getSize() <= 528 and c.getSize() > 204
select c, c.getLocation(), c.getSize()

The query was refined to include only objects allocated in the FastMalloc partition, where the AudioBus backing store (AudioArray) lives. Among the results, BiquadDSPKernel stands out: leaking a vtable pointer also exposes the biquad_ field, five AudioDoubleArrays whose backing store addresses can serve later as storage for a fake vtable.

By arranging the heap so a BiquadDSPKernel sits directly behind the crafted AudioBus, triggering the bug leaks the kernel object into the next AudioNode‘s input. A ScriptProcessorNode reads that input through JavaScript, yielding the vtable address and the AudioDoubleArray pointers.

From leak to code execution

The remainder follows a standard pattern. The leaked BiquadDSPKernel vtable gives the offset of libchrome.so, from which ROP gadget addresses are computed. A fake vtable is placed inside one of the leaked AudioDoubleArrays with virtual function pointers aimed at chosen gadgets.

The use-after-free is then triggered once more, taking the direct path that calls a virtual function on the freed AudioHandler. That slot is reclaimed with an AudioArray of suitable size filled with controlled data so its vtable references the fabricated one.

call func

Gadgets modeled after the previous exploit call OS::SetPermissions to mark the AudioDoubleArray backing store as rwx. Shellcode placed there runs on a final trigger. In the working exploit, a DelayNode serves as the freed AudioHandler with the feedforward coefficients of an IIRFilterNode impersonating the DelayHandler. The full chain is available with setup notes.

Why this matters for Chrome’s sandbox

The WebAudio vulnerability once again traces back to intricate object cleanup interacting with multithreading, yielding a capable renderer RCE primitive. Blink bugs take longer to weaponize than V8 ones, but remain a substantial attack surface for reaching sandboxed RCE.

Looking across the full chain, Chrome’s rapid patching—roughly six weeks for both the renderer bug and the sandbox escape—kept the vulnerabilities from overlapping in a stable release, which is exactly what makes sandboxing effective. On Android, however, the Zygote forking model’s once-per-boot ASLR weakens that protection: while Chrome’s base is still randomized between processes, many libraries are not, so local exploits can reuse gadgets trivially. Since both Windows and Android share this limitation, once-per-boot ASLR remains a significant weak spot in Chrome’s overall sandbox architecture against local privilege escalation.