← All reports

[1] JSC MicrotaskCallCache retains detached CodeBlock entry points

HighJSC runtime/interpreterUAF

The detach that guarantees no compiled code survives missed one cache.

75a9d41

High. A forced code-detach that exists precisely to guarantee "no compiled code survives" left one VM-lifetime cache still holding a JIT entry point keyed on a still-live executable. Escalation depends on the detach landing between microtask drains and the JIT memory being reclaimed before the next GC reconciliation.

JavaScript engines cache the address of already-compiled machine code so a repeated call can jump straight to it instead of re-resolving the callee. In JSC, a ScriptExecutable is the persistent representation of a piece of JS source, and the CodeBlock installed on it is the compiled artifact for one specialization; a cache that memoises the executable-to-entry-point pairing is only correct while that pairing is the one the VM actually has installed. The engine has a forced-detach operation that severs every such pairing across the whole heap, and the invariant it is meant to establish is that no previously compiled code remains reachable afterward.

The angle: script that arranges a forced code detach to land between microtask drains could have an async-generator resumption continue executing through a cached entry point the engine already declared dead, and, once that JIT memory is reclaimed, call through a dangling executable-memory pointer.

When Heap::deleteAllCodeBlocks is called, CodeBlock is forcefully detached from the ScriptExecutable. As a result, many of CallLinkInfo cache's Executable -> CodeBlock pair gets broken. When using CachedCall/MicrotaskCall on the stack, this is fine since we use them only after entering VMEntryScope, and Heap::deleteAllCodeBlocks cannot be called with VMEntryScope. But MicrotaskCallCache in VM is not scoped with VMEntryScope, thus its pairing is important and needs to be cleared when Heap::deleteAllCodeBlocks is called.

We are doing the same thing for Wasm's DataIC below, and we need to do the same for VM's MicrotaskCallCache. This patch clears that cache in Heap::deleteAllCodeBlocks.

Source/JavaScriptCore/heap/Heap.cpp

void Heap::deleteAllCodeBlocks(DeleteAllCodeEffort effort)
...
});
});
 
+ // 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();
+
#if ENABLE(WEBASSEMBLY)
{
// We must ensure that we clear the JS call ICs from Wasm. Otherwise, Wasm will

Source/JavaScriptCore/interpreter/MicrotaskCall.cpp

+void MicrotaskCall::clear()
+{
+ if (isOnList())
+ remove();
+ m_addressForCall = nullptr;
+ m_codeBlock = nullptr;
+ m_functionExecutable = nullptr;
+ m_numParameters = 0;
+}
+
void MicrotaskCall::reconcileWeakReferencesAtGCEnd(VM& vm)
{
- if ((m_functionExecutable && !vm.heap.isMarked(m_functionExecutable)) || (m_codeBlock && !vm.heap.isMarked(m_codeBlock))) {
- if (isOnList())
- remove();
- m_addressForCall = nullptr;
- ...
- }
+ if ((m_functionExecutable && !vm.heap.isMarked(m_functionExecutable)) || (m_codeBlock && !vm.heap.isMarked(m_codeBlock)))
+ clear();
}

Source/JavaScriptCore/runtime/VM.cpp

+void VM::clearMicrotaskCallCaches()
+{
+ m_syncResumeCallCache->clear();
+}

JSTests/stress/microtask-call-cache-delete-all-code.js

+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) => { shouldBe(result, count * (count - 1) / 2); asyncTestPassed(); });
+// Deleting all code detaches every CodeBlock from its executable, and it runs once this script returns,
+// so the resumptions above happen afterwards and must not reuse the entry points cached for them here.
+$vm.deleteAllCodeWhenIdle();

MicrotaskCall::clear() is factored out of reconcileWeakReferencesAtGCEnd(): it removes the entry from its CodeBlock incoming-call list when linked and nulls m_addressForCall, m_codeBlock, m_functionExecutable and m_numParameters. MicrotaskCallCache::clear() in MicrotaskCall.h loops the 8-entry m_entries array calling the new per-entry clear(). A new VM::clearMicrotaskCallCaches() forwards to m_syncResumeCallCache->clear(). Heap::deleteAllCodeBlocks() calls it immediately after the loop that clears code on every clearable ScriptExecutable, just ahead of the existing WebAssembly JS-call-IC clearing block.

