Back to Blog
Java

Java Object Deserialization: How It Works and How to Do It Safely

java object deserialization: Learn how Java object deserialization works, why it is dangerous with untrusted data, and how to protect your application with filters and...

JavaDeserializationSecurityObjectInputStreamSerialization
A Java object being deserialized from a byte stream into a structured object, with a shield indicating security protection.

Java object deserialization is the process of reconstructing an object from its serialized byte stream. While the mechanism is straightforward, it carries a well-known security risk: deserializing untrusted data can lead to remote code execution. This article explains the mechanics, the dangers, and the practical steps you can take to deserialize safely.

How Java Object Deserialization Works

Java's native serialization mechanism converts an object's state into a byte stream using ObjectOutputStream. The reverse operation, deserialization, reads that byte stream and rebuilds the object graph using ObjectInputStream. The readObject() method is the entry point. When called, the JVM reads the class metadata, field values, and references from the stream, then constructs a new instance without invoking any constructor. This is a fundamental difference from normal object creation: no constructor runs, which means any initialization logic in constructors is bypassed.

The process is recursive. If the serialized object contains references to other objects, those are also deserialized automatically. This can lead to a deep object graph, and the total amount of memory and CPU consumed depends on the size and complexity of the stream. Because the format is binary and includes class metadata, the stream is not human-readable and cannot be easily validated by simple string checks.

Implementing Deserialization with ObjectInputStream

The most basic way to deserialize an object is to wrap an InputStream with ObjectInputStream and call readObject(). Here is a minimal example:

import java.io.*; public class DeserializeExample { public static Object deserialize(byte[] data) throws IOException, ClassNotFoundException { try (ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(data))) { return ois.readObject(); } } }

This method reads a single object from the byte array. The return type is Object, so the caller must cast it to the expected type. The cast can fail at runtime if the actual class in the stream is not assignable to the target type, which is one reason deserialization is inherently unsafe when the input is not trusted.

A common pattern is to deserialize a specific type, such as a User object:

User user = (User) deserialize(data);

This works only if the User class implements Serializable and has a matching serialVersionUID. If the class definition changes without a corresponding serialVersionUID update, an InvalidClassException is thrown. This compatibility check is a source of operational headaches, especially in distributed systems where producers and consumers may be upgraded independently.

Why Native Deserialization Is a Security Risk

The core problem is that readObject() can instantiate any class that appears in the stream, as long as that class is on the classpath. An attacker who controls the byte stream can craft a sequence of class names and field values that, when deserialized, triggers arbitrary code execution. This is known as a deserialization gadget chain. Libraries such as Apache Commons Collections, Spring, and Groovy have historically contained gadgets that can be chained to call dangerous methods like Runtime.exec().

Even without a full gadget chain, deserialization can cause denial-of-service attacks. A malicious stream can declare an array of enormous size or a deeply nested object graph, forcing the JVM to allocate excessive memory or perform recursive operations until it crashes. Because there is no built-in limit on the number of objects or the depth of the graph, a tiny payload can consume gigabytes of memory.

The risk is not theoretical. Many high-profile vulnerabilities, including several CVEs in Java frameworks, have been caused by unsafe deserialization of untrusted input. The Java community has responded with multiple defense mechanisms, but the safest approach is to avoid native deserialization altogether when the data comes from an untrusted source.

Validating Input Before Deserialization

If you must deserialize data that might be untrusted, you should validate it before calling readObject(). The first line of defense is to ensure the byte stream conforms to an expected structure. For example, you can check the magic header bytes (the stream starts with 0xAC 0xED) and the version number, but this is a weak check because an attacker can easily replicate those bytes.

A more effective validation is to inspect the class names that are being deserialized. You can override the resolveClass() method in a custom ObjectInputStream subclass to enforce an allowlist of permitted classes. Here is an example:

public class SafeObjectInputStream extends ObjectInputStream { private static final Set<String> ALLOWED_CLASSES = Set.of( "com.example.User", "java.util.ArrayList" ); public SafeObjectInputStream(InputStream in) throws IOException { super(in); } @Override protected Class<?> resolveClass(ObjectStreamClass desc) throws IOException, ClassNotFoundException { if (!ALLOWED_CLASSES.contains(desc.getName())) { throw new InvalidClassException("Unexpected class", desc.getName()); } return super.resolveClass(desc); } }

This approach blocks unknown classes before they are loaded, but it is not sufficient by itself. An allowed class might still contain dangerous methods that can be triggered through its fields. The allowlist must be carefully curated to include only classes that are known to be safe and that do not have gadget potential.

Using Serialization Filters to Restrict Types

Java 9 introduced a built-in serialization filter mechanism that provides a more robust way to control what can be deserialized. You can configure a filter globally via the system property jdk.serialFilter, or programmatically on a specific ObjectInputStream using setObjectInputFilter(). The filter can enforce limits on class names, array sizes, graph depth, and total stream length.

Here is an example of setting a filter programmatically:

ObjectInputFilter filter = ObjectInputFilter.Config.createFilter( "com.example.*;java.util.*;maxdepth=10;maxarray=1000;maxbytes=100000" ); ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(data)); ois.setObjectInputFilter(filter);

The filter pattern syntax allows you to specify a list of class name patterns, each optionally prefixed with ! to reject, and a set of limits. The filter is applied to every class that is resolved during deserialization. If a class does not match any pattern, it is rejected by default unless you include a wildcard or a default allow rule.

Serialization filters are a significant improvement over manual resolveClass() overrides because they are integrated into the JVM and can be applied consistently across all deserialization points. However, they are not a silver bullet. A filter that is too permissive may still allow a gadget chain, while a filter that is too restrictive may break legitimate functionality. You must test your filter against the actual classes your application uses.

Alternatives to Native Deserialization

Given the risks, many applications have moved away from Java's native serialization for data exchange. Instead, they use text-based or structured formats like JSON, XML, or Protocol Buffers. These formats are human-readable (in the case of JSON and XML) and have well-defined parsing rules that do not automatically instantiate arbitrary classes.

For example, using Jackson to deserialize JSON is safer because the parser only creates instances of the target class and its properties, not arbitrary classes from the input. You still need to configure Jackson to reject unknown properties and to avoid polymorphic typing unless absolutely necessary. Here is a simple example:

ObjectMapper mapper = new ObjectMapper(); User user = mapper.readValue(json, User.class);

This approach is not only safer but also more portable across languages and platforms. JSON and Protocol Buffers are language-agnostic, so a Java service can communicate with a Python or Go service without dealing with Java's serialization format. The tradeoff is that these formats are not as compact as Java's native binary format, and they require explicit schema management.

When Native Deserialization Is Still the Right Choice

There are a few scenarios where native Java object deserialization remains a reasonable choice. The most common is when you are deserializing data that was produced by your own application and stored in a controlled environment, such as a local cache or a database column. If the data never leaves the trust boundary, the risk is minimal. For example, you might serialize a session object and store it in memory or on disk, then deserialize it later in the same JVM.

Another case is when you are using a framework that already handles deserialization safely, such as a library that applies its own filters. But even then, you should not assume safety without verifying the configuration. The safest policy is to treat any data that crosses a network boundary as untrusted and to use a non-native format.

If you must use native deserialization, combine multiple defenses: use a strict serialization filter, validate the input size, and keep your dependencies updated to avoid known gadget chains. Also, consider using a custom ObjectInputStream that rejects classes outside a small allowlist. No single measure is foolproof, but layering them reduces the attack surface significantly.

Finally, remember that deserialization is not just a security concern; it also affects performance. Native deserialization is CPU-intensive and allocates many objects, especially for large object graphs. If your application deserializes high-volume data, a structured format like JSON with a streaming parser may offer better throughput and lower memory overhead. Measure your actual workload before choosing a format, and always test with realistic data sizes.

java object deserialization: Practical Usage and Code Exampl | RYUSLOG DEV