Java Serialization Security Risks
Fast recall
Quick revision
Core answer
Interview-ready guide
Interview answer
The main security risk of Java native serialization is that deserialization reconstructs an object graph rather than merely reading plain fields. The stream contains class descriptors and references, and ObjectInputStream can invoke class-specific restoration hooks such as readObject, readResolve, and validation callbacks while processing reachable types.
For example, an attacker may craft a graph that reaches a dangerous sequence of library classes, consumes extreme memory or CPU, creates deeply nested structures, or violates object invariants. Casting the returned value to an expected class is not sufficient because harmful work can happen while readObject() is still constructing the graph, before the cast executes.
The preferred control is architectural: do not deserialize native Java streams from untrusted or tamperable sources. Use an explicit format with a constrained schema and validation. Do not assume that a private network, cache, message queue, file extension, encryption alone, or a transient field automatically establishes trustworthy content.
When a controlled legacy integration cannot be removed immediately, set an ObjectInputFilter before reading. A filter can allow or reject classes and impose limits on depth, references, arrays, or stream bytes. The surrounding system should also authenticate provenance, reduce the classpath, patch dependencies, limit process resources, and log rejections.
Filters are defense in depth, not proof that every allowed class is harmless or every denial-of-service path is covered. An allow-list must be narrow and tested against the exact graph expected. The durable fix is to migrate the boundary to a simple versioned data contract and ordinary validated construction.
Java Serialization Security Risks
Direct Answer
Deserializing untrusted Java object streams is dangerous because the stream can select reachable classes and trigger object-restoration hooks before the caller validates the final type. Avoid it; filters and graph limits are defense in depth for constrained legacy use.
Deep Dive & Architecture
A plain-data parser usually chooses a small vocabulary—objects, arrays, strings, and numbers—and lets application code decide which domain type to create. ObjectInputStream instead resolves Java classes and restores their state as part of parsing. Serializable hooks and methods reached through the class graph expand the behavior available to an attacker controlling the bytes.
Resource exhaustion does not require a code-execution gadget. Huge arrays, excessive references, deep nesting, expensive hash behavior, and recursive graphs can consume heap or CPU. ObjectInputFilter exposes metrics such as depth, reference count, array length, and stream bytes so a policy can reject unreasonable graphs early, though policies still need realistic tests and operating-system limits.
Filtering should be installed before the first object is read. Prefer explicit class allow-lists over broad package acceptance, because dependencies change and one package can contain types with very different behavior. Filter decisions can be ALLOWED, REJECTED, or UNDECIDED; a permissive fallback can accidentally defeat the boundary.
Migration is safer than endlessly growing a filter. Define the minimal fields, select a maintained format, add schema and size validation, authenticate messages where required, and convert old records. Keep the legacy reader isolated with narrow permissions until all supported data has moved.
Code Example
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.ObjectInputFilter;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serializable;
class SerializationFiltering {
static final class Message implements Serializable {
private static final long serialVersionUID = 1L;
final String text;
Message(String text) { this.text = text; }
}
public static void main(String[] args) throws Exception {
byte[] bytes;
try (var buffer = new ByteArrayOutputStream();
var output = new ObjectOutputStream(buffer)) {
output.writeObject(new Message("trusted legacy record"));
output.flush();
bytes = buffer.toByteArray();
}
ObjectInputFilter filter = info -> {
if (info.depth() > 5 || info.references() > 20 || info.streamBytes() > 4096) {
return ObjectInputFilter.Status.REJECTED;
}
Class<?> type = info.serialClass();
if (type == null) return ObjectInputFilter.Status.UNDECIDED;
return type == Message.class || type == String.class
? ObjectInputFilter.Status.ALLOWED
: ObjectInputFilter.Status.REJECTED;
};
try (var input = new ObjectInputStream(new ByteArrayInputStream(bytes))) {
input.setObjectInputFilter(filter);
Message message = (Message) input.readObject();
if (!"trusted legacy record".equals(message.text)) throw new AssertionError();
System.out.println(message.text);
}
}
}
Check your understanding
Answer these without looking back at the article.
- Why does casting after readObject not prevent gadget execution?
- Which graph limits can ObjectInputFilter inspect?
- Why are filters defense in depth rather than a complete fix?
Looking for a structured roadmap?
Every module on this page is also part of the full Java backend interview track.