Cybersecurity-Projects/PROJECTS/beginner/deserialization-gadget-lab/learn/01-CONCEPTS.md

42 KiB
Raw Blame History

marshalsea: Concepts

This chapter is the theory the lab is built on. Every claim in it is either traced to a primary source with a URL, or verified by running Ruby in a pinned container, and it says which. Where a widely repeated claim turned out to be wrong, the correction is here rather than a quiet omission.

Start with the one everybody gets wrong

The most-cited example of insecure deserialization was not insecure deserialization.

Search for "insecure deserialization breach" and Equifax comes back at the top. The 2017 breach, 147 million people, the largest data-breach settlement on record. It is in slide decks, in course material, in interview answers, and in the introductory paragraph of an enormous number of write-ups about this exact bug class.

It was OGNL expression injection, and NVD classifies it CWE-755, Improper Handling of Exceptional Conditions. Not CWE-502.

Here is the NVD record for CVE-2017-5638, verbatim:

"The Jakarta Multipart parser in Apache Struts 2 2.3.x before 2.3.32 and 2.5.x before 2.5.10.1 has incorrect exception handling and error-message generation during file-upload attempts, which allows remote attackers to execute arbitrary commands via a crafted Content-Type, Content-Disposition, or Content-Length HTTP header, as exploited in the wild in March 2017 with a Content-Type header containing a #cmd= string."

The mechanism: a malformed Content-Type header raises an exception, the exception message is built by a routine that evaluates OGNL, and the attacker's OGNL expression executes. No object graph is reconstructed at any point. There is no serialized payload anywhere in it. It is closer to template injection than to deserialization.

CISA agrees, and it is worth seeing how precisely. In the KEV catalog, CVE-2017-5638 is titled "Apache Struts Remote Code Execution Vulnerability" and mapped to CWE-20, while its sibling CVE-2017-9805 is titled "Apache Struts Deserialization of Untrusted Data Vulnerability" and mapped to CWE-502. Same product, same year, different bug class. Somebody named them differently on purpose.

How to tell them apart

Struts had two famous RCEs in 2017, six months apart, and only the second one is deserialization:

CVE-2017-5638 CVE-2017-9805
NVD published 2017-03-10 2017-09-15
Component Jakarta Multipart parser REST plugin
Mechanism OGNL injection during error-message generation XStream deserialization with no type filtering
NVD CWE CWE-755 CWE-502
CVSS v3.1 9.8 8.1
Used against Equifax Yes No

Why the myth is so durable

The timeline explains it completely, and the Apache Software Foundation documented the correction itself. CVE-2017-9805, the one that is deserialization, was disclosed 2017-09-04. Equifax announced the breach 2017-09-07. Three days apart. Early reporting reasonably guessed the fresh CVE. Equifax corrected the record on 2017-09-13. The ASF published a media alert on 2017-09-14 that exists specifically to fix this:

"Following this announcement, additional claims stated that the breach was caused by CVE-2017-9805, an exploit in Apache Struts that was disclosed on 4 September 2017."

"On 13 September 2017, Equifax issued a statement confirming that 'The vulnerability was Apache Struts CVE-2017-5638'."

The correction lost. The originating Quartz article still carries its own retraction notice and its URL slug still says "nine-year-old security flaw" while the headline has been rewritten.

Four primary sources were opened and text-searched for this chapter, and none of them uses the word:

  1. GAO-18-559 (2018-08-30, 40 pages): "deserialization," "serialization," and "OGNL" appear zero times, as does any CVE number.
  2. US House Committee on Oversight majority staff report (December 2018, 96 pages): names CVE-2017-5638 explicitly and cites NVD directly. "Deserialization" appears zero times.
  3. Equifax's own press release (2017-09-15): "The attack vector used in this incident occurred through a vulnerability in Apache Struts (CVE-2017-5638)."
  4. DOJ indictment press release (2020-02-10): "the defendants exploited a vulnerability in the Apache Struts Web Framework." "Deserialization": zero occurrences.

That is a better story than the myth was: a plausible inference, made under time pressure, that outran its own retraction by nine years. And it teaches something the myth cannot, which is how to look at a CVE and tell expression injection from deserialization.

If you want the correct one-sentence version: the largest data-breach settlement on record came from an unpatched Struts OGNL injection, a different bug class that is frequently mislabelled as deserialization.

So what is deserialization, actually

Serializing an object writes its state to bytes. Deserializing reads those bytes back into a live object. The trap is that the second step is not a copy. To rebuild an object, the runtime has to run code:

   Marshal.dump           bytes on the wire            Marshal.load
   ────────────           ─────────────────            ────────────
   object state    ──>    "\x04\bU:\x15Gem::..."  ──>  allocate the class
                                                       CALL its marshal_load
                                                       CALL #hash on hash keys
                                                       CALL #<=> on Range ends
                                                       return the object

Every one of those CALLs is a method the attacker chose by choosing the bytes. That is the entire vulnerability. Marshal.load is not "parsing"; it is a small, attacker-steerable interpreter over your loaded class graph.

Ruby's own documentation for Marshal says this plainly, and has for years:

"By design, Marshal.load can deserialize almost any class loaded into the Ruby process. In many cases this can lead to remote code execution if the Marshal data is loaded from an untrusted source. As a result, Marshal.load is not suitable as a general purpose serialization format and you should never unmarshal user supplied input or other untrusted data."

Python says the same thing about pickle:

"The pickle module is not secure. Only unpickle data you trust. It is possible to construct malicious pickle data which will execute arbitrary code during unpickling. Never unpickle data that could have come from an untrusted source, or that could have been tampered with."

PHP says it about unserialize():

"Do not pass untrusted user input to unserialize() regardless of the options value of allowed_classes. Unserialization can result in code being loaded and executed due to object instantiation and autoloading."

Three languages, three official docs, all saying the same thing for a decade. All three still generating CVEs. The failure is not missing documentation. That framing is more useful than any severity score, and it is the reason this lab spends its effort on why the obvious fixes fail rather than on repeating the warning.

Note the clause most write-ups drop from the Python quote: "or that could have been tampered with." It is the half that motivates the hmac sentence that follows it in the real docs. The teaching point is precise: hmac addresses tampering, not untrusted origin. Signing a payload produced by an attacker who holds the key buys you exactly nothing.

The gadget chain

Here is the part that makes this bug class feel like magic, and the part that stops feeling like magic once you see the shape.

A payload does not contain code. It contains a description of an object graph. What the attacker does is pick a set of classes that are already loaded in your process, arrange them so that reviving one calls a method on the next, and keep going until the last one does something useful. Each class in that sequence is a gadget. The sequence is a chain.

   attacker controls only the SHAPE of the graph
   ┌───────────────────────────────────────────────────────────┐
   │  Hash                                                     │
   │   └─ key: DeprecatedInstanceVariableProxy                 │
   │        @instance = ERB (with attacker-controlled @src)    │
   │        @method   = :def_module                            │
   └───────────────────────────────────────────────────────────┘
                             │
        Marshal.load rebuilds the Hash, which rehashes its keys
                             │
                             v
        proxy#hash  ->  method_missing  ->  @instance.def_module
                             │
                             v
        ERB#def_module -> ERB#def_method -> module_eval(@src)
                             │
                             v
                     attacker's Ruby runs

Nowhere in that payload is there an instruction saying "run a command." Every step is a normal method doing exactly what it was written to do. #hash is supposed to be called when you rebuild a hash. method_missing is supposed to forward. def_module is supposed to compile a template. The attacker supplied only the arrangement.

This is why the Apache Software Foundation refused to treat Commons Collections as vulnerable in 2015, and their statement is the clearest articulation of the idea anyone has published:

"this is not the only known and especially not unknown useable gadget. So replacing your installations with a hardened version of Apache Commons Collections will not make your application resist this vulnerability."

All three of the famous 2015 Java CVEs (CVE-2015-4852 for Oracle WebLogic, CVE-2015-7501 for Red Hat JBoss, CVE-2015-6420 for Cisco) are scoped to downstream vendors. Apache Commons Collections itself never received a CVE, because InvokerTransformer was doing exactly what it was documented to do. The vulnerability was readObject() on untrusted bytes. The library was ammunition.

Before the upstream fix shipped, the remediation of last resort was to physically delete InvokerTransformer, InstantiateFactory, and InstantiateTransformer class files out of deployed jars. That tells you how well the "just allowlist it" strategy was going.

The axis that matters: gated versus ungated

This is the organizing idea of the whole lab, it is not written down in the published Ruby literature, and it was established here by execution on Ruby 3.0.7, 3.1.7, 3.3.8, 3.3.12, 3.4.10, and 4.0.6, with negative controls.

Ruby's deserializers reach attacker-controlled objects two different ways, and the difference decides whether a class is usable at all:

GATED      the deserializer calls respond_to?(m, true) FIRST
           false -> TypeError, chain dead
           reachable through method_missing ONLY if respond_to_missing? also answers true

UNGATED    the deserializer calls the method directly
           no gate, no check
           method_missing catches it for free

The verified table for Marshal.load, identical on Ruby 3.0.7 through 4.0.6:

Method Gated? When Marshal.load invokes it
marshal_load(data) GATED on respond_to?(:marshal_load, true) Object was dumped via marshal_dump (the U tag)
self._load(str) GATED, on the class Object was dumped via _dump (the u tag). The gate is on the singleton class
respond_to_missing?(m, true) it is the gate Called before the two above whenever the method is not concretely defined. Itself a reachable sink
method_missing(m) inherits the gate Fires for the two above only if respond_to_missing? returned true
hash UNGATED Object is a Hash key, or nested in an Array or Set used as a key
eql?(other) UNGATED Only on hash-bucket collision between two keys
<=>(other) UNGATED Range#marshal_load validates its endpoints, so it fires on both ends of a bounded Range

And the verified negatives, which matter just as much. Marshal.load never invokes any of these directly: to_s, to_str, to_ary, to_hash, to_proc, to_int, inspect, ==, coerce, each, call, <<, +, length, size, freeze.

That negative list is easy to get wrong and expensive to get wrong. Gem::RequestSet::Lockfile#to_s is a real step in the published universal chain, so it is tempting to file to_s as a sink. But it is called by another gadget, not by Marshal. to_s is a link, never an entry point. A scanner that conflates the two produces a flood of false positives, which is why this project models them as two different kinds of node and reports 53 links separately from 140 entry points.

The payoff: the same class, opposite outcomes

Take a proxy class that undefines every public method, the shape Rails uses for its deprecation proxies. Reached through a gated sink:

1) marshal_load via method_missing (respond_to_missing? => true)  -> fires
2) NEGATIVE: respond_to_missing? => false                         -> TypeError, chain dead
3) NEGATIVE: no marshal_load, no method_missing                   -> TypeError, chain dead
4) fully-wiped proxy                                              -> TypeError, chain dead

Row 4 is the payoff. A class that undefines everything cannot be a marshal_load entry point, because it undefined respond_to? without supplying respond_to_missing?.

Now the same wiped class through an ungated sink:

A) wiped proxy as a Hash key      [UNGATED #hash]  -> method_missing(hash) | ok
B) wiped proxy inside an Array key [UNGATED #hash]  -> method_missing(hash) | ok
C) wiped proxy inside a Set        [UNGATED #hash]  -> method_missing(hash) | ok
D) two colliding wiped keys                         -> MM(hash) | MM(hash) | MM(eql?) | ok

Same class. Gated path dies, ungated path fires. Everything the scanner does is built on that distinction.

Row D is worth pausing on, because it is the one that bit this project. #eql? only fires on a bucket collision, which means a test fixture with a single key can never observe it. An earlier version of this lab's differential oracle dumped { key => nil }, one key, no collision, and #eql? was unobservable by construction. Three detector bypasses shipped under a green suite because of it. The oracle now uses two-key colliding hashes.

Psych is not Marshal, and the difference is sharper than it looks

The same exercise for YAML.unsafe_load, verified identical on Psych 3.3.2, 4.0.4, 5.1.2, 5.2.2, and 5.3.1:

Method Gated? Trigger
init_with(coder) soft gate: o.respond_to?(:init_with) as an ordinary Ruby call, so method_missing intercepts it any !ruby/object:X mapping
marshal_load(data) gated on respond_to?(:marshal_load) the !ruby/marshalable:X tag
hash UNGATED object used as a YAML mapping key
== UNGATED mapping key insertion
[]=(k, v) UNGATED !ruby/hash:Subclass, where Psych calls []= on the allocated subclass

The sharpest difference, and one this research run found nowhere in the literature: Psych calls instance.respond_to?(:init_with) as an ordinary Ruby method call, not through the C-level rb_obj_respond_to that Marshal uses. On a method-erased proxy, respond_to? itself falls into method_missing, which returns something truthy, so Psych then calls init_with, which also falls into method_missing.

A fully method-erased proxy class is a valid YAML entry point and an invalid Marshal entry point. Identical class, opposite outcome. The lab has a test that asserts exactly that in both directions, and it is the reason the scanner scores entry points per format rather than globally: today's scan finds 29 entry points reachable through Marshal and 33 through Psych, and those two sets are not nested.

One blind spot worth knowing about

A String subclass that overrides #hash and is used as a Marshal hash key never has its #hash called. Ruby's internal rb_any_hash special-cases T_STRING and hashes the bytes directly. Verified by execution on Ruby 4.0.6:

loading a String-subclass key dispatched: []
loading an Object-subclass key dispatched: [:"Object subclass"]

Consequence for anyone writing a scanner: String subclasses are dead as #hash entry points. Report them and you produce false positives. Array, Hash, Object, and Struct subclasses all dispatch normally. (The same C fast path plausibly covers Symbol, Integer, Float, nil, true, and false, but those cannot be subclassed, so it could not be tested and is not claimed here.)

The two allowlists

This is the spine of the project. Everyone teaches "do not deserialize untrusted input." Almost nobody explains why the obvious fix fails, and the answer is a specific, checkable fact about where one function call sits.

Marshal.load accepts a proc. It is tempting to use it as an allowlist:

Marshal.load(bytes, ->(obj) { raise SecurityError unless ALLOWED.include?(obj.class); obj })

That does not work. Here is the TYPE_USRMARSHAL case from marshal.c on ruby/ruby master, with the ordering annotated:

case TYPE_USRMARSHAL:
    VALUE name = r_unique(arg);
    VALUE klass = path2class(name);
    ...
    v = obj_alloc_by_klass(klass, arg, &oldclass);   /* 1. allocate */
    ...
    v = r_entry(v, arg);
    data = r_object(arg);
    load_funcall(arg, v, s_mload, 1, &data);         /* 2. YOUR GADGET RUNS */
    ...
    v = r_post_proc(v, arg);                         /* 3. proc finally sees it */
    break;

r_post_proc is where your proc is invoked. It is two statements after load_funcall(... s_mload ...), which is the call that runs marshal_load. By the time your proc is handed the object and raises, the gadget has already fired. Executed confirmation on Ruby 4.0.6:

EXP-B: allowlist proc that permits everything EXCEPT Inner
  proc raised: blocked Inner
  side effects fired BEFORE the proc could veto: ["Inner#marshal_load RAN"]
  => allowlist proc FAILED TO PREVENT the callback

There is also no allowlist keyword to fall back on. Marshal.load accepts exactly proc and freeze::

Marshal.load(data, permitted_classes: [String])
  ArgumentError: unknown keyword: :permitted_classes

Psych's allowlist genuinely is a veto, for exactly one reason: it checks the tag before revival. Same intent, opposite outcome, decided entirely by where the check sits.

Marshal   bytes ──> build the object ──> RUN its hook ──> your allowlist runs
                                         ^^^^^^^^^^^^     too late, an autopsy

Psych     bytes ──> CHECK the tag ──> refuse
                    ^^^^^^^^^^^^^^   in time, a bouncer

The lab makes that executable rather than asserting it. One test loads the same conceptual payload through both deserializers and asserts on which callbacks fired:

  • Psych.safe_load(document, permitted_classes: []) raises Psych::DisallowedClass and init_with never ran.
  • Marshal.load(blob, ->(o) { o }) returns, and marshal_load already ran.

The target application exposes both so you can curl the difference. /render and /yaml/unsafe both reach code execution with the same ERB object. /yaml/safe refuses it by tag. /render/safe can only inspect the bytes first and hope.

That asymmetry is Ruby's position in the wider ecosystem, and it is not flattering: Marshal has no JEP 290, no weights_only, no allowed_classes, and no .NET 9 moment. Psych got a safe default in Ruby 3.1. Marshal got a documentation warning. That is the reason this lab exists and the reason it targets Marshal specifically.

The worked example: CVE-2026-41316

Four months old at the time of writing, and the cleanest teaching case available because the patch is three lines and you can read all of it.

  • Advisory: ruby-lang.org, 2026-04-21. GHSA-q339-8rmv-2mhv. NVD published 2026-04-23.
  • CVSS v3.1 8.1. CWE-502 and CWE-693 (Protection Mechanism Failure). The dual mapping is the story.
  • Affected: erb < 4.0.3.1, = 4.0.4, >= 5.0.0 < 6.0.1.1, >= 6.0.2 < 6.0.4. Patched: 4.0.3.1, 4.0.4.1, 6.0.1.1, 6.0.4.
  • Credit: TristanInSec.
  • Precondition, quoted from the advisory:

    "Any Ruby application that calls Marshal.load on untrusted data AND has both erb and activesupport loaded is vulnerable to arbitrary code execution."

Three databases give three different dates for it (rubysec 2026-04-13, ruby-lang 2026-04-21, NVD 2026-04-23), and NVD assigns CWE-502 plus CWE-693 while the GHSA page lists only CWE-693. Cite the one you actually pulled.

The mechanism. Ruby 2.7.0 added an @_init instance-variable guard so that an ERB object reconstructed through Marshal.load would refuse to evaluate its template. ERB#result and ERB#run check it. ERB#def_method, ERB#def_module, and ERB#def_class evaluated the template source without checking it.

Reading the shipped source in a pinned container makes it concrete. In erb 4.0.4.1, a patched version, the assignment and the checks look like this:

in def initialize(...)    | @_init = self.class.singleton_class
in def result(b=...)      | unless @_init.equal?(self.class.singleton_class)
in def def_method(mod,..) | unless @_init.equal?(self.class.singleton_class)

And the reason one added check fixes all three methods, read from the patched source:

def def_module(methodname='erb')
  mod = Module.new
  def_method(mod, methodname, @filename || '(ERB)')
  mod
end

def def_class(superklass=Object, methodname='result')
  cls = Class.new(superklass)
  def_method(cls, methodname, @filename || '(ERB)')
  cls
end

def_module and def_class both delegate to def_method, so guarding def_method closes the whole family. That is what "fix the guard, not the symptom" looks like as a diff.

The exploit primitive, which explains why these three methods were exploitable rather than merely unguarded. def_method wraps the template source in a generated def <methodname> ... end. An attacker who controls @src prefixes it with end\n, closing the generated wrapper early, so the injected code runs at module_eval time, during definition, rather than waiting for anyone to call the method. This lab builds exactly that, and you can print it:

Marshalsea::Chains::ErbDefMethod.canary("/tmp/canary", "pwned").src
# => "#\nend\nFile.write(\"/tmp/canary\", \"pwned\")\ndef _marshalsea_unused\n"

ERB#def_method does not simply prepend the wrapper. Its actual line, read from the shipped source, is:

