Alpaca CTF 2026: super-short-python-golf
Challenge Information
| Attribute | Details |
|---|---|
| Event | Alpaca CTF 2026 |
| Category | Misc / Python jail |
| Author | minaminao |
| Challenge | super-short-python-golf |
| Remote | nc 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_FLAGat module scope. - Image:
python:3.14.4-slim-trixie. Server:socat TCP-L:5000,fork EXEC:'python jail.py',stderr. - No PTY (
pty,cttyabsent from the socat exec) → pydoc selectsplainpager(no!shpager-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:
| Expression | Length | Why it fails |
|---|---|---|
vars() | 6 | Returns a dict, never printed |
dir() | 5 | Returns a list, never printed |
locals | 6 | Returns a dict reference, never printed |
exit() | 6 | Terminates cleanly |
1/0 | 3 | ZeroDivisionError traceback — no flag in locals |
0() | 3 | TypeError — no leak |
[][0] | 5 | IndexError — 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> jailcauses 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:
- Re-reads
os.environ["FLAG"]into the new module’sALPACA_FLAG. - Calls
input("code > "), waiting for a line on stdin. We answer withdir()(or any ≤ 6-char benign expression). - 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 stdinhelp> jail pydoc.locate("jail") → __import__("jail") └─ /app/jail.py executes a SECOND timecode > dir() inner jail's input() consumes the next line eval("dir()") returns harmlessly pydoc.TextDoc.docmodule(jail) renders outputDATA ALPACA_FLAG = '...' ← flag leaks here code = 'dir()'Why Each Constraint is Barely Satisfied
| Constraint | How it’s met |
|---|---|
len(code) > 6 blocks longer input | help() = exactly 6 chars |
code.isascii() | help() is plain ASCII |
| Expression-only restriction | help() is a function call — a valid expression |
No PTY → plainpager → no pager escape | Irrelevant; the import-and-render path writes straight to stdout |
Exploit
Manual Session
$ nc 34.170.146.252 55149code > help()help> jailcode > dir()Help on module jail:
NAME jail
DATA ALPACA_FLAG = 'Alpaca{h3lp_m3_Im_scar3d}' code = 'dir()'
FILE /app/jail.pyPython 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 moduleio.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 evaluationimport 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 fromsys.stdinindefinitely, promoting a one-shot eval into a multi-line conversation. Any jail that restricts only the first input line is vulnerable ifhelp()fits in the budget.- pydoc’s
help>prompt imports arbitrary modules: The request athelp>is resolved via__import__, noteval. If a module is importable, pydoc will import it and render itsDATAsection — exposing every module-level constant. sys.path[0]includes the script’s own directory: When Python runspython script.py, the script’s directory is prepended tosys.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
- Python
pydocmodule docs: https://docs.python.org/3/library/pydoc.html - Python
builtins.help: https://docs.python.org/3/library/functions.html#help - Python
sys.pathinitialisation: https://docs.python.org/3/library/sys.html#sys.path - pwntools documentation: https://docs.pwntools.com/