Context-Specific Deserialization Filters (JEP 415): Securing Java Deserialization
Finalized in Java 17 (JEP 415). Extends JEP 290 (Java 9), which introduced the basic deserialization filter API.
Why Deserialization Is Dangerous
Java object deserialization (ObjectInputStream.readObject()) is one of the most exploited attack surfaces in Java. When a Java application deserializes untrusted bytes, the JVM instantiates arbitrary classes and calls their methods as a side effect — before your application code even sees the result.
Attackers craft gadget chains: sequences of serializable classes in common libraries (Apache Commons Collections, Spring Framework, etc.) that, when deserialized in sequence, execute arbitrary code — spawn shells, write files, establish reverse connections.
Real-world incidents involving Java deserialization:
- Apache Struts 2 RCE (2017) — led to the Equifax breach
- WebLogic Server — repeated CVEs from 2015 onward
- Jenkins — deserialization RCE (2016)
- JBoss, WebSphere — multiple CVEs in 2015–2017
The root cause in all cases: ObjectInputStream deserializes whatever classes are on the classpath, regardless of whether the application ever intentionally uses them.
The Fix: Deserialization Filters
Java 9 (JEP 290) introduced ObjectInputFilter — a filter you attach to an ObjectInputStream to reject unexpected classes before they are instantiated:
ObjectInputStream ois = new ObjectInputStream(input);
ois.setObjectInputFilter(filter);
Object obj = ois.readObject(); // filter runs before each class is instantiated
The filter receives a FilterInfo object describing the class about to be deserialized and can return:
ALLOWED— permit instantiationREJECTED— throwInvalidClassExceptionand abortUNDECIDED— defer to the next filter in the chain
JEP 290 Limitations (Java 9–16)
JEP 290 worked but had a key limitation: it supported a single JVM-wide filter and per-stream filters, but it provided no mechanism to:
- Combine the JVM-wide filter with a per-stream filter in a consistent way
- Allow library code to install its own filter alongside application-level filters
- Select different filters based on execution context (who is calling
readObject)
JEP 415 solves this with a filter factory — a function that produces the effective filter for each ObjectInputStream, taking the current JVM-wide filter and the stream’s own filter into account.
ObjectInputFilter Basics
Pattern-Based Filters
The simplest way to create a filter is with a pattern string:
import java.io.ObjectInputFilter;
// Allow only specific classes; reject everything else
ObjectInputFilter filter = ObjectInputFilter.Config.createFilter(
"com.example.User;com.example.Order;!*"
);
Pattern syntax:
| Pattern | Meaning |
|---|---|
pkg.ClassName | Allow this class |
pkg.* | Allow all classes in pkg (not subpackages) |
pkg.** | Allow all classes in pkg and subpackages |
!pkg.ClassName | Reject this class |
!* | Reject everything not previously allowed |
maxdepth=N | Reject if object graph depth > N |
maxrefs=N | Reject if reference count > N |
maxbytes=N | Reject if serialized bytes > N |
maxarray=N | Reject if any array length > N |
Order matters — first match wins:
// Allow User, reject commons-collections, reject everything else
ObjectInputFilter filter = ObjectInputFilter.Config.createFilter(
"com.example.User;" +
"!org.apache.commons.collections.**;" +
"!*"
);
Programmatic Filter
For complex logic, implement ObjectInputFilter as a lambda or class:
ObjectInputFilter safeFilter = filterInfo -> {
Class<?> cls = filterInfo.serialClass();
if (cls == null) return ObjectInputFilter.Status.UNDECIDED; // not a class check
String name = cls.getName();
// Allow only known-safe classes
if (name.startsWith("com.example.") ||
name.startsWith("java.util.") ||
cls.isPrimitive() ||
cls.isArray()) {
return ObjectInputFilter.Status.ALLOWED;
}
// Reject everything else
return ObjectInputFilter.Status.REJECTED;
};
JEP 415: The Filter Factory
JEP 415 introduces ObjectInputFilter.Config.setSerialFilterFactory() — a factory function that is called every time an ObjectInputStream is created. The factory receives:
- The current JVM-wide filter (may be null)
- The stream-specific filter (may be null, e.g., if the library didn’t set one)
And returns the filter that will actually be used for that stream.
Setting a Filter Factory
import java.io.ObjectInputFilter;
import java.util.function.BinaryOperator;
// Factory: merge JVM-wide filter with stream-specific filter
BinaryOperator<ObjectInputFilter> factory = (jvmFilter, streamFilter) -> {
if (jvmFilter == null) return streamFilter;
if (streamFilter == null) return jvmFilter;
// Merge: REJECTED from either → REJECTED; otherwise ALLOWED if either ALLOWS
return ObjectInputFilter.merge(streamFilter, jvmFilter);
};
ObjectInputFilter.Config.setSerialFilterFactory(factory);
ObjectInputFilter.merge(filter1, filter2) returns a composed filter: filter1 is consulted first; if UNDECIDED, filter2 is consulted.
New Helper Methods (JEP 415)
JEP 415 adds factory methods to ObjectInputFilter for building filters programmatically:
// Allow only these classes; reject everything else
ObjectInputFilter allowList = ObjectInputFilter.allowFilter(
cls -> cls.getName().startsWith("com.example."),
ObjectInputFilter.Status.REJECTED // status if predicate returns false
);
// Reject specific packages
ObjectInputFilter denyList = ObjectInputFilter.rejectFilter(
cls -> cls.getName().startsWith("org.apache.commons.collections"),
ObjectInputFilter.Status.UNDECIDED // status if predicate returns false (defer to next filter)
);
// Reject if any class is undecided (strict mode)
ObjectInputFilter strict = ObjectInputFilter.rejectUndecidedClass(denyList);
// Merge: first filter decides; second filter handles UNDECIDED
ObjectInputFilter composed = ObjectInputFilter.merge(denyList, allowList);
Layered Filter Architecture
The recommended architecture for Java 17 applications:
JVM-wide filter (set via system property or Config.setSerialFilter)
↓
Filter factory (set via Config.setSerialFilterFactory)
↓ combines JVM-wide + per-stream filter
Effective filter per ObjectInputStream
↓ applied per class during deserialization
ALLOWED / REJECTED / UNDECIDED
Step 1: Set a Conservative JVM-Wide Filter
# Via system property at JVM startup
java -Djdk.serialFilter="com.example.**;java.util.**;java.lang.**;!*" MyApp
Or in code at application startup:
ObjectInputFilter.Config.setSerialFilter(
ObjectInputFilter.Config.createFilter(
"com.example.**;java.util.*;java.lang.*;!*"
)
);
Step 2: Install a Filter Factory That Honors Both Filters
// At application startup — call once, before any deserialization
ObjectInputFilter.Config.setSerialFilterFactory((jvmFilter, streamFilter) -> {
// Start with the most restrictive: stream-specific filter
// Fall back to JVM-wide filter for UNDECIDED
if (streamFilter != null && jvmFilter != null) {
return ObjectInputFilter.merge(streamFilter, jvmFilter);
}
return streamFilter != null ? streamFilter : jvmFilter;
});
Step 3: Apply Per-Stream Filters in Application Code
// Deserializing a specific type — apply a tight per-stream filter
ObjectInputFilter userFilter = ObjectInputFilter.Config.createFilter(
"com.example.User;java.lang.String;!*"
);
try (ObjectInputStream ois = new ObjectInputStream(inputStream)) {
ois.setObjectInputFilter(userFilter);
User user = (User) ois.readObject();
}
With the factory installed, the effective filter is the merge of userFilter and the JVM-wide filter — providing defense in depth.
Logging and Monitoring Filter Decisions
Enable deserialization filter logging to audit what is being deserialized:
java -Djava.util.logging.config.file=logging.properties MyApp
logging.properties:
handlers=java.util.logging.ConsoleHandler
java.util.logging.ConsoleHandler.level=FINEST
java.io.serialization.level=FINE
Or programmatically:
// Log every filter decision
ObjectInputFilter loggingFilter = filterInfo -> {
Class<?> cls = filterInfo.serialClass();
if (cls != null) {
System.out.printf("Deserializing: %s%n", cls.getName());
}
return ObjectInputFilter.Status.UNDECIDED; // defer to next filter
};
Wrapping your actual filter with a logging filter helps audit deserialization during testing.
Practical Defense Strategy
1. Prefer Non-Serialization Alternatives
The safest deserialization filter is one you never need:
- Use JSON (Jackson, Gson) instead of Java serialization for cross-system data exchange
- Use protocol buffers or Avro for high-performance serialization
- Reserve Java serialization for local caching or session storage where you control both sides
2. Apply the Allowlist Principle
Deny everything by default; explicitly allow only the classes your application needs:
// Tight allowlist — much safer than a blocklist
ObjectInputFilter filter = ObjectInputFilter.Config.createFilter(
"com.myapp.model.**;" +
"java.util.ArrayList;java.util.HashMap;java.util.HashSet;" +
"java.lang.String;java.lang.Integer;java.lang.Long;" +
"!*" // reject everything not listed
);
Blocklists (deny known-bad classes) are inherently incomplete — new gadget classes are discovered regularly.
3. Limit Graph Size
Prevent resource exhaustion attacks with size limits:
ObjectInputFilter filter = ObjectInputFilter.Config.createFilter(
"com.myapp.model.**;" +
"maxdepth=10;" + // max nesting depth
"maxrefs=1000;" + // max object references
"maxbytes=65536;" + // max 64KB
"maxarray=1000;" + // max array elements
"!*"
);
Configuration via System Property
For environments where code changes are not possible, set the filter via system property:
# JVM-wide allowlist
java -Djdk.serialFilter="com.example.**;java.util.*;java.lang.*;maxrefs=500;maxdepth=8;!*" App
# Filter factory class (must implement BinaryOperator<ObjectInputFilter>)
java -Djdk.serialFilterFactory=com.example.MyFilterFactory App
Summary
| Concept | API |
|---|---|
| Create pattern filter | ObjectInputFilter.Config.createFilter("pkg.**;!*") |
| Allow filter | ObjectInputFilter.allowFilter(predicate, rejectedStatus) |
| Deny filter | ObjectInputFilter.rejectFilter(predicate, undecidedStatus) |
| Merge filters | ObjectInputFilter.merge(filter1, filter2) |
| Reject undecided | ObjectInputFilter.rejectUndecidedClass(filter) |
| Set JVM-wide filter | ObjectInputFilter.Config.setSerialFilter(filter) |
| Set filter factory | ObjectInputFilter.Config.setSerialFilterFactory(factory) |
| Apply to stream | ois.setObjectInputFilter(filter) |
| System property | -Djdk.serialFilter="..."; -Djdk.serialFilterFactory=className |
What’s Next
Article 11: JDK Encapsulation and Removed APIs covers JEP 403 (Strong Encapsulation), the removal of RMI Activation (JEP 407), and the deprecation of the Security Manager (JEP 411) — the three most impactful platform changes for existing Java codebases upgrading to Java 17.