SSTI1

Published: April 2, 2025

Description

The announcement form renders unescaped Jinja2 templates, so you can access Python globals through the `cycler` object and execute shell commands.

Load the challenge URL and locate the text field that reflects arbitrary user input.

Submit a harmless template (e.g., `{{7*7}}`) to confirm server-side template execution.

curl http://rescued-float.picoctf.net:52534/
curl -L -X POST -d "content={{7*7}}" http://rescued-float.picoctf.net:52534/

Solution

  1. Step 1Pop a shell via cycler
    Jinja exposes the `cycler` helper, whose `__init__.__globals__` dictionary contains the `os` module. Calling `os.popen` lets you run arbitrary commands from a template payload.
    {{ cycler.__init__.__globals__.os.popen('ls -la').read() }}
    Learn more

    Server-Side Template Injection (SSTI) occurs when user input is embedded directly into a template string that is then rendered by a server-side template engine. Unlike reflected XSS (which runs in the victim's browser), SSTI runs on the server with the application's privileges - making it far more dangerous. The canonical test payload is {{7*7}}: if the server responds with 49 instead of the literal string {{7*7}}, template injection is confirmed.

    Jinja2, Flask's default template engine, exposes a global namespace containing Python builtins and context objects. The cycler object (a Jinja2 utility for cycling through values) is always available in the template context. Its __init__ method is a Python function, and all Python functions carry a __globals__ attribute pointing to the module's global namespace. If the os module was imported anywhere in the application, it appears in this dictionary - accessible via template syntax without any import statement in the payload itself.

    SSTI is ranked in the OWASP Top 10 under Injection and has led to complete server compromise in real-world incidents. Affected template engines include Jinja2, Twig (PHP), FreeMarker (Java), Pebble (Java), Velocity (Java), and others. The payload chain differs per engine, which is why identification (what engine is running?) precedes exploitation in a real engagement. PayloadAllTheThings and HackTricks maintain comprehensive SSTI payload lists per engine.

  2. Step 2Read the flag file
    Listing the directory reveals the `flag` file. Replace the command string with `cat flag` (or use curl to POST the payload) and the response contains the picoCTF flag.
    {{ cycler.__init__.__globals__.os.popen('cat flag').read() }}
    curl -L -X POST -d "content={{ cycler.__init__.__globals__.os.popen('cat flag').read() }}" http://rescued-float.picoctf.net:52534/
    Learn more

    os.popen() opens a pipe to a shell command and returns a file-like object whose .read() method captures standard output. This is functionally equivalent to Python's subprocess.run(..., capture_output=True) but requires only a single chained method call, making it ideal for injection payloads where brevity matters.

    From a defensive standpoint, the root fix is trivially simple: never pass user input directly to a template renderer. Instead, pass data as template variables using the context dictionary - render_template('page.html', content=user_input) - and reference it as {{ content }} in the template file. Jinja2 automatically HTML-escapes variables rendered this way, preventing both SSTI and XSS. The vulnerability only exists when code uses render_template_string(user_input) or jinja2.Environment().from_string(user_input) directly.

    If template rendering of user content is genuinely required (e.g., a CMS allowing template-like syntax), the mitigation is to use a sandboxed environment: jinja2.sandbox.SandboxedEnvironment() blocks access to private attributes (__init__, __globals__) and disallows calling arbitrary Python functions. This doesn't provide perfect security (sandbox escapes exist) but dramatically raises the exploitation bar.

  3. Step 3Optional: script the extraction
    Pipe the HTML through grep/cut if you want a clean output from the command line.
    curl -L -X POST -d "content={{ cycler.__init__.__globals__.os.popen('cat flag').read() }}" http://rescued-float.picoctf.net:52534/ | grep -E "picoCTF\{.*\}"
    Learn more

    Chaining curl with text processing tools like grep, cut, and sed is a quick way to automate flag extraction without writing a full Python script. The -E flag enables extended regular expressions in grep, and picoCTF\{.*\} matches the flag pattern. This approach works well for one-off extractions but breaks if the flag contains characters that have special regex meaning.

    For more robust automation, pwntools (via from pwn import *) provides HTTP request helpers alongside its binary exploitation capabilities. For web-only challenges, requests (Python) or httpx offer clean APIs for building and sending POST requests, parsing JSON responses, and following redirects - making them ideal for multi-step web exploits that require maintaining session state across requests.

    Scripting the exploit also makes it reproducible: if the challenge instance resets or you want to demonstrate the vulnerability to others, a self-contained script is far more reliable than manual browser interactions. Good CTF habit is to save every working exploit script in your notes alongside the flag.

Flag

picoCTF{s4rv3r_s1d3_t3mp14t3_1nj3ct10n5_4r3_c001_ae48...}

The payload chain works in-browser or via curl; both outputs include the full flag inside the rendered HTML.

Want more picoCTF 2025 writeups?

Useful tools for Web Exploitation

Related reading

Do these first

What to try next