← All reports

CVE 취약점 리포트를 규칙에 맞춰 한국어로 재작성하겠습니다.

[JSC] Wasm::InstanceAnchor should be unregistered at the prologue of JSWebAssemblyInstance destructor

HighJSC WebAssembly runtimeUAF

CVE: CVE-2026-28947 · Safari 26.5 · Released May 13, 2026 Impact: Processing maliciously crafted web content may lead to an unexpected Safari crash Apple's description: A use-after-free issue was addressed with improved memory management. Credit: dr3dd

76b3468 | Bugzilla 310234

High. Destructor 내부에서 실제로 바뀐 것은 코드 자체가 아니라 다섯 줄의 위치뿐입니다. 하지만 그 다섯 줄의 위치가 "다른 어떤 thread도 이 객체를 이름으로 찾을 수 없는 상태"와 "테이블이 이미 destruct된 뒤에도 compiler thread가 여전히 이 객체를 찾아낼 수 있는 상태"를 가르는 경계선입니다. 일반적인 스크립트만으로도 race의 양쪽을 모두 유발할 수 있으며, background thread crash를 넘어서는 확장 여부는 compiler가 stale slot을 가지고 실제로 무엇을 하느냐에 달려 있습니다.

JavaScriptCore는 WebAssembly를 tier 단위로 컴파일합니다. 즉, main thread가 스크립트를 계속 실행하는 동안 hot function은 background thread에서 재컴파일되는 구조입니다. 이때 background thread는 자신이 컴파일하는 대상 데이터를 가진 살아있는 instance에 접근할 방법이 필요한데, 그 경로가 바로 Wasm::InstanceAnchor입니다. Instance는 생성이 끝나는 순간 자신을 소유한 module에 이 handle을 등록합니다. 소스 주석에도 그 목적이 정확히 적혀 있습니다: "Now, JSWebAssemblyInstance is fully initialized. Expose it to the concurrent compiler." Publication을 마지막에 하는 것은 의도된 설계 원칙입니다. 모든 필드가 유효해진 이후에야 객체가 다른 곳에서 접근 가능해지도록 만드는 것입니다. 이 계약의 반대편 절반은 destruction 시점에도 성립해야 합니다. 즉, 어떤 필드든 유효성을 잃기 전에 먼저 unpublish가 이루어져야 합니다.

관전 포인트: 페이지가 garbage collection을 유발해 Wasm instance의 테이블과 캐시된 데이터를 정리하는 동안, background compiler thread가 여전히 같은 instance를 찾아 읽어낼 수 있는 상황이 만들어집니다.

Source/JavaScriptCore/wasm/js/JSWebAssemblyInstance.cpp

JSWebAssemblyInstance::~JSWebAssemblyInstance()
{
+ if (m_anchor) {
+ m_anchor->tearDown();
+ m_anchor = nullptr;
+ }
+
m_vm->traps().unregisterMirror(m_stackMirror);
clearJSCallICs(*m_vm);
 
for (auto& slot : importFunctionInfos())
std::destroy_at(&slot);
 
for (auto& slot : tables())
std::destroy_at(&slot);
 
for (auto& slot : baselineDatas())
std::destroy_at(&slot);
-
 
- if (m_anchor) {
 
- m_anchor->tearDown();
 
- m_anchor = nullptr;
 
- }
}

JSTests/wasm/stress/instance-anchor.js

+//@ runDefault("--jitPolicyScale=0.1")
+function bury(f, n) {
+ if (n === 0) {
+ return f();
+ }
+ return bury(f, n - 1);
+}
+function main() {
+ const mod = new WebAssembly.Module(WASM_CODE);
+ function warmUpInstanceB() {
+ const instanceB = new WebAssembly.Instance(mod);
+ instanceB.exports.foo();
+ }
+ bury(warmUpInstanceB, 500);
+ const instanceA = new WebAssembly.Instance(mod);
+ for (let i = 0; i < 500; i++)
+ instanceA.exports.foo();
+ gc();
+ print("done (should have crashed above)");
+}
+main();

