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.
Thử ba mươi giây
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. Ba mươi giây để 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.
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.