serialization: Java serialization companion code (UID drift, JEP 290/415 filters, records, JSON/Protobuf comparison)

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01KqJyCidz3ZgRyHABv2GVJh
This commit is contained in:
Claude
2026-09-30 19:03:40 +00:00
parent b31c6716a3
commit 97e7352f45
31 changed files with 867 additions and 0 deletions
+1
View File
@@ -16,6 +16,7 @@ article; each module's own README has that article's version table, quickstart,
| [`hashmap-concurrenthashmap`](hashmap-concurrenthashmap/) | Java HashMap vs ConcurrentHashMap: Complete Interview Guide |
| [`exceptions`](exceptions/) | Java Exception Handling Deep Dive: Checked vs Unchecked, Suppression, and What Exceptions Actually Cost |
| [`regex`](regex/) | Java Regex Tutorial: Pattern, Matcher, Groups, Lookarounds and ReDoS |
| [`serialization`](serialization/) | Java Serialization in 2026: Why It's Dangerous and What Replaced It |
## License
+1
View File
@@ -24,6 +24,7 @@
<module>hashmap-concurrenthashmap</module>
<module>exceptions</module>
<module>regex</module>
<module>serialization</module>
</modules>
<properties>
+42
View File
@@ -0,0 +1,42 @@
# serialization
Companion code for the ankurm.com post *"Java Serialization in 2026: Why It's Dangerous and What Replaced It."*
Module `serialization` in `java-core-examples`.
All explanation lives in the post; this module holds the runnable evidence and the captured output.
Nothing here uses a real library gadget chain: the "attacker" classes are local classes that print a line.
## Versions
| Component | Version |
|---|---|
| JDK | 25.0.4.1+1 (Temurin, LTS) |
| Jackson (`tools.jackson.core:jackson-databind`) | 3.2.3 |
| protobuf-java | 4.36.2 |
| JMH | 1.37 |
| JUnit Jupiter | 5.11.0 |
| Hardware | 2 vCPU x86-64 VM (timings are indicative, not a leaderboard) |
## Quickstart
```bash
export JDK25_HOME=/path/to/jdk-25
./scripts/run-all.sh # rebuilds and regenerates everything in output/
```
## What is in here
| File | Shows | Output |
|---|---|---|
| `UidInStreamDemo` | the serialVersionUID is stored in the stream; patching it gives `InvalidClassException` | `01` |
| `src/versions/` | two versions of one class compiled separately by `run-all.sh`, without and with an explicit UID | `02`, `03` |
| `ReadObjectRunsCodeDemo` | `readObject` of the class named in the stream runs before the cast; an allow-list filter stops it | `04` |
| `ResourceLimitsDemo` | forged array length, deep graph, `maxarray` / `maxdepth` / `maxbytes` | `05` |
| `RecordsDemo` | records run the canonical constructor on deserialization; ordinary classes run none | `06` |
| `FilterFactoryDemo` | JEP 415 filter factory with a per-request context, on top of `-Djdk.serialFilter` | `07` |
| `Codecs`, `Order`, `order.proto`, `FormatSizeDemo` | the same object as Java serialization, Jackson 3 JSON and Protobuf wire format (hand-coded, no protoc) | `08` |
| `SerializationBenchmark` | JMH round trip of the three encoders | `09` |
| `SerializationTest` | 11 assertions behind the claims above | `10` |
The JMH run is 2 forks, 5 warmup and 8 measurement iterations of 1 s; re-running moves the numbers
but the Java-serialization-is-slowest ordering held in every run here.
@@ -0,0 +1,4 @@
declared serialVersionUID = 1
UID found in the stream = 1
patched stream UID = 2
InvalidClassException: com.ankurm.serialization.UidInStreamDemo$Ticket; local class incompatible: stream classdesc serialVersionUID = 2, local class serialVersionUID = 1
@@ -0,0 +1,6 @@
$ # implicit serialVersionUID (none declared)
$ java -cp v1 DriftWrite
wrote 113 bytes; serialVersionUID in stream = 1201216851776330172
$ java -cp v2 DriftRead # v2 adds a field
this class's serialVersionUID = -732213015030957059
InvalidClassException: com.ankurm.serialization.drift.Account; local class incompatible: stream classdesc serialVersionUID = 1201216851776330172, local class serialVersionUID = -732213015030957059
@@ -0,0 +1,6 @@
$ # explicit serialVersionUID = 1L
$ java -cp v1 DriftWrite
wrote 113 bytes; serialVersionUID in stream = 1
$ java -cp v2 DriftRead # v2 adds a field
this class's serialVersionUID = 1
read: Account[owner=asha, balance=500, email=null]
@@ -0,0 +1,6 @@
application expects a Greeting and writes: String greeting = (Greeting) in.readObject()
>>> Noisy.readObject() is running -- code of the class named in the stream
ClassCastException AFTER the side effect: class com.ankurm.serialization.ReadObjectRunsCodeDemo$Noisy cannot be cast to class com.ankurm.serialization.ReadObjectRunsCodeDemo$Greeting (com.ankurm.serialization.ReadObjectRunsCodeDemo$Noisy and com.ankurm.serialization.ReadObjectRunsCodeDemo$Greeting are in unnamed module of loader 'app')
same bytes, allow-list filter that only admits Greeting:
InvalidClassException: filter status: REJECTED
@@ -0,0 +1,16 @@
max heap = 256 MiB
forged stream is 37 bytes long but claims a byte[1000000000]
no filter -> OutOfMemoryError: Java heap space
maxarray=100000 -> InvalidClassException: filter status: REJECTED
filter saw: class=class [B arrayLength=-1 depth=1 streamBytes=21 -> UNDECIDED
filter saw: class=class [B arrayLength=1000000000 depth=1 streamBytes=27 -> REJECTED
maxarray, logged -> InvalidClassException: filter status: REJECTED
a legitimate-looking chain of 200 nodes is 1324 bytes
no filter -> accepted: Node
maxdepth=50 -> InvalidClassException: filter status: REJECTED
maxbytes=10000, one 50 KB array -> accepted: byte[]
an ArrayList of 5000 integers is 50125 bytes
maxbytes=10000, 5000 integers -> InvalidClassException: filter status: REJECTED
maxbytes=1000000, 5000 integers -> accepted: ArrayList
+5
View File
@@ -0,0 +1,5 @@
record default serialVersionUID = 0
forged stream with years = -5:
record -> InvalidObjectException: years out of range: -5
cause: java.lang.IllegalArgumentException: years out of range: -5
class -> AgeClass[years=-5] (no constructor ran)
@@ -0,0 +1,13 @@
$ java -Djdk.serialFilter="maxdepth=10;com.ankurm.serialization.*;java.lang.*;!*" FilterFactoryDemo
jdk.serialFilter (system property) = maxdepth=10;com.ankurm.serialization.*;java.lang.*;!*
Config.getSerialFilter() = maxdepth=10;com.ankurm.serialization.*;java.lang.*;!*
factory before = java.io.ObjectInputFilter$Config$BuiltinFilterFactory
factory installed; installing a second one:
IllegalStateException: Cannot replace filter factory
context A (Ok + String allowed):
read Ok -> Ok[s=fine]
read String -> a plain string
read 50-deep chain -> InvalidClassException: filter status: REJECTED
context B (String only):
read String -> a plain string
read Ok -> InvalidClassException: filter status: REJECTED
+10
View File
@@ -0,0 +1,10 @@
Java serialization : 382 bytes, starts with aced0005 (magic aced0005)
Jackson 3 JSON : 223 bytes
Protobuf wire : 80 bytes
JSON text: {"id":1000042,"customer":"Asha Mehta","currency":"INR","lines":[{"sku":"BK-JAVA-25","quantity":2,"priceMinor":49900},{"sku":"BK-JVM-INT","quantity":1,"priceMinor":79900},{"sku":"CBL-USB-C","quantity":3,"priceMinor":19900}]}
round trips equal : java=true json=true protobuf=true
JSON cannot name a class to instantiate unless you opt in. Feeding it a type hint:
parsed as Order[id=1, customer=x, currency=INR, lines=[]]
+4
View File
@@ -0,0 +1,4 @@
Benchmark Mode Cnt Score Error Units
SerializationBenchmark.jacksonJson avgt 16 3107.305 ± 467.508 ns/op
SerializationBenchmark.javaSerialization avgt 16 11316.514 ± 538.967 ns/op
SerializationBenchmark.protobufWire avgt 16 3736.484 ± 283.111 ns/op
+4
View File
@@ -0,0 +1,4 @@
-------------------------------------------------------------------------------
Test set: com.ankurm.serialization.SerializationTest
-------------------------------------------------------------------------------
Tests run: 11, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.851 s -- in com.ankurm.serialization.SerializationTest
+100
View File
@@ -0,0 +1,100 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.ankurm</groupId>
<artifactId>java-core-examples</artifactId>
<version>1.0</version>
</parent>
<artifactId>serialization</artifactId>
<name>serialization</name>
<description>Java serialization in 2026: serialVersionUID drift, deserialization filters (JEP 290/415), records, and JSON/Protobuf alternatives with size and speed numbers.</description>
<properties>
<jmh.version>1.37</jmh.version>
<jackson.version>3.2.3</jackson.version>
<protobuf.version>4.36.2</protobuf.version>
</properties>
<dependencies>
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-core</artifactId>
<version>${jmh.version}</version>
</dependency>
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-generator-annprocess</artifactId>
<version>${jmh.version}</version>
</dependency>
<dependency>
<groupId>tools.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>${jackson.version}</version>
</dependency>
<dependency>
<groupId>com.google.protobuf</groupId>
<artifactId>protobuf-java</artifactId>
<version>${protobuf.version}</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.11.0</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<finalName>benchmarks</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration>
<release>25</release>
<!--
Same JDK-25-stops-discovering-annotation-processors-implicitly trap documented in
the jmm module: JMH's @Benchmark-method-to-*_jmh.java generator needs to be declared
explicitly here or the build silently produces a jar with nothing runnable in it.
-->
<annotationProcessorPaths>
<path>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-generator-annprocess</artifactId>
<version>${jmh.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.5.1</version>
<executions>
<execution>
<phase>package</phase>
<goals><goal>shade</goal></goals>
<configuration>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>org.openjdk.jmh.Main</mainClass>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
# Regenerates every file in ../output/. Requires JDK25_HOME.
set -euo pipefail
[[ -z "${JDK25_HOME:-}" ]] && { echo "JDK25_HOME must be set" >&2; exit 1; }
cd "$(dirname "$0")/.."
OUT=output; mkdir -p "$OUT"
export JAVA_HOME="$JDK25_HOME"
mvn -q -f ../pom.xml -pl serialization -am package 2>&1 | grep -v -E "Picked up|^WARNING" || true
J="$JDK25_HOME/bin/java"; JC="$JDK25_HOME/bin/javac"
M2="${HOME}/.m2/repository"
CP="target/classes:$(ls $M2/tools/jackson/core/jackson-databind/3.2.3/*.jar):$(ls $M2/tools/jackson/core/jackson-core/3.2.3/*.jar):$(ls $M2/com/fasterxml/jackson/core/jackson-annotations/*/*.jar | tail -1):$(ls $M2/com/google/protobuf/protobuf-java/4.36.2/*.jar)"
run() { f=$1; shift; echo "==> $f"; "$J" "$@" 2>&1 | grep -v "Picked up" > "$OUT/$f"; }
# 01: the UID travels inside the stream
run 01-uid-in-stream.txt -cp "$CP" com.ankurm.serialization.UidInStreamDemo
# 02/03: two versions of the same class, with and without an explicit serialVersionUID
for mode in implicit explicit; do
rm -rf target/drift-$mode; mkdir -p target/drift-$mode
for v in v1 v2; do
d=target/drift-$mode/$v/com/ankurm/serialization/drift; mkdir -p $d
if [[ $mode == explicit ]]; then
sed 's#/\*UID\*/#private static final long serialVersionUID = 1L;#' src/versions/$v/Account.java > $d/Account.java
else
sed 's#/\*UID\*/##' src/versions/$v/Account.java > $d/Account.java
fi
cp src/versions/common/*.java $d/
$JC -d target/drift-$mode/$v/classes $d/*.java 2>&1 | grep -v "Picked up" || true
done
done
{
echo '$ # implicit serialVersionUID (none declared)'
echo '$ java -cp v1 DriftWrite'
"$J" -cp target/drift-implicit/v1/classes com.ankurm.serialization.drift.DriftWrite target/account-implicit.ser 2>&1 | grep -v "Picked up"
echo '$ java -cp v2 DriftRead # v2 adds a field'
"$J" -cp target/drift-implicit/v2/classes com.ankurm.serialization.drift.DriftRead target/account-implicit.ser 2>&1 | grep -v "Picked up"
} > "$OUT/02-drift-implicit.txt"
{
echo '$ # explicit serialVersionUID = 1L'
echo '$ java -cp v1 DriftWrite'
"$J" -cp target/drift-explicit/v1/classes com.ankurm.serialization.drift.DriftWrite target/account-explicit.ser 2>&1 | grep -v "Picked up"
echo '$ java -cp v2 DriftRead # v2 adds a field'
"$J" -cp target/drift-explicit/v2/classes com.ankurm.serialization.drift.DriftRead target/account-explicit.ser 2>&1 | grep -v "Picked up"
} > "$OUT/03-drift-explicit.txt"
run 04-readobject-runs-code.txt -cp "$CP" com.ankurm.serialization.ReadObjectRunsCodeDemo
run 05-resource-limits.txt -Xmx256m -cp "$CP" com.ankurm.serialization.ResourceLimitsDemo
run 06-records.txt -cp "$CP" com.ankurm.serialization.RecordsDemo
{
echo '$ java -Djdk.serialFilter="maxdepth=10;com.ankurm.serialization.*;java.lang.*;!*" FilterFactoryDemo'
"$J" -Djdk.serialFilter='maxdepth=10;com.ankurm.serialization.*;java.lang.*;!*' -cp "$CP" com.ankurm.serialization.FilterFactoryDemo 2>&1 | grep -v "Picked up"
} > "$OUT/07-filter-factory.txt"
run 08-format-sizes.txt -cp "$CP" com.ankurm.serialization.FormatSizeDemo
echo "==> JMH"
"$J" -jar target/benchmarks.jar SerializationBenchmark -rf text -rff "$OUT/09-jmh-raw.txt" > /dev/null 2>&1
cp target/surefire-reports/com.ankurm.serialization.SerializationTest.txt "$OUT/10-tests.txt"
echo Done
@@ -0,0 +1,77 @@
package com.ankurm.serialization;
import com.google.protobuf.CodedInputStream;
import com.google.protobuf.CodedOutputStream;
import tools.jackson.databind.json.JsonMapper;
import java.io.*;
import java.util.ArrayList;
import java.util.List;
/**
* Three encoders for {@link Order}: Java serialization, Jackson 3 JSON, and Protobuf wire format.
* The Protobuf side is written against protobuf-java's CodedOutputStream / CodedInputStream using the
* same field numbers a .proto file would declare (see order.proto). That is the real wire format, but
* there is no protoc-generated class here.
*/
public final class Codecs {
private Codecs() {}
public static final JsonMapper JSON = JsonMapper.builder().build();
// ---- Java serialization
public static byte[] javaWrite(Order o) throws IOException { return Wire.write(o); }
public static Order javaRead(byte[] b) throws Exception { return (Order) Wire.read(b); }
// ---- Jackson 3
public static byte[] jsonWrite(Order o) { return JSON.writeValueAsBytes(o); }
public static Order jsonRead(byte[] b) { return JSON.readValue(b, Order.class); }
// ---- Protobuf wire format: Order { int64 id=1; string customer=2; string currency=3; repeated Line lines=4; }
// Line { string sku=1; int32 quantity=2; int64 price_minor=3; }
public static byte[] pbWrite(Order o) throws IOException {
ByteArrayOutputStream bos = new ByteArrayOutputStream(128);
CodedOutputStream out = CodedOutputStream.newInstance(bos);
out.writeInt64(1, o.id());
out.writeString(2, o.customer());
out.writeString(3, o.currency());
for (Order.Line l : o.lines()) {
ByteArrayOutputStream lb = new ByteArrayOutputStream(32);
CodedOutputStream lo = CodedOutputStream.newInstance(lb);
lo.writeString(1, l.sku());
lo.writeInt32(2, l.quantity());
lo.writeInt64(3, l.priceMinor());
lo.flush();
out.writeByteArray(4, lb.toByteArray());
}
out.flush();
return bos.toByteArray();
}
public static Order pbRead(byte[] b) throws IOException {
CodedInputStream in = CodedInputStream.newInstance(b);
long id = 0; String customer = "", currency = ""; List<Order.Line> lines = new ArrayList<>();
for (int tag; (tag = in.readTag()) != 0; ) {
switch (tag >>> 3) {
case 1 -> id = in.readInt64();
case 2 -> customer = in.readStringRequireUtf8();
case 3 -> currency = in.readStringRequireUtf8();
case 4 -> {
CodedInputStream li = CodedInputStream.newInstance(in.readByteArray());
String sku = ""; int q = 0; long price = 0;
for (int t; (t = li.readTag()) != 0; ) {
switch (t >>> 3) {
case 1 -> sku = li.readStringRequireUtf8();
case 2 -> q = li.readInt32();
case 3 -> price = li.readInt64();
default -> li.skipField(t);
}
}
lines.add(new Order.Line(sku, q, price));
}
default -> in.skipField(tag);
}
}
return new Order(id, customer, currency, lines);
}
}
@@ -0,0 +1,61 @@
package com.ankurm.serialization;
import java.io.*;
import java.util.function.BinaryOperator;
/**
* JEP 415: a process-wide filter FACTORY chooses the filter for each ObjectInputStream from the
* calling context. Here the context is a ThreadLocal holding a per-request filter, merged with a global one.
* Run with: -Djdk.serialFilter="maxdepth=10;java.base/*;!*" (see run-all.sh).
*/
public class FilterFactoryDemo {
static final ThreadLocal<ObjectInputFilter> CONTEXT = new ThreadLocal<>();
record Ok(String s) implements Serializable {}
public static void main(String[] args) throws Exception {
System.out.println("jdk.serialFilter (system property) = " + System.getProperty("jdk.serialFilter"));
System.out.println("Config.getSerialFilter() = " + ObjectInputFilter.Config.getSerialFilter());
System.out.println("factory before = " + ObjectInputFilter.Config.getSerialFilterFactory().getClass().getName());
BinaryOperator<ObjectInputFilter> factory = (current, requested) -> {
ObjectInputFilter ctx = CONTEXT.get();
// 'current' is the filter already in effect for the stream (the process-wide one on first call)
ObjectInputFilter merged = ObjectInputFilter.merge(ctx, current);
return ObjectInputFilter.merge(requested, merged);
};
ObjectInputFilter.Config.setSerialFilterFactory(factory);
System.out.println("factory installed; installing a second one:");
try {
ObjectInputFilter.Config.setSerialFilterFactory(factory);
} catch (IllegalStateException e) {
System.out.println("IllegalStateException: " + e.getMessage().substring(0, e.getMessage().indexOf(':')));
}
byte[] ok = Wire.write(new Ok("fine"));
byte[] str = Wire.write("a plain string");
// Context A: a request that may read Ok records
CONTEXT.set(ObjectInputFilter.Config.createFilter("com.ankurm.serialization.FilterFactoryDemo$Ok;com.ankurm.serialization.ResourceLimitsDemo$Node;java.lang.String;!*"));
System.out.println("context A (Ok + String allowed):");
System.out.println(" read Ok -> " + Wire.read(ok));
System.out.println(" read String -> " + Wire.read(str));
// the process-wide maxdepth=10 from -Djdk.serialFilter still applies underneath the context filter
try {
Wire.read(Wire.write(ResourceLimitsDemo.chain(50)));
} catch (InvalidClassException e) {
System.out.println(" read 50-deep chain -> InvalidClassException: " + e.getMessage());
}
// Context B: a request that may only read Strings
CONTEXT.set(ObjectInputFilter.Config.createFilter("java.lang.String;!*"));
System.out.println("context B (String only):");
System.out.println(" read String -> " + Wire.read(str));
try {
Wire.read(ok);
} catch (InvalidClassException e) {
System.out.println(" read Ok -> InvalidClassException: " + e.getMessage());
}
}
}
@@ -0,0 +1,28 @@
package com.ankurm.serialization;
import java.nio.charset.StandardCharsets;
/** Prints the encoded size of the same Order in each format, plus what each one looks like on the wire. */
public class FormatSizeDemo {
public static void main(String[] args) throws Exception {
Order o = Order.sample();
byte[] java = Codecs.javaWrite(o), json = Codecs.jsonWrite(o), pb = Codecs.pbWrite(o);
System.out.println("Java serialization : " + java.length + " bytes, starts with " + Wire.hexHead(java, 4) + " (magic aced0005)");
System.out.println("Jackson 3 JSON : " + json.length + " bytes");
System.out.println("Protobuf wire : " + pb.length + " bytes");
System.out.println();
System.out.println("JSON text: " + new String(json, StandardCharsets.UTF_8));
System.out.println();
System.out.println("round trips equal : java=" + o.equals(Codecs.javaRead(java))
+ " json=" + o.equals(Codecs.jsonRead(json)) + " protobuf=" + o.equals(Codecs.pbRead(pb)));
System.out.println();
System.out.println("JSON cannot name a class to instantiate unless you opt in. Feeding it a type hint:");
String hostile = "{\"@class\":\"java.lang.ProcessBuilder\",\"id\":1,\"customer\":\"x\",\"currency\":\"INR\",\"lines\":[]}";
try {
System.out.println(" parsed as " + Codecs.JSON.readValue(hostile, Order.class));
} catch (Exception e) {
System.out.println(" " + e.getClass().getSimpleName() + ": " + e.getMessage().lines().findFirst().orElse(""));
}
}
}
@@ -0,0 +1,17 @@
package com.ankurm.serialization;
import java.io.Serializable;
import java.util.List;
/** The same small business object encoded three ways for the size/speed comparison. */
public record Order(long id, String customer, String currency, List<Line> lines) implements Serializable {
public record Line(String sku, int quantity, long priceMinor) implements Serializable {}
public static Order sample() {
return new Order(1_000_042L, "Asha Mehta", "INR", List.of(
new Line("BK-JAVA-25", 2, 49_900),
new Line("BK-JVM-INT", 1, 79_900),
new Line("CBL-USB-C", 3, 19_900)));
}
}
@@ -0,0 +1,46 @@
package com.ankurm.serialization;
import java.io.*;
/**
* Safe demonstration of the core problem: deserialization executes code from the class named in the
* stream, BEFORE the caller gets the object back and therefore before any cast or instanceof check.
* The "payload" here only prints a line. No real library class is involved.
*/
public class ReadObjectRunsCodeDemo {
/** A class that happens to be on the classpath and has a readObject with a side effect. */
static class Noisy implements Serializable {
private static final long serialVersionUID = 1L;
String note = "hello";
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
in.defaultReadObject();
System.out.println(" >>> Noisy.readObject() is running -- code of the class named in the stream");
}
}
/** The class the application thinks it is reading. */
record Greeting(String text) implements Serializable {}
public static void main(String[] args) throws Exception {
byte[] bytes = Wire.write(new Noisy());
System.out.println("application expects a Greeting and writes: String greeting = (Greeting) in.readObject()");
try {
Greeting g = (Greeting) Wire.read(bytes);
System.out.println("got " + g);
} catch (ClassCastException e) {
System.out.println("ClassCastException AFTER the side effect: " + e.getMessage());
}
System.out.println();
System.out.println("same bytes, allow-list filter that only admits Greeting:");
ObjectInputFilter onlyGreeting = ObjectInputFilter.Config.createFilter(
"com.ankurm.serialization.ReadObjectRunsCodeDemo$Greeting;!*");
try {
Wire.read(bytes, onlyGreeting);
} catch (InvalidClassException e) {
System.out.println("InvalidClassException: " + e.getMessage());
}
}
}
@@ -0,0 +1,42 @@
package com.ankurm.serialization;
import java.io.*;
/** Records deserialize through their canonical constructor; ordinary classes do not run any constructor. */
public class RecordsDemo {
record AgeRecord(int years) implements Serializable {
AgeRecord {
if (years < 0 || years > 150) throw new IllegalArgumentException("years out of range: " + years);
}
}
static class AgeClass implements Serializable {
private static final long serialVersionUID = 1L;
final int years;
AgeClass(int years) {
if (years < 0 || years > 150) throw new IllegalArgumentException("years out of range: " + years);
this.years = years;
}
@Override public String toString() { return "AgeClass[years=" + years + "]"; }
}
public static void main(String[] args) throws Exception {
System.out.println("record default serialVersionUID = " + ObjectStreamClass.lookup(AgeRecord.class).getSerialVersionUID());
byte[] rec = Wire.write(new AgeRecord(30));
byte[] cls = Wire.write(new AgeClass(30));
// the int field is the last four bytes of each stream
Wire.putInt(rec, rec.length - 4, -5);
Wire.putInt(cls, cls.length - 4, -5);
System.out.println("forged stream with years = -5:");
try {
System.out.println(" record -> " + Wire.read(rec));
} catch (InvalidObjectException e) {
System.out.println(" record -> InvalidObjectException: " + e.getMessage());
System.out.println(" cause: " + e.getCause());
}
System.out.println(" class -> " + Wire.read(cls) + " (no constructor ran)");
}
}
@@ -0,0 +1,75 @@
package com.ankurm.serialization;
import java.io.*;
/**
* Resource exhaustion without any gadget: a stream can ask for a huge array or a very deep object
* graph. The JEP 290 limits maxarray / maxdepth / maxbytes reject it before the allocation or recursion.
*/
public class ResourceLimitsDemo {
static class Node implements Serializable {
private static final long serialVersionUID = 1L;
Node next;
}
static Node chain(int depth) {
Node head = new Node(), cur = head;
for (int i = 1; i < depth; i++) { cur.next = new Node(); cur = cur.next; }
return head;
}
/** Serialize a byte[10], then patch the declared array length to 'claimed'. The array length int is 14 bytes from the end. */
static byte[] forgedArray(int claimed) throws IOException {
byte[] b = Wire.write(new byte[] {0, 1, 2, 3, 4, 5, 6, 7, 8, 9});
Wire.putInt(b, b.length - 14, claimed);
return b;
}
static void attempt(String label, byte[] bytes, ObjectInputFilter filter) {
try {
Object o = filter == null ? Wire.read(bytes) : Wire.read(bytes, filter);
System.out.println(label + " -> accepted: " + o.getClass().getSimpleName());
} catch (InvalidClassException e) {
System.out.println(label + " -> InvalidClassException: " + e.getMessage());
} catch (EOFException e) {
System.out.println(label + " -> EOFException (stream ended; the array WAS allocated first)");
} catch (OutOfMemoryError e) {
System.out.println(label + " -> OutOfMemoryError: " + e.getMessage());
} catch (Exception e) {
System.out.println(label + " -> " + e);
}
}
public static void main(String[] args) throws Exception {
System.out.println("max heap = " + Runtime.getRuntime().maxMemory() / (1024 * 1024) + " MiB");
byte[] bomb = forgedArray(1_000_000_000);
System.out.println("forged stream is " + bomb.length + " bytes long but claims a byte[1000000000]");
attempt("no filter ", bomb, null);
attempt("maxarray=100000 ", bomb, ObjectInputFilter.Config.createFilter("maxarray=100000"));
ObjectInputFilter limit = ObjectInputFilter.Config.createFilter("maxarray=100000");
attempt("maxarray, logged ", bomb, info -> {
ObjectInputFilter.Status st = limit.checkInput(info);
System.out.println(" filter saw: class=" + info.serialClass() + " arrayLength=" + info.arrayLength()
+ " depth=" + info.depth() + " streamBytes=" + info.streamBytes() + " -> " + st);
return st;
});
System.out.println();
byte[] deep = Wire.write(chain(200));
System.out.println("a legitimate-looking chain of 200 nodes is " + deep.length + " bytes");
attempt("no filter ", deep, null);
attempt("maxdepth=50 ", deep, ObjectInputFilter.Config.createFilter("maxdepth=50;com.ankurm.serialization.ResourceLimitsDemo$Node;!*"));
System.out.println();
byte[] one = Wire.write(new byte[50_000]);
attempt("maxbytes=10000, one 50 KB array ", one, ObjectInputFilter.Config.createFilter("maxbytes=10000"));
java.util.ArrayList<Integer> many = new java.util.ArrayList<>();
for (int i = 0; i < 5_000; i++) many.add(i);
byte[] list = Wire.write(many);
System.out.println("an ArrayList of 5000 integers is " + list.length + " bytes");
attempt("maxbytes=10000, 5000 integers ", list, ObjectInputFilter.Config.createFilter("maxbytes=10000"));
attempt("maxbytes=1000000, 5000 integers ", list, ObjectInputFilter.Config.createFilter("maxbytes=1000000"));
}
}
@@ -0,0 +1,19 @@
package com.ankurm.serialization;
import org.openjdk.jmh.annotations.*;
import java.util.concurrent.TimeUnit;
/** Round trip (encode + decode) of the same Order. Indicative only; 2 vCPU VM. */
@State(Scope.Benchmark)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 5, time = 1)
@Measurement(iterations = 8, time = 1)
@Fork(2)
public class SerializationBenchmark {
final Order order = Order.sample();
@Benchmark public Order javaSerialization() throws Exception { return Codecs.javaRead(Codecs.javaWrite(order)); }
@Benchmark public Order jacksonJson() { return Codecs.jsonRead(Codecs.jsonWrite(order)); }
@Benchmark public Order protobufWire() throws Exception { return Codecs.pbRead(Codecs.pbWrite(order)); }
}
@@ -0,0 +1,31 @@
package com.ankurm.serialization;
import java.io.*;
/** The serialVersionUID is written into the stream next to the class name; the reader compares it with its own class. */
public class UidInStreamDemo {
static class Ticket implements Serializable {
private static final long serialVersionUID = 1L;
String seat = "12A";
}
/** Offset of the 8-byte UID: magic+version (4), TC_OBJECT (1), TC_CLASSDESC (1), name length (2), name. */
static int uidOffset(Class<?> c) { return 4 + 1 + 1 + 2 + c.getName().length(); }
public static void main(String[] args) throws Exception {
byte[] bytes = Wire.write(new Ticket());
int off = uidOffset(Ticket.class);
long inStream = java.nio.ByteBuffer.wrap(bytes, off, 8).getLong();
System.out.println("declared serialVersionUID = " + ObjectStreamClass.lookup(Ticket.class).getSerialVersionUID());
System.out.println("UID found in the stream = " + inStream);
bytes[off + 7] = 2; // pretend the writer was running version 2 of the class
System.out.println("patched stream UID = " + java.nio.ByteBuffer.wrap(bytes, off, 8).getLong());
try {
Wire.read(bytes);
} catch (InvalidClassException e) {
System.out.println("InvalidClassException: " + e.getMessage());
}
}
}
@@ -0,0 +1,36 @@
package com.ankurm.serialization;
import java.io.*;
/** Small helpers shared by the demos: serialize to bytes, deserialize from bytes, hex dump. */
final class Wire {
private Wire() {}
static byte[] write(Object o) throws IOException {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
try (ObjectOutputStream out = new ObjectOutputStream(bos)) { out.writeObject(o); }
return bos.toByteArray();
}
static Object read(byte[] bytes) throws IOException, ClassNotFoundException {
try (ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(bytes))) { return in.readObject(); }
}
static Object read(byte[] bytes, ObjectInputFilter filter) throws IOException, ClassNotFoundException {
try (ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(bytes))) {
in.setObjectInputFilter(filter);
return in.readObject();
}
}
/** Overwrites four bytes with a big-endian int at the given offset. */
static void putInt(byte[] b, int off, int v) {
b[off] = (byte) (v >>> 24); b[off + 1] = (byte) (v >>> 16); b[off + 2] = (byte) (v >>> 8); b[off + 3] = (byte) v;
}
static String hexHead(byte[] b, int n) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < Math.min(n, b.length); i++) sb.append(String.format("%02x", b[i]));
return sb.toString();
}
}
+15
View File
@@ -0,0 +1,15 @@
syntax = "proto3";
// The schema that Codecs.pbWrite/pbRead follow by hand (field numbers and wire types).
message Order {
int64 id = 1;
string customer = 2;
string currency = 3;
repeated Line lines = 4;
}
message Line {
string sku = 1;
int32 quantity = 2;
int64 price_minor = 3;
}
@@ -0,0 +1,85 @@
package com.ankurm.serialization;
import org.junit.jupiter.api.Test;
import java.io.*;
import java.util.ArrayList;
import static org.junit.jupiter.api.Assertions.*;
class SerializationTest {
@Test void uidMismatchThrowsInvalidClassException() throws Exception {
byte[] b = Wire.write(new UidInStreamDemo.Ticket());
b[UidInStreamDemo.uidOffset(UidInStreamDemo.Ticket.class) + 7] = 2;
InvalidClassException e = assertThrows(InvalidClassException.class, () -> Wire.read(b));
assertTrue(e.getMessage().contains("local class incompatible"));
}
@Test void readObjectRunsBeforeTheCast() throws Exception {
PrintStream old = System.out;
ByteArrayOutputStream cap = new ByteArrayOutputStream();
System.setOut(new PrintStream(cap));
try {
byte[] b = Wire.write(new ReadObjectRunsCodeDemo.Noisy());
assertThrows(ClassCastException.class, () -> { ReadObjectRunsCodeDemo.Greeting g = (ReadObjectRunsCodeDemo.Greeting) Wire.read(b); });
} finally { System.setOut(old); }
assertTrue(cap.toString().contains("Noisy.readObject() is running"));
}
@Test void allowListRejectsUnexpectedClass() throws Exception {
byte[] b = Wire.write(new ReadObjectRunsCodeDemo.Noisy());
ObjectInputFilter f = ObjectInputFilter.Config.createFilter("com.ankurm.serialization.ReadObjectRunsCodeDemo$Greeting;!*");
assertThrows(InvalidClassException.class, () -> Wire.read(b, f));
}
@Test void maxarrayRejectsForgedLengthBeforeAllocation() throws Exception {
byte[] bomb = ResourceLimitsDemo.forgedArray(1_000_000_000);
ObjectInputFilter f = ObjectInputFilter.Config.createFilter("maxarray=100000");
assertThrows(InvalidClassException.class, () -> Wire.read(bomb, f));
}
@Test void maxdepthRejectsDeepChain() throws Exception {
byte[] deep = Wire.write(ResourceLimitsDemo.chain(200));
assertNotNull(Wire.read(deep));
ObjectInputFilter f = ObjectInputFilter.Config.createFilter("maxdepth=50");
assertThrows(InvalidClassException.class, () -> Wire.read(deep, f));
}
@Test void maxbytesChecksAtCallbacksNotAtTheArrayHeader() throws Exception {
ObjectInputFilter f = ObjectInputFilter.Config.createFilter("maxbytes=10000");
assertNotNull(Wire.read(Wire.write(new byte[50_000]), f)); // single array: accepted
ArrayList<Integer> many = new ArrayList<>();
for (int i = 0; i < 5_000; i++) many.add(i);
assertThrows(InvalidClassException.class, () -> Wire.read(Wire.write(many), f));
}
@Test void recordConstructorValidationRunsOnDeserialization() throws Exception {
byte[] rec = Wire.write(new RecordsDemo.AgeRecord(30));
Wire.putInt(rec, rec.length - 4, -5);
InvalidObjectException e = assertThrows(InvalidObjectException.class, () -> Wire.read(rec));
assertInstanceOf(IllegalArgumentException.class, e.getCause());
}
@Test void plainClassSkipsItsConstructor() throws Exception {
byte[] cls = Wire.write(new RecordsDemo.AgeClass(30));
Wire.putInt(cls, cls.length - 4, -5);
assertEquals(-5, ((RecordsDemo.AgeClass) Wire.read(cls)).years);
}
@Test void recordDefaultUidIsZero() {
assertEquals(0L, ObjectStreamClass.lookup(RecordsDemo.AgeRecord.class).getSerialVersionUID());
}
@Test void allThreeFormatsRoundTrip() throws Exception {
Order o = Order.sample();
assertEquals(o, Codecs.javaRead(Codecs.javaWrite(o)));
assertEquals(o, Codecs.jsonRead(Codecs.jsonWrite(o)));
assertEquals(o, Codecs.pbRead(Codecs.pbWrite(o)));
}
@Test void sizeOrderingIsJavaGreaterThanJsonGreaterThanProtobuf() throws Exception {
Order o = Order.sample();
int j = Codecs.javaWrite(o).length, s = Codecs.jsonWrite(o).length, p = Codecs.pbWrite(o).length;
assertTrue(j > s && s > p, j + " " + s + " " + p);
}
}
@@ -0,0 +1,16 @@
package com.ankurm.serialization.drift;
import java.io.*;
import java.nio.file.*;
public class DriftRead {
public static void main(String[] args) throws Exception {
long uid = ObjectStreamClass.lookup(Account.class).getSerialVersionUID();
System.out.println("this class's serialVersionUID = " + uid);
try (ObjectInputStream in = new ObjectInputStream(Files.newInputStream(Path.of(args[0])))) {
System.out.println("read: " + in.readObject());
} catch (InvalidClassException e) {
System.out.println("InvalidClassException: " + e.getMessage());
}
}
}
@@ -0,0 +1,15 @@
package com.ankurm.serialization.drift;
import java.io.*;
import java.nio.file.*;
public class DriftWrite {
public static void main(String[] args) throws Exception {
Path file = Path.of(args[0]);
try (ObjectOutputStream out = new ObjectOutputStream(Files.newOutputStream(file))) {
out.writeObject(new Account("asha", 500));
}
long uid = ObjectStreamClass.lookup(Account.class).getSerialVersionUID();
System.out.println("wrote " + Files.size(file) + " bytes; serialVersionUID in stream = " + uid);
}
}
@@ -0,0 +1,14 @@
package com.ankurm.serialization.drift;
import java.io.Serializable;
/** Version 1 of the class: two fields. The marker line below is replaced by run-all.sh. */
public class Account implements Serializable {
/*UID*/
final String owner;
final long balance;
public Account(String owner, long balance) { this.owner = owner; this.balance = balance; }
@Override public String toString() { return "Account[owner=" + owner + ", balance=" + balance + "]"; }
}
@@ -0,0 +1,15 @@
package com.ankurm.serialization.drift;
import java.io.Serializable;
/** Version 2 of the class: one extra field, email. The marker line below is replaced by run-all.sh. */
public class Account implements Serializable {
/*UID*/
final String owner;
final long balance;
final String email;
public Account(String owner, long balance) { this.owner = owner; this.balance = balance; this.email = null; }
@Override public String toString() { return "Account[owner=" + owner + ", balance=" + balance + ", email=" + email + "]"; }
}