기능적으로 바뀐 부분은 다섯 줄이 위치를 옮긴 것뿐이며, 코드 자체가 다시 작성된 것은 아닙니다. if (m_anchor) { m_anchor->tearDown(); m_anchor = nullptr; } 블록은 수정 전후로 byte 단위까지 동일하며, 단지 ~JSWebAssemblyInstance()의 맨 아래에서 맨 위로 옮겨졌을 뿐입니다. 이전까지 이 블록보다 먼저 실행되던 코드들은 이제 모두 그 뒤에서 실행됩니다. m_vm->traps().unregisterMirror(m_stackMirror), clearJSCallICs(*m_vm), 그리고 importFunctionInfos(), tables(), baselineDatas()를 순회하는 세 개의 std::destroy_at 루프가 이에 해당합니다.

  Before:                          After:
  ~JSWebAssemblyInstance()         ~JSWebAssemblyInstance()
    unregisterMirror                 anchor->tearDown()   ◄── window closed here
    clearJSCallICs                   unregisterMirror
    destroy_at importFunctionInfos   clearJSCallICs
    destroy_at tables                destroy_at importFunctionInfos
    destroy_at baselineDatas         destroy_at tables
    anchor->tearDown()  ◄── too late destroy_at baselineDatas

Commit의 나머지 절반은 새로운 regression test인 JSTests/wasm/stress/instance-anchor.js입니다. 이 테스트는 --jitPolicyScale=0.1 옵션 하에서 실행되는데, 이 옵션은 tier-up threshold를 낮춰서 함수가 평소보다 훨씬 적은 호출 횟수만으로도 optimizing compilation 대상으로 큐에 들어가도록 만듭니다. 테스트는 같은 module을 두 번 instantiate합니다. instanceBbury()를 통해 500단계 깊이의 재귀 안에서 생성되고 warm-up되며, 이후 instanceA는 최상위 레벨에서 생성되어 500번 warm-up됩니다. 그 다음 gc()가 동기적으로 호출됩니다. 마지막 줄은 print("done (should have crashed above)")로 되어 있는데, 패치 이전에는 이 테스트의 pass 조건이 다름 아닌 crash였다는 뜻입니다.

JSC에서의 Wasm instance. new WebAssembly.Instance(module)을 호출하면 JSWebAssemblyInstance가 만들어지는데, 이는 garbage-collected JSC cell로서 해당 instantiation의 메모리, 테이블, 전역 변수, import function call link 정보, JS call inline cache, instance별 baseline data를 소유합니다. GC로 관리되는 객체이기 때문에 destructor는 스크립트가 결정하는 시점이 아니라, main thread의 sweep 도중 JSWebAssemblyInstance::destroy에서 실행됩니다.

Concurrent tier-up. JSC는 Wasm을 한 번만 컴파일하지 않습니다. 함수는 낮은 tier에서 시작하고, 실행 횟수가 threshold를 넘으면 optimizing compiler에 의해 background compiler thread에서 재컴파일 대상으로 큐에 들어갑니다. 이 과정에서 main thread는 계속 JavaScript를 실행하며, background thread는 baseline data, 테이블, call link 정보 같은 instance별 상태를 참조하며 작업을 수행합니다.

Wasm::InstanceAnchor. Compiler thread에게는 살아있는 instance를 이름으로 지목할 방법이 필요한데, anchor가 바로 그 역할을 합니다. finishCreation의 마지막 동작으로 m_module->registerAnchor(this)를 통해 소유 module에 등록되는 handle입니다. Owner가 아닌 thread가 instance에 도달하는 조회 경로가 바로 이것이며, tearDown()은 이에 대응하는 등록 해제 동작입니다.

std::destroy_at. 이 함수는 storage를 해제하지 않은 채 제자리에서 객체의 destructor만 호출합니다. Instance의 slot 배열들에는 이 구분이 중요합니다. Slot의 바이트 자체는 cell 안에 인라인으로 존재하며 GC sweep이 끝날 때까지 남아 있지만, slot 객체가 heap pointer를 통해 소유하고 있던 대상, 예를 들어 테이블의 backing store 같은 것들은 destructor가 실행되는 즉시 해제됩니다.

