← 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입니다. "컴파일된 코드가 하나도 살아남지 않는다"는 것을 보장하기 위해 존재하는 강제 code-detach 동작이 있음에도, VM lifetime 동안 유지되는 캐시 하나가 여전히 살아있는 executable을 키로 하는 JIT entry point를 붙들고 있었습니다. 이 문제가 확장되려면 detach가 microtask drain 사이 시점에 발생하고, 다음 GC reconciliation 이전에 JIT 메모리가 회수되어야 합니다.

JavaScript 엔진은 이미 컴파일된 machine code의 주소를 캐싱해, 반복 호출 시 callee를 다시 resolve하지 않고 곧바로 점프할 수 있도록 합니다. JSC에서 ScriptExecutable은 JS 소스 코드 조각을 나타내는 영속적인 표현이며, 여기에 설치되는 CodeBlock은 특정 specialization에 대한 컴파일 결과물입니다. executable-to-entry-point 쌍을 memoize하는 캐시는, 그 쌍이 VM이 실제로 설치해 둔 쌍과 일치할 때만 올바르게 동작합니다. 엔진에는 heap 전체에서 이런 모든 쌍을 끊어버리는 강제 detach 연산이 있으며, 이 연산이 보장하려는 invariant는 그 이후에는 이전에 컴파일된 코드가 어디에서도 도달 가능하지 않아야 한다는 점입니다.

관전 포인트: microtask drain 사이에 강제 code detach가 발생하도록 스크립트를 조작하면, async-generator resumption이 엔진이 이미 폐기 처리한 cached entry point를 통해 계속 실행될 수 있습니다. 그리고 그 JIT 메모리가 회수된 이후에는 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()reconcileWeakReferencesAtGCEnd()에서 분리되어 나온 함수입니다. entry가 linked 상태이면 CodeBlock의 incoming-call list에서 제거하고, m_addressForCall, m_codeBlock, m_functionExecutable, m_numParameters를 모두 null 처리합니다. MicrotaskCall.hMicrotaskCallCache::clear()는 8개 entry로 구성된 m_entries 배열을 순회하며 새로 추가된 per-entry clear()를 호출합니다. 새로 추가된 VM::clearMicrotaskCallCaches()m_syncResumeCallCache->clear()로 위임됩니다. Heap::deleteAllCodeBlocks()는 clearable한 모든 ScriptExecutable의 code를 지우는 루프 직후, 기존 WebAssembly JS-call-IC clearing 블록 바로 앞에서 이 함수를 호출합니다.

부수적으로 두 가지 변경이 함께 이루어졌습니다. MicrotaskQueue::drainImpl에는 stack-local MicrotaskCallCache가 왜 안전한지를 설명하는 주석이 추가되었습니다. 또한 CodeCache::clear()m_sourceCode.clear() 이전에 write()를 호출하도록 바뀌었는데, 이는 memory-safety fix와는 무관한 bytecode-cache flush 관련 변경입니다. 회귀 테스트는 for await 루프를 통해 3000회의 async-generator resumption을 실행시킨 뒤 $vm.deleteAllCodeWhenIdle()를 호출해, detach가 microtask drain 사이 시점에 발생하도록 만듭니다.

파생된 pointer를, 그 파생 관계를 끊어버린 invalidation 이벤트에 연결하지 않은 채로, 계속 유효한 key 아래 캐싱해 둔 패턴입니다.

이 코드가 있는 위치. JSC는 여러 tier를 거쳐 JavaScript를 컴파일하며, 반복 호출 시 재 resolve를 생략할 수 있도록 call site마다 call target을 캐싱합니다. ScriptExecutable은 함수 본문, 프로그램, 모듈 등 JS 소스 코드 조각을 나타내는 영속적인 표현입니다. CodeBlock은 특정 specialization 종류에 대해 그 executable 위에 설치되는, 컴파일 및 링크가 끝난 bytecode/JIT 결과물입니다.

강제 code detach. ScriptExecutable::clearCode()는 이 쌍을 분리하는 함수로, m_codeBlockForCall/m_codeBlockForConstruct를 지우고 캐시된 m_jitCodeForCall pointer들을 null 처리합니다. Heap::deleteAllCodeBlocks(DeleteAllCodeEffort)는 heap 안의 clearable한 모든 executable에 대해 이 detach를 수행하며, 호출 지점은 memory pressure, debugger attach, option 변경 등입니다. $vm.deleteAllCodeWhenIdle()은 다음 idle 시점에 이 동작을 예약하는 테스트 셸 훅입니다.

