Minhal's Blog

Back

V8!

Challenge description#

This writeup documents a beginner-friendly V8 exploitation challenge delivered as a d8/V8 binary inside a Docker + QEMU environment. The provided target is a patched V8/d8 that exposes several helper builtins (addrof, backdoor, fakeobj, read64, write64). The goal is to use those primitives — or to construct your own primitives from them — to achieve code execution and spawn a shell. The challenge is aimed at newcomers to V8 exploitation: it includes a direct (backdoor) path to a shell and more advanced paths (WASM RWX, fake-object read/write) for deeper learning.


Preface#

I was fortunate to receive this challenge. Although it is not long, working through it taught me a great deal. The challenge ships with guidance files that make it straightforward to get started with V8 exploitation; those materials helped me a lot. During the process I encountered many error messages and solved problem after problem — the failures themselves turned out to be great learning opportunities.


Environment setup#

After unpacking the challenge I realised it revolves around V8. To run QEMU inside Docker I needed Docker and QEMU available locally. The installation was not difficult — a few web searches provided the necessary steps. After installing the prerequisites I followed the repository README to bring up the environment.

Debugging environment configuration#

I configured the debugging environment as follows:

cp v8/tools/gdbinit gdbinit_v8
$ cat ~/.gdbinit
source /home/ubuntu/pwndbg/gdbinit.py
source path/gdbinit_v8
bash

With that in place V8 debugging works with pwndbg and V8-specific helpers. I used commands such as %DebugPrint and %BreakPoint in code inspection. When running d8 for debugging I passed --allow-natives-syntax.


Exploit#

Research#

This was my first V8 challenge, so initially I felt a bit lost. Fortunately, there is abundant public material on V8 exploitation; I found a 2019 CTF challenge with strong similarities which provided useful ideas and references.

Analyzing the patch#

Per the provided PDF guidance and my searches, the first step is to inspect the patch. The patch adds several helper builtins — addrof, backdoor, fakeobj, write64, read64 — which immediately suggested straightforward primitives. The Helper PDF also mentions using the backdoor function, so I analysed these new builtins one by one.

GlobalBackdoor accepts an object, extracts its map field relative to the isolate base, then compares it to 0x13371337. If equal, it calls execv("/bin/bash", ...) — giving arbitrary code execution. In all cases it returns the map value as a double.

+BUILTIN(GlobalAddrof) {
+    HandleScope scope(isolate);
+    DCHECK_EQ(args.length(), 2);
+
+    Handle<Object> arg = args.atOrUndefined(isolate, 1);
+    if (arg->IsSmi()) {
+      return ReadOnlyRoots(isolate).undefined_value();
+    }
+
+    double bits = *reinterpret_cast<double*>(arg.address());
+    return *isolate->factory()->NewNumber(bits);
+}
c

GlobalAddrof returns the address (as a double) of the argument object (unless it is a Smi).

+BUILTIN(GlobalFakeobj) {
+    HandleScope scope(isolate);
+    DCHECK_EQ(args.length(), 2);
+
+    Handle<Object> arg = args.at(1);
+    ASSIGN_RETURN_FAILURE_ON_EXCEPTION(isolate, arg, Object::ToNumber(isolate, arg));
+
+    double bits = arg->Number();
+
+    return *reinterpret_cast<Object*>(&bits);
+}
c

GlobalFakeobj interprets a numeric value as a pointer and returns an object reference built from that raw bits value — i.e., it creates a fake object from a given address representation.

GlobalWrite64 / GlobalRead64 provide arbitrary 8-byte memory write/read primitives when given a numeric pointer encoded as a double.

Exploiting via backdoor()#

skeleton.js already implements much of the harness, so I only needed to implement a small portion. To trigger backdoor, I had to create an object and then modify its map pointer to 0x13371337 (or the compressed representation appropriate for that V8 build).

For a sample object:

let obj = {x: 'ls'};
js

I determined the layout of the object and where its map pointer lives by using the debugger and observing memory:

The map field is stored at the object address; when reading a pointer from memory I needed to subtract 1 to untag it. This is because V8 uses tagged pointers — the PDF guidance covers pointer tagging and compression.

So the plan is straightforward:

  1. Use the patched addrof to obtain the address of obj.
  2. Untag / convert the value to a raw address.
  3. Use write64 to overwrite the map field with the target map value (compressed or uncompressed as appropriate).
  4. Call backdoor(obj) to trigger the execv() condition.

Depending on pointer compression in the build, the write can be performed in either compressed or uncompressed form.

Uncompressed example:

let addr = float2num(addrof(obj)) - 1n;
let b = my_write64(addr, 0x13371337n);
js

Compressed example (where pointers are compressed relative to isolate):

let addr = float2num(addrof(obj)) - 1n;
print("0x" + addr.toString(16));
let a = my_read64(addr);
let isolate = float2num(addrof(obj)) & ~(0xFFFFFFFFn);
let ptr = isolate + 0x13371337n;
print('---------ptr------' + ptr.toString(16));
let b = my_write64(addr, 0x13371337n);
print(b);
print('-----------------');
js

I implemented my_write64 as a thin wrapper around the patched write64:

function my_write64(addr, value) {
    return float2num(write64(num2float(addr), num2float(value)));
}
js

Finally, call the backdoor:

let ret = backdoor(obj);
js

The run produced a shell with root privileges:

A complete exploit (backdoor approach) follows.

Alternative: not using backdoor()#

