Traps

91. Serialization Pitfalls

Java serialization is dangerous: missing serialVersionUID causes InvalidClassException on class changes; transient fields are skipped; deserialization of untrusted data is a remote code execution vulnerability.

Java 21 (core language)

Explanation

Serializable marks a class for Java's binary object serialization. If serialVersionUID is not declared explicitly, the JVM computes it from the class structure โ€” add or remove a field and the UID changes, making old serialized data unreadable (InvalidClassException). Always declare private static final long serialVersionUID = ...;.

transient fields are excluded from serialization โ€” useful for caches, derived values, or sensitive data (passwords, keys). They are initialised to their default value (null, 0, false) upon deserialization.

The real danger: deserializing data from an untrusted source can execute arbitrary code. Attackers craft gadget chains through existing classes on the classpath (Apache Commons, Spring internals, etc.) that chain together via deserialization callbacks. This has been the source of critical CVEs (Apache Struts RCE, JBoss RCE, etc.). Use ObjectInputFilter (Java 9+), serialization proxies (Effective Java), or avoid Java serialization entirely โ€” use JSON/protobuf instead.

Example

import java.io.*;

public class SerializationPitfalls {
    // CORRECT: explicit serialVersionUID prevents accidental breakage
    static class GoodUser implements Serializable {
        private static final long serialVersionUID = 1L; // explicit!
        private String name;
        private transient String password; // NOT serialized (security!)
        private transient int cachedHash;  // NOT serialized (derived)

        GoodUser(String name, String password) {
            this.name = name;
            this.password = password;
        }

        // Custom deserialization: restore transient fields if needed
        private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
            in.defaultReadObject(); // restore non-transient fields
            this.cachedHash = name.hashCode(); // re-derive
            // password remains null after deserialization (by design)
        }

        @Override public String toString() {
            return "GoodUser{name='" + name + "', password=[" +
                   (password == null ? "not set" : "set") + "]}";
        }
    }

    public static void main(String[] args) throws Exception {
        GoodUser user = new GoodUser("Alice", "secret123");
        System.out.println("Before: " + user);

        // Serialize to bytes
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        try (ObjectOutputStream oos = new ObjectOutputStream(baos)) {
            oos.writeObject(user);
        }
        System.out.printf("Serialized: %d bytes%n", baos.size());

        // Deserialize
        try (ObjectInputStream ois = new ObjectInputStream(
                new ByteArrayInputStream(baos.toByteArray()))) {
            GoodUser restored = (GoodUser) ois.readObject();
            System.out.println("After:  " + restored);
            // password is null โ€” transient field NOT restored
        }

        // Modern alternative: avoid Java serialization
        // Use JSON (Jackson, Gson), Protocol Buffers, or records + JSON
        System.out.println("\nPrefer: JSON or Protobuf over Java serialization");
        System.out.println("Use ObjectInputFilter for any serialization of external data");
    }
}

Sources