Scope와 stack liveness. VMEntryScope는 VM이 해당 스레드에서 JS를 실행 중인 구간을 표시합니다. Conservative stack scanning으로 인해 GC는 machine stack의 word들을 잠재적인 object reference로 취급하므로, stack frame에 들어 있는 raw pointer는 그 대상을 계속 marked 상태로 유지시킵니다. 이 때문에 오직 stack에만 존재하는 캐시는 별도 tracing 없이도 안전하게 pointer를 유지할 수 있습니다.

Call-site 캐시들. CallLinkInfoBase는 call-site 캐시들(CallLinkInfo, CachedCall, PolymorphicCallNode, MicrotaskCall)이 공유하는 base 클래스입니다. 이 인스턴스들은 CodeBlock의 incoming-call SentinelLinkedList 위에 존재하며, 해당 CodeBlock이 교체되거나 해제될 때 unlinkOrUpgradeImpl(VM&, oldCodeBlock, newCodeBlock)를 전달받습니다. MicrotaskCall은 하나의 callee executable에 대해 resolve된 CodeBlock*, 그 numParameters, 그리고 raw JIT entry point인 m_addressForCall을 캐싱합니다. tryCallWithArguments는 인자 개수가 callee의 parameter 개수와 맞을 때 이 값들을 이용해 vmEntryToJavaScriptWithNArguments를 통해 곧바로 점프합니다. MicrotaskCallCache는 이런 record 8개로 구성된 배열이며, find()는 callee의 ExecutableBase*를 기준으로 매칭합니다.

reconcileWeakReferencesAtGCEnd(). Weak-reference sweep 훅입니다. GC 종료 시점에 executable이나 CodeBlock이 marked되지 않은 entry들을 제거합니다. 이는 liveness를 확인하는 훅이지, invalidation을 처리하는 훅이 아닙니다.

같은 캐시의 두 거처. Async generator와 for await 루프는 microtask를 스케줄링하는 방식으로 resume됩니다. VM::m_syncResumeCallCache는 이런 동기적 resumption에 사용되는 VM lifetime 캐시입니다 (이 역할은 제공된 context 안에서 직접적인 call site로 확인되기보다는, member 이름과 회귀 테스트의 구조로부터 유추한 것입니다). 이는 MicrotaskQueue::drainImpl 안에 stack local로 생성되는 drain 단위 캐시와 대비됩니다.

이 버그는 stale한 cached code pointer에 관한 문제로, detach된 CodeBlock과 그 JITCode가 회수되는 순간 use-after-free로 이어집니다. 두 개의 invalidation 메커니즘이 각각 개별적으로는 완결된 것처럼 보였지만, VM이 소유한 캐시는 그 둘 어디에도 포함되지 않았습니다.

  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()는 오직 callee의 executable(entry.isInitializedFor(executable))만을 key로 사용하며, MicrotaskCall::tryCallWithArgumentsm_addressForCall이 non-null이기만 하면 fast path를 타서 그 entry point로 곧장 점프하고 m_codeBlockvmEntryToJavaScriptWithNArguments에 전달합니다. 이 설계는 link 시점에 기록된 executable-to-CodeBlock 쌍이, 호출 시점에도 VM이 설치해 둔 그 쌍과 여전히 같다는 전제에 의존합니다. Heap::deleteAllCodeBlocks()는 정확히 이 전제를 깨뜨립니다. clearable-code set을 순회하며 각 executable에 ScriptExecutable::clearCode()를 호출해 JIT code pointer를 null 처리하고 CodeBlock 필드를 지웁니다. 즉 executable은 detach되지만, 여전히 살아있는 그 executable은 캐시의 callee 검사를 계속 통과합니다. (Heap.cpp 발췌본은 해당 루프 본문 이전에서 잘려 있지만, 루프의 형태는 주변 코드 및 패치가 추가한 주석과 일치하며, detach 자체는 commit message가 설명하는 바와 일치합니다.)

MicrotaskQueue::drainImpl 안의 stack-local 쌍둥이가 안전한 이유는 패치가 이번에 주석으로 명시한 두 가지에 있습니다. Conservative stack scanning이 tracing되지 않는 CodeBlock을 계속 살아있게 유지한다는 점, 그리고 VMEntryScope가 활성화된 동안에는 Heap::deleteAllCodeBlocks가 실행될 수 없다는 점입니다. 반면 VM이 소유한 사본은 이 두 속성 어느 것도 갖고 있지 않습니다. drain을 넘어, VM entry와 exit를 넘어 계속 살아남으며, 유일한 invalidation 훅은 reconcileWeakReferencesAtGCEnd()뿐이었습니다. 이 훅은 GC 종료 시점에만 실행되고, executable이나 CodeBlock이 unmarked 상태가 된 entry만 지웁니다. 강제 detach는 GC-liveness 이벤트가 아닙니다. executable은 여전히 완벽히 도달 가능한 상태입니다. 따라서 deleteAllCodeBlocks와 다음 reconciliation 사이의 구간에서는, 캐시가 async-generator나 await의 resumption을 VM이 이미 폐기 처리한 CodeBlock으로 계속 라우팅할 수 있었습니다. 이때 사용되는 code pointer는 executable이 더 이상 참조하지 않는 JITCode로부터 파생된 값입니다.

