How the Ladybird‑WASM‑ESM‑Host‑Function‑RCE‑PoC Achieves Remote Code Execution
The PoC exploits a dangling FunctionType reference in JavaScript host functions, a stale Wasm register in the bytecode interpreter, and a Memory64 leak via ImageData/WebGL to achieve full native code execution in Ladybird's WebContent process.
The ladybird-wasm-esm-host-function-rce-poc demonstrates a complete browser exploit chain against Ladybird's WebAssembly implementation. By chaining three independent primitives—a use‑after‑free in host‑function type handling, a register lifetime bug in the Wasm interpreter, and a type confusion in memory‑backed ImageData—the proof‑of‑concept turns a Wasm bug into arbitrary native code execution. This article breaks down each stage of the exploit with direct references to the source code in bikini/exploitarium.
Vulnerability 1: Dangling FunctionType Reference in Host Functions
Ladybird's WebAssembly host function creation captures the Wasm::FunctionType by reference rather than by value. In WebAssembly.cpp, when Detail::create_host_function is invoked, it stores Wasm::FunctionType const& type as a member of the generated host callback.
The ESM import path constructs this FunctionType as a stack‑local variable. Once the caller returns, the host callback retains a reference to now‑dead stack memory. When the callback later accesses type.parameters() or type.results(), it reads from freed stack space—classic use‑after‑free behavior.
From the PoC README lines 59‑82, this primitive manifests as:
// Import a JS function with a static result type "arrayref"
WebAssembly.instantiateStreaming(fetch("module.wasm"), {
env: { host_func: () => {} } // host_func captures dangling type reference
});
The host_func callback, when later invoked from Wasm, dereferences the stale FunctionType pointer to determine expected argument and return types.
Vulnerability 2: Stale Register + Unchecked GC Array Write
Ladybird's Wasm interpreter in BytecodeInterpreter.cpp fails to validate that the host function's actual return vector length matches the static type.results() length. When the stale type.results() indicates a non‑empty result but the host returns an empty vector, the destination register preserves its previous attacker‑controlled value.
This stale register becomes the "abstract reference" for a subsequent array.set operation. The interpreter performs only a null‑check on the reference's low bits before writing, trusting the value as a valid Wasm::ArrayInstance pointer.
From lines 92‑104 of the README:
- Host callback returns empty vector despite
type.results()claiming[arrayref] - Interpreter writes stale register value into result slot
array.setinterprets stale value asArrayInstance*, enabling arbitrary address write
The attacker controls the stale register's value through prior Wasm execution, achieving a constrained but powerful write primitive.
Vulnerability 3: Memory64 Leak via ImageData and WebGL
Ladybird's ImageData implementation wraps Uint8ClampedArray backing stores through Bitmap::create_wrapper, storing the raw pointer without ownership. When a Wasm Memory64 instance grows, its backing store reallocates to a new address, but existing ImageData objects continue pointing to the old region.
The PoC fills the abandoned memory region with JavaScript objects and Wasm GC objects, then uses WebGL2RenderingContext.texImage2D followed by readPixels to exfiltrate pointer‑shaped values.
From lines 13‑36:
// Wrap a Uint8ClampedArray in ImageData
const img = new ImageData(new Uint8ClampedArray(buf), width, height);
// Old backing store pointer cached in ImageData
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);
// Memory64 grow moves backing store; img still points to old region
memory.grow(1);
// Fill old region with objects, then read back via WebGL
gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, leakBuf);
This leak yields:
ArrayBufferobject addresses- Backing store pointers
Wasm::ArrayInstancelocationsliblagom-wasmbase address- libc base via
__cxa_finalize@GLOB_DAT
Full Exploit Chain: From Wasm to Native Code Execution
The PoC combines these primitives into a complete RCE sequence (lines 41‑49 of the README):
-
Build fake
ArrayInstance— craft a structure whose element pointer targets attacker‑chosen native memory using leaked heap addresses. -
Trigger host‑function bug — import JavaScript function returning
arrayref, invoke with ESM path to activate danglingFunctionTypereference. -
Force stale register — return zero Wasm values from host callback, preserving attacker‑controlled register content.
-
Arbitrary write via
array.set— use the stale register as fakeArrayInstance*, write through crafted element pointer. -
Retarget
DataView— modify aDataView's backing store pointer to achieve arbitrary native read/write. -
Resolve library bases — parse leaked pointers to locate
liblagom-wasmand libc. -
Forge virtual dispatch table — construct fake vtable and
setcontextframe for stack pivot. -
Trigger WebGL virtual call — execute controlled virtual function that ultimately runs:
const commandBytes = new TextEncoder().encode("touch /tmp/ladybird_wasm_esm_rce\0");
The final WebGL call executes the marker command, proving native code execution within the Ladybird browser process.
Key Source Files in bikini/exploitarium
| File | Purpose | Location |
|---|---|---|
poc.html |
Standalone exploit driver containing payload, Wasm instantiation logic, and WebGL leak implementation | ladybird-wasm-esm-host-function-rce-poc/poc.html |
README.md |
Complete reproduction notes, source code traces, and architectural vulnerability analysis | ladybird-wasm-esm-host-function-rce-poc/README.md |
Both files are available at the repository root: https://github.com/bikini/exploitarium/tree/main/ladybird-wasm-esm-host-function-rce-poc
Summary
-
Root cause: Ladybird's
WebAssembly.cppcapturesFunctionTypeby reference in host functions, creating a use‑after‑free when the ESM import path uses stack‑allocated types. -
Intermediate primitive: The Wasm interpreter in
BytecodeInterpreter.cppfails to validate host return vector length, enabling stale register exploitation for type confusion. -
Address leak: Memory64 growth combined with
ImageData/Bitmap::create_wrapperpointer caching creates a reliable infoleak via WebGL texture operations. -
Final execution: Fake
ArrayInstanceconstruction → arbitrary write →DataViewbacking store hijack → libc resolution → fake vtable +setcontext→ WebGL virtual call hijacking. -
Impact: Full remote code execution in Ladybird's WebContent process, demonstrated by arbitrary file creation.
Frequently Asked Questions
How does the dangling FunctionType reference occur in Ladybird?
Ladybird's WebAssembly.cpp stores Wasm::FunctionType const& type as a reference in generated host callbacks. The ESM import path creates this FunctionType as a stack‑local variable. After the import call returns, the callback holds a dangling reference to freed stack memory. Subsequent accesses to type.parameters() or type.results() read stale or attacker‑controlled data.
Why does the stale register enable arbitrary memory writes?
The Wasm interpreter trusts the length from type.results() without checking the actual returned vector length. When the host returns an empty vector but type.results() claims a non‑empty result, the destination register retains its previous value. This stale value becomes an "abstract reference" that passes the interpreter's null‑check, allowing array.set to write through an attacker‑controlled pointer.
What makes the Memory64 ImageData leak reliable?
Bitmap::create_wrapper caches the raw pointer to a Uint8ClampedArray backing store without ownership semantics. Memory64 growth reallocates the backing store, but existing ImageData objects maintain the old pointer. By filling the abandoned region with objects and reading via texImage2D/readPixels, the attacker obtains pointer values with deterministic memory layouts.
Can this exploit chain work without the WebGL component?
No—the WebGL leak is essential for defeating ASLR. The exploit requires leaked pointers to liblagom-wasm and libc bases to construct the fake virtual table and setcontext frame. Without address disclosure, the final arbitrary write primitive cannot be targeted to executable or writable regions necessary for code execution.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →