Skip to main content

MATRIX picoMini by redpwn Solution

Reverse engineer a binary that runs code through a custom virtual machine with its own instruction set.

Published: April 2, 2026Updated: August 25, 2026

Description

Escape the matrix.

Download the binary from the challenge page.

bash
wget <challenge_url>/matrix  # download the binary

Solution

Want to try it yourself first?

The guided walkthrough reveals hints one step at a time.

Walk me through it

Despite the simple description, this challenge is a custom stack-based virtual machine. The binary interprets embedded bytecode that encodes a navigable maze. Solving it requires three phases: reverse-engineer the VM opcodes in Ghidra, write a Python emulator/disassembler to visualize the maze, then determine the correct navigation sequence and feed it to the binary to get the flag.

  1. Step 1Run the binary and observe interactive behavior
    Observation
    The binary is a stripped ELF with no obvious flag strings, so static inspection will not settle what it is. Run it and watch how it prompts.
    Make the binary executable and run it. It prompts for directional input and navigates through a maze structure. Valid movement characters are u (up), d (down), l (left), and r (right). The program accepts a sequence of these characters and reports whether you escaped the maze. This runtime behavior tells you that the binary is not a simple flag-comparison validator but a full interactive interpreter.
    bash
    file matrix
    bash
    chmod +x matrix
    bash
    ./matrix

    Expected output

    matrix: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, stripped
    What didn't work first

    Tried: Running strings on the binary hoping to find the flag or a hard-coded movement sequence

    strings prints noise and never the flag, which is assembled a character at a time at runtime by the output opcode. There is no hardcoded input either, since the path is validated as you walk it.

    Tried: Providing a random sequence of movement characters like 'rrrrrrrrrr' expecting to brute-force the exit

    The VM checks both position and a running health counter at each barrier. Reach the exit with the wrong health and it jumps past the flag routine and exits quietly. Knowing which path collects enough boosts requires reading the bytecode.

    Learn more

    When the binary runs, it does not just compare a password string against a stored constant. Instead, it reads directional characters one at a time and updates internal state. That pattern is the signature of an interpreter: there is a main loop that dispatches on instruction opcodes, and the "program" being interpreted is maze bytecode baked into the binary.

    Knowing this upfront shapes your entire analysis strategy in Ghidra. Rather than hunting for a comparison against a stored flag, you want to find the opcode dispatch function and reconstruct what each opcode does.

  2. Step 2Reverse-engineer the VM in Ghidra
    Observation
    It reads directional characters one at a time and responds to each, which makes it a custom interpreter rather than a password check. Find the opcode dispatch loop in Ghidra and reconstruct the instruction set.
    Load the binary into Ghidra, let auto-analysis run, then navigate to the main function and trace into the dispatch loop. The core function (named something like step() in clean decompilations) reads one byte from the bytecode array and branches on its value. Identify every opcode: stack operations (NOP, DUP, POP, ADD, SUB, SWP), alt-stack transfers (TO_ALT_STACK, FROM_ALT), control flow (JMP, JMP_IF_ZERO, JMP_IF_NOT_ZERO, JMP_IF_LESS_THAN_ZERO), I/O (GETCHAR, PUTCHAR), and data loading (GETVAL for 1-byte immediates, GET2VALS for 2-byte immediates).
    Learn more

    A stack-based VM keeps all state in a program counter and one or more stacks rather than general-purpose registers. When you see the decompiler produce a large switch on a single byte with arms that push/pop from arrays, you are looking at the opcode dispatcher.

    The two stacks here are the main computation stack and an alternate (temporary) stack. Control-flow opcodes like JMP_IF_ZERO check the top of the main stack and jump the program counter by a signed offset if the condition holds. This is how the maze implements checkpoint barriers: the bytecode pops your accumulated health counter and jumps past the exit if the counter is too low.

    Clean up Ghidra's decompiler output by renaming variables (pc, stack_top, opcode, etc.) until the logic is readable. That cleaned pseudo-C is what you will port to Python in the next step.

  3. Step 3Write a Python disassembler and visualize the maze
    Observation
    The bytecode contains character-output opcodes emitting ASCII one byte at a time, which means it encodes a printable maze grid. Port the step function to Python and run the emulator with tracing to see it.
    Port the Ghidra-cleaned step() function to Python. Extract the raw bytecode from the binary (it starts at roughly 0x1020f0 and ends near 0x1026c1 - confirm the exact range in Ghidra). Run your emulator over the bytecode and collect every PUTCHAR call. The output characters form an ASCII-art maze with walls (#), health-modifier cells (^ increases health, v decreases health), and an exit marker ($). Print the collected output to visualize the full 16x16 grid.
    bash
    # Extract raw bytecode bytes
    python
    python3 -c "data=open('matrix','rb').read(); print(data[0x1020f0:0x1026c2].hex())"
    bash
    # Then run your emulator script
    python
    python3 disasm.py

    The maze printout looks like a grid of #, ^, v, space, and $ characters. Walls are #; cells marked ^ add to your health counter when you step on them, cells marked v subtract. You must arrive at $ with enough health to pass five checkpoint barriers (each checks that the third value from the stack bottom is at least 5).

    What didn't work first

    Tried: Using the wrong bytecode offset range when extracting bytes, such as starting at 0x102000 instead of the confirmed 0x1020f0

    A wrong start offset drags in section header or symbol table bytes ahead of the bytecode. The emulator reads those as opcodes, hits an undefined one immediately, and crashes or prints garbage instead of the grid. Confirm the exact bounds of the bytecode array in the data segment.

    Tried: Emulating only PUTCHAR calls and ignoring the health-counter logic, treating the output as a pure ASCII maze to navigate by shortest path

    The shortest geometric route skips the health cells by design and fails every barrier while still reaching the exit first. The search state has to carry health alongside position, or the path it returns dies at the first checkpoint.

    Learn more

    The reason for writing your own emulator rather than just running the binary is control: you can add tracing, inspect the stack at each step, and test candidate paths without feeding characters interactively. A Python emulator also lets you implement a BFS or DFS over the maze state (position + health counter) to find the shortest valid path automatically.

    Once the maze is visualized, you can also solve it manually by tracing the path on paper. The key insight is that you need to visit enough ^ cells early in the route to build up sufficient health to clear all checkpoint barriers before reaching the exit.

  4. Step 4Determine the correct navigation sequence and get the flag
    Observation
    The maze holds cells that raise and lower health, and five checkpoints test the accumulated counter, so the shortest path fails. Search over position and health together to find one that clears every barrier.
    Once you have the maze layout, find a path from start to exit that keeps health at or above the checkpoint threshold. A working navigation sequence is: rrrrrlrlrlrlrllddddddlddrrddrrrrdddrruuuruuuuuuurrddddddddlddrd. Supply this string to the binary (followed by a newline) and the program prints the flag.
    bash
    echo 'rrrrrlrlrlrlrllddddddlddrrddrrrrdddrruuuruuuuuuurrddddddddlddrd' | ./matrix
    What didn't work first

    Tried: Typing the navigation sequence interactively character by character in the terminal instead of piping it via echo

    The VM reads input a byte at a time as it runs, so typing works in principle, and one mistyped character means restarting the whole sequence. Piping the string in delivers it exactly, which matters across 63 characters.

    Tried: Supplying a custom BFS-generated path that is geometrically shorter than the reference sequence

    A shorter route skips several health-boost cells in the early rows. Each of the five checkpoints requires the counter to be at least 5, so a shorter path fails one of them silently and produces no output, which looks exactly like a malformed input.

    Learn more

    The directional sequence works because it visits enough ^ cells to build a health counter above the minimum required at each of the five checkpoint barriers before arriving at the $ exit cell. Moving right-left in the early rows is not backtracking for its own sake - it is deliberately collecting health boosts so later barriers can be cleared.

    If you want to generate your own path, implement BFS with state (row, col, health). Health is clamped (it cannot go below zero) and the goal is any state where you reach the exit cell with health above the threshold. BFS guarantees the shortest solution; DFS finds any valid solution faster to implement.

Interactive tools
  • Base64 & Base32 DecoderDecode Base64 and Base32 strings with auto-detection. Multi-layer mode unwraps nested encodings automatically.
  • Recipe ChainStack decoders into a pipeline: Base64, hex, ROT, XOR, Morse, URL, Atbash, Vigenère, and more. Magic mode auto-discovers the chain. Bookmark the URL to save it.
  • Number Base ConverterConvert numbers between binary, octal, decimal, and hexadecimal instantly. Enter any value and see all four bases update in real time.

Flag

Reveal flag

picoCTF{y0uv3_3sc4p3d_th3_m4ze...f0r_n0w-hYkq2D9PmrA5GpEq}

The flag is printed by the VM when the navigation sequence successfully reaches the exit cell with enough health to clear all five checkpoint barriers.

Key takeaway

Custom virtual machines inside binaries are a CTF staple and a real technique in software protection, anti-cheat engines, and obfuscated malware. Reversing one takes two stages: find the dispatch loop and work out what each opcode means, then either emulate the machine in a scripting language or reason about the bytecode statically. Porting the interpreter to Python and searching its state space works on any binary that encodes a puzzle as an interpreted program.

Related reading

Useful tools for Reverse Engineering

Where to go next