Hãy phân biệt hai thứ tưởng giống nhau: một tấm ảnh chụp căn phòng, và một bản vẽ hướng dẫn thợ dựng lại căn phòng đó. Tấm ảnh là dữ liệu chết — bạn nhìn nó, không có gì xảy ra. Bản vẽ thi công thì khác: đưa cho một đội thợ, họ sẽ làm theo — cưa, đóng, khoan. Nếu ai đó lén sửa bản vẽ thành "khoan một lỗ vào tường nhà hàng xóm", đội thợ vẫn ngoan ngoãn làm, vì việc của họ là thực hiện chỉ dẫn chứ không phải nghi ngờ nó. Đây chính xác là khác biệt giữa JSON và pickle, và nó là lý do một dòng pickle.loads() trên dữ liệu người dùng có thể thành thực thi mã từ xa.

Tuần tự hoá là biến một object trong bộ nhớ thành chuỗi byte để lưu hay gửi đi; giải tuần tự hoá là dựng lại object từ chuỗi đó. Nghe như thao tác dữ liệu vô hại. Nhưng với vài định dạng — Python pickle, Java ObjectInputStream, PHP unserialize — việc "dựng lại object" có thể chạy mã. Và nếu chuỗi byte đến từ kẻ tấn công, đó là RCE. Bài này tôi chứng minh nó thật, đo bằng con số, rồi cho thấy vì sao JSON miễn nhiễm.

Toàn bộ chạy trong container tự dựng, tự dọn. Tôi chỉ tấn công tiến trình do chính mình tạo, không nhắm vào hệ thống của ai. (Kết quả là hành vi đo được trong container, không phải perf counter phần cứng.)

Cách dựng phép đo

Python cho phép một object định nghĩa phương thức __reduce__ — trả về cách dựng lại chính nó khi được unpickle. Đây là chỗ kẻ tấn công lợi dụng: cho __reduce__ trả về "gọi os.system với lệnh này". Khi ai đó pickle.loads dữ liệu đó, Python ngoan ngoãn gọi os.system — chạy đúng lệnh của kẻ tấn công. Tôi dựng đúng payload ấy và đo.

Ba lần chạy, kết quả giống hệt:

Thao tác Kết quả
pickle.loads(payload độc) tạo /tmp/bangchung.txt chứa RCE-QUA-PICKLE
Payload chạy lệnh id uid=0(root) gid=0(root)
json.loads cùng dữ liệu trả về một dict — không chạy gì

Dòng đầu là RCE thật, và chỗ đáng sợ là: chỉ tải dữ liệu đã chạy lệnh — không có lời gọi hàm nào khác, không có bước "thực thi" riêng đâu cả. pickle.loads() vừa giải mã vừa thực thi trong cùng một thao tác. Dòng hai cho thấy cái giá: lệnh chạy với quyền của tiến trình — ở đây là root.

Gốc rễ nằm ở chỗ pickle lưu cách dựng lại, không chỉ lưu giá trị

Đây là điểm phân biệt định dạng nguy hiểm với định dạng an toàn, nên tôi muốn nói cho kỹ. Khi pickle một object, nó ghi lại "gọi hàm nào, với tham số gì, để tái tạo object này". Thông tin đó cần thiết để dựng lại các object phức tạp — nhưng nó cũng có nghĩa là chuỗi byte chứa lời chỉ dẫn gọi hàm. Kẻ tấn công chỉ việc làm cho hàm được gọi là os.system thay vì một constructor vô hại. JSON thì ngược lại hoàn toàn: nó chỉ lưu giá trị — số, chuỗi, danh sách, từ điển. Không có chỗ nào trong JSON để nói "gọi hàm này", nên chẳng có gì để khai thác.

Và đây mới là cái bẫy tâm lý: "chỉ tải dữ liệu" nghe an toàn. Lập trình viên hình dung deserialize như parse — một thao tác đọc thụ động. Với JSON đúng là vậy. Với pickle, "đọc" và "thực thi" là cùng một hành động. Rất nhiều lỗ RCE nghiêm trọng sinh ra từ việc một lập trình viên chọn pickle (hoặc Java serialization) để nhận dữ liệu qua mạng chỉ vì nó "tiện" — pickle được bất kỳ object Python nào, khỏi phải viết mã chuyển đổi — mà không nhận ra cái tiện đó đi kèm một cửa thực thi mã mở toang.

Cần nói rõ: đây không phải chuyện riêng của Python. Cùng cơ chế tồn tại ở Java (ObjectInputStream.readObject — nguồn của vô số CVE, gồm cả các chuỗi gadget nổi tiếng), PHP (unserialize với "POP chain"), Ruby (Marshal.load), .NET (BinaryFormatter, đã bị Microsoft khuyến cáo bỏ). Mọi ngôn ngữ có một định dạng tuần tự hoá "giàu" — lưu được cả hành vi chứ không chỉ dữ liệu — đều mang lỗ này. Chi tiết khai thác có khác (Java cần một "chuỗi gadget" các lớp có sẵn), nhưng nguyên tắc chung không đổi: giải tuần tự hoá dữ liệu không tin cậy bằng định dạng giàu là giao cho kẻ tấn công một phần quyền điều khiển tiến trình.

Payload trông thế nào

Payload độc trong phép đo là kết quả của một class rất nhỏ:

class DocHai:
    def __reduce__(self):
        return (os.system, ("lenh cua ke tan cong",))

__reduce__ là hook Python dùng để pickle biết cách dựng lại object. Ở đây nó nói dối: thay vì trả về cách tái tạo một DocHai, nó trả về "gọi os.system với lệnh này". Khi pickle.loads gặp chỉ dẫn đó, nó gọi đúng như được bảo. Chuỗi byte pickle bắt đầu bằng ... (base64: gASV...) mã hoá chính lời gọi ấy — kẻ tấn công chỉ cần gửi chuỗi này tới bất cứ nơi nào máy chủ sẽ pickle.loads.

