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.
wget https://challenge-files.picoctf.net/c_activist_birds/7eac27979c12e4bd449f03e40a8492044221b7d2a96ac85f1150e30983c56eac/server.tar.gztar -xvf server.tar.gzfile *.wasmwasm2c nand_checker.wasm -o nand_checker.cgrep -oE '0x[0-9a-fA-F]{4}' nand_checker.c | sort | uniq -c | sort -rn | head -20# The most-referenced 16-bit values are the port addressesSolution
Want to try it yourself first?
The guided walkthrough reveals hints one step at a time.
Step 1Reverse the WASM CPU simulator
ObservationThe server ships a .wasm binary implementing a custom CPU. Lift it to readable C or WAT with wasm2c or wasm2wat, then grep for repeated 16-bit constants to recover the memory-mapped port addresses.The server runs a WASM-compiled Verilog CPU. Convert it to C withwasm2cand 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, flagExpected 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 addressesWhat didn't work first
Tried: Run
strings nand_checker.wasmto find port addresses instead of decompiling.WASM stores 16-bit constants as LEB128-encoded immediates, not printable text, so strings yields nothing numeric. wasm2c and wasm2wat lift them back into readable hex literals, which is why grepping for constants works on the decompiled output and not on the raw binary.
Tried: Use Ghidra's default x86 disassembler on nand_checker.wasm instead of the WASM plugin.
By default Ghidra reads WASM bytecode as x86, producing nonsense with no recognizable control flow or port constants. The WASM loader plugin, or wasm2c on the command line, decodes the module sections, function table, and local slots properly.
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 likewasm2wat, 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_checkerprogram 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.
Step 2Identify the validation bypass vulnerability
ObservationThe validator checks node_id against 0x1000 but then computes the write index as node_id times 2. The check and the operation see different values. A node id of 0xfff passes, while its NAND-inverted output of 0xf000 wraps past 0x10000 once scaled, putting the write at instruction address zero.The validator checks that each node ID is below 0x1000, but the array index it computes after the check isnode_id * 2. Wires are 16 bits wide, so a NAND with both inputs tied to node 0xfff returns ~(0x0fff & 0x0fff) = 0xf000. The node id 0xfff passes the check, and the 0xf000 it produces, scaled by 2 and added to the inputs base, wraps modulo 0x10000 to address 0x0000. That is exactly where instructions live, so the bug becomes an arbitrary instruction-memory write.bash# NAND is a 16-bit NOT when both inputs carry the same value: # NAND(0x0000, 0x0000) = 0xffff # NAND(0x0fff, 0x0fff) = 0xf000 (~0x0fff in 16 bits) # => output = ~B (bitwise NOT of the shared 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 = ~0x0fff = 0xf000 (16-bit inversion) # 0xf000 * 2 = 0x1e000, + base 0x2000 = 0x20000 # 0x20000 mod 0x10000 = 0x0000 = instruction memory baseWhat didn't work first
Tried: Try node_id 0x1000 (the boundary value) to trigger the OOB write.
The check rejects anything at or above 0x1000, so 0x1000 itself never gets through. Use 0xfff: it passes, and its NAND-inverted output of 0xf000 is the large value that wraps once scaled and added to the base. The exploitable number is the NAND output, not the node id.
Tried: Tie both NAND inputs to 0x000 so the inversion returns the largest possible value, 0xffff.
0xffff is the larger output, but the wrap has to land exactly on instruction memory. 0xffff * 2 = 0x1fffe, and adding the 0x2000 base gives 0x21ffe, which wraps to 0x1ffe rather than 0x0000: the write lands at the very top of instruction memory, past the code the CPU actually fetches, so the original instruction stream runs unchanged. Only 0xf000, the inversion of 0x0fff, scales and wraps to address zero.
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 INT30-C (ensure that unsigned integer operations do not wrap) exists precisely to catch this.
Step 3Patch instructions via the OOB write
ObservationThe tarball includes flag.bin, a prebuilt binary for this CPU. Disassemble it with the recovered instruction set for the exact opcode bytes, then build a circuit whose out-of-bounds write drops those bytes at address zero.flag.binships 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 theflag_magicinstruction. The flag port is the 16-bit address that the simulator copies bytes from data_out into when the CPU executesflag_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 both inputs tied to node 0x0fff # 2. 0x0fff is below 0x1000, so it passes the bounds check # 3. The NAND inversion: ~(0x0fff & 0x0fff) = 0xf000 # 4. 0xf000 * 2 + 0x2000 wraps to 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 circuit input the validator has already read; the CPU never fetches instructions from there. The opcodes have to land at instruction memory starting at zero, which is exactly where the 16-bit wraparound puts the write. Anywhere else and the CPU runs the original instruction stream and halts normally.
Tried: Manually craft the flag_magic opcode bytes by guessing the custom ISA encoding instead of disassembling flag.bin.
Nothing documents this instruction set: opcode widths, operand order, and immediate formats come only from tracing the simulator's decoder in the decompiled C. Guess at the encoding and you almost certainly emit an illegal instruction, and the CPU halts or loops without ever driving the flag port. Disassembling flag.bin gives the exact bytes it 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.binprogram 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
- 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.
- 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.
- 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{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.