[1] JSC MicrotaskCallCache retains detached CodeBlock entry points
The detach that guarantees no compiled code survives missed one cache.
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를 통해 호출이 발생할 수 있습니다.
Commit message
When
Heap::deleteAllCodeBlocksis called,CodeBlockis forcefully detached from theScriptExecutable. As a result, many ofCallLinkInfocache's Executable -> CodeBlock pair gets broken. When usingCachedCall/MicrotaskCallon the stack, this is fine since we use them only after enteringVMEntryScope, andHeap::deleteAllCodeBlockscannot be called withVMEntryScope. ButMicrotaskCallCachein VM is not scoped withVMEntryScope, thus its pairing is important and needs to be cleared whenHeap::deleteAllCodeBlocksis 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 inHeap::deleteAllCodeBlocks.
Source/JavaScriptCore/heap/Heap.cpp
Source/JavaScriptCore/interpreter/MicrotaskCall.cpp
Source/JavaScriptCore/runtime/VM.cpp
JSTests/stress/microtask-call-cache-delete-all-code.js
Patch Details
MicrotaskCall::clear()는 reconcileWeakReferencesAtGCEnd()에서 분리되어 나온 함수입니다. entry가 linked 상태이면 CodeBlock의 incoming-call list에서 제거하고, m_addressForCall, m_codeBlock, m_functionExecutable, m_numParameters를 모두 null 처리합니다. MicrotaskCall.h의 MicrotaskCallCache::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 아래 캐싱해 둔 패턴입니다.
Background
이 코드가 있는 위치.
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 단위 캐시와 대비됩니다.
Analysis
이 버그는 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::tryCallWithArguments는 m_addressForCall이 non-null이기만 하면 fast path를 타서 그 entry point로 곧장 점프하고 m_codeBlock을 vmEntryToJavaScriptWithNArguments에 전달합니다. 이 설계는 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으로 이어질 가능성도 있습니다.
Insight
더 근본적인 구분점은 안전성이 scope에서 나오는 캐시와, 안전성이 invalidation에서 나와야 하는 캐시 사이의 차이입니다. CachedCall과 drainImpl 안의 MicrotaskCallCache stack local은, conservative stack scanning이 tracing되지 않는 필드를 marked 상태로 유지시켜 주고 VMEntryScope 안에서는 detach가 발생할 수 없기 때문에 안전합니다. 그런데 동일한 타입이 VM lifetime UniqueRef member로 승격되는 순간, 이 두 속성은 코드 컴파일 자체는 아무 변화 없이 조용히 사라져 버립니다. Heap::deleteAllCodeBlocks는 이제 모든 code-pointer 캐시가 구독해야 할 broadcast 지점이 되었지만, 그 구독은 수동으로 이루어집니다. 새로운 캐시가 자동으로 여기에 등록되도록 강제하는 타입 시스템 장치는 존재하지 않습니다. drainImpl에 추가된 주석은, 다음에 stack 캐시를 member로 승격시키려는 사람을 위한 일종의 경고 라벨인 셈입니다.
Audit directions
-
long-lived identity에 키를 두지만 실제로는 거기서 파생된 pointer를 저장하는 cache. 이 파생 관계는 cache가 관찰하지 못하는 이벤트에 의해 끊어질 수 있습니다.
VM진입 scope보다 오래 사는 owner가 rawCodeBlock*또는CodePtr<JSEntryPtrTag>를 들고 있는 모든 JSC cache를 점검해야 합니다. 이때reconcileWeakReferencesAtGCEnd뿐 아니라Heap::deleteAllCodeBlocks와deleteAllUnlinkedCodeBlocks에서도 무효화되는지 확인해야 합니다. 먼저CallLinkInfoBase::CallSiteType의 하위 클래스들(CallLinkInfo,PolymorphicCallNode,DirectCall,CachedCall,MicrotaskCall)과, 이 타입들을 멤버로 갖는 모든VM/JSGlobalObject필드를 나열하는 것부터 시작합니다. 좁게 보면,VM이나 global object에 선언된 멤버 중reconcileWeakReferencesAtGCEndhook은 있지만Heap::deleteAllCodeBlocks에서 도달 가능한clear()호출은 없는 경우가 대응되는 패턴입니다. 조금 더 넓게 보면, megamorphic·property inline cache,MegamorphicCache,HasOwnPropertyCache,StringSplitCache, watchpoint 기반 constant fold 등 파생 결과물을 저장하는 다른 cache에서도 동일한 형태가 나타날 수 있습니다. 이 경우 key는 살아남지만, invalidation 이벤트가 value만 무효화하는 상황이 발생합니다. 여기서 신호가 되는 것은, 하나의 container만 순회하는 invalidation 루틴이 있고 다른 container는 그 요소로부터 파생된 pointer를 여전히 들고 있는 구조입니다. 가장 넓게 보면, 이는 "invalidation broadcast에 등록되지 않은 subscriber가 존재하는" 일반적인 클래스에 해당합니다. schema나 type identity에 키를 둔 여러 개의 독립적인 cache를 가진 JIT나 query planner라면 어디든 점검 대상이 됩니다. V8의 feedback vector와 deopt, SpiderMonkey의 IC와discardJitCode, DDL 이후의 database plan cache가 그 예입니다. 여기서 지켜야 할 invariant는, 파생 관계를 memoize하는 모든 cache가 그 관계를 끊을 수 있는 모든 이벤트로부터 도달 가능해야 한다는 점입니다. 그리고 그런 이벤트들의 목록은 새로운 cache 작성자가 읽을 수 있는 곳에 반드시 문서화되어 있어야 합니다. -
"이건 항상 stack 위에만 존재한다"는 전제 위에 세워진 안전성. Conservative stack scanning 기반의 liveness와 scope 기반 배제 로직은, 해당 타입이 heap에 할당되는 owner로 승격되는 순간 조용히 깨집니다. JSC 안에서
WTF_FORBID_HEAP_ALLOCATION으로 표시된 타입들(MicrotaskCall도 포함됩니다)을 검색하고, 이 중 어느 것이VM,JSGlobalObject, 혹은UniqueRef/Ref필드가 소유하는 대상 안에 직접 또는 배열 멤버 형태로 내장되어 있는지 확인해야 합니다.MicrotaskCallCache가std::array<MicrotaskCall, cacheSize>를 통해VM::m_syncResumeCallCache에 도달하는 경로가, 바로 이 annotation을 빠져나간 실제 사례입니다. 좁게 보면, stack 전용으로 annotate된 타입이 heap에 할당된 aggregate의 멤버나 배열 요소로 나타나는 패턴이 대응됩니다. 조금 더 넓게 보면, 추적되지 않는 raw field가 오직 conservative scanning에 의해서만 살아있는 모든 구조, 그리고 "scope Y 안에 있는 동안에는 X가 일어날 수 없다"는 형태의 invariant에서 해당 object의 복사본이 scope Y 밖으로 빠져나가는 모든 경우가 이 클래스에 해당합니다. scope 배타성을 correctness 근거로 삼는 주석이 있는지 찾아보는 것이 좋습니다. 가장 넓게 보면, 이는 stack root와 heap root를 구분하는 모든 GC 통합 런타임에 대응됩니다. SpiderMonkey의Rooted<T>와Heap<T>, V8의Handle/HandleScope와Global, Go의 stack-scanned local과 write barrier가 필요한 heap object가 그 예입니다. stack에 뿌리를 둔 타입을 heap storage로 승격시키려면, 추적되지 않던 모든 field를 추적 가능하게 만들거나 명시적으로 무효화되도록 바꿔야 하며, 컴파일러는 이를 알려주지 않습니다. -
invalidation fan-out의 누락. 여러 teardown 진입점이 각각 개별적으로는 점검되었지만, 전체 consumer 집합을 기준으로 교차 검증된 적은 없었습니다.
Heap::deleteAllCodeBlocks,Heap::deleteAllUnlinkedCodeBlocks,CodeBlock::jettison,ScriptExecutable::installCode,ScriptExecutable::clearCode각각에 대해 어떤 cache가 무효화되는지를 추적하고, 이를 행렬 형태로 정리해볼 필요가 있습니다. 이번에 수정된 gap은,clearCode가 executable까지는 도달하지만unlinkOrUpgradeIncomingCalls는 list로 연결된 consumer까지만 도달했고, VM cache는 두 집합 어디에도 속하지 않았다는 점이었습니다. 좁게 보면, 하나의 ownership graph(incoming-callSentinelLinkedList나 clearable-codeIsoCellSet)만 순회하며 상태를 지우는 invalidation 루틴이 있는데, consumer는 executable-keyed lookup 같은 제3의 경로를 통해 동일한 상태에 도달하는 경우가 대응됩니다. 조금 더 넓게 보면, teardown trigger가 두 개 이상 존재하는 모든 WebKit subsystem에서 같은 형태가 나타납니다. detach 시점의 style/render tree invalidation과 style recalc 시점의 invalidation, connection close에 의한 IPC 쪽 object teardown과 명시적 destroy 메시지에 의한 teardown이 그 예입니다. 가장 넓게 보면, 공유된 파생 상태에 대해 여러 개의 독립적인 invalidation trigger를 갖는 모든 시스템에 적용됩니다. cache-coherence protocol, CDN purge 경로, raw SQL 이후의 ORM identity map이 그런 예에 해당합니다. 각 invalidation trigger에 대해, subscriber 집합이 서로 동일한지를 물어야 하고, 그렇지 않다면 어느 trigger의 집합이 더 작은지, 그리고 왜 그런지를 물어야 합니다.