← 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 tables store funcref entries as a pair of fields: m_value, a JS wrapper object tracked by a WriteBarrier so the GC knows to mark it, and m_function, metadata including an RTT pointer used for call_indirect signature checks. A prior commit (313985@main) added an optimization where Function::isEmpty() checks only m_function.rtt, and the GC's visitAggregateImpl used that check to skip marking m_value for slots it considered empty. Table::grow's JS-API path — unlike the Wasm-instruction table.grow / table.set path — populated only m_value and left m_function default with a null RTT, so a fully live slot looked empty to the collector. This commit routes the JS path through a new FuncRefTable::Function::setFunction() that writes both halves together, and removes the isEmpty() short-circuit from visitAggregateImpl as a safety net.

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

The bug is reachable directly from JS via the standard WebAssembly.Table API and produces a genuine use-after-free: table.get() can return a JSValue pointing to GC-reclaimed memory after a full collection. The half-populated slot also caused call_indirect traps, since the missing RTT is exactly what signature checking consults.

A GC-liveness invariant — isEmpty() used as a proxy for "has a value to mark" — was violated by a code path that updates only half of a two-field representation, creating a marking gap. Narrow: audit the other Wasm reference-type table operations and externref tables for paths that write m_value or its equivalent without the matching metadata write; the match tell is any assignment to a WriteBarrier-tracked field that is not adjacent to the metadata write a liveness predicate consults. Wider: sweep visitAggregateImpl and visitChildren implementations across JSC for any that gate marking on a cheap flag rather than directly checking the tracked value's nullness — the shape to notice in review is a continue or early return inside a marking loop whose condition reads a field other than the one being marked. Widest: the invariant is that a liveness predicate must be derived from the reference being kept alive, not from a sibling field that a partial write can leave stale — it transfers to any tracing collector with multi-field slot representations, and to generation/epoch flags used to skip work in mark phases.