Skip to content

Detection & Fingerprinting

Two questions, two probes:

# 1. Is the input evaluated as template syntax (vs. string-substituted)?
{{7*7}} # SSTI: returns 49. String sub: returns "{{7*7}}".
# 2. Which engine?
{{7*'7'}} # Jinja2: '7777777'. Twig: 49.

Two probes, three outcomes:

  • {{7*7}} unchanged → not Jinja-family. Try ${7*7}, <%= 7*7 %>, @(7*7) next.
  • {{7*7}}49, {{7*'7'}}7777777 → Jinja2 (Python-side).
  • {{7*7}}49, {{7*'7'}}49 → Twig (PHP-side).

The probe below covers six common engines in one string. Submit it once - the output tells you which engines saw it and which didn’t:

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

The malformed mix triggers parser errors in template engines that interpret any of those delimiters - Jinja2 throws a TemplateSyntaxError, Twig logs a Twig_Error, Freemarker logs a ParseException, etc. The exact error message often names the engine in the traceback.

If error responses are suppressed, fall back to the per-syntax probes below.

SyntaxEngine familiesProbeReturns on hit
{{ ... }}Jinja2 / Twig / Nunjucks / Handlebars{{7*7}}49
${ ... }Freemarker / Velocity / Thymeleaf (th:text)${7*7}49
<%= ... %>ERB / EJS / JSP <%=<%= 7*7 %>49
#{ ... }Pug / Ruby #{} interp / Slim#{7*7}49
@( ... )Razor@(7*7)49
[[ ... ]]Smarty (alt delim)[[7*7]]49
{ ... }Smarty (default), Mustache (limited){7*7}49

If none of the arithmetic probes evaluate, it’s almost certainly not SSTI - the input is being string-substituted or escaped before reaching any template engine.

A few patterns look like SSTI hits but aren’t:

  • {{7*7}} returns 7*7 literally, not {{7*7}} - the delimiters were stripped by a template engine that DIDN’T evaluate the expression. This means there’s a templating layer, but expressions are being treated as variable names that don’t exist. Sometimes you can still escape - try {{7}}{{7}} to see if 7 evaluates.
  • {{7*7}} returns the rendered HTML page with 49 somewhere - check whether 49 is in your reflection point or somewhere else on the page. A coincidence is possible if the page already contains the number 49.
  • <%= 7*7 %> returns 49 but only in a downloaded file (PDF, CSV) - sink is in a templated report generator, not in HTML. Still SSTI, just a less interactive testing loop.

Engine identification - disambiguation matrix

Section titled “Engine identification - disambiguation matrix”

Once {{7*7}} or ${7*7} evaluates, narrow to a specific engine. Run probes in order; first divergence wins.

{{7*'7'}}
  • Jinja2 (Python) → 7777777 - string-multiply semantics
  • Twig (PHP) → 49 - numeric coercion
  • Nunjucks (Node) → 49 - numeric coercion
  • Handlebars (Node) → error or unchanged - Handlebars doesn’t do arithmetic

Then to split Twig vs Nunjucks (both returned 49):

{{request}}
  • Twig → dumps the Symfony Request object (HTTP request)
  • Nunjucks → empty or undefined (no global request symbol)

Or the surest tell - try a constructor expression:

{{ "".__class__ }} # Jinja2: <class 'str'>
{{ _self }} # Twig: object dump of __TwigTemplate_...
{{ range }} # Nunjucks: [object Function]

Some applications strip {{, ${, or <% outright. A few approaches:

# Whitespace inside delimiters
{{ 7*7 }} # still works, but most filters catch this too
{ {7*7} } # broken - delimiters split
# Comment-fenced probes (engines that support comments)
{#- 7*7 -#} # Jinja2 comment - tests engine presence
{%- if 7*7 %}YES{%- endif %} # Jinja2 statement block
# Alternate delimiters (configurable in some engines)
<% if 7*7 %>YES<% endif %> # if engine uses <% as alt
[[7*7]] # if Smarty was configured with alt delims
# Encoded
%7B%7B7*7%7D%7D # URL-encoded {{7*7}}
&#x7b;&#x7b;7*7&#x7d;&#x7d; # HTML entity {{7*7}}
\u007b\u007b7*7\u007d\u007d # Unicode escape in JSON contexts

If {{ and ${ are both stripped, but the reflection still looks suspicious, check whether the application is using an unusual engine - Pug (#{...}), Slim, or Razor (@(...)) won’t be caught by the obvious filters.

Not every SSTI sink reflects to the screen. Probes that work where direct reflection doesn’t:

  • Email body - submit {{7*7}} in the field, trigger the email, read it. Common in “share / invite” features.
  • PDF/CSV report - submit probe, download the generated report. Templated reports frequently SSTI when the HTML view doesn’t.
  • Webhook payload - submit probe in a field that becomes part of an outgoing webhook. Inspect the receiving side (your <COLLAB> host).
  • Admin notification - probe in a user-facing field, trigger an action that emails an admin. Admin’s mail client renders it.
  • Logs/dashboard - some monitoring tools render log content as templates in their UI. Probe via a log-producing action.

Output-channel SSTI is high-value because filters in those paths are weaker than the HTML rendering path. The same input that’s HTML-escaped on the web page might be raw in the PDF.

A confirmed SSTI is unusually distinctive - the arithmetic-eval signal is hard to fake. If every probe returns unchanged across every syntax family, it’s almost certainly not SSTI. Switch to:

  • Reflected XSS if the reflection appears in HTML context unescaped → web XSS pages
  • SQL/Command injection if the input ends up in a query or shell command → other web pages
  • SSRF if the input is a URL-shaped field → SSRF

Don’t spend more than five minutes probing for SSTI in a field where every probe returns unchanged. The signal-to-noise ratio is too good for prolonged speculation.