1. Introduction
In this tutorial, we’ll learn about Java’s new Context-Specific Deserialization Filter functionality. We’ll establish a scenario and then use it in practice to determine what deserialization filters should be used for each situation in our application.
2. How It Relates to JEP 290
JEP 290 was introduced in Java 9 to filter deserialization from external sources through a JVM-wide filter and the possibility to define a filter for each ObjectInputStream instance. These filters rejected or allowed an object to be deserialized based on runtime parameters.
The dangers of deserializing untrusted data have long been debated, and mechanisms to help with that have also been improving. So, we now have more options for dynamically choosing deserialization filters, and it’s easier to create them.
3. New Methods in ObjectInputFilter With JEP 415
To give more options on how and when to define deserialization filters, JEP 415 introduced in Java 17 the ability to specify a JVM-wide filter factory invoked every time a deserialization occurs. This way, our solutions for filtering don’t become too restrictive or too broad anymore.
Also, to give more context control, there are new methods that ease the creation and combination of filters:
- rejectFilter(Predicate<Class<?>> predicate, Status otherStatus): rejects the deserialization if the predicate returns true, otherStatus otherwise
- allowFilter(Predicate<Class<?>> predicate, Status otherStatus): allows deserialization if the predicate returns true, otherStatus otherwise
- rejectUndecidedClass(ObjectInputFilter filter): maps every UNDECIDED return from the filter passed to REJECTED, with a few exceptional cases
- merge(ObjectInputFilter filter, ObjectInputFilter anotherFilter): tries to test both filters but returns REJECTED on the first REJECTED status it gets. It’s also null-safe for anotherFilter, returning the filter itself instead of a new, combined filter
Note: rejectFilter() and allowFilter() return UNDECIDED if information about the class being deserialized is null.
4. Building Our Scenario and Setup
To illustrate our deserialization filter factory’s job, our scenario will involve a few POJOs serialized somewhere else and deserialized by our application via a few different service classes. We’ll use these to simulate situations where we can block potentially unsafe deserialization of external sources. Ultimately, we’ll learn how to define parameters to detect unexpected properties in serialized content.
Let’s start with our POJOs’ marker interface:
public interface ContextSpecific extends Serializable {}
Firstly, our Sample class will contain basic properties that are checkable during deserialization through ObjectInputFilter, like arrays and nested objects:
public class Sample implements ContextSpecific, Comparable<Sample> {
private static final long serialVersionUID = 1L;
private int[] array;
private String name;
private NestedSample nested;
public Sample(String name) {
this.name = name;
}
public Sample(int[] array) {
this.array = array;
}
public Sample(NestedSample nested) {
this.nested = nested;
}
// standard getters and setters
@Override
public int compareTo(Sample o) {
if (name == null)
return -1;
if (o == null || o.getName() == null)
return 1;
return getName().compareTo(o.getName());
}
}
We’re only implementing Comparable to add our instances to a TreeSet later. It’ll help in showing how code can be executed indirectly. Secondly, we’ll use our NestedSample class to change the depth of our deserialized objects, which we’ll use to set a limit on how deep an object graph can be before deserialization:
public class NestedSample implements ContextSpecific {
private Sample optional;
public NestedSample(Sample optional) {
this.optional = optional;
}
// standard getters and setters
}
Finally, let’s create a simple exploit example to filter out later. It contains side effects in its toString() and compareTo() methods, which, for example, can be indirectly called by TreeSet every time we add items to it:
public class SampleExploit extends Sample {
public SampleExploit() {
super("exploit");
}
public static void maliciousCode() {
System.out.println("exploit executed");
}
@Override
public String toString() {
maliciousCode();
return "exploit";
}
@Override
public int compareTo(Sample o) {
maliciousCode();
return super.compareTo(o);
}
}
Note that this simple example is for illustration purposes only and doesn’t aim to emulate a real-world exploit.
4.1. Serialization and Deserialization Utilities
To facilitate our test cases later, let’s create a few utilities to serialize and deserialize our objects. We’ll start with simple serialization:
public class SerializationUtils {
public static void serialize(Object object, OutputStream outStream) throws IOException {
try (ObjectOutputStream objStream = new ObjectOutputStream(outStream)) {
objStream.writeObject(object);
}
}
}
Again, to help with our tests, we’ll create a method that deserializes all non-rejected objects into a set, along with a deserialize() method that optionally receives another filter:
public class DeserializationUtils {
public static Object deserialize(InputStream inStream) {
return deserialize(inStream, null);
}
public static Object deserialize(InputStream inStream, ObjectInputFilter filter) {
try (ObjectInputStream in = new ObjectInputStream(inStream)) {
if (filter != null) {
in.setObjectInputFilter(filter);
}
return in.readObject();
} catch (InvalidClassException e) {
return null;
}
}
public static Set<ContextSpecific> deserializeIntoSet(InputStream... inputStreams) {
return deserializeIntoSet(null, inputStreams);
}
public static Set<ContextSpecific> deserializeIntoSet(
ObjectInputFilter filter, InputStream... inputStreams) {
Set<ContextSpecific> set = new TreeSet<>();
for (InputStream inputStream : inputStreams) {
Object object = deserialize(inputStream, filter);
if (object != null) {
set.add((ContextSpecific) object);
}
}
return set;
}
}
Note that, for our scenario, we’re returning null when an InvalidClassException happens. This exception is thrown every time any filter rejects a deserialization. That way, we don’t break deserializeIntoSet() since we’re only interested in collecting successful deserializations and discarding the others.
4.2. Creating Filters
Before building a filter factory, we need some filters to choose from. We’ll create a few simple filters using ObjectInputFilter.Config.createFilter(). It receives a pattern of accepted or rejected packages, along with a few parameters to check before an object is deserialized:
public class FilterUtils {
private static final String DEFAULT_PACKAGE_PATTERN = "java.base/*;!*";
private static final String POJO_PACKAGE = "com.baeldung.deserializationfilters.pojo";
// ...
}
We start by setting DEFAULT_PACKAGE_PATTERN with a pattern to accept any classes from the “java.base” module and reject anything else. Then, we set POJO_PACKAGE with the package that contains the classes in our application that need deserialization.
With that information, let’s create methods to serve as a basis for our filters. With baseFilter(), we’ll receive the name of the parameter we want to check, along with a maximum value:
private static ObjectInputFilter baseFilter(String parameter, int max) {
return ObjectInputFilter.Config.createFilter(String.format(
"%s=%d;%s.**;%s", parameter, max, POJO_PACKAGE, DEFAULT_PACKAGE_PATTERN));
}
// ...
And, with fallbackFilter(), we’ll create a more restrictive filter that only accepts classes from DEFAULT_PACKAGE_PATTERN. It’ll be used for deserializations outside of our service classes:
public static ObjectInputFilter fallbackFilter() {
return ObjectInputFilter.Config.createFilter(String.format("%s", DEFAULT_PACKAGE_PATTERN));
}
Finally, let’s write the filters that we’ll use to restrict the number of bytes read, the array sizes in our objects, and the maximum depth of the object graph for deserialization:
public static ObjectInputFilter safeSizeFilter(int max) {
return baseFilter("maxbytes", max);
}
public static ObjectInputFilter safeArrayFilter(int max) {
return baseFilter("maxarray", max);
}
public static ObjectInputFilter safeDepthFilter(int max) {
return baseFilter("maxdepth", max);
}
And with all that setup, we’re ready to start writing our filter factory.
5. Creating a Deserialization Filter Factory
A deserialization filter factory allows us to dynamically select a specific filter depending on what’s being deserialized instead of relying on a single filter for the entire application. Or, setting a different one every time we create an ObjectInputStream instance. We can now have many context-specific filters and choose or combine them during runtime.
The mechanism for this involves implementing a BinaryOperator<ObjectInputFilter> and then setting its class name via the jdk.serialFilterFactory JVM property, or by calling ObjectInputFilter.Config.setSerialFilterFactory(). The factory is JVM-wide and can only be set once. So, if it’s set via the JVM property, it cannot be replaced programmatically. Also, for security reasons, it cannot be set to null.
5.1. Strategy for Choosing Filters
The strategy for our filter factory is to choose one of the filters we created based on what class was called. This will be our context. So, let’s make a few service classes that call DeserializationUtils.deserializeIntoSet(). They will be all identified by the DeserializationService interface:
public interface DeserializationService {
Set<ContextSpecific> process(InputStream... inputStreams);
}
public class LimitedArrayService implements DeserializationService {
@Override
public Set<ContextSpecific> process(InputStream... inputStreams) {
return DeserializationUtils.deserializeIntoSet(inputStreams);
}
}
public class LowDepthService implements DeserializationService {
// process...
}
public class SmallObjectService implements DeserializationService {
// process...
}
5.2. Filter Factory Structure
Our filter factory will rely on the current thread’s stack trace to check if the call is coming from a service class and which one. So let’s start with a utility method for that:
public class ContextSpecificDeserializationFilterFactory implements BinaryOperator<ObjectInputFilter> {
private static Class<?> findInStack(Class<?> superType) {
for (StackTraceElement element : Thread.currentThread().getStackTrace()) {
try {
Class<?> subType = Class.forName(element.getClassName());
if (superType.isAssignableFrom(subType)) {
return subType;
}
} catch (ClassNotFoundException e) {
return null;
}
}
return null;
}
// ...
}
Finally, let’s override the apply() method:
@Override
public ObjectInputFilter apply(ObjectInputFilter current, ObjectInputFilter next) {
if (current == null) {
Class<?> caller = findInStack(DeserializationService.class);
if (caller == null) {
current = FilterUtils.fallbackFilter();
} else if (caller.equals(SmallObjectService.class)) {
current = FilterUtils.safeSizeFilter(190);
} else if (caller.equals(LowDepthService.class)) {
current = FilterUtils.safeDepthFilter(2);
} else if (caller.equals(LimitedArrayService.class)) {
current = FilterUtils.safeArrayFilter(3);
}
}
return ObjectInputFilter.merge(current, next);
}
With this implementation, we:
- check if the current filter isn’t already set
- if it isn’t, we try to find if there’s a service class in the stack
- if there isn’t, we use the fallback filter
- otherwise, if the call comes from SmallObjectService, we use the safeSizeFilter() with a value of 190
- check for the other possible service classes, applying the appropriate filter
- ultimately, we merge the resulting filter with whatever is in the next filter to keep a filter that was possibly set for the ObjectOutputStream instance or via ObjectInputFilter.Config.setSerialFilter()
Note that the value for safeSizeFilter() was based on a serialized instance’s maximum expected size in bytes. Since our SampleExploit class is serialized with a larger size due to its extra content, it’s rejected on deserialization.
6. Testing Our Solution
Let’s start by setting up our tests with a few serialized Sample objects. Most importantly, we call setSerialFilterFactory() with our factory class:
static ByteArrayOutputStream serialSampleA = new ByteArrayOutputStream();
static ByteArrayOutputStream serialBigSampleA = new ByteArrayOutputStream();
static ByteArrayOutputStream serialSampleC = new ByteArrayOutputStream();
static ByteArrayOutputStream serialBigSampleC = new ByteArrayOutputStream();
@BeforeAll
static void setup() throws IOException {
ObjectInputFilter.Config.setSerialFilterFactory(new ContextSpecificDeserializationFilterFactory());
SerializationUtils.serialize(new Sample("simple"), serialSampleA);
SerializationUtils.serialize(new SampleExploit(), serialBigSampleA);
SerializationUtils.serialize(new Sample(new NestedSample(null)), serialSampleC);
SerializationUtils.serialize(new Sample(new NestedSample(new Sample("deep"))), serialBigSampleC);
}
private static ByteArrayInputStream bytes(ByteArrayOutputStream stream) {
return new ByteArrayInputStream(stream.toByteArray());
}
In this test, the resulting set contains only the “simple” object because SampleExploit was rejected, preventing the execution of maliciousCode():
@Test
void whenSmallObjectContext_thenCorrectFilterApplied() {
Set<ContextSpecific> result = new SmallObjectService().process(
bytes(serialSampleA),
bytes(serialBigSampleA)
);
assertEquals(1, result.size());
assertEquals(
"simple", ((Sample) result.iterator().next()).getName());
}
6.1. Combined Filters
For example, when using LowDepthService, safeDepthFilter(2) is applied by our filter factory, which rejects objects with more than two levels of nesting:
@Test
void whenLowDepthContext_thenCorrectFilterApplied() {
Set<ContextSpecific> result = new LowDepthService().process(
bytes(serialSampleC),
bytes(serialBigSampleC)
);
assertEquals(1, result.size());
}
But, after modifying LowDepthService.process() to accept a custom filter:
public Set<ContextSpecific> process(ObjectInputFilter filter, InputStream... inputStreams) {
return DeserializationUtils.deserializeIntoSet(filter, inputStreams);
}
We can combine the safeDepthFilter() with any other filter. In this case, safeSizeFilter():
@Test
void givenExtraFilter_whenCombinedContext_thenMergedFiltersApplied() {
Set<ContextSpecific> result = new LowDepthService().process(
FilterUtils.safeSizeFilter(190),
bytes(serialSampleA),
bytes(serialBigSampleA),
bytes(serialSampleC),
bytes(serialBigSampleC)
);
assertEquals(1, result.size());
}
This results in only serialSampleA being allowed.
7. Conclusion
In this article, we saw Java’s latest enhancement, Context-Specific Deserialization Filter (JEP 415), in action. It introduces a dynamic and context-aware approach to filtering during deserialization operations with a filter factory. Our practical scenario showcased a service-based strategy, where different service classes were associated with specific deserialization contexts. This strategy provides a robust mechanism for developers to enhance security.
As always, the source code is available over on GitHub.