Part 10 of 14

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 instantiation
  • REJECTED — throw InvalidClassException and abort
  • UNDECIDED — 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:

  1. Combine the JVM-wide filter with a per-stream filter in a consistent way
  2. Allow library code to install its own filter alongside application-level filters
  3. 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:

PatternMeaning
pkg.ClassNameAllow this class
pkg.*Allow all classes in pkg (not subpackages)
pkg.**Allow all classes in pkg and subpackages
!pkg.ClassNameReject this class
!*Reject everything not previously allowed
maxdepth=NReject if object graph depth > N
maxrefs=NReject if reference count > N
maxbytes=NReject if serialized bytes > N
maxarray=NReject 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

ConceptAPI
Create pattern filterObjectInputFilter.Config.createFilter("pkg.**;!*")
Allow filterObjectInputFilter.allowFilter(predicate, rejectedStatus)
Deny filterObjectInputFilter.rejectFilter(predicate, undecidedStatus)
Merge filtersObjectInputFilter.merge(filter1, filter2)
Reject undecidedObjectInputFilter.rejectUndecidedClass(filter)
Set JVM-wide filterObjectInputFilter.Config.setSerialFilter(filter)
Set filter factoryObjectInputFilter.Config.setSerialFilterFactory(factory)
Apply to streamois.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.