Tuần tự hóa
Tới giờ ta mới truyền văn bản theo dòng qua socket. Nhưng nhiều ứng dụng cần gửi cả một đối tượng có cấu trúc, ví dụ một bản tin chứa tên người gửi, nội dung và thời điểm. Viết tay phần chuyển đối tượng thành chuỗi byte rồi dựng lại ở đầu kia vừa nhàm chán vừa dễ sai. Java cung cấp sẵn cơ chế tuần tự hóa cho việc này, và bài này chỉ cách dùng nó để gửi nguyên một đối tượng qua mạng.
Hãy thấy rõ vấn đề trước khi thấy lời giải. Giả sử bạn tự quy ước gửi bản tin thành một dòng văn bản có ba phần cách nhau bởi dấu chấm phẩy. Ổn, cho tới khi nội dung bản tin chứa dấu chấm phẩy, thế là phải thêm quy tắc thoát ký tự. Rồi bản tin cần thêm một trường, phải sửa cả hai đầu. Rồi một trường lại là đối tượng lồng bên trong, phải nghĩ cách bẹt nó ra. Mỗi bước là một chỗ để hai đầu lệch nhau âm thầm. Tuần tự hóa của Java lo trọn gói phần cơ khí đó. Nhưng tiện thì có giá của nó, và nửa sau bài này nói vì sao ngày nay người ta hạn chế dùng nó cho dữ liệu đến từ bên ngoài.
Khái niệm tuần tự hóa
Tuần tự hóa (serialization) là quá trình biến trạng thái của một đối tượng thành một chuỗi byte để lưu trữ hay truyền đi. Quá trình ngược lại, dựng lại đối tượng từ chuỗi byte, gọi là giải tuần tự hóa (deserialization).
Để một lớp được phép tuần tự hóa, nó chỉ cần cài giao tiếp đánh dấu Serializable. Đây là một giao tiếp đánh dấu (marker interface): không có phương thức nào, chỉ nói với Java rằng đối tượng loại này được phép biến thành byte. Khi đó ObjectOutputStream sẽ ghi nguyên cả đối tượng, kể cả các đối tượng mà nó tham chiếu tới, còn ObjectInputStream đọc và dựng lại y nguyên ở đầu kia.
Lớp dữ liệu cài Serializable và từ khóa transient
Ta định nghĩa một lớp bản tin đơn giản. Một số trường không nên hoặc không cần truyền đi, ví dụ một mật khẩu tạm hay một giá trị tính lại được; đánh dấu chúng transient để tuần tự hóa bỏ qua, khi dựng lại chúng nhận giá trị mặc định.
import java.io.Serializable;
import java.time.LocalDateTime;
public class Message implements Serializable {
// version id guards against incompatible class changes during deserialization
private static final long serialVersionUID = 1L;
private final String sender;
private final String content;
private final LocalDateTime sentAt;
private transient String tempToken; // not serialized, restored as null
public Message(String sender, String content) {
this.sender = sender;
this.content = content;
this.sentAt = LocalDateTime.now();
}
@Override
public String toString() {
return "[" + sentAt + "] " + sender + ": " + content;
}
}
Trường serialVersionUID là một số phiên bản gắn với lớp. Khi giải tuần tự hóa, Java so số này giữa lớp đã ghi và lớp hiện có; nếu lệch, nó báo lỗi để tránh dựng nhầm một đối tượng từ phiên bản lớp không tương thích. Khai báo tường minh hằng này là thói quen tốt vì nếu để Java tự sinh, mỗi thay đổi nhỏ trong lớp có thể đổi số và làm hỏng tương thích.
Nói cụ thể hơn: nếu bạn không khai serialVersionUID, máy ảo tự tính một số từ cấu trúc lớp, gồm tên lớp, các trường, các phương thức. Đổi tên một trường private hay thêm một phương thức cũng đủ để số đó đổi theo, và tệp ghi bằng phiên bản cũ của lớp sẽ không đọc lại được nữa: Java ném InvalidClassException vì hai số không khớp. Khai báo tay một hằng cố định là cách nói rằng bạn tự chịu trách nhiệm về tương thích, và chỉ tăng số đó khi cấu trúc đổi tới mức không còn đọc lại được dữ liệu cũ.
Một quy tắc thực dụng để chọn khi nào tăng số này: nếu thay đổi của bạn chỉ thêm trường mới, dữ liệu cũ vẫn đọc được và trường mới nhận giá trị mặc định, nên giữ nguyên serialVersionUID. Nhưng nếu bạn đổi kiểu một trường, đổi ý nghĩa của nó, hay bỏ một trường mà đối tượng dựng lại sẽ sai nghĩa, hãy tăng số lên để Java từ chối đọc dữ liệu cũ ngay, thay vì âm thầm dựng một đối tượng méo mó. Từ chối đọc to tiếng bao giờ cũng dễ chữa hơn một đối tượng sai lặng lẽ trôi qua hệ thống.
Hai quy tắc nhỏ hay bị quên. Trường static không được tuần tự hóa vì nó thuộc về lớp chứ không thuộc về đối tượng. Và quan trọng hơn nhiều: khi readObject dựng lại đối tượng, nó không gọi hàm dựng của lớp bạn, mà cấp phát đối tượng rồi đổ thẳng giá trị từ luồng byte vào các trường.
Nếu hàm dựng của bạn chặn điểm âm, đừng tưởng đối tượng dựng lại từ byte cũng có điểm hợp lệ: kẻ tấn công sửa vài byte trong luồng là có ngay một SinhVien điểm âm mà không hàm dựng nào ngăn được. Trường vừa final vừa transient cũng là một cái bẫy, vì sau khi dựng lại nó mang giá trị mặc định và bạn không gán lại được bằng cách thông thường. Muốn kiểm tra sau khi dựng, hãy cài thêm private void readObject(ObjectInputStream in), gọi in.defaultReadObject() rồi xác thực trong đó.
Mọi trường không phải transient cũng phải tuần tự hóa được, nghĩa là kiểu của chúng cũng cài Serializable. Nếu một trường trỏ tới đối tượng không tuần tự hóa được, Java ném NotSerializableException. Ngoài ra đừng tin tưởng tuần tự hóa dữ liệu từ nguồn lạ, vì giải tuần tự hóa dữ liệu độc hại là một lỗ hổng an ninh đã biết.
Gửi và nhận đối tượng qua socket
Để truyền đối tượng, ta bọc luồng của socket bằng ObjectOutputStream ở phía gửi và ObjectInputStream ở phía nhận. Phía gửi gọi writeObject, phía nhận gọi readObject rồi ép kiểu về lớp mong đợi.
import java.io.*;
import java.net.*;
public class ObjectClient {
public static void main(String[] args) throws IOException {
try (Socket socket = new Socket("localhost", 5000);
ObjectOutputStream out =
new ObjectOutputStream(socket.getOutputStream())) {
Message msg = new Message("Hao", "Xin chao tu may khach");
out.writeObject(msg); // serialize and send the whole object
out.flush();
System.out.println("Sent: " + msg);
}
}
}
import java.io.*;
import java.net.*;
public class ObjectServer {
public static void main(String[] args)
throws IOException, ClassNotFoundException {
try (ServerSocket server = new ServerSocket(5000)) {
System.out.println("Object server on port 5000");
try (Socket client = server.accept();
ObjectInputStream in =
new ObjectInputStream(client.getInputStream())) {
// readObject returns Object, cast it to the expected type
Message msg = (Message) in.readObject();
System.out.println("Received object: " + msg);
}
}
}
}
Phía nhận khai báo ném thêm ClassNotFoundException vì khi dựng lại đối tượng, máy ảo cần tìm thấy định nghĩa lớp Message trong đường nạp lớp (classpath) của mình. Nói cách khác, cả máy gửi và máy nhận đều phải có cùng lớp Message. Đây là điểm cần nhớ khi triển khai: một thay đổi cấu trúc lớp ở một phía mà phía kia không cập nhật sẽ làm hỏng giao tiếp.
Tuần tự hóa Java tiện cho trao đổi giữa các chương trình Java cùng chia sẻ lớp. Khi cần liên thông với hệ thống viết bằng ngôn ngữ khác, người ta thường chọn định dạng trung lập như JSON hay Protocol Buffers thay cho tuần tự hóa Java.
Rủi ro bảo mật khi giải tuần tự hóa dữ liệu không tin cậy
Đây là phần quan trọng nhất của bài, và cũng là phần hay bị bỏ qua nhất. Hãy nhìn lại ObjectServer ở trên: nó mở một cổng, nhận byte từ bất kỳ ai gửi tới, rồi gọi readObject. Câu hỏi là readObject làm gì với chuỗi byte đó.
Nó không chỉ đọc dữ liệu. Nó dựng lại đối tượng, và trong khi dựng thì nó chạy mã: các phương thức readObject riêng của từng lớp trên đường đi, các phương thức readResolve. Vấn đề nằm ở chỗ chính luồng byte nói cho máy ảo biết nó chứa đối tượng thuộc lớp nào, còn máy chủ thì ngoan ngoãn dựng lại đúng lớp đó nếu tìm thấy trong classpath. Kẻ tấn công không cần lớp Message của bạn: họ soạn một luồng byte mô tả chuỗi đối tượng thuộc các lớp có sẵn trong thư viện bạn đang dùng, sắp xếp sao cho chuỗi lời gọi phát sinh lúc dựng lại dẫn tới một lệnh hệ điều hành. Chuỗi đó gọi là gadget chain, và nó đã gây ra hàng loạt lỗ hổng thực thi mã từ xa, kinh điển nhất là nhóm dựa trên Apache Commons Collections.
Điểm cần khắc cốt: xác thực sau khi gọi readObject là quá muộn, vì mã độc đã chạy trong lúc dựng lại rồi. Từ Java 9, JEP 290 đưa vào bộ lọc ObjectInputFilter cho phép khai báo trước danh sách lớp được phép dựng lại, và đó là phòng thủ tối thiểu nếu bạn buộc phải dùng cơ chế này.
Gốc rễ của lỗ hổng không phải một cài đặt cẩu thả mà nằm ngay trong thiết kế: trong luồng byte của Java, chính dữ liệu mang theo tên lớp cần dựng, tức là dữ liệu tự quyết định mã nào được chạy khi dựng lại. Bên nhận không hề khai trước rằng mình chỉ mong đợi một Message; nó dựng bất cứ lớp nào luồng byte chỉ định, miễn có trong classpath. Bộ lọc ObjectInputFilter chỉ dựng lại được hàng rào mà lẽ ra kiểu tĩnh phải cho từ đầu, nên nó là phòng thủ theo lớp chứ không phải liều thuốc chữa: một thư viện mới thêm vào classpath ngày mai có thể mang theo một chuỗi gadget mà danh sách cho phép của bạn tình cờ vẫn chừa cửa. Vì lẽ đó danh sách cho phép luôn hơn danh sách chặn, bạn không thể liệt kê hết những lớp nguy hiểm mình chưa từng nghe tên.
import java.io.*;
import java.net.Socket;
public class FilteredReader {
// ObjectInputFilter is available since Java 9 (JEP 290)
static Message docAnToan(Socket socket)
throws IOException, ClassNotFoundException {
try (ObjectInputStream in =
new ObjectInputStream(socket.getInputStream())) {
// allow-list: only these classes may be rebuilt, reject everything else
in.setObjectInputFilter(ObjectInputFilter.Config.createFilter(
"vn.dau.cntt.Message;java.time.*;java.lang.*;!*"));
return (Message) in.readObject();
}
}
}
Quy tắc đơn giản và không có ngoại lệ đáng nhớ: chỉ dùng ObjectInputStream trên dữ liệu do chính hệ thống bạn kiểm soát sinh ra. Với dữ liệu đến từ Internet, từ máy khách bên ngoài, từ tệp người dùng tải lên, hãy dùng định dạng dữ liệu thuần như JSON. Nếu không thể đổi, tối thiểu phải bật ObjectInputFilter với danh sách lớp cho phép, và không bao giờ dùng danh sách chặn, vì bạn không thể liệt kê hết những gì mình chưa biết.
Thay thế bằng JSON
Lý do chuyển sang JSON không chỉ là an toàn. JSON là văn bản thuần, đọc được bằng mắt khi gỡ lỗi, và mọi ngôn ngữ đều phân tích được, nên máy chủ Java nói chuyện thoải mái với máy khách viết bằng JavaScript hay Python. Quan trọng hơn, thư viện JSON không tự ý dựng lại lớp mà luồng dữ liệu chỉ định: chính bạn nói cho nó biết cần dựng ra kiểu gì. Dữ liệu chỉ là dữ liệu, không mang theo quyền quyết định lớp nào được nạp.
Ví dụ dưới dùng thư viện Jackson, cần thêm phụ thuộc jackson-databind, và jackson-datatype-jsr310 nếu lớp có trường kiểu LocalDateTime. Khác với Serializable, Jackson không đọc thẳng vào trường private, nên lớp Message phải bổ sung các phương thức đọc trường công khai (getSender, getContent, getSentAt) và một hàm dựng đánh dấu @JsonCreator để dựng lại được.
import com.fasterxml.jackson.databind.ObjectMapper;
public class JsonDemo {
public static void main(String[] args) throws Exception {
ObjectMapper mapper = new ObjectMapper();
Message msg = new Message("Hao", "Xin chao");
String json = mapper.writeValueAsString(msg); // object to text
System.out.println(json);
// the caller decides the target type, the data does not
Message back = mapper.readValue(json, Message.class);
System.out.println(back);
}
}
Đổi lại, JSON đòi lớp phải có cách để thư viện đọc và ghi trường, thường là các phương thức đọc trường công khai cùng một hàm dựng phù hợp, và nó không tự động theo đồ thị tham chiếu vòng như tuần tự hóa Java. Đó là cái giá hợp lý cho việc dữ liệu không còn quyền chọn lớp.
Nói cho gọn, khác biệt cốt lõi giữa hai cách không nằm ở tốc độ hay độ dài chuỗi mà ở chỗ ai giữ quyền chọn lớp. Với Serializable, quyền đó nằm trong luồng byte, nên người soạn được luồng byte cũng nắm quyền. Với JSON, quyền đó nằm trong lời gọi readValue(json, Message.class) do bạn viết, còn dữ liệu chỉ điền vào một cái khuôn bạn đã chỉ định sẵn. Đó là toàn bộ lý do vì sao một định dạng tưởng như kém thông minh hơn lại an toàn hơn hẳn cho dữ liệu từ bên ngoài.
Bài tập thực hành
Bài 1. (Lập trình) Thêm vào lớp Message một trường priority kiểu int và một trường transient lưu một đối tượng tạm. Viết chương trình ghi đối tượng ra tệp bằng ObjectOutputStream rồi đọc lại bằng ObjectInputStream, in ra để xác nhận trường transient trở thành giá trị mặc định sau khi dựng lại.
Gợi ý
Dùng new ObjectOutputStream(new FileOutputStream("msg.bin")) để ghi và new ObjectInputStream(new FileInputStream("msg.bin")) để đọc. Sau khi đọc lại, in giá trị trường transient ra; với kiểu tham chiếu nó sẽ là null, với kiểu nguyên thủy int nó sẽ là 0. Trường priority không transient thì giữ nguyên giá trị đã ghi.
Bài 2. (Lập trình) Chuyển ví dụ máy khách và máy chủ đối tượng sang gửi một List<Message> thay vì một đối tượng đơn. Kiểm chứng rằng cả danh sách được tuần tự hóa và dựng lại đúng ở phía nhận, rồi rút ra nhận xét về việc tuần tự hóa các đối tượng lồng nhau.
Gợi ý
ArrayList cài Serializable, nên chỉ cần out.writeObject(list) ở phía gửi và ép kiểu (List<Message>) in.readObject() ở phía nhận. Mọi phần tử Message trong danh sách cũng tuần tự hóa được nên cả cấu trúc lồng nhau được ghi và dựng lại theo. Nhận xét: tuần tự hóa đi sâu theo đồ thị tham chiếu, miễn mọi đối tượng trên đường đi đều cài Serializable.
Bài 3. Giải thích vì sao phía nhận phải khai báo ném ClassNotFoundException, trong khi phía gửi thì không. Điều này nói gì về việc triển khai cùng một lớp ở hai đầu?
Gợi ý
Khi readObject dựng lại đối tượng, máy ảo cần tìm định nghĩa lớp Message trong classpath của phía nhận; nếu không có lớp đó, nó ném ClassNotFoundException. Phía gửi chỉ ghi byte từ một đối tượng đã có nên không gặp tình huống này. Hệ quả: cả máy gửi và máy nhận đều phải có cùng lớp Message, và mọi thay đổi cấu trúc lớp phải đồng bộ ở cả hai đầu.
Bài 4. (Bảo mật) Lớp Message có hàm dựng chặn nội dung rỗng. Một máy khách lạ gửi tới máy chủ một luồng byte trong đó trường content là chuỗi rỗng. Máy chủ dựng lại đối tượng thành công hay ném ngoại lệ? Giải thích, rồi đề xuất hai cách vá.
Lời giải
Máy chủ dựng lại thành công một Message có nội dung rỗng, vì readObject không gọi hàm dựng của lớp: máy ảo cấp phát đối tượng rồi đổ thẳng giá trị từ luồng byte vào các trường. Mọi kiểm tra viết trong hàm dựng đều bị đi vòng qua. Đây chính là lý do tuần tự hóa Java không bảo vệ được bất biến của lớp trước dữ liệu do người khác soạn.
Cách vá thứ nhất, còn dùng Serializable: cài thêm private void readObject(ObjectInputStream in), gọi in.defaultReadObject() rồi tự kiểm tra và ném InvalidObjectException nếu dữ liệu sai; đồng thời bật ObjectInputFilter với danh sách lớp cho phép. Cách vá thứ hai, triệt để hơn: bỏ tuần tự hóa Java cho dữ liệu từ bên ngoài, nhận JSON rồi tự dựng đối tượng qua hàm dựng, khi đó mọi kiểm tra lại có hiệu lực.
Tự kiểm tra
Trường transient ảnh hưởng thế nào tới đối tượng sau khi giải tuần tự hóa?
Trường đánh dấu transient bị bỏ qua khi tuần tự hóa, nghĩa là giá trị của nó không được ghi vào chuỗi byte. Khi dựng lại đối tượng ở phía nhận, trường này không được khôi phục giá trị cũ mà nhận giá trị mặc định của kiểu: null với kiểu tham chiếu, 0 với kiểu số nguyên, false với kiểu boolean. Vì thế chỉ nên đánh dấu transient những trường tính lại được hoặc không nên truyền đi, như mật khẩu tạm hay bộ đệm trung gian.
Câu hỏi tự kiểm
- 1Một trường đánh dấu transient sẽ mang giá trị gì sau khi đối tượng được dựng lại?
- 2Khi readObject dựng lại một đối tượng, nó có gọi hàm dựng của lớp không?
- 3Vì sao không nên gọi readObject trên dữ liệu đến từ nguồn không tin cậy?
- 4Trường serialVersionUID dùng để làm gì?
Tóm tắt
Bốn ý cần nhớ. Cài Serializable là bước đầu tiên để một lớp biến thành byte, với điều kiện mọi trường không phải transient cũng tuần tự hóa được; cặp ObjectOutputStream cùng ObjectInputStream lo phần còn lại, kể cả các đối tượng lồng nhau. Hai từ khóa cần dùng đúng: transient loại một trường khỏi luồng byte, serialVersionUID khóa chặt tương thích giữa các phiên bản lớp. Điều dễ quên nhất là readObject không gọi hàm dựng, nên mọi kiểm tra dữ liệu bạn viết trong hàm dựng đều bị đi vòng qua. Và điều quan trọng nhất: giải tuần tự hóa dữ liệu từ nguồn lạ là một lỗ hổng thực thi mã đã có tiền lệ, nên hãy dùng cơ chế này trong nội bộ hệ thống Java tin cậy, còn với dữ liệu từ bên ngoài thì chọn JSON, nơi bạn quyết định kiểu cần dựng chứ không phải dữ liệu quyết định thay bạn.
Tuần tự hóa với Serializable cùng cặp ObjectOutputStream và ObjectInputStream cho phép gửi nguyên một đối tượng qua mạng, với transient để loại trường không muốn truyền. Trường serialVersionUID bảo vệ tương thích giữa các phiên bản lớp, và cả hai đầu phải chia sẻ cùng định nghĩa lớp thì giao tiếp mới đúng.