← All reports

[JSC] Do not clone patchpoints for Wasm calls within a try block

HighJSC B3 optimizer / Wasm OMG JITTypeConfusion

CVE: CVE-2026-65334 · Safari 26.6.1 · Released August 18, 2026 Impact: Processing maliciously crafted web content may lead to an unexpected Safari crash Apple's description: A memory corruption issue was addressed with improved state management. Credit: OpenAI Codex Security - Amy Burnett

9f07374 | Bugzilla 316791

High. optimizer가 call site를 복제하면서, 서로 다른 두 machine code 위치가 하나의 exception 복원 레코드를 공유하게 되었습니다. 이 레코드는 throw 시점에 살아 있는 모든 reference의 위치를 기술합니다. 일반적인 웹 콘텐츠만으로 도달 가능하며, reference를 위조하는 쪽으로 확장하려면 두 clone의 register 배치가 공격자에게 유리한 방향으로 어긋나야 합니다.

코드를 재배열하고 복제하는 JIT은 unwinding을 수행하는 runtime에게 한 가지를 보장해야 합니다. 컴파일러가 exception handler에게 "살아 있는 값이 어디에 저장되어 있는지" 알려준 내용은, 실제로 throw가 그 지점에 도달하는 순간에도 여전히 참이어야 합니다. WebAssembly의 OMG tier는 이 약속을 call site별 레코드로 지킵니다. 즉 CallSiteIndex를 키로 하는 stackmap이며, try 안의 호출을 넘어 살아남는 모든 Wasm 값의 위치를 slot 단위로 기술합니다. 여기서 성립해야 하는 invariant는 개수에 관한 것입니다. 키 하나에 call site 하나, 배치 하나입니다. 이 대응이 깨지면 catch 블록은 실제로 throw한 코드가 아닌, 다른 코드의 설명을 보고 local을 다시 채우게 됩니다.

관전 포인트: 페이지가 WebAssembly 모듈을 적절히 구성하면, catch 블록이 reference를 담은 적 없는 slot에서 값을 복원해 reference 타입 값으로 JavaScript에 전달하도록 만들 수 있습니다.

Source/JavaScriptCore/wasm/WasmOMGIRGenerator.cpp

RefPtr<PatchpointExceptionHandle> OMGIRGenerator::preparePatchpointForExceptions(...)
if (!mustSaveState)
return nullptr;
 
+ ASSERT(patch->kind().isCloningForbidden());
+
unsigned firstStackmapChildOffset = patch->numChildren();
unsigned firstStackmapParamOffset = firstStackmapChildOffset + m_proc.resultCount(patch->type());
auto OMGIRGenerator::createCallPatchpoint(BasicBlock* block, const RTT& signature, ...)
auto constrainedPatchArgs = createCallConstrainedArgs(block, wasmCalleeInfo, tmpArgs);
 
advanceCallSiteIndex();
 
- PatchpointValue* patchpoint = m_proc.add<PatchpointValue>(returnType, origin());
+ // try 안의 호출은 CallSiteIndex를 키로 하는 catch 복원용 stackmap을 갖습니다. preparePatchpointForExceptions 참고.
+ // B3 transform이 두 call site를 하나의 stackmap에 aliasing하지 않도록 cloning을 금지합니다.
+ auto patchpointKind = m_tryCatchDepth ? cloningForbidden(Patchpoint) : Patchpoint;
+ PatchpointValue* patchpoint = m_proc.add<PatchpointValue>(returnType, origin(), patchpointKind);
patchpoint->effects.writesPinned = true;
patchpoint->effects.readsPinned = true;
patchpoint->clobberEarly(RegisterSet::macroClobberedGPRs());

Source/JavaScriptCore/b3/B3ReduceStrength.cpp

&& (value->child(1)->isConstant() || value->child(2)->isConstant());
});
 
