CWE-94: Code Injection - Java
Overview
Code Injection in Java occurs when untrusted input is evaluated as executable code at runtime, rather than treated as data. Java applications are vulnerable when they pass user input to Groovy's GroovyShell.evaluate(), evaluate Spring Expression Language against a StandardEvaluationContext, use a scripting engine through javax.script.ScriptEngine, or configure template engines without sandboxing. The ScriptEngine API is exposed unintentionally more often than the others, because configuration-driven features reach it without anyone writing eval in application code.
Which of these is live depends on the JDK and the classpath, and it is worth settling before triaging a finding. Nashorn - the JavaScript engine that shipped with the JDK - was deprecated in Java 11 and removed in Java 15, so on any currently supported JDK getEngineByName("JavaScript") returns null and the call site throws NullPointerException rather than executing anything. SpEL and Groovy have no such reprieve: both are dependencies rather than JDK features, and both still evaluate exactly as they always did.
Unlike OS command injection (CWE-78), code injection allows the attacker to execute arbitrary logic within the running JVM process itself - with full access to the classpath, file system, and network.
Primary Defence: Replace dynamic code evaluation with static logic, lookup tables, or a restricted expression evaluator (e.g., Spring Expression Language scoped to read-only property access, not method invocation).
Common Vulnerable Patterns
ScriptEngine with User Input
import javax.script.ScriptEngine;
import javax.script.ScriptEngineManager;
import org.springframework.web.bind.annotation.*;
@RestController
public class CalculatorController {
// Resolves to null on JDK 15+ unless an engine is on the classpath;
// resolves to Nashorn on JDK 8-14, and to GraalVM JS or Rhino wherever
// one of those was added as a dependency.
private final ScriptEngine engine =
new ScriptEngineManager().getEngineByName("JavaScript");
@GetMapping("/calculate")
public String calculate(@RequestParam String expression) throws Exception {
// VULNERABLE - evaluates arbitrary JavaScript including java.lang.Runtime.exec()
Object result = engine.eval(expression);
return result.toString();
}
}
Why this is vulnerable:
ScriptEngine.eval()executes the string as JavaScript. An attacker can passjava.lang.Runtime.getRuntime().exec('id')or load arbitrary Java classes via reflection.- There is no safe way to sandbox Nashorn or Rhino when user input can reach
eval(). GraalVM's polyglot API can be configured to deny host access, but itsjavax.scriptbridge is not that configuration - reaching for the engine by name gets you the permissive default. - Check the JDK before triaging this one. Nashorn was removed in Java 15, so on a Java 17 or 21 service
getEngineByName("JavaScript")returnsnulland the endpoint throwsNullPointerExceptionon the first request - dead code rather than a live sink. Confirmed on JDK 26:getEngineFactories()returns an empty list. Look for anorg.openjdk.nashorn:nashorn-core,org.graalvm.js, or Rhino dependency in the build file; if one is present the finding is live, and if none is, what you have is an endpoint that has been failing since the JDK upgrade. Either way the fix is the same - theeval()call goes - but only one of them is an incident. - The same JDK question does not apply to
getEngineByName("groovy")orgetEngineByName("nashorn")on a project that added the engine back as a dependency. Those resolve and execute.
Groovy Dynamic Evaluation
import groovy.lang.GroovyShell;
public class RuleEngine {
public Object evaluate(String userRule) {
// VULNERABLE - GroovyShell gives full JVM access to the script
GroovyShell shell = new GroovyShell();
return shell.evaluate(userRule);
}
}
Why this is vulnerable:
- Groovy scripts have unrestricted access to the JVM. An attacker can read files, open network connections, or execute OS commands from within the script.
GroovyClassLoader.parseClass()andGroovyScriptEngineare equally dangerous.
SpEL with Unrestricted Method Invocation
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
@RestController
public class TemplateController {
private final ExpressionParser parser = new SpelExpressionParser();
@PostMapping("/render")
public String render(@RequestBody String template) {
// VULNERABLE - SpEL can call T(Runtime).exec() with user-supplied input
return parser.parseExpression(template).getValue(String.class);
}
}
Why this is vulnerable:
- SpEL supports the
T()operator that references arbitrary Java classes:T(java.lang.Runtime).getRuntime().exec('id')is valid SpEL syntax.
Secure Patterns
Lookup Table (Recommended for Configurable Operations)
import java.util.Map;
import java.util.function.Function;
import org.springframework.web.bind.annotation.*;
@RestController
public class CalculatorController {
// SECURE - predefined operations - no dynamic evaluation
private static final Map<String, Function<Double, Double>> OPERATIONS = Map.of(
"double", x -> x * 2,
"square", x -> x * x,
"negate", x -> -x,
"sqrt", Math::sqrt
);
@GetMapping("/calculate")
public double calculate(@RequestParam String operation, @RequestParam double value) {
Function<Double, Double> op = OPERATIONS.get(operation);
if (op == null) {
throw new IllegalArgumentException("Unknown operation: " + operation);
}
return op.apply(value);
}
}
Why this works:
- The lookup table maps string identifiers to predefined Java lambdas. User input selects from this fixed set - it can never introduce new logic.
- If the key is not in the map, the request is rejected before any computation occurs.
Strategy Pattern for Pluggable Business Rules
public interface PricingRule {
double apply(double basePrice, int quantity);
}
@Component("bulk")
class BulkDiscount implements PricingRule {
public double apply(double basePrice, int quantity) {
return quantity > 10 ? basePrice * 0.9 : basePrice;
}
}
@Service
public class PricingService {
private final Map<String, PricingRule> rules; // injected by Spring by bean name
public PricingService(Map<String, PricingRule> rules) {
this.rules = rules;
}
// SECURE - user selects a named rule; the logic is always predefined Java code
public double applyRule(String ruleName, double basePrice, int quantity) {
PricingRule rule = rules.get(ruleName);
if (rule == null) {
throw new IllegalArgumentException("Unknown rule: " + ruleName);
}
return rule.apply(basePrice, quantity);
}
}
Why this works:
- The strategy pattern delegates to concrete Java classes defined at compile time. User input only selects a name - it never supplies logic.
Apache Commons JEXL with Sandboxing (When Scripting is Genuinely Required)
import org.apache.commons.jexl3.*;
import org.apache.commons.jexl3.introspection.JexlSandbox; // not covered by the jexl3.* import
import java.util.Map;
// SECURE - JEXL with a JexlSandbox that blocks access to system classes
public class SafeExpressionEvaluator {
private final JexlEngine engine;
public SafeExpressionEvaluator() {
JexlSandbox sandbox = new JexlSandbox(false); // deny all by default
sandbox.allow(Double.class.getName());
sandbox.allow(Math.class.getName());
// System, Runtime, File, etc. are all blocked
this.engine = new JexlBuilder()
.sandbox(sandbox)
// allow() grants permission, not reach: without this namespace
// nothing can name Math in the first place, and math:abs(-3)
// fails as an unknown namespace
.namespaces(Map.of("math", Math.class))
.strict(true)
.create();
}
public Object evaluate(String expression, Map<String, Object> variables) {
if (expression == null || expression.length() > 200) {
throw new IllegalArgumentException("Expression too long or null");
}
JexlContext context = new MapContext(variables);
return engine.createExpression(expression).evaluate(context);
}
}
Why this works:
JexlSandbox(false)denies access to all classes by default. Only explicitly permitted classes can be referenced, blockingSystem.exit(),Runtime.exec(), file I/O, and reflection. Verified against JEXL 3.5: withDoubleandMathallowed,x.doubleValue()evaluates ands.length(),rt.exec(...)and''.getClass().forName(...)are all refused, wheresandrtare context variables of typeStringandRuntime.- A refused call returns
null; it does not throw.strict(true)governs arithmetic and undefined variables, not sandbox denial, sort.exec('...')under this sandbox evaluates tonulland the expression carries on. That matters twice over: a test that asserts "the malicious expression throws" passes against an unsandboxed engine too and proves nothing, so assert on the returned value instead; and legitimate expressions that touch an un-allowed class fail silently asnullrather than telling you which class to add. Log denials from the sandbox during rollout. - Allowing a class allows every one of its members.
sandbox.allow(...)takes a class name and permits the whole public surface, so allow narrow, concrete types - not a base class or an interface whose implementations you do not control. allow()grants permission, not reach, and the two are configured separately.sandbox.allow(Math.class.getName())on its own does not makeMathusable: with no namespace registered and noMathin the context,Math.abs(-3)evaluates tonull, becauseMathis simply an undefined variable and denial is silent. Registering.namespaces(Map.of("math", Math.class))is what makes it reachable, asmath:abs(-3). The allow is still load-bearing - drop it and the same call fails withunsolvable function/method 'abs(Byte)'- but anallow()line with nothing exposing the class reads like a working configuration and grants nothing. Whenever you add a class to the sandbox, evaluate an expression that uses it and check the result is a value rather thannull.
SpEL with SimpleEvaluationContext
import org.springframework.expression.*;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.SimpleEvaluationContext;
// SECURE - SimpleEvaluationContext restricts SpEL to property access only
ExpressionParser parser = new SpelExpressionParser();
EvaluationContext context = SimpleEvaluationContext.forReadOnlyDataBinding().build();
// T() operator and method invocation are blocked in SimpleEvaluationContext
String result = parser.parseExpression(template).getValue(context, myBean, String.class);
Why this works:
SimpleEvaluationContextdisables theT()type reference operator and method invocation, making it impossible to referenceRuntime,System, or other dangerous classes.
Testing
- Normal input: exercise each supported named operation, strategy, or restricted expression the application still needs.
- Boundary input: test unknown operation names, long expressions, nested properties, and invalid syntax.
- Malicious input: submit
T(java.lang.Runtime).getRuntime().exec('id'),System.exit(1), file access, reflection, and class-loading payloads; confirm they are rejected before execution.
Common Pitfalls
JexlSandboxconstructed allow-by-default:new JexlSandbox(true)creates a permissive sandbox that allows everything except explicitly blocked classes; the safety in the secure pattern above comes specifically fromnew JexlSandbox(false)(deny-by-default) plus an explicit allowlist. Getting the boolean backwards silently produces an unrestricted evaluator that still looks sandboxed in code review.SimpleEvaluationContextused, but the root object exposes a dangerous getter: Restricting SpEL toSimpleEvaluationContext.forReadOnlyDataBinding()blocks theT()type-reference operator and method invocation, but ordinary property access on the root object is still allowed - if that object's own property graph includes a getter returning aClassLoader, file handle, or similar reflective object, the restriction doesn't stop the expression from reaching it.- Re-adding a scripting engine dependency just to fix a startup error: Since Nashorn was removed from the JDK in Java 15,
ScriptEngineManager().getEngineByName("JavaScript")returnsnullon newer JDKs. Adding a scripting-engine dependency back purely to resolve the resultingNullPointerException, without revisiting why untrusted input reacheseval()in the first place, reinstates the exact code-injection surface the JDK removal incidentally reduced.