Có hai cách nhận một kiện hàng lạ. JSON giống một tấm bưu thiếp: bạn cầm lên, đọc chữ trên đó, rồi tự quyết làm gì — nội dung là nội dung, nó không tự làm gì cả. Java serialization giống một cái hộp mà ngay khi bạn mở nắp, nó tự làm theo mấy dòng chỉ dẫn in bên trong — trước cả khi bạn kịp nhìn thấy món đồ thật. Nếu dòng chỉ dẫn đó là "chạy lệnh này", bạn đã thua trước khi kịp kiểm tra. Toàn bộ vấn đề an ninh của serialization gói trong hình ảnh cái hộp tự-thi-hành đó.

Serialization của Java được chính người thiết kế gọi là sai lầm khủng khiếp. Mark Reinhold, kiến trúc sư trưởng của Java, ước tính nó chiếm một phần ba tới một nửa số lỗ hổng bảo mật của nền tảng.

Bài này giải thích vì sao, bằng bốn thứ chạy được.

Vòng cơ bản

class Nguoi implements Serializable {
    private static final long serialVersionUID = 1L;
    String ten; int tuoi;
    transient String matKhau;
}
  trước : Nguoi[Minh, 30, mk=bimat123]
  sau   : Nguoi[Minh, 30, mk=null]

Cài Serializable là đủ — không phương thức nào phải viết. transient đánh dấu trường không lưu, và sau khi đọc lại nó mang giá trị mặc định.

Sự tiện lợi này chính là vấn đề: nó khiến người ta dùng mà không nghĩ.

Vấn đề một: readObject chạy mã

private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
    in.defaultReadObject();
    System.out.println(">>> readObject() ĐANG CHẠY");
}
  gọi readObject(...):
    >>> readObject() ĐANG CHẠY — trước khi bạn kịp kiểm tra gì
  chỉ SAU đó bạn mới cầm được đối tượng: CoMaChay

Đây là điểm cốt lõi. Khi bạn gọi in.readObject(), JVM:

Đọc tên lớp từ luồng byte.

Nạp lớp đó.

Gọi readObject của nó — mã của lớp, chạy ngay.

Chỉ sau đó mới trả về đối tượng cho bạn.

Nghĩa là bạn không có cơ hội kiểm tra dữ liệu trước khi mã chạy. Mọi lớp có Serializable trong classpath — kể cả lớp của thư viện bên thứ ba mà bạn không biết là có — đều là mục tiêu tiềm năng.

Đây là cơ chế đằng sau hàng loạt lỗ hổng nghiêm trọng những năm qua: kẻ tấn công không cần lớp của bạn, họ chỉ cần một chuỗi lớp có sẵn trong các thư viện phổ biến mà khi ghép lại thì thực thi được lệnh.

So sánh với JSON: bộ phân tích JSON chỉ dựng dữ liệu, rồi bạn ánh xạ sang đối tượng. Không có bước nào chạy mã của lớp trong lúc đọc.

Vấn đề hai: lộ cấu trúc bên trong

  ....sr..Ser$Nguoi...........I..tuoiL..tent..Ljava/lang/String;xp....t..Minh

Luồng byte chứa nguyên tên lớp, tên trường, kiểu trường. Ai bắt được dữ liệu đó là có sơ đồ nội bộ của ứng dụng.

Nó cũng có nghĩa: cấu trúc lớp trở thành một phần của giao thức. Đổi tên một trường private là phá vỡ tương thích với dữ liệu cũ — thứ mà đóng gói lẽ ra phải bảo vệ bạn khỏi.

Đó là lý do có serialVersionUID. Không khai thì trình biên dịch tự tính từ cấu trúc lớp, và mọi thay đổi nhỏ — thêm một phương thức, đổi mức truy cập — cũng làm nó đổi theo, khiến dữ liệu cũ không đọc được nữa với InvalidClassException.

Vấn đề ba: cồng kềnh

  Java serialization : 75 byte
  JSON tương đương   : 24 byte
  gấp 3,1 lần

Một đối tượng hai trường. Phần dôi ra là siêu dữ liệu về lớp.

Với dữ liệu lớn, tỷ lệ này giảm nhưng vẫn thua JSON, và thua xa các định dạng nhị phân chuyên dụng như Protobuf hay Avro.

Và nó chỉ Java đọc được. Một dịch vụ viết bằng Go hay Python không có cách nào tham gia.

Nếu buộc phải dùng: ObjectInputFilter

Từ Java 9, bạn khai được danh sách lớp cho phép:

in.setObjectInputFilter(ObjectInputFilter.Config.createFilter("Ser$Nguoi;!*"));
  lớp không nằm trong danh sách cho phép -> InvalidClassException

Cú pháp: liệt kê lớp được phép, rồi !* để từ chối mọi thứ còn lại. Dấu chấm than là "cấm", dấu sao là "tất cả".

Vài mẫu hữu ích:

"com.vidu.dto.*;!*"          // chỉ cho phép một gói
"maxdepth=10;maxarray=1000;maxbytes=1000000;!*"   // giới hạn tài nguyên

Ba giới hạn cuối chống một kiểu tấn công khác: dữ liệu lồng nhau rất sâu hoặc mảng khai kích thước khổng lồ, khiến JVM hết bộ nhớ trước khi kịp làm gì.

Đặt filter toàn cục bằng thuộc tính hệ thống jdk.serialFilter, hoặc từ Java 17 dùng ObjectInputFilter.Config.setSerialFilterFactory cho từng ngữ cảnh.

Nhưng đây là vá, không phải chữa. Danh sách cho phép phải đúng và phải được bảo trì; thêm một thư viện mới là phải xem lại.

Ba lựa chọn thay thế

JSON với Jackson hoặc Gson — mặc định cho phần lớn trường hợp. Người đọc được, mọi ngôn ngữ đọc được, và quan trọng nhất: không chạy mã của lớp trong lúc phân tích.

Vẫn cần cẩn thận một chỗ: đừng bật tính năng "đa hình theo tên lớp" (enableDefaultTyping của Jackson) với dữ liệu không tin cậy — nó tái tạo đúng vấn đề của Java serialization.

Protobuf hoặc Avro — khi cần gọn và nhanh. Có lược đồ tường minh, sinh mã cho nhiều ngôn ngữ, và tương thích tiến lùi được thiết kế sẵn thay vì phó mặc cho serialVersionUID.

Tự viết cho định dạng đơn giản — với một record vài trường, một dòng String.join và một dòng split đôi khi là đủ, và bạn kiểm soát hoàn toàn.

Khi nào Java serialization còn hợp lý

Không phải bỏ hoàn toàn. Nó vẫn ổn khi cả hai đầu đều do bạn kiểm soát và dữ liệu không đi qua mạng không tin cậy:

Bộ nhớ đệm trên đĩa của chính ứng dụng.

Truyền đối tượng giữa các tiến trình trong cùng một cụm mà bạn quản lý, có mã hoá và xác thực.

Externalizable cho định dạng tự kiểm soát hoàn toàn — nhưng lúc đó bạn đã tự viết mã đọc ghi rồi, và JSON cũng chẳng tốn hơn.

Ngay cả trong những trường hợp đó, tôi vẫn khuyên cân nhắc JSON trước. Chi phí thêm vài chục byte gần như không bao giờ đáng đánh đổi lấy một bề mặt tấn công.

Tương lai

JEP 154 đề xuất loại bỏ hẳn serialization khỏi Java, và công việc thay thế đang diễn ra dưới tên Project Amber với ý tưởng "serialization dựa trên record" — chỉ đọc ghi dữ liệu, không gọi mã nào.

Chưa có trong Java 21. Nhưng hướng đi đã rõ: đừng xây thứ mới dựa trên Serializable.

Nếu muốn tự thấy cái hộp tự-thi-hành ở đầu bài, thử ngay: thêm một readObject in ra một dòng vào một lớp Serializable bất kỳ, rồi giải tuần tự nó. Dòng đó in ra trước khi readObject() trả về cho bạn — đủ để thấy vì sao mọi lời khuyên "hãy kiểm tra dữ liệu trước khi dùng" đều vô nghĩa ở đây: mã đã chạy rồi.

Mẫu số chung

Điều khiến Java serialization nguy hiểm không phải lỗi của riêng Java — nó là hệ quả tất yếu của mọi định dạng "tái dựng đối tượng gốc của ngôn ngữ bằng cách chạy móc nối khi đọc". Và gần như ngôn ngữ động nào cũng có một cái y hệt, với đúng lời cảnh báo đó.

  • Python có pickle — bản song sinh hoàn hảo. Phương thức __reduce__ cho phép một object pickle chạy mã tuỳ ý lúc pickle.load, và câu "đừng bao giờ unpickle dữ liệu không tin cậy" là kinh nhật tụng của cộng đồng Python, y như với Java readObject.
  • Ruby có Marshal.load, PHP có unserialize (với "POP chain" đúng vai "gadget chain" của Java) — cùng một lỗ hổng, cùng một bài khai thác.
  • .NET minh hoạ cái kết: BinaryFormatter nguy hiểm tới mức Microsoft gỡ bỏ hẳn khỏi .NET hiện đại — đi trước đúng con đường JEP 154 đang vạch cho Java.
  • Ngược lại, JSON, Protobuf, Avro an toàn khỏi lớp lỗi này ở mọi ngôn ngữ, vì chúng chỉ mang dữ liệu — không có chỗ nào nhét "hãy chạy hàm này" vào luồng byte.

Sợi chỉ chung đáng mang theo, đúng cho mọi ngôn ngữ: ranh giới an toàn nằm giữa "định dạng mô tả dữ liệu" và "định dạng tái dựng đối tượng". Cái đầu (JSON, protobuf) đọc xong bạn mới quyết làm gì với dữ liệu; cái sau (Java serialization, pickle, Marshal, BinaryFormatter) chạy thứ gì đó trong lúc đọc, nên dữ liệu không tin cậy biến thành thực thi mã từ xa. Khi nhận dữ liệu từ bên ngoài, câu hỏi sống còn luôn giống nhau — định dạng này chỉ mang dữ liệu, hay nó tái dựng đối tượng bằng cách chạy mã? Trả lời được câu đó thì bạn biết ngay mình đang đọc một tấm bưu thiếp hay một cái hộp có bẫy.

Ngày mai: JSON với Jackson — ánh xạ đối tượng, xử lý trường thiếu, và cái bẫy thoát ký tự khi nhúng JSON vào HTML.