Description
Escape the matrix.
Setup
Download the binary from the challenge page.
wget <challenge_url>/matrix # download the binarySolution
Want to try it yourself first?
The guided walkthrough reveals hints one step at a time.
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.
Step 1
Run the binary and observe interactive behaviorObservationI noticed the binary was a stripped ELF with no obvious flag strings, which suggested that simply inspecting static output would not suffice and that running it first to observe its interactive prompt was the right way to understand what kind of program it actually is.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.bashfile matrixbashchmod +x matrixbash./matrixExpected 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 a lot of noise and the flag string is never stored in plaintext - it is assembled character by character at runtime by the VM's PUTCHAR opcode. The binary also has no hard-coded input string because the correct path is validated dynamically, so strings gives no useful signal about maze navigation.
Tried: Providing a random sequence of movement characters like 'rrrrrrrrrr' expecting to brute-force the exit
The VM validates both position and a running health counter at multiple checkpoint barriers. Simply reaching the exit cell with a wrong health value causes the program to jump past the flag-printing routine and exit silently. You need to understand the bytecode to know which path collects enough health boosts to clear all five barriers.
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.
Step 2
Reverse-engineer the VM in GhidraObservationI noticed the binary read directional characters one at a time and responded to them dynamically, which indicated a custom interpreter rather than a password check, so I needed Ghidra to find and decode the opcode dispatch loop and reconstruct the VM's 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.
Step 3
Write a Python disassembler and visualize the mazeObservationI noticed Ghidra revealed PUTCHAR opcodes that emit ASCII characters one at a time, which suggested the bytecode encodes a printable maze grid and that porting the step() function to Python would let me run the emulator with full tracing to reconstruct and inspect that grid.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 bytespythonpython3 -c "data=open('matrix','rb').read(); print(data[0x1020f0:0x1026c2].hex())"bash# Then run your emulator scriptpythonpython3 disasm.pyThe 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 markedvsubtract. 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
An incorrect start offset pulls in ELF section header or symbol table bytes before the actual bytecode. The emulator will misinterpret those bytes as opcodes, hit an undefined opcode value immediately, and either crash or print garbage instead of the maze grid. You must confirm the exact start and end addresses in Ghidra by looking at where the bytecode array is defined 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 path to the exit may deliberately skip ^ cells and fail all five checkpoint barriers even though it reaches $ first. The emulator must track the health value alongside position so the BFS state is (row, col, health) rather than just (row, col), otherwise the path it finds will be rejected at the first barrier.
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.Step 4
Determine the correct navigation sequence and get the flagObservationI noticed the visualized maze contained health-modifying cells (^ and v) and five checkpoint barriers that tested the accumulated health counter, which meant a plain shortest-path solution would fail and I needed a BFS over the (row, col, health) state space to find a path that cleared 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.bashecho 'rrrrrlrlrlrlrllddddddlddrrddrrrrdddrruuuruuuuuuurrddddddddlddrd' | ./matrixWhat 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 with GETCHAR one byte at a time as the program runs, so interactive typing can work in principle, but a single mistyped character cannot be corrected and the whole sequence must be restarted. Using echo and a pipe feeds the exact string atomically, eliminating transcription errors, which matters especially for a 62-character sequence.
Tried: Supplying a custom BFS-generated path that is geometrically shorter than the reference sequence
A shorter geometric path likely skips several ^ health-boost cells in the early rows. The VM's checkpoint barriers check that the health counter stored at a specific stack depth is at least 5 at each of five validation points. A path that is shorter but collects fewer ^ cells will fail one of these barriers silently and produce no output, which looks identical to a wrong-format 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.