Monitoring Deserialization to Improve Application Security
Chris Hegarty on March 2, 2021Many Java frameworks rely on serialization and deserialization for exchanging messages between JVMs on different computers, or persisting data to disk. Monitoring deserialization can be helpful for application developers who use such frameworks, as it provides insight into the low level data which ultimately flows into the application. This insight, in turn, helps to configure serialization filtering, a mechanism introduced in Java 9 to prevent vulnerabilities by screening incoming data before it reaches the application. Framework developers may also benefit from a more efficient and dynamic means of monitoring the deserialization activity performed by their framework.
Photo by Shane Aldendorff
Unfortunately, monitoring deserialization was hard because it required advanced knowledge of how the Java class libraries perform deserialization. For example, you would have to use brittle techniques like debugging or instrumenting calls to methods in java.io.ObjectInputStream
. A better approach is to use JDK Flight Recorder (JFR), a low-overhead data collection framework for troubleshooting Java applications and the HotSpot JVM that Oracle open-sourced in Java 11. Starting with Java 17, JFR has first-class support for deserialization, via a new Deserialization Event that is triggered by deserialization operations. You can use JFR along with a tool like JDK Mission Control (JMC) to identify deserialization of objects by ObjectInputStream
. With this, it is possible to, say, identify deserialization of objects of a particular class, or monitor for deserialization operations where a serialization filter is not configured, or even operations that are rejected or allowed by a filter.
This article introduces the new Deserialization Event, describes how it can be enabled, and finally how it can be leveraged to provide insight into the deserialization operations that occur in a running JVM.
JFR Deserialization Event
Within the Java Platform ObjectInputStream
performs deserialization operations. That is, ObjectInputStream::readObject
yields live objects in the Java heap from their “at rest” representation in the byte stream. JFR Deserialization Events are now raised for the inner workings of ObjectInputStream
as it processes the byte stream in order to reconstitute the object graph.
A Deserialization Event is created for each new object in the stream. The event captures details relevant to the particular object in the stream, e.g. the class of the object being deserialized, as well as other information like the status of the serialization filter, if one has been configured. There are a number of similarities between the information captured by the Deserialization Event and that of what is reported to the serialization filter, but to be clear, the creation of the Deserialization Event is agnostic of whether a filter is configured or not.
The Deserialization Event captures:
- Whether a serialization filter is configured or not.
- The serialization filter status, if one is configured.
- The class of the object being deserialized.
- The number of array elements when deserializing an array.
- The current graph depth.
- The current number of object references.
- The current number of bytes in the stream that have been consumed.
- The exception type and message, if thrown by the serialization filter.
Let’s take a look at a concrete example.
Setting up the Example
To keep things as straightforward as possible this example only uses tools from the Java Development Kit (JDK). You will need a build of JDK 17, currently in early access.
In order to trigger a Deserialization Event to be created, we first need a class that can be serialized and deserialized. Let’s use a serializable record class:
package q;
record Point(int x, int y) implements java.io.Serializable { }
We’re going to need a couple of small utility methods to do the serializing and deserializing:
/** Returns a serialized byte stream representation of the given obj. */
public static <T> byte[] serialize(T obj) throws IOException {
try(var baos = new ByteArrayOutputStream();
var oos = new ObjectOutputStream(baos)) {
oos.writeObject(obj);
oos.flush();
return baos.toByteArray();
}
}
/** Returns (reconstitutes) an object from a given serialized byte stream. */
static <T> T deserialize(byte[] streamBytes) throws Exception {
try (var bais = new ByteArrayInputStream(streamBytes);
var ois = new ObjectInputStream(bais)) {
return (T) ois.readObject();
}
}
Lastly, a small program that performs deserialization:
public class BasicExample {
public static void main(String... args) throws Exception {
byte[] serialBytes = serialize(new q.Point(5, 6));
q.Point p = deserialize(serialBytes);
}
}
The small program, BasicExample
, first serializes a Point
object in order to generate some known serial byte stream data. The second part is more interesting as it performs the deserialization operation which we’re going to capture and inspect with JFR.
With the above pieces in place we now have all we need to generate and analyze a Deserialization Event.
Running with JFR
JFR recording can be enabled and disabled dynamically with tools such as jcmd. But for demonstration purposes, it’s most straightforward to pass a command line argument so that the java launcher will setup JFR to start recording.
$ java -XX:StartFlightRecording=filename=recording.jfr,settings=deserEvent.jfc BasicExample
Started recording 1. No limit specified, using maxsize=250MB as default.
Use jcmd 2884 JFR.dump name=1 to copy recording data to file.
The StartFlightRecording
option accepts a number of arguments; filename
is the output file for the captured recordings; settings
is a file that contains the JFR configuration, in this case it is set to a file in the current directory, deserEvent.jfc, that enables the Deserialization Event, as follows:
<?xml version="1.0" encoding="UTF-8"?>
<configuration version="2.0" description="test">
<event name="jdk.Deserialization">
<setting name="enabled">true</setting>
<setting name="stackTrace">false</setting>
</event>
</configuration>
In this case, since we’re only interested in a single kind of event, jdk.Deserialization, then only that event is enabled in the configuration file.
When the program terminates recording.jfr
contains the recorded events. Let’s use the JDK’s jfr
command line tool to print the recordings:
$ jfr print recording.jfr
jdk.Deserialization {
startTime = 19:55:55.773
filterConfigured = false
filterStatus = N/A
type = q.Point (classLoader = app)
arrayLength = -1
objectReferences = 1
depth = 1
bytesRead = 34
exceptionType = N/A
exceptionMessage = N/A
eventThread = "main" (javaThreadId = 1)
}
A single Deserialization Event is printed, which is expected since the program performs just a single deserialize
operation of a trivial Point
object. The type
field identifies the class of object being deserialized, in this case q.Point
. The stream object is not an array, so arrayLength
is not applicable and has a value of -1
. This Deserialization event captures the first object reference in the stream, so objectReferences
has a value of 1
. The bytesRead
field provides a value of the total bytes read from the serial byte stream at the time that the event was created, ‘34’ bytes in this particular case.
Given the significance and potential security benefits of using a serialization filter, there are a number of fields in the event relating to it. The value of filterConfigured
is a boolean that indicates whether a serialization filter is configured or not. In this case a filter is not configured, so it has the value of false. The filterStatus
contains a value of the filter decision status, but since there is no filter configured then it has the value of Not Applicable (N/A). Lastly, exceptionType
and exceptionMessage
capture any exception details thrown from the filter (if any), but again since there is no filter configured then these fields have the value of Not Applicable (N/A).
Running with a Serialization Filter
A relatively common approach for filtering is to configure a defensive reject list that rejects a list of classes that are not trusted. (An allow list is preferable, if the set of classes is known)
Let’s run the same program again, but this time with a serialization filter configured to reject deserialization of objects whose class is q.Point
. To do this we use the jdk.serialFilter
property on the command line, which allows to define a pattern-based filter without changing the program code. We’ll supply a basic pattern that matches the name of the class preceded by the ‘!’ character (a class that matches a pattern that is preceded by ‘!’ is rejected).
$ java -Djdk.serialFilter='!q.Point' -XX:StartFlightRecording=filename=recording.jfr,settings=deserEvent.jfc -cp target/ q.BasicExample
Started recording 1. No limit specified, using maxsize=250MB as default.
Use jcmd 14725 JFR.dump name=1 to copy recording data to file.
Exception in thread "main" java.io.InvalidClassException: filter status: REJECTED
at java.base/java.io.ObjectInputStream.filterCheck(ObjectInputStream.java:1378)
at java.base/java.io.ObjectInputStream.readNonProxyDesc(ObjectInputStream.java:2032)
at java.base/java.io.ObjectInputStream.readClassDesc(ObjectInputStream.java:1889)
at java.base/java.io.ObjectInputStream.readOrdinaryObject(ObjectInputStream.java:2196)
at java.base/java.io.ObjectInputStream.readObject0(ObjectInputStream.java:1706)
at java.base/java.io.ObjectInputStream.readObject(ObjectInputStream.java:496)
at java.base/java.io.ObjectInputStream.readObject(ObjectInputStream.java:454)
at serial.Utils.deserialize(Utils.java:46)
at BasicExample.main(BasicExample.java:32)
The program threw an exception during the deserialize operation as expected (since the filter is configured to reject the deserialization of objects whose class is q.Point
). Let’s take a look at the Deserialization Event that was created for this particular run of the program:
$ jfr print recording.jfr
jdk.Deserialization {
startTime = 11:06:02.865
filterConfigured = true
filterStatus = "REJECTED"
type = q.Point (classLoader = app)
arrayLength = -1
objectReferences = 1
depth = 1
bytesRead = 34
exceptionType = N/A
exceptionMessage = N/A
eventThread = "main" (javaThreadId = 1)
stackTrace = [ ... ]
}
We can see the value of filterConfigured
is now true, reflecting the fact that a filter is configured. The filterStatus
shows REJECTED, since the deserialization operation was rejected (as expected).
Wrapping Up
A Deserialization Event has been added in Java 17 that allows the monitoring and inspection of all deserialization operations performed by the Platform’s Serialization API, namely ObjectInputStream
. Recording with Deserialization Events enabled allows to answer such questions like: are all deserialization operations using a configured filter, or are there any deserialization operations for the Foo
class, or how many deserialization operations are rejected for a given period of time.
The Deserialization Event is a JFR event so can be consumed by tools like JDK Mission Control and Advanced Management Console to dynamically monitor and inspect deserialization operations of interest in a running JVM or across a number of JVMs on remote systems.
The full source code as outlined above can be found here.