← All reports

[JSC] BBQCallee should be kept alive between callsite collection and repatch

HighJSC WebAssembly runtime —UAF

CVE: CVE-2026-43658 · Safari 26.5 · Released May 13, 2026 Impact: Processing maliciously crafted web content may lead to an unexpected Safari crash Apple's description: The issue was addressed with improved memory handling. Credit: Do Young Park

Severity: High | Component: JSC WebAssembly runtime — Wasm::CalleeGroup | 9a16de4 | Bugzilla 307669

High. 이번 패치가 닫은 window는 이미 파일 안에 기록되어 있던 문제였습니다. 누군가 이미 이 window를 진단했지만, ownership chain에서 잘못된 쪽 끝을 고정해두었습니다. Race에서 이기면 해제된 JIT allocation 내부의 code location에 store가 발생합니다. 관찰된 impact는 crash이며, 이를 그 이상으로 확장하려면 diff에서 다루지 않는 allocator grooming이 필요합니다.

WebKit의 WebAssembly는 세 개의 실행 tier를 거쳐 동작하며, tier 사이의 승격이 일어날 때마다 bookkeeping 문제가 남습니다. 컴파일이 끝난 caller들이 자신이 컴파일될 당시 tier의 entrypoint를 가리키는 direct call instruction을 여전히 들고 있기 때문입니다. 이를 고치려면 모든 caller를 순회하면서 해당 call instruction들의 machine-code 주소를 기록한 뒤, 다시 돌아가 그 위치에 새로운 target을 기록해야 합니다. 이 "주소를 먼저 수집하고 이후 그 위치에 store한다"는 2단계 구조가 바로 Wasm::CalleeGroup::updateCallsitesToCallUs이며, 정확성은 하나의 invariant에 달려 있습니다. 2단계에서 generated code에 기록될 모든 객체는 그 시점까지 반드시 살아있어야 한다는 조건입니다.

관전 포인트: GC가 callee를 해제하는 동안 페이지가 wasm 컴파일을 계속 바쁘게 돌리면, 엔진이 방금 해제된 JIT code allocation에 pointer를 기록하도록 유도할 수 있습니다.

Source/JavaScriptCore/wasm/WasmCalleeGroup.cpp

// This is necessary since Callees are released under `Heap::stopThePeriphery()`, but that only stops JS compiler
 
- // threads and not wasm ones. So the OMGOSREntryCallee could die between the time we collect the callsites and when
 
- // we actually repatch its callsites.
+ // threads and not wasm ones. So a weakly held BBQCallee and its OMGOSREntryCallee could die between the time we
+ // collect the callsites and when we actually repatch its callsites. Since BBQCallee owns OMGOSREntryCallee,
+ // keeping BBQCallee alive is enough to ensure that both are alive for the required duration.
+ //
+ // There is however an edge case here - it can happen that a BBQCallee has been freed but its OMGOSREntryCallee
+ // has been added to the pending-destruction set and not yet free'd. This means that m_osrEntryCallees will still
+ // hold a weak ref to it. In this scenario, BBQCallee won't be kept alive since it does not exist so we manually
+ // have to keep the OMGOSREntryCallee alive separately. This should only be done in this scenario else we will
+ // end up with multiple owners for OMGOSREntryCallee.
+
// FIXME: These inline capacities were picked semi-randomly. We should figure out if there's a better number.
+ Vector<Ref<BBQCallee>, 4> keepAliveBBQCallees;
Vector<Ref<OMGOSREntryCallee>, 4> keepAliveOSREntryCallees;
Vector<Callsite, 16> callsites;
+ bool bbqCalleeKeptAlive = false;
+ UNUSED_VARIABLE(bbqCalleeKeptAlive);
#if ENABLE(WEBASSEMBLY_BBQJIT)
// This callee could be weak but we still need to update it since it could call our BBQ callee
// that we're going to want to destroy.
@@
if (bbqCallee) {
collectCallsites(bbqCallee.get());
ASSERT(!bbqCallee->osrEntryCallee() || m_osrEntryCallees.find(callerIndex) != m_osrEntryCallees.end());
+ keepAliveBBQCallees.append(bbqCallee.releaseNonNull());
+ bbqCalleeKeptAlive = true;
}
#endif
if (auto iter = m_osrEntryCallees.find(callerIndex); iter != m_osrEntryCallees.end()) {
if (RefPtr callee = iter->value.get()) {
collectCallsites(callee.get());
 
- keepAliveOSREntryCallees.append(callee.releaseNonNull());
+ // If we track the OMGOSREntryCallee as a callsite there are 2 possibilities -
+ // 1. The BBQCallee is already being tracked - in this case we don't have to
+ // track the OMGOSREntryCallee since the BBQCallee owns it and keeping the
+ // BBQCallee alive is good enough to keep the OMGOSREntryCallee alive. Also,
+ // OMGOSREntryCallee is only supposed to be owned by BBQCallee
+ // 2. The BBQCallee is not tracked - This happens if the BBQCallee is already
+ // released but the OMGOSREntryCallee is still alive. In this case there is
+ // no other strong reference to OMGOSREntryCallee so we have to keep it
+ // alive here.
+ if (!bbqCalleeKeptAlive)
+ keepAliveOSREntryCallees.append(callee.releaseNonNull());
} else
m_osrEntryCallees.remove(iter);
}

