Leçon 4/6 10 min

Injection de template (SSTI)

Quand ce que tu tapes est compilé comme template : {{7*7}} donne 49 et mène à l'exécution de code. Déclenche, puis fix.

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).

SSTI : code ou donnée ? Entrée dans la source {{7*7}} le moteur compile 49, puis RCE compilé évalué Entrée en donnée {{7*7}} template fixe affiché tel quel variable inséré
À gauche, la saisie devient du code que le moteur exécute. À droite, elle n'est qu'une donnée insérée dans un template fixe : jamais évaluée.

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) renvoie 49 au 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é.

Message de bienvenue · monsite.com
Bonjour

        
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 :

  1. jamais d'entrée dans la source du template : on passe des variables de contexte ;
  2. pour du gabarit fourni par l'utilisateur, un moteur logique-less ou en bac à sable ;
  3. l'auto-échappement activé pour la XSS (problème distinct, mais à ne pas oublier) ;
  4. moindre privilège sur le process qui rend le template (limite l'impact d'un RCE) ;
  5. 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.

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).

SSTI: code or data? Input in the source {{7*7}} the engine compiles 49, then RCE compiled evaluated Input as data {{7*7}} fixed template shown as-is variable inserted
On the left, the input becomes code the engine runs. On the right, it's just data inserted into a fixed template: never evaluated.

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) returns 49 instead 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.

Welcome message · mysite.com
Hello

        
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:

  1. never input in the source of the template: pass context variables;
  2. for user-supplied templates, a logic-less or sandboxed engine;
  3. auto-escaping on for XSS (a distinct problem, but don't forget it);
  4. least privilege on the process that renders the template (limits an RCE's impact);
  5. 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.

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).

Prédisez avant de lire

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.

Predict before reading on

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
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 ?

Une bonne explication dit : le serveur a collé la saisie dans la source du template, puis a demandé au moteur de la compiler. Du coup {{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
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) ?

Une SSTI survient quand l'entrée utilisateur est compilée comme la source d'un template au lieu d'être une donnée. Le moteur évalue alors les expressions : {{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
Accepter ou rejeter 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 ?

À rejeter : c'est encore une blacklist, et elle reste fragile. Selon le moteur, les expressions s'écrivent ${...}, #{...}, <%= ... %> 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.
Sur un serveur vulnérable, {{7*7}} affiche 49. Qu'est-ce que ça prouve ?
Pourquoi htmlspecialchars (échappement HTML) ne stoppe pas la SSTI ?
Quelle est la bonne parade contre la SSTI ?
Jusqu'où va, le plus souvent, une SSTI exploitée à fond ?
Prochaine étape

Le moteur de template a exécuté votre code. La leçon suivante exécute un objet entier que vous fabriquez : la désérialisation non sécurisée, quand reconstruire une donnée déclenche du code caché.

Leçon 5 : Désérialisation non sécurisée →
Besoin d'un développeur pour votre projet ?

Réponse sous 24h · Sans engagement