Skip to main content

SER00-G: Do Not Deserialize Untrusted Data


Introduction

Deserializing untrusted data can create serious security vulnerabilities for your application by allowing attackers to create objects of any class that the Java Virtual Machine (JVM) can load. This can lead to risks such as remote code execution and denial-of-service (DoS) attacks. By default, the JVM uses the first non-bootclass class loader it encounters to perform class deserialization. Starting in Java 1.9, the stack now searches for the most recent class loader, which is neither the new platform class loader nor an ancestor due to the module system. Additionally, if remote code invocation (RIM) has been explicitly enabled, an attacker could run code from a remote codebase using this technique.

The safest approach is to avoid deserializing data from untrusted sources altogether. Only when it's absolutely necessary to use deserialization should you use other protection methods like whitelisting.

Deserializing an object from a stream requires creating a new object and populating its fields. If you deserialize untrusted data, the attacker can access the objects being created and their order, allowing them to control the JVM that deserialized the stream. There is a collection of well-known gadgets and presumably unknown vulnerable classes an attacker can use to execute attacker-supplied byte codes and other dangerous exploits.

Serialization supports the transformation of a graph of objects into a stream of bytes for storage or transmission.

Serialization

These bytes can be transformed back into a graph of objects. Objects, such as the following Bicycle class need to implement the Serializable class to be serializable:

uses java.io.Serializable

public class Bicycle implements Serializable {

private static final var serialVersionUID : long = 5754104541168320730L
private var _id: int as id
private var _name : String as name
private var _nbrWheels : int as nbrWheels

public construct(id_0 : int, name_0 : String, nbrWheels_0 : int) {
this.id = id_0
this.name = name_0
this.nbrWheels = nbrWheels_0
}
}

By default, non-transient and non-static fields are serialized. The following class contains methods to serialize and deserialize an object to and from an array of bytes:

uses java.io.*

public class Serial {
public static function serialize(o : Object) : byte[] {
using (var ba = new ByteArrayOutputStream(),
var oos = new ObjectOutputStream(ba)) {
oos.writeObject(o)
return ba.toByteArray()
}
}

public static function deserialize(bytes : byte[]) : Object {
return new ObjectInputStream(new ByteArrayInputStream(bytes)).readObject()
}
}

The serialize method calls the writeObject of an ObjectOutputStream object to serialize the specified root object. The deserialize method calls the readObject of an ObjectInputStream object to deserialize the serialized bytes. Deserialization of trusted data is allowed, provided both endpoints are adequately secured.

Non-Compliant Code Example

The following non-compliant code deserializes a byte array without first validating what classes will be created. The program creates and serializes a new Bicycle object and a new File object (an exploit gadget in this example).

// Noncompliant Code Example

var serializedBicycle : byte[] = null
var serializedFile : byte[] = null
try {
serializedBicycle = Serial.serialize(new Bicycle(0, "Unicycle", 1))
serializedFile = Serial.serialize(new File("file.txt"))
}
catch (e1 : IOException) {
e1.printStackTrace()
}

var bicycle0 : Bicycle = deserialize(serializedBicycle) as Bicycle
print(bicycle0.name + " has been deserialized.")

try {
var exploit : Object = deserialize(serializedFile)
System.out.println("Exploit has been deserialized.")
}
catch (e : ClassNotFoundException) {
e.printStackTrace()
}
catch (e : IOException) {
e.printStackTrace()
}

The output from executing this program is:

Unicycle has been deserialized.
Exploit has been deserialized.

The program deserializes both the expected object and the exploit without complaint. In this case, the exploit was innocuous but could have easily been a remote code execution.

Compliant Solution (Look-Ahead Object Input Streams)

Look-ahead deserialization or look-ahead object input streams (LAOIS) can be used to create an allow list or deny list of objects. There are several off-the-shelf solutions; the most popular is JEP 290, which is fully integrated into Java 9 and partially supported by earlier releases.

Filtering incoming object-serialization data streams improves security and robustness by narrowing the set of deserializable classes to a context-appropriate set of classes known to be secure.

The first step is to define a custom filter by implementing the checkInput method of the ObjectInputFilter interface. The checkInput method is called during deserialization to accept or reject specific classes, packages, or modules and enforce limits on array sizes, graph depth, total references, and stream size.

The following BikeFilter class overrides the checkInput function with one that returns Status.ALLOWED for the Bicycle class and Status.REJECTED for any other classes. If the filter cannot determine the status, it should return Status.UNDECIDED.

// Compliant Solution

uses java.io.ObjectInputFilter

class BikeFilter implements ObjectInputFilter {

override public function checkInput(filterInfo : FilterInfo) : Status {
var clazz : Class<Object> = filterInfo.serialClass()
if (clazz != null) {
if (clazz.Name == "Bicycle") {
return Status.ALLOWED
} else {
return Status.REJECTED
}
}
return Status.UNDECIDED
}
}

We can then add the following function to the Serialize class:

// Compliant Solution

public static function deserialize(buffer : byte[],
filter : ObjectInputFilter) : Object {
var obj : Object
using (var ois = new ObjectInputStream(new ByteArrayInputStream(buffer)))
{
ois.setObjectInputFilter(filter)
obj = ois.readObject()
}
return obj
}

This function takes an additional ObjectInputFilter parameter and calls the setObjectInputFilter method on the ObjectInputStream to apply the filter to any subsequent calls to readObject. We can then change the deserialize function called by our non-compliant code example to apply the BikeFilter:

// Compliant Solution

private static function deserialize(buffer : byte[]) : Object {
var filter : BikeFilter = new BikeFilter()
return Serial.deserialize(buffer, filter)
}

The modified code will still deserialize the Unicycle object but will reject any object that is not instantiated from the Bicycle class:

Unicycle has been deserialized.
java.io.InvalidClassException: filter status: REJECTED
at java.base/java.io.ObjectInputStream.filterCheck(ObjectInputStream.java:1287)
at java.base/java.io.ObjectInputStream.readNonProxyDesc(ObjectInputStream.java:1896)
at java.base/java.io.ObjectInputStream.readClassDesc(ObjectInputStream.java:1772)
at java.base/java.io.ObjectInputStream.readOrdinaryObject(ObjectInputStream.java:2060)
at java.base/java.io.ObjectInputStream.readObject0(ObjectInputStream.java:1594)
at java.base/java.io.ObjectInputStream.readObject(ObjectInputStream.java:430)
at Serial.deserialize(Serial.gs:20)


Building a suitable allow list can be challenging. Exploits have been constructed for classes in the Apache Commons Collection and core classes such as java.util.concurrent.AtomicReferenceArray (CVE 2012-0507). Even simple classes, like java.util.HashSet [Terse 2015] and java.net.URL [Terse 2015] can cause a denial-of-service attack.

Risk Assessment

Deserializing untrusted data can allow an attacker to execute arbitrary code in the JVM, perform a denial-of-service attack, or perform a variety of other exploits.

RuleSeverityLikelihoodRemediation CostPriorityLevel
SER00-GHighHighly LikelyMediumL18L1

Additional Resources