Step 193. SSTI: Template Injection → RCE — When Your Input Becomes the Server’s Code

Step 193. SSTI: Template Injection → RCE — When Your Input Becomes the Server’s Code

Level 3 — Real-World CTF & Advanced Attack Skills | Difficulty ★★★★☆ | Estimated time: 3.5 hours

Prerequisites: the injection mindset from Steps 135–137 (input becomes syntax) and XSS from Step 138. You can spin up a simple server with Python Flask.

  • What you need: Python 3 + Flask (you’ll build the vulnerable server yourself), requests or a browser.
  • ⚠️ All exercises in this chapter are for your own lab and legal platforms only. Applying them to unauthorized systems is a crime. PortSwigger Web Security Academy is a legal learning platform built for attack practice.
  • Caution: today’s core — from {{7*7}} detection to {{config}} disclosure — is entirely hands-on measured on a local server you run yourself. The RCE stage, because of its destructive power, is covered as concept and payload structure only and is not executed. PortSwigger lab screens are shown as screen examples.

Meet the last variant of the injection family. SQL injection turned input into SQL syntax, and XSS turned input into HTML syntax. SSTI (Server-Side Template Injection) turns input into the template engine’s syntax. When user input slips into template syntax like {{ name }} that a web framework uses to render pages, {{7*7}} gets evaluated into 49 — and it doesn’t end there. Because templates execute inside the server, digging deep enough can reach OS command execution on the server (RCE). Today you build the vulnerable server yourself and measure the front half of this chain.


1. Learning Objectives

By the end of this chapter, you will be able to:

  • Explain that SSTI shares the same root as SQL injection and XSS (input becomes code)
  • Find SSTI points with detection payloads from the {{7*7}} and ${7*7} family
  • Identify the template engine from the result difference of {{7*'7'}}
  • Explain what {{config}} disclosure and object hierarchy traversal (__class__, __mro__) mean in Jinja2
  • Describe the structure of the SSTI → RCE chain and its defense (never put input into render_template_string)

2. Background Knowledge — Today’s Tools and Concepts

Today’s Tools at a Glance

Category Details
Language/environment Python 3 + Flask (Jinja2 built in) — you’ll build both a vulnerable and a safe server, requests (detection script)
Today’s payloads {{7*7}} (detection), {{7*'7'}} (engine identification), {{config}} (config disclosure), {{ self.__class__.__mro__ }} (hierarchy traversal)
Concepts needed Template engines (Jinja2/Twig), server-side rendering, Python object hierarchy, limits of sandboxes
Today’s deliverables lab193.py (vulnerable server) + lab193_safe.py (safe server) + probe193.py (detector) + SSTI attack chain notes

2-1. Template Engines — The Factory That Builds Pages

Web frameworks don’t build HTML by string concatenation; they build it with a template engine. Python Flask’s default engine is Jinja2, and PHP’s Symfony uses Twig. Their syntax is nearly identical.

# Normal use: template and data are separated
render_template_string("<h1>Hello {{ name }}!</h1>", name=name)

Here name is data. Even if a user submits {{7*7}}, those characters simply appear on the screen as-is — because they’re the value of a variable, not template code.

2-2. Vulnerable Use — When Input Becomes Not a Template "String" but Template "Code"

# Vulnerable: user input gets concatenated into the template body
render_template_string("<h1>Hello " + name + "!</h1>")

In this code, the {{7*7}} the user sent is parsed as part of the template. Since Jinja2 interprets {{ }} as "evaluate this expression," the screen shows 49. That one-line difference is the whole of SSTI — the familiar sentence again: injection is born when input becomes syntax instead of data.

2-3. The Attack Chain — Detect → Identify → Explore → RCE

SSTI attacks follow a fixed order.

  1. Detect: put {{7*7}} into every input point and find where 49 comes back. Alternate with ${7*7} and <%= 7*7 %> in case the engine differs.
  2. Identify: send {{7*'7'}}. If the result is 7777777 (string repeated 7 times), it’s Jinja2/Twig; if it’s 49 (numeric multiplication), it’s a different engine (e.g., some PHP engines). This stage matters because every later payload changes with the engine.
  3. Explore: with Jinja2, peek at the server config via {{config}}, and climb Python object attributes like __class__ and __mro__ to find usable classes.
  4. RCE: find a subprocess-family class somewhere in the object hierarchy and execute OS commands. Today we look at this stage’s structure only, without executing it (2-4).

