Skip to content

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 pass java.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 its javax.script bridge 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") returns null and the endpoint throws NullPointerException on the first request - dead code rather than a live sink. Confirmed on JDK 26: getEngineFactories() returns an empty list. Look for an org.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 - the eval() call goes - but only one of them is an incident.
  • The same JDK question does not apply to getEngineByName("groovy") or getEngineByName("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() and GroovyScriptEngine are 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

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, blocking System.exit(), Runtime.exec(), file I/O, and reflection. Verified against JEXL 3.5: with Double and Math allowed, x.doubleValue() evaluates and s.length(), rt.exec(...) and ''.getClass().forName(...) are all refused, where s and rt are context variables of type String and Runtime.
  • A refused call returns null; it does not throw. strict(true) governs arithmetic and undefined variables, not sandbox denial, so rt.exec('...') under this sandbox evaluates to null and 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 as null rather 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 make Math usable: with no namespace registered and no Math in the context, Math.abs(-3) evaluates to null, because Math is simply an undefined variable and denial is silent. Registering .namespaces(Map.of("math", Math.class)) is what makes it reachable, as math:abs(-3). The allow is still load-bearing - drop it and the same call fails with unsolvable function/method 'abs(Byte)' - but an allow() 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 than null.

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:

  • SimpleEvaluationContext disables the T() type reference operator and method invocation, making it impossible to reference Runtime, 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

  • JexlSandbox constructed 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 from new 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.
  • SimpleEvaluationContext used, but the root object exposes a dangerous getter: Restricting SpEL to SimpleEvaluationContext.forReadOnlyDataBinding() blocks the T() 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 a ClassLoader, 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") returns null on newer JDKs. Adding a scripting-engine dependency back purely to resolve the resulting NullPointerException, without revisiting why untrusted input reaches eval() in the first place, reinstates the exact code-injection surface the JDK removal incidentally reduced.

Additional Resources