A SaaS app lets users customize their notification emails with a template: Hi {{name}}, your order shipped. To render it, the backend does the obvious thing — drops the user's template string into the engine:
from jinja2 import Template
Template("Hi " + user_input + ", welcome!").render(name=user.name)A user sets their template to {{7*7}} and the email says "Hi 49, welcome!". That 49 is the whole vulnerability in one character: the engine evaluated the expression instead of printing it. From there it's a short walk to reading files and running shell commands, because a template expression can reach the language's object graph. This is server-side template injection, and unlike XSS it usually means remote code execution on your server.
Why {{7*7}} becomes a shell
Template engines are small programming languages. Once an attacker can write expressions, they navigate from a harmless object to something dangerous by walking Python's (or Ruby's, or Java's) introspection chain. The classic Jinja2 progression looks like this:
{{7*7}} -> 49 (confirms evaluation)
{{ ''.__class__.__mro__ }} -> reach the base object type
{{ ''.__class__.__mro__[1].__subclasses__() }} -> list every loaded class
... find subprocess.Popen in that list ...
{{ ...Popen('id', shell=True, stdout=-1).communicate() }} -> run a command
The details differ per engine — Twig, Freemarker, Velocity, Handlebars, ERB, and Razor each have their own escape gadgets — but the shape is identical: an expression language plus reflection equals code execution. Even "sandboxed" engines have repeatedly been escaped, because the object graph is vast and one reachable dangerous method is all it takes.
Finding it
Test any field whose value later appears in server-rendered output, especially:
- Email/notification templates, PDF and report generators
- "Custom message" or "signature" fields that get personalized per recipient
- CMS or page-builder features that let users insert variables
- Error pages or search results that reflect input through a template
Send an engine-appropriate probe and watch for evaluation: {{7*7}}, ${7*7}, #{7*7}, <%= 7*7 %>, {7*7}. If any renders as 49, you have SSTI. The specific syntax that fires tells you which engine you're in, and therefore which exploitation gadgets apply.
The fix: user input is context, never the template
The root cause is mixing the template (trusted code) with data (untrusted input). The fix is to keep them separate — exactly like parameterized queries keep SQL separate from data.
Defense in depth
Separating template from data is the fix; these reduce the damage if something slips through:
- Prefer logic-less engines for user content. Mustache/Handlebars substitute values without a full expression language, so there's far less to exploit than in Jinja2/Twig/Freemarker.
- Never compile a user-supplied string as a template. If customization is required, expose a fixed placeholder allowlist and precompiled layouts.
- Sandbox and drop privileges. Render in a process with no shell access, no cloud credentials, and network egress disabled — so a successful escape reaches very little.
- Patch the engine. Sandbox-escape gadgets are found regularly; keep the template library current.
The sentence to remember: if a user can supply template syntax, they can run code — so user input must only ever be a variable inside a template you wrote, never the template itself.
See the full chain in the SSTI simulation: start with {{7*7}}, walk the object graph to a command, then apply the context-variable fix and watch 49 turn back into a literal string. It's the server-side sibling of XSS — both are "input rendered as code," one in the browser, one on the server.
Frequently Asked Questions
Related posts
LDAP Injection: Bypassing Auth Through the Directory
LDAP injection lets an attacker rewrite a directory query with characters like * ( ) and | — bypassing login and dumping user data. Here's how the filter syntax gets abused and how to escape it correctly in Node.js, Python, and Java.
Aug 7, 2026 · 7 min readNoSQL Injection: When $gt and $where Bypass Your Login
NoSQL databases aren't immune to injection — they just get attacked differently. Here's how operator injection and $where JavaScript let attackers bypass auth in MongoDB, and how to stop it in Node.js and Python.
Aug 6, 2026 · 7 min readPath Traversal: Reading Files You Were Never Meant to Reach
Path traversal (directory traversal) turns a file parameter into a key to your whole filesystem. Here's how ../ attacks work, why string filtering fails, and how to canonicalize and confine paths in Node.js, Python, Java, and PHP.
Aug 5, 2026 · 8 min read