Server-Side Template Injection (SSTI): The Complete Practical Guide

Identifying the Template Engine

How to fingerprint the SSTI engine: the string-multiplication probe that splits Jinja2 from Twig, engine-specific tells, and the decision tree.

Published

Once you have confirmed SSTI, the next job is fingerprinting the exact engine, because the exploit path is completely engine-specific. A Jinja2 payload does nothing against Freemarker. This page gives the decision tree, starting with the single probe that splits the two most common engines apart. Practice on a free live target.

Step 1: confirm the delimiter family

You partly did this during detection: whichever math-probe syntax computed tells you the family. Curly-brace {{...}} points at Jinja2, Twig, Nunjucks, or Liquid. Dollar-brace ${...} points at Freemarker, Velocity, or an expression language, and the others at ERB, EJS, Smarty, or Razor. That narrows the field but rarely pins one engine, so you run a discriminator.

Step 2: the string-multiplication discriminator

This is the elegant part. Multiply a number by a quoted digit and read the result:

POST /greeting HTTP/1.1
Host: target.example
Content-Type: application/x-www-form-urlencoded
 
name={{7*'7'}}

The result depends on the host language's type rules, which splits the two big curly-brace engines cleanly:

{{7*'7'}}  ->  7777777   Jinja2 (Python: int times string repeats the string)
{{7*'7'}}  ->  49        Twig   (PHP: the string is coerced to a number)

So a repeated string means Jinja2, a plain 49 means Twig. This is the single most useful fingerprint probe, and it is reliable because you are really testing Python-versus-PHP semantics, not the template syntax. A response of:

HTTP/1.1 200 OK
Content-Type: text/html
 
<p>Hello 7777777</p>

tells you Jinja2 (Python) in one request.

The decision tree

{{7*7}} == 49 ?
  |-- yes --> {{7*'7'}} == ?
  |             |-- 7777777  --> Jinja2 (Python)
  |             |-- 49       --> Twig (PHP)  [or Nunjucks (JS), which also coerces to 49]
  |-- no  --> ${7*7} == 49 ?
                |-- yes --> Freemarker / Velocity / expression language
                |-- no  --> try <%= 7*7 %> (ERB / EJS) , {7*7} (Smarty) , @(7*7) (Razor)

Step 3: confirm with an engine-specific tell

When the family and arithmetic still leave two candidates, probe for something unique to the engine:

Jinja2:   {{config}}         renders the Flask config object
Twig:     {{_self}}          renders a Twig template self-reference
Freemarker: a stack trace naming freemarker.* on a bad expression
Velocity: ${class.inspect(...)} style reflection responds
Smarty:   {$smarty.version}  renders the Smarty version

A {{config}} that dumps a Flask configuration object confirms Jinja2. A {{_self}} that renders confirms Twig. Error strings help too: a parser exception usually names its engine package. Once you have a unique tell, you have the engine and can commit to its exploit path.

Why getting this right matters

Firing the wrong engine's payload wastes requests and can trip detection for nothing. The string-multiplication probe plus one unique tell gets you to certainty in two or three requests, after which you go straight to the matching exploitation spoke: Jinja2, Twig and PHP, or Freemarker and Velocity.

Fixing it

Fingerprinting only works because the engine evaluates your input at all. The fix is the same as every SSTI: pass user input as a context variable so no probe evaluates, and keep the engine patched so version tells cannot guide an attacker to a known exploit. Full guidance is in preventing SSTI.

Where to go next

With the engine identified, pick its exploit path. Most commonly that is exploiting Jinja2. Or try the discriminator on a free live target.

← Back to Server-Side Template Injection (SSTI): The Complete Practical Guide