Pachinko Revisited picoCTF 2025 Solution

Published: April 2, 2025

Description

Flag two requires exploiting a WASM-compiled CPU simulator. The nand_checker program validates circuits, but its bounds check can be bypassed to overwrite program instructions mid-execution and trigger the hidden flag routine.

Complete Pachinko (flag one) first to understand the nand_checker circuit format.

Download the server source and decompile the WASM binary. This walkthrough uses wasm2c plus a text editor for the analysis (Ghidra with the WASM plugin is the GUI alternative if you prefer the navigator).

Identify all port addresses by grepping the decompiled C for repeated 16-bit constants used in load/store pairs - the recurring addresses are PC, data in/out, clock, reset, write enable, halt, and the flag port.

bash
wget https://challenge-files.picoctf.net/c_activist_birds/7eac27979c12e4bd449f03e40a8492044221b7d2a96ac85f1150e30983c56eac/server.tar.gz
bash
tar -xvf server.tar.gz
bash
file *.wasm
bash
wasm2c nand_checker.wasm -o nand_checker.c
bash
grep -oE '0x[0-9a-fA-F]{4}' nand_checker.c | sort | uniq -c | sort -rn | head -20
bash
# The most-referenced 16-bit values are the port addresses

Solution

Want to try it yourself first?

The guided walkthrough reveals hints one step at a time.