Two collateral edits ride along: MicrotaskQueue::drainImpl gains a comment documenting why the stack-local MicrotaskCallCache is sound, and CodeCache::clear() now calls write() before m_sourceCode.clear() — a bytecode-cache flush unrelated to the memory-safety fix. The regression test drives 3000 async-generator resumptions through a for await loop and then calls $vm.deleteAllCodeWhenIdle() so the detach lands between microtask drains.

Caching a derived pointer under a key that stays valid, without hooking the invalidation event that severed the derivation.

Where this lives. JSC compiles JavaScript through several tiers and caches call targets at call sites so a repeated call skips re-resolution. ScriptExecutable is the persistent representation of a piece of JS source — a function body, program, or module. CodeBlock is the compiled, linked bytecode/JIT artifact installed on that executable for a given specialization kind.

Forced code detach. ScriptExecutable::clearCode() detaches the pair: it clears m_codeBlockForCall/m_codeBlockForConstruct and nulls the cached m_jitCodeForCall pointers. Heap::deleteAllCodeBlocks(DeleteAllCodeEffort) performs that detach across every clearable executable in the heap; its callers are memory pressure, debugger attach, and option changes. $vm.deleteAllCodeWhenIdle() is the test shell hook that schedules it for the next idle point.

Scope and stack liveness. VMEntryScope marks the window during which the VM is executing JS on this thread. Conservative stack scanning means the GC treats machine-stack words as potential object references, so a raw pointer held in a stack frame keeps its target marked — which is why a cache that only ever lives on the stack can hold untraced pointers safely.

Call-site caches. CallLinkInfoBase is the shared base for call-site caches (CallLinkInfo, CachedCall, PolymorphicCallNode, MicrotaskCall); instances live on a CodeBlock's incoming-call SentinelLinkedList and receive unlinkOrUpgradeImpl(VM&, oldCodeBlock, newCodeBlock) when that CodeBlock is replaced or torn down. MicrotaskCall caches, for one callee executable, the resolved CodeBlock*, its numParameters, and the raw JIT entry point m_addressForCall; tryCallWithArguments uses those to jump directly via vmEntryToJavaScriptWithNArguments when the argument count fits the callee's parameter count. MicrotaskCallCache is an 8-entry array of such records with find() matching on the callee's ExecutableBase*.

reconcileWeakReferencesAtGCEnd(). The weak-reference sweep hook: at GC end it drops entries whose executable or CodeBlock was not marked. It is a liveness hook, not an invalidation hook.

Two homes for the same cache. Async generators and for await loops resume by scheduling microtasks. VM::m_syncResumeCallCache is the VM-lifetime cache used for those synchronous resumptions (its role is indicated by the member name and the regression test's shape rather than by a call site in the supplied context), in contrast to the per-drain cache created as a stack local in MicrotaskQueue::drainImpl.

The bug is a stale cached code pointer that becomes a use-after-free once the detached CodeBlock and its JITCode are reclaimed. Two invalidation mechanisms each looked complete on its own, and the VM-owned cache sat outside both.

  Reaches cache entries?        deleteAllCodeBlocks   GC-end reconcile
  ────────────────────────      ───────────────────   ────────────────
  walks clearable executables          yes                  no
  walks incoming-call list             no                   no
  checks mark bits                     no                   yes

  VM::m_syncResumeCallCache:      not on a CodeBlock list, executable still
                                  marked  ──►  survives BOTH

MicrotaskCallCache::find() keys only on the callee's executable (entry.isInitializedFor(executable)), and MicrotaskCall::tryCallWithArguments takes the fast path whenever m_addressForCall is non-null, jumping straight to that entry point and passing m_codeBlock to vmEntryToJavaScriptWithNArguments. The design depends on the executable-to-CodeBlock pairing recorded at link time still being the VM's installed pairing at call time. Heap::deleteAllCodeBlocks() breaks exactly that: it walks the clearable-code sets and calls ScriptExecutable::clearCode() on each executable, nulling the JIT code pointers and clearing the CodeBlock fields — the executable is detached, but the still-live executable continues to satisfy the cache's callee check. (The Heap.cpp excerpt is truncated before that loop body; the loop's shape is consistent with the surrounding code and with the comment the patch adds, and the detach itself is what the commit message describes.)