회귀 테스트는 정확히 이 구간을 만들어냅니다. 스크립트가 반환되는 시점에도 3000개의 resumption이 여전히 pending 상태이고, $vm.deleteAllCodeWhenIdle()은 detach가 drain 사이 시점에 발생하도록 예약합니다 (deferred-to-idle 동작 방식은 별도로 제공된 구현이 아니라 테스트 자체의 주석에서 명시된 내용입니다). 따라서 그 이후에 이어지는 resumption들은 사전에 캐싱된 entry point를 재사용해서는 안 됩니다.

Fix가 드러내는 incoming-call 쪽의 비대칭성도 눈여겨볼 만합니다. unlinkOrUpgradeImpl(vm, old, nullptr)m_addressForCall만 null 처리하고 executable과 CodeBlock 필드는 남겨두는데, 이는 null entry가 relink를 강제하기 때문에 문제가 되지 않습니다. 강제 detach는 incoming-call list와 GC marking 어느 경로로도 entry에 도달하지 못합니다. (detach 경로가 해당 CodeBlock들에 대해 unlinkOrUpgradeIncomingCalls를 호출하지 않는다는 점이 여기서 필요한 전제인데, 관련 해제 코드는 제공된 발췌본에 포함되어 있지 않습니다.)

발견 경위는 fuzzing보다는 self-audit에 가깝게 읽힙니다. Commit message는 Heap::deleteAllCodeBlocks 안에 이미 존재하는 WebAssembly DataIC clearing을 언급하며 — "We are doing the same thing for Wasm's DataIC below" — 강제 code detach 시 통지받아야 할 소비자들을 하나씩 점검하다가, 새로 추가된 VM-level 캐시가 그 목록에서 빠져 있음을 발견한 것으로 보입니다. MicrotaskCall/MicrotaskCallCache 파일들은 2026년 copyright를 갖고 있어 비교적 최근 추가된 기능이며, 작성자 본인이 이를 점검한 것으로 볼 수 있습니다. 회귀 테스트가 직접 손으로 구성된 형태라는 점도 이 해석을 뒷받침하지만, 공격적인 deleteAllCode option을 사용한 stress run으로도 동일한 crash가 드러났을 가능성이 존재합니다.

이 vulnerability는 WebContent process 내부의 memory-type safety와 code integrity를 약화시킵니다. Heap::deleteAllCodeBlocks는 강제 detach 이후 이전에 컴파일된 코드가 전혀 도달 가능하지 않다는 것을 보장하기 위해 존재하며, memory pressure, debugger attach, option 변경 등 모든 호출자가 이 보장에 의존합니다. Fix 이전에는 VM이 소유한 microtask call 캐시가 detach 이후에도 살아남는, executable을 key로 하는 entry point를 붙들고 있어 이 보장을 위반하고 있었습니다. Attacker가 microtask drain 사이 시점에 detach가 발생하도록 조작할 수 있다면, JavaScript가 엔진이 이미 죽었다고 간주한 code pointer를 통해 계속 실행되도록 만들 수 있습니다. 그리고 detach된 CodeBlock과 그 JIT 메모리가 캐시 entry가 reconcile되기 전에 회수된다면, 그 resumption은 dangling된 executable-memory pointer를 통해 호출을 수행하게 됩니다. JIT 메모리 재사용이 유리한 조건에서는, 이런 primitive가 renderer 안에서 control-flow hijack으로 이어질 가능성도 있습니다.

더 근본적인 구분점은 안전성이 scope에서 나오는 캐시와, 안전성이 invalidation에서 나와야 하는 캐시 사이의 차이입니다. CachedCalldrainImpl 안의 MicrotaskCallCache stack local은, conservative stack scanning이 tracing되지 않는 필드를 marked 상태로 유지시켜 주고 VMEntryScope 안에서는 detach가 발생할 수 없기 때문에 안전합니다. 그런데 동일한 타입이 VM lifetime UniqueRef member로 승격되는 순간, 이 두 속성은 코드 컴파일 자체는 아무 변화 없이 조용히 사라져 버립니다. Heap::deleteAllCodeBlocks는 이제 모든 code-pointer 캐시가 구독해야 할 broadcast 지점이 되었지만, 그 구독은 수동으로 이루어집니다. 새로운 캐시가 자동으로 여기에 등록되도록 강제하는 타입 시스템 장치는 존재하지 않습니다. drainImpl에 추가된 주석은, 다음에 stack 캐시를 member로 승격시키려는 사람을 위한 일종의 경고 라벨인 셈입니다.