Publish/unpublish의 대칭성. 이 원칙에 따르면, 초기화는 객체를 공유 가능한 상태로 노출하는 것을 마지막 단계로 두어야 하고, destruction은 그 반대로 unpublish를 첫 단계로 두어야 합니다. 이렇게 해야 객체가 visible한 구간이 usable한 구간을 항상 포함하는 상위 집합이 됩니다.

Conservative stack scanning. JSC의 collector는 machine stack의 살아있는 영역을 GC root 집합으로 취급합니다. 이미 반환된 frame에서 살아있던 값이라고 해서 자동으로 계속 유지되는 것은 아닙니다. Collector가 더 이상 그 영역을 스캔하지 않기 때문입니다. 깊은 재귀 안에서 함수를 호출한 뒤 다시 unwind하면, 그 함수가 들고 있던 reference는 스캔 범위를 벗어난 stack 메모리에 남게 되며, 테스트는 이 방식으로 특정 객체를 원하는 시점에 collectable하게 만듭니다. 테스트가 사용하는 보조 옵션은 tier-up threshold를 낮추는 --jitPolicyScale=0.1과, jsc shell에서 동기적으로 collection을 요청하는 gc() intrinsic입니다.

이 버그는 partial-destroy 도중에 unpublish가 이루어지는 유형에 해당합니다. Destructor가 이미 객체를 해체하기 시작한 상태에서도, 다른 thread는 registry를 통해 여전히 이 객체를 찾아낼 수 있습니다.

  Main thread (GC sweep)              Wasm compiler thread
  ────────────────────────            ─────────────────────────────
  ~JSWebAssemblyInstance()
    unregisterMirror
    clearJSCallICs                    module->anchor lookup ──► this
    destroy_at importFunctionInfos      │  still resolves
    destroy_at tables                   │
    destroy_at baselineDatas            ▼
                                      read baselineDatas()/tables()
                                        ── destructors already ran ──
    anchor->tearDown()   ◄── window ends only here

위 다이어그램에서 window에 해당하는 구간은 tearDown() 이전에 실행되는 destructor 본문 전체입니다. finishCreation은 publication 순서를 올바르게 지키고 있습니다. Anchor는 모든 필드가 유효해진 이후에만 등록되며, 소스 주석도 그 이유를 설명하고 있습니다. 다만 destructor는 저자가 멤버를 떠올린 순서대로 작성되었을 뿐, visibility의 역순으로 작성되지는 않았습니다. 그 결과 unpublish가 맨 마지막에 놓이게 되었습니다. Destructor의 첫 statement부터 마지막 statement까지, anchor는 여전히 this로 resolve됩니다. 이 구간 안에서 anchor를 통해 instance를 획득한 compiler thread는 이미 절반쯤 destruct된 객체를 다루게 됩니다.

"절반쯤 destruct되었다"는 것이 구체적으로 어떤 상태인지는 sweep이 어디까지 진행되었는지에 달려 있습니다. clearJSCallICs(*m_vm) 이후에는 내장된 JS call inline cache가 이미 리셋된 상태입니다. std::destroy_at 루프들이 실행된 이후에는 importFunctionInfos(), tables(), baselineDatas()의 각 slot에 대해 destructor가 이미 호출된 상태입니다. Slot storage 자체는 cell 안에 인라인으로 남아 있어 아직 해제되지 않았지만, 그 객체들이 heap에서 소유하고 있던 대상은 이미 해제된 상태입니다. 따라서 이 시점에 Wasm::Table slot을 읽는 compiler thread는 이미 backing store를 소유하지 않는 구조체를 순회하게 되고, baselineDatas()를 읽는 thread는 destructor가 이미 완료된 entry를 읽게 됩니다.

