Java — Từ Zero đến Senior/Serialization — Serializable và những cạm bẫy
~22 phútI/O & NIOMiễn phí

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, serialVersionUIDtransient, 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:

  1. Class implement Serializablemarker interface (không có method), signal "an toàn serialize".
  2. Field không transient — sẽ được serialize. Field transient bỏ qua.
  3. 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, passwordconn = 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 serialVersionUIDstatic 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.

⚠️ Luôn khai báo serialVersionUID explicit

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ó:

  1. Đọc class name từ byte stream.
  2. Load class từ classpath.
  3. Dùng reflection tạo instance (bỏ qua constructor bình thường — dùng ObjectInputStream.readSerialData).
  4. Set field từ byte stream.
  5. Gọi readObject private method của class nếu có.

Bước 5 là lỗ hổng: readObjectcode — 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.

⚠️ Quy tắc vàng

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 .proto schema — 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

📚 Deep Dive Oracle

Spec / reference chính thức:

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

  • Serializable marker interface, không method — JVM dùng reflection serialize field qua ObjectOutputStream.
  • Luôn khai báo serialVersionUID — không khai báo = fragile với rebuild.
  • transient bỏ 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ạ: readObject chạ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) ObjectInputFilter allowlist 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

Tự kiểm tra
Q1
Vì sao 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.

Q2
Vì 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:

  1. Class A's readObject gọi method của B.
  2. Method B gọi method của C.
  3. 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:

  1. Không deserialize input untrusted — quan trọng nhất.
  2. ObjectInputFilter allowlist Java 9+ — reject class không biết.
  3. 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.

Q3
Khi nào dùng transient?

4 use case chính:

  1. Secret / sensitive: password, API token, session token. Không persist disk — leak file ngã mất security.
  2. Resource: Socket, Connection, InputStream, Thread, FileChannel. Không có ý nghĩa khi deserialize ở máy khác / lần khác. Phải rebuild.
  3. Cache / computed field: field tính từ field khác. Vd displayPrice từ amount + currency. Rebuild trong readObject.
  4. 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
}
Q4
Vì sao hiện nay team production chuyển sang JSON/Protobuf thay Java native serialization?

6 lý do chính:

  1. Security: JSON/Protobuf parse data thuần, không execute code trong deserialize. Attacker không craft được gadget chain RCE.
  2. Cross-language: Python, Go, Rust, JavaScript đọc JSON/Protobuf được. Java .ser chỉ Java.
  3. Stable: không phụ thuộc class binary compat — schema evolve được (JSON thêm field OK, Protobuf có field number).
  4. Human-readable (JSON): debug, inspect không cần tool special.
  5. Performance (Protobuf): nhỏ hơn 5×, nhanh hơn 100× so với Java native serialization.
  6. 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; }

3 vấn đề:

  1. Thiếu serialVersionUID — fragile với rebuild, bug silent khi deploy.
  2. Connection không Serializable — throw NotSerializableException khi writeObject (Connection là resource, implementation không implement Serializable).
  3. 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.

Q6
Record (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:

  1. 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.
  2. Compact constructor validate: compact constructor chạy trước khi field được set, validate input. File .ser vớ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 validation

Tấ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?