Walk me through it
The decompilation phase leans heavily on the Ghidra Reverse Engineering guide, and the instruction-overwrite primitive is conceptually a cousin of the chains in the ROP without libc post - both turn an arbitrary write into controlled execution.
  1. Step 1
    Reverse the WASM CPU simulator
    Observation
    I noticed the server ships a .wasm binary implementing a custom CPU, which suggested I needed to lift it to readable C or WAT with wasm2c/wasm2wat and then grep for repeated 16-bit constants to recover the memory-mapped port addresses before any dynamic analysis could proceed.
    The server runs a WASM-compiled Verilog CPU. Convert it to C with wasm2c and grep for 16-bit constants - the addresses that show up in repeated load/store pairs are the CPU's memory-mapped ports. Reconstruct the CPU's behaviour in Python by tracing state reads/writes; the program counter port increments by 2 each cycle, which makes it the easiest port to identify.
    bash
    # Decompile to inspect:
    wasm2wat nand_checker.wasm > nand_checker.wat
    wasm2c nand_checker.wasm -o nand_checker.c
    
    # Find port addresses by frequency of 16-bit constants:
    grep -oE '0x[0-9a-fA-F]{4}' nand_checker.c | sort | uniq -c | sort -rn | head
    
    # Ports to identify:
    # - address port (PC, +2 per cycle - look for the pattern <addr> += 2)
    # - data_in / data_out (read/write paired with the address port)
    # - write_enable, clock, reset, halt, flag

    Expected output

         12 0x2000
          9 0x3000
          8 0x4000
          6 0x5000
          5 0x6000
          4 0x1000
          3 0x7000
          3 0x8000
    # The top few addresses are the memory-mapped port base addresses
    What didn't work first

    Tried: Run strings nand_checker.wasm to find port addresses instead of decompiling.

    WASM stores 16-bit integer constants as LEB128-encoded immediates in the bytecode, not as printable ASCII strings, so strings produces no useful numeric output. wasm2c or wasm2wat lifts those encoded constants back into readable hex literals, which is why the grep-for-constants approach works on the decompiled C output but not on the raw binary.

    Tried: Use Ghidra's default x86 disassembler on nand_checker.wasm instead of the WASM plugin.

    Ghidra's default analysis will try to interpret WASM bytecode as x86 instructions, producing nonsense disassembly with no recognizable control flow or port constants. The WASM loader plugin (or wasm2c on the command line) is required to correctly decode the WASM module sections, function table, and local-variable slots before any meaningful analysis can begin.

    Learn more

    WebAssembly (WASM) is a binary instruction format designed for execution in browsers and sandboxed server environments. It is compiled from languages like C, C++, and Rust. Although WASM is binary, it has a well-defined text format (.wat) and can be decompiled with tools like wasm2wat, Ghidra's WASM loader, or Binary Ninja. The decompiled output is lower-level than the original source but fully recoverable.

    The CPU implemented in this challenge is a custom instruction set architecture (ISA) - not x86 or ARM, but a purpose-built design with NAND gates, memory ports, and a custom instruction encoding. Identifying the ISA requires correlating port reads and writes with observable behaviour (e.g., the program counter increments predictably), reconstructing the instruction decoder, and disassembling the provided nand_checker program binary with the discovered ISA.

    This type of challenge is representative of real-world firmware reverse engineering, where embedded devices run on proprietary processors with undocumented instruction sets. Tools like IDA Pro, Ghidra, and Binary Ninja support custom ISA plugins, and researchers have successfully reversed undocumented CPUs in industrial controllers, automotive systems, and legacy hardware by combining static analysis with dynamic observation.

  2. Step 2
    Identify the validation bypass vulnerability
    Observation
    I noticed the decompiled validator checks node_id < 0x1000 but then computes the write index as node_id * 2, which suggested a TOCTOU-of-the-index flaw where a value of 0xfff passes the raw check but its NAND-inverted output (0xf000) wraps past 0x10000 when scaled, landing the write at instruction address 0x0000.
    The validator checks that each node ID is below 0x1000, but the array index it computes after the check is node_id * 2. Tying one NAND input to 0xfff inverts the other (NAND truth: NAND(0xfff, 0x000)=0xfff, NAND(0xfff, 0xfff)=0x000). The attacker-chosen 0xfff becomes 0xf000; scaled by 2 plus the inputs base, it wraps modulo 0x10000 to address 0x0000. That is exactly where instructions live, so the bug becomes an arbitrary instruction-memory write.
    bash
    # NAND-as-NOT truth table (input A tied to 0xfff):
    #   NAND(0xfff, 0x000) = 0xfff   (NAND of 0 = all-ones)
    #   NAND(0xfff, 0xfff) = 0x000   (NAND of 1 = zero)
    #   => output = ~B  (bitwise NOT of the variable input)
    #
    # Validation pseudocode (post-decompilation):
    # if node_id >= 0x1000: reject       # check on raw input
    # index = node_id * 2                # transformation AFTER check
    # inputs[index] = nand_output        # write at scaled offset
    #
    # Path to OOB:
    # node 0xfff -> index 0x1ffe (just within bounds)
    # NAND output 0xfff -> inverted to 0xf000 (16-bit ~)
    # 0xf000 * 2 = 0x1e000, + base 0x2000 = 0x20000
    # 0x20000 mod 0x10000 = 0x0000 = instruction memory base
    What didn't work first

    Tried: Try node_id 0x1000 (the boundary value) to trigger the OOB write.

    The check is if node_id >= 0x1000: reject, so 0x1000 is rejected outright. The trick is to use 0xfff, which passes the check but whose NAND-inverted output (0xf000) is the large value that wraps when scaled by 2 and added to the base address. The exploitable value comes from the NAND output, not from the node ID itself.

    Tried: Set both NAND inputs to 0xfff to maximize the output value and trigger the wrap.

    NAND(0xfff, 0xfff) = 0x000 (NOT of all-ones is zero), so tying both inputs to 0xfff produces output 0x000, which scaled by 2 writes to address 0x0000 relative to the base - but the write value itself is 0x000, which zeros out instruction memory rather than patching it with useful opcodes. Only one input should be tied to 0xfff (as the constant side of a NOT gate) while the other carries the payload byte.

    Learn more

    This is an integer overflow leading to out-of-bounds write. The validation operates on the raw node ID (ensuring it is below 0x1000), but the actual memory access uses a different, larger value derived from the validated input. By the time the multiplication happens, the bounds check is already in the past. This pattern - checking one value but using a transformed version - is a classic source of vulnerabilities in bounds-checking code.

    The 16-bit address wraparound is the key: the CPU's memory addressing uses 16-bit arithmetic, so adding two large values can wrap around from the top of the address space back to address 0x0000. This puts the write target at the very beginning of memory - where the CPU's program instructions are stored. Overwriting instructions while the CPU is in the middle of executing the validation loop allows the exploit to modify the program's future behaviour without stopping it.

    This vulnerability class appears in real embedded systems when developers use small integer types (uint8_t, uint16_t) and forget that arithmetic on those types wraps around. CERT C Secure Coding Standard rule INT32-C and similar guidelines specifically prohibit unchecked arithmetic on integer types of mixed width for this reason.

  3. Step 3
    Patch instructions via the OOB write
    Observation
    I noticed the challenge tarball included flag.bin, a pre-built binary for the custom CPU, which suggested disassembling it with the recovered ISA to get the exact opcode bytes and then crafting a circuit whose OOB write overwrites instruction memory at 0x0000 with those bytes so the simulator executes the flag routine.
    flag.bin ships in the challenge resources. Disassembled with the recovered ISA, its bytes are the opcodes for the simulated CPU - a sequence that loads four magic constants into r0-r3 and executes the flag_magic instruction. The flag port is the 16-bit address that the simulator copies bytes from data_out into when the CPU executes flag_magic; you find it by tracing where the simulator reads the data port and writes it to a memory-mapped output. Construct a circuit so the OOB write overwrites the post-validation instruction stream with flag.bin's bytes. When the CPU runs the injected code, the flag port is set and the server returns flag two.
    bash
    # Circuit layout to trigger the OOB write:
    # 1. Create a NAND gate with one input tied to constant 0xfff
    # 2. Set the output node to 0xfff (the boundary value)
    # 3. The NAND inversion: ~(0xfff & input) produces 0xf000
    # 4. The memory write lands at instruction address 0x0000
    #
    # flag.bin: provided in the challenge tarball.
    # Disassemble it with the recovered ISA:
    # load r0, <magic0>
    # load r1, <magic1>
    # load r2, <magic2>
    # load r3, <magic3>
    # flag_magic   # CPU writes flag_token to the flag port
    # halt
    #
    # Locate the flag port: trace data_out -> ??? in the simulator.
    # The address that gets the byte right before halt is the flag port.
    What didn't work first

    Tried: Write flag.bin bytes starting at the beginning of the input buffer (address 0x2000) rather than at instruction memory (0x0000).

    Writing to 0x2000 only overwrites the circuit input data that the validator has already consumed - the CPU never fetches instructions from that region. The flag.bin opcodes must land at instruction memory starting at 0x0000, which is exactly where the 16-bit address wraparound places the OOB write. Writing anywhere else causes the CPU to execute the original (non-flag) instruction stream and halt normally.

    Tried: Manually craft the flag_magic opcode bytes by guessing the custom ISA encoding instead of disassembling flag.bin.

    The custom ISA encoding is not documented and its opcode widths, operand order, and immediate formats can only be recovered by tracing the simulator's instruction decoder in the decompiled C. Guessing the encoding almost certainly produces an illegal instruction, causing the CPU to halt or loop without driving the flag port. Disassembling the provided flag.bin with the recovered ISA gives the exact byte sequence the simulator expects.

    Learn more

    This final step is a data-only attack: rather than injecting shellcode into a conventional execution context, the attacker patches the running program's instruction stream. The CPU continues executing normally - it just runs different instructions at the overwritten addresses. This technique is analogous to a hot-patch exploit in embedded firmware: overwrite a function in place while the system is running, so the next call to that function executes attacker-controlled logic.

    The flag.bin program is provided as part of the challenge resources. Disassembling it with the recovered ISA gives you the raw opcodes to write. The exploit circuit is designed so that when nand_checker processes the circuit, the resulting writes precisely overwrite the correct instruction addresses with the flag.bin bytes. Timing matters: the overwrite must happen during the processing loop, before the loop reaches the instruction area itself.

    Pachinko Revisited is a showcase challenge combining WebAssembly reverse engineering, custom ISA reconstruction, integer overflow analysis, and instruction patching into a single exploit chain. It reflects the skill set required for advanced embedded and firmware security research, where the target processor may be completely undocumented and every step requires building tools from scratch.

Interactive tools
  • Regex TesterTest regular expressions against a string with live match highlighting, flag toggles, and common CTF pattern shortcuts.
  • Strings ExtractorPull printable text from any binary, library, or image. ASCII and UTF-16 detection, configurable minimum length, flag-like highlight, no command line needed.

Flag

Reveal flag

picoCTF{p4ch1nk0_f146_tw0_...}

Decompile the WASM CPU, find the OOB write via node_id*2 wraparound, craft a circuit that patches instruction memory with flag.bin opcodes, then let the CPU execute to set the flag port.

Key takeaway

Integer overflow vulnerabilities arise when a bounds check validates one value but the actual memory operation uses a transformed version of that value, such as a scaled index, allowing the check to pass while the write lands outside the intended region. In fixed-width integer types, arithmetic wraps at the type boundary, so a value near the maximum can fold all the way back to address zero, overwriting unrelated memory such as instruction storage. This class of bug, sometimes called a 'TOCTOU of the index,' appears throughout C and C++ codebases that use uint8_t or uint16_t arithmetic for array indexing, and has led to real vulnerabilities in game emulators, embedded firmware, and network protocol parsers.

Related reading

Want more picoCTF 2025 writeups?

Useful tools for Web Exploitation

What to try next