Source/JavaScriptCore/wasm/WasmCalleeGroup.cpp의 단일 함수에 세 곳의 수정이 가해졌습니다.

첫 번째는 기존 accumulator 옆에 추가된 새로운 accumulator입니다. Vector<Ref<BBQCallee>, 4> keepAliveBBQCalleeskeepAliveOSREntryCallees, Vector<Callsite, 16> callsites와 나란히 선언되었습니다. 패치 이전에는 이 함수 안 어디에도 BBQ callee에 대한 strong reference를 관측 시점 이후까지 유지하는 코드가 없었습니다.

두 번째는 per-caller body 내부의 변경입니다. collectCallsites(bbqCallee.get())가 해당 callee의 call-instruction 주소들을 callsites에 기록한 뒤, 패치는 keepAliveBBQCallees.append(bbqCallee.releaseNonNull())로 local handle을 새 vector로 옮기고 local bool bbqCalleeKeptAlive = true를 설정합니다. 이 flag는 #if ENABLE(WEBASSEMBLY_BBQJIT) 블록 앞에서 선언되고 곧바로 UNUSED_VARIABLE에 전달됩니다. 따라서 BBQ가 비활성화된 빌드에서 이 값에 아무것도 대입되지 않아도 warning 없이 컴파일됩니다.

세 번째는 기존에 있던 keep-alive 로직을 조건부로 바꾼 부분입니다. OSR-entry branch는 여전히 m_osrEntryCallees에서 weak handle을 resolve하고 여전히 callsite를 수집하지만, keepAliveOSREntryCallees.append(...)는 이제 if (!bbqCalleeKeptAlive) 조건 아래 놓입니다. 새로 추가된 두 comment block은 이유를 직접 명시합니다. BBQCalleeOMGOSREntryCallee를 소유하므로 owner를 유지하는 것만으로 owned object까지 보호되며, 여기에 두 번째 Ref를 추가하면 원래 단일 소유였어야 할 객체에 두 번째 독립 owner가 생겨버립니다. Unconditional한 경로는 BBQ callee가 이미 사라졌지만 그 OSR-entry callee는 여전히 resolve 가능한 경우만을 위해 남겨두었습니다.

retention 커버리지의 변화는 다음과 같습니다.

  Before (per caller):              After (per caller):
  bbqCallee (RefPtr, local)         bbqCallee ──► keepAliveBBQCallees
    └─ collectCallsites                └─ collectCallsites
    └─ scope ends → ref dropped        └─ held until function returns
  osrCallee ──► keepAlive vector     osrCallee ──► keepAlive vector
                                                   ONLY if no bbqCallee

Wasm 실행 tier. WebKit은 WebAssembly를 세 개의 tier로 실행합니다. wasm bytecode를 직접 실행하는 in-place interpreter인 IPInt, 빠른 baseline JIT인 BBQ, 그리고 optimizing JIT인 OMG입니다. 컴파일된 각 tier는 런타임에서 Callee 객체로 표현됩니다 — BBQCallee, OMGCallee, OMGOSREntryCallee — 그리고 이 객체가 해당 함수의 generated machine code를 소유합니다.

OSR entry. On-stack replacement는 낮은 tier에서 이미 실행 중인 loop을 함수 반환을 기다리지 않고 iteration 도중에 곧바로 새로 컴파일된 상위 tier 코드로 옮겨줍니다. OMGOSREntryCallee는 바로 이런 하나의 진입점을 위해 생성된 OMG-컴파일 body입니다. 설계상 이 객체는 자신을 만들어낸 BBQCallee가 유일하게 소유하며, 바로 이 단일 소유 구조 때문에 이번 패치의 두 번째 부분이 존재하게 됩니다.

CalleeGroup. 모듈과 memory mode 각각에 대해, CalleeGroup은 그 모듈 함수들의 모든 callee를 담는 container입니다. 여기서 중요한 멤버는 두 개입니다. m_bbqCalleeLock 뒤에서 BBQ와 OMG callee를 per-function tuple로 담는 m_optimizedCallees, 그리고 caller index에서 OSR-entry callee로의 map인 m_osrEntryCallees입니다.

Callsite repatching. 함수가 상위 tier로 재컴파일되면, 이미 컴파일된 모든 caller는 여전히 예전 entrypoint를 immediate target으로 갖는 direct call instruction을 담고 있습니다. updateCallsitesToCallUs가 이를 고칩니다. caller들을 순회하며 이 call instruction들의 code location을 Vector<Callsite, 16> callsites에 수집한 다음, 각각을 새로운 entrypoint를 가리키도록 rewrite합니다. 수집과 rewrite는 함수 body에 대한 별도의 두 pass로 나뉘어 있습니다.

Weak 핸들과 strong 핸들. m_bbqCalleem_osrEntryCallees의 값들은 weak하게 보유됩니다. weak handle에 .get()을 호출하면 RefPtr이 반환됩니다. 객체가 이미 파괴되었다면 null이고, 그렇지 않다면 그 RefPtr local이 scope 안에 남아있는 동안만 객체를 살려두는 strong reference입니다. RefRefPtr은 WebKit의 reference-counted smart pointer이며, releaseNonNull()은 non-null이 확인된 RefPtr을 refcount를 건드리지 않고 Ref로 옮깁니다. 여기서 중요한 것은 scope에 관한 함의입니다. 업그레이드된 weak handle은 그로부터 파생된 무언가의 마지막 사용 시점까지가 아니라, 그 handle을 선언한 block이 끝날 때까지만 객체를 보호합니다.

Heap::stopThePeriphery(). collector가 안전하게 객체를 해제할 수 있도록 보조 thread들을 정지시키는 GC phase입니다. 패치 이전부터 이 함수에 있던 comment에 따르면, JS compiler thread는 정지시키지만 wasm 컴파일 thread는 정지시키지 않습니다.

UNUSED_VARIABLE. 특정 #if 조건에서만 읽히는 변수에 대해 unused-variable 진단을 억제하는 WebKit macro입니다.

이 버그는 collect-then-mutate 형태의 2단계 연산에서, weak handle을 1단계에서만 strong으로 업그레이드해두는 바람에 발생하는 use-after-free입니다.

  wasm compilation / GC release      updateCallsitesToCallUs
  ─────────────────────────────      ──────────────────────────────────
                                     phase 1: bbqCallee = m_bbqCallee.get()
                                              collectCallsites(bbqCallee)
                                              ── lambda scope ends ──
                                              RefPtr destructs, refcount--
   last strong ref dropped
   ~BBQCallee() → JIT code freed
   (owned OMGOSREntryCallee too)
                                     phase 2: repatch each collected Callsite
                                              store → freed code memory

두 column은 동시에 실행되며, 가운데 column이 바로 그 window에 해당합니다. phase 1에서 함수는 optimized-callee tuple에서 각 caller의 BBQ callee를 resolve합니다 — tuple->m_bbqCalleeLock 아래에서 tuple->m_bbqCallee.get()을 호출하는데, 이 과정이 weak handle을 임시 RefPtr로 업그레이드합니다. 이어서 collectCallsites(bbqCallee.get())가 그 callee의 generated code를 순회하며 rewrite가 필요한 call instruction들의 주소를 기록합니다. 그런 다음 lambda가 반환됩니다. 이 RefPtr은 local이었기 때문에 소멸하면서 refcount가 감소하고, 그 순간부터는 아무것도 해당 callee를 붙잡고 있지 않습니다. phase 2로 넘어가는 것은 Vector<Callsite>뿐인데, 여기 담긴 것은 owning reference가 아니라 raw code location — 즉 owner가 방금 손을 놓은 메모리를 가리키는 주소들입니다.

이 free가 실제로 동시에 일어날 수 있다는 사실은 이미 이 파일 안에 알려져 있었습니다. keep-alive vector들 위에 있던, 패치 이전부터 존재하던 comment가 이를 명시합니다. callee는 Heap::stopThePeriphery() 아래에서 해제되는데, 이 primitive는 JS compiler thread는 정지시키지만 wasm thread는 정지시키지 않는다는 내용입니다. wasm 컴파일 thread, 또는 GC의 release path 자체가, 이 함수가 조용하다고 가정한 바로 그 구간에서 BBQCallee의 마지막 reference를 자유롭게 떨어뜨릴 수 있습니다. destructor가 실행되면 generated machine code가 함께 사라지고, ownership 구조상 그 OMGOSREntryCallee도 함께 사라집니다. 이후 phase 2는 앞서 수집한 주소들에 대해 repatch loop를 실행하며 각 위치에 store를 수행합니다. 이 store는 해제되었거나 재사용되었을 수도 있는 executable memory에 그대로 떨어집니다.

이 케이스를 두 번 읽어볼 가치가 있게 만드는 지점은, 정확히 이 hazard에 대한 fix가 이미 세 줄 아래에 있었다는 사실입니다. keepAliveOSREntryCallees가 존재하는 이유는, 누군가 이전에 이미 같은 추론을 거쳐 phase 1에서 수집된 객체는 phase 2가 끝날 때까지 고정되어야 한다는 결론에 도달했기 때문입니다. 다만 그 사람은 OMGOSREntryCallee를 고정했습니다. OMGOSREntryCallee를 소유하는 객체인 BBQCallee는 고정하지 않았습니다. 이전 fix는 자식을 보호했지만, 그 밑에서 부모가 파괴될 수 있는 경로는 열어둔 셈입니다.

이번 패치는 업그레이드된 handle을 lambda의 scope 밖으로 꺼내 두 phase 모두를 아우르는 lifetime을 가진 vector인 keepAliveBBQCallees로 옮김으로써 이 window를 닫습니다. releaseNonNull()은 새로운 reference를 취하는 대신 기존 strong reference를 이전하므로, repatch loop가 store를 수행할 대상 객체는 callsite가 관측된 순간부터 함수가 반환될 때까지 계속 살아있음이 보장됩니다.

기존 vector에 붙은 !bbqCalleeKeptAlive guard는 별도의 두 번째 버그 수정이 아닙니다. 첫 번째 fix가 ownership 모델을 깨뜨리지 않도록 막아주는 장치에 가깝습니다. BBQ callee가 일단 retain되면 그것이 소유한 OSR-entry callee도 transitively 보호됩니다. 이 상태에서 독립적인 Ref를 추가로 append하면, 원래 단일 소유로 명시된 객체에 두 개의 동시 owner가 생겨버립니다. 그래서 manual retain은 이제 진짜로 필요한 단 하나의 경우로만 범위가 좁혀졌습니다. BBQ callee는 이미 파괴되었지만 그 OSR-entry callee는 아직 pending destruction 상태라서 m_osrEntryCallees의 weak handle이 여전히 resolve되는 경우입니다. 이 경우에는 다른 strong reference가 전혀 존재하지 않기 때문에, 명시적인 retain만이 그 객체를 붙잡아둘 유일한 수단입니다.

collect-then-repatch 2단계 구조에서 weak handle을 1단계에서만 strong으로 업그레이드해둔 탓에, 동시에 실행되는 wasm thread가 2단계에서 store 대상이 될 JIT code를 먼저 해제할 수 있었습니다.

이번 케이스는 diff 자체에서 읽어낼 수 있는 incomplete-prior-fix 패턴에 해당합니다. 기존 keepAliveOSREntryCallees vector와 그 comment는 같은 window가 이전에 이미 진단되었지만, ownership chain에서 한 단계 낮은 곳에 패치가 적용되었음을 보여줍니다. owned object는 고정했지만 owner는 weak한 채로 남겨둔 것입니다. critical window를 가로질러 ownership chain의 한 link를 고정하는 fix가 들어올 때는, 그 chain의 다른 모든 link도 동일한 감사 대상이 되어야 합니다. 그리고 그 결과로 생기는 keep-alive list들은 각 ownership edge를 따라 상호 배타적으로 구성해서, owner를 보호하는 행위가 owner가 소유한 대상의 ownership을 은연중에 중복시키지 않도록 해야 합니다.