Chỗ đáng sợ nhất: object DocHai không cần tồn tại ở phía máy chủ. pickle mang theo tên module và hàm cần gọi (os, system) — những thứ có sẵn ở mọi tiến trình Python. Kẻ tấn công không cần biết gì về mã của bạn, chỉ cần biết bạn pickle.loads dữ liệu của nó.

Nói cho gọn thì khác biệt giữa pickle và JSON đúng là khác biệt giữa dữ liệu và chương trình. JSON là một ngôn ngữ dữ liệu: ngữ pháp của nó chỉ có object, mảng, chuỗi, số, boolean, null — không có động từ, không có lời gọi, đọc nó không thể làm gì ngoài tạo ra các giá trị đó. Còn pickle là một máy ảo nhỏ: định dạng của nó có các lệnh, gồm "gọi đối tượng có thể gọi này với các tham số kia", và đọc pickle chính là chạy các lệnh đó. Nó gần với "thực thi một chương trình" hơn là "phân tích một tài liệu". Đó là lý do lời khuyên đúng không phải "dùng pickle cẩn thận" mà là "đừng dùng pickle cho dữ liệu không tin cậy" — cái nguy hiểm là bản chất định dạng, không phải cách dùng.

Rút ra cho công việc thực tế: dùng JSON (hoặc định dạng chỉ-dữ-liệu khác như Protocol Buffers, MessagePack) cho mọi dữ liệu qua mạng hoặc từ người dùng, vì chúng biểu diễn dữ liệu mà không mang hành vi. Đừng bao giờ pickle.loads / readObject / unserialize dữ liệu bạn không tự sinh và bảo vệ — kể cả từ cache, từ hàng đợi, từ cookie; hễ kẻ tấn công chạm được vào chuỗi byte, đó là RCE. Nếu bắt buộc dùng định dạng giàu (ví dụ giữa các dịch vụ nội bộ tin nhau), hãy ký HMAC chuỗi byte và kiểm chữ ký trước khi giải, để chỉ dữ liệu do bạn sinh mới được giải. Và tận dụng các van an toàn có sẵn: .NET đã bỏ BinaryFormatter, Java có bộ lọc lớp ObjectInputFilter để giới hạn lớp nào được phép giải.

Mẫu số chung

Bài này vốn đã trải qua Python, Java, PHP, Ruby, .NET — nên thay vì nhắc lại, tôi muốn kéo nó xuống một tầng sâu hơn và soi cùng lằn ranh ấy ở nơi dữ liệu nằm yên: cơ sở dữ liệu và hàng đợi.

Trong thế giới Java, ObjectInputStream là ví dụ kinh điển, nhưng đáng nhớ hơn là chỗ nó ẩn mình: một object bị pickle/serialize nhét vào cột bytea, vào một message của JMS/Kafka, vào HTTP session lưu trong Redis. Khi tầng khác đọc lại và gọi readObject, gadget chain kích hoạt — dữ liệu "chỉ nằm trong DB" hoá ra là một quả mìn hẹn giờ. Bộ lọc ObjectInputFilter (allowlist lớp) là câu trả lời đúng khi không bỏ được định dạng giàu.

Trong Go, encoding/gob cũng là định dạng "giàu" hơn JSON, nhưng nó khắt khe hơn pickle rất nhiều: gob chỉ điền vào các kiểu bạn đã khai trước, không có cơ chế "chuỗi byte tự bảo runtime gọi hàm nào". Cộng với việc thư viện chuẩn của Go mặc định đẩy bạn về encoding/json, hệ sinh thái Go ít dính lớp lỗ này hơn — một minh hoạ nữa cho "an toàn theo mặc định".

Còn ở cơ sở dữ liệu, cùng nguyên tắc "dữ liệu không được là chỉ dẫn" hiện ra dưới cái tên quen thuộc: đừng lưu object đã serialize bằng định dạng giàu vào một cột rồi tin nó khi đọc ra; hãy lưu dữ liệu thuần (JSON/JSONB, cột có kiểu rõ) để lúc đọc lại chỉ dựng được giá trị, không dựng được hành vi. Đây đúng là họ hàng gần của SQL injection: cả hai đều là "dữ liệu bị đối xử như mã".

Mẫu số chung: một định dạng an toàn khi giải chỉ có thể sinh ra giá trị; một định dạng nguy hiểm khi giải có thể sinh ra hành vi — và ranh giới RCE nằm đúng ở chỗ đó, bất kể dữ liệu đi qua mạng, nằm trong hàng đợi hay ngủ yên trong một cột. Vì thế câu hỏi đúng không phải "tôi parse dữ liệu này an toàn chưa" mà là "khi giải chuỗi byte này, điều tệ nhất nó có thể khiến xảy ra là gì — tạo một giá trị, hay gọi một hàm" — và chỉ loại định dạng chỉ-dữ-liệu mới cho bạn câu trả lời "chỉ một giá trị".

Muốn tự soát trong ba mươi giây, tìm trong mã của bạn các lời gọi pickle.loads, pickle.load (Python), readObject (Java), unserialize (PHP), Marshal.load (Ruby), BinaryFormatter (.NET). Với mỗi chỗ, truy ngược: dữ liệu đầu vào đến từ đâu? Nếu có bất kỳ đường nào để nó chạm tới người dùng — một trường form, một cookie, một tin nhắn hàng đợi, một tệp tải lên, một cột trong DB — bạn đang có một lỗ thực thi mã từ xa. Chuyển sang JSON, hoặc ký HMAC nếu bắt buộc giữ định dạng giàu.