The patch provides read64 and write64, so one can exploit the engine without calling backdoor. Two common routes:

  1. Leak obj address and locate memory regions / d8 loading addresses to derive further leaks (for example, a table pointer to leak libc or other modules).
  2. Use a WebAssembly (WASM) RWX region to place shellcode and execute it by hijacking a WASM function’s code region.

I chose the second approach as it is convenient when an RWX JIT page exists.

The strategy:

  • Load a small WASM module.
  • Use addrof on the WASM function to locate shared_info, WasmExportedFunctionData, and the instance.
  • From the instance structure, resolve the RWX code page and write shellcode into it (or redirect an ArrayBuffer backing store to that RWX page), then call the WASM function to execute the shellcode.

Example WASM stub and workflow:

var wasmCode = new Uint8Array([0,97,115,109,1,0,0,0,1,133,128,128,128,0,1,96,0,1,
127,3,130,128,128,128,0,1,0,4,132,128,128,128,0,1,112,0,0,5,131,128,128,128,0,
1,0,1,6,129,128,128,128,0,0,7,145,128,128,128,0,2,6,109,101,109,111,114,121,2,
0,4,109,97,105,110,0,0,10,138,128,128,128,0,1,132,128,128,128,0,0,65,10,11]);
var wasmModule = new WebAssembly.Module(wasmCode);
var wasmInstance = new WebAssembly.Instance(wasmModule, {});
var funcAsm = wasmInstance.exports.main;
js

I traced the object relationships:

Function -> shared_info -> WasmExportedFunctionData -> instance

Using %DebugPrint / telescope and manual pointer arithmetic I extracted offsets for that particular V8 build (note: offsets vary between builds). For my target, I found the offsets were at +0xc, +0x4, +0x8, and the RWX pointer at +0x68 relative to the located structures.

To discover the RWX page I used vmmap and compared the memory mappings before and after the WASM function compilation. A new RWX region of 0x1000 appeared at the start address 0x71b9b403000 (addresses differ by environment). By zeroing the first portion of the page and hitting a breakpoint around WASM function compilation, I confirmed the start address of the generated code page.

I implemented a small helper addr(a, b) that reconstructs compressed pointers using a base address:

function addr(a, b) {
    return float2num(addrof(a)) & ~(0xFFFFFFFFn) | (b & 0xFFFFFFFn);
}
js

After resolving instanceAddr and reading memoryRWX = my_read64(instanceAddr + 0x68n - 0x1n), I obtained the RWX page address.

To write shellcode I redirected an ArrayBuffer backing store to the RWX page:

This produced a shell as shown:

Constructing generic read/write primitives (fake object technique)#

Another classical approach is constructing arbitrary read/write primitives via a fake object. The layout I used:

Steps:

  1. Use backdoor to get the map of an object (my_backdoor).
  2. Build a double_array whose second element will be used to hold a forged elements pointer that points into arbitrary memory.
  3. Use fakeobj to coerce that crafted pointer into an object reference and index it to perform read/write.

Wrappers:

function my_backdoor(value) {
    return float2num(backdoor(value));
}
function my_fakeobj(value) {
    return fakeobj(num2float(value));
}
js

Exploit snippet:

When I ran this on the challenge binary an error occurred:

I investigated with the debugger and eventually examined the V8 source for checks. The error appears to be a validation triggered in debug mode: builds compiled with debug checks reject an invalid elements pointer assignment. I found references online that in debug builds V8 performs stricter runtime checks; my exploit fails in the supplied debug build because of those checks.

I attempted to compile a non-debug (release) d8 locally to confirm whether the exploit would work in release mode. However, building V8 requires fetching dependencies from Google-hosted repositories, and from China those fetches often fail. I tried to configure a proxy but it did not succeed. I then asked a remote friend to attempt the build, but the compilation still failed — the Chromium/V8 build infrastructure has additional constraints (see linked discussion). Because of time constraints I did not complete the non-debug d8 build, so the hypothesis remains unconfirmed.

If debug is truly the limiting factor, the same technique would likely succeed on a normal release build without those runtime checks. It is also possible that a different vulnerability or nuance is required; I did not fully rule out other causes.


Summary#

This was an excellent challenge. The provided PDF and environment made the learning path smooth. The challenge designers included the simplest exploitation path (directly using a backdoor), then left room for deeper exploitation approaches: constructing shellcode via WASM RWX pages, or building generic read/write primitives without the provided patch functions.

Although I had limited time — I work during the day and attend evening English classes — I found myself thinking about the challenge during lunch and losing track of time while debugging. I learned the practical workflow of V8 exploitation: object layout, tagged pointers, compressed pointers, and V8 debugging techniques.

Overall, the experience was rewarding and educational. I am grateful for the opportunity.


Extra (build issues & notes)#

I suspect the elements assignment check is enforced by debug mode. All provided d8 binaries in the Docker images are debug builds, so I attempted to build a non-debug d8 myself to validate the hypothesis.

The build failed due to dependency fetch issues (Chromium/V8 uses Google-hosted infra). I attempted various proxy solutions and remote compilation via a friend’s machine, but the build still failed. See the Chromium dev group discussion for context.

Given time limitations I could not finish a release build, so I could not definitively confirm whether the exploit would work on a release d8. It remains a plausible explanation that debug checks prevented the fake elements approach from working in the supplied environment.


References#

A starter V8 challenge
https://minhal.me/blog/v8challenge
Author Minhal
Published at 2023年3月18日
Comment seems to stuck. Try to refresh?✨