Do not evaluate request input as SpEL with a StandardEvaluationContext: it allows T(...) type references and method calls, so the formula runs code. Use SimpleEvaluationContext.forReadOnlyDataBinding() with the row as the root object, which allows only property navigation and operators, or parse a fixed arithmetic grammar yourself.
- Find every place request input reaches a SpEL parser (SpelExpressionParser.parseExpression) and is evaluated. Treat a StandardEvaluationContext over user input as remote code execution.
- Switch to SimpleEvaluationContext.forReadOnlyDataBinding().build() and pass the data object as the root. It supports property access and the arithmetic, relational and logical operators, and refuses type references, bean references and constructor or static method calls.
- Keep the data model narrow: expose only the properties the formula needs (price, quantity, discount), so even property navigation cannot wander.
- If the feature needs only arithmetic, parse that grammar yourself instead of using SpEL at all; the control is the grammar, not a denylist.
- Reject anything the context will not evaluate with a 400, never a 500 and never a fallback to StandardEvaluationContext.
- Add regression tests that send T(java.lang.System)... and a reflection payload that evades a denylist, and assert no code ran.
The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.
The vulnerable code
@Override
public double evaluate(String formula, QuoteRow row) {
Expression expr = PARSER.parseExpression(formula);
StandardEvaluationContext ctx = new StandardEvaluationContext(row);
Number value = expr.getValue(ctx, Number.class);
return value == null ? Double.NaN : value.doubleValue();
}With IMPL=vulnerable, 2 of 5 tests fail (2 exploit tests).
The fix
@Override
public double evaluate(String formula, QuoteRow row) {
Expression expr = PARSER.parseExpression(formula);
SimpleEvaluationContext ctx = SimpleEvaluationContext.forReadOnlyDataBinding().build();
// The row is the root object; only its properties (price, quantity, discount) are reachable.
Number value = expr.getValue(ctx, row, Number.class);
return value == null ? Double.NaN : value.doubleValue();
}With IMPL=fixed, 5 of 5 tests pass.
A fix that looks safe but is not
@Override
public double evaluate(String formula, QuoteRow row) {
if (BLOCKED.matcher(formula).find()) {
throw new IllegalArgumentException("formula contains a blocked token");
}
Expression expr = PARSER.parseExpression(formula);
StandardEvaluationContext ctx = new StandardEvaluationContext(row);
Number value = expr.getValue(ctx, Number.class);
return value == null ? Double.NaN : value.doubleValue();
}With IMPL=wrong, 1 of 5 tests fail (1 exploit test).
Route: a formula is data, not a 500 risk
// The caller sends a pricing formula and a row of numbers. The evaluator returns a number or
// throws; a formula it will not compute is a 400, never a 500, and never run as code.
@PostMapping("/quote")
ResponseEntity<?> quote(@RequestBody QuoteRequest body) {
String formula = body.getFormula();
if (formula == null || formula.isEmpty() || formula.length() > 500) {
return ResponseEntity.badRequest().body(Map.of("error", "formula must be a non-empty string under 500 chars"));
}
QuoteRow row = body.getRow() == null ? new QuoteRow() : body.getRow();
try {
double result = evaluator.evaluate(formula, row);
if (!Double.isFinite(result)) {
return ResponseEntity.badRequest().body(Map.of("error", "formula did not produce a finite number"));
}
return ResponseEntity.ok(Map.of("result", result));
} catch (Exception e) {
return ResponseEntity.badRequest().body(Map.of("error", "cannot evaluate formula"));
}
}The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.
// No formula may run code. The marker property must stay unset; a safe evaluator rejects these.
@Test
void exploitAFormulaCannotCallASystemMethod() throws Exception {
quote("T(java.lang.System).setProperty('" + MARKER + "','pwned')");
assertThat(System.getProperty(MARKER)).as("formula ran code via T(java.lang.System)").isNull();
}
@Test
void exploitADenylistIsEvadedByReflection() throws Exception {
// Reaches System through java.lang.Class.forName with a split name, so the denylist of
// "System"/"Runtime"/"exec" never matches the source text.
quote("T(java.lang.Class).forName('java.lang.Sys'+'tem')"
+ ".getMethod('setProperty',T(java.lang.String),T(java.lang.String))"
+ ".invoke(null,'" + MARKER + "','pwned')");
assertThat(System.getProperty(MARKER)).as("formula ran code via reflection").isNull();
}- A denylist of tokens (T(, Runtime, exec, System) is not a grammar. T(java.lang.Class).forName('java.lang.Sys'+'tem') reaches the same place through a class the list did not ban and a split string. That is this card's wrong implementation.
- StandardEvaluationContext is the default power tool: it allows type references, constructors and bean references, so any expression built from input is dangerous.
- Evaluating a user-supplied template with the TemplateParserContext and a StandardEvaluationContext has the same risk; see the server-side template injection card.
- Exposing a rich root object to SimpleEvaluationContext still lets a formula read any reachable property; limit the model.
- Returning the raw evaluated value can leak internal data even when the result is a number.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs code-injection/java-spring does this for you).
- The exploit tests send T(java.lang.System).setProperty(...) and a reflection payload that evades the denylist, and assert the marker system property was never set. The functional tests keep ordinary arithmetic working.
- On your own endpoint, confirm a type-reference or reflection formula neither sets a property, reads the environment nor starts a process. Semgrep's SpEL rule flags every site (justify the SimpleEvaluationContext one); CodeQL distinguishes the safe context. The regression tests are the control for the denylist bypass.
What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | java.spring.security.audit.spel-injection.spel-injection | Flagged all three SpEL evaluation sites: VulnerableFormulaEvaluator, WrongFormulaEvaluator and FixedFormulaEvaluator. It is an audit rule that cannot see the context type, so the fixed hit (which uses SimpleEvaluationContext, the mitigation) must be reviewed and justified.Checked: Semgrep 1.180.0 (CE) in semgrep/semgrep:1.180.0 (src/main/java) |
| CodeQL | java/spel-expression-injection | Models an expression parsed by SpelExpressionParser and evaluated against a StandardEvaluationContext as a SpEL-injection sink, with @RequestBody as a remote source, so it is expected to flag the vulnerable and wrong evaluators. SimpleEvaluationContext (the fixed evaluator) is the documented mitigation and is not reported.Checked: github/codeql main; not executed |
| Find Security Bugs (SpotBugs plugin) | SPEL_INJECTIONPotential code injection when using Spring Expression | Not run on this card.Checked: Bug pattern listed on the Find Security Bugs site; not executed |
Semgrep's SpEL audit rule flags every parseExpression().getValue() site regardless of the context, so the fixed SimpleEvaluationContext is a false positive to justify; CodeQL distinguishes the contexts. The denylist bypass in the wrong evaluator is what the regression tests catch.
| Component | Version |
|---|---|
| Language | Java |
| Runtime | OpenJDK 21.0.12.1 (Homebrew), Maven 3.9.16 |
| Framework | Spring Boot 4.1.1 (Spring Framework 7.0.9, SpEL / spring-expression) |
| org.springframework.boot:spring-boot-starter-parent | 4.1.1 |
| org.springframework.boot:spring-boot-starter-webmvc | 4.1.1 |
| org.springframework:spring-expression | 7.0.9 (managed by Spring Boot) |
| org.springframework.boot:spring-boot-starter-webmvc-test | 4.1.1 |
| org.junit.jupiter:junit-jupiter (managed by Spring Boot) | 6.0.3 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the code-injection/java-springfolder. Maven runs with JDK 21 (set JAVA_HOME).
mvn -B -q test# Expected: every test passes
IMPL=fixed mvn -B -o -q test
# Expected: an exploit test fails
IMPL=vulnerable mvn -B -o -q test
# Expected: a test fails
IMPL=wrong mvn -B -o -q testEach run writes a JUnit report to target/surefire-reports/TEST-*.xml. From the top folder of the download, node run-all.mjs code-injection/java-spring runs all three modes and checks the expected results.
Verified
- Machine
- macOS 27.0 (Darwin 27.0.0, arm64)
- Toolchain
- java OpenJDK 21.0.12.1
- maven 3.9.16
- springBoot 4.1.1
- springFramework 7.0.9
- junitJupiter 6.0.3
- Matrix
- fixed: 5 of 5 tests pass; vulnerable: 2 of 5 tests fail (2 exploit tests); wrong: 1 of 5 tests fail (1 exploit test)
- Test results
- IMPL=fixed 5/5 pass; IMPL=vulnerable 2/5 fail (both exploit: a T(java.lang.System) call and the reflection payload both set the marker property); IMPL=wrong 1/5 fail (1 exploit: the denylist blocks the direct T(java.lang.System) call but the reflection payload through java.lang.Class.forName with a split string evades it).
Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.
References
- OWASP Top 10:2025 A05 Injection
- CWE-94: Improper Control of Generation of Code ('Code Injection')
- CWE-95: Improper Neutralization of Directives in Dynamically Evaluated Code ('Eval Injection')
- Spring Framework: SpEL Evaluation (SimpleEvaluationContext and StandardEvaluationContext)
- Spring Security guidance on SpEL injection (use SimpleEvaluationContext for untrusted input)
- OWASP Code Injection
Attack patterns: