[JSC] Procedure::usesSIMD() not set when OMG inlines a SIMD callee into a non-SIMD root
CVE: CVE-2026-64780 · Safari 26.6.1 · 2026년 8월 18일 릴리스 Impact: 악의적으로 조작된 웹 콘텐츠를 처리할 경우 예기치 않은 Safari crash가 발생할 수 있습니다 Apple's description: 개선된 검사를 통해 문제를 해결했습니다. Credit: OpenAI Codex Security - Amy Burnett
High. 상태를 기록하는 용도로 이름이 붙은 hook이 실제로는 assertion만 수행했습니다. 그 결과 top-tier wasm compiler는 vector 값을 다루지 않는다고 판단한 함수에 맞춰 stack frame을 잡으면서, 정작 그 frame으로 128비트 접근을 emit했습니다. 함께 포함된 테스트는 살아 있는 JS 객체 참조를 손상시킵니다. 다만 확장을 위해서는 폭이 초과된 접근이 어느 slot에 떨어지는지를 제어할 수 있어야 합니다.
Inlining을 수행하는 compiler라면 두 가지를 항상 일치시켜야 합니다. 하나는 compilation unit이 스스로에 대해 주장하는 내용이고, 다른 하나는 그 unit이 실제로 담고 있는 내용입니다. JavaScriptCore의 top-tier WebAssembly compiler는 compilation unit을 생성하는 시점에 이미 하나의 결정을 내립니다. 해당 unit이 128비트 vector 값을 다루게 될지 여부입니다. 이 결정은 한참 뒤 backend 단계에서 stack slot 크기와 callee-saved register 폭을 정하는 근거가 됩니다. 그런데 판단 자체는 body의 opcode를 단 하나도 파싱하기 전에, root 함수의 미리 계산된 metadata만 보고 이루어집니다. unit 하나가 wasm 함수 하나를 의미하는 동안에만 이 방식이 성립합니다.
관전 포인트: 웹 페이지 하나만으로 top-tier wasm compiler가 vector 값에 필요한 stack 공간을 실제보다 적게 잡도록 유도할 수 있습니다. 이때 attacker가 고른 정수에서 만들어진 128비트 write가 같은 frame 안에 살아 있는 JavaScript 객체 참조 위로 떨어지게 됩니다.
Source/JavaScriptCore/wasm/WasmOMGIRGenerator.cpp
Source/JavaScriptCore/b3/B3Procedure.h
Source/JavaScriptCore/b3/B3Procedure.cpp
JSTests/wasm/stress/inline-wasm-simd-into-non-simd.js
Patch Details
변경된 소스 파일은 세 개지만, 실제로 동작이 달라지는 것은 그중 하나뿐입니다.
동작이 바뀌는 hunk는 WasmOMGIRGenerator.cpp에 있습니다. OMGIRGenerator::notifyFunctionUsesSIMD()는 wasm function parser가 SIMD opcode를 decode하는 순간 호출하는 callback입니다. 패치 이전에는 이 함수의 본문이 ASSERT(m_info.usesSIMD(m_functionIndex)) 한 줄뿐이었습니다. 현재 파싱 중인 함수가 module metadata에서 SIMD 사용으로 표시되어 있는지만 확인하고, 확인한 사실은 어디에도 반영하지 않았습니다. 패치는 이 assertion을 그대로 둔 채 m_proc.setUsesSIMD() 한 문장을 추가했습니다. 컴파일이 진행 중인 B3::Procedure에 해당 사실을 전달하는 코드입니다. 함께 읽기용 accessor인 bool OMGIRGenerator::usesSIMD() const도 추가되었습니다.
나머지 두 hunk는 오타 수정입니다. s가 하나 더 붙어 있던 B3::Procedure::setUsessSIMD()가 B3Procedure.h에서 setUsesSIMD()로 이름이 바뀌었고, 본문(RELEASE_ASSERT(Options::useWasmSIMD()); m_usesSIMD = true;)은 그대로입니다. 기존 호출 지점은 Procedure::Procedure(bool usesSIMD) 생성자 한 곳뿐이었으며, 여기도 새 이름에 맞춰 수정되었습니다.
regression test는 wabt를 이용해 함수 두 개짜리 module을 만듭니다. export된 root 함수 $A는 i64 하나와 externref 49개를 인자로 받아 그 49개의 externref를 그대로 반환하며, $B 호출은 try/catch_all로 감싸져 있습니다. $B는 payload에 v128이 포함된 tag를 throw하는데, 이 vector는 i64x2.splat (local.get 0)으로 생성됩니다. 즉 vector의 내용은 $A의 호출자가 전달한 i64 값 그대로입니다. 테스트는 testLoopCount만큼 $A를 반복 호출해 OMG까지 도달시킨 뒤, 반환된 externref가 모두 인자로 전달했던 sentinel 객체와 동일한지 확인합니다. 하나라도 다르면 "usesSIMD desync"를 throw합니다.
Background
OMG. JavaScriptCore는 WebAssembly를 여러 tier로 나누어 컴파일합니다. 함수는 처음에 빠르게 생성되는 하위 tier에서 실행되다가, 실행 횟수가 충분히 쌓이면 top-tier optimizing compiler인 OMG로 tier up됩니다. OMG는 컴파일 중인 함수 안으로 callee의 body를 inline할 수 있습니다.
B3와 Air, 그리고 Procedure. B3는 JSC의 low-level SSA intermediate representation이고, 그 아래에는 register allocation까지 마친 machine-level IR인 Air가 있습니다. B3::Procedure는 compilation unit 하나를 나타내는 객체입니다. value graph와 Air::Code를 소유하며, 이후 backend 단계가 layout을 결정할 때 참조하는 unit 단위 속성도 함께 들고 있습니다.
Procedure::usesSIMD(). 해당 compilation unit이 128비트 vector 값을 다루는지를 나타내는 unit 단위 boolean입니다. 값의 출처는 생성자 인자인 Procedure(bool usesSIMD = false)입니다. setter는 RELEASE_ASSERT(Options::useWasmSIMD())로 보호되는데, release build에서 아무것도 남지 않는 ASSERT와 달리 이 검사는 모든 build에 유지됩니다.
ModuleInformation::usesSIMD(functionIndex). wasm module을 파싱하고 검증하는 시점에 계산되는 함수 단위 metadata로, 해당 함수 body에 SIMD opcode가 들어 있는지를 기록합니다. 색인 기준은 compilation unit이 아니라 함수입니다.
v128과 vector register, 그리고 ARM64 ABI. wasm의 SIMD 값 타입은 128비트로, double의 두 배 폭입니다. 128비트 register를 통째로 저장하면 scalar FP 저장에 비해 stack을 두 배로 사용하고, 정렬 요구도 더 엄격합니다. ARM64의 platform ABI는 callee-saved vector register 중 하위 64비트만 보존합니다. 따라서 호출을 넘어 vector 전체를 살려 두어야 하는 코드는 상위 절반을 직접 보존해야 합니다.
externref. host 쪽의 불투명한 참조를 담는 wasm reference type입니다. JSC에서는 JSValue 크기의 machine word 하나를 차지하며, garbage collector와 JS engine 모두 이 값을 살아 있는 객체 pointer로 해석합니다.
Wasm 예외 처리와 stack materialization. tag는 타입이 지정된 payload를 갖는 예외 타입을 선언하고, throw가 이를 발생시키며, try/catch_all이 이를 잡습니다. WasmOMGIRGenerator.cpp의 주석에 따르면, OMG parser가 Try/TryTable(또는 OSR이 있는 loop)을 만나면 wasm expression stack 전체를 B3::Variable로 materialize합니다. catch entrypoint가 값을 복원할 고정된 저장 공간을 확보하기 위해서입니다. 그 결과 try를 가로질러 살아 있는 값들은 각자 전용 frame slot을 갖게 됩니다.
Spill slot과 callee save. 호출이나 control flow 병합을 넘어 register에 머무를 수 없는 값은 타입에 맞는 크기의 stack slot을 배정받습니다. 이 크기와 offset이 모여 frame layout을 이룹니다. 일반적인 반환 경로와 예외 unwind 경로는 이 layout에 대해 동일한 전제를 공유해야 합니다.
Analysis
요약 비트와 그 비트가 요약하는 대상 사이에서 상태가 어긋난 사례입니다. Procedure는 vector 값을 다루지 않는다고 보고하지만, 정작 자신의 value graph 안에는 그 값이 들어 있습니다.
Before: After:
Procedure(m_info.usesSIMD(root)) Procedure(m_info.usesSIMD(root))
m_usesSIMD = false ─────┐ m_usesSIMD = false
parse root $A │ parse root $A
inline callee $B │ inline callee $B
i64x2.splat ──► │ i64x2.splat ──►
notifyFunctionUsesSIMD│ notifyFunctionUsesSIMD
ASSERT(...) (no-op)│ ASSERT(...)
│ m_proc.setUsesSIMD() ──┐
V128 Value in graph ──────┤ V128 Value in graph │
backend reads usesSIMD()◄─┘ backend reads usesSIMD() ◄───┘
== false → narrow slot == true → 128-bit slot
왼쪽 열에 이 버그가 그대로 드러나 있습니다. Procedure는 어떤 함수 body도 파싱되기 전에 생성되고, 이때 전달되는 boolean은 root 함수에 대한 ModuleInformation::usesSIMD(functionIndex) 값입니다. 이런 유도 방식은 compilation unit 하나가 정확히 wasm 함수 하나만 담고 있을 때에 한해 올바릅니다. 그런데 OMG의 inliner가 바로 그 전제를 깨뜨리는 변환입니다. callee의 body를 root의 Procedure 안으로 끌어오기 때문에, 자기 metadata상 usesSIMD == false인 root가 inline된 callee에서 만들어진 V128 B3 value를 품게 될 수 있습니다.
이 상황을 잡아내기로 되어 있던 장치는 이미 존재했고, 연결도 되어 있었습니다. WasmFunctionParser는 SIMD opcode를 decode하는 순간 notifyFunctionUsesSIMD()를 호출합니다. 현재 순회 중인 함수 body가 무엇이든 상관없으며, inline 대상 함수의 body도 예외가 아닙니다. 이름은 기록을 담당하는 hook처럼 붙어 있었지만, 본문은 assertion 하나가 전부였습니다:
// Before the fix — Source/JavaScriptCore/wasm/WasmOMGIRGenerator.cpp
void NODELETE notifyFunctionUsesSIMD() { ASSERT(m_info.usesSIMD(m_functionIndex)); }
ASSERT는 release build에서 컴파일 과정에 사라집니다. 실제 출시된 Safari에서 이 함수는 본문이 아예 없는 것이나 마찬가지였습니다. 현재 파싱 중인 함수의 metadata가 "SIMD"라고 표시하는지만 확인한 뒤, 그 사실을 m_proc으로 전달하지 않고 버렸습니다. debug build에서는 inline된 함수에 대해 assertion이 정상적으로 동작했습니다. 알림 경로가 살아 있는 것처럼 보였던 이유이며, 누락이 눈에 띄지 않고 넘어가기 쉬웠던 이유이기도 합니다.
false로 굳어진 flag가 실제로 비용을 만들어내는 지점은 backend입니다. JSC는 Procedure::usesSIMD()와 그에 대응하는 Air::Code 쪽 값을 참조해, 이 unit이 FP register bank를 128비트 폭으로 다뤄야 하는지를 판단합니다. 영향이 가장 큰 곳은 vector spill slot의 크기와 정렬, 그리고 callee-saved FP register를 저장하고 복원할 때의 폭입니다. flag가 false인 상태에서는 stack allocator가 vector 값의 저장 공간을 scalar FP 폭으로 잡을 가능성이 높습니다. 반면 emit된 명령은 여전히 128비트 접근을 수행합니다. 그래서 폭이 초과된 store가 자기 slot을 넘어 옆 slot까지 닿게 됩니다. 두 번째 후보도 있으며, 앞의 설명과 배타적이지 않습니다. OMG의 prologue와 epilogue가 emit한 frame layout과, throw/catch entrypoint의 복원 경로가 FP callee-save에 대해 가정하는 layout이 서로 어긋나는 경우입니다. 어느 쪽이든 넘친 데이터는 인접한 frame 저장 공간이 받아내게 됩니다.
테스트는 그 overflow가 JavaScript에서 확인 가능한 자리에 떨어지도록 설계되어 있습니다:
$A는i64하나와externref49개를 인자로 받아 49개의externref결과로 반환하는 export 함수입니다.- 이 49개의 참조는
try를 가로질러 살아 있습니다. 따라서Try처리 과정에서 expression stack이B3::Variable로 materialize되고, 각 참조는 전용 frame slot을 배정받습니다. $A가$B를 호출하고, OMG는 이 호출을 inline합니다.$B는 payload에v128을 담은 tag를 throw하며, 이v128은i64x2.splat (local.get 0)으로 생성됩니다. 즉 vector의 비트는 JS 호출자가 고른$A의i64인자 그대로입니다.testLoopCount만큼 반복 호출하면$A가 OMG로 tier up되고, 어긋난 flag가 여기서 효력을 발휘합니다.catch_all이 예외를 삼키고,$A는 49개의 참조를 반환합니다.- 반환된
externref중 sentinel과 동일성 비교에 실패하는 것이 있다면 그것이 곧 corruption이며,"usesSIMD desync"가 발생합니다.
도달 경로는 이것이 전부입니다. 평범한 웹 콘텐츠면 충분하고, 특별한 권한도 필요 없으며, process 경계를 넘지도 않습니다. 손상되는 word가 GC에 노출된 객체 참조라는 점도 중요합니다. 피해가 손상 시점에 곧바로 드러날 필요가 없다는 뜻이기 때문입니다. 이후 garbage collection이 망가진 참조를 순회하다 터지는 형태도 그만큼 가능성이 있으며, Apple이 언급한 "예기치 않은 Safari crash"라는 표현과도 맞아떨어집니다.
패치는 이 flag를 생성 시점에 고정되는 값이 아니라, IR 생성 도중 한 방향으로만 확장될 수 있는 값으로 바꿉니다. notifyFunctionUsesSIMD()가 이름대로 실제 기록을 수행하게 되었기 때문입니다. 그 결과 unit 안으로 접혀 들어온 어떤 body에서든, root든 inline 대상이든, 첫 SIMD opcode가 나오는 순간 backend가 값을 읽기 전에 Procedure가 승격됩니다. setUsessSIMD → setUsesSIMD 이름 변경 자체는 동작에 영향이 없습니다. 다만 한 번 짚고 넘어갈 만한 신호이기도 합니다. public header에 오타가 살아남을 수 있는 조건은 그 함수를 호출하는 곳이 사실상 없다는 것인데, 이 setter의 호출 지점은 생성자 단 하나뿐이었습니다.
release build에서 아무 동작도 하지 않는 hook 탓에, inline된 SIMD callee가 있어도 compilation unit은 vector를 쓰지 않는다고 주장한 채 남았습니다. 그렇게 잡힌 frame을 attacker가 고른 i64에서 나온 128비트 write가 넘어서면서, 살아 있는 JS 객체 참조를 덮었습니다.
Insight
검색으로 찾아낼 수 있는 신호가 여기서 두 가지 겹치며, 둘 다 이번 버그를 넘어 일반화됩니다. 첫째, 호출 지점이 생성자 하나뿐인 mutator는 "이 속성은 객체 수명 동안 바뀌지 않는다"는 주장을 암묵적으로 담고 있습니다. 그리고 inlining은 그런 주장을 무너뜨리는 대표적인 변환입니다. unit 객체가 이미 만들어진 뒤에 그 unit이 담고 있는 내용을 바꾸기 때문입니다. 둘째 신호는 더 날카롭습니다. notify/record/mark 같은 이름을 달고 있으면서 정작 어떤 상태도 바꾸지 않는 메서드입니다. ASSERT가 컴파일 과정에서 사라지므로 release build에서의 의미는 "아무것도 하지 않음"이었고, 반대로 debug build의 assertion은 이 경로가 연결되어 있다는 잘못된 확신을 심어 주었습니다. 흔치 않은 형태의 실패입니다. build 설정에 따라 동작이 갈리면서, debug binary가 출시 binary보다 더 올바른 상황이 된 셈입니다.
Audit directions
-
compilation unit의 내용이 확정되기 전에 snapshot된 unit 전역 capability flag. 지켜져야 할 invariant는 다음과 같습니다. 내용이 확정되기 전에 도출된 compilation unit의 속성은, 그 내용이 늘어나는 동안 단조적으로 넓어질 수 있어야 합니다. Narrow — 먼저
WasmOMGIRGenerator.cpp와WasmBBQJIT.cpp에서m_info.의 함수별 metadata를 읽는 지점을 모두 나열합니다. 각 지점에서 사용되는 함수 index가 root의 것인지, 아니면 현재 파싱 중인 inlinee의 것인지 확인해야 합니다. 출발점으로는Procedure(bool usesSIMD)생성 지점이 적절하며, 여기서부터 같은 계열의 flag들을 함께 훑어볼 필요가 있습니다. exception handler 존재 여부, tail call 사용 여부, memory/table 사용 여부, GC type 사용 여부가 그 대상입니다. Wider — 범위를 넓히면, 생성자에서 초기화된 뒤 이후로 한 번도 기록되지 않는B3::Procedure나Air::Code의 멤버가 모두 대상이 됩니다. 검색 결과에서 눈여겨볼 형태는, setter가 initializer list나 생성자 본문에서만 호출되는 private bool 또는 enum입니다. Widest — 가장 넓게 보면 "inliner가 서로 다른 capability 속성을 가진 unit을 병합한다"는 일반적인 유형에 해당합니다. 같은 invariant가 LLVM의 inline 지점target-features호환성 검사, V8 Turbofan의 함수별 feedback 및 feature flag, Rust의#[target_feature]inlining 제약에도 동일하게 적용됩니다. 어디서든 통하는 판별 기준은 이렇습니다. codegen이 참조하는 unit별 속성인데, 두 unit이 합쳐질 때 join이나 merge 연산이 전혀 적용되지 않는 경우입니다. -
본문 전체가 assertion 하나뿐인 notification hook. 지켜져야 할 invariant는 다음과 같습니다. 상태를 기록한다는 이름을 가진 메서드는 실제로 상태를 변경해야 하며, 검증은 별개의 관심사입니다. Narrow —
Source/JavaScriptCore/wasm/과Source/JavaScriptCore/b3/에서void notify.*ASSERT(또는void (mark|record|set).*\{ ASSERT\(.*\); \}패턴에 걸리는 한 줄짜리 멤버 함수를 검색합니다. 가장 수확이 큰 출발점은 각 IR generator가 구현하는WasmFunctionParsercallback 표면입니다. BBQ, OMG, 그리고 그 옆의 validator들이 여기에 해당합니다. 모든 generator가 동일한 callback 집합을 구현하는 만큼, 이들 사이에 생기는 차이가 바로 어느 한쪽이 기록을 빠뜨린 지점입니다. Wider — 이 유형은#if ASSERT_ENABLED로 감싸인 bookkeeping, 그리고ASSERT/DEBUG_ASSERT의 인자 표현식 안에서 수행되는 bookkeeping 전반으로 확장됩니다. 요약 정보를 갱신하는 대신 precondition만 검사하는 "observer" 성격의 메서드도 함께 포함됩니다. Widest — "debug 빌드에서만 동작하는 assertion 안에 의미 있는 작업이 숨어 있다"는 유형은 side effect가 있는 인자를 받는 ChromiumDCHECK, mutation을 감싼 Rustdebug_assert!, side effect를 동반하는 Javaassert문에 모두 해당합니다. 어디서나 적용 가능한 판별 방법이 있습니다. assertion 매크로의 확장 결과를 삭제해도 프로그램 상태가 전혀 달라지지 않는데 함수 이름은 상태가 달라진다고 약속하고 있다면, 그것이 곧 버그입니다. -
flag를 만드는 쪽만이 아니라 사용하는 쪽까지 점검. 지켜져야 할 invariant는 다음과 같습니다. frame layout과 ABI 관련 결정은 IR의 실제 내용에서 도출되어야 하며, 내용이 알려지기 전에 계산된 요약 비트에 기대서는 안 됩니다. Narrow —
Source/JavaScriptCore/b3/와Source/JavaScriptCore/b3/air/전체에서B3::Procedure::usesSIMD()와Air::Code::usesSIMD()를 읽는 지점을 나열합니다. 특히 stack slot 크기 계산, callee-saveRegisterAtOffsetList의 폭 선택, wasm catch entrypoint의 레지스터 복원 세 곳을 중점적으로 봅니다. 각 지점마다, flag가 false인 상태로V128Value/Tmp가 해당 단계에 도달했을 때 slot이 작게 잡히거나 저장 폭이 좁아지는지를 확인해야 합니다. Wider — 같은 형태는 폭, 정렬, register bank를 결정하는 코드가 scope 안에서 가장 넓은 타입을 직접 조사하지 않고 boolean 요약값만 읽는 모든 곳에서 나타납니다. 검색 결과에서의 단서는, 두 가지 크기나 정렬 중 하나를 고르는if또는 삼항 연산자인데 그 조건이 value 목록에 대한 질의가 아니라 flag인 경우입니다. Widest — 이후 pass가 무효화할 수 있는 pre-pass 요약값을 근거로 frame layout이 고정되는 컴파일러라면 어디에나 적용됩니다. 들고 다닐 질문은 두 가지입니다. 어떤 변환이 요약값의 예측을 넘어서는 저장 공간을 요구하는 value를 도입할 수 있는지, 그리고 그 변환 이후에 요약값이 다시 계산되는지입니다. -
fix 자체가 넓혀 놓은
RELEASE_ASSERT도달 범위.setUsesSIMD()는RELEASE_ASSERT(Options::useWasmSIMD())로 시작하며, 이 검사는 배포 빌드에도 그대로 남아 있습니다. 패치 이전에는Procedure생성자에서만 도달할 수 있었고, 그 호출자는 이미 module metadata를 확인한 상태였습니다. 이제는 parser callback에서도 도달하게 됩니다. Narrow —Options::useWasmSIMD()가 false일 때, OMG IR 생성 이전 단계에서WasmFunctionParser/WasmSectionParser가 모든v128타입과 SIMD opcode를 거부하는지 확인합니다. import된 함수 signature, tag payload 타입, global 타입을 경유하는 경로까지 포함해야 합니다. 여기에 구멍이 있으면 validation 누락이 배포 빌드의 abort로 이어집니다. Wider — 패치가 도달 범위를 넓혀 놓은RELEASE_ASSERT전반이 이 유형에 해당합니다. 단서는, 이미 release assert가 걸린 함수를 새로 호출하는 fix인데 정작 그 호출자는 assert된 precondition을 스스로 보장하지 않는 경우입니다. Widest — "refactor가 fail-fast 검사의 호출자 집합을 넓히면서 precondition은 다시 증명하지 않는다"는 유형은, 위반 시 abort하는 invariant 위에 세워진 코드베이스라면 어디에나 적용됩니다. 새로 공용화된 helper 안의 Rustassert!/unwrap, 승격된 유틸리티의 Gopanic도 마찬가지입니다. 들고 다닐 invariant는 이렇습니다. fail-fast precondition은 함수 계약의 일부이며, 새로 추가되는 모든 호출자가 이를 독립적으로 보장해야 합니다.