Quand votre texte devient du code de template
Une appli laisse personnaliser un message de bienvenue. Pour insérer le prénom, elle utilise un moteur de template (Twig en PHP, Jinja2 en Python, Freemarker en Java…). L'erreur : coller le texte de l'utilisateur dans le template lui-même :
// le message de l'utilisateur devient la source du template
$html = $twig->createTemplate("Bonjour " . $_GET['nom'])->render();
Depuis le début de ce niveau, le même motif revient : une entrée déborde de sa zone prévue. En SSRF, elle forçait le serveur à parler vers l'interne. Ici, elle franchit une frontière différente : elle devient du code que le moteur compile à votre place.
L'utilisateur ne tape pas son prénom. Il tape une expression de template :
{{ 7 * 7 }}
Et la page affiche 49. Ce petit test prouve tout : le serveur n'a pas affiché le texte {{ 7 * 7 }}, il l'a évalué. Votre saisie est traitée comme du code. À partir de là, on ne calcule pas 7 × 7 : on parcourt les objets du moteur pour atteindre le système et exécuter des commandes sur le serveur.
C'est la SSTI (Server-Side Template Injection). Comme la XSS, c'est une donnée prise pour des instructions, mais cette fois du côté serveur : l'aboutissement n'est pas un script dans le navigateur, c'est l'exécution de code sur la machine. C'est une forme d'injection (A05 du Top 10 OWASP 2025).
Pourquoi ça marche
Toujours la même cause racine : du code et des données mélangés. Un moteur de template attend une source (écrite par vous, le développeur) et des données (le contexte : prénom, panier, etc.). Il évalue la source et y injecte les données de façon sûre.
La faille apparaît quand l'entrée de l'utilisateur se retrouve dans la source au lieu d'être une simple donnée. Le moteur compile alors la saisie comme du template, avec toute sa puissance : expressions, accès aux objets, filtres. En partant de ces objets, on remonte souvent jusqu'au moteur lui-même, puis jusqu'au système d'exploitation.
- Le test qui confirme :
{{7*7}}(ou${7*7},#{7*7}selon le moteur) renvoie49au lieu du texte brut. - La fuite : parcourir le contexte (
{{ config }}, variables d'environnement) révèle des secrets. - Le RCE : via les objets internes du moteur, on atteint
system()et on exécute des commandes.
SSTI n'est pas XSS. L'échappement HTML (htmlspecialchars) protège de la XSS mais ne fait rien contre la SSTI : le mal est déjà fait au moment où le moteur compile votre saisie, bien avant l'affichage. La vraie parade n'est pas d'échapper la sortie, c'est de ne jamais mettre l'entrée dans la source du template (section 4).
À vous d'attaquer : faites évaluer votre saisie
Voici un personnalisateur de message vraiment vulnérable, simulé dans votre navigateur. En mode vulnérable, votre texte devient la source du template. Confirmez l'injection avec {{7*7}}, lisez un secret du contexte, puis poussez jusqu'au RCE. Activez ensuite la version sécurisée. Tout est simulé.
Bloqué ? Voir la solution
Tapez {{7*7}} : le serveur affiche 49, l'injection est confirmée. Puis {{ config.SECRET_KEY }} fuite un secret du contexte. Et {{ system('id') }} (ou un accès aux objets internes du moteur) exécute une commande : c'est le RCE.
En version sécurisée, votre saisie est une donnée : le template est fixe (Bonjour {{ nom }}) et votre texte remplit la variable nom. Quoi que vous tapiez, {{7*7}} s'affiche tel quel, jamais évalué.
Le correctif : l'entrée est une donnée, pas du code
La règle tient en une phrase : le template est écrit par vous, l'utilisateur ne fournit que des données. On ne concatène jamais une entrée dans la source ; on la passe en variable de contexte, que le moteur insère sans l'évaluer.
// ✗ Vulnérable : l'entrée devient la source
$html = $twig->createTemplate("Bonjour " . $_GET['nom'])->render();
// ✓ Sûr : le template est fixe, l'entrée est une donnée
$html = $twig->render('bienvenue.twig', ['nom' => $_GET['nom']]);
// bienvenue.twig contient : Bonjour {{ nom }}
Dans la version sûre, même si nom vaut {{7*7}}, le moteur l'affiche littéralement : la variable est une valeur, pas du code à compiler.
Si vous devez vraiment laisser l'utilisateur écrire un gabarit (un éditeur d'e-mails, par exemple), n'utilisez pas votre moteur applicatif. Choisissez un moteur logique-less (comme Mustache) ou un bac à sable dédié qui interdit l'accès aux objets et aux fonctions dangereuses. Un moteur complet exposé à l'utilisateur finit toujours en RCE.
Défense en profondeur :
- jamais d'entrée dans la source du template : on passe des variables de contexte ;
- pour du gabarit fourni par l'utilisateur, un moteur logique-less ou en bac à sable ;
- l'auto-échappement activé pour la XSS (problème distinct, mais à ne pas oublier) ;
- moindre privilège sur le process qui rend le template (limite l'impact d'un RCE) ;
- pas de secrets exposés dans le contexte de rendu sans raison.
Référence : PortSwigger, Server-Side Template Injection.
La méthode et l'arsenal du pentester
On vient de voir comment défendre. Maintenant, retournons-nous : comment un attaquant détecte-t-il la faille et l'exploite-t-il jusqu'au bout ?
1. Détecter. Partout où une saisie ressort dans la page (message, nom, modèle d'e-mail, libellé), on injecte un test de calcul : {{7*7}}, ${7*7} ou #{7*7} selon le moteur. Si 49 apparaît, c'est une SSTI. Le polyglotte ${{<%[%'"}}%\ sert, lui, à déclencher une erreur révélatrice quand on ignore encore quel moteur tourne.
2. Identifier le moteur. Selon ce qui s'évalue ou plante, on devine Twig, Jinja2, Freemarker, Velocity, ERB ou Handlebars. Chaque moteur a ses chemins d'exploitation.
3. Escalader. On parcourt les objets internes du moteur pour atteindre le runtime, puis system() et l'exécution de commandes. En Jinja2 : {{''.__class__.__mro__...}} ; en Twig : _self.env.registerUndefinedFilterCallback. L'objectif final, c'est le RCE.
L'arsenal.
- tplmap : détecter et exploiter automatiquement la SSTI selon le moteur.
- Burp Suite : injecter les sondes et observer les réponses.
- PayloadsAllTheThings (SSTI) : les charges d'escalade pour chaque moteur.
L'impact
La SSTI mène le plus souvent au RCE : une fois la saisie compilée par le moteur, on remonte jusqu'au système et on exécute des commandes. C'est une prise complète du serveur, au même niveau qu'une injection de commandes ou un webshell. En chemin, le simple parcours du contexte fuit des secrets : clés d'API, variables d'environnement, configuration.
Le piège, c'est qu'une SSTI ressemble d'abord à une coquetterie d'affichage (« tiens, {{7*7}} affiche 49 »). Beaucoup la sous-estiment, alors qu'elle se trouve souvent là où on l'attend le moins : un objet de mail personnalisable, un nom de produit avec des variables, un rapport généré. Partout où un moteur de template touche une entrée utilisateur.
Ce que ça révèle côté défense : un moteur de template est un interpréteur, et y verser une entrée brute revient à lui donner du code à exécuter. La frontière à tenir est toujours la même : le code (la source) est à vous, la donnée est à l'utilisateur, et les deux ne se mélangent jamais. La confiance se vérifie (leçon 1).
La checklist SSTI
À vérifier partout où un moteur de template rencontre une entrée utilisateur.
- Aucune entrée concaténée dans la source du template : que des variables de contexte.
- Gabarit utilisateur → moteur logique-less ou bac à sable, jamais le moteur applicatif complet.
- Auto-échappement activé (pour la XSS, problème distinct).
- Moindre privilège sur le process de rendu.
- Contexte minimal : pas de secrets exposés sans raison.
Les références. Le guide SSTI de PortSwigger détaille la détection et l'escalade par moteur. Pour s'entraîner : les labs SSTI de la Web Security Academy.
Rappel. Tester une injection sur un service qui n'est pas le vôtre est une intrusion. On ne teste que ses propres systèmes ou une cible explicitement autorisée (leçon 1 du cours principal).
When your text becomes template code
An app lets you customise a welcome message. To insert the first name, it uses a template engine (Twig in PHP, Jinja2 in Python, Freemarker in Java…). The mistake: pasting the user's text into the template itself:
// the user's message becomes the template source
$html = $twig->createTemplate("Hello " . $_GET['name'])->render();
Since the start of this level, the same pattern keeps coming back: input overflows its intended zone. In SSRF, it forced the server to talk inward. Here, it crosses a different boundary: it becomes code the engine compiles on your behalf.
The user doesn't type their first name. They type a template expression:
{{ 7 * 7 }}
And the page shows 49. This tiny test proves everything: the server didn't display the text {{ 7 * 7 }}, it evaluated it. Your input is treated as code. From there, you don't compute 7 × 7: you walk the engine's objects to reach the system and run commands on the server.
That's SSTI (Server-Side Template Injection). Like XSS, it's data taken for instructions, but this time on the server side: the endgame isn't a script in the browser, it's code execution on the machine. It's a form of injection (A05 of the OWASP Top 10 2025).
Why it works
Always the same root cause: code and data mixed together. A template engine expects a source (written by you, the developer) and data (the context: name, cart, etc.). It evaluates the source and injects the data into it safely.
The flaw appears when the user's input ends up in the source instead of being plain data. The engine then compiles the input as a template, with all its power: expressions, object access, filters. And from those objects, you often climb up to the engine itself, then to the operating system.
- The confirming test:
{{7*7}}(or${7*7},#{7*7}depending on the engine) returns49instead of the raw text. - The leak: walking the context (
{{ config }}, environment variables) reveals secrets. - RCE: through the engine's internal objects, you reach
system()and run commands.
SSTI is not XSS. HTML escaping (htmlspecialchars) protects against XSS but does nothing against SSTI: the damage is already done when the engine compiles your input, well before display. The real fix isn't escaping the output, it's to never put input into the template source (section 4).
Your turn to attack: make your input get evaluated
Here's a genuinely vulnerable message customiser, simulated in your browser. In vulnerable mode, your text becomes the template source. Confirm the injection with {{7*7}}, read a secret from the context, then push to RCE. Then switch on the secure version. Everything is simulated.
Stuck? Show the solution
Type {{7*7}}: the server shows 49, the injection is confirmed. Then {{ config.SECRET_KEY }} leaks a secret from the context. And {{ system('id') }} (or access to the engine's internal objects) runs a command: that's RCE.
In the secure version, your input is data: the template is fixed (Hello {{ name }}) and your text fills the name variable. Whatever you type, {{7*7}} shows as-is, never evaluated.
The fix: input is data, not code
The rule fits in one sentence: the template is written by you, the user only supplies data. You never concatenate input into the source; you pass it as a context variable, which the engine inserts without evaluating it.
// ✗ Vulnerable: input becomes the source
$html = $twig->createTemplate("Hello " . $_GET['name'])->render();
// ✓ Safe: the template is fixed, input is data
$html = $twig->render('welcome.twig', ['name' => $_GET['name']]);
// welcome.twig contains: Hello {{ name }}
In the safe version, even if name equals {{7*7}}, the engine displays it literally: the variable is a value, not code to compile.
If you really must let users write a template (an email editor, say), don't use your application engine. Pick a logic-less engine (like Mustache) or a dedicated sandbox that forbids access to objects and dangerous functions. A full engine exposed to the user always ends in RCE.
Defense in depth:
- never input in the source of the template: pass context variables;
- for user-supplied templates, a logic-less or sandboxed engine;
- auto-escaping on for XSS (a distinct problem, but don't forget it);
- least privilege on the process that renders the template (limits an RCE's impact);
- no secrets exposed in the rendering context without reason.
Reference: PortSwigger — Server-Side Template Injection.
The pentester's method and arsenal
We've just seen how to defend. Now let's flip sides: how does an attacker spot the flaw and push it all the way?
1. Detect. Anywhere an input comes back in the page (message, name, email template, label), you inject a math test: {{7*7}}, ${7*7} or #{7*7} depending on the engine. If 49 appears, it's an SSTI. The polyglot ${{<%[%'"}}%\ is instead used to trigger a revealing error when you don't yet know which engine is running.
2. Identify the engine. Based on what evaluates or breaks, you guess Twig, Jinja2, Freemarker, Velocity, ERB or Handlebars. Each engine has its exploitation paths.
3. Escalate. You walk the engine's internal objects to reach the runtime, then system() and command execution. In Jinja2: {{''.__class__.__mro__...}}; in Twig: _self.env.registerUndefinedFilterCallback. The end goal is RCE.
The arsenal.
- tplmap: detect and exploit SSTI automatically per engine.
- Burp Suite: inject the probes and watch the responses.
- PayloadsAllTheThings (SSTI): the escalation payloads for each engine.
The impact
SSTI most often leads to RCE: once the input is compiled by the engine, you climb up to the system and run commands. It's a full server takeover, on par with a command injection or a webshell. Along the way, simply walking the context leaks secrets: API keys, environment variables, configuration.
The trap is that an SSTI first looks like a display quirk ("oh, {{7*7}} shows 49"). Many underestimate it, while it often sits where you least expect it: a customisable email object, a product name with variables, a generated report. Anywhere a template engine touches user input.
What this reveals for defense: a template engine is an interpreter, and pouring raw input into it is like handing it code to run. The boundary to hold is always the same: the code (the source) is yours, the data is the user's, and the two never mix. Trust is verified (lesson 1).
The SSTI checklist
To check anywhere a template engine meets user input.
- No input concatenated into the template source: context variables only.
- User-supplied template → logic-less or sandboxed engine, never the full application engine.
- Auto-escaping on (for XSS, a distinct problem).
- Least privilege on the rendering process.
- Minimal context: no secrets exposed without reason.
The references. The PortSwigger SSTI guide details detection and escalation per engine. To practice: the SSTI labs of the Web Security Academy.
Reminder. Testing an injection on a service that isn't yours is an intrusion. Only test your own systems or an explicitly authorized target (lesson 1 of the main course).
Un dev échappe le HTML de la saisie (htmlspecialchars) avant de la passer à createTemplate(...). Avant de dérouler : est-ce que ça stoppe la SSTI ?
Voir la réponse
Non. L'échappement HTML protège la sortie contre la XSS, mais la SSTI frappe avant : au moment où le moteur compile la saisie comme un template. {{7*7}} ne contient aucun caractère HTML spécial à échapper ; il est évalué tel quel. La seule vraie parade est de ne pas mettre l'entrée dans la source : on la passe en variable de contexte (donnée), et le template reste fixe.
A dev HTML-escapes the input (htmlspecialchars) before passing it to createTemplate(...). Before you expand: does that stop the SSTI?
Show the answer
No. HTML escaping protects the output against XSS, but SSTI strikes before: when the engine compiles the input as a template. {{7*7}} contains no special HTML character to escape; it's evaluated as-is. The only real fix is not putting input in the source: pass it as a context variable (data), and keep the template fixed.
🎯 Pratique
S'entraîner (clique pour ouvrir) :
💬 Ré-explique sans regarder
Avec tes mots : pourquoi {{7*7}} renvoie 49 sur un serveur vulnérable, et pourquoi passer l'entrée en variable de contexte (au lieu de la concaténer) règle le problème ?
{{7*7}} n'est pas affiché, il est évalué (49). Le moteur étant un interpréteur complet, on remonte ensuite ses objets internes jusqu'au système pour exécuter des commandes (RCE), ou on fuit des secrets du contexte. L'échappement HTML n'y change rien : il agit sur la sortie, pas sur la compilation. La parade sépare code et données : le template est fixe (écrit par le dev) et l'entrée passe en variable de contexte. Le moteur insère alors la donnée sans l'évaluer ; {{7*7}} s'affiche littéralement.🧠 Rappel libre
Sans remonter : qu'est-ce qu'une SSTI, pourquoi l'échappement HTML ne protège pas, et quelle est la parade (entrée = donnée) ?
{{7*7}} renvoie 49, et en remontant les objets internes du moteur on atteint le système (RCE) ou on fuit des secrets. L'échappement HTML ne protège pas : il agit sur la sortie (XSS), alors que la SSTI frappe à la compilation, avant l'affichage. Parade : ne jamais concaténer l'entrée dans la source ; la passer en variable de contexte d'un template fixe. Pour un gabarit fourni par l'utilisateur : moteur logique-less ou bac à sable.⚖️ Juge le code de l'IA
Tu signales la SSTI à l'IA. Elle répond : « Je filtre les accolades : je retire {{ et }} de la saisie avant de la passer à createTemplate. Plus d'expressions possibles. » Tu acceptes, ou tu rejettes ?
${...}, #{...}, <%= ... %> ou via des balises de bloc {% ... %} : retirer {{ et }} n'en couvre qu'une partie. Et surtout, le problème n'est pas l'accolade, c'est que l'entrée devient la source du template. Tant que createTemplate(saisie) compile la saisie, on trouvera une syntaxe qui passe. La vraie correction ne nettoie pas la saisie : elle change l'architecture. Le template devient fixe, et l'entrée est une variable de contexte que le moteur n'évalue jamais.{{7*7}} affiche 49. Qu'est-ce que ça prouve ?htmlspecialchars (échappement HTML) ne stoppe pas la SSTI ?The template engine ran your code. The next lesson runs a whole object you craft: insecure deserialization, when rebuilding a piece of data triggers hidden code.
Lesson 5: Insecure deserialization →