Do not deserialize untrusted bytes with ObjectInputStream: its readObject runs code chosen by the payload. Read JSON into a fixed type instead (Jackson without default typing). If native serialization is unavoidable, set an ObjectInputFilter allowlist that permits only the classes you expect and rejects the rest with !*. A blocklist of known gadget names misses the next one.
- Find every new ObjectInputStream(...).readObject()/readUnshared(), every Spring RMI/HTTP-invoker or JMS ObjectMessage path, and any library (XStream, Kryo, SnakeYAML, Jackson with default typing) that rebuilds types from the data.
- Prefer not to deserialize objects at all: accept JSON and bind it to a concrete DTO with Jackson (no activateDefaultTyping, no @class), or another schema format.
- If you must read a serialized object, set an ObjectInputFilter allowlist with ObjectInputStream.setObjectInputFilter (or a JVM-wide jdk.serialFilter): list the exact classes and java.base/*, end with !*, and add maxdepth/maxrefs/maxbytes limits.
- Never configure the filter as a blocklist of known gadget packages; a class you did not list is UNDECIDED and therefore allowed.
- Add regression tests that deserialize a gadget-shaped class (its readObject writes a marker) and require the marker never appears, next to functional tests that round-trip the seed profile and preferences.
- Do not return the deserialization exception to the client, and give the service account only the privileges it needs.
The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.
The vulnerable code
@Override
public Profile restoreProfile(String token) {
try {
byte[] bytes = Base64.getDecoder().decode(token);
try (ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(bytes))) {
// No filter: whatever class the stream names is resolved and
// deserialized, and any readObject it defines runs.
return (Profile) in.readObject();
}
} catch (Exception e) {
throw new BadTokenException("invalid token", e);
}
}With IMPL=vulnerable, 2 of 7 tests fail (2 exploit tests).
The fix
// Allow only Profile and the java.base platform classes it needs, and
// reject the rest (!*). java.base/* is a pragmatic baseline; a stricter
// allowlist names each class. A filter check happens before readObject.
private static final ObjectInputFilter PROFILE_FILTER =
ObjectInputFilter.Config.createFilter("com.example.session.model.Profile;java.base/*;!*");
@Override
public Profile restoreProfile(String token) {
try {
byte[] bytes = Base64.getDecoder().decode(token);
try (ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(bytes))) {
in.setObjectInputFilter(PROFILE_FILTER);
return (Profile) in.readObject();
}
} catch (Exception e) {
throw new BadTokenException("invalid token", e);
}
}With IMPL=fixed, 7 of 7 tests pass.
The fix: allowlist for the object graph
// The preferences graph needs its collection classes too; allowlist the
// Preferences record plus java.base (HashMap, ArrayList, Boolean, the
// immutable-collection proxies), and reject everything else.
private static final ObjectInputFilter PREFERENCES_FILTER =
ObjectInputFilter.Config.createFilter("com.example.session.model.Preferences;java.base/*;!*");
@Override
public Preferences loadPreferences(String token) {
try {
byte[] bytes = Base64.getDecoder().decode(token);
try (ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(bytes))) {
in.setObjectInputFilter(PREFERENCES_FILTER);
return (Preferences) in.readObject();
}
} catch (Exception e) {
throw new BadTokenException("invalid preferences", e);
}
}With IMPL=fixed, 7 of 7 tests pass.
A fix that looks safe but is not
// Reject the gadget packages from past advisories, allow everything else.
private static final ObjectInputFilter BLOCKLIST = ObjectInputFilter.Config.createFilter(
"!org.apache.commons.collections.functors.*;"
+ "!org.apache.commons.collections4.functors.*;"
+ "!org.codehaus.groovy.runtime.*;"
+ "!com.sun.org.apache.xalan.internal.xsltc.trax.*;"
+ "!org.springframework.beans.factory.ObjectFactory");
@Override
public Profile restoreProfile(String token) {
try {
byte[] bytes = Base64.getDecoder().decode(token);
try (ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(bytes))) {
in.setObjectInputFilter(BLOCKLIST);
return (Profile) in.readObject();
}
} catch (Exception e) {
throw new BadTokenException("invalid token", e);
}
}With IMPL=wrong, 2 of 7 tests fail (2 exploit tests).
The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.
// Exploit: the payload is a serialized MarkerGadget whose readObject writes
// a marker file. The fix must reject the class before readObject runs.
@Test
void exploitNativeGadgetInProfileDoesNotRun() throws Exception {
Path marker = Files.createTempFile("fixcard-marker-", ".txt");
Files.delete(marker);
try {
mvc.perform(get("/session/restore").param("token", token(new MarkerGadget(marker.toString()))));
assertThat(Files.exists(marker))
.as("insecure deserialization: the gadget's readObject ran").isFalse();
} finally {
Files.deleteIfExists(marker);
}
}
@Test
void exploitNativeGadgetInPreferencesDoesNotRun() throws Exception {
Path marker = Files.createTempFile("fixcard-marker-", ".txt");
Files.delete(marker);
try {
mvc.perform(post("/session/preferences")
.content(token(new MarkerGadget(marker.toString()))).contentType(MediaType.TEXT_PLAIN));
assertThat(Files.exists(marker))
.as("insecure deserialization: the gadget's readObject ran").isFalse();
} finally {
Files.deleteIfExists(marker);
}
}- An ObjectInputFilter is an allowlist only if it ends with !*. Without a terminal reject, unlisted classes are UNDECIDED and permitted, which is how the 'wrong' blocklist here is bypassed.
- The filter check runs when each class descriptor is read, before the object is created, so it stops a gadget before its readObject - but only for classes it actually rejects.
- java.base/* is a pragmatic baseline that allows every platform class; a strict allowlist names each class. Note java.base/** behaves differently from java.base/* and rejected the graph in testing - use java.base/*.
- Jackson is safe by default in this card because default typing is off; ObjectMapper.activateDefaultTyping (or the old enableDefaultTyping) re-introduces CWE-502. Spring Boot 4 ships Jackson 3, which restricts polymorphic typing by default - do not loosen it.
- Gadgets live in libraries on your classpath (commons-collections, groovy, xalan), not in your code, so 'we don't deserialize anything dangerous' is not a defence; the allowlist is.
- readResolve/readObject/validateObject run during deserialization; a serialized object graph can trigger them deep inside collections, which is why the allowlist must cover the whole graph (HashMap, ArrayList, the immutable-collection proxies).
- Signing the serialized blob and checking the signature before readObject stops a client swapping it, but only in addition to the allowlist, never instead.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs java-spring does this for you).
- Run semgrep --config r/java.lang.security.audit.object-deserialization.object-deserialization src/main/java; expect it to flag every ObjectInputStream call, the fixed one included, then confirm by hand that fixed sets an allowlist filter.
- Against your own endpoint, post a base64 serialized gadget and confirm no marker file is written and the request is rejected with 400.
What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | java.lang.security.audit.object-deserialization.object-deserialization | Flagged all three implementations - VulnerableSessionService (L20, L36), WrongSessionService (L31, L46) AND FixedSessionService (L28, L49). The rule is a blunt 'you called ObjectInputStream.readObject' audit: it cannot see that the fixed code sets an ObjectInputFilter allowlist, so it flags the correct code too. Treat it as 'find the sink', then verify the filter by hand and with the regression tests.Checked: Semgrep 1.180.0 (CE) |
| CodeQL | java/unsafe-deserialization | Not run. The Java model treats ObjectInputStream.readObject on tainted input as an unsafe-deserialization sink and tracks @RequestParam/@RequestBody as remote sources, so it is expected to flag vulnerable and wrong. Whether it clears the fixed code depends on its ObjectInputFilter sanitiser model; like Semgrep it may still report the filtered stream.Checked: github/codeql main; not executed |
| Find Security Bugs (SpotBugs plugin) | OBJECT_DESERIALIZATIONObject deserialization is used (untrusted data) | Listed pattern for new ObjectInputStream(...).readObject(); like the Semgrep rule it reports the sink, not whether a filter makes it safe.Checked: Bug pattern listed on the Find Security Bugs site; not executed |
| Component | Version |
|---|---|
| Language | Java 21 |
| Runtime | OpenJDK 21.0.12.1 (Homebrew), Maven 3.9.16 |
| Framework | Spring Boot 4.1.1 (Spring Framework 7, Spring MVC) |
| org.springframework.boot:spring-boot-starter-parent | 4.1.1 |
| org.springframework.boot:spring-boot-starter-webmvc | 4.1.1 |
| org.springframework.boot:spring-boot-starter-webmvc-test | 4.1.1 |
| org.junit.jupiter:junit-jupiter (managed by Spring Boot) | 6.0.3 |
| tools.jackson.core:jackson-databind (Jackson 3, response JSON only; the pom overrides Boot's 3.1.5) | 3.1.7 |
| java.io.ObjectInputStream / java.io.ObjectInputFilter (JDK, JEP 290) | Java 21 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the deserialization/java-springfolder. Maven runs with JDK 21 (set JAVA_HOME).
mvn -B -q test# Expected: every test passes
IMPL=fixed mvn -B -o -q test
# Expected: an exploit test fails
IMPL=vulnerable mvn -B -o -q test
# Expected: a test fails
IMPL=wrong mvn -B -o -q testEach run writes a JUnit report to target/surefire-reports/TEST-*.xml. From the top folder of the download, node run-all.mjs deserialization/java-spring runs all three modes and checks the expected results.
Verified
- Toolchain
- openjdk 21.0.12.1
- maven 3.9.16
- springBoot 4.1.1
- junitJupiter 6.0.3
- jackson 3.1.7
- Matrix
- fixed: 7 of 7 tests pass; vulnerable: 2 of 7 tests fail (2 exploit tests); wrong: 2 of 7 tests fail (2 exploit tests)
- Test results
- IMPL=fixed 7/7 pass; IMPL=vulnerable 2 exploit tests fail (native gadget in the profile and in the preferences stream); IMPL=wrong 2 exploit tests fail (the gadget-package blocklist has no terminal !*, so MarkerGadget is UNDECIDED and allowed).
- Scanners
- Semgrep 1.180.0 from a throwaway venv (../.semgrep-venv), not a card dependency.
Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.
References
- OWASP Deserialization Cheat Sheet
- JEP 290: Filter Incoming Serialization Data
- Java SE 21 API: ObjectInputFilter (pattern syntax)
- Java SE 21 API: ObjectInputStream.setObjectInputFilter
- Serialization Filtering (Java core libraries guide)
- CWE-502: Deserialization of Untrusted Data
- CAPEC-586: Object Injection
- Find Security Bugs: OBJECT_DESERIALIZATION
Attack patterns: