Alpaca CTF 2026: super-short-python-golf

Challenge Information

AttributeDetails
EventAlpaca CTF 2026
CategoryMisc / Python jail
Authorminaminao
Challengesuper-short-python-golf
Remotenc 34.170.146.252 55149

Summary

jail.py passes exactly ≤ 6 ASCII characters of attacker input through Python’s eval(), with the flag bound to a module-level global ALPACA_FLAG. Six characters is not enough to print or even name an 11-character variable — but it is enough for help(). Pydoc’s interactive help> prompt accepts a module name; since jail.py lives at /app/jail.py (the Docker WORKDIR), asking pydoc for help on it (help> jail) causes pydoc to import the module, re-execute the file, and render the DATA section — dumping every module-level constant, flag included.


Challenge

jail.py

import os
ALPACA_FLAG = os.environ.get("FLAG", "Alpaca{dummy}")
code = input("code > ")
if len(code) > 6:
print("Too long")
exit()
if not code.isascii():
print("Not ASCII")
exit()
eval(code)

Constraints

  • One line of input, ≤ 6 ASCII characters.
  • Input is passed to eval() — single expression only; no statements (import, assignment, etc.).
  • Flag is stored in ALPACA_FLAG at module scope.
  • Image: python:3.14.4-slim-trixie. Server: socat TCP-L:5000,fork EXEC:'python jail.py',stderr.
  • No PTY (pty,ctty absent from the socat exec) → pydoc selects plainpager (no !sh pager-escape).

What Doesn’t Work

Every 6-character one-shot expression is a dead end — the return value is discarded and tracebacks don’t expose locals:

ExpressionLengthWhy it fails
vars()6Returns a dict, never printed
dir()5Returns a list, never printed
locals6Returns a dict reference, never printed
exit()6Terminates cleanly
1/03ZeroDivisionError traceback — no flag in locals
0()3TypeError — no leak
[][0]5IndexError — no leak

The flag variable name ALPACA_FLAG is 11 characters by itself — no expression that names it fits in the 6-char budget.


What Works — help()jail

help() is exactly 6 characters and is interactive: rather than returning a value, it enters pydoc.Helper.interact(), an input loop that reads further lines from sys.stdin and prompts with help>. The jail only constrains the first line of input. Once help() is running, every subsequent line we send bypasses the length check entirely because it is consumed by pydoc, not by the jail’s eval.

Inside the help> REPL, pydoc resolves the input as a module name via pydoc.locate()safeimport()__import__(). So:

help> jail

causes pydoc to import jail, which resolves to /app/jail.py because WORKDIR /app puts /app on sys.path. That import re-executes jail.py, printing a second code > prompt (answered with any harmless ≤ 6-char expression like dir()). Once the import completes, pydoc renders the module’s documentation, including a DATA section that calls repr() on every module-level binding — ALPACA_FLAG included.


Why It Works — Mechanics Step by Step

Four independent facts align to make this exploit possible. Removing any one of them kills the route.

1) help() fits in 6 characters and reads more input

help is a pydoc.Helper instance installed into builtins. Calling it with no arguments enters Helper.interact(), which reads from sys.stdin line-by-line indefinitely. The 6-char constraint only applies to the first line passed to eval.

2) help> is a module-name resolver, not a Python REPL

Pydoc’s Helper.help() method checks the request against keywords, topics, and built-in categories. The catch-all path is pydoc.doc(request), which calls pydoc.locate()pydoc.safeimport(). safeimport wraps __import__. In practice, anything that is not a built-in keyword gets imported as a module. We cannot type Python expressions at help> — pydoc never runs the request through eval — but we can name any importable module and pydoc will import it and render its docs.

3) jail.py is importable as the module jail due to WORKDIR /app

The Dockerfile sets WORKDIR /app and places jail.py there. When Python starts with python jail.py, CPython prepends the script’s directory (/app) to sys.path[0]. Therefore import jail succeeds, resolving to /app/jail.py. This is the non-obvious step: most readers assume “module name” means the standard library, but sys.path[0] is whatever directory the entrypoint script lives in.

