September 3, 2026
Updated: September 3, 2026
How a single {{7*7}} probe walks to remote code execution, and the one design rule that closes the whole class.
Abdalla Mohamed

SSTI (Server-Side Template Injection) is a web vulnerability that happens when an application embeds user input directly into a server-side template that the engine then evaluates as code, instead of passing that input safely as data. The classic proof is a single probe: send {{7*7}} into an input, and if the response comes back as 49, the server just evaluated your expression. From that 49, the walk to full remote code execution (RCE) and complete server takeover is often short. This guide covers what SSTI is, how to detect it, how to fingerprint the template engine, the payloads that matter for Jinja2, Twig, FreeMarker and others, the real-world CVEs it has caused, and, most importantly, the one architectural change that closes the entire class.
This guide covers SSTI in web security. The same acronym also means Skin and Soft Tissue Infection in medicine; that is a different topic entirely and not covered here.
Updated: September 2026. Reflects the PortSwigger detect-identify-exploit methodology, the 2025 error-based blind SSTI research, and recent CVEs including VMware Workspace ONE, changedetection.io, and BeyondTrust.
| What it is | User input is treated as template code, not template data |
| The probe | {{7*7}} (or ${7*7}) returns 49 in the response |
| Impact | Frequently remote code execution and full server takeover (CVSS up to 9.8–10.0) |
| OWASP class | A03 Injection (CWE-1336) |
| Common engines | Jinja2 (Python), Twig and Blade (PHP), FreeMarker and Velocity (Java), ERB (Ruby) |
| The one fix | Never pass user input as template source; pass it as a rendering variable |
The line to remember: {{7*7}} = 49 is the moment a template engine tells you, unprompted, that it will evaluate any expression you send it, on the server.
Template engines exist to merge data into a page: a developer writes a template like Hello {{ name }}, and the engine substitutes the real name at render time. That is safe, because name is data filling a placeholder in a template the developer wrote.
SSTI happens when the roles invert: the user controls the template string itself, not just the value dropped into it. Concretely, a vulnerable app does something like render_template_string("Hello " + user_input) instead of render_template("hello.html", name=user_input). Now the engine lexes and evaluates whatever expression syntax the attacker embeds. Because template engines are, by design, expression evaluators in the host language, controlling the template means controlling which expressions run, and in Python, PHP, Java, or Ruby, that quickly reaches operating-system commands.
That is the whole vulnerability: untrusted input crossing from data into code at a template-rendering sink. Everything after the {{7*7}} probe is engine-specific plumbing to get from expression evaluation to a shell.

SSTI is often mistaken for cross-site scripting because both can start from a reflected input, but the impact is a different universe. Reflected XSS is bounded by the victim's browser session; SSTI runs code on the server. The same input that would be a medium-severity XSS becomes a critical RCE the instant it reaches an expression-evaluating template engine, which is why an experienced tester treats {{7*7}} = 49 as a potential foothold, not a curiosity.
There is also client-side template injection (CSTI), where the vulnerable engine (Angular, Vue, and similar) runs in the browser. The quick way to tell them apart: re-request the input with a non-browser client such as curl. If 49 appears in the raw HTTP response body, it is server-side (SSTI); if it only appears in the rendered DOM after JavaScript runs, it is client-side (CSTI).
Authorized testing only. The probes and payloads below are for defenders, and for testing systems you are explicitly authorized to assess. Running them against systems you do not own or have permission to test is illegal. The payloads shown are the well-known, publicly documented detection and identification primitives; the goal here is to detect, understand, and prevent SSTI.
The industry-standard approach, documented by OWASP's Web Security Testing Guide, follows three stages: detect, identify, exploit. Detection comes first.
Fuzz with a polyglot. Inject a string of special characters drawn from many template syntaxes at once, such as ${{<%[%'"}}%\, into every input that ends up rendered. If the server throws an exception, a 500, or otherwise mangles the response rather than echoing the string literally, template processing is likely reaching your input. (This polyglot was ranked the #1 web hacking technique of 2025 for turning blind SSTI scanning into single-probe engine fingerprinting.)
Then test the two contexts. SSTI appears in two forms, each needing its own check:
render("Hello " + username). Send username={{7*7}} (or ${7*7}) and look for Hello 49. A numeric result confirms server-side evaluation.greeting=data.username. Here you first break out of the expression, for example by appending }}, then observe whether injected syntax is evaluated rather than errored or blanked.Reproduce any hit with two more probes, {{8*9}} should give 72 and {{13*13}} should give 169, so you know you are seeing evaluation and not a coincidence or a cached value.
Exploitation (and remediation guidance) is entirely engine-dependent, so identification is the pivotal step. Two techniques do most of the work.
Error messages. Submitting invalid syntax like <%=foobar%> often returns a stack trace that names the engine outright, jinja2.exceptions.TemplateSyntaxError, Twig\Error\SyntaxError, freemarker.core.ParseException, mako.exceptions.SyntaxException. One malformed probe frequently ends the guessing.
Differential arithmetic. When errors are suppressed, use probes whose result differs by engine. The canonical one is {{7*'7'}}: Jinja2 returns 7777777 (Python string repetition) while Twig returns 49 (PHP numeric coercion). This single probe separates the two most common dual-brace engines.
| Engine | Language | Detection probe | {{7*'7'}} | Error signature |
|---|---|---|---|---|
| Jinja2 | Python | {{7*7}} → 49 | 7777777 | jinja2.exceptions.* |
| Twig | PHP | {{7*7}} → 49 | 49 | Twig\Error\SyntaxError |
| Blade (Laravel) | PHP | {{7*7}} → 49 | 49 | Laravel/PHP error |
| FreeMarker | Java | ${7*7} → 49 | n/a | freemarker.core.ParseException |
| Velocity | Java | #set($x=7*7)$x → 49 | n/a | ParseErrorException |
| ERB | Ruby | <%= 7*7 %> → 49 | n/a | (erb):N:in ... |
| Mako | Python | ${7*7} → 49 | n/a | mako.exceptions.* |
A useful caution: the same payload can succeed in more than one engine, so never conclude from a single response. Practitioner references such as HackTricks and PayloadsAllTheThings catalog the full decision trees and engine-specific chains.
Once the engine is known, an SSTI injection payload moves from a harmless arithmetic probe to an expression that reaches the host runtime. These are the classic, publicly documented primitives, shown here so defenders can recognize them in logs and code review. The takeaway is not any single string; it is that every expression-evaluating engine has a published path from template control to RCE.
os module, for example {{ cycler.__init__.__globals__.os.popen('id').read() }}. Detection artifacts include {{config}} dumping the Flask config (and SECRET_KEY)._self environment, for example registering system as an undefined-filter callback; modern payloads route through callback-accepting filters like {{ ['id']|filter('system') }}.Execute built-in, <#assign ex="freemarker.template.utility.Execute"?new()>${ ex("id") }.java.lang.Runtime via reflection on the class tools.<%= system("id") %> or <%= `id` %>.In every case, a benign command like id is used first to prove execution, exactly as it is in a legitimate penetration test, because the point is to demonstrate impact, not cause damage. This is one of the flaws that pure scanners routinely miss and that a human finds, which is why manual penetration testing matters for this class.

Jinja2, the default engine for Flask, is the most-studied SSTI target, and the secondary search terms (jinja2 ssti, flask ssti, jinja ssti) reflect that. The reason it is so dangerous is that a plain Jinja2 Environment exposes the entire Python object graph: from any value in the template context, an attacker can read __class__, walk __mro__ and __subclasses__(), and reach modules where os is already imported, ending at subprocess.Popen or os.popen.
Jinja2 ships a SandboxedEnvironment (and ImmutableSandboxedEnvironment) that blocks dunder attribute access and defuses the classic object-graph walk. It genuinely helps, but it is not absolute: when _ and __ are filtered, attackers reach the same primitives through Flask globals like lipsum, cycler, and namespace, combined with the |attr() filter and hex escapes. The sandbox has also had outright breakouts, notably CVE-2024-56326 and CVE-2024-56201 (fixed in Jinja 3.1.5), where a str.format oversight and a malicious-filename bug allowed code execution even inside the sandbox. The lesson: treat the sandbox as one layer, keep Jinja patched, and never rely on it as your only defense.
Because django ssti is a common query, this deserves a clear answer: Django's built-in template language (DTL) is architecturally resistant to SSTI-to-RCE, but Django applications are not automatically safe.
DTL is not an expression evaluator. When it sees {{ 7*7 }} it raises a TemplateSyntaxError at parse time rather than returning 49, it forbids attribute access that begins with an underscore (killing __class__ traversal), and an undefined name simply renders as an empty string. There is no path to the Python object graph, because the only name resolution DTL has is a lookup against the context dictionary the view explicitly passed.
That guarantee evaporates in two situations:
Template(user_input). If a developer feeds a user-controlled string straight into django.template.Template(), the user is now writing the template. In a pure-DTL project the blast radius is bounded, data disclosure and tag invocation, not RCE, because the parser still refuses to evaluate expressions. It is still a real stored-injection bug, just not a shell.from_string() on user input inherits the full Jinja2 object-graph RCE described above, with SandboxedEnvironment (not the default) as the only architectural safeguard.So: distrust any Template() or from_string() call whose argument is not a literal, the same way you would distrust cursor.execute(raw_sql).
SSTI is rare on a first scan and easy to introduce by accident, and in 2024 to 2026 it has clustered in a predictable set of surfaces where developers let users supply "templates" for personalization:
The pattern is consistent: a feature that "lets users customize the copy" quietly routes user input into from_string() or createTemplate(). When you test web applications and APIs, these are the first places to probe, and they are the same places our own application security research keeps surfacing high-impact bugs.
SSTI is not academic. A sample of documented, high-impact cases:
| CVE | Product | Engine | Why it matters |
|---|---|---|---|
| CVE-2022-22954 | VMware Workspace ONE Access | FreeMarker | Unauthenticated SSTI, CVSS 9.8, added to CISA KEV; a working exploit appeared roughly 48 hours after disclosure, with Mirai and cryptominers, then APTs, exploiting it at scale |
| CVE-2025-5309 | BeyondTrust Remote Support | Blade (Laravel) | Both authenticated and unauthenticated RCE; the unauthenticated path fired when a support agent merely viewed a chat transcript containing the payload |
| CVE-2024-32651 | changedetection.io | Jinja2 | Notification-body template rendered with from_string() and no sandbox, critical RCE and full host takeover |
| CVE-2024-45053 | Fides | Jinja2 | Email-templating feature allowed a privileged user to reach RCE on the webserver container |
| CVE-2024-56326 / -56201 | Jinja (library) | Jinja2 | Sandbox breakouts fixed in 3.1.5, proof that a sandbox is not a guarantee |
The VMware case is the canonical warning: a single user-controlled string routed through an expression-evaluating engine, exposed pre-authentication, became one of the most exploited vulnerabilities of its year. Cases like these are why keeping software patched against known-exploited template-engine flaws is non-negotiable.
The good news: unlike many bug classes, SSTI has a clean architectural fix that closes the whole category.
render_template('file.html', name=user_input), never render_template_string(user_input); the equivalent holds for Twig::createTemplate, FreeMarker new Template(...), and Handlebars compile(). If the string being rendered is user-controlled at any of those sinks, that is your SSTI.SandboxedEnvironment, Twig's SecurityPolicy allowlist, FreeMarker's SAFER_RESOLVER, and Velocity's SecureUberspector all raise the bar, but every one has published bypasses. Use them behind the "no user templates" rule, never instead of it.render_template_string misuse) find dangerous sinks in code review; a WAF blocks the obvious payloads (__class__, _self.env, ?new()) as one opportunistic layer. Neither replaces the architectural fix.For engineers auditing a codebase, grep for the dangerous sinks directly: Jinja2 from_string and render_template_string, Twig createTemplate, FreeMarker new Template(... StringReader(userInput) ...), Velocity Velocity.evaluate, and Handlebars compile(userInput). Any of these with a non-literal argument is a finding.
Because SSTI hides in personalization features and only becomes obvious to a tester who is explicitly hunting for it, it is a textbook example of what automated scanners miss and manual testing catches. Our testers fuzz every rendered input with polyglot and arithmetic probes, fingerprint the engine, and, on authorized engagements, prove impact with a benign command rather than leaving you a "potential SSTI" guess, then document the exact sink and the architectural fix in the penetration testing report. It is the same depth-over-breadth approach behind our published research on client-side path traversal and subdomain JWT account takeover.
SSTI is one of the highest-impact bugs in web security precisely because it looks trivial at first: a little arithmetic gets evaluated. But {{7*7}} = 49 is an engine admitting it will run whatever expression you give it, and from there the path to remote code execution and server takeover is well-worn in Jinja2, Twig, Blade, FreeMarker, and beyond. Detect it by probing every rendered input, identify the engine before anything else, and understand that sandboxes only slow a determined attacker down. The durable defense is architectural and simple to state: keep templates as code the developer wrote, and keep user input as data. Any place those two cross is an RCE waiting for a vector.
DeepStrike's penetration testing hunts SSTI and the rest of the injection class the way real attackers do, proving exploitability on the systems you authorize and handing your engineers the exact sink and fix. For US-based teams, see our US penetration testing services.
SSTI (server-side template injection) is a web vulnerability where an application inserts user input directly into a server-side template that the engine then evaluates as code, rather than passing the input as data. The tell-tale sign is that a probe like {{7*7}} returns 49. Because template engines evaluate expressions in the host language, SSTI frequently escalates to remote code execution and full server takeover.
In web security, SSTI stands for Server-Side Template Injection, a vulnerability class under OWASP A03 Injection that can lead to remote code execution. The same acronym is also used in medicine for Skin and Soft Tissue Infection, an unrelated topic. This guide covers the security meaning only. If you are researching the medical term, a clinical source is the right reference, not this page.
Inject {{7*7}} (or ${7*7} for Java and some Python engines) into any input that gets rendered, then check the response. If it returns 49, the server evaluated the expression, confirming SSTI. Reproduce with {{8*9}} (72) and {{13*13}} (169) to rule out coincidence, and fuzz with the polyglot ${{<%[%'"}}%\ to trigger an engine-identifying error.
Django's built-in template language (DTL) is architecturally resistant: {{ 7*7 }} raises a syntax error instead of evaluating, and it blocks underscore attribute access, so classic object-graph RCE does not work. However, a Django app becomes vulnerable if a developer passes user input to Template() (a bounded injection) or uses the Jinja2 backend's from_string() on user input, which restores full RCE risk.
Both can start from a reflected input, but the impact differs enormously. XSS executes in the victim's browser and is bounded by their session. SSTI executes on the server and typically leads to remote code execution and complete host compromise. An input that returns 49 for {{7*7}} is server-side template injection, not XSS, and should be triaged as a potential foothold rather than a client-side issue.
Never pass user input as template source. Load templates from files the developer wrote and supply user data only as rendering variables (render_template('file.html', name=user_input), not render_template_string(user_input)). Prefer logic-less engines, treat sandboxes like Jinja2's SandboxedEnvironment as defense in depth rather than a guarantee, isolate any renderer that must handle untrusted templates, and flag dangerous sinks in CI.

Stay secure with DeepStrike penetration testing services. Reach out for a quote or customized technical proposal today
Contact Us