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 1Find the off-by-one in the get/put bounds check
ObservationThe binary is a custom Befunge-93 interpreter with g and p opcodes for indexed board access. The bug will be in the bounds checking for those, not in a conventional 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 accepts 'index <= limit' where it should require 'index < limit', so the row index rows itself passes validation and board[rows][.] is 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.What didn't work first
Tried: Trying to use the . (output-as-integer) op instead of g to read global memory directly
The decimal-output opcode prints the top stack value and touches no memory at all. Only g and p index into the board, so pushing an address and printing it just prints that number. Reach the out-of-bounds cells with g and a computed row and column.
Tried: Looking for a format string or stack overflow in the binary instead of analyzing the g/p bounds check
This is an interpreter, not an ordinary command-line tool, so there is no format string sink and no classic overflow entry point. The bug lives entirely in the bounds checking for the two indexed opcodes. checksec and a hunt for printf call sites turn up nothing.
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 2Overwrite the rows global to reach the flag
ObservationIn the Ghidra layout, the rows global sits at exactly the cell the off-by-one exposes, and the flag buffer lives at a higher row offset past the globals. Overwrite rows with a larger value and those flag cells become 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 bounds the column dimension, not the row ceiling that limits how far down you can reach. The flag sits at a higher row than the board, not a higher column, so inflating cols only extends rightward inside an existing row. In the flat memory layout the flag offset is past the row limit, which is why rows is the target.
Tried: Writing a value much larger than 64 into rows to cover the entire address space
The interpreter still checks that a computed offset stays inside the buffer it owns. A very large rows value overflows the index arithmetic or produces an offset it rejects, and the program exits before reading anything. 64 is the smallest value that clears the flag buffer without tripping those secondary 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 3Print the flag byte by byte
ObservationWith the ceiling raised, the flag bytes fall inside the addressable grid, and Befunge's character-output opcode is already an exfiltration channel. Loop over the flag cells, read each one, and print it, with no shell or gadgets involved.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
That opcode prints each byte's decimal value rather than the character, so the flag arrives as a stream of space-separated numbers you then have to reassemble. The character-output opcode emits the byte directly.
Tried: Reusing an exact hardcoded 4-line Befunge payload verbatim on a different instance
The payload hardcodes where the flag starts relative to the extended board. Different padding or a different flag length on the remote binary shifts that, and the loop either starts in the wrong place or stops early, printing garbage. Recompute the address of the first flag byte from the 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.