← All reports

[Wasm] Fix JS Table.grow with a default value

JSC WebAssemblyUninitializedMemory

A Wasm table slot that was full but looked empty to the collector.

Component: JSC WebAssembly | 36a3e59

Source/JavaScriptCore/wasm/WasmTable.cpp

case TableElementType::Funcref: {
auto* funcTable = static_cast<FuncRefTable*>(this);
auto* defaultFunction = dynamicDowncast<WebAssemblyFunctionBase>(defaultValue);
ASSERT(defaultFunction || defaultValue.isNull());
bool success = checkedGrow(funcTable->m_importableFunctions, [&](auto& slot) {
ASSERT(slot.m_value.isNull());
if (defaultFunction)
slot.setFunction(vm, m_owner, defaultFunction);
});
...
void Table::visitAggregateImpl(Visitor& visitor)
{
...
for (unsigned i = 0; i < m_length; ++i) {
auto& slot = table->m_importableFunctions.get()[i];
- if (slot.isEmpty())
- continue;
visitor.append(slot.m_value);
visitor.append(slot.m_function.targetInstance);
visitor.append(slot.m_function.importFunction);

Source/JavaScriptCore/wasm/WasmTable.h

struct Function {
+ void setFunction(VM&, JSCell* owner, WebAssemblyFunctionBase*);
bool isEmpty() const { return !m_function.rtt; }
...
+ WasmOrJSImportableFunction m_function;
+ WriteBarrier<Unknown> m_value { NullWriteBarrierTag };
+ void* m_padding { nullptr };
};

Wasm table은 funcref entry를 두 필드의 쌍으로 저장합니다. 하나는 m_value로, GC가 마킹 대상임을 알 수 있도록 WriteBarrier로 추적되는 JS wrapper object입니다. 다른 하나는 m_function으로, call_indirect의 signature check에 쓰이는 RTT pointer 등의 metadata를 담습니다. 이전 commit(313985@main)에서 Function::isEmpty()가 오직 m_function.rtt만 확인하도록 최적화가 들어갔고, GC의 visitAggregateImpl도 이 check를 이용해 empty로 판단된 slot의 m_value 마킹을 건너뛰도록 했습니다. 문제는 Table::grow의 JS-API 경로에 있었습니다. Wasm-instruction 쪽의 table.grow / table.set 경로와 달리, 이 경로는 m_value만 채우고 m_function은 RTT가 null인 기본값 상태로 남겨두었습니다. 그 결과 실제로는 살아 있는 slot이 collector 입장에서는 empty로 보이게 되었습니다. 이번 commit은 JS 경로가 새로 추가된 FuncRefTable::Function::setFunction()을 거치도록 변경하여 두 필드를 함께 기록하게 했고, 안전장치로 visitAggregateImpl에서 isEmpty() short-circuit도 제거했습니다.

void FuncRefTable::Function::setFunction(VM& vm, JSCell* owner, WebAssemblyFunctionBase* function)
{
    m_function = function->importableFunction();
    m_value.set(vm, owner, function);
}
Before:
JS: table.grow(n, fn) ──► Table::grow()
                             ├─ slot.m_value = fn        (write barrier, live)
                             └─ slot.m_function = {}      (rtt = null)

GC::visitAggregateImpl(slot):
  if (slot.isEmpty())    // !m_function.rtt, true here
      skip marking slot.m_value   // wrapper unmarked despite being live

fullGC() ──► wrapper reclaimed ──► table.get(n) returns dangling cell

이 버그는 표준 WebAssembly.Table API를 통해 JS에서 직접 도달할 수 있으며, 실제 use-after-free를 발생시킵니다. Full collection 이후 table.get()이 GC에 회수된 메모리를 가리키는 JSValue를 반환할 수 있습니다. 절반만 채워진 slot은 call_indirect trap도 유발했는데, 이는 signature checking이 참조하는 값이 바로 누락된 RTT였기 때문입니다.

isEmpty()를 "마킹할 값이 있는지"의 proxy로 사용하는 GC-liveness invariant가, 두 필드로 구성된 representation 중 절반만 갱신하는 code path에 의해 깨진 사례입니다. 그 결과 marking gap이 생겼습니다. 좁게 보면, 다른 Wasm reference-type table 연산과 externref table에서도 m_value나 그에 대응하는 필드만 쓰고 metadata 쓰기는 누락하는 경로가 있는지 점검할 필요가 있습니다. 코드 리뷰에서 눈여겨볼 신호는 WriteBarrier로 추적되는 필드에 값을 대입하면서, liveness predicate가 참조하는 metadata 쓰기와 나란히 붙어 있지 않은 경우입니다. 넓게 보면, JSC 전반의 visitAggregateImplvisitChildren 구현들을 훑어, 추적 대상 값의 null 여부를 직접 확인하는 대신 값싼 flag로 마킹 여부를 결정하는 곳이 있는지 살펴볼 필요가 있습니다. 코드 리뷰에서 눈에 띄는 형태는 marking loop 안의 continue나 조기 반환의 조건이 실제로 마킹되는 필드가 아닌 다른 필드를 읽는 경우입니다. 가장 넓게 보면, 핵심 invariant는 liveness predicate가 살려두려는 reference 자체로부터 도출되어야 하며, partial write로 stale 상태가 될 수 있는 sibling field에서 도출되어서는 안 된다는 것입니다. 이는 multi-field slot representation을 가진 모든 tracing collector, 그리고 mark phase에서 작업을 건너뛰는 데 쓰이는 generation/epoch flag 전반에 적용됩니다.