2-4. Why It Reaches RCE — And Today’s Boundary

Inside a Jinja2 template you can access Python objects. If you climb the class hierarchy like self.__class__.__mro__ and obtain the full list of subclasses (__subclasses__()) from the top-level object, that list contains classes capable of reading files and spawning processes. The typical chain:

{{ ''.__class__.__mro__[1].__subclasses__() }}   ← the full list of subclasses
   → find a subprocess.Popen-family class among them
   → execute OS commands via .__init__ or a method

Today’s boundary: this is where the "conceptual explanation" ends. This chapter’s local measurements stop at stage 3 (config disclosure, hierarchy traversal). Two reasons — ① an RCE payload is a finished product that can be copied anywhere, so printing its execution result is inappropriate for an introductory book, and ② the index into __subclasses__() differs per environment, so it can’t be copied verbatim anyway. Understanding the principle, then exploring on your own in your own lab — that’s the proper path.


3. Follow Along

3-1. The Vulnerable Server and the Safe Server — Two Worlds One Line Apart

lab193.py (educational vulnerable code — localhost only, never deploy anywhere):

from flask import Flask, request, render_template_string

app = Flask(__name__)

@app.route("/hello")
def hello():
    name = request.args.get("name", "guest")
    # Vulnerability: user input is parsed as template "code"
    return render_template_string("<h1>Hello " + name + "!</h1>")

if __name__ == "__main__":
    app.run(port=5193)

lab193_safe.py — the fixed version:

from flask import Flask, request, render_template_string

app = Flask(__name__)

@app.route("/hello")
def hello():
    name = request.args.get("name", "guest")
    # Safe: input is just data, never template code
    return render_template_string("<h1>Hello {{ name }}!</h1>", name=name)

if __name__ == "__main__":
    app.run(port=5194)

Open the two files side by side and confirm the difference with your eyes. One line — precisely, one argument of difference. Remember that this is the entire defense.

3-2. Detection and Engine Identification

Start both servers and run probe193.py.

import requests

BASE = "http://127.0.0.1:5193/hello"

def inject(payload):
    return requests.get(BASE, params={"name": payload}).text

print("== 1. Detection: {{7*7}} ==")
print("Response:", inject("{{7*7}}"))

print("== 2. Engine identification: {{7*'7'}} ==")
print("Response:", inject("{{7*'7'}}"))

Output (measured 2026-09-09):

== 1. Detection: {{7*7}} ==
Response: <h1>Hello 49!</h1>
== 2. Engine identification: {{7*'7'}} ==
Response: <h1>Hello 7777777!</h1>

How to read it: ① {{7*7}} was computed into 49 — conclusive evidence that input executed as template code. ② {{7*'7'}} became 7777777 — Python computes string × number as repetition, so this server is identified as Jinja2 (Python family). The same payload yields 7777777 in PHP Twig too, but it clearly separates from engines that yield the number 49.

Send the same thing to the safe server (port 5194) (measured 2026-09-09):

Safe server response: <h1>Hello {{7*7}}!</h1>

How to read it: on a server where template and data are separated, the payload is just characters. The contrast between the two servers confirms that the attack’s success or failure turns on a one-line difference.

3-3. Server Config Disclosure — {{config}}

print("== 3. Server config disclosure: {{config}} ==")
print("Response:", inject("{{config}}"))

print("== 4. Object hierarchy traversal: self.__class__.__mro__ ==")
print("Response:", inject("{{ self.__class__.__mro__ }}"))

Output (measured 2026-09-09, partially omitted; HTML entities shown in original form):

== 3. Server config disclosure: {{config}} ==
Response: <h1>Hello &lt;Config {&#39;DEBUG&#39;: False, &#39;TESTING&#39;: False,
... &#39;SECRET_KEY&#39;: None, ... &#39;SESSION_COOKIE_NAME&#39;: &#39;session&#39;,
...}&gt;!</h1>
== 4. Object hierarchy traversal: self.__class__.__mro__ ==
Response: <h1>Hello (&lt;class &#39;jinja2.runtime.TemplateReference&#39;&gt;, &lt;class &#39;object&#39;&gt;)!</h1>

How to read it: ① config is the Flask app’s settings object — the session cookie name and every flag got exposed wholesale. In the field, if SECRET_KEY is embedded here, it leads to session cookie forgery (recall Step 134’s signatures — a leaked secret key renders signatures powerless). ② self.__class__.__mro__ is "this object’s class pedigree" — you can see the ladder climbing TemplateReferenceobject. Call __subclasses__() at the top of that ladder (object), and every class loaded into the process opens up. The door to RCE sits right here.

Why: the meaning of the exploration stage is drawing "a map of what I can touch inside the server." Config disclosure alone is often fatal (secret keys), and hierarchy traversal is the process of finding the map’s end — the command execution path.

3-4. The Structure of the RCE Stage (Concept — Not Executed)

From here on, we only look at structure. A typical Jinja2 RCE chain looks like this (conceptual example):

Stage 1: {{ ''.__class__.__mro__[1] }}            → obtain the top-level object class
Stage 2: ...__subclasses__()                      → list of all loaded subclasses
Stage 3: search the list for subprocess.Popen     → index differs per environment
Stage 4: execute OS commands via that class       → commands run with server privileges

As said in 2-4, today you do not execute any of this yourself. Instead, remember two things. ① This chain is possible because templates reach into Python’s object space, and ② the defense is not cutting the middle of the chain but preventing input from becoming template code in the first place (the safe server from 3-1).

3-5. Applying It to PortSwigger Labs (Screen Example)

The Academy’s "Server-side template injection" path follows the same order.

  1. Put {{7*7}} into the lab’s input points (product name, template editor, etc.) and find where it computes.
  2. Identify the engine with {{7*'7'}} — most labs state the engine in their description.
  3. Read the engine’s documentation (linked in the lab) and proceed: config object disclosure → object traversal.
  4. Some labs have a sandbox — bypassing it is the lab’s topic, and hints point to that engine’s bypass techniques.

Having built the server yourself locally, you can imagine what the lab’s server code looks like — that imagination is the real weapon in SSTI labs.


4. Missions & Exercises

Mission — Reproduce the Vulnerable Server and Document the Attack Chain

  1. Complete lab193.py, lab193_safe.py, and probe193.py and reproduce the four outputs (49, 7777777, config disclosure, class pedigree).
  2. Add a payload that picks a specific key like {{config.SECRET_KEY}}, and write down what a real service would have stored there.
  3. Send the same payloads to the safe server, confirm they’re neutralized, and capture the one-line code difference between the two servers.
  4. In your wiki, write SSTI-chain.md — the four stages (detect → identify → explore → RCE) with each stage’s payload and cautions.

Exercises

Exercise 1. Explain the difference between render_template_string("<h1>Hello " + name + "!</h1>") and render_template_string("<h1>Hello {{ name }}!</h1>", name=name) in terms of "at which stage the input gets processed."

Exercise 2. Explain, using Python’s operator rules, why 7777777 from {{7*'7'}} identifies Jinja2/Twig.

Exercise 3. Explain why {{config}} disclosure is more dangerous than simple information leakage, connecting it to Step 134’s session signing.

Exercise 4. Using today’s safe server code as evidence, explain why SSTI’s fundamental defense is not "stronger filtering."


5. Model Answers & Completion Criteria

Mission Model Answer

How to verify: ① On the vulnerable server, does {{7*7}}49 and {{7*'7'}}7777777 check out (2026-09-09 measured baseline)? ② Does the {{config}} response include the settings key list? ③ On the safe server, does every payload print literally? ④ Does your notes document contain the four-stage chain plus the caution that "RCE payload indexes are environment-dependent"?

Exercise Answers

Answer 1. In the vulnerable version, input is concatenated into the template string and passes through the engine’s parsing stage — {{ }} gets evaluated as code. In the safe version, the template is compiled first and the input is substituted only as a variable value at rendering time — input containing {{ }} is mere character data. Same input; the difference is whether it becomes template before parsing or a value after parsing.

Answer 2. In Python, 'string' * number is a repetition operation — '7' * 7 is 7777777. Engines that do numeric computation, by contrast, evaluate 7 * '7' as 49. The answer to the same payload reveals the engine’s language rules, so it works as an identification signal.

Answer 3. Flask’s session cookies are signed with SECRET_KEY (Step 134). If that key is exposed through config, an attacker can sign arbitrary sessions themselves — up to and including minting an admin session. It’s the classic cascade where information leakage leads to authentication bypass.

Answer 4. The safe server didn’t add a single filter — it merely passed input as a variable instead of gluing it into template code, and every payload was neutralized. Filters must keep chasing {{, config, __class__, and more, but change the structure and the list itself disappears. Exactly the same philosophy as parameter binding for SQL injection.

Completion Criteria Checklist

  • [ ] I can state in one sentence that SSTI is an injection where "input is parsed as template code"
  • [ ] I reproduced {{7*7}} detection and {{7*'7'}} engine identification locally
  • [ ] I confirmed config disclosure via {{config}} and can explain the danger
  • [ ] I can explain the meaning of the __class____mro____subclasses__() hierarchy traversal
  • [ ] I can explain the four stages of the SSTI → RCE chain in order (conceptually, without execution)
  • [ ] I can explain why safe code (template/data separation) is the fundamental defense
  • [ ] Mission: reproduced both servers + finished the SSTI-chain.md write-up

6. Common Pitfalls & Fixes

Wall 1. I sent {{7*7}} and it came back verbatim

Symptom: <h1>Hello {{7*7}}!</h1> prints as-is.

Most likely cause: that input point is not parsed as template code — a safe structure (exactly the safe-server measurement from 3-2). Second: the engine isn’t Jinja2, so the syntax differs.

Fix: alternate other syntaxes — ${7*7} (some Java engines), <%= 7*7 %> (ERB, etc.). If everything comes back verbatim, that point isn’t SSTI. Move to the next input point.

Wall 2. My payload returns a 500 error

Symptom: only Internal Server Error comes back.

Cause: it was parsed as template code, but the syntax is wrong or you referenced a nonexistent attribute. Paradoxically, a 500 is also a signal that you found the SSTI point — it means your input reached the engine.

Fix: shrink the payload to a minimum and narrow the stage. {{7*7}} (success) → {{config}} (success/failure) → add one attribute at a time. Not throwing a long chain all at once is the golden rule of SSTI debugging.

Wall 3. The __subclasses__() index differs from the document

Cause: the loaded class list differs every time with Python version, framework, and import order. An index like [259] from an internet write-up is that environment’s value.

Fix: don’t memorize indexes — search. Print the list and look for names like Popen, subprocess, os with your eyes, or number the list with a template loop that enumerates it. Understanding "why the index is environment-dependent" is this stage’s learning goal.

Wall 4. I thought it was the safe server but it got breached

Cause: the render_template_string("..." + name + "...") pattern remains on another route. If even one spot concatenates template strings, that route is vulnerable.

Fix: find every render_template_string( call in the codebase and check whether the first argument involves string concatenation (+, f-string). The first argument must always be a fixed string.

Wall 5. It’s Jinja2 but {{config}} doesn’t appear

Cause: config is injected automatically only in the Flask context. Other framework/engine setups use different global object names (Django uses a different engine, so {{ }} behaves entirely differently).

Fix: redo engine identification (3-2), then find "objects exposed to templates by default" in that engine’s documentation. Even with Jinja2, if it’s not Flask, start exploring from Jinja2 built-in globals like self, cycler, namespace.


7. Summary

Today’s Concepts

Concept One-line explanation
SSTI An injection where user input is parsed as template engine code — execution happens on the server
Template engine A tool that builds pages with {{ variable }} syntax — Jinja2 (Python), Twig (PHP)
Detection payload {{7*7}} → if 49, template code execution confirmed
Engine identification {{7*'7'}} → if 7777777, Python family (Jinja2/Twig)
Object hierarchy traversal Mapping the server’s objects via __class____mro____subclasses__()
SSTI → RCE OS command execution via a subprocess-family class in the hierarchy — indexes are environment-dependent

Today’s Commands and Payloads

Command/payload What it does
{{7*7}} SSTI detection — if 49, it executed
{{7*'7'}} Engine identification — if 7777777, Jinja2/Twig
{{config}} Flask config object disclosure (secret key danger)
{{ self.__class__.__mro__ }} Check class pedigree — the start of exploration
render_template_string("...", name=name) Safe rendering — template/data separation
render_template_string("..." + name) Vulnerable rendering — today’s attack target

The Instinct That Matters More Than Commands

With SSTI learned, your map of the injection family is complete — SQL (Step 135), command injection, XSS (Step 138), and now templates. Only the syntax differs; they’re all the same disease: the mistake of mixing code and data in one bowl. And they all share the same prescription: separation. Parameter binding, output escaping, template/data separation. Check this symmetry every time a new attack technique appears — when you meet the next injection, you’ll already know its principle and its defense.


Once every box is checked, Step 193 is complete. Click the checkbox in the sidebar to save your progress.