75a9d414a4a8b98d26840162d103eb16227eaeb6 [JSC] Clear MicrotaskCallCache in VM when CodeBlock gets detached
Triage note: MicrotaskCallCache caches entry points keyed on the callee executable outside any CodeBlock; deleteAllCodeBlocks detaches CodeBlocks without clearing the cache, so a later microtask resumption would call into detached/freed code — commit adds vm.clearMicrotaskCallCaches() and MicrotaskCall::clear().
Contents
The bug at a glance
The stale-entry-point window is reachable from ordinary web JavaScript: any script that schedules async/await or async-generator microtask resumptions and then causes a full CodeBlock detach (deleteAllCode, triggered by memory pressure, debugger attach, or the internal idle path exercised by the test) leaves the VM’s syncResumeCallCache pointing at code that has been detached from its executable. On the next microtask drain the cached entry point is invoked against detached/freed JIT code, yielding a controllable use-after-free that is a strong renderer-RCE primitive; the CVSS 8.1 reflects reliable remote reachability tempered by the need to force a whole-heap code deletion.
CachedCall-style inline caches are safe precisely because they only live on the C++ stack inside a VMEntryScope, and Heap::deleteAllCodeBlocks can never run while a VM entry is active — so conservative stack scanning keeps the callee alive and the Executable->CodeBlock pairing stays valid for the life of the call. The VM’s syncResumeCallCache broke that invariant: it is a long-lived member of VM, not a stack local, caching a microtask resumption’s entry point keyed on the callee FunctionExecutable. deleteAllCodeBlocks forcibly severs every CodeBlock from its ScriptExecutable but left this cache untouched, so its callee-identity check would still hit and dispatch into code that no longer belongs to any live CodeBlock. The fix simply teaches deleteAllCodeBlocks to flush that cache the same way it already flushes Wasm’s JS-call DataICs.
Root cause
MicrotaskCall is an inline-cache slot that memoizes how to re-enter a suspended coroutine (async function / async generator) when its microtask runs: it stores m_functionExecutable, m_codeBlock, m_numParameters and a raw m_addressForCall entry point. A MicrotaskCallCache is a fixed table of these slots. Crucially, the copy that lives inside MicrotaskQueue::drainImpl is a stack local (the added comment now spells out why that is the only sound place for one), whereas VM::m_syncResumeCallCache is a UniqueRef member that persists across drains.
Heap::deleteAllCodeBlocks walks every ScriptExecutable and forcibly detaches its CodeBlock (clearing the Executable->CodeBlock association that all call ICs are keyed on). The commit message is explicit that on-stack CachedCall / MicrotaskCall are unaffected because they are only consulted after entering a VMEntryScope, and deleteAllCodeBlocks is structurally forbidden from running inside a VMEntryScope. The persistent m_syncResumeCallCache has no such protection.
The reaching path: script schedules many pending async-generator resumptions, whose entry points get cached in m_syncResumeCallCache; before those microtasks drain, deleteAllCode fires (the PoC uses $vm.deleteAllCodeWhenIdle(), which runs after the current script returns) and detaches every CodeBlock. When the queued microtasks then resume, the cache’s callee check (still matching on the surviving FunctionExecutable) succeeds and the stale m_addressForCall is jumped to — but that entry point belonged to a CodeBlock that has been detached and whose backing JIT memory may already be reclaimed. That is the use-after-free.
The fix adds VM::clearMicrotaskCallCaches(), which calls m_syncResumeCallCache->clear(); a new MicrotaskCallCache::clear() iterates its entries and a new MicrotaskCall::clear() removes the slot from its list and nulls m_addressForCall/m_codeBlock/m_functionExecutable/m_numParameters (factored out of reconcileWeakReferencesAtGCEnd, which now just calls it). Heap::deleteAllCodeBlocks invokes vm.clearMicrotaskCallCaches() right after the detach loop, so no stale entry point survives the code deletion.
Key code
deleteAllCodeBlocks now flushes the VM’s persistent microtask call cache after detaching all CodeBlocks
// Heap::deleteAllCodeBlocks, after the CodeBlock detach loop:
// MicrotaskCallCache lives outside any CodeBlock and keys its cached entry points on the callee's
// executable, so after the code is detached above its callee check would still hit and call into it.
vm.clearMicrotaskCallCaches();
// VM.cpp
void VM::clearMicrotaskCallCaches()
{
m_syncResumeCallCache->clear();
}
// MicrotaskCall.cpp
void MicrotaskCall::clear()
{
if (isOnList())
remove();
m_addressForCall = nullptr;
m_codeBlock = nullptr;
m_functionExecutable = nullptr;
m_numParameters = 0;
}
// MicrotaskCall.h (MicrotaskCallCache)
void clear()
{
for (auto& entry : m_entries)
entry.clear();
}
Patch walkthrough
Source/JavaScriptCore/heap/Heap.cpp— Inside deleteAllCodeBlocks, immediately after the loop that detaches every CodeBlock from its executable, a call to vm.clearMicrotaskCallCaches() is inserted. The added comment states the root cause: the cache lives outside any CodeBlock and keys on the callee executable, so after detachment its callee check would still hit and call into detached code. This sits right beside the pre-existing Wasm DataIC clearing, mirroring that established pattern.Source/JavaScriptCore/runtime/VM.cpp— Adds VM::clearMicrotaskCallCaches(), which forwards to m_syncResumeCallCache->clear(). It is placed next to reconcileWeakReferencesAtGCEnd, which already reconciled the same cache against GC marking — the new method provides the unconditional flush that a forced detach (not a GC sweep) requires.Source/JavaScriptCore/runtime/VM.h— Declares void clearMicrotaskCallCaches() next to the existing m_syncResumeCallCache member and its accessor, exposing the flush entry point to Heap.cpp.Source/JavaScriptCore/interpreter/MicrotaskCall.cpp— Extracts the slot-reset logic into a new MicrotaskCall::clear() that removes the entry from its list if linked and nulls m_addressForCall, m_codeBlock, m_functionExecutable and m_numParameters. reconcileWeakReferencesAtGCEnd is rewritten to call clear() when its weak-reference condition holds, so the GC path and the forced-flush path share one code path.Source/JavaScriptCore/interpreter/MicrotaskCall.h— Declares MicrotaskCall::clear() and adds MicrotaskCallCache::clear(), which loops over m_entries calling entry.clear() — the aggregate flush VM::clearMicrotaskCallCaches drives, matching the existing reconcileWeakReferencesAtGCEnd aggregate right below it.Source/JavaScriptCore/runtime/MicrotaskQueue.cpp— No behavioral change; adds a comment to the stack-local MicrotaskCallCache in drainImpl documenting the invariant that made this class of bug subtle — on-stack entries are kept alive by conservative scanning and code is detached only outside a VM entry scope, so only a cache that outlives a drain (VM’s) needs clear()/reconcileWeakReferencesAtGCEnd.Source/JavaScriptCore/runtime/CodeCache.h— Hardening in the same area: CodeCache::clear() now calls write() before clearing m_sourceCode, flushing any pending cached source-code state instead of dropping it, so a clear triggered alongside code deletion does not lose in-flight cache writes.
Background
deleteAllCodeBlocks / DeleteAllCodeEffort — A whole-heap operation that forcibly detaches the compiled CodeBlock from every ScriptExecutable, invalidating all cached Executable->CodeBlock associations. It is driven by memory pressure, debugger/inspector attach, and internal idle reclamation, and is structurally never invoked while a VMEntryScope is active.
MicrotaskCall / syncResumeCallCache — An inline cache used to re-enter a suspended async function or async generator when its resumption microtask runs. It stores the callee’s FunctionExecutable, CodeBlock, parameter count and a raw entry-point address. VM::m_syncResumeCallCache is a long-lived instance, unlike the stack-local one in MicrotaskQueue::drainImpl.
VMEntryScope invariant — On-stack call caches (CachedCall, drainImpl’s MicrotaskCall) are safe because they are only consulted inside a VM entry scope, where conservative stack scanning pins the callee alive and code detach cannot occur. Any cache that survives outside that scope must be explicitly reconciled on GC and flushed on forced code deletion.
Wasm DataIC precedent — deleteAllCodeBlocks already cleared Wasm’s JS-call inline caches (DataICs) after detaching JS code for the same reason. This patch applies the identical treatment to the VM’s microtask call cache, closing the one persistent cache that was still missed.
Vulnerability window
- Cache population — Script drives async-generator / for-await iteration, scheduling many resumption microtasks whose entry points are memoized in VM::m_syncResumeCallCache, each keyed on its callee FunctionExecutable.
- Forced detach — deleteAllCode runs (in the PoC, $vm.deleteAllCodeWhenIdle() fires after the current script returns) and Heap::deleteAllCodeBlocks detaches every CodeBlock from its executable, breaking all Executable->CodeBlock pairings.
- Stale-cache survival (pre-fix) — The persistent syncResumeCallCache is not touched by the detach, so its slots still hold the now-dangling m_addressForCall and m_codeBlock while the matching m_functionExecutable is still live.
- Microtask resumption — The queued resumptions drain; the cache’s callee-identity check hits and control jumps to the stale entry point of a detached CodeBlock whose JIT memory may already be freed — use-after-free.
- Fix — deleteAllCodeBlocks calls vm.clearMicrotaskCallCaches() right after the detach loop; MicrotaskCallCache::clear()/MicrotaskCall::clear() unlink and null every slot so no stale entry point can be reused.
Proof of concept
This is the shipped regression test (JSTests/stress/microtask-call-cache-delete-all-code.js). The async generator produces 3000 yields, creating a large backlog of resumption microtasks whose entry points populate VM::m_syncResumeCallCache. deleteAllCodeWhenIdle schedules a full CodeBlock detach that runs after the top-level script returns but before those resumptions finish. On an unpatched build the resumptions dispatch through stale cached entry points into detached code (crash / UAF); on a patched build the cache was flushed and the sum computes correctly. $vm.deleteAllCodeWhenIdle is a JSC test-harness intrinsic; a web-facing trigger requires forcing deleteAllCode via memory pressure or inspector attach, so no weaponized web PoC is reconstructable from the patch alone.
const count = 3000;
async function* generator() {
for (let index = 0; index < count; ++index)
yield index;
}
async function sum() {
let result = 0;
for await (const value of generator())
result += value;
return result;
}
asyncTestStart(1);
sum().then((result) => {
// expects count*(count-1)/2
asyncTestPassed();
});
// Detaches every CodeBlock after this script returns; the pending
// async-generator resumptions above then run and must NOT reuse the
// entry points cached for them.
$vm.deleteAllCodeWhenIdle();
Exploitation
- Prime the cache — Run async-generator or async/await code that suspends and resumes repeatedly to fill VM::m_syncResumeCallCache slots with entry points for chosen callees, and keep a large batch of resumptions pending in the microtask queue.
- Force code deletion — Trigger deleteAllCode outside a VM entry scope. From the web this is indirect — sustained memory pressure or an attached debugger — which makes reliability the main constraint; $vm gives a deterministic trigger only in test builds.
- Reuse the dangling entry — Let the queued microtasks drain so a resumption dispatches through a stale m_addressForCall into detached JIT code. Grooming the freed CodeBlock/JIT allocation before the jump turns the control transfer into a controllable UAF and a jump-oriented primitive.
Detection & hunting
For defenders and SOC / detection engineers:
- Crash signature —
- Detach-then-drain race —
- Cache invariant assertion —
Audit directions
- Other persistent call caches —
- deleteAllCodeBlocks completeness —
- Cache-clear correctness —