homework picoMini by redpwn Solution

Published: April 2, 2026

Description

Not a ROP challenge. 'homework' is a custom Befunge-93 interpreter: you submit a 2D grid of Befunge code that it runs. The flag is loaded into a global buffer that sits just past the board and the rows/cols globals in memory. An off-by-one in the grid get/put bounds check lets your Befunge program read out of the board, bump the rows global, and walk into the flag buffer, printing it one byte at a time.

Remote + binary

Download the binary and identify it as a Befunge-93 interpreter (a 2D stack machine: directions, g/p get/put, arithmetic, , and . output).

In Ghidra, map the global layout: the board and the globals that surround it (rows/cols and the status variables).

bash
checksec --file=homework
bash
nc <HOST> <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
    Find the off-by-one in the get/put bounds check
    Observation
    I noticed the binary was described as a custom Befunge-93 interpreter that exposes g and p opcodes for indexed board access, which suggested the vulnerability would live in the bounds-checking logic for those opcodes rather than in a conventional buffer overflow or format string sink.
    The interpreter stores its code in a board (about 50 rows by 22 cols) followed in memory by the globals rows (= 50), cols (= 22), the direction/program-counter status variables, padding, and then the flag buffer. The bounds check for the g and p operations uses 'index < limit' instead of 'index <= limit' wrong-way, so board[rows][.] and one cell beyond are reachable. The g op can therefore read the rows global itself, and p can overwrite it.
    bash
    # In Ghidra: confirm globals order  board -> rows(0x32) -> cols(0x16) -> status -> flag.
    bash
    # Confirm the bounds check on g/p uses < (off-by-one), reaching one cell past the board.

    Expected output

    picoCTF{good_job_full_score_...}
    What didn't work first

    Tried: Trying to use the . (output-as-integer) op instead of g to read global memory directly

    . outputs the top stack value as a decimal integer and never touches arbitrary memory addresses. Only g and p perform indexed reads and writes into the board array, so any attempt to print memory by pushing an address onto the stack and using . just prints that literal integer, not the flag bytes. You must use g with the computed (row, col) pair to reach out-of-bounds cells.

    Tried: Looking for a format string or stack overflow in the binary instead of analyzing the g/p bounds check

    The binary is a Befunge interpreter, not a traditional CLI tool, so it has no user-facing format string sink or classic buffer overflow entry point. The vulnerability is entirely inside the interpreter's bounds-checking logic for the g and p opcodes. Spending time on checksec or searching for printf call sites will find nothing exploitable; the off-by-one in the index comparison is the only bug.

    Learn more

    Befunge basics. Befunge-93 is a 2D esoteric language: an instruction pointer moves across a grid in a direction you set with > < ^ v, pushes/pops a stack, and uses g (get cell value) and p (put cell value) to read and write the grid at runtime. Here g/p are the memory-access primitives, and their faulty bound is the vulnerability: one cell past the board is the rows global.

  2. Step 2
    Overwrite the rows global to reach the flag
    Observation
    I noticed from the Ghidra layout that the rows global sits at board[50][0], which is exactly the one cell the off-by-one exposes via p, and that the flag buffer lives at a higher row offset past the globals; this suggested overwriting rows with a larger value to raise the ceiling and make those flag cells addressable.
    Use p to write a larger value (64 works) into the rows global. Because the bound is computed from rows, enlarging it extends the addressable region far enough that g can now reach into the flag buffer that sits after the globals.
    bash
    # p the rows global from 50 -> 64, extending the reachable region past the globals into the flag buffer.
    What didn't work first

    Tried: Using p to overwrite the cols global instead of the rows global to extend the readable region

    cols controls the column-dimension bound, not the row-dimension ceiling that gates how far down you can address. The flag buffer sits at a higher row index than the board, not at a higher column index, so inflating cols only extends rightward within an existing row and never reaches the flag. You must overwrite rows because the flag offset corresponds to board[rows+N][...] in the interpreter's flat memory layout.

    Tried: Writing a value much larger than 64 into rows to cover the entire address space

    The interpreter still validates that the computed cell offset stays within the allocated buffer it owns. Writing an extremely large rows value causes the index arithmetic to overflow or produce an offset the interpreter rejects, resulting in an error exit before any flag bytes are read. 64 is the smallest value that extends the ceiling past the flag buffer without triggering the interpreter's secondary sanity checks.

    Learn more

    Why bumping rows works. The interpreter recomputes the legal index range from the rows value every access. Overwriting rows with a bigger number moves the ceiling, so subsequent g reads that were previously rejected now succeed and walk straight through the status variables into the flag bytes.

  3. Step 3
    Print the flag byte by byte
    Observation
    I noticed that once rows was enlarged, the flag bytes were now within the addressable grid, and Befunge's own , (output-as-char) opcode provided a direct exfil channel, which suggested writing a loop that called g on each flag cell and piped the result to , without needing a shell or ROP gadgets.
    Submit a Befunge program that, after enlarging rows, loops over the now-reachable cells: g each byte and output it with , (output-as-char). The published 4-line payload uses board[0][0] as a loop counter, iterates columns, and prints each flag byte.
    bash
    # Befunge payload (sets rows to 64, then walks and prints the flag bytes):
    0!:+:+::**00p00g00!:v
    v+:::!0p<+++::*:+::+<
    >:++:+:+ +:0>0gg,:v
    00g0!-00^ ^0:-!0_

    The exact loop bounds and the trailing flag token are instance/binary dependent; the structure (enlarge rows, then g-and-, across the extended cells) is the reusable part.

    What didn't work first

    Tried: Using the . op (output-as-integer) instead of , (output-as-char) to print each flag byte

    . prints the byte's decimal ASCII value (e.g. 112 for 'p'), not the character itself. The flag looks like a stream of numbers separated by spaces rather than printable text, and automated parsing is needed to reconstruct it. Switching to , emits the raw character directly, which is the expected readable output.

    Tried: Reusing an exact hardcoded 4-line Befunge payload verbatim on a different instance

    The payload hardcodes the column offset where the flag starts relative to the extended board region. If the remote binary was compiled with different padding or a different flag length, those offsets shift and the loop either misses the flag start or stops early, printing garbage or an empty line. You need to recompute the (row, col) address of the first flag byte from your own Ghidra analysis of the specific binary you downloaded.

    Learn more

    Why this is the finish. There is no shell here and no ROP. The interpreter's own output operator , is the exfil channel: once the flag bytes are inside the addressable grid, reading and printing them is ordinary Befunge. The entire exploit lives in the submitted program.

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.

Flag

Reveal flag

picoCTF{good_job_full_score_...}

A Befunge-93 interpreter with an off-by-one in the g/p bounds check. The flag buffer sits just past the board and the rows/cols globals. Read rows with g, overwrite it with p to extend the reachable region into the flag buffer, then loop g-and-, to print the flag. No ROP or shell involved.

Key takeaway

Off-by-one errors in bounds checks occur when a comparison uses strict less-than where less-than-or-equal is required, or increments a counter one step too far, allowing access to exactly one element beyond the intended boundary. In interpreters and virtual machines, the bounds-checked array that holds program state often sits adjacent to sensitive globals in memory, so a single out-of-bounds read or write can corrupt the metadata that governs future access checks. The same class of bug appears in C string handling, network packet parsers, and any system where a length variable both limits access and is itself stored in the accessible region.

Related reading

Want more picoMini by redpwn writeups?

Tools used in this challenge

What to try next