The stack-local twin in MicrotaskQueue::drainImpl is sound for two reasons the patch now spells out in a comment: conservative stack scanning keeps the untraced CodeBlock alive, and Heap::deleteAllCodeBlocks cannot run while a VMEntryScope is active. The VM-owned copy has neither property. It survives across drains, across VM entry and exit, and its only invalidation hook was reconcileWeakReferencesAtGCEnd(), which fires at GC end and only clears entries whose executable or CodeBlock went unmarked. A forced detach is not a GC-liveness event — the executable is still perfectly reachable — so between deleteAllCodeBlocks and the next reconciliation the cache would still route an async-generator or await resumption into a CodeBlock the VM has declared dead, through a code pointer derived from a JITCode the executable no longer references.

The regression test constructs precisely that window: 3000 resumptions are still pending when the script returns, and $vm.deleteAllCodeWhenIdle() schedules the detach to land between drains (the deferred-to-idle behavior is stated by the test's own comment rather than by any supplied implementation), so the resumptions that follow must not reuse the entry points cached for them beforehand.

Note also the asymmetry the fix exposes on the incoming-call side: unlinkOrUpgradeImpl(vm, old, nullptr) only nulls m_addressForCall, leaving the executable and CodeBlock fields, which is fine because the null entry forces a relink. Forced detach reaches entries through neither the incoming-call list nor GC marking. (That the detach path does not invoke unlinkOrUpgradeIncomingCalls for the affected CodeBlocks is a necessary premise here; the relevant teardown code is not among the supplied excerpts.)

Discovery reads as self-audit rather than fuzzing. The commit message points at the WebAssembly DataIC clearing already present in Heap::deleteAllCodeBlocks — "We are doing the same thing for Wasm's DataIC below" — i.e. someone enumerated the consumers that must be notified on forced code detach and found the newly added VM-level cache missing from that list. The MicrotaskCall/MicrotaskCallCache files carry 2026 copyrights, so this is a recent addition audited by its author; the regression test's hand-built shape supports that reading, though a stress run with aggressive deleteAllCode options could have surfaced the same crash.

This vulnerability weakens memory-type safety and code integrity inside the WebContent process. Heap::deleteAllCodeBlocks exists precisely to guarantee that no previously compiled code remains reachable after a forced detach, and every caller — memory pressure, debugger attach, option changes — relies on that guarantee; before the fix the VM-owned microtask call cache violated it by holding an executable-keyed entry point that outlived the detach. An attacker who arranged the detach to land between microtask drains could have JavaScript continue executing through a code pointer the engine considers dead, and if the detached CodeBlock and its JIT memory were reclaimed before the cache entry was reconciled, the resumption would call through a dangling executable-memory pointer — the kind of primitive that, under favourable JIT-memory reuse, could escalate toward control-flow hijack in the renderer.

The deeper distinction is between caches whose safety comes from scope and caches whose safety must come from invalidation. CachedCall and the MicrotaskCallCache stack local in drainImpl are safe because conservative stack scanning marks their untraced fields and detach cannot happen inside a VMEntryScope; the moment the same type is hoisted into a VM-lifetime UniqueRef member, both properties silently evaporate while the code compiles unchanged. Heap::deleteAllCodeBlocks has become a broadcast point that every code-pointer cache must subscribe to, and subscription is manual — nothing in the type system forces a new cache to register. The comment added to drainImpl is effectively a warning label for the next person who considers promoting a stack cache to a member.