src = self.src.sub(/^(?!#|$)/) { "def #{methodname}\n" } << "\nend\n"

It inserts def <name> before the first line that is neither a comment nor blank. That regex exists because a genuinely compiled ERB template starts with a magic encoding comment, which has to stay on line one:

ERB.new("hello <%= 1 %>").src
# => "#coding:UTF-8\n_erbout = +''; _erbout.<< \"hello \".freeze; ..."

So the payload's leading # is impersonating that magic comment, which pushes the insertion point down onto the end. Running the same substitution on the payload produces exactly this:

#                                     # the fake magic comment, so the `sub` skips line 1
def render_it                         # the wrapper lands HERE, on the `end` line
end                                   # and closes immediately: an empty method
File.write("/tmp/canary", "pwned")    # now at module_eval top level, runs during eval
def _marshalsea_unused                # a second empty method, which eats the
                                      # wrapper's own appended "\nend\n"
end

Verified by executing it: the payload fires during eval, and both render_it and _marshalsea_unused are defined as empty methods afterward.

fired during eval: [:RAN_AT_EVAL_TIME]
methods defined:   [:_marshalsea_unused, :render_it]

That is the entire trick. Nothing waits for anyone to call render_it.

def_module takes no arguments, which is what makes it reachable from a gadget chain rather than only from cooperating application code, and it is why this lab ships two payloads with different labels:

Payload Kind Enters through Fires when Needs
erb-def-module chain ungated #hash on a hash key inside Marshal.load, no application call activesupport loaded in the target
erb-def-method primitive the @_init guard bypass only when the application calls def_method nothing

Calling the second one a "chain" would be a lie, and the distinction teaches something real: the dangerous call site can live in your code. The lab's target application calls template.def_method(...) if the deserialized object responds to it, which is a plausible thing for a template-caching layer to do and is exactly the cooperation the primitive needs.

Why CWE-693 matters pedagogically. A mitigation existed, was deliberate, was six years old, and covered two of five entry points. Partial guards read as safety and they audit as safety. This is the strongest available argument for the position that allowlisting individual sinks is a losing game.

The lab's exploit gate proves both halves by pulling two real images one version apart: the chain fires on the vulnerable one and is blocked on the patched one, with the boundary asserted in both directions.

What actually happened in Ruby

Short version, all traced to primary sources.

CVE-2013-0156, the one that made the Ruby world care. Rails' XML parameter parser let a request declare the type of a parameter, and the supported list included yaml and symbol. So a request body could instruct the framework to hand attacker-controlled bytes to the unsafe YAML parser, before any application code ran, on every controller:

<?xml version="1.0" encoding="UTF-8"?>
<bang type="yaml">--- !ruby/object:Time {}
</bang>

Five lines, and it contains the entire vulnerability class. The upstream advisory's workaround deletes "symbol" and "yaml" from ActiveSupport::XmlMini::PARSING, and it is blunt about the rest:

"there is no fix for YAML object injection"

rubygems.org was compromised on 2013-01-30 using this class of flaw, and it is documented by the maintainers themselves. RubyGems.org called YAML.load on the metadata.gz of uploaded gems, so an attacker uploaded a gem whose metadata instantiated objects and exfiltrated config files. Per the maintainers' own writeup, no API keys were actually exposed, because the service ran on Heroku and kept secrets in ENV rather than in config files. An accident of deployment style, not a control. Response took roughly 53 hours and included re-verifying SHA512 checksums for every gem against community mirrors.

Note the vector: not an HTTP request into a Rails app, but gem metadata processing. Same sink, different door. There is no dollar figure and there does not need to be one. The cost was ecosystem-wide trust.

The key-management pair, thirteen years apart, and they belong together.

  • CVE-2019-5420 (Rails, CVSS 9.8): in development mode Rails derived secret_key_base from the application's own name, which an attacker could recover by requesting an invalid route. With the key, mint a correctly signed payload and get RCE. NVD assigns CWE-330 and CWE-77, not CWE-502. The root cause was a predictable secret; deserialization was merely the payload. The fix was key generation, not a serializer change.
  • CVE-2026-39324 (rack-session, CVSS 9.3): the key was fine, and the failure path fell open. Quoting NVD: "If cookie decryption fails, the implementation falls back to a default decoder instead of rejecting the cookie." Fail-open beat the cryptography.

Together they make the case that "sign and encrypt the payload" is necessary and demonstrably not sufficient: what your code does when verification fails is part of the control. (Rails applications are explicitly not affected by the rack-session one. It uses a different code path. Getting that wrong would misinform every Rails reader.)

CVE-2022-32224, the modern shape. Active Record's serialize :options defaulted to YAML and deserialized with YAML.unsafe_load. An attacker who can write to the database, typically via SQL injection, escalates to RCE when the row is read back. Two things make it the best modern teaching case. It is a second-order sink: the untrusted data arrives from your own database, so any threat model drawing the trust boundary at the HTTP edge misses it entirely. And the fix shipped an opt-out, use_yaml_unsafe_load, because safe-by-default broke real applications. That tension between a safe default and a compatibility escape hatch that quietly restores the vulnerability is the single most transferable idea here, and it recurs in Psych 4, PyTorch 2.6, and Rails' cookie serializer.

Two negatives worth more than most of the positives.

The two most significant pieces of Ruby deserialization research in recent years received no CVE at all. Luke Jahnke's Gem::SafeMarshal escape (2024-12-03) and his Ruby 3.4 universal gadget chain (2024-11-24) were both fixed as ordinary RubyGems point releases. An NVD keyword search for SafeMarshal returns zero results. A scanner, corpus, or curriculum built on CVE feeds alone will miss the actual state of the art. Track the RubyGems ### Security: changelog headings and the primary researchers' writeups instead.

And: no Ruby or Rails deserialization CVE appears in the CISA KEV catalog, verified programmatically against all 1,653 entries. The only Rails entries in KEV are path traversals. Ruby deserialization is a rich research area with no CISA-confirmed mass-exploitation event. There is documented commodity-botnet exploitation of CVE-2013-0156 and one documented ecosystem compromise. That is a real but much smaller claim than the one usually made, and saying it plainly is the fastest way to keep a knowledgeable reader.

Everyone else got it too

Java, 2015. The technique landed at OWASP AppSec California on 2015-01-28, in Chris Frohoff and Gabriel Lawrence's "Marshalling Pickles: How Deserializing Objects Will Ruin Your Day," which covered Python pickle, Ruby Marshal, PHP serialization, and Java together and introduced ysoserial. Ruby was in the original talk; this project is not a footnote to the Java story, it is part of the same disclosure.

The era exploded ten months later, and not because of the talk. Foxglove Security's post on 2015-11-06 weaponized it against named enterprise products and shamed the vendors in public:

"Even though proof of concept code was released OVER 9 MONTHS AGO, none of the products mentioned in the title of this post have been patched, along with many more."

Oracle's security alert landed four days later. That is a useful thing to understand about how disclosure actually moves vendors.

Java's structural answer was JEP 290, "Filter Incoming Serialization Data," created 2016-04-22 and delivered in Java 9. The critical caveat: a serialization filter is not enabled or configured by default. Nine years after ysoserial, the Java default is still unfiltered.

.NET went furthest. Microsoft's guidance is the most unambiguous vendor statement on this class anywhere: "BinaryFormatter is insecure and can't be made secure." Their analogy is worth stealing: "assume that calling BinaryFormatter.Deserialize over a payload is the equivalent of interpreting that payload as a standalone executable and launching it." Starting in .NET 9 the in-box implementation throws on use.

PHP has the densest gadget space of any of them, because __wakeup, __destruct, and __toString give far more reachable magic methods than Java's single readObject. Roughly tens of chains for Java, and roughly 170 chains across 44 frameworks for phpggc. (The 44 framework directories were counted twice and agree. The 170 came from a single count, so treat it as approximate; the comparison holds either way.) The best in-the-wild case is CVE-2015-8562 (Joomla), where the payload arrives in a User-Agent header, which makes the "untrusted input is everywhere, not just the request body" point better than any diagram. Sucuri documented the curve: first exploit 2015-12-12, and by 12-14 "basically every site and honeypot we have being attacked."

The single best idea to steal from PHP is Sam Thomas's phar:// work (Black Hat USA 2018). Phar archive metadata is deserialized by ordinary file operations: fopen, file_exists, file_get_contents, filesize. An application can suffer deserialization with no unserialize() call anywhere in its code. That is the strongest available argument against the mental model "grep for the dangerous function and you have found the attack surface." The transferable lesson for Ruby readers: the audit question is not "where do we call Marshal.load" but "what reaches a deserializer."

Python supplies the best argument that denylist scanning loses. picklescan, the scanner the ML ecosystem relies on, has accumulated 26+ CVEs of its own between 2025-02 and 2026-06:

CVE CVSS The bypass
CVE-2025-1716 9.8 pip was not on the unsafe-globals list; pip.main() pulls a malicious package
CVE-2025-1889 9.8 Non-standard file extensions fall outside scan scope
CVE-2025-1945 9.8 Flipping bits in ZIP headers hides the pickle from the scanner while torch.load() still loads it
CVE-2025-10156 9.8 A deliberately bad CRC halts the scanner
CVE-2025-71350 8.1 torch.utils.collect_env.run was not blocked

Twenty-six CVEs, each one "we forgot about this callable." And it is not one bad tool: Trail of Bits' fickling has the identical failure in CVE-2026-22608, chainable to RCE while the tool reports the file as safe.

Note the CWE on both fickling's and picklescan's first: CWE-184, "Incomplete List of Disallowed Inputs." MITRE has a dedicated weakness class for "your denylist is missing something," and both of the ecosystem's leading pickle scanners have been assigned it.

Denylist scanning of a serialization stream is not losing on execution. It is losing on architecture. That is the honest counterweight to this project's own scanner, and it is stated in exactly those terms: a gadget-discovery tool tells you what chains exist today. It is not a control.

The one thing that did work, across three ecosystems, was changing the default. PyTorch flipped torch.load's weights_only to True in 2.6.0 (2025-01-29). NumPy flipped allow_pickle to False in 1.16.3, its release notes saying "in response to CVE-2019-6446." Ruby made YAML.load safe in 3.1. All three broke real users' workflows, and that cost is precisely what kept the unsafe default in place for years. A decade of warnings did nothing; changing the default did.

Where this class stands, honestly

The current OWASP citation is A08:2025 Software or Data Integrity Failures. Note the exact wording, because the naming has drifted across three editions and getting it wrong is the most likely error anyone makes here:

Edition Number Exact name
2017 A08:2017 Insecure Deserialization (its own dedicated category)
2021 A08:2021 Software and Data Integrity Failures
2025 A08:2025 Software or Data Integrity Failures

Insecure deserialization has not had its own Top 10 category since 2017. It is one of 14 mapped CWEs inside A08, and CWE-502 is explicitly among them.

CWE-502 is named "Deserialization of Untrusted Data," has been in the CWE Top 25 every year from 2019 through 2025, and ranks #15 in the 2025 list with a score of 5.23, up one place from #16 in 2024. Its neighbours are stack and heap buffer overflows.

The KEV numbers, downloaded and parsed locally rather than asked of a search engine (catalog version 2026.07.24):

Measure Value
Total KEV entries 1,653
Entries mapped to CWE-502 69 (4.17%)
CWE-502 rank among all CWEs in KEV 7th
CWE-502 entries with known ransomware campaign use 24 of 69 (34.8%)
Baseline: all KEV entries with known ransomware use 332 of 1,653 (20.1%)

Two findings from that worth carrying away. Deserialization bugs are roughly 1.7x more likely to be used by ransomware crews than the average confirmed-exploited vulnerability. It is the strongest honest impact claim available for this class, it comes from a government catalog rather than a vendor, and anyone with curl can reproduce it. And the rate is not declining: 2025 was the highest single year on record, and Microsoft SharePoint alone picked up five CWE-502 KEV entries inside twelve months, one of them ("ToolShell," CVE-2025-53770) with ransomware use confirmed.

If you see MITRE's CWE page report 11 KEV entries for CWE-502 rather than 69, both are right and they measure different things. The CWE Top 25 methodology analyses the 39,080 CVE records published between 2024-06-01 and 2025-06-01, so its count is scoped to a one-year publication window. The 69 is the all-time count in the live catalog. Quoting them side by side without that note reads as an error.

There is no credible aggregate dollar figure for this bug class. Numbers of that shape circulate. They come from vendor marketing and extrapolations from "average cost of a breach" surveys, and they are not traceable to incident data. A repo laundering a marketing number into an academic-looking citation is worse than having no number.

Corrections: things you will read that are wrong

Every item below is something a confident write-up plausibly asserts, checked against a primary source, and found wrong. This list is the most useful thing in this chapter, because it is a map of where the popular retelling of this vulnerability class fails.

The common claim What the record says
Equifax was a deserialization breach. Wrong. CVE-2017-5638 is OGNL injection, NVD CWE-755, CISA KEV CWE-20. The Struts deserialization CVE is CVE-2017-9805, a different bug in a different component, and it was not the Equifax vector.
CVE-2013-0156 is a CWE-502 record. Wrong. NVD assigns CWE-20. So does CVE-2013-3567 (Puppet). Filtering NVD by CWE-502 to enumerate "the deserialization CVEs" silently drops the most famous Ruby one.
CVE-2019-5420 is a deserialization CVE. Misleading. NVD assigns CWE-330 and CWE-77. The root cause is a predictable dev-mode secret_key_base. The fix was key generation.
The current OWASP category is "A08:2021 Software and Data Integrity Failures." Stale. It is A08:2025 Software or Data Integrity Failures. "Or", not "and".
YAML.load is unsafe in Ruby. Version-dependent, and the unqualified claim is now wrong. Verified by execution: Psych 3.3.2 (Ruby 3.0.7) deserializes arbitrary objects; Psych 4.0.4 (Ruby 3.1.7) and later raise Psych::DisallowedClass. Marshal.load reconstructs arbitrary objects on every version tested. It never got a safe default.
Apache Commons Collections had a CVE. Wrong, and it hides the lesson. All three identifiers are vendor-scoped: CVE-2015-4852 (Oracle), CVE-2015-7501 (Red Hat), CVE-2015-6420 (Cisco). The library never received one, and the ASF publicly argued it should not.
The 2016 SFMTA / San Francisco Muni ransomware attack was Oracle WebLogic CVE-2015-4852. Unsupported, and contradicted by SFMTA. Their own statement: "The SFMTA network was not breached from the outside, nor did hackers gain entry through our firewalls." The WebLogic association describes the attacker's general toolkit across many victims, not a finding about SFMTA. CISA KEV marks CVE-2015-4852 knownRansomwareCampaignUse: "Unknown".
RDoc CVE-2024-27281 was fixed in 6.3.4 / 6.4.1 / 6.5.1 / 6.6.3. Wrong. The Ruby advisory states those contain an incorrect fix. The correct versions are 6.3.4.1, 6.4.1.1, 6.5.1.1, 6.6.3.1.
CVE-2026-39324 (rack-session) affects Rails. Wrong. The upstream advisory says Rails is typically not affected; it uses a different code path.
ruby-saml's 2024-2025 CVEs are deserialization bugs. Wrong. CVE-2024-45409 is CWE-347. CVE-2025-25291 / CVE-2025-25292 are signature wrapping via a parser differential. Serious, critical, widely exploited, and not CWE-502.
Luke Jahnke's Gem::SafeMarshal escape has a CVE. No CVE exists. Fixed as an ordinary RubyGems point release under a ### Security: changelog heading.
Equifax's breach cost $1.4 billion "per SEC filings." Unverified as a filed figure. It traces to earnings-call commentary relayed by press. Defensible: $113.3M (FY2017 10-K, verbatim) and $575M to $700M (FTC settlement).

One method warning falls out of that table and it is worth stating on its own: filtering a vulnerability corpus by CWE-502 to "find the deserialization CVEs" is a broken method, and it silently drops most of the canon. CVE-2013-0156 is CWE-20. CVE-2015-8562 (Joomla, mass-exploited) is CWE-20. CVE-2016-4010 (Magento) is CWE-74. CVE-2025-27407 (graphql-ruby) is CWE-94. Older records predate consistent CWE-502 mapping, and CNAs disagree with NVD routinely. The KEV numbers in this chapter use the catalog's own cwes field and therefore inherit the same limitation: they are a floor, not a census.

Finally, a standing hazard. This topic is heavily polluted by content-farm output. One article claiming a Ruby Oj.load object-injection RCE uses a placeholder CVE ID, has no GHSA, no version range, and names a product that does not exist. If a claim has no primary source, it does not belong in a teaching document. That rule did more work on this topic than on any other in this repo.

Where to go next

02-ARCHITECTURE.md turns these ideas into structure: the two readers that share one vocabulary, the three decision states and why there is no accepted? predicate, the scanner's gate-plus-format-plus-arity taxonomy, and why the parser and the detector are deliberately allowed to disagree about the same stream.