- if (select) {
+ if (!select)
+ break;
+
+ // Select와 Check 사이의 모든 값은 clone 가능해야 합니다.
+ bool canClone = true;
+ for (unsigned i = m_index; ; --i) {
+ Value* value = m_block->at(i);
+ if (value->kind().isCloningForbidden()) {
+ canClone = false;
+ break;
+ }
+ if (value == select)
+ break;
+ RELEASE_ASSERT(i); // Select는 반드시 발견되어야 합니다
+ }
+
+ if (canClone) {
specializeSelect(select);
m_didSpecializeSelect = true;
break;

JSTests/wasm/stress/omg-reduce-strength-select-exception-stackmap.js

+ const targetBody = body([[1, 0x6f]], [
+ 0x06, 0x6f, // try (결과 externref)
+ ...bits0..bits23 (global.get x24, i64 sentinels 0xfffe00000000002a + n*0x100)
+ 0xd0, 0x6f, // ref.null extern (상수 select arm)
+ 0x23, 0x00, // global.get $object (반대쪽 select arm)
+ ...localGet(0), // select 조건
+ 0x1c, 0x01, 0x6f, // typed select -> externref
+ ...localSet(1), // $live = select 결과
+ ...localGet(1),
+ 0x10, 0x01, // call $helper (try 내부 patchpoint; 항상 throw)
+ 0x1a,
+ ...localGet(1),
+ 0xd4, // ref.as_non_null -> 대상 B3 Check
+ 0x1a,
+ 0xd0, 0x6f,
+ 0x19, // catch_all
+ ...localGet(1), // 복원된 $live 반환
+ 0x0b,
+ ]);
+
+for (let iteration = 0; iteration < wasmTestLoopCount; ++iteration) {
+ const result = target(1);
+ if (result !== null)
+ throw new Error(`expected null catch restoration, got ${String(result)} at iteration ${iteration}`);
+}

프로덕션 코드 변경은 두 곳입니다. contract 양쪽에 하나씩 들어갔고, 여기에 손으로 조립한 regression test가 추가되었습니다.

먼저 producer 쪽입니다. OMGIRGenerator::createCallPatchpoint()는 더 이상 모든 Wasm call의 PatchpointValue를 맨몸 Patchpoint kind로 생성하지 않습니다. 이제 kind는 generator의 현재 중첩 상태로부터 계산됩니다 — m_tryCatchDepth ? cloningForbidden(Patchpoint) : Patchpoint. 따라서 try 안 어디에서든 emit된 call은 노드가 만들어지는 시점에, 즉 어떤 optimizer가 들여다보기도 전에 복제 불가로 표시됩니다. 반면 try 바깥의 call은 변경 없이 자유롭게 clone 가능한 상태로 남습니다. 이 구분에는 이유가 있습니다. 해당 call site들은 catch 복원 레코드를 애초에 획득하지 않으므로, 표시를 붙여봐야 얻는 것 없이 최적화 기회만 잃게 됩니다.

이에 대응하는 ASSERT(patch->kind().isCloningForbidden())가 OMGIRGenerator::preparePatchpointForExceptions() 최상단에 추가되었습니다. 위치는 의도적으로 if (!mustSaveState) return nullptr; early-out 뒤입니다. 즉 stackmap children을 append하고 PatchpointExceptionHandle을 등록하는 경로에만 걸립니다. 이 assertion이 겨냥하는 대상은 이번 call site 하나가 아니라 일반 규칙 자체입니다. 앞으로 어떤 IR 생성 경로가 여전히 복사 가능한 patchpoint에 exception handle을 붙이면, debug build에서 곧바로 걸리게 됩니다.

consumer 쪽에서는 ReduceStrength의 Check handler가 재구성되었습니다. 이전에는 constant arm을 가진 Select 후보를 찾기만 하면 transform이 곧바로 동작했습니다: if (select) { specializeSelect(select); ... }. 패치는 이 형태를 뒤집어 실패 시 바로 break하도록 바꾸고, m_index에서 Select까지 거슬러 올라가는 역방향 스캔을 넣었습니다. 이 구간에 들어 있는 값들이 곧 transform이 복제하려는 대상 집합입니다. 그래서 루프는 각 값에 value->kind().isCloningForbidden()을 묻고, 하나라도 걸리면 canClone을 내려 specialization을 포기합니다. 루프 안의 RELEASE_ASSERT(i)는 Select가 끝내 발견되지 않는 경우 스캔이 블록 앞쪽으로 벗어나는 것을 막습니다. transform이 이미 암묵적으로 의존하던 구조적 invariant를 이번에 명시한 셈입니다.

test인 JSTests/wasm/stress/omg-reduce-strength-select-exception-stackmap.js는 WAT가 아니라 raw byte로 조립되었습니다. in-tree WAT assembler로는 GC/reference type과 exception handling을 한 module 안에 함께 표현할 수 없기 때문입니다. export된 $target은 transform이 예전에 삼키던 IR 형태를 그대로 재현합니다. externref에 대한 typed select 한쪽 arm이 상수 ref.null extern이고, try 안에서 throw하는 helper를 호출한 뒤, select 결과에 ref.as_non_null을 적용합니다. 이 마지막 부분이 specializeSelect가 기준으로 삼는 downstream B3 Check로 lowering됩니다. helper에는 nop 600개가 채워져 있는데, 크기를 inlining threshold 위로 유지하기 위해서입니다. driver 루프는 OMG tier-up을 강제하려고 wasmTestLoopCount만큼 반복합니다.

B3와 patchpoint. B3는 JSC의 저수준 SSA 중간 표현이자 backend이며, JavaScript용 FTL JIT와 WebAssembly용 OMG JIT가 함께 사용합니다. PatchpointValue는 생성된 기계어 안에 구멍을 예약해 두는 IR 노드로, compiler의 client가 손으로 쓴 assembly로 그 구멍을 채웁니다. 일반 operand와 별개로 patchpoint는 stackmap children을 함께 들고 다닙니다. client가 해당 지점에서 live하다고 선언한 추가 값들입니다. B3는 register allocation이 끝난 뒤 각 값이 어디에 놓였는지를 client에 다시 알려줍니다. 특정 register일 수도 있고, 특정 stack slot일 수도 있습니다.

Stackmap과 CallSiteIndex. 그 위치 정보를 담은 기록이 stackmap입니다. Wasm OMG는 advanceCallSiteIndex()로 모든 call에 증가하는 CallSiteIndex를 부여하고, unwinding 과정에서 런타임이 해당 코드 위치의 metadata를 찾을 때 이 인덱스를 key로 사용합니다.

OMG의 Wasm exception handling. try 블록 안에 있는 call이라면, OMGIRGenerator::preparePatchpointForExceptions()가 현재 live한 Wasm 값들을 stackmap children으로 append하고 PatchpointExceptionHandle을 반환합니다. exception이 catch나 catch_all까지 unwind되면, 런타임은 CallSiteIndex로 handler를 조회한 뒤 기록된 위치로부터 catch entrypoint의 상태를 다시 채웁니다. 파일 자체의 주석이 설명하듯, try/catch와 OSR loop가 Wasm 표현식 스택을 B3::Variable로 "materialize"하는 이유가 바로 여기에 있습니다. catch entrypoint가 복원할 고정된 자리를 확보하기 위해서입니다.

Kind::isCloningForbidden(). B3의 Kind는 opcode와 함께 플래그를 들고 다닙니다. cloningForbidden(Patchpoint)는 "이 값을 transform이 복제해서는 안 된다"는 의미의 플래그가 붙은 Patchpoint kind를 만듭니다. 이 플래그 자체는 이번 commit보다 앞서 존재했습니다. commit message에 따르면 throw/rethrow patchpoint를 위해 266643@main에서 도입되었습니다.

ReduceStrength의 specializeSelect. ReduceStrength는 B3의 strength reduction 및 canonicalization phase입니다. 그 안의 transform 중 하나가, downstream Check로 흘러 들어가는 Select(cond, a, b)에 대한 specialization입니다. 블록을 Select 지점에서 분할하고, Select와 그 consumer 사이의 값들을 두 벌로 복제해 각 arm에 특화시킵니다. 그러면 각 복사본은 런타임 선택 대신 구체적인 arm을, 대개는 상수를 보게 됩니다. 이번 패치가 추가한 역방향 스캔이 훑는 구간이 정확히 이 범위입니다.

JSC의 externref. Wasm의 externref는 JSValue로 표현되며 JavaScript로 곧바로 반환될 수 있습니다. 결국 Wasm에서 보이는 reference slot과 64비트 scalar slot은 폭이 같고, 정적 타입으로만 구분됩니다.

OMG tier-up. OMG는 Wasm의 최상위 optimizing tier입니다. 함수는 충분히 실행된 뒤에야 이 tier에 도달합니다. regression test가 target()을 한 번만 호출하지 않고 루프로 돌리는 이유입니다.

고유한 key로 등록된 side-table 항목을 소유하던 코드 영역이 통째로 복제되었습니다. 그 결과 두 복사본이 같은 항목 하나를 가리키게 되는데, 정작 그 항목은 둘 중 한쪽만 정확히 기술합니다. compiler transform이 유발한 metadata aliasing이 root cause입니다.

  Before the fix (specializeSelect fires):        After:

  Select(cond, null, $object)                     Select(cond, null, $object)
        │                                               │  ← scan hits cloningForbidden
   ┌────┴────┐  block split + range cloned              │     → transform bails
   ▼         ▼                                          ▼
  call#7    call#7'    ← two machine call sites        call#7    ← one call site
   │         │                                          │
   └────┬────┘                                          │
        ▼                                               ▼
  stackmap[CallSiteIndex 7]                       stackmap[CallSiteIndex 7]
  (describes ONE layout)                          (describes THE layout)

패치 이전에는 producer 쪽에서 해당 call이 특별하다는 사실을 전혀 표시하지 않았습니다. createCallPatchpoint()는 advanceCallSiteIndex()를 호출한 뒤 평범한 PatchpointValue를 생성했을 뿐입니다. call이 try 안에 있는 경우에 한해, 그 다음 단계인 preparePatchpointForExceptions()가 live Wasm 값들을 stackmap children으로 append하고 해당 call의 CallSiteIndex를 key로 PatchpointExceptionHandle을 등록했습니다. 이 등록은 구조상 일대일 대응입니다. 코드 위치 하나에, throw 시점에 각 live 값이 어디 있는지를 적은 설명 하나가 붙습니다. 다만 그 사실은 IR 노드 자체에 아무것도 기록되지 않았습니다. 한편 consumer인 specializeSelect는 Select와 downstream Check 사이의 값 구간 전체를 복제하면서도 Kind::isCloningForbidden()을 한 번도 확인하지 않았습니다. 플래그는 이미 있었고, 이 transform이 읽지 않았을 뿐입니다. 버그가 성립하려면 양쪽이 모두 실패해야 했는데, 실제로 둘 다 실패했습니다.

위 그림에서 핵심은 두 개의 clone입니다. 둘은 Select의 서로 반대쪽 arm에 특화됩니다. 한쪽은 상수 ref.null extern을 보고, 다른 쪽은 런타임 값 $object를 봅니다. 그래서 각 clone의 downstream은 서로 독립적으로 최적화됩니다. live 값 집합도 따로 계산되고, 무엇보다 register와 spill slot 배정이 따로 계산됩니다. commit message는 이 점을 직접 언급합니다. clone들이 "potentially differing live-value layouts"를 갖는다는 것입니다. 그런데 복제 이후 살아남아 둘 다를 기술하는 stackmap은 하나뿐입니다.

런타임에서 벌어지는 일은 여기서 기계적으로 따라옵니다. 한쪽 clone 안에서 throw가 발생하면 unwinding은 CallSiteIndex로 handler를 찾습니다. 이어서 그 하나뿐인 stackmap에 적힌 위치로부터 catch entrypoint의 B3::Variable들을 다시 채웁니다. 그 위치들이 다른 clone을 기술하고 있다면, 각 live Wasm 값은 throw가 발생한 clone이 해당 register나 spill slot에 남겨둔 값으로 복원됩니다. 복원 결과를 검증하는 코드는 없습니다. stackmap의 내용을 frame에 대한 ground truth로 신뢰한다는 것이 애초의 전제이기 때문입니다.

regression test의 데이터는 위장된 threat model에 가깝습니다. trigger 시퀀스가 짧으므로 그대로 따라가 보겠습니다.

  1. global.get $bits0 … $bits23 — attacker가 제어하는 24개의 i64 sentinel입니다. 0xfffe00000000002a + n*0x100 형태이며, call을 가로질러 live 값으로 올라갑니다.
  2. ref.null extern을 상수 arm으로 갖는 externref typed select — specializeSelect가 기준으로 삼는 바로 그 형태입니다.
  3. local.set 1이 결과를 reference 타입 local인 $live에 저장합니다.
  4. try 안의 call $helper — CallSiteIndex를 key로 하는 복원 stackmap을 획득하는 patchpoint입니다. 이 helper는 항상 throw합니다.
  5. $live에 대한 ref.as_non_null — 복제 구간을 닫는 downstream B3 Check로 lowering됩니다.
  6. catch_all이 $live를 반환하고, JavaScript가 그 값을 null과 비교합니다.

live 집합 안에서 externref 바로 옆에 놓인 24개의 sentinel은 crash를 유도하기 위한 padding이 아닙니다. reference slot에 scalar가 들어앉는 상황을 낚기 위한 미끼입니다. test는 catch 경로가 null을 반환하는지 확인하고, 그 외의 값이 나오면 실패합니다.

보안 측면의 결과는 WebContent process 안에서 Wasm type system의 가장 기본적인 보장이 깨진다는 점입니다. reference 타입 local은 언제나 reference를 담고 있어야 한다는 보장입니다. test로 확인되는 직접적인 primitive는, catch_all에서 reference 타입 local이 그 자리에 live였던 적 없는 값으로 복원되는 것입니다. JavaScript 입장에서는 엉뚱한 객체가 노출되는 형태로 관찰되거나, reference가 아닌 비트 패턴을 역참조하면서 crash로 나타납니다. 여기서 한 단계 더 나아가 보면, 어긋난 stackmap slot이 attacker가 공급한 i64 argument 위치 중 하나와 겹치는 경우를 상정할 수 있습니다. 이 경우 복원된 externref는 attacker가 완전히 선택한 64비트 패턴을 담게 될 가능성이 있습니다. Wasm/JS heap에서의 forged-reference type confusion에 해당하게 됩니다. 내부 JSValue나 heap 주소가 노출되는 수준에서부터, 더 강한 primitive를 구축하는 데 쓸 만한 위조된 reference까지 — 이 폭이 있기 때문에 Apple이 advisory 수준에서 쓴 "unexpected Safari crash"라는 표현은 버그의 실체를 축소해서 전달합니다. 여기에 도달하는 데 필요한 것은 평범한 웹 콘텐츠뿐입니다. exception handling과 reference type을 함께 쓰는 WebAssembly module을 OMG tier-up이 일어날 때까지 warm up하면 됩니다. 다만 모든 과정은 renderer 안에서 일어납니다. B3와 WasmOMGIRGenerator는 JSC의 compiler 구성요소이므로, WebContent sandbox를 벗어나려면 attacker에게 별도의 escape가 여전히 필요합니다.

fix는 양쪽 방향에서 일대일 대응을 회복시킵니다. producer는 try 안의 call patchpoint에 cloningForbidden을 찍어 IR 자체가 복제 불가 속성을 들고 다니게 합니다. consumer는 복사하려는 구간을 스캔해서, 플래그가 붙은 값이 하나라도 있으면 specialization을 거부합니다.

exception 복원 stackmap이 CallSiteIndex를 key로 걸려 있는 call site를 복제하면, 두 코드 위치가 하나의 layout 설명을 공유하게 되고, catch 블록은 엉뚱한 frame으로부터 reference를 복원하게 됩니다.

이번 패치는 앞선 불완전한 fix의 나머지 절반입니다. 그 fix는 두 방향에서 동시에 실패했습니다. cloningForbidden 플래그는 throw/rethrow patchpoint의 복제를 막기 위해 266643@main에서 도입되었습니다. 그런데 적용 대상이 그 특정 patchpoint들로만 한정되었습니다. m_tryCatchDepth != 0인 동안 똑같이 CallSiteIndex key의 stackmap을 획득하는, 훨씬 넓은 일반 call patchpoint 집단에는 붙지 않았습니다. 게다가 일부 cloning transform은 이 플래그를 존중했지만 specializeSelect는 무시했습니다. call site 하나를 놓친 것만으로 구멍이 다시 열릴 수 있었던 구조적 이유가 여기서 드러납니다. 강제 지점이 cloning primitive 내부가 아니라 그것을 호출하는 쪽에 놓여 있기 때문입니다. preparePatchpointForExceptions()에 새로 들어간 ASSERT는 producer 쪽 누락을 잡는 tripwire로는 적절합니다. 다만 debug 전용입니다. release build에서는 다른 IR 생성 경로가 clone 가능한 patchpoint에 exception handle을 붙여도 아무 진단이 나오지 않습니다.