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.
Setup
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).
checksec --file=homeworknc <HOST> <PORT_FROM_INSTANCE>Solution
Want to try it yourself first?
The guided walkthrough reveals hints one step at a time.
Step 1
Find the off-by-one in the get/put bounds checkObservationI 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 usesg(get cell value) andp(put cell value) to read and write the grid at runtime. Hereg/pare the memory-access primitives, and their faulty bound is the vulnerability: one cell past the board is therowsglobal.Step 2
Overwrite the rows global to reach the flagObservationI 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
rowsvalue every access. Overwritingrowswith a bigger number moves the ceiling, so subsequentgreads that were previously rejected now succeed and walk straight through the status variables into the flag bytes.Step 3
Print the flag byte by byteObservationI 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, theng-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.