Skip to content

SSTI

The application takes input that gets rendered as part of a template, not just substituted as a string. Anything the template engine can express - arithmetic, attribute access, method calls, imports - you can do. In most engines, that path leads to RCE within three or four payloads.

# Probe
{{7*7}} # Jinja2/Twig/Nunjucks: returns 49
${7*7} # Freemarker/Velocity: returns 49
<%= 7*7 %> # ERB: returns 49
#{7*7} # Pug/Ruby/Slim: returns 49
@(7*7) # Razor: returns 49
# Confirm it's the template, not a coincidence
{{7*'7'}} # Jinja2: '7777777' Twig: 49
{{config}} # Jinja2/Nunjucks: dumps config object

Success indicator: 7*7 reflects as 49 (not as the literal string). Different syntax dialects tell you which engine.

Any reflection point where input shows up in HTML, email bodies, error messages, or PDFs - and the reflection is not HTML-escaped the way XSS sinks are. SSTI sinks tend to be in templated output features:

  • Email templates - “send invitation” / “password reset” - the customer-name field is usually templated
  • Error pages - applications that render a user-supplied error message
  • Report generators - PDF/HTML report builders that accept template fragments
  • CMS pages - any “page builder” or “snippet” feature that lets non-developers write template syntax
  • Profile / display-name fields - rendered later in admin dashboards or notification emails
  • URL slugs that become page titles - /<SLUG> where <SLUG> is echoed into a templated <title>
  • Configuration values - dashboards where admins can set “welcome message” or “site name”
  • Webhook payload templates - integrations that let users format the outgoing payload

If you can describe the feature as “the application takes my input and uses it to build something else for someone else to see,” it’s an SSTI candidate.

  1. Confirm template evaluation - does {{7*7}} / ${7*7} / <%= 7*7 %> return 49? → Detection
  2. Identify the engine - syntax dialect + behavior of polyglot probes narrows it down → Detection
  3. Engine = Jinja2 / Flask / Django / AnsibleJinja2
  4. Engine = Twig / Symfony / Craft / Drupal 8+Twig
  5. Engine = Freemarker / Confluence / SpringFreemarker
  6. Engine = Velocity / Liferay / legacy SpringVelocity
  7. Engine = ERB / Rails / PuppetERB
  8. Engine = Handlebars / Express / GhostHandlebars
  9. Engine = Razor / ASP.NETRazor
  10. Inside a sandboxed environment - payload evaluates but os/subprocess/Runtime unreachable → Sandbox escape
  11. Payload rejected / filtered - keywords like system, __class__, Runtime stripped → Filter bypass

The probes below differ between engines. Run them in order; the first one that produces a useful divergence identifies the engine.

ProbeJinja2TwigFreemarkerVelocityERBHandlebarsRazor
{{7*7}}4949errorerrorunchanged49unchanged
${7*7}unchangedunchanged4949unchangedunchangedunchanged
<%= 7*7 %>unchangedunchangedunchangedunchanged49unchangedunchanged
@(7*7)unchangedunchangedunchangedunchangedunchangedunchanged49
{{7*'7'}}777777749n/an/an/aerrorn/a
{{config}}dumps configdumps confign/an/an/aemptyn/a
{{self}}object reprobject reprn/an/an/aunchangedn/a

“unchanged” means the literal string {{7*7}} appears in the response - the engine doesn’t recognize the syntax, so it’s not that engine.

The single most diagnostic probe is {{7*'7'}}: Jinja2 does string repetition ('7777777'), Twig does numeric coercion (49). Same input, different behavior, immediate disambiguation.

TierWhat you getHow
1Confirmed arithmetic eval{{7*7}} returns 49
2Object/attribute access{{config}} dumps secrets, dump session tokens, exfil environment vars
3Function calls in sandboxUse built-in filters/functions to read files or trigger SSRF
4Sandbox escapeWalk the object graph to reach os/subprocess/Runtime
5RCEDirect command execution as the application user

Tiers 1-2 are nearly free on most engines. Tier 3 depends on filters available. Tiers 4-5 are the engine-specific escape sequences documented per page.

The bug is misclassified by developers and frameworks alike. Most teams think “we escape HTML, so this can’t be XSS” and stop there - but SSTI evaluates before the HTML escaping step. The template engine has already constructed a Python/Ruby/Java object graph by the time HTML escaping runs.

This means:

  • The same input that gets escaped on the way to the browser already gave you RCE on the server.
  • Tools designed to catch XSS (<script> blocklists, CSP headers, sanitizers) miss SSTI entirely.
  • Bug bounty triage frequently downgrades SSTI to XSS until you demo cat /etc/passwd.

The detection page covers polyglot probes that survive most filters; the per-engine pages document the path from arithmetic eval to RCE for each common engine.

The single most useful habit: probe with {{7*7}} first, always. It works on six of the seven engines listed and produces an immediate, unambiguous signal. Save the dialect-specific probes (${...}, <%= ... %>, @(...)) for when {{...}} returns unchanged.

The second most useful habit: try probing in fields you wouldn’t expect to be templated. Display names, profile bios, location strings, “company name” in invoice generators. The high-yield SSTI bugs are in fields whose template-ness is non-obvious. Anything reflected to another user (an admin, the recipient of an email) is worth probing.

The detection page is the right starting point unless you already know the engine. The per-engine pages assume you know what you’re working with and want the shortest path to RCE.