serialization
items 85–90: mostly, don't. and if you must, use a proxy.
- chapter
- chapter 16.11
- reading time
- 8 min read
the last six items, in one line: don't use java serialization. readObject on untrusted bytes can run code from any serializable class on your classpath. if you're stuck with it, filter what can be deserialized, treat readObject as a public constructor that skips your checks, and serialize a proxy (or a record) instead of the object itself.
- item 85prefer anything to java serializationserialization filters
- item 86implementing Serializable is a forever promise
- item 87design the serialized form on purpose
- item 88readObject is a constructor in disguise
- item 89enums for instance control
- item 90serialize a proxy, not the objectrecords do this for you
don't
item 85 · prefer anything to java serialization
ObjectInputStream.readObject() reads a class name from the stream, instantiates that class (any serializable class the JVM can find), and runs its deserialization code. an attacker who controls the bytes chooses which classes run. chains of innocent-looking classes ("gadgets") in common libraries have been strung together into remote code execution many times. there's no way to make it safe in general; the attack surface is your whole classpath.
hover a stage.
readObject methods run.so: for new systems, use a cross-platform format (JSON, protobuf, a plain binary format you parse yourself) where the receiving side decides the types. if you must accept java serialization, never from untrusted sources, and with an allowlist filter:
import java.io.*;
import java.util.*;
public class F {
static byte[] write(Object o) throws IOException {
var bytes = new ByteArrayOutputStream();
try (var out = new ObjectOutputStream(bytes)) { out.writeObject(o); }
return bytes.toByteArray();
}
static Object read(byte[] data) throws Exception {
try (var in = new ObjectInputStream(new ByteArrayInputStream(data))) {
// allow exactly these classes, reject everything else, cap the depth.
// ArrayList keeps an Object[] inside, and arrays are checked by element type.
in.setObjectInputFilter(ObjectInputFilter.Config.createFilter(
"java.util.ArrayList;java.lang.String;java.lang.Object;maxdepth=5;!*"));
return in.readObject();
}
}
public static void main(String[] args) throws Exception {
System.out.println(read(write(new ArrayList<>(List.of("ada", "bo")))));
try {
read(write(new ArrayList<>(List.of(new Date(0))))); // a Date isn't on the list
} catch (InvalidClassException e) {
System.out.println("blocked: " + e.getMessage());
}
}
}[ada, bo] blocked: filter status: REJECTED
if you must
item 86 · implementing Serializable is a forever promise
the moment a class is Serializable, its byte layout (including private fields) becomes part of its public API: change a field and old streams can't be read, unless you keep supporting the old form. it adds a hidden constructor (item 88), makes every new version a compatibility test, and is inherited by every subclass. declare an explicit serialVersionUID so a harmless change doesn't break compatibility on its own. classes designed for inheritance and inner classes should almost never be serializable. value classes like BigInteger and big collections are the classic legitimate cases.
item 87 · design the serialized form on purpose
the default form writes every non-transient field, which is the object's physical representation. that's fine when physical and logical match (a point is two ints). it's terrible for, say, a linked list: the default form writes every node and every link, recursively (slow, deep enough to overflow the stack on long lists), when the logical form is just "these elements, in order". write the logical form with writeObject/readObject, mark everything else transient, and remember that transient fields come back as 0/null, so re-establish them in readObject.
protecting yourself
item 88 · readObject is a constructor in disguise
deserialization creates an object without calling your constructor, so your constructor's validation and defensive copies (item 50) are skipped. a forged stream can produce an object your constructor would have rejected, or one that shares a mutable field with the attacker:
import java.io.*;
class Range implements Serializable {
final int lo, hi;
Range(int lo, int hi) {
if (lo > hi) throw new IllegalArgumentException(lo + " > " + hi);
System.out.println("class constructor ran");
this.lo = lo; this.hi = hi;
}
}
record Span(int lo, int hi) implements Serializable {
Span {
if (lo > hi) throw new IllegalArgumentException(lo + " > " + hi);
System.out.println("record constructor ran");
}
}
public class Main {
static Object roundTrip(Object o) throws Exception {
var bytes = new ByteArrayOutputStream();
try (var out = new ObjectOutputStream(bytes)) { out.writeObject(o); }
var in = new ObjectInputStream(new ByteArrayInputStream(bytes.toByteArray()));
return in.readObject();
}
public static void main(String[] args) throws Exception {
Range r = (Range) roundTrip(new Range(1, 5)); // constructor runs once: on new
System.out.println("got range " + r.lo + ".." + r.hi);
Span s = (Span) roundTrip(new Span(1, 5)); // twice: on new, and on read
System.out.println("got " + s);
}
}class constructor ran got range 1..5 record constructor ran record constructor ran got Span[lo=1, hi=5]
see? the class came back without its constructor running; the record went through its canonical constructor again. for a class, a defensive readObject must: copy every mutable component (copy first, as in item 50), then check every invariant and throw InvalidObjectException if one fails, and never call overridable methods.
item 89 · enums for instance control
a serializable singleton isn't a singleton anymore: every readObject makes a fresh instance. readResolve can swap the copy for the real one, but it's fragile (every instance field must be transient, or an attacker can grab a reference to the copy before the swap). an enum's identity is guaranteed by the JVM:
class Clock implements Serializable {
static final Clock INSTANCE = new Clock();
private Clock() {}
}
class FixedClock implements Serializable {
static final FixedClock INSTANCE = new FixedClock();
private FixedClock() {}
// swap the deserialized copy for the one true instance
private Object readResolve() { return INSTANCE; }
}
enum EnumClock { INSTANCE }
// roundTrip(Clock.INSTANCE) == Clock.INSTANCE → ?
// roundTrip(FixedClock.INSTANCE) == FixedClock.INSTANCE → ?
// roundTrip(EnumClock.INSTANCE) == EnumClock.INSTANCE → ?item 90 · serialize a proxy, not the object
the cleanest fix for items 87 and 88 at once: give the class a private nested proxy class that holds just the logical state. writeReplace writes the proxy instead of the object; the proxy's readResolve rebuilds the object through the public constructor, so every check runs again:
- Range(1, 5)a valid object
- bytesevery field, as written
- readObject()allocates, sets fields directly
- Range(?, ?)no constructor ran. no checks ran.
- Range(1, 5)writeReplace() swaps in…
- Proxy(lo, hi)just the logical data
- bytesthe proxy's fields
- readResolve()calls new Range(lo, hi)
- Range(1, 5)built by the constructor. checked.
import java.io.*;
final class Range implements Serializable {
private final int lo, hi;
Range(int lo, int hi) {
if (lo > hi) throw new IllegalArgumentException(lo + " > " + hi);
this.lo = lo; this.hi = hi;
}
@Override public String toString() { return lo + ".." + hi; }
private Object writeReplace() { return new Proxy(lo, hi); } // write the proxy instead
// a forged stream that claims to hold a Range directly is refused
private void readObject(ObjectInputStream in) throws InvalidObjectException {
throw new InvalidObjectException("use the proxy");
}
private record Proxy(int lo, int hi) implements Serializable {
// back through the constructor, so the check runs again
private Object readResolve() { return new Range(lo, hi); }
}
}
public class Main {
public static void main(String[] args) throws Exception {
var bytes = new ByteArrayOutputStream();
try (var out = new ObjectOutputStream(bytes)) { out.writeObject(new Range(1, 5)); }
var in = new ObjectInputStream(new ByteArrayInputStream(bytes.toByteArray()));
Object back = in.readObject();
System.out.println(back + " " + back.getClass().getSimpleName());
}
}1..5 Range
the costs: it doesn't work for classes clients can extend, or for object graphs with cycles, and it's a little slower than the default form.
- deserializing untrusted bytes runs code chosen by the bytes. don't, or filter with an allowlist.
Serializableturns your private fields into public API, forever.- the default form is the physical layout. write the logical form instead.
readObjectskips your constructor: copy, validate, and never trust the stream.- an enum is the only serializable singleton you don't have to defend.
- serialize a proxy and rebuild through the constructor. records do exactly that, for free.