이번에 추가된 테스트는 이 정확한 interleaving을 스크립트만으로 만들어내는 압축된 레시피입니다.

  1. Module을 한 번만 컴파일합니다. 그래야 두 instance가 같은 Wasm::Module을 공유하고, 결과적으로 같은 anchor registry와 같은 컴파일 파이프라인을 공유하게 됩니다.
  2. bury(warmUpInstanceB, 500) 내부에서 instanceB를 생성하고 export를 호출합니다. --jitPolicyScale=0.1 하에서는 이 instance가 거의 즉시 tier-up threshold를 넘게 됩니다.
  3. 재귀가 unwind됩니다. 이제 instanceB의 유일한 reference는 살아있는 frame 아래쪽의 stack 메모리에만 남아 있는데, conservative scanning은 더 이상 이 영역을 커버하지 않습니다. 따라서 이 시점에는 실제로 수거 가능한 상태가 됩니다.
  4. instanceA를 생성하고 export를 500번 호출해, 같은 module의 컴파일 파이프라인을 background thread에서 계속 바쁘게 유지합니다.
  5. gc()를 호출합니다. Main thread에서는 instanceB에 대한 ~JSWebAssemblyInstance()가 sweep 과정에서 실행되고, 그와 동시에 같은 module에 대한 tier-up이 compiler thread에서 진행 중입니다.

bury()가 존재하는 이유는 바로 3단계 때문입니다. bury()가 없다면 instanceB는 여전히 살아있는 frame에서 도달 가능한 상태이므로 collection 대상이 되지 않고, 결과적으로 race를 결정적으로 재현할 방법이 사라집니다. 이것은 결정적인 dereference가 아니라 race입니다. Compiler thread가 정확히 이 window 안에서 anchor를 통해 획득해야만 성립하기 때문입니다. 다만 race의 양쪽 모두 일반적인 웹 콘텐츠만으로 유발할 수 있고, 이 테스트는 평범한 JavaScript만으로도 두 시점을 충분히 맞출 수 있음을 보여줍니다.

패치는 tearDown()을 prologue로 끌어올립니다. 그 결과 anchor는 unregisterMirror가 실행되기 전, call IC가 정리되기 전, 그리고 어떤 std::destroy_at보다도 먼저 module의 registry에서 사라집니다. 이렇게 하면 visible lifetime이 다시 usable lifetime을 완전히 포함하게 됩니다. Destruction이 시작된 순간부터는 어떤 lookup도 이 instance를 다른 곳에 넘겨줄 수 없습니다. 이 패치가 막는 직접적인 관찰 결과는 destructor가 이미 실행된 상태를 compiler thread가 건드려서 발생하는 crash입니다. 회수된 heap 내용을 읽는 cross-thread use-after-free로 이어질 가능성이 있으며, write primitive로 확장되려면 compiler thread가 그 stale 상태를 실제로 변조해야 하는데, 이 부분은 제공된 context만으로는 뒷받침되지 않습니다. 전체 과정은 WebContent process 내부에서 일어납니다. JSC와 그 Wasm compiler thread들은 모두 renderer thread이므로, 이 자체로 sandbox 경계를 넘는 것은 아니며, 공격자는 여전히 별도의 escape 수단이 필요합니다.

Construction 단계에서는 publication이 올바르게 마지막에 배치되었지만, destruction 단계에서도 unpublication이 마찬가지로 마지막에 배치되어 있었습니다. 그 결과 destructor 본문 전체가 compiler thread가 여전히 instance를 resolve할 수 있는 window로 남아 있었습니다.

올바른 원칙은 이미 이 파일 안에 적혀 있었습니다. finishCreation은 anchor를 마지막에 publish하며, 왜 초기화가 완전히 끝난 뒤에 publication이 이루어져야 하는지를 주석으로 설명하고 있습니다. Publish-last는 계약의 절반일 뿐이고, unpublish-first가 나머지 절반입니다. 그런데 이 절반은 잊히기 쉽습니다. Destructor는 대체로 저자가 멤버를 떠올린 순서대로 작성되지, visibility의 역순으로 작성되지 않기 때문입니다. JSC의 Wasm 구현이 점점 더 많은 작업을 concurrent compiler thread로 옮기면서, anchor나 callee group, 공유 baseline data처럼 cross-thread handle이 새로 추가될 때마다 그 teardown 순서가 GC sweep 타이밍과 충돌하지 않는지 함께 점검할 필요가 생깁니다. 이번 테스트 기법은 버그 자체와는 별개로도 눈여겨볼 만합니다. bury(f, 500)은 conservative stack scanning을 우회해 특정 객체를 원하는 gc() 시점에 확실히 collectable하게 만드는 이식성 있는 트릭으로, JSC lifetime 테스트나 PoC 어디에서든 활용할 수 있습니다.