← All reports

CVE: CVE-2026-43745 · Safari 26.5.2 · 2026년 6월 29일 릴리스 Impact: 악의적으로 조작된 웹 콘텐츠를 처리하는 과정에서 예기치 않게 Safari가 crash할 수 있습니다. Apple's description: Out-of-bounds write 문제가 개선된 입력 검증을 통해 수정되었습니다. Credit: OpenAI Codex Security - Amy Burnett, Khai Tran

0f0de8f | Bugzilla 315365

Medium — 컴파일 진입점은 두 곳이고, 이를 검증할 acceptance predicate는 하나뿐인데, 실제로 이 predicate를 평가하는 진입점은 그중 하나에 불과했습니다. Streaming 경로는 validator가 원래 거부해야 할 module class를 그대로 interpreter에게 넘겨주었습니다. Renderer crash는 이미 확인된 결과이고, 그 이상으로 이어질지는 어긋난 rethrow-slot index가 얼마나 벗어나는지에 달려 있습니다.

WebAssembly의 exception handling은 두 세대가 존재합니다. 하나는 JSC가 하위 호환을 위해 여전히 지원하는 초기 proposal이고, 다른 하나는 이를 대체한 표준화 버전입니다. 두 방식은 control-frame 규약이 서로 호환되지 않기 때문에, 하나의 module은 반드시 한 계열만 선택해서 일관되게 사용해야 합니다. 이를 위해 JSC는 module이 어느 계열을 사용했는지를 ModuleInformation의 플래그 두 개에 기록하고, 두 계열을 동시에 사용한 module은 거부하도록 되어 있습니다. 문제는 "이 module이 두 계열을 모두 사용했는가?"라는 질문에 답하려면 모든 function body의 파싱이 끝나야 한다는 점입니다. 그런데 JSC에는 컴파일이 그 지점에 도달하는, 구조적으로 서로 다른 경로가 두 곳 존재합니다.

관전 포인트: WebAssembly.compileStreaming()으로 조작된 바이트를 전달하는 페이지는, 일반 constructor라면 거부했을 module을 컴파일되고 호출 가능한 상태로 얻게 됩니다. 이 module의 function body는 interpreter의 rethrow 처리 로직이 애초에 함께 다룰 것으로 설계되지 않은 두 가지 exception-handling 방식을 뒤섞어 사용합니다.

Source/JavaScriptCore/wasm/WasmEntryPlan.cpp

for (uint32_t index = functionIndex; index < functionIndexEnd; ++index)
compileFunction(FunctionCodeIndex(index));
 
- if (m_moduleInformation->m_usesModernExceptions.loadRelaxed() && m_moduleInformation->m_usesLegacyExceptions.loadRelaxed()) {
+ {
Locker locker { m_lock };
 
- fail(makeString("Module uses both legacy exceptions and try_table"_s));
 
**Patch Details**
 
소스 변경은 세 곳, 테스트는 한 개이며, 이 중 실제로 동작에 영향을 주는 변경은 하나뿐입니다.
 
`WasmEntryPlan.cpp`에서는 기존에 `EntryPlan::compileFunctions()` 끝부분에 인라인으로 있던 거부 로직이 새 메서드 `EntryPlan::failIfMixedExceptionHandlingProposals()`로 그대로 옮겨졌습니다. Predicate 자체는 변경되지 않았습니다. `ModuleInformation`의 relaxed atomic 두 개, `m_usesModernExceptions`와 `m_usesLegacyExceptions`를 읽어서 둘 다 설정되어 있으면 `fail("Module uses both legacy exceptions and try_table")`을 호출하고 `true`를 반환하는 구조 그대로입니다. 호출 지점에서 바뀐 부분은 lock을 잡는 방식입니다. 기존 코드는 `m_lock`을 잡기 전에 두 플래그를 먼저 검사하고, 실패를 기록할 때만 잠긴 영역에 진입했습니다. 새 코드는 무조건 `m_lock`을 먼저 잡은 뒤 그 안에서 predicate를 평가합니다. 이 덕분에 `WasmEntryPlan.h`에서 해당 helper에 `WTF_REQUIRES_LOCK(m_lock)` annotation을 붙일 수 있게 되었고, 이후 컴파일러의 thread-safety analysis가 향후 모든 호출자가 lock을 보유하도록 강제하게 됩니다. 즉 이 부분은 추출과 annotation 작업이지, 거부 대상 자체가 바뀐 것은 아닙니다.
 
실질적인 fix는 `WasmIPIntPlan.cpp`에 추가된 두 줄입니다. 스트리밍 컴파일러가 구동하는 finalization 루틴인 `IPIntPlan::completeInStreaming()`은 `compileFunctions()`의 tail 부분을 전혀 거치지 않는데, 여기서 이제 `m_lock` 하에서 공유 helper를 호출하고, `complete()` 이전에 조기 반환하도록 바뀌었습니다. 이 라인이 추가되기 전에는 `Locker` 다음 곧바로 `complete()`가 이어졌고, 그 사이에는 아무 검증도 없었습니다.
 
`streaming-compile-try-table-agreement.js` 테스트는 그 자체로 눈여겨볼 만한 결과물입니다. "이 모듈은 반드시 throw해야 한다"는 식의 단정을 검사하는 대신, `try_table`만 사용하는 모듈 하나와 legacy `try`/`catch_all`/`rethrow 0`를 `try_table`과 함께 섞은 모듈 하나를 직접 바이트 단위로 조립한 뒤, 두 모듈 모두를 `new WebAssembly.Module()`과 `$vm.createWasmStreamingCompilerForInstantiate()` 양쪽 경로에 통과시킵니다. 두 경로가 컴파일 성공 여부에서 서로 다른 결과를 내면 실패하고, 둘 다 실패하는 경우에도 어느 한쪽이라도 `WebAssembly.CompileError`가 아닌 다른 결과를 냈다면 실패합니다. 특정 기대값을 검증하는 테스트가 아니라 두 경로 간의 equivalence oracle에 해당합니다.
 
Commit message에는 `JSC::IPInt::rethrowSlotForDepth`를 다루는 `WasmIPIntSlowPaths.cpp`/`.h` 변경도 함께 언급되어 있습니다. 이 부분은 위에 재현된 diff에는 포함되어 있지 않지만, changelog에 함께 언급되었다는 사실 자체가 이번 module-level check가 어떤 하위 계산을 지키고 있었는지를 보여줍니다.
 
**Background**
 
**WebAssembly exception handling, two generations.** 원래의 "legacy" EH proposal은 `try`(0x06), `catch`, `catch_all`(0x19), `rethrow`(0x09), `delegate`를 도입했습니다. `try`가 block을 열면 handler가 그 block에 붙고, `rethrow N`은 N개의 control level 밖에 있는 handler가 잡은 exception을 다시 던집니다. 이를 대체한 표준화 proposal은 이 전체를 단일 instruction인 `try_table`(0x1f)로 교체했는데, 여기서의 catch 절은 handler block을 여는 대신 *enclosing label로 branch*하면서 exception 값을 block result로 실어 나릅니다. 스펙은 후자만 정의하고 있지만, JSC는 하위 호환을 위해 전자도 계속 지원합니다. 그래서 어느 계열을 사용했는지는 parsing 도중 설정되는 relaxed atomic 두 개, `ModuleInformation::m_usesLegacyExceptions`와 `m_usesModernExceptions`로 추적됩니다.
 
**`rethrow N` and the rethrow slot.** Legacy `rethrow`는 target을 값이 아니라 control depth로 지정하기 때문에, runtime은 depth 숫자로부터 해당 handler가 저장한 exception까지 거슬러 올라갈 수 있어야 합니다. IPInt에서는 이 역할을 control depth로 주소화되는 스택 슬롯이 담당하며, `rethrowSlotForDepth`가 이를 계산합니다. `try_table` handler는 이런 슬롯을 유지하지 않습니다. exception 값이 branch operand로 전달되기 때문입니다.
 
**Plan objects and their states.** `Plan`(및 그 subclass인 `EntryPlan`)은 하나의 모듈 컴파일 작업을 소유하는 JSC 객체입니다. `Initial → Validated → Prepared → Compiled → Completed`라는 state machine을 가지며, failure 기록과 `Completed`로의 전환 모두를 lock `m_lock`이 보호합니다. `fail()`은 plan에 오류 메시지를 저장하고, 이 메시지가 JavaScript 쪽에 `WebAssembly.CompileError`로 노출됩니다.
 
**Batch versus streaming compilation.** `new WebAssembly.Module(bytes)`는 batch 경로입니다. 전체 바이너리가 미리 준비되어 있으므로 plan은 `prepare()`를 실행한 뒤 `compileFunctions()`를 실행하며, 이 함수는 모든 함수 index에 대해 `compileFunction()`을 반복 호출한 다음, Wasm-to-Wasm stub을 생성하고 모듈 전체 단위의 최종 정리를 수행하는 tail 부분을 실행합니다. `WebAssembly.compileStreaming()`과 `instantiateStreaming()`은 점진적 경로를 사용합니다. 네트워크로 전달되는 대로 바이트가 `StreamingParser`에 공급되고, 각 함수 body는 완성되는 즉시 컴파일되며, finalization은 `compileFunctions()`의 tail과는 완전히 별개인 `completeInStreaming()`을 통해 이루어집니다. 두 경로 모두 결국 동일한 코드 생성기와 동일한 executable module로 수렴합니다.
 
**IPInt.** In-Place Interpreter는 JSC의 baseline Wasm 실행 tier입니다. `parseAndCompileMetadata`가 만들어낸 side metadata를 바탕으로 Wasm bytecode를 직접 실행하며, throw나 rethrow 같은 동작에서는 `WasmIPIntSlowPaths.cpp`의 C++ slow path로 빠져나갑니다. 함수가 hot해지면 BBQ/OMG로 tier up됩니다.
 
**`$vm.createWasmStreamingCompilerForInstantiate`.** `WebAssembly.instantiateStreaming()`을 뒷받침하는 것과 동일한 streaming compiler를 구동하는 JSC test-shell hook으로, network fetch 없이도 테스트가 동기적으로 바이트를 공급할 수 있게 해줍니다.
 
<!-- GATE:analysis -->
 
**Analysis**
 
이번 사례는 validator parity가 깨진 경우에 해당합니다. 마지막 함수 body를 다 확인해야만 평가할 수 있는 acceptance predicate가, 두 개의 파이프라인 종착점 중 한 곳에만 작성되어 있었습니다.
 

module bytes │ ├─ new WebAssembly.Module() ├─ compileStreaming() │ prepare() │ StreamingParser │ compileFunctions() │ └─ compileFunction() ×N │ ├─ compileFunction() ×N │ (per-function checks OK) │ └─ TAIL: mixed-EH check ────┤ completeInStreaming() │ │ │ └─ complete() ← no check │ ▼ │ │ │ CompileError │ ▼ │ │ State::Completed


위 다이어그램의 두 경로는 `compileFunction()`을 공유합니다. 바로 이 점 때문에 *함수 단위* validation은 애초에 위험할 일이 없었습니다. 함수 body 하나만 보고 판단 가능한 검증은 이 공유 호출을 통해 두 경로 모두에서 확인됩니다. 그러나 mixed-EH predicate는 이런 방식으로 판단할 수 없습니다. 어떤 함수는 legacy opcode를 쓰고 다른 함수는 `try_table`을 쓴다는 사실이 함께 확인되어야만 모듈을 불법으로 판정할 수 있고, 이 정보는 입력이 끝나야 비로소 완전해집니다. 이런 지연된 검증은 반드시 종착점에 작성해야 하는데, batch 종착점이 바로 개발자가 loop를 작성할 때 보고 있던 지점입니다.

그래서 `EntryPlan::compileFunctions()`의 tail에는 검증이 추가되었지만, 세 줄짜리 함수인 `IPIntPlan::completeInStreaming()` — lock을 잡고 `complete()`를 호출하는 — 에는 추가되지 않았습니다. "`State::Completed`에 도달한 모듈은 정확히 하나의 EH proposal만 사용한다"는 invariant가 한쪽 경로에서만 유지되고 다른 경로에서는 아예 존재하지 않았던 셈입니다.

Diff에 포함된 테스트 모듈은 이를 보여주는 최소한의 예시입니다. 단일 함수 body는 다음과 같습니다.

0x06,0x40, try (void) 0x01, nop 0x19, catch_all 0x09,0x00, rethrow 0 ← legacy: 핸들러를 control depth로 지정 0x0b, end 0x02,0x40, block 0x1f,0x40,0x01, try_table (catch_all → label 0) ← 스펙에 부합하는 EH 0x08,0x00, throw $e 0x0b, end 0x0b, end ```

이 body를 parsing하면 (try/catch_all/rethrow로 인해) m_usesLegacyExceptions가, (try_table로 인해) m_usesModernExceptions가 함께 설정됩니다. 이 바이트를 new WebAssembly.Module()에 넘기면 batch 경로를 타고 tail check에 걸려 WebAssembly.CompileError가 발생합니다. 그런데 동일한 바이트를 compileStreaming()에 넘기면 — 동일한 parser, 동일한 metadata 생성기를 거치면서도 — 오른쪽 경로를 타고 State::Completed에 도달하며 IPInt callee가 등록됩니다. 모듈이 instantiate되고, export된 함수는 호출 가능한 상태가 됩니다.

이로 인해 공격자가 얻게 되는 것은 누구도 코드를 작성해두지 않은 runtime 상태입니다. 두 EH 계열은 control-frame convention을 공유하지 않습니다. Legacy rethrow NrethrowSlotForDepth를 통해 control depth로 주소화된 슬롯에서 exception을 해소하는 반면, try_table handler는 exception 값이 branch operand로 전달되기 때문에 그런 슬롯을 전혀 유지하지 않습니다. 하나의 함수 안에 두 계열이 함께 중첩되면, slow path가 계산하는 depth→slot 매핑이 metadata generator가 실제로 만들어낸 frame layout과 더 이상 일치하지 않게 됩니다. 결과적으로 rethrow는 live exception이 아닌 다른 무언가가 들어 있는 interpreter stack 위치를 읽게 됩니다. 모듈 단위의 전면적인 거부는 이 계산이 공격자가 제어 가능한 입력에 노출되지 않도록 막아주는 유일한 장치였고, 이 mismatch를 독립적으로 잡아낼 프레임 단위의 별도 discriminator는 하위에 존재하지 않습니다. Apple은 이를 improved input validation으로 개선된 out-of-bounds write로 분류하는데, 이는 emitted frame이 갖고 있지 않은 control depth로부터 슬롯 인덱스가 도출된다는 사실과 일치합니다.

Fix는 문제가 발생한 바로 그 지점을 막습니다. completeInStreaming()은 이제 Completed로 전환하기 전에 동일한 lock 하에서 동일한 predicate를 평가하므로, 다이어그램의 두 경로 모두 동일한 acceptance 판정을 거쳐 종료됩니다. Reachability 측면에서는 bytes를 fetch할 수 있는 어떤 페이지든 compileStreaming()을 구동할 수 있으므로 평범한 web content 수준이며, 컴파일과 IPInt 실행 모두 WebContent process 안에서 이루어지므로 별도의 sandbox escape가 없는 한 blast radius는 renderer sandbox 내부에 머무릅니다. 확인된 결과는 공격자가 유발하는 crash입니다. 더 강력한 primitive, 즉 잘못 선택된 rethrow slot에서 type confusion이 걸린 exception 객체를 얻어내는 시나리오는 이 diff의 범위를 넘어서는 rethrowSlotForDepth의 동작에 달려 있습니다.

Streaming Wasm compilation에서는 module-level의 mixed-EH 거부 로직이 통째로 빠져 있어서, 어떤 페이지든 legacy rethrow가 metadata generator가 마련해두지 않은 frame slot을 인덱싱하는 함수를 컴파일할 수 있었습니다.

여기서 오래 남을 기여는 두 줄짜리 삽입 자체가 아닙니다. Predicate가 인라인 코드에서 벗어나 WTF_REQUIRES_LOCK annotation이 붙은, 다음번 module-level check를 추가할 개발자에게 명확한 호출 지점을 제공하는 named method failIfMixedExceptionHandlingProposals()로 자리 잡았다는 점입니다. 테스트의 형태도 마찬가지로 재사용 가치가 있습니다. 특정 경로가 throw해야 한다는 것을 단정하는 대신, 동일한 바이트에 대해 두 경로가 결과와 오류 클래스 모두에서 일치하는지를 검증합니다. 이는 equivalence oracle에 해당하며, 수정 없이 그대로 differential fuzzing harness에 편입될 수 있습니다.

Front-door mutual exclusion with no downstream re-assertion. module 레벨에서 포괄적으로 거부하는 방식이, 배제된 케이스를 처리하도록 작성된 적 없는 machinery에 대해 유일한 guard 역할을 한다면 defense in depth는 사실상 전무한 셈입니다. Guard를 우회하는 경로가 하나라도 있으면 machinery가 그대로 노출됩니다.

Narrow: Source/JavaScriptCore/wasm에서 m_usesLegacyExceptions / m_usesModernExceptions를 읽는 지점을 검색해야 합니다. 그리고 각 consumer — IPInt metadata generation, WasmIPIntSlowPaths.cpp의 throw/rethrow slow path, BBQ/OMG EH lowering — 마다 독립적인 assertion을 갖고 있는지, 아니면 plan 레벨의 거부만 그대로 물려받는지 확인해야 합니다. 단서는, control stack을 순회하는 control-depth나 slot-index 계산이 단일 handler 종류만 가정한 채 frame별 discriminator 없이 동작하는 패턴입니다.

Wider: 이 범주는 frame layout이나 metadata encoding을 공유하는, 서로 배타적인 두 feature mode 전반에 적용됩니다. SIMD와 non-SIMD IPInt frame, tail-call과 일반 call frame, GC-typed local과 untyped local이 그 예입니다. WasmFunctionIPIntMetadataGenerator에 있는 generator들의 caller를 따라가면서, emit된 layout이 mode flag로 parameterize되어 있고 consumer마다 이를 독립적으로 재도출하는지 확인해야 합니다.

Widest: front door에서만 강제되는 mutual-exclusion 제약은, 그 제약에 의존하는 모든 downstream 지점에서 다시 assert되어야 합니다. 이는 임의의 JIT에서 feature-flag로 분기되는 calling convention, handler가 단일 버전만 가정하는 protocol version negotiation, 단일 record layout을 가정하는 schema-migration 코드에도 동일하게 적용됩니다. 계속 던져야 할 질문은 이것입니다. front-door check를 삭제하면, 어떤 downstream 계산이 assert 없이 잘못된 index를 조용히 만들어낼 것인가?