36a3e59bad [Wasm] Fix JS Table.grow with a default value
Triage note: GC marking gap left m_value unmarked while m_function.rtt null, freeing a live wrapper reachable via table.get, a clear UAF/type-confusion.
Contents
The bug at a glance
WebAssembly.Table.prototype.grow(delta, fillValue) is directly script-callable, and the bug leaves a live WebAssemblyFunction wrapper unmarked so the GC frees it while table.get still returns it - a use-after-free / dangling-cell yielding a freed JSCell to script. That is a strong exploitation primitive and the patch ships two regression tests, so high.
The JS-API Table.grow funcref path wrote only slot.m_value and left slot.m_function default-constructed. Combined with a recently-introduced Function::isEmpty() == !m_function.rtt predicate that visitAggregateImpl used to skip marking, grown slots read as empty, so the GC did not visit the live m_value wrapper and reclaimed it; table.get then returned a dangling cell (and call_indirect trapped on the null rtt).
Root cause
WebAssembly.Table.prototype.grow(delta, fillValue) routes through Wasm::Table::grow(delta, defaultValue) with a non-null defaultValue. In the TableElementType::Funcref case, the old code did slot.m_value.set(vm, m_owner, defaultValue) for each new slot and nothing else. A FuncRefTable slot is a Function struct with two coupled halves: m_function (a WasmOrJSImportableFunction carrying the rtt / target instance / entrypoint) and m_value (a WriteBarrier<Unknown> holding the JS wrapper). Table.grow filled only m_value, leaving m_function default-constructed with a null rtt.
Two consequences follow. First (functional/type bug): call_indirect into a grown slot dereferences the null rtt and traps with ‘signature does not match’ instead of dispatching to the fill function. Second (memory-safety bug): commit 313985@main introduced Function::isEmpty() defined as !m_function.rtt, and Table::visitAggregateImpl used if (slot.isEmpty()) continue; to skip GC visiting of empty slots. A slot populated only by Table::grow has a live wrapper in m_value but a null m_function.rtt, so isEmpty() returns true and visitAggregateImpl skipped visitor.append(slot.m_value). The wrapper’s only strong reference was that WriteBarrier, so the GC reclaimed a live object; a subsequent table.get(i) returned the dangling cell to JavaScript - a use-after-free of a freed JSCell fully controlled by script.
The Wasm bytecode (table.grow …) path is unaffected because it always passes jsNull to Table::grow and then populates both halves through tableSet, so only the JS API reached the buggy state.
The fix centralizes ‘set both halves with a write barrier’ into FuncRefTable::Function::setFunction(vm, owner, function), which sets m_function = function->importableFunction() and m_value.set(vm, owner, function). Table::grow’s funcref case now downcasts defaultValue to WebAssemblyFunctionBase (asserting it is that or null) and, when non-null, calls slot.setFunction, populating both halves consistently. FuncRefTable::setFunction is refactored to call the same helper. As defense-in-depth, visitAggregateImpl drops the if (slot.isEmpty()) continue; short-circuit so any slot with a live m_value but null m_function is still visited and kept alive.
Key code
Table::grow funcref case and visitAggregateImpl (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);
});
...
for (auto& instance : funcTable->m_instances) {
if (auto* strongReference = instance.get())
strongReference->updateCachedTable0();
}
...
// visitAggregateImpl:
for (unsigned i = 0; i < m_length; ++i) {
auto& slot = table->m_importableFunctions.get()[i];
visitor.append(slot.m_value);
visitor.append(slot.m_function.targetInstance);
visitor.append(slot.m_function.importFunction);
Patch walkthrough
Source/JavaScriptCore/wasm/WasmTable.cpp— Table::grow funcref case: instead of onlyslot.m_value.set(vm, m_owner, defaultValue), it downcasts defaultValue to WebAssemblyFunctionBase (ASSERT it is that or null), and if non-null calls slot.setFunction(vm, m_owner, defaultFunction) to populate both m_function and m_value (asserting the incoming slot’s m_value was null). visitAggregateImpl removes theif (slot.isEmpty()) continue;guard so every slot’s m_value/m_function fields are appended to the visitor. FuncRefTable::setFunction is rewritten to delegate to the new Function::setFunction; the definition of Function::setFunction sets m_function then m_value.set().Source/JavaScriptCore/wasm/WasmTable.h— FuncRefTable::Function gains avoid setFunction(VM&, JSCell* owner, WebAssemblyFunctionBase*)declaration; the data members m_function/m_value/m_padding are reordered to follow the methods (offsetOfFunction/offsetOfValue unchanged in meaning).JSTests/stress/wasm-funcref-table-grow-gc-marking-gap.js— Regression test isolating the marking gap: grows a funcref table 1000 times each with an ephemeral wrapper whose only strong ref is the slot’s m_value, runs fullGC(), then asserts every table.get(i) is still a callable function returning its expected constant - pre-patch these were reclaimed dangling cells.JSTests/wasm/stress/grow-funcref-table-with-default.js— Regression test covering both the call_indirect-after-grow functional bug and the GC-preserves-wrapper memory-safety bug via the JS Table.grow API, plus a null-default case that must trap on call_indirect.
Background
FuncRefTable::Function slot — Each funcref table entry is a Function struct pairing m_function (WasmOrJSImportableFunction: rtt, target instance, entrypoint used by call_indirect) with m_value (a WriteBarrier<Unknown> holding the JS-visible wrapper). The two must be kept consistent; call_indirect reads m_function while table.get reads m_value.
Function::isEmpty() — Introduced in 313985@main as return !m_function.rtt;. It was intended to identify never-populated slots, but it keys emptiness off m_function alone, so a slot with a live m_value but unset m_function.rtt is misclassified as empty - the root of the marking gap.
visitAggregateImpl / GC marking — The table’s GC visit routine appends each slot’s barriers to the marking visitor so referenced instances and wrappers survive collection. Skipping a slot via isEmpty() means its m_value wrapper is never marked; if that WriteBarrier is the wrapper’s only strong reference, the wrapper is freed.
WriteBarrier and write barriers — WriteBarrier<Unknown>::set(vm, owner, value) both stores the pointer and informs the GC of the owner->value edge. The new setFunction ensures m_value is set with a proper barrier from the same place that fills m_function, so the edge is always registered when a slot is populated.
Vulnerability window
- Precondition — Commit 313985@main adds Function::isEmpty()==!m_function.rtt and the visitAggregateImpl isEmpty() skip.
- Trigger — Script calls WebAssembly.Table.prototype.grow(delta, fn) on a funcref table with a real function fill value, routing to Wasm::Table::grow with non-null defaultValue.
- Half-init — Table::grow writes only slot.m_value (the live wrapper) and leaves m_function.rtt null.
- GC marking gap — visitAggregateImpl sees isEmpty()==true for the grown slot and skips visitor.append(slot.m_value); the wrapper, referenced only by that slot, is reclaimed by fullGC/collection.
- UAF — table.get(i) returns the freed wrapper as a dangling JSCell to script (and call_indirect traps on null rtt).
- Fix — setFunction populates both halves with a barrier and the isEmpty() skip is removed from visitAggregateImpl.
Proof of concept
VERBATIM excerpt of the added JSTests/stress/wasm-funcref-table-grow-gc-marking-gap.js (makeModule elided for length; it emits a tiny module whose exported f returns its constant). It grows a funcref table 1000 times, each time with a wrapper from a fresh instance whose only remaining strong reference is the slot’s m_value barrier, forces fullGC(), and then asserts every table.get(i) is still a live callable returning its constant. Pre-patch, the marking gap frees these wrappers and table.get yields dangling cells.
// Regression test: Wasm::Table::grow on a funcref table with a non-null
// default left slots in an inconsistent state where m_value held a live
// WebAssemblyFunction wrapper but m_function.rtt was null. Because
// Function::isEmpty() == !m_function.rtt and visitAggregateImpl skipped
// visiting m_value on "empty" slots, the wrapper went unmarked, GC freed
// it, and table.get returned a dangling pointer.
if (!this.WebAssembly)
quit(0);
function shouldBe(actual, expected) {
if (actual !== expected)
throw new Error("bad value: " + actual + " (expected " + expected + ")");
}
const N = 1000;
const table = new WebAssembly.Table({ element: "funcref", initial: 0 });
// Each slot's only strong reference is the m_value WriteBarrier on the slot
// itself; the `fn` local goes dead between iterations.
for (let i = 0; i < N; ++i) {
const fn = new WebAssembly.Instance(makeModule(i)).exports.f;
table.grow(1, fn);
}
fullGC();
for (let i = 0; i < N; ++i) {
const stale = table.get(i);
if (typeof stale !== "function")
throw new Error("table.get(" + i + ") was not a function: " + stale);
shouldBe(stale(), i);
}
Exploitation
- Reachability — WebAssembly.Table with element ‘funcref’ and grow(delta, fn) are standard JS-API surface; the buggy path is reached purely from script.
- Freeing the live wrapper — Populate slots via table.grow(1, fn) with wrappers held only by the slot, then force GC; the marking gap reclaims the wrappers while the slots still ‘contain’ them.
- Dangling cell to script — table.get(i) hands the freed WebAssemblyFunction cell back to JavaScript. This is a genuine UAF primitive: reclaiming the freed cell’s memory with a controlled object before calling/inspecting it would yield type confusion, though the shipped tests only detect the dangling state and do not weaponize it.
Detection & hunting
For defenders and SOC / detection engineers:
- ASAN UAF from table.get result —
- Wasm RuntimeError ‘signature does not match’ —
- GC verifier / marking-gap assertions —
Audit directions
- All slot-populating paths —
- isEmpty()-gated marking elsewhere —
- Function::isEmpty() consumers —
- JS-API vs bytecode divergence —