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
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
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 N은 rethrowSlotForDepth를 통해 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을 인덱싱하는 함수를 컴파일할 수 있었습니다.
Insight
여기서 오래 남을 기여는 두 줄짜리 삽입 자체가 아닙니다. Predicate가 인라인 코드에서 벗어나 WTF_REQUIRES_LOCK annotation이 붙은, 다음번 module-level check를 추가할 개발자에게 명확한 호출 지점을 제공하는 named method failIfMixedExceptionHandlingProposals()로 자리 잡았다는 점입니다. 테스트의 형태도 마찬가지로 재사용 가치가 있습니다. 특정 경로가 throw해야 한다는 것을 단정하는 대신, 동일한 바이트에 대해 두 경로가 결과와 오류 클래스 모두에서 일치하는지를 검증합니다. 이는 equivalence oracle에 해당하며, 수정 없이 그대로 differential fuzzing harness에 편입될 수 있습니다.
Audit directions
- Deferred whole-module predicates enforced at one terminus. Narrow:
ModuleInformation에 있는 module-level 플래그들 —m_usesLegacyExceptions/m_usesModernExceptions곁에 있는 다른m_uses*relaxed atomic들, 그리고 SIMD, tail-call, GC feature bit들 — 을 나열하고,Source/JavaScriptCore/wasm/Wasm*Plan.cpp안의 모든fail(...)지점을 점검하여, 이 중 어느 것이EntryPlan::compileFunctions()의 tail과IPIntPlan::completeInStreaming()/ BBQ와 LLInt의 streaming 대응 함수 양쪽 모두에서 도달 가능한지 확인해야 합니다. 단서는, 마지막 함수 body가 확인되어야만 완전해지는 상태를 조건으로 삼으면서compileFunctions()만을 유일한 호출자로 두는fail()입니다. Wider: 동일한 형태는 하나의 backend 위에 one-shot API와 incremental API를 함께 제공하는 모든 parser나 loader에서 나타날 수 있습니다 — progressive 방식과 whole-buffer 방식을 함께 지원하는 image decoder, chunked/complete 입력을 모두 받는XMLDocumentParser, streaming/buffered variant를 함께 갖춘 IPC decoder 등이 해당합니다. 코드 검색 시, 두 함수가 모두 객체를 terminal/ready 상태로 전환시키면서 그중 한쪽에만 검증 로직이 있는 패턴을 찾아보아야 합니다. Widest: 하나의 backend에 대해 streaming entry point와 batch entry point를 함께 노출하는 시스템이라면, 점검해야 할 invariant는 acceptance predicate가 한쪽 종착점에만 있는 인라인 코드가 아니라 양쪽 종착점 모두에서 호출되는 공유 함수여야 한다는 점입니다. 이는 V8의AsyncStreamingDecoder와SyncStreamingDecoder, SpiderMonkey의CompileStreaming, incremental reader를 가진 protobuf/JSON parser, buffered/streaming 모드를 모두 갖춘 TLS record parser에도 동일하게 적용됩니다.
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를 조용히 만들어낼 것인가?
- Differential compile-path equivalence as tested fact, not assumption. Narrow:
JSTests/wasm아래에서$vm.createWasmStreamingCompilerForInstantiate를 실제로 실행하는 테스트가 몇 개인지,new WebAssembly.Module만 사용하는 테스트와 비교해 세어봐야 합니다. 이 coverage gap 자체가 finding이며, module을 동기적으로만 구성하는 validation 중심 테스트가 있다면 그것이 단서입니다. Wider: 이 oracle을 differential fuzzing harness에 편입시킬 수 있습니다. Wasm grammar fuzzer로 module을 생성한 뒤, batch compile, streaming compile, chunk를 인위적으로 쪼갠 byte delivery를 사용하는compileStreaming(chunk 경계가 함수 중간에 걸치는 경우는 그 자체로 별도의 trigger class에 해당합니다), validation-only mode 사이의 결과가 일치하는지 assert하면 됩니다. 어느 mode가 "옳은" 것인지와 무관하게, 결과가 어긋나면 그 자체로 finding입니다. Widest: 하나의 acceptance predicate에 대해 중복 구현된 여러 경로를 서로 비교하는 differential testing 기법은, V8과 JSC와 SpiderMonkey의 Wasm validation 비교, 임의의 runtime에서 sync parser와 async parser 비교, 그리고 한 곳에서만 이루어지던 판단이 최적화로 인해 두 번째 구현을 갖게 된 모든 시스템에 그대로 적용됩니다.