← All reports

[JSC] Procedure::usesSIMD() not set when OMG inlines a SIMD callee into a non-SIMD root

HighJavaScriptCore WebAssembly OMG tierMemoryCorruption

CVE: CVE-2026-64780 · Safari 26.6.1 · 2026년 8월 18일 릴리스 Impact: 악의적으로 조작된 웹 콘텐츠를 처리할 경우 예기치 않은 Safari crash가 발생할 수 있습니다 Apple's description: 개선된 검사를 통해 문제를 해결했습니다. Credit: OpenAI Codex Security - Amy Burnett

9a17cd1 | Bugzilla 316918

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

// SIMD
 
- void NODELETE notifyFunctionUsesSIMD() { ASSERT(m_info.usesSIMD(m_functionIndex)); }
+ bool NODELETE usesSIMD() const { return m_info.usesSIMD(m_functionIndex); }
+ void NODELETE notifyFunctionUsesSIMD()
+ {
+ ASSERT(m_info.usesSIMD(m_functionIndex));
+ m_proc.setUsesSIMD();
+ }

Source/JavaScriptCore/b3/B3Procedure.h

 
- void setUsessSIMD()
+ void setUsesSIMD()
{
RELEASE_ASSERT(Options::useWasmSIMD());
m_usesSIMD = true;

Source/JavaScriptCore/b3/B3Procedure.cpp

Procedure::Procedure(bool usesSIMD)
, m_heaps(makeUniqueRef<AbstractHeapRepository>())
{
if (usesSIMD)
 
- setUsessSIMD();
+ setUsesSIMD();
// Initialize all our fields before constructing Air::Code since
// it looks into our fields.
m_code = std::unique_ptr<Air::Code>(new Air::Code(*this));

JSTests/wasm/stress/inline-wasm-simd-into-non-simd.js

+const NUM_REFS = 49;
+// refParams/refResults = " externref" x 49, pushResults = (local.get 1..49)
+const wat = `
+(module
+ (tag $T (param i64 i64 i64 v128))
+ (func $B (param i64)
+ (throw $T (i64.const 0) (i64.const 0) (i64.const 0)
+ (i64x2.splat (local.get 0))))
+ (func $A (export "A") (param i64${refParams}) (result${refResults})
+ (try
+ (do (call $B (local.get 0)))
+ (catch_all))
+ ${pushResults}
+ )
+)`;
+const sentinel = { marker: "SENTINEL" };
+const args = [0xan];
+for (let i = 0; i < NUM_REFS; i++)
+ args.push(sentinel);
+// $A를 OMG tier까지 올립니다.
+for (let i = 0; i < testLoopCount; i++)
+ last = A.apply(null, args);
+for (let k = 0; k < NUM_REFS; k++) {
+ if (last[k] !== sentinel) { bad = k; break; }
+}
+if (bad >= 0) {
+ throw new Error("usesSIMD desync");
+}

변경된 소스 파일은 세 개지만, 실제로 동작이 달라지는 것은 그중 하나뿐입니다.

동작이 바뀌는 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합니다.

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에 대해 동일한 전제를 공유해야 합니다.

요약 비트와 그 비트가 요약하는 대상 사이에서 상태가 어긋난 사례입니다. 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에서 확인 가능한 자리에 떨어지도록 설계되어 있습니다:

  1. $A는 i64 하나와 externref 49개를 인자로 받아 49개의 externref 결과로 반환하는 export 함수입니다.
  2. 이 49개의 참조는 try를 가로질러 살아 있습니다. 따라서 Try 처리 과정에서 expression stack이 B3::Variable로 materialize되고, 각 참조는 전용 frame slot을 배정받습니다.
  3. $A가 $B를 호출하고, OMG는 이 호출을 inline합니다. $B는 payload에 v128을 담은 tag를 throw하며, 이 v128은 i64x2.splat (local.get 0)으로 생성됩니다. 즉 vector의 비트는 JS 호출자가 고른 $A의 i64 인자 그대로입니다.
  4. testLoopCount만큼 반복 호출하면 $A가 OMG로 tier up되고, 어긋난 flag가 여기서 효력을 발휘합니다.
  5. catch_all이 예외를 삼키고, $A는 49개의 참조를 반환합니다.
  6. 반환된 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 객체 참조를 덮었습니다.

검색으로 찾아낼 수 있는 신호가 여기서 두 가지 겹치며, 둘 다 이번 버그를 넘어 일반화됩니다. 첫째, 호출 지점이 생성자 하나뿐인 mutator는 "이 속성은 객체 수명 동안 바뀌지 않는다"는 주장을 암묵적으로 담고 있습니다. 그리고 inlining은 그런 주장을 무너뜨리는 대표적인 변환입니다. unit 객체가 이미 만들어진 뒤에 그 unit이 담고 있는 내용을 바꾸기 때문입니다. 둘째 신호는 더 날카롭습니다. notify/record/mark 같은 이름을 달고 있으면서 정작 어떤 상태도 바꾸지 않는 메서드입니다. ASSERT가 컴파일 과정에서 사라지므로 release build에서의 의미는 "아무것도 하지 않음"이었고, 반대로 debug build의 assertion은 이 경로가 연결되어 있다는 잘못된 확신을 심어 주었습니다. 흔치 않은 형태의 실패입니다. build 설정에 따라 동작이 갈리면서, debug binary가 출시 binary보다 더 올바른 상황이 된 셈입니다.