12 min read

White-Box Testing
Intermediate
A05 A08

Java and Spring Security

Java's defects are stable and well catalogued, which makes them fast to review and easy to miss — the injection is usually a string that predates the framework, and the deserialization is usually a utility class nobody has opened since 2014. Spring adds its own small set on top: an expression language, a binding mechanism, and a proxy that silently does nothing when you call it from the wrong side.

Review the version you deploy

Spring Security 6.5 is end of life and 7.1.x is current. Spring4Shell belongs to 5.3.x and earlier — a finding filed against a 7.x service because the pattern matched is a finding that costs the team an afternoon and buys nothing. Resolve the dependency tree before you write anything down.

JDBC and JPA injection

The framework does not remove the defect; it removes the reason for the defect. JdbcTemplate will happily execute a concatenated string, and EntityManager.createQuery will happily parse one. What changes is that the safe call is now shorter than the unsafe one.

UserRepository.java
public User find(String name) {
    String sql = "SELECT * FROM users WHERE name = '" + name + "'";
    return jdbc.queryForObject(sql, User.class);
}

// JPA escape hatches behave the same way
String jpql = "FROM User u WHERE u.name = '" + name + "'";
em.createQuery(jpql).getSingleResult();
public User find(String name) {
    String sql = "SELECT * FROM users WHERE name = '" + name + "'";
    return jdbc.queryForObject(sql, User.class);
}

// JPA escape hatches behave the same way
String jpql = "FROM User u WHERE u.name = '" + name + "'";
em.createQuery(jpql).getSingleResult();

Two places hide this from a scan. The first is @Query in Spring Data, where a nativeQuery = true annotation turns a string into SQL and a missing parameter binding into a defect. The second is query building in report code, which is usually written by whoever needed the report and lives outside the repository package.

Deserialization

Java deserialization is not a bug in a library — it is a language feature that lets the stream choose which classes to instantiate and which readObject methods to run. Any class on the classpath is a potential gadget. The control that actually works is a stream filter with an allowlist, introduced as JEP 290 and available since JDK 9.

SessionStore.java
// no filter: the stream decides which classes get constructed
try (ObjectInputStream in = new ObjectInputStream(new FileInputStream(path))) {
    Object o = in.readObject();
    return (Session) o;
}
// no filter: the stream decides which classes get constructed
try (ObjectInputStream in = new ObjectInputStream(new FileInputStream(path))) {
    Object o = in.readObject();
    return (Session) o;
}

Be precise about what this is: a mitigation that shrinks the attack surface to the classes you named, not a repair. The repair is to stop accepting Java serialization from untrusted input. Where the format is fixed by an upstream system, the filter plus a library inventory is the practical control, and both belong in the finding.

CVE-2016-1000027 — the HttpInvokerServiceExporter deserialization issue — affects 4.x through 5.3.x and was never patched in place because the entire mechanism was deprecated. If the codebase still exposes an HTTP invoker endpoint, the finding is "remove the endpoint", not "upgrade the minor version".

Spring-specific sinks

Three Spring features produce findings that generic rules miss. SpEL evaluation on request data is arbitrary code execution dressed as configuration — the T() operator reaches Runtime directly. @ModelAttribute binds every request field to a matching property, including ones the form never showed. And the method-security proxy is only entered from outside the bean, which makes the annotation decorative on an internal call.

FilterController.java
// user input becomes an expression, not a value
String expr = request.getParameter("filter");
ExpressionParser parser = new SpelExpressionParser();
String value = parser.parseExpression(expr).getValue(String.class);
// user input becomes an expression, not a value
String expr = request.getParameter("filter");
ExpressionParser parser = new SpelExpressionParser();
String value = parser.parseExpression(expr).getValue(String.class);
ReportService.java
@Service
public class ReportService {

    @PreAuthorize("hasRole('ADMIN')")
    public Report adminReport(long id) { ... }

    // self-invocation: the proxy is never entered, the annotation does nothing
    public Report summary(long id) {
        return this.adminReport(id);
    }
}
@Service
public class ReportService {

    @PreAuthorize("hasRole('ADMIN')")
    public Report adminReport(long id) { ... }

    // self-invocation: the proxy is never entered, the annotation does nothing
    public Report summary(long id) {
        return this.adminReport(id);
    }
}

Authorisation

Read the security configuration as three questions: what is permitted by default, where the rules are evaluated, and what the rules are keyed on. A requestMatchers chain keyed on URL prefixes is fine until a controller grows a new path under an already-permitted prefix. Method security keyed on roles is fine until the object being returned belongs to someone else. The defect that survives both is the missing per-object check.

Configuration What it actually enforces Failure mode
anyRequest().authenticated() Login, nothing else Any logged-in user reaches every handler
requestMatchers("/api/admin/**") Path prefix A controller mapped outside the prefix escapes the rule
@PreAuthorize on the controller The HTTP entry point A message consumer or scheduler calls the service unchecked
@PreAuthorize("hasRole('ADMIN')") The role, not the object Admin of tenant A reads tenant B

SSRF and XXE

XXE is mostly closed by default in modern Java XML parsers, which means a finding here is usually about a parser configured to be permissive for a legacy integration. Look for DocumentBuilderFactory with DTDs enabled, or a SAXParser built before the hardening calls. The fix is four lines and belongs in the finding.

SSRF is the more common live issue: any code that takes a URL from a user and fetches it. The control that works is a destination allowlist enforced after DNS resolution, or an egress proxy — not a string check on the hostname, which fails against decimal IPs, IPv6 literals, DNS rebinding and redirects.

Crypto and secrets

Three greps cover most of it: MessageDigest.getInstance("MD5"|"SHA-1"), Cipher.getInstance without an AEAD mode, and new Random() used for anything that must not be predictable. Password storage has one acceptable answer in Spring — BCryptPasswordEncoder or Argon2 — and a codebase that hand-rolls salt + sha256 is a finding even when the salt is per-user.

Check Expected Where to look
No query is assembled from a string concatenation Every JDBC, JPA and native query passes user values as bound parameters; the only concatenation left is over constants grep for executeQuery, createQuery and @Query
ObjectInputStream is either absent or filtered by an allowlist An ObjectInputFilter configured per stream with an explicit class allowlist, not a `!` denylist of known gadgets The readObject call site
Method security is enabled and effective @EnableMethodSecurity present, @PreAuthorize on the service method rather than only the controller, and no call from inside the same bean SecurityConfig and the annotated methods
Spring Security version is supported 7.1.x or a maintained 7.0 line; 6.5 is end of life and 5.8 fixes for Spring4Shell are ancient history The dependency tree (mvn dependency:tree or gradle dependencies)

Evidence Collection

For a Java finding, the reviewer needs the dependency versions as much as the source line — half of these defects are version-specific:

The resolved version, not the declared one: Spring manages its own BOM; a 5.3.x pulled in transitively will not appear in the pom as written. Quote the tree output.

The gadget or expression, in full: For deserialization, the chain that reaches a runtime exec. For SpEL, the expression string. Both must be reproducible.

Whether a filter or proxy was in the path: ObjectInputFilter, method security proxy, or the container filter chain. The finding is only real if none of them was.

False Positive Identification

  • The concatenated string is not a query: String concatenation with a constant, a column name from an enum, or an ORDER BY direction chosen from a switch is not injection. The value must reach the parser.
  • The annotation is on the proxied call: Method security is a proxy. If the call crosses the bean boundary, the check runs. Only self-invocation and private methods bypass it.
  • The deserialization source is trusted: A stream written and read by the same process from a private file descriptor is not an external source. Say so in the ticket rather than filing a critical.
  • The framework already binds parameters: Spring Data derived queries and JPA Criteria API calls are bound. A grep hit on those methods is not a finding.

Java and Spring Review Checklist

Data access

  • ☐ No string-concatenated JDBC, JPQL or native query with a variable part.
  • ☐ JPA escape hatches (createNativeQuery, EntityManager.createQuery) reached with user data are bound.
  • ☐ Database accounts used by the application have the narrowest grants that work.

Deserialization

  • ☐ ObjectInputStream is absent, or every stream has an allowlist filter.
  • ☐ No Commons Collections, Groovy or XStream on the classpath where user data is deserialized.
  • ☐ Jackson polymorphic typing (enableDefaultTyping) is not enabled on external input.

Spring

  • ☐ Method security is enabled and used on service methods, not only controllers.
  • ☐ @ModelAttribute binding does not populate fields the caller must not set (role, owner, price).
  • ☐ No SpEL or expression evaluation on request data.
  • ☐ Actuator endpoints are authenticated and not exposed on the public interface.

Crypto and config

  • ☐ Password hashing uses BCrypt, Argon2 or PBKDF2 with a per-user salt.
  • ☐ No key material in application.properties, the repository or the image.
  • ☐ CSRF protection is enabled for state-changing browser flows; stateless APIs use token auth and no cookie session.

Fixing What You Found

Spring gives you the safe primitive in almost every case. Prefer it to a hand-rolled filter:

Spring4Shell: what actually happened

CVE-2022-22965 was a data-binding issue, not an injection issue: a request parameter could reach class.module.classLoader on a bound object, and from there the servlet container's access log. It affected Spring MVC on a servlet container with a JDK 9 or later, and it was fixed in 5.3.18 and 5.2.20. In a review it is useful as a case study in why @ModelAttribute needs a field allowlist — the same shape, without the CVE, still produces mass-assignment findings today.

Java Review Practice Targets

Projects with real Java and Spring defects, small enough to confirm each finding by reading the path.

OWASP Benchmark (Java) Custom Lab medium
Labelled true and false positives per CWESQLi, XSS and command injection casesCompare to your rule pack
OWASP WebGoat Custom Lab medium
Spring Boot codebaseDeserialization and access control lessonsSource available for every challenge
Java deserialization labs Custom Lab hard
Gadget chain inventoryFilter bypass patternsWhy denylists fail