Importing jail re-executes jail.py in a fresh module namespace. That re-execution:

  1. Re-reads os.environ["FLAG"] into the new module’s ALPACA_FLAG.
  2. Calls input("code > "), waiting for a line on stdin. We answer with dir() (or any ≤ 6-char benign expression).
  3. Reaches end-of-file cleanly, returning a fully-initialised module object. exit() is never called because none of the failure branches fire.

The nested input() call and pydoc’s outer input() share sys.stdin over the same TCP socket — the connection is a straight FIFO, so the lines we send are consumed in order.

4) pydoc renders module-level globals in the DATA section

Once import jail returns, pydoc.TextDoc.docmodule() walks module.__dict__, classifies each binding (classes, functions, data), and prints the remainder under a DATA heading using repr(value). ALPACA_FLAG is a module-level str, so it falls through to DATA, and repr of a string is its quoted literal — flag included.

Flow Diagram

nc → jail.py (outer) eval("help()")
└─ Helper.interact() reads stdin
help> jail pydoc.locate("jail") → __import__("jail")
└─ /app/jail.py executes a SECOND time
code > dir() inner jail's input() consumes the next line
eval("dir()") returns harmlessly
pydoc.TextDoc.docmodule(jail) renders output
DATA
ALPACA_FLAG = '...' ← flag leaks here
code = 'dir()'

Why Each Constraint is Barely Satisfied

ConstraintHow it’s met
len(code) > 6 blocks longer inputhelp() = exactly 6 chars
code.isascii()help() is plain ASCII
Expression-only restrictionhelp() is a function call — a valid expression
No PTY → plainpager → no pager escapeIrrelevant; the import-and-render path writes straight to stdout

Exploit

Manual Session

$ nc 34.170.146.252 55149
code > help()
help> jail
code > dir()
Help on module jail:
NAME
jail
DATA
ALPACA_FLAG = 'Alpaca{h3lp_m3_Im_scar3d}'
code = 'dir()'
FILE
/app/jail.py

Python Script

from pwn import *
io = remote("34.170.146.252", 55149)
io.sendlineafter(b"code > ", b"help()") # enter pydoc REPL (6 chars)
io.sendlineafter(b"help> ", b"jail") # import /app/jail.py as module
io.sendlineafter(b"code > ", b"dir()") # satisfy nested input() call
print(io.recvall().decode())

Flag

Alpaca{h3lp_m3_Im_scar3d}

The flag text — “h3lp_m3_Im_scar3d” — is a direct pun on help(), confirming the intended route.


Root Cause & Fix

What Went Wrong

The challenge is intentionally designed as a golf puzzle, not a real vulnerability. That said, the conceptual lesson is real: pydoc’s help() builtin is an interactive escape hatch from size-limited jails. It is rarely considered because people think of help() as a documentation tool, not an arbitrary module importer.

Hardening a Real Jail

# Restrict builtins before evaluation
import builtins
_safe = {k: getattr(builtins, k) for k in ('len', 'range', 'int', 'str')}
code = input("code > ")
if len(code) > 6 or not code.isascii():
exit()
eval(code, {"__builtins__": _safe})

Restricting __builtins__ to an explicit allowlist prevents access to help, breakpoint, input, __import__, and every other interactive or introspective builtin.


Key Takeaways

  • help() is an interactive builtin: It reads from sys.stdin indefinitely, promoting a one-shot eval into a multi-line conversation. Any jail that restricts only the first input line is vulnerable if help() fits in the budget.
  • pydoc’s help> prompt imports arbitrary modules: The request at help> is resolved via __import__, not eval. If a module is importable, pydoc will import it and render its DATA section — exposing every module-level constant.
  • sys.path[0] includes the script’s own directory: When Python runs python script.py, the script’s directory is prepended to sys.path. The script itself is therefore importable as a module by its base name, complete with re-execution side effects.
  • Six chars is enough to start a conversation: The constraint is not “do everything in 6 chars” but “get a foothold in 6 chars”. help() provides that foothold; everything else follows from stdin.
  • Interactive escape hatches deserve their own category: Alongside format-string leaks and return-oriented programming, “interactive builtin promotion” is a real jailbreak primitive worth memorising for golf-style challenges.

Tools Used

  • netcat: Manual one-shot exploitation
  • pwntools: Scripted socket interaction for repeatability

References