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

Detecting and Confirming SSTI

How to detect SSTI with a polyglot and math probes, tell it apart from XSS, and confirm the server's template engine actually evaluated your input.

Published

Detecting SSTI means proving that the server's template engine evaluated your input, not just reflected it. The probes are simple, but the discipline is in confirming server-side evaluation and not confusing it with XSS. This page covers the detection polyglot, the math probes, and how to confirm. Practice on a free live target.

Step 1: send a math probe in template syntax

The fastest signal is arithmetic the engine will compute. Send a small multiplication wrapped in the engine's delimiters into any field that echoes back, and look for the computed result:

POST /greeting HTTP/1.1
Host: target.example
Content-Type: application/x-www-form-urlencoded
 
name={{7*7}}
HTTP/1.1 200 OK
Content-Type: text/html
 
<p>Hello 49</p>

49 in the response means something evaluated 7*7. That is your lead. Different engines use different delimiters, so keep the whole family ready:

{{7*7}}        Jinja2, Twig, Nunjucks, Liquid, Django variable syntax
${7*7}         Freemarker, Velocity, JSP EL, Thymeleaf
#{7*7}         some expression languages
<%= 7*7 %>     ERB (Ruby), EJS (Node)
{7*7}          Smarty
@(7*7)         Razor (.NET)

When you do not know the syntax yet, the detection polyglot is designed to throw an error or reflect oddly in several engines at once, which flags a template context worth probing properly:

${{<%[%'"}}%\

An unusual error or a mangled fragment in the response is a sign the input reached a template parser.

Step 2: rule out XSS and coincidence

A reflected 49 is not proof on its own. Two things to eliminate:

  • Was it the server or the browser? Look at the raw HTTP response, not the rendered page. If the computed 49 is already in the response body the server sent, the server evaluated it. If instead your literal expression is in the response and only executes once a browser processes it, you are looking at reflection or XSS, not SSTI.
  • Was it a coincidence? A field might just happen to contain 49. Change the numbers ({{8*8}} returning 64) to confirm the server is actually computing your expression rather than echoing a fixed value.

Step 3: confirm with a string expression

Clinch it with an expression a plain reflector could never produce, using string operations the engine evaluates:

POST /greeting HTTP/1.1
Host: target.example
Content-Type: application/x-www-form-urlencoded
 
name={{'inj'~'ected'}}
HTTP/1.1 200 OK
Content-Type: text/html
 
<p>Hello injected</p>

The two string literals were concatenated by the engine into injected, which only happens if the engine ran your code. Concatenation operators differ by engine (~ in Jinja2/Twig, + elsewhere), so this doubles as an early fingerprint hint. Now you have confirmed SSTI and can move to identifying the exact engine.

Edge cases worth trying

  • Delimiter is stripped or encoded: try the alternate syntaxes, and try submitting the payload in a different parameter or header that feeds the same template.
  • Only errors come back: a stack trace from a template parser is itself strong evidence, and often names the engine.
  • No reflection at all: the render may happen out of band (an email or PDF). That is blind SSTI, covered in blind SSTI.

Fixing it

  • Do not render user input as a template. Pass it as a context variable so a probe like 7*7 is never evaluated.
  • Escape output so a reflected expression cannot become XSS either.

Full guidance is in preventing SSTI. Next, pin down the exact engine in identifying the engine, or run a probe on a free lab.

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