Bug Bounty Academy

Cours · classe de vulnérabilité

Server-Side Template Injection

Repérer l'évaluation côté serveur et l'identifier proprement.

~20 min de lecture

Objectifs

À la fin de ce cours, tu seras capable de :

  • Distinguer une injection de template côté serveur (SSTI) d'une injection côté client (CSTI) et d'une XSS classique.
  • Identifier la cause racine d'une SSTI : une saisie concaténée dans la source d'un template au lieu d'être passée comme donnée de contexte.
  • Analyser la réponse à des sondes arithmétiques inoffensives pour confirmer une évaluation et déduire la famille du moteur.
  • Évaluer la sévérité d'une SSTI selon le moteur, le niveau d'authentification requis et la présence d'un bac à sable.
  • Rédiger un rapport qui prouve l'évaluation côté serveur sans dépasser ce que le programme autorise.
  • Corriger un code vulnérable en Python (Jinja2) et en Node.js en séparant strictement template et données.

Pourquoi c'est important en bug bounty

Les moteurs de templates (Jinja2, Twig, FreeMarker, ERB, EJS, Handlebars, Nunjucks…) sont partout : pages HTML, e-mails transactionnels, PDF générés, notifications, messages personnalisables par l'utilisateur. Dès qu'une application laisse quelqu'un influencer la source d'un template, le moteur se met à interpréter ce que cette personne écrit.

La SSTI est moins fréquente que la XSS ou l'IDOR, mais elle fait partie des vulnérabilités les mieux rémunérées. La raison est structurelle : un moteur de templates est un mini-langage de programmation exécuté sur le serveur. Une expression arbitraire évaluée côté serveur donne souvent accès aux objets internes de l'application, et dans beaucoup de moteurs cela peut aller jusqu'à l'exécution de code. Les programmes attribuent donc couramment une sévérité High à Critical, et les rapports publics HackerOne sur le sujet figurent régulièrement parmi les primes les plus élevées des programmes concernés.

Ce qui distingue un rapport payé d'un rapport fermé :

  • Payé : la preuve montre sans ambiguïté que l'expression est évaluée par le serveur (résultat présent dans la réponse HTTP brute), le moteur est identifié, l'impact est argumenté avec rigueur, et le chercheur s'est arrêté à une preuve minimale.
  • Fermé : un 49 visible uniquement dans le DOM rendu par le navigateur (c'est une CSTI, à traiter comme une XSS), une simple réflexion de {{7*7}} sans évaluation, ou au contraire un chercheur qui a lu des fichiers du serveur, ce qui viole les règles de quasiment tous les programmes.

Le mécanisme

Un moteur de templates reçoit deux entrées distinctes :

  1. une source : le texte du template, avec ses délimiteurs ({{ … }}, ${ … }, <%= … %> selon le moteur) ;
  2. un contexte : les variables que le développeur met à disposition du template.

Le moteur analyse la source, repère les délimiteurs et évalue les expressions qu'ils contiennent. Les valeurs du contexte, elles, ne sont jamais réanalysées : elles sont simplement insérées (et, dans la plupart des moteurs HTML, échappées).

La SSTI naît quand le développeur fabrique la source en y collant une saisie utilisateur, par exemple avec une f-string ou une concaténation, puis la fait compiler. La saisie cesse alors d'être une donnée : elle devient du code de template. Si l'utilisateur tape {{7*7}}, le moteur calcule 49. C'est exactement la même erreur de conception que l'injection SQL : mélanger le canal du code et celui des données.

DÉTECTION SSTISondes inoffensives et identification du moteur
💡 Mécanisme clé : Chaque sonde ne fait qu'un calcul arithmétique : la réponse suffit à confirmer l'évaluation côté serveur et à orienter vers une famille de moteurs. La cause racine est toujours la même : la saisie est concaténée dans la source au lieu d'être passée en contexte.

SSTI, CSTI et XSS : trois choses différentes

Où l'expression est-elle évaluée ?Signal typiqueImpact habituel
SSTISur le serveur, par le moteur de templates49 présent dans la réponse HTTP bruteAccès aux objets serveur, souvent critique
CSTIDans le navigateur, par un framework JS (ex. AngularJS ancien, Vue avec templates dynamiques)49 visible dans le DOM, mais la réponse brute contient encore {{7*7}}Équivalent à une XSS
XSSDans le navigateur, par le parseur HTML/JSbalise ou attribut injecté interprétéActions au nom de la victime

La confusion SSTI/CSTI est la première cause de rapports surévalués. Le test décisif est simple : regarde la réponse dans ton proxy, pas dans l'onglet du navigateur.

Question de réflexion : un champ « prénom » affiche 49 dans le navigateur après la sonde à doubles accolades, mais le proxy montre la sonde intacte dans le HTML reçu. De quoi s'agit-il ?

La sonde était {{7*7}} et la réponse brute contenait encore {{7*7}}. C'est une injection de template côté client (CSTI). Le serveur a renvoyé la chaîne telle quelle et c'est un framework JavaScript qui l'a évaluée dans le navigateur. L'impact se rapproche d'une XSS (exécution de JavaScript dans la session de la victime), pas d'une compromission du serveur. Le rapport doit être qualifié et noté en conséquence.

Pourquoi c'est généralement critique

Sans entrer dans les techniques, retiens le principe : les expressions de template ne manipulent pas que des nombres. Elles accèdent à des objets du langage hôte (Python, PHP, Java, Ruby, JavaScript). Dans les moteurs qui n'isolent pas ces objets, une expression peut remonter de proche en proche vers des fonctionnalités internes de l'application ou du langage : configuration, secrets en mémoire, système de fichiers, voire exécution de commandes. C'est pourquoi une SSTI confirmée sur un moteur non isolé est traitée comme un risque d'exécution de code à distance, même si tu ne l'as pas démontré et sans que tu aies à le faire.

Bacs à sable et leurs limites

Certains moteurs proposent un mode isolé : SandboxedEnvironment pour Jinja2, le mode sandbox de Twig, les modèles « logic-less » comme Mustache, ou des réglages restrictifs pour FreeMarker. Ils limitent les attributs et fonctions accessibles. C'est une défense en profondeur utile, mais pas une garantie : l'historique de ces moteurs comporte des contournements publiés, et une liste de blocage doit anticiper toutes les façons d'atteindre un objet sensible. La seule correction fiable reste de ne jamais laisser une saisie devenir de la source.

Où chercher

La SSTI se cache là où l'application génère du texte personnalisé à partir de contenus éditables :

  • Modèles d'e-mails et de notifications configurables (« Bonjour {{prenom}}, votre commande… ») dans les back-offices, outils marketing et CRM.
  • Générateurs de documents : factures, devis, attestations PDF dont l'en-tête ou le pied de page est personnalisable.
  • Pages ou widgets personnalisables : signatures, messages d'accueil, pages de statut.
  • Messages d'erreur qui reprennent un paramètre (?message=…) construits à la volée.
  • Champs de profil (nom, bio) réutilisés plus tard dans un e-mail rendu côté serveur : la SSTI peut être différée, stockée à un endroit et évaluée ailleurs.
  • Fonctionnalités de prévisualisation (« tester mon modèle »).

Signaux faibles à repérer dans le proxy :

  • l'interface documente elle-même des variables entre accolades ou entre dollars ;
  • une erreur 500 apparaît quand tu saisis une accolade ou un % isolé ;
  • les en-têtes ou les pages d'erreur trahissent la pile (Express, Flask/Werkzeug, Symfony, Spring, Rails) ;
  • le texte saisi revient transformé (espaces supprimés, casse modifiée), signe qu'il a traversé un moteur.

Méthode de test

Travaille uniquement dans le périmètre autorisé, avec tes propres comptes de test, et arrête-toi dès que l'évaluation et le moteur sont établis.

Étape 1 — Cartographier les réflexions. Saisis un marqueur unique et inoffensif (par exemple zq7canari) dans chaque champ candidat, puis cherche-le dans toutes les réponses : page, e-mail reçu sur ta boîte de test, PDF généré.

Étape 2 — Envoyer une sonde arithmétique. Remplace le marqueur par une sonde qui encadre un calcul, de préférence entourée de texte pour bien voir la transformation :

POST /account/notifications/template HTTP/1.1
Host: app.acme.test
Cookie: session=<ta-session-de-test>
Content-Type: application/x-www-form-urlencoded
 
subject=zq7{{7*7}}zq7

Puis demande la prévisualisation et observe la réponse brute :

HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
 
<p>Objet : zq749zq7</p>

Étape 3 — Interpréter le résultat.

Réponse observéeInterprétation
zq749zq7L'expression est évaluée côté serveur : SSTI probable
zq77777777zq7Évaluation avec multiplication de chaîne : typique de Python (Jinja2)
zq7{{7*7}}zq7Texte brut : pas d'évaluation ici (ou mauvaise syntaxe), essaie une autre famille
Erreur 500 ou traceLe parseur a réagi à la syntaxe : très bon indice, souvent le nom du moteur apparaît
zq7zq7Expression avalée sans sortie : la syntaxe est reconnue, creuse prudemment

Étape 4 — Identifier la famille du moteur. Suis l'arbre du schéma : teste {{7*7}}, ${7*7} et <%= 7*7 %>. Si la syntaxe à doubles accolades répond 49, la sonde de distinction {{7*'7'}} sépare Jinja2 (qui répète la chaîne : 7777777) de Twig (qui convertit et calcule : 49). Complète avec les indices passifs : en-têtes, format des erreurs, langage du framework.

Étape 5 — S'arrêter. À ce stade, tu as tout ce qu'il faut : point d'injection, preuve d'évaluation côté serveur, moteur identifié. Ne cherche pas à accéder aux objets internes, aux fichiers ou à l'environnement. Si tu penses que la preuve est insuffisante pour le programme, demande au triage avant d'aller plus loin.

Question de réflexion : pourquoi entourer la sonde d'un marqueur comme zq7 plutôt que d'envoyer la sonde seule ?

Parce qu'un 49 isolé peut apparaître par hasard dans une page (un prix, un identifiant, un compteur). Encadré par un marqueur unique, le résultat devient non ambigu : zq749zq7 ne peut provenir que de l'évaluation de ta sonde. C'est aussi plus lisible pour le trieur qui reproduit ton rapport.

Variantes et défenses courantes

Variantes.

  • SSTI réfléchie : la sonde est évaluée dans la réponse immédiate.
  • SSTI stockée ou différée : la saisie est enregistrée (nom de profil, objet d'un modèle) et évaluée plus tard, par exemple dans un e-mail ou un export PDF. Pense à vérifier ta boîte de test et les documents générés.
  • SSTI aveugle : aucune sortie visible, seule une erreur ou une différence de comportement trahit l'évaluation. La preuve est alors plus délicate et se discute avec le programme.
  • Injection dans une expression existante : la saisie atterrit déjà à l'intérieur d'un bloc {{ … }}. Il n'est pas nécessaire d'ouvrir des délimiteurs ; une simple opération arithmétique suffit à révéler l'évaluation.

Pourquoi les défenses partielles échouent.

  • Filtrer les accolades : chaque moteur a plusieurs syntaxes (expressions, instructions, commentaires), et d'autres délimiteurs peuvent être configurés. Une liste de blocage est toujours en retard d'une syntaxe.
  • Échapper le HTML de la saisie : l'échappement HTML protège contre la XSS, pas contre l'interprétation par le moteur, qui a lieu avant la production du HTML.
  • Compter sur le bac à sable : il réduit l'impact mais ne corrige pas la cause, et son efficacité dépend de la version et de la configuration.
  • Réserver la fonctionnalité aux administrateurs : cela réduit la surface, mais un compte administrateur compromis, un rôle délégué ou une CSRF sur l'édition du modèle suffit à rouvrir le risque. Beaucoup de programmes acceptent ces cas avec une sévérité réduite.

Prouver l'impact sans nuire

La preuve acceptable dépend du programme : lis la politique, et en cas de doute, demande au triage avant de dépasser la détection.

Ce qui est généralement accepté comme preuve suffisante :

  • l'évaluation d'une expression arithmétique encadrée d'un marqueur unique, visible dans la réponse HTTP brute ;
  • l'identification du moteur par une sonde de distinction et par des indices passifs (erreurs, en-têtes) ;
  • une explication écrite, sourcée par la documentation du moteur, de ce qu'un attaquant pourrait atteindre si le moteur n'est pas isolé.

Certains programmes, explicitement, demandent davantage pour évaluer la criticité, par exemple l'affichage d'une valeur anodine et non sensible propre à l'application. Ne le fais que si la politique le prévoit noir sur blanc, et reste sur ce minimum.

Ce qui est interdit ou à proscrire :

  • lire des fichiers, des variables d'environnement, des secrets ou des données d'autres utilisateurs ;
  • exécuter des commandes, ouvrir une connexion sortante, déposer un fichier sur le serveur ;
  • tester sur un modèle partagé par d'autres utilisateurs (un e-mail envoyé à de vrais clients, par exemple).

Preuve minimale dans le rapport : la requête, la réponse brute avec zq749zq7, la sonde de distinction et sa réponse, le moteur déduit, puis un paragraphe d'impact argumenté. Précise que tu t'es arrêté volontairement à ce stade.

Évaluer la sévérité

ScénarioVecteur CVSS 3.1Score
SSTI sur un paramètre public, moteur non isoléAV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H9.8 Critique
SSTI dans un modèle d'e-mail éditable par tout utilisateur connectéAV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H8.8 Élevée
SSTI réservée à un rôle administrateur du back-officeAV:N/AC:L/PR:H/UI:N/S:U/C:H/I:H/A:H7.2 Élevée
Moteur strictement isolé, seules quelques variables de contexte exposéesAV:N/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N4.3 Moyenne

Pour mémoire, une CSTI qui aboutit à une XSS réfléchie se note généralement comme une XSS : AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N, soit 6.1.

Les trois premiers scores supposent un impact complet sur le serveur. Si le programme exige une démonstration et que tu ne l'as pas faite, présente ce vecteur comme potentiel et laisse le triage trancher : l'honnêteté sur ce point renforce ta crédibilité.

Corriger

La règle tient en une phrase : le template est du code écrit par les développeurs, la saisie est une donnée passée en contexte. Jamais l'inverse.

Python / Flask (Jinja2)

Code vulnérable : la saisie est insérée dans la source par une f-string, puis compilée.

from flask import Flask, request, render_template_string
 
app = Flask(__name__)
 
@app.route("/bienvenue")
def bienvenue():
    nom = request.args.get("nom", "invité")
    # VULNÉRABLE : la saisie devient une partie du template
    source = f"<h1>Bonjour {nom} !</h1>"
    return render_template_string(source)

Avec ?nom={{7*7}}, la page affiche Bonjour 49 !.

Code corrigé : la source est fixe, la saisie est passée comme variable et échappée automatiquement.

from flask import Flask, request, render_template_string
 
app = Flask(__name__)
 
BIENVENUE = "<h1>Bonjour {{ nom }} !</h1>"  # source constante
 
@app.route("/bienvenue")
def bienvenue():
    nom = request.args.get("nom", "invité")
    return render_template_string(BIENVENUE, nom=nom)

Mieux encore : place le template dans un fichier (templates/bienvenue.html) et utilise render_template("bienvenue.html", nom=nom). Avec ?nom={{7*7}}, la page affiche désormais littéralement Bonjour {{7*7}} !.

Node.js (Express + EJS)

Code vulnérable : la chaîne fournie par l'utilisateur est compilée comme template.

const express = require("express");
const ejs = require("ejs");
const app = express();
 
app.get("/bienvenue", (req, res) => {
  const nom = req.query.nom || "invité";
  // VULNÉRABLE : la saisie est concaténée dans la source EJS
  const html = ejs.render("<h1>Bonjour " + nom + " !</h1>");
  res.send(html);
});

Code corrigé : source constante, donnée en contexte, sortie échappée par la balise <%=.

const express = require("express");
const ejs = require("ejs");
const app = express();
 
const BIENVENUE = "<h1>Bonjour <%= nom %> !</h1>"; // source constante
 
app.get("/bienvenue", (req, res) => {
  const nom = String(req.query.nom || "invité");
  res.send(ejs.render(BIENVENUE, { nom }));
});

Quand les utilisateurs doivent vraiment écrire des modèles

Certaines fonctionnalités métier exigent des modèles éditables (e-mails marketing, factures). Dans ce cas :

  • utilise un moteur logic-less (Mustache, Handlebars sans helpers personnalisés dangereux) qui ne sait que substituer des variables ;
  • ou propose un mini-langage maison limité à une liste blanche de variables ([prenom], [numero_commande]) remplacées par une simple substitution de chaînes ;
  • à défaut, active le mode isolé du moteur (SandboxedEnvironment pour Jinja2, sandbox Twig avec politique stricte) et maintiens le moteur à jour.

Défense en profondeur : exécute le rendu avec un compte système aux droits minimaux, sans secrets dans l'environnement du processus, filtre les connexions sortantes, journalise les erreurs de rendu (une rafale d'erreurs de syntaxe est un signal d'attaque) et ajoute une règle d'analyse statique qui signale tout appel à render_template_string, Template(...) ou ejs.render avec une source non constante.

Erreurs qui font rejeter un rapport

  1. Confondre CSTI et SSTI : annoncer une compromission du serveur alors que l'évaluation a lieu dans le navigateur.
  2. Prendre une réflexion pour une évaluation : {{7*7}} renvoyé tel quel n'est pas une vulnérabilité.
  3. Dépasser la preuve autorisée : lire des fichiers ou l'environnement « pour montrer l'impact » expose à l'exclusion du programme.
  4. Ne pas identifier le moteur : sans moteur, le triage ne peut pas évaluer le risque réel et sous-note le rapport.
  5. Gonfler le CVSS sans argument : coller 9.8 sur une fonctionnalité réservée aux administrateurs avec un moteur isolé.
  6. Tester sur un modèle partagé : modifier un e-mail envoyé à de vrais clients crée un préjudice réel.

Checklist du hunter

  • Repérer les fonctionnalités de modèles, d'e-mails, de PDF et de prévisualisation.
  • Injecter un marqueur unique et suivre toutes ses réapparitions, y compris différées.
  • Envoyer {{7*7}}, ${7*7}, <%= 7*7 %> encadrés d'un marqueur.
  • Vérifier le résultat dans la réponse HTTP brute, pas dans le DOM.
  • Distinguer le moteur avec {{7*'7'}} et les indices passifs.
  • S'arrêter à la preuve d'évaluation ; demander au triage avant tout test supplémentaire.
  • Noter la sévérité selon les privilèges requis et l'isolation du moteur.
  • Proposer la correction : source constante, donnée en contexte.

Pour aller plus loin

  • PortSwigger Web Security Academy — Server-side template injection.
  • OWASP WSTG v4.2 — WSTG-INPV-18 : Testing for Server-side Template Injection.
  • CWE-1336 — Improper Neutralization of Special Elements Used in a Template Engine.
  • OWASP Top 10 2021 — A03:2021 Injection.
  • Documentation Jinja2 — Sandbox (SandboxedEnvironment) et documentation Twig — Sandbox Extension.
  • James Kettle, Server-Side Template Injection: RCE for the modern webapp (PortSwigger Research, 2015), pour la méthodologie de détection et d'identification.