Serialization — Serializable và những cạm bẫy
Java native serialization, serialVersionUID, transient, custom writeObject/readObject. Câu chuyện CVE deserialization — lỗ hổng số 1 Java 2015. Vì sao team hiện đại chuyển sang JSON/Protobuf.
Tháng 11/2015, Foxglove Security publish talk tại AppSec California với tiêu đề gây sốc: "Marshalling Pickles — How deserialization can cause trouble". Trong talk, 2 researcher demo cách gửi 1 byte array HTTP tới WebSphere, JBoss, Jenkins — deserialize byte đó thực thi arbitrary code trên server. Không cần login, không cần exploit web framework cụ thể — chỉ cần server có Apache Commons Collections trong classpath (gần như mọi Java app enterprise lúc đó).
Industry gọi sự kiện này là "Java deserialization apocalypse". CVE-2015-4852 (JBoss), CVE-2015-7501 (Jenkins), CVE-2015-8103 (các vendor khác) — hàng chục vendor phải phát hành emergency patch. Các năm sau tiếp tục xuất hiện gadget chain mới: Spring, Groovy, Hibernate, BeanShell.
Gốc rễ: Java native serialization (Serializable, ObjectInputStream.readObject) có flaw cơ bản — quá trình deserialize thực thi code của class được deserialize. Nếu byte đến từ nguồn không tin, attacker có thể craft chuỗi class gây RCE.
Oracle response: Java 9 thêm ObjectInputFilter; JEP 154 (2013) propose remove Serialization hoàn toàn — coi là design flaw. Vẫn chưa deprecated official, nhưng team JDK khuyến nghị mạnh mẽ code mới không dùng native serialization.
Bài này giải thích: cơ chế serialization, serialVersionUID và transient, cách custom writeObject/readObject, câu chuyện vulnerability chi tiết, và alternatives hiện đại (JSON, Protobuf, Avro).
1. Java native serialization — cơ chế cơ bản
Ví dụ đơn giản
import java.io.*;
class User implements Serializable {
private static final long serialVersionUID = 1L;
String name;
int age;
User(String n, int a) { name = n; age = a; }
}
// Serialize
try (ObjectOutputStream out = new ObjectOutputStream(
new FileOutputStream("user.ser"))) {
out.writeObject(new User("Alice", 30));
}
// Deserialize
try (ObjectInputStream in = new ObjectInputStream(
new FileInputStream("user.ser"))) {
User u = (User) in.readObject();
System.out.println(u.name); // Alice
}
3 yêu cầu:
- Class implement
Serializable— marker interface (không có method), signal "an toàn serialize". - Field không
transient— sẽ được serialize. Fieldtransientbỏ qua. serialVersionUID— version identifier.
Serializable chỉ là marker
public interface Serializable {
// Khong co method
}
Không có method nào — JVM dùng reflection để đọc field, serialize. Class không implement Serializable → throw NotSerializableException khi writeObject.
Marker interface là pattern có kể từ Java 1.1 — khác modern-style annotation (@Serializable như Kotlin). Lý do lịch sử: Java 1.1 không có annotation (thêm Java 5), dùng interface rỗng.
Field nào được serialize
- Field không
transient→ serialize. - Field không
static→ serialize (static thuộc class, không instance). - Field
transient→ bỏ qua, deserialize trả default (null/0/false).
class User implements Serializable {
private static final long serialVersionUID = 1L;
String name; // serialize
int age; // serialize
transient String password; // KHONG serialize
transient Connection conn; // KHONG serialize
static int COUNT; // KHONG serialize (static)
}
Deserialize sau, password và conn = null (default cho reference). Nếu cần value, override readObject (mục 4) để rebuild.
2. serialVersionUID — version của class
class User implements Serializable {
private static final long serialVersionUID = 1L;
// ...
}
Mỗi class Serializable nên có field serialVersionUID — static final long. Vai trò: version identifier.
Deserialize check
Khi deserialize user.ser:
- JVM đọc UID từ byte stream.
- So với UID class hiện tại.
- Match → deserialize tiếp.
- Khác → throw
InvalidClassException.
Cơ chế: "file serialize version cũ không load được bằng class version mới khác UID".
Nếu không khai báo
JVM tính UID tự động bằng hash từ structure class: signature field, method, interface. Algorithm có trong spec nhưng không hoàn toàn deterministic giữa javac version, JDK version, OS.
Hậu quả:
- Recompile với javac khác → UID đổi.
- Thêm 1 method (kể cả private) → UID đổi.
- Reorder method → UID đổi (có thể).
File .ser serialized với version A không deserialize được bằng version B. Bug silent — phát hiện khi deploy.
Không khai báo → fragile với rebuild. IDE có warning: "The serializable class does not declare a static final serialVersionUID field". Nên bật strict mode và fix.
Khi tăng UID?
Tăng UID (1L → 2L) khi cố ý break compatibility — file cũ không load được với class mới. Ví dụ đổi schema field không tương thích, rename class.
Giữ UID khi thay đổi backward-compatible — thêm field mới (default), thêm method. Deserialize file cũ vẫn OK, field mới = default.
3. transient — bỏ field khỏi serialize
class Session implements Serializable {
private static final long serialVersionUID = 1L;
String userId;
transient String authToken; // Secret - khong ghi disk
transient Socket connection; // Resource runtime
transient Instant lastAccessed; // Compute lai duoc
}
Deserialize → authToken null, connection null, lastAccessed null.
Dùng cho:
Secret / sensitive data
Password, API token, session token — không persist disk (security risk nếu file leak).
Resource runtime
Socket, Connection, InputStream, Thread — không có ý nghĩa khi deserialize ở máy khác hoặc lần khởi động khác. Phải rebuild.
Cache / computed field
Field tính từ field khác — rebuild trong readObject khi cần:
class PriceCache implements Serializable {
private static final long serialVersionUID = 1L;
private final BigDecimal amount;
private final String currency;
private transient String displayPrice; // "$100.00" - compute tu amount + currency
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
in.defaultReadObject();
this.displayPrice = formatPrice(amount, currency); // rebuild
}
}
Large intermediate
Buffer, temporary structure — không cần persist, tạo lại khi cần.
4. Custom writeObject / readObject
Override cơ chế mặc định để control format:
class User implements Serializable {
private static final long serialVersionUID = 1L;
String name;
transient String passwordHash;
private void writeObject(ObjectOutputStream out) throws IOException {
out.defaultWriteObject(); // Serialize field binh thuong
out.writeUTF(encrypt(passwordHash)); // Custom: encrypt password truoc khi ghi
}
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
in.defaultReadObject(); // Deserialize field binh thuong
this.passwordHash = decrypt(in.readUTF()); // Custom: decrypt
}
}
Method private nhưng JVM vẫn gọi được — dùng reflection bypass access check. Thiết kế hơi kỳ lạ, di sản từ Java 1.1.
Use case:
- Encrypt sensitive field before write.
- Validate invariant in readObject (phòng data corruption).
- Rebuild transient field after deserialize.
- Change storage format mà không đổi field (backward-compatible migration).
Thêm readObjectNoData() — gọi khi deserialize object của subclass từ stream cũ không có data parent class. Hiếm dùng.
5. Vulnerability — deserialization là lỗ hổng cấp độ thảm hoạ
Đây là phần quan trọng nhất. Hiểu để không repeat mistake.
Cơ chế gadget chain
Khi ObjectInputStream.readObject() chạy, nó:
- Đọc class name từ byte stream.
- Load class từ classpath.
- Dùng reflection tạo instance (bỏ qua constructor bình thường — dùng
ObjectInputStream.readSerialData). - Set field từ byte stream.
- Gọi
readObjectprivate method của class nếu có.
Bước 5 là lỗ hổng: readObject là code — nó chạy trong JVM với quyền của process. Nếu class có readObject với logic "nguy hiểm" (vd Runtime.exec), deserialize triggers execution.
Nhưng attacker không control class trên server — làm sao? Dùng gadget chain: chain class có sẵn trên classpath, kết hợp để cuối cùng gọi Runtime.exec.
Gadget chain nổi tiếng — Apache Commons Collections InvokerTransformer:
// Byte craft boi attacker deserialize thanh:
Transformer[] chain = new Transformer[] {
new ConstantTransformer(Runtime.class),
new InvokerTransformer("getMethod", ..., {"exec", String.class}),
new InvokerTransformer("invoke", ..., {null, ...}),
new InvokerTransformer("exec", ..., {"rm -rf /"}),
};
// Khi deserialize, chain kich hoat -> Runtime.exec("rm -rf /")
Attacker gửi byte này tới endpoint nào accept ObjectInputStream (vd old RMI, old SOAP endpoint, serialization cache) → server execute command.
CVE nổi bật
- CVE-2015-4852 (JBoss) — RCE qua JMX endpoint.
- CVE-2015-7501 (Jenkins) — RCE qua remote channel.
- CVE-2016-1000027 (Spring) — Spring MVC RemoteInvocationSerializingExporter.
- CVE-2019-2725 (Oracle WebLogic) — lỗ hổng nghiêm trọng, Oracle emergency patch.
- Hàng chục khác — Hibernate, Groovy, BeanShell, Commons-FileUpload.
Tất cả cùng pattern: deserialize byte từ input không tin → gadget chain → RCE.
Phòng chống
Cách 1 — không deserialize untrusted input. Đây là biện pháp số 1.
Cách 2 — Allowlist filter (Java 9+):
ObjectInputFilter filter = ObjectInputFilter.Config.createFilter(
"com.myapp.User;com.myapp.Order;!*"
);
ObjectInputStream ois = new ObjectInputStream(source);
ois.setObjectInputFilter(filter);
User u = (User) ois.readObject();
Pattern "com.myapp.User;com.myapp.Order;!*":
com.myapp.User— cho phép.com.myapp.Order— cho phép.!*— từ chối mọi class khác.
Chỉ class trong allowlist được deserialize. Gadget chain với InvokerTransformer bị reject.
Java 8 không có ObjectInputFilter — dùng library SerialKiller (third-party) hoặc upgrade Java.
Cách 3 — dùng format khác hoàn toàn (JSON, Protobuf) — xem mục 6.
Không deserialize byte từ nguồn không tin. Nếu phải serialize cross-process/network, dùng JSON hoặc Protobuf — format data thuần, không có concept "execute class's readObject" — không có code execution surface.
6. Alternatives — JSON và Protobuf
Hiện nay team production tránh Java native serialization, chuyển sang text/binary format trung lập.
JSON với Jackson
Thư viện phổ biến: Jackson (default trong Spring), Gson (Google).
import com.fasterxml.jackson.databind.ObjectMapper;
ObjectMapper mapper = new ObjectMapper();
// Serialize
User u = new User("Alice", 30);
String json = mapper.writeValueAsString(u);
// {"name":"Alice","age":30}
// Deserialize
User u2 = mapper.readValue(json, User.class);
Ưu điểm:
- Human-readable: debug, inspect, modify bằng tay.
- Không executable class: format data thuần, không concept execute readObject.
- Cross-language: Python, Go, JavaScript, Rust cùng đọc được.
- Không yêu cầu
Serializable: class thường là OK. - Schema-less: thêm field mới không break client cũ (skip unknown).
Nhược:
- Chậm hơn binary (~5-10× so với Protobuf).
- Size lớn hơn (~2-3×).
- Không type-safe (JSON number parse thành int/long/double tuỳ config).
Dùng cho: REST API, config file, log format, cache với debug requirement.
Protobuf cho high-performance
Google Protocol Buffers — binary format với schema.
// user.proto
syntax = "proto3";
message User {
string name = 1;
int32 age = 2;
}
Codegen Java class từ schema:
protoc --java_out=src/main/java user.proto
User u = User.newBuilder()
.setName("Alice")
.setAge(30)
.build();
byte[] bytes = u.toByteArray();
User u2 = User.parseFrom(bytes);
Ưu điểm:
- Rất nhanh: ~100× nhanh hơn JSON parse, ~5× nhỏ hơn.
- Schema evolution: thêm field mới backward-compatible.
- Không code execution: binary data thuần.
- Cross-language: codegen Python, Go, C++, Rust.
- Forward compatibility: client cũ parse được message mới (skip unknown field).
Nhược:
- Cần
.protoschema — thêm build step. - Binary không human-readable — debug cần tool.
- Ecosystem learning curve.
Dùng cho: gRPC (default), high-throughput RPC, microservice communication, data pipeline (Kafka).
Avro — schema evolution friendly
Similar Protobuf nhưng:
- Schema stored cùng data trong header → không cần distribute schema ngoài.
- Hadoop native — file format cho big data.
- Schema registry (Confluent) cho Kafka.
Dùng cho: Kafka message, data lake, ETL pipeline.
Message Pack, CBOR
Binary JSON — nhanh hơn JSON 2-3×, không cần schema. Tradeoff: không fast như Protobuf, không stable như JSON schema.
7. Record serialization (Java 14+)
record Point(int x, int y) implements Serializable {
private static final long serialVersionUID = 1L;
}
Record tự serialize field. Khi deserialize:
- JVM dùng canonical constructor — validate input qua compact constructor nếu có.
record Age(int value) implements Serializable {
private static final long serialVersionUID = 1L;
public Age {
if (value < 0) throw new IllegalArgumentException();
}
}
Deserialize file với value = -1 → canonical constructor chạy → throw → reject. An toàn hơn class thường — class thường bypass constructor khi deserialize, compact constructor của record không bị bypass.
Record vẫn có thể là gadget trong chain nếu implement readObject custom — nhưng mặc định an toàn hơn.
8. Pitfall tổng hợp
❌ Nhầm 1: Không khai báo serialVersionUID.
class User implements Serializable { String name; } // UID tu compute
✅ private static final long serialVersionUID = 1L;
❌ Nhầm 2: Serialize resource.
class Session implements Serializable {
private Socket socket; // Fail hoac corrupt khi deserialize
}
✅ Mark transient, rebuild trong readObject:
private transient Socket socket;
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
in.defaultReadObject();
this.socket = createSocket(); // rebuild
}
❌ Nhầm 3: Deserialize input từ user/network.
byte[] data = request.getBody();
User u = (User) new ObjectInputStream(new ByteArrayInputStream(data)).readObject();
// Lo hong RCE neu classpath co gadget chain
✅ JSON parse với schema validation:
User u = mapper.readValue(request.getBody(), User.class);
❌ Nhầm 4: Dùng Java serialization cho cross-language.
// Python client khong doc duoc .ser file
✅ JSON / Protobuf cho interop.
❌ Nhầm 5: Dùng .ser làm config persistent.
// Format coupled voi Java version, class version
✅ YAML / JSON — stable, editable, version-independent.
❌ Nhầm 6: Quên filter trong Java 9+.
ObjectInputStream ois = new ObjectInputStream(source);
ois.readObject(); // Khong filter - vulnerable
✅ ObjectInputFilter với allowlist:
ois.setObjectInputFilter(ObjectInputFilter.Config.createFilter("com.myapp.*;!*"));
9. 📚 Deep Dive Oracle
Spec / reference chính thức:
- Serializable interface — API + serialVersionUID rule.
- Java Object Serialization Specification — format binary chi tiết.
- ObjectInputFilter — filter chống gadget chain.
- JEP 154: Remove Serialization — Oracle proposal remove serialization.
- Frohoff - Marshalling Pickles talk (2015) — talk gốc mở màn deserialization apocalypse.
- OWASP Deserialization Cheat Sheet — best practice security.
Ghi chú: JEP 154 cho biết Oracle muốn bỏ hẳn Java serialization — thay bằng "Records + explicit serialization API" hoặc khuyến khích JSON/Protobuf. Hiện chưa deprecated official API, nhưng JDK team (Brian Goetz, Stuart Marks) cảnh báo mạnh mẽ — code mới không nên tin vào Serializable cho lâu dài. Đọc kỹ OWASP Cheat Sheet nếu dự án đang dùng native serialization — biết rủi ro, cách mitigate.
10. Tóm tắt
Serializablemarker interface, không method — JVM dùng reflection serialize field quaObjectOutputStream.- Luôn khai báo
serialVersionUID— không khai báo = fragile với rebuild. transientbỏ field khỏi serialize — dùng cho secret, resource, cache, computed.- Custom
writeObject/readObject(private method) override mặc định — dùng cho encrypt, validate, rebuild. - Deserialization là lỗ hổng security cấp độ thảm hoạ:
readObjectchạy code của class; gadget chain với class trên classpath dẫn đến RCE. - CVE 2015 "Java deserialization apocalypse" — hàng chục vendor emergency patch.
- Phòng chống: (1) không deserialize untrusted input; (2)
ObjectInputFilterallowlist Java 9+; (3) chuyển sang format khác. - JSON (Jackson, Gson) — human-readable, cross-language, không executable format. Dùng cho REST, config, log.
- Protobuf — binary, nhanh, schema evolution. Dùng cho gRPC, high-throughput RPC.
- Avro — schema với data, Hadoop/Kafka native.
- Record (Java 14+) serialize qua canonical constructor — an toàn hơn class thường (compact constructor validate).
- Oracle roadmap (JEP 154) muốn bỏ hẳn native serialization. Code mới không rely dài hạn.
11. Tự kiểm tra
Q1Vì sao serialVersionUID phải khai báo explicit?▸
serialVersionUID phải khai báo explicit?Không khai báo → JVM compute UID từ signature class (field, method, modifier, interface) qua hash algorithm. Algorithm có trong spec nhưng không hoàn toàn deterministic — UID có thể đổi giữa:
- Các version javac khác nhau.
- JDK khác nhau (Oracle, OpenJDK, Azul).
- Thêm/bớt method dù private.
- Reorder declaration.
Hậu quả: file .ser hoặc RMI message ghi bằng version A không deserialize được bằng version B — throw InvalidClassException. Bug silent, phát hiện khi deploy production.
Khai báo explicit (= 1L, = 2L...) → dev chủ động quyết định version, có migration path khi intentionally break compatibility.
IDE (IntelliJ, Eclipse) có warning cho thiếu serialVersionUID — nên bật strict mode fix hết.
Q2Vì sao deserialization từ input không tin là lỗ hổng cấp độ thảm hoạ?▸
Cơ chế: ObjectInputStream.readObject chạy readObject private của class deserialize (qua reflection bypass access check). Nếu class trong classpath có logic dangerous trong readObject, deserialize ≈ execute code.
Attacker không control class trên server, nhưng craft **gadget chain** — chain class có sẵn trên classpath:
- Class A's
readObjectgọi method của B. - Method B gọi method của C.
- Method C cuối chain gọi
Runtime.exec.
Apache Commons Collections InvokerTransformer là gadget nổi tiếng nhất — có trong classpath 90% enterprise Java app 2015. Attacker gửi byte crafted → server execute arbitrary command → RCE.
Phòng:
- Không deserialize input untrusted — quan trọng nhất.
ObjectInputFilterallowlist Java 9+ — reject class không biết.- Dùng JSON/Protobuf — format data thuần, không concept execute.
Java native serialization là một trong những flaw thiết kế lịch sử của Java — Oracle roadmap (JEP 154) muốn bỏ hẳn.
Q3Khi nào dùng transient?▸
4 use case chính:
- Secret / sensitive: password, API token, session token. Không persist disk — leak file ngã mất security.
- Resource:
Socket,Connection,InputStream,Thread,FileChannel. Không có ý nghĩa khi deserialize ở máy khác / lần khác. Phải rebuild. - Cache / computed field: field tính từ field khác. Vd
displayPricetừamount+currency. Rebuild trongreadObject. - Large temporary: buffer, intermediate state — tiết kiệm size serialized.
Deserialize → transient field = default (null cho reference, 0 cho numeric, false cho boolean).
Nếu cần value: override readObject:
private transient Connection conn;
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
in.defaultReadObject();
this.conn = connect(); // rebuild after deserialize
}Q4Vì sao hiện nay team production chuyển sang JSON/Protobuf thay Java native serialization?▸
6 lý do chính:
- Security: JSON/Protobuf parse data thuần, không execute code trong deserialize. Attacker không craft được gadget chain RCE.
- Cross-language: Python, Go, Rust, JavaScript đọc JSON/Protobuf được. Java
.serchỉ Java. - Stable: không phụ thuộc class binary compat — schema evolve được (JSON thêm field OK, Protobuf có field number).
- Human-readable (JSON): debug, inspect không cần tool special.
- Performance (Protobuf): nhỏ hơn 5×, nhanh hơn 100× so với Java native serialization.
- Ecosystem: schema registry, validation tool, documentation (OpenAPI, Protobuf schema) — mature.
Java native serialization vẫn dùng cho:
- RMI, EJB legacy (đang phasedown).
- Cache distributed (Infinispan, Hazelcast) mặc định — có thể config alternative.
- Session replication Spring cũ.
Code mới microservice dùng JSON + OpenAPI hoặc gRPC + Protobuf. Không dùng Java native cho cross-service communication.
Q5Đoạn sau có vấn đề gì? class Order implements Serializable { int id; String name; Connection conn; }▸
class Order implements Serializable { int id; String name; Connection conn; }3 vấn đề:
- Thiếu
serialVersionUID— fragile với rebuild, bug silent khi deploy. ConnectionkhôngSerializable— throwNotSerializableExceptionkhi writeObject (Connection là resource, implementation không implement Serializable).- Semantic sai — Connection là resource runtime, không có ý nghĩa persist. Ngay cả nếu serialize được, deserialize ở máy khác cũng không dùng được connection đó (DB connection bind vào process cụ thể).
Fix:
class Order implements Serializable {
private static final long serialVersionUID = 1L;
int id;
String name;
transient Connection conn; // Khong serialize
}Tốt hơn về mặt design: tách Connection khỏi model domain. Dependency injection — Order là data, Connection là dependency:
class Order implements Serializable { // Model thuan
private static final long serialVersionUID = 1L;
int id;
String name;
}
class OrderRepository { // Data access, khong serializable
private final Connection conn;
public Order findById(int id) { ... }
}Separation of concerns (bài 5.4 — encapsulation) — model không biết DB layer.
Q6Record (Java 14+) có an toàn hơn class thường khi deserialize không? Vì sao?▸
Có — an toàn hơn ở 2 điểm:
- Canonical constructor chạy khi deserialize: class thường khi deserialize bypass constructor — JVM tạo instance "raw" và set field bằng reflection. Record không bypass — luôn đi qua canonical constructor.
- Compact constructor validate: compact constructor chạy trước khi field được set, validate input. File
.servới data invalid → throw → reject.
record Age(int value) implements Serializable {
private static final long serialVersionUID = 1L;
public Age {
if (value < 0) throw new IllegalArgumentException();
}
}
// Deserialize file voi value = -1 -> IllegalArgumentException
// Class thuong: chi field duoc set, ko validationTấn công: với class thường, attacker craft byte stream bypass validation trong constructor → tạo object ở state invalid → exploit downstream. Record không cho.
Nhưng: record vẫn có thể là gadget trong chain nếu implement readObject / readObjectNoData custom. Mặc định an toàn hơn, custom có thể phá.
Khuyến nghị: nếu phải dùng native serialization, ưu tiên record. Nhưng vẫn nên chuyển sang JSON/Protobuf cho code production lâu dài.
Bài tiếp theo: Stream file — xử lý log với Files.lines
Bài này có giúp bạn hiểu bản chất không?