Deserialization
Deserialization is the process of reconstructing a Java object from a byte stream previously produced by serialization. It is performed by ObjectInputStream.readObject(), which reads the class descriptor from the stream, loads the corresponding class, allocates a new instance without calling any constructor, and populates the fields from the stream data. The field population bypasses the constructor entirely — invariants established in the constructor are not automatically re-enforced. This constructor-bypass is the source of both the power and the danger of Java deserialization: it can reconstruct complex object graphs in one call, but it can also produce objects in states that the constructor would have rejected. This entry covers the deserialization process step by step, the constructor bypass and its security implications, resolving class versions with serialVersionUID, handling missing and extra fields during version evolution, readObject and readResolve hooks, ObjectInputStream configuration (class loader, filter), and safe deserialization practices.
The Deserialization Process — Constructor Bypass and Field Population
// ── Basic deserialization ─────────────────────────────────────────────
try (ObjectInputStream ois = new ObjectInputStream(
new BufferedInputStream(new FileInputStream("person.ser")))) {
Person person = (Person) ois.readObject();
System.out.println("Deserialized: " + person);
// Constructor was NOT called — object allocated and populated directly
}
// ── Reading multiple objects from the same stream ──────────────────────
try (ObjectInputStream ois = new ObjectInputStream(
new BufferedInputStream(new FileInputStream("people.ser")))) {
try {
while (true) {
Person p = (Person) ois.readObject();
System.out.println("Read: " + p);
}
} catch (EOFException e) {
System.out.println("All objects read");
}
}
// ── Constructor bypass: invariants not re-enforced ─────────────────────
public class SafePeriod implements Serializable {
private static final long serialVersionUID = 1L;
private final Date start;
private final Date end;
public SafePeriod(Date start, Date end) {
if (start.after(end)) throw new IllegalArgumentException("start > end");
this.start = new Date(start.getTime());
this.end = new Date(end.getTime());
}
// WITHOUT readObject: an attacker can craft a stream where start > end
// despite the constructor preventing it — the constructor is never called
}
// Demonstrate via reflection-less route — crafting a stream manually is complex
// but the point: readObject MUST validate invariants for security-sensitive classes
// ── readObject: re-enforce invariants after deserialization ───────────
public class ValidatedPeriod implements Serializable {
private static final long serialVersionUID = 1L;
private final Date start;
private final Date end;
public ValidatedPeriod(Date start, Date end) {
if (start.after(end)) throw new IllegalArgumentException("start > end");
this.start = new Date(start.getTime());
this.end = new Date(end.getTime());
}
private void readObject(ObjectInputStream ois)
throws IOException, ClassNotFoundException {
ois.defaultReadObject(); // populate fields from stream
// Re-enforce constructor invariants — as if the constructor ran:
if (start.after(end)) throw new InvalidObjectException("start > end");
// Re-apply defensive copy (the stream may have set the field to a
// mutable Date — we need our own copy):
// Note: cannot reassign final fields here without reflection tricks
// Use non-final fields or the serialization proxy pattern instead
}
}
// ── Field matching during version evolution ───────────────────────────
// Version 1 of Person: {name, age, email}
// Version 2 of Person: {name, age, email, phone} (added field)
// Deserializing v1 data into v2 class:
// - name, age, email: populated from stream
// - phone: set to null (not in stream — gets default for reference type)
// No error — compatible evolution with same serialVersionUID
// Deserializing v2 data into v1 class (with same serialVersionUID):
// - name, age, email: populated from stream
// - phone: in stream but not in class — IGNORED
// No error — extra fields are silently skippedreadObject, readResolve, and ObjectInputStream Configuration
// ── readObject: custom deserialization with extra data ────────────────
public class VersionedData implements Serializable {
private static final long serialVersionUID = 1L;
private String name;
private int value;
private transient String derived; // not serialized
private transient long timestamp; // not serialized in v1, serialized in v2
private void writeObject(ObjectOutputStream oos) throws IOException {
oos.defaultWriteObject(); // write name, value
oos.writeLong(System.currentTimeMillis()); // write extra timestamp (v2 addition)
}
private void readObject(ObjectInputStream ois)
throws IOException, ClassNotFoundException {
ois.defaultReadObject(); // read name, value
// Read extra data written by writeObject (if available):
// ObjectInputStream.available() is unreliable — use try/catch for version compat:
try {
this.timestamp = ois.readLong(); // v2 data
} catch (EOFException e) {
this.timestamp = 0L; // v1 data had no timestamp — use default
}
// Re-derive transient computed fields:
this.derived = name.toUpperCase() + "_" + value;
// Re-enforce invariants:
if (value < 0) throw new InvalidObjectException("value cannot be negative");
}
}
// ── readResolve: singleton and enum-like patterns ─────────────────────
public final class Weekday implements Serializable {
private static final long serialVersionUID = 1L;
public static final Weekday MONDAY = new Weekday("MONDAY");
public static final Weekday TUESDAY = new Weekday("TUESDAY");
public static final Weekday WEDNESDAY = new Weekday("WEDNESDAY");
// ... etc.
private final String name;
private Weekday(String name) { this.name = name; }
// readResolve: return the canonical constant instead of the new instance
private Object readResolve() {
return switch (name) {
case "MONDAY" -> MONDAY;
case "TUESDAY" -> TUESDAY;
case "WEDNESDAY" -> WEDNESDAY;
default -> throw new InvalidObjectException("Unknown weekday: " + name);
};
}
}
// ── Custom class loader via ObjectInputStream subclass ────────────────
public class ContextClassLoaderOIS extends ObjectInputStream {
private final ClassLoader classLoader;
public ContextClassLoaderOIS(InputStream in, ClassLoader cl)
throws IOException {
super(in);
this.classLoader = cl;
}
@Override
protected Class<?> resolveClass(ObjectStreamClass desc)
throws IOException, ClassNotFoundException {
try {
return Class.forName(desc.getName(), false, classLoader);
} catch (ClassNotFoundException e) {
return super.resolveClass(desc); // fall back to default resolution
}
}
}
// ── ObjectInputFilter: whitelist-based security ───────────────────────
// Per-stream filter:
try (ObjectInputStream ois = new ObjectInputStream(
new BufferedInputStream(new FileInputStream("data.ser")))) {
ois.setObjectInputFilter(filterInfo -> {
Class<?> clazz = filterInfo.serialClass();
if (clazz == null) return ObjectInputFilter.Status.UNDECIDED;
// Whitelist: accept only these classes
if (clazz == Person.class ||
clazz == Department.class ||
clazz == java.util.ArrayList.class ||
clazz == java.lang.String.class) {
return ObjectInputFilter.Status.ALLOWED;
}
// Reject everything else:
System.err.println("Rejected class: " + clazz.getName());
return ObjectInputFilter.Status.REJECTED;
});
Object obj = ois.readObject(); // filter applied to every class in graph
}
// Global JVM filter (set once at startup):
// System property: -Djdk.serialFilter=com.example.*;java.util.*;java.lang.String;!*
// Programmatic:
ObjectInputFilter.Config.setSerialFilter(
ObjectInputFilter.Config.createFilter("com.example.*;java.util.*;java.lang.String;!*")
);