A UAF in the Fast Partition
In March 2020, I reported a use-after-free (UAF) vulnerability in Chrome's WebAudio module that was assigned CVE-2020-6449. What makes this bug interesting is not the UAF itself, but where it happens: the freed object is a non-garbage-collected object allocated by PartitionAlloc in the Fast partition.
In Blink (which WebAudio is part of), heap objects are allocated with different memory allocators based on type. Most garbage-collected objects use Oilpan, while non-garbage-collected objects use PartitionAlloc. The exceptions are ArrayBuffer and String back stores, which land in PartitionAlloc even though the objects themselves are garbage-collected.
PartitionAlloc's design is a significant obstacle to exploitation. It isolates primitive containers (strings, vectors, array buffers) into dedicated partitions, separate from regular "executable" objects in the Fast partition. This separation means a corruption in the Buffer or ArrayBuffer partitions is hard to turn into control-flow hijack, and a corruption in the Fast partition is hard to use for crafting fake objects with attacker-controlled data from the other partitions.
Two high-profile 2019 in-the-wild exploits—CVE-2019-5786 and CVE-2019-13720 (WizardOpium)—involved UAFs in the ArrayBuffer partition, where the challenge was breaking out of that partition. Detailed write-ups for those are available elsewhere, with a broader technique summary here.
This article takes the opposite direction: going from a UAF in the Fast partition to code execution, using CVE-2020-6449 as the concrete example. The technique generalizes to other Fast-partition UAFs.
Root Cause
The bug lives in DeferredTaskHandler::BreakConnections:
void DeferredTaskHandler::BreakConnections() {
...
wtf_size_t size = finished_source_handlers_.size();
if (size > 0) {
for (auto* finished : finished_source_handlers_) {
// Break connection first and then remove from the list because that can
// cause the handler to be deleted.
finished->BreakConnectionWithLock();
active_source_handlers_.erase(finished);
}
finished_source_handlers_.clear();
}
}
Normally, active_source_handlers_ keeps alive the raw pointers stored in finished_source_handlers_. The entry finished is only erased from active_source_handlers_ after it has been used, so the invariant holds. The UAF occurs if active_source_handlers_ can be cleared without also clearing finished_source_handlers_—then finished may already be freed when BreakConnections dereferences it.
Triggering the Bug
A closely related bug I reported earlier helps explain the trigger conditions.
void DeferredTaskHandler::BreakConnections() {
...
wtf_size_t size = finished_source_handlers_.size();
if (size > 0) {
for (auto* finished : finished_source_handlers_) {
active_source_handlers_.erase(finished); //<-- finished is now free'd
finished->BreakConnectionWithLock(); //<-- UaF
}
finished_source_handlers_.clear();
}
}
In that earlier issue, finished is removed from active_source_handlers_ before it is used, so triggering it only requires that active_source_handlers_ be the sole owner keeping finished_source_handlers_ alive at that point. For CVE-2020-6449, active_source_handlers_ must additionally be cleared in advance.
Both lists relate to AudioScheduledSourceNode, with subclasses ConstantSourceNode and OscillatorNode. Calling start() on one of those nodes adds its AudioHandler to active_source_handlers_. In the PoC for the first bug, this adds the handler for src:
let src = audioCtx.createConstantSource();
src.start();
At that point, both the node src and active_source_handlers_ pin the AudioHandler. Calling stop() schedules a stop event at time zero for src; the event reaches HandleStoppableSourceNode, which moves it into finished_source_handlers_. Then, still inside the promise handler, we can call audioCtx.suspend(), which runs JavaScript on the main thread:
audioCtx.suspend((3 * 128)/3072.0).then(()=>{
gc();
audioCtx.resume();
});
After the ConstantSourceNode's JavaScript handle is gone, nothing else references it, so invoking gc() collects and destroys the object. From that point active_source_handlers_ alone keeps the entry in finished_source_handlers_ alive. A call to audioCtx.resume() eventually reaches BreakConnections and hits the UAF.
The CVE-2020-6449 trigger adds one more step: clearing active_source_handlers_ before reaching BreakConnections. The only practical way to do that is to tear down the JavaScript execution context—wrapping the whole PoC in an iframe and destroying it. The main PoC file is embedded in the iframe and also grows the audio graph significantly; for instance, its onLoad handler creates 2000 PannerNode instances.
function onLoad() {
startStop().then((audioCtx) => {
audioCtx.suspend((3 * 128)/3072.0).then(()=>{
//======new======
let dest = audioCtx.createConstantSource();
dest.start();
for (let i = 1; i < 2000; i++) {
dest = dest.connect(audioCtx.createPanner());
}
dest.connect(audioCtx.destination);
//=====new end======
....
});
audioCtx.startRendering();
});
}
The stopStart method also attaches an AudioWorkletNode. Both node types exist to shape the timing between the audio thread (where BreakConnections runs) and the main thread (where active_source_handlers_ is cleared). They slow down the audio thread enough for the main thread to finish clearing the handler list before the bug fires.
Turning the UAF into a type confusion
Exploiting this bug requires replacing the freed object — a subclass of AudioScheduledSourceHandler — with one of similar size, hoping that BreakConnectionWithLock does something useful on the replacement. In release 80.0.3987.137 for Linux, the candidates are ConstantSourceHandler (240 bytes) and OscillatorHandler (312 bytes), falling into bins (225–240) and (289–320).
Looking for receiver objects of matching size, BiquadDSPKernel stands out. It is reachable from JavaScript via AudioContext::createBiquadFilter(), and crucially, the connection_ref_count_ field decremented by BreakConnectionWithLock aligns with biquad_.a1_.allocation, a pointer field in an AudioDoubleArray. On its own this seems like a dead end: allocation_ is only used to back aligned_data_, and if it is never read afterward, our decrement appears harmless.
void AudioHandler::BreakConnectionWithLock() {
deferred_task_handler_->AssertGraphOwner(); //<---- No effect in release build
connection_ref_count_--;
#if DEBUG_AUDIONODE_REFERENCES
fprintf(stderr,
"[%16p]: %16p: %2d: AudioHandler::BreakConnectionWitLock %3d [%3d] "
"@%.15g\n",
Context(), this, GetNodeType(), connection_ref_count_,
node_count_[GetNodeType()], Context()->currentTime());
#endif
if (!connection_ref_count_)
DisableOutputsIfNecessary(); //<--- calls virtual function
}
The debug-only dereference is omitted in release; the remaining work is a decrement of a counter and, potentially, a virtual call through DisableOutputsIfNecessary. But the "dead end" is not quite dead — when the AudioArray is destroyed, allocation_ becomes the head of the free list in its bin. Decrementing that pointer by one creates a one-byte overlap with the previous chunk. By triggering the bug repeatedly with a fresh BiquadDSPKernel each time, PartitionAlloc reuses the same chunks and the same allocation_ pointer, so each iteration shaves another byte off the freed pointer. The attack only needs to repeat this 62 times to amass enough overlap for a real type confusion.
Finding same-bin objects to place as victims is straightforward using CodeQL:
from Type t
where (t.getSize() <= 240) and (t.getSize() > 225)
select t
Corrupting the free list this way yields the first primitive: a writable overlap between objects in the bin of size 1024 bytes. From there, the goal is an information leak that reveals both a libchrome address and a heap pointer, allowing controlled data at a known location.
Leaking through the audio graph
The chosen target for that leak is an HRTFPanner (size 1152, same bin as the corrupted allocation_ pointer). If a fresh HRTFPanner allocation lands where the corrupted pointer expects it, the beginning of HRTFPanner's vtable and shared pointer field database_loader_ overlap the end of a prior chunk:
The usual partition separation works against us here. HRTFPanner and similar objects live in the Fast partition, while JavaScript-facing containers like ArrayBuffer live in the buffer or array buffer partition, so readably sized JavaScript objects cannot simply overlap the panner. The AudioArray we used to corrupt the free list is also in the fast partition and is size-controllable from C++ but generally not readable from JavaScript — its contents are overwritten before they are ever exposed.
Except in one case. In AudioDelayDSPKernel::Process, the AudioFloatArray field buffer_ may not be fully overwritten before being returned to the user. This lets us craft a DelayNode whose buffer_ overlaps a pending HRTFPanner, and then render the audio graph so the panner's vtable and heap pointer end up in the delay node's output:
//Create a DelayDSPKernel whose buffer_ has the right size, which will be used to leak data.
delay_leak = audioCtx.createDelay(0.0908);
//3/3072 = 1./1024, need to divide by power of 2 to avoid rounding error when converting to double
delay_leak.delayTime.value = 3 * 0.0009765625;
With those two addresses — the vtable location and heap pointer for database_loader_ — code execution follows. Rather than use the initial UAF to trigger a virtual call, we can instead destroy the delay_leak node, allocate another AudioArray over the now-freed HRTFPanner, and overwrite its vtable to point at a chosen callback gadget. The panner's virtual destructor then invokes it.
A suitable gadget is one of libchrome's many callback trampolines, which let us call arbitrary functions with arbitrary arguments:
//mov rax,QWORD PTR [rdi + 0x20]; <-- function call
//mov rsi,QWORD PTR [rdi + 0x98]; <-- arg0
//mov rdx,QWORD PTR [rdi + 0xa0]; <-- arg1
//add rdi, 0x28 <--- arg2
Its approximate address aligns with a symbol that itself takes three arguments:
base::internal::Invoker<base::internal::BindState<void (*)(blink::KURL const&, base::WaitableEvent*, std::__1::unique_ptr<blink::WebGraphicsContext3DProvider, std::__1::default_delete<blink::WebGraphicsContext3DProvider> >*), blink::KURL, WTF::CrossThreadUnretainedWrapper<base::WaitableEvent>, WTF::CrossThreadUnretainedWrapper<std::__1::unique_ptr<blink::WebGraphicsContext3DProvider, std::__1::default_delete<blink::WebGraphicsContext3DProvider> > > >, void ()>::RunOnce(base::internal::BindStateBase*)
Calling OS::SetPermissions through that trampoline flips the pages holding our controlled data to rwx, at which point arbitrary shellcode runs in the renderer.
Beyond the Write
The exploitation of CVE-2020-6449 ultimately hinged on a highly constrained primitive: decrementing a single pointer field within a replaced object. This limitation is instructive. It demonstrates that while exploitation often benefits from broad capabilities, success is still possible with a powerful yet narrow action when the surrounding object layout and application logic cooperate. The complexity arose not from the vulnerability itself but from navigating the memory allocator’s mitigations, which reliably turned a textbook use-after-free into a more challenging engineering problem.
This case also highlights why layered defenses matter. A single bug, regardless of its severity, was insufficient to fully compromise the browser. The sandbox architecture forced the exploit to stop at the renderer boundary, requiring a separate, independent vulnerability to achieve system-level access. The combined effect of rapid patching, continuous bug discovery, and process isolation significantly raises the practical cost of turning any individual flaw into a full chain.
The working exploit, tested against a symbol build of 80.0.3987.137 on Ubuntu, is available with setup notes in the GitHub Security Lab repository.



