← All reports

CVE 항목 및 diff 블록을 포함한 섹션을 스타일 가이드에 맞춰 한국어로 재작성하겠습니다.

[JSC] Keep JSWebAssemblyMemory alive from wasm-originated JSArrayBuffers

HighJavaScriptCore — ArrayBuffer / WebAssembly memory seamUAF

CVE: CVE-2026-43716 · Safari 26.5.2 · 2026년 6월 29일 릴리스 영향: 악의적으로 조작된 웹 콘텐츠를 처리하는 과정에서 Safari가 예기치 않게 crash할 수 있습니다. Apple 설명: 메모리 처리 방식을 개선하여 문제를 해결했습니다. Credit: Maher Azzouzi, Tuan and Duc from Calif.io, OpenAI Codex Security - Amy Burnett, Evan Lambert

a012bab | Bugzilla 313473

High. Weak back-pointer 하나가 mode selector 역할까지 겸하고 있었습니다. 그 결과 한 객체가 garbage-collect되는 순간 resize 요청이 조용히 엉뚱한 allocator로 흘러가게 되는데, 이 allocator는 지금 내주려는 페이지를 애초에 예약해둔 적이 없습니다. Script가 collection 시점을 직접 제어할 수 있는 만큼, 이번에 추가된 regression test 자체가 곧 trigger입니다.

Resizable ArrayBuffer는 하나의 JavaScript API를 공유하지만 내부 구현은 두 갈래로 완전히 다릅니다. JS 쪽에서 생성한 버퍼는 생성 시점에 maxByteLength 전체만큼 address space를 예약해두고, 이미 소유한 tail 영역의 보호를 해제하는 방식으로 성장합니다. 반면 WebAssembly.Memory.prototype.toResizableBuffer()로 script에 전달되는 wasm 쪽 버퍼는 wasm linear memory를 들여다보는 창일 뿐이고, 성장 로직 자체는 wasm allocator가 담당합니다. 이 둘을 구분하려면 버퍼가 자신이 어느 쪽 세계에서 왔는지 알고 있어야 하는데, 이번 commit 이전에는 그 정보가 garbage collector가 자유롭게 무효화할 수 있는 pointer 하나에 담겨 있었습니다.

관전 포인트: 페이지가 자신의 WebAssembly.Memory 객체를 버리면서도 여기서 얻은 buffer는 그대로 보유한 뒤, collection을 강제로 발생시키고 그 buffer를 잘못된 allocator를 통해 성장시킬 수 있습니다. 결과적으로 실제로는 한 번도 매핑된 적 없는 메모리 영역에 대해 script에서 관찰 가능한 length가 만들어집니다.

Source/JavaScriptCore/runtime/ArrayBuffer.cpp

-void ArrayBuffer::setAssociatedWasmMemory(Wasm::Memory* memory)
-{
 
- // The pointer from a buffer to a memory is only required when the buffer is resizable non-shared,
 
- // to direct a grow request to the memory (see ArrayBuffer::resize). In other scenarios
 
- // the pointer is not necessary and we should not be setting it to anything but a nullptr.
 
- ASSERT(isWasmMemory() && (isResizableNonShared() || !memory));
-#if ENABLE(WEBASSEMBLY)
 
- m_associatedWasmMemory = memory;
-#else
 
- UNUSED_PARAM(memory);
-#endif
-}
-
-// Wasm JS API redefines the abstract operation HostResizeArrayBuffer as follows:
-// https://webassembly.github.io/threads/js-api/index.html#abstract-operation-hostresizearraybuffer
Expected<int64_t, GrowFailReason> ArrayBuffer::resize(VM& vm, size_t newByteLength)
{
+ RELEASE_ASSERT(!isWasmMemory());
+
auto memoryHandle = m_contents.m_memoryHandle;
if (!memoryHandle || m_contents.m_shared) [[unlikely]]
return makeUnexpected(GrowFailReason::GrowSharedUnavailable);
@@
deltaByteLength = static_cast<int64_t>(newByteLength) - static_cast<int64_t>(m_contents.m_sizeInBytes);
-#if ENABLE(WEBASSEMBLY)
 
- if (Options::useWasmMemoryToBufferAPIs()) {
 
- if (isWasmMemory() && (deltaByteLength < 0 || deltaByteLength % PageCount::pageSize))
 
- return makeUnexpected(GrowFailReason::InvalidGrowSize);
 
- }
-#endif
if (!deltaByteLength)
return 0;
@@
if (newPageCount != oldPageCount) {
ASSERT(memoryHandle->maximum() >= newPageCount);
 
-#if ENABLE(WEBASSEMBLY)
 
- if (Options::useWasmMemoryToBufferAPIs()) {
 
- // If this is currently associated with a Wasm memory, let the memory do the growing.
 
- // The memory will call back to our refreshAfterWasmMemoryGrow().
 
- RefPtr<Wasm::Memory> memory = m_associatedWasmMemory.get();
 
- if (memory) {
 
- std::ignore = memory->grow(vm, PageCount(newPageCount.pageCount() - oldPageCount.pageCount()));
 
- return deltaByteLength;
 
- }
 
- }
-#endif
size_t desiredSize = newPageCount.bytes();
RELEASE_ASSERT(desiredSize <= MAX_ARRAY_BUFFER_SIZE);

Source/JavaScriptCore/runtime/ArrayBuffer.h

void NODELETE makeWasmMemory();
inline bool isWasmMemory();
 
- void NODELETE setAssociatedWasmMemory(Wasm::Memory*);
// When a resizable buffer is associated with a non-shared Wasm memory, this function is called by the memory's growthSuccessCallback.
void refreshAfterWasmMemoryGrow(Wasm::Memory*);
@@
public:
Weak<JSArrayBuffer> m_wrapper;
private:
 
- WeakPtr<Wasm::Memory> m_associatedWasmMemory;
Checked<unsigned> m_pinCount { 0 };
bool m_isWasmMemory { false };

Source/JavaScriptCore/runtime/JSArrayBuffer.cpp

+#if ENABLE(WEBASSEMBLY)
+JSWebAssemblyMemory* JSArrayBuffer::associatedWasmMemoryWrapper() const
+{
+ return m_associatedWasmMemoryWrapper.get();
+}
+
+void JSArrayBuffer::setAssociatedWasmMemoryWrapper(VM& vm, JSWebAssemblyMemory* wrapper)
+{
+ ASSERT(impl()->isWasmMemory() && impl()->isResizableNonShared());
+ m_associatedWasmMemoryWrapper.set(vm, this, wrapper);
+}
+
+void JSArrayBuffer::clearAssociatedWasmMemoryWrapper()
+{
+ m_associatedWasmMemoryWrapper.clear();
+}
+#endif // ENABLE(WEBASSEMBLY)
+
+template<typename Visitor>
+void JSArrayBuffer::visitChildrenImpl(JSCell* cell, Visitor& visitor)
+{
+ auto* thisObject = uncheckedDowncast<JSArrayBuffer>(cell);
+ ASSERT_GC_OBJECT_INHERITS(thisObject, info());
+ Base::visitChildren(thisObject, visitor);
+#if ENABLE(WEBASSEMBLY)
+ visitor.append(thisObject->m_associatedWasmMemoryWrapper);
+#endif // ENABLE(WEBASSEMBLY)
+}
+
+DEFINE_VISIT_CHILDREN(JSArrayBuffer);

Source/JavaScriptCore/runtime/JSArrayBuffer.h

+#if ENABLE(WEBASSEMBLY)
+ JSWebAssemblyMemory* associatedWasmMemoryWrapper() const;
+ void setAssociatedWasmMemoryWrapper(VM&, JSWebAssemblyMemory*);
+ void clearAssociatedWasmMemoryWrapper();
+#endif // ENABLE(WEBASSEMBLY)
+
DECLARE_EXPORT_INFO;
+
+ DECLARE_VISIT_CHILDREN;
@@
ArrayBuffer* m_impl;
+ // For resizable non-shared Wasm buffers, this points back to the owning JSWebAssemblyMemory so
+ // that ArrayBuffer.prototype.resize can delegate to Wasm::Memory::grow.
+#if ENABLE(WEBASSEMBLY)
+ WriteBarrier<JSWebAssemblyMemory> m_associatedWasmMemoryWrapper;
+#endif // ENABLE(WEBASSEMBLY)

Source/JavaScriptCore/runtime/JSArrayBufferPrototype.cpp

size_t newByteLength = static_cast<size_t>(newLength);
+
+#if ENABLE(WEBASSEMBLY)
+ // Wasm JS API redefines the abstract operation HostResizeArrayBuffer as follows:
+ // https://webassembly.github.io/threads/js-api/index.html#abstract-operation-hostresizearraybuffer
+ //
+ // Further, WebAssembly-originated resizable ArrayBuffers must defer resizing to the backing
+ // WebAssembly memory for correct handling of refreshing bounds-checking memories.
+ if (auto* jsMemory = thisObject->associatedWasmMemoryWrapper()) {
+ size_t oldByteLength = thisObject->impl()->byteLength();
+ if (newByteLength < oldByteLength)
+ return throwVMRangeError(globalObject, scope, "Cannot shrink WebAssembly memory"_s);
+ if (newByteLength % PageCount::pageSize)
+ return throwVMRangeError(globalObject, scope, makeString("WebAssembly memory cannot be resized to new byte length "_s, newByteLength, " because it is not a multiple of "_s, PageCount::pageSize));
+ size_t delta = newByteLength - oldByteLength;
+ if (delta) {
+ auto result = jsMemory->memory().grow(vm, PageCount::fromBytes(delta));
+ if (!result)
+ return throwVMRangeError(globalObject, scope, makeString("ArrayBuffer resize failed with new byte length "_s, newByteLength));
+ }
+ return JSValue::encode(jsUndefined());
+ }
+#endif
+
if (!thisObject->impl()->resize(vm, newByteLength))

Source/JavaScriptCore/wasm/js/JSWebAssemblyMemory.cpp

m_buffer->makeWasmMemory();
 
- if (m_buffer->isResizableNonShared())
 
- m_buffer->setAssociatedWasmMemory(m_memory.ptr());
 
auto* arrayBuffer = JSArrayBuffer::create(vm, globalObject->arrayBufferStructure(m_buffer->sharingMode()), m_buffer.get());
+ if (m_buffer->isResizableNonShared())
+ arrayBuffer->setAssociatedWasmMemoryWrapper(vm, this);
@@
if (!m_buffer->isShared())
m_buffer->detach(vm);
 
- m_buffer->setAssociatedWasmMemory(nullptr);
m_buffer = nullptr;
+ if (auto* wrapper = m_bufferWrapper.get())
+ wrapper->clearAssociatedWasmMemoryWrapper();
m_bufferWrapper.clear();

Source/JavaScriptCore/wasm/WasmMemory.h

-#include <wtf/RefCountedAndCanMakeWeakPtr.h>
+#include <wtf/RefCounted.h>
#include <wtf/RefPtr.h>
#include <wtf/TZoneMalloc.h>
-#include <wtf/ThreadSafeWeakPtr.h>
@@
-class Memory final : public RefCountedAndCanMakeWeakPtr<Memory> {
+class Memory final : public RefCounted<Memory> {
@@
void registerInstance(JSWebAssemblyInstance&);
 
**Patch Details**
 
이번 변경은 object graph에서 하나의 edge를 다시 배선하고, 그 edge가 가능하게 하는 코드 이동을 함께 담고 있습니다.
 
먼저 edge 자체부터 보면, `ArrayBuffer`에서 `WeakPtr<Wasm::Memory> m_associatedWasmMemory`와 그 setter인 `ArrayBuffer::setAssociatedWasmMemory()`가 제거되었습니다. 대신 JS에서 보이는 wrapper cell인 `JSArrayBuffer`가 `WriteBarrier<JSWebAssemblyMemory> m_associatedWasmMemoryWrapper`를 갖게 되었고, accessor 3종(`associatedWasmMemoryWrapper()` / `setAssociatedWasmMemoryWrapper(VM&, JSWebAssemblyMemory*)` / `clearAssociatedWasmMemoryWrapper()`)이 추가되었습니다. 결정적으로, `JSArrayBuffer`가 처음으로 `visitChildrenImpl`을 갖게 되어 이 필드를 marking visitor에 등록합니다. 이전까지 `JSArrayBuffer`는 자체 `visitChildren`이 없었습니다. `DECLARE_VISIT_CHILDREN` / `DEFINE_VISIT_CHILDREN(JSArrayBuffer)` 쌍은 이번 commit에서 새로 추가된 것으로, 이것이 없으면 새 필드는 ownership edge가 아니라 추적되지 않는 pointer에 그쳤을 것입니다.
 

Before: After: JSWebAssemblyMemory ──Ref──► Wasm::Memory JSWebAssemblyMemory ──Ref──► Wasm::Memory │ (GC cell) ▲ │ (GC cell) ▲ │ ┊ WeakPtr │ │ ▼ ┊ ▼ │ WriteBarrier JSArrayBuffer ──raw──► ArrayBuffer┘ JSArrayBuffer ───────┘ (GC-traced) └──raw──► ArrayBuffer


이제 이 edge는 cell에서 cell로 이어지므로, tracing collector가 이를 인식할 수 있습니다. 예전에 native `ArrayBuffer`에 매달려 있다가 끊어지곤 했던 화살표는 완전히 사라졌습니다.

이어서 두 연결 지점의 배선도 함께 바뀝니다. `JSWebAssemblyMemory::associateArrayBuffer`는 더 이상 wrapper 생성 전에 `m_buffer->setAssociatedWasmMemory(m_memory.ptr())`를 호출하지 않습니다. 대신 `JSArrayBuffer`를 먼저 생성한 뒤, buffer가 resizable하면서 non-shared인 경우에 한해 `arrayBuffer->setAssociatedWasmMemoryWrapper(vm, this)`를 호출합니다. `disassociateArrayBuffer` 역시 이미 `m_bufferWrapper`에 추적 중인 wrapper를 통해 연결을 해제한 뒤 이를 버립니다.

ownership이 보장됨에 따라 wasm growth semantics도 한 계층 위로 옮겨갑니다. `JSArrayBufferPrototype.cpp`의 `arrayBufferProtoFuncResize`는 이제 가장 먼저 `associatedWasmMemoryWrapper()`를 확인하고, 값이 설정되어 있으면 wasm 분기를 그 자리에서 직접 처리합니다. shrink 요청은 `"Cannot shrink WebAssembly memory"`로 거부하고, `PageCount::pageSize`의 배수가 아닌 길이는 `RangeError`로 거부하며, 그 외의 경우 `jsMemory->memory().grow(vm, PageCount::fromBytes(delta))`를 호출합니다. 같은 로직은 `ArrayBuffer::resize()`에서 제거되는데, page-multiple/shrink 검증과 weak-upgrade delegation 블록이 모두 삭제되고, 함수 앞부분에는 `RELEASE_ASSERT(!isWasmMemory())`가 추가되어 있습니다. `HostResizeArrayBuffer`에 대한 스펙 주석도 이 로직과 함께 이동합니다.

후속 정리도 이어집니다. `Wasm::Memory`는 더 이상 weak-pointer 지원이 필요 없어져 `RefCountedAndCanMakeWeakPtr<Memory>`에서 평범한 `RefCounted<Memory>`로 되돌아가고, `ThreadSafeWeakPtr` include도 제거됩니다. debug tripwire였던 `checkLifetime()` — `ASSERT(!refCountDebugger().deletionHasBegun())` — 도 `adopt()`와 `growSuccessCallback()`의 호출 지점과 함께 삭제됩니다. commit message에는 `ArrayBuffer.prototype.resize` 실행 동안 `WebAssembly.Memory`를 pin해 두었던 이전 spot fix(`305413.660@safari-7624-branch`)가 이번 commit으로 되돌려졌다는 내용도 함께 기록되어 있습니다.

마지막으로 `JSTests/wasm/stress/wasm-resizable-buffer-resize-after-gc.js`가 이번 회귀 테스트입니다. IIFE 안에서 memory를 생성하고 그 resizable buffer만 꺼낸 뒤 memory 참조를 버리고, 재귀 helper로 낡은 stack root를 지우고, full GC를 두 번 강제로 수행한 다음, 살아남은 buffer를 resize하고 값을 기록하는 과정을 다섯 번 반복합니다.

**Background**

**Resizable ArrayBuffers.** resizable `ArrayBuffer`는 `maxByteLength` 옵션과 함께 생성되는 `ArrayBuffer`입니다. `ArrayBuffer.prototype.resize(n)`은 길이를 제자리에서 바꾸며, 이 위의 typed-array view는 detach되지 않고 현재 길이를 그대로 추적합니다. JSC 내부에서는 `m_hasMaxByteLength`가 설정된 `ArrayBufferContents`가 이 상태를 담당하고, mapping을 서술하는 `RefPtr<BufferMemoryHandle> m_memoryHandle`이 함께 존재합니다.

**The up-front reservation model.** JS에서 생성된 resizable buffer의 경우, `ArrayBuffer.cpp`의 `tryAllocateResizableMemory()`가 `BufferMemoryManager::tryAllocateGrowableBoundsCheckingMemory()`를 통해 `maxByteLength` 전체에 해당하는 virtual address space를 미리 예약합니다. 이 중 초기 바이트만 commit하고, 나머지는 `OSAllocator::protect()`로 접근 불가 상태로 표시합니다. 따라서 growth는 저렴하고 로컬한 작업이 됩니다. 이미 프로세스가 소유한 tail 영역의 일부를 unprotect하는 것뿐입니다. 주소 공간이 생성 시점에 이미 확보되어 있으므로, 이 경로는 allocator와 별도로 협상할 일이 없습니다.

**Wasm memory modes.** `Wasm::Memory`는 *signaling* 방식이거나 *bounds-checking* 방식 중 하나입니다. Signaling은 큰 virtual reservation을 잡아두고 범위를 벗어난 접근을 hardware trap으로 잡아내는 방식이고, bounds-checking은 모든 접근에 명시적인 길이 비교를 동반하는 방식입니다. commit message는 여기서 중요한 결과를 하나 짚습니다. JS에서 생성된 resizable buffer는 max를 미리 예약하지만, wasm에서 생성된 buffer는 bounds-checking 방식이면 그렇게 하지 않고, signaling 방식이면 그렇게 한다는 점입니다.

**`toResizableBuffer()`와 소유권 구조.** `WebAssembly.Memory.prototype.toResizableBuffer()`는 wasm memory의 linear memory를 가리키는 `ArrayBuffer` view를 script에 전달합니다. `JSWebAssemblyMemory::associateArrayBuffer`가 native `ArrayBuffer`를 생성하고 `makeWasmMemory()`로 태그를 붙인 뒤 `JSArrayBuffer` cell로 감쌉니다. native `Wasm::Memory`에 대한 유일한 strong reference는 `JSWebAssemblyMemory::m_memory`가 `Ref` 형태로 갖고 있으며, `adopt()`는 이를 가져온 직후 `m_memory->refCount() == 1`을 확인합니다. 즉 native memory 객체의 lifetime은 오직 이 GC cell 하나에만 묶여 있습니다.

**`WeakPtr` versus `Ref`.** `WeakPtr<T>`는 대상을 살려두지 않습니다. 대상이 파괴되고 나면 `.get()`은 null을 반환합니다. 반면 `Ref`와 `RefPtr`은 대상을 살려두는 owning reference입니다.

**`WriteBarrier<T>` and `visitChildren`.** `WriteBarrier<T>`는 한 cell에서 다른 cell로 이어지는 GC-traced pointer를 위한 JSC의 필드 타입입니다. 이 edge를 통해 대상을 살려두려면, 소유 클래스가 marking 과정에서 이를 보고해야 합니다. 클래스에 `DECLARE_VISIT_CHILDREN`을 선언하고 `visitChildrenImpl` 안에서 `visitor.append(...)`를 호출하는 방식입니다. cell 사이의 reference cycle은 여기서 문제가 되지 않습니다. tracing collector는 도달 불가능한 cycle을 통째로 회수하기 때문이며, 그래서 commit message는 이번 구조를 의도된 "GC lifetime cycle"로 표현할 수 있는 것입니다.

**`PageCount`.** JSC의 wasm page 추상화입니다. `PageCount::pageSize`는 65536바이트이며, `PageCount::fromBytes()`는 바이트 단위 delta를 page 단위로 변환합니다.

<!-- GATE:analysis -->

**Analysis**

이 버그는 ownership 오류이며, 그 결과는 dangling read가 아니라 *구현이 조용히 바뀌는* 형태로 나타납니다. null이 될 수 있는 non-owning pointer가 분기 선택자로 쓰이고 있었고, 그 pointer가 null일 때 떨어지는 분기는 전혀 다른 allocator에 속한 코드였습니다.

script GC ArrayBuffer::resize() ──────── ── ───────────────────── m = new WA.Memory() buf = m.toResizableBuffer() └─ ArrayBuffer.m_associatedWasmMemory = WeakPtr(Wasm::Memory) drop m ────────────────► JSWebAssemblyMemory unreachable ~Ref ──► WeakPtr now null buf.resize(big) ──────────────────────────────► memory = weak.get() → null (no else branch) ▼ falls through generic growth path assumes max was reserved


위 그림의 각 단계를 순서대로 따라가 보겠습니다. weak edge는 `associateArrayBuffer`가 `toResizableBuffer()` 시점에 설치하며, native `ArrayBuffer`에서 native `Wasm::Memory`를 가리킵니다. 이후 script는 buffer는 유지한 채 `WebAssembly.Memory` 객체에 대한 마지막 참조를 버립니다. buffer 자체가 독립적인 API를 가진 first-class 값인 만큼, 이는 지극히 평범한 동작입니다. 그러면 `JSWebAssemblyMemory` cell이 garbage가 되고 그 안의 `Ref<Wasm::Memory>`가 destruct되면서, buffer가 들고 있던 weak pointer는 null이 됩니다. 이 commit 이전에는 그 cell을 buffer 대신 살려둘 traced edge가 object graph 어디에도 없었습니다.

여기서 빠져 있던 invariant는 명확합니다. *어떤 객체가 협력 객체에 작업을 위임한다면, 그 작업이 도달 가능한 동안에는 협력 객체를 살려두는 참조를 스스로 소유해야 한다*는 원칙입니다. `buf.resize()`는 영원히 도달 가능한 채로 남지만, 협력 객체인 `Wasm::Memory`는 그렇지 못했습니다.

이제 세 번째 단계로 넘어가 보겠습니다. `ArrayBuffer::resize()`는 `ArrayBuffer.prototype.resize`의 유일한 진입점이었고, 그 안의 wasm 처리 로직이 바로 diff에서 삭제되는 upgrade-and-delegate 블록입니다.

```cpp
RefPtr<Wasm::Memory> memory = m_associatedWasmMemory.get();
if (memory) {
    std::ignore = memory->grow(vm, PageCount(newPageCount.pageCount() - oldPageCount.pageCount()));
    return deltaByteLength;
}
size_t desiredSize = newPageCount.bytes();   // ← fallthrough on null

else가 없습니다. 에러를 반환하는 코드도 없습니다. upgrade가 null이 되면 코드는 그대로 desiredSize와 범용 resizable-buffer growth 코드로 흘러 들어갑니다. 이 코드는 원래 tryAllocateResizableMemory()가 생성한 buffer를 위한 것으로, 생성 시점에 tryAllocateGrowableBoundsCheckingMemory()maxByteLength 전체 mapping을 예약해 둔 상태를 전제로 하며, growth란 이미 소유하고 있는 tail 영역을 unprotect하는 작업일 뿐입니다. commit message에 따르면, wasm에서 생성된 bounds-checking memory는 애초에 그런 예약을 한 적이 없습니다.

요란한 실패보다 더 나쁜 지점은, 살아남은 상태가 겉보기에 멀쩡해 보인다는 데 있습니다. RefPtr<BufferMemoryHandle> m_contents.m_memoryHandleWasm::Memory보다 오래 살아남아 여전히 maximum()을 100 page로 보고합니다. 그래서 growth 경로를 지키는 sanity check도 —

ASSERT(memoryHandle->maximum() >= newPageCount);

— 그대로 통과합니다. 선언된 maximum 값이 그 뒤의 page들이 실제로 mapping되어 있다는 근거로 읽히고 있는 셈인데, wasm allocator의 bounds-checking 모드에서는 그렇지 않습니다. 이후 범용 경로는 m_contents.m_sizeInBytes를, 그리고 이와 함께 buffer의 byteLength를, wasm allocator가 한 번도 mapping한 적 없는 영역까지 밀어 올립니다. 이 buffer 위에 생성되는 모든 Uint8Array는 이렇게 부풀려진 길이를 그대로 물려받습니다. 삭제된 검증 블록은 이 도달 가능한 구조를 한층 더 악화시켰습니다. page-multiple 검사와 shrink 검사 자체가 Options::useWasmMemoryToBufferAPIs()에 의해 gating되어 있었고, 이제는 자신이 wasm buffer를 다루고 있는지조차 알 수 없게 된 바로 그 함수 안에 놓여 있었기 때문입니다.

실제 trigger는 추가된 회귀 테스트 그대로이며, 특별히 이례적인 조건이 필요하지 않습니다.

  1. IIFE 안에서 new WebAssembly.Memory({ initial: 1, maximum: 100 })를 생성합니다.
  2. m.toResizableBuffer()를 바깥 scope의 buf에 담아두고, IIFE가 반환되도록 하여 m이 더 이상 참조되지 않게 합니다.
  3. 재귀 helper인 flushStackRoots(64)를 호출해, 그렇지 않으면 memory를 붙잡아 둘 낡은 conservative stack root를 덮어씁니다.
  4. fullGC()를 두 번 호출하고 그 사이에 root flush를 한 번 더 넣어, collection이 운에 맡겨지지 않고 항상 동일하게 일어나도록 만듭니다.
  5. buf.resize(65536 * 100)을 호출한 뒤, 새로 만든 Uint8Array(buf)를 통해 읽고 쓰며 view[view.length - 1] = 0x42까지 수행합니다.

이 단계들은 모두 평범한 웹 콘텐츠 수준에서 가능한 동작입니다. 여기서 GC 타이밍은 이겨야 하는 race가 아니라 도달해야 하는 하나의 상태이며, 테스트가 fullGC()를 두 번 사용하는 구조는 script에서 이 상태에 얼마나 기계적으로 도달할 수 있는지를 보여줍니다.

fix는 이 invariant를 방어적으로가 아니라 구조적으로 복구합니다. buffer의 wrapper cell은 이제 WriteBarrier<JSWebAssemblyMemory> m_associatedWasmMemoryWrapper를 갖고 이를 visitChildrenImpl에서 보고하므로, script가 buffer를 참조할 수 있는 한 collector는 이 edge를 통해 JSWebAssemblyMemory를 marking하고, 그것이 소유한 Ref<Wasm::Memory>는 destruct될 수 없습니다. 두 cell 사이의 연결은 이제 진짜 cycle이 되고, tracing collector는 별다른 도움 없이도 이를 처리합니다. buffer가 살아 있는 동안에는 이 edge가 더 이상 null이 될 수 없으므로, 분기를 상위 계층으로 끌어올리는 작업도 안전해집니다. arrayBufferProtoFuncResizeassociatedWasmMemoryWrapper()를 먼저 확인하고, 값이 있으면 shrink 거부와 page-multiple 거부를 직접 처리한 뒤 jsMemory->memory().grow()를 호출하며, ArrayBuffer::resize에는 아예 진입하지 않습니다. 버그를 일으켰던 fallthrough는 단순히 발생 가능성을 낮춘 수준이 아니라, ArrayBuffer::resize 맨 앞의 RELEASE_ASSERT(!isWasmMemory())로 아예 도달 불가능하다고 못박아 둡니다. 이로써 wasm buffer가 범용 growth 코드에 도달하는 남은 경로가 있더라도, 즉시 abort로 이어져 exploit할 수 없는 상태가 됩니다.

이번 commit이 삭제한 부분에서 두 번째 측면도 드러납니다. 이전에 배포되었던 spot fix(305413.660@safari-7624-branch)는 ArrayBuffer.prototype.resize가 실행되는 동안에만 WebAssembly.Memory를 pin해 살려두었고, Wasm::Memory::checkLifetime()!refCountDebugger().deletionHasBegun()을 확인하는 debug tripwire로 존재했습니다. 이 assertion은 destruction이 이미 시작된 Wasm::Memory를 누군가 사용하고 있을 때에만 의미를 갖습니다. 이 두 장치는 모두 같은 문제, 즉 weak upgrade가 유일한 소유자의 destruction과 race하는 상황을 겨냥하고 있었습니다. 이 측면이 실제로 발현되면 길이 불일치가 아니라 grow() 내부에서 refcounted native 객체에 대한 use-after-free로 나타났을 것입니다. 두 장치 모두 이번 패치가 제거한 하나의 weak edge 위에 서 있었기 때문에, spot fix와 tripwire 둘 다 이 edge와 함께 삭제될 수 있었습니다.

영향 범위는 WebContent process, 즉 JSC heap과 wasm linear memory 안으로 한정됩니다. Apple의 advisory는 이 결과를 예기치 못한 Safari crash로 설명하고 있습니다. 이 구조가 시사하는 primitive는 script에서 도달 가능한 ArrayBuffer의 길이와 backing store 사이의 불일치이며, Uint8Array view를 통한 상대적인 out-of-bounds read/write로 이어집니다. 오프셋의 범위는 memory에 선언된 maximum 값에 묶여 있으므로 사실상 공격자가 상당 부분 결정할 수 있습니다. 이를 renderer 바깥으로 확장하려면 별도의 sandbox escape가 여전히 필요합니다.

mode selector로 쓰이던 weak back-pointer가 null이 되자, wasm 기반 buffer가 범용 allocator를 통해 조용히 자라났습니다. 이 allocator는 "max가 이미 예약되어 있다"는 전제를 충족한 적이 없었습니다.

이전 이력 자체가 교훈을 남깁니다. 앞선 fix는 resize 본문이 실행되는 동안 WebAssembly.Memory를 pin했고, checkLifetime()은 destruction 도중인 memory를 아무도 건드리지 않는다는 것을 확인했습니다. 둘 다 같은 edge를 겨냥해 window를 좁히거나 tripwire를 심은 조치였을 뿐, 협력 객체가 애초에 사라질 수 있었던 representation 자체는 바꾸지 못했습니다. commit message의 서술에서 진짜 결함이 representation에 있었다는 단서가 드러납니다. weak edge를 유지한다는 것은 "non-wasm ArrayBuffer 코드가 WebAssembly.Memory가 collect될 가능성을 항상 염두에 두어야 한다"는 뜻이었고, 이는 그 파일을 앞으로 수정할 때마다 collect된 협력 객체를 고려해야 하는 부담으로 남습니다. 연결을 strong reference로 바꾸고 fallback 경로를 도달 불가능하다고 못박음으로써, 이 부담과 버그 유형을 한꺼번에 해소한 셈입니다. 범위를 좁힌 protector 방식이었다면 둘 중 어느 쪽도 해결하지 못했을 것입니다.