Cybersecurity-Projects/PROJECTS/beginner/deserialization-gadget-lab
CarterPerez-dev f0ce950ac8 feat(marshalsea): close both audits, then build the three halves the contract promised
Two independent audits of the same tree, one executed and one static. Both were
worth running: the static pass found seven real defects the executed pass missed,
including the worst one here, and the executed pass found three the static pass
could not see because seeing them required running Ruby.

The release workflow could publish from any branch. The publish job carried no ref
condition and its tag check read `[ tag != expected ] && [ event = push ]`, so on a
workflow_dispatch the second clause was false, the && never fired, and control fell
straight through to rubygems/release-gem. Anyone with the Actions tab could ship a
mutable branch checkout to rubygems.org. The job is now gated on a pushed
refs/tags/marshalsea-v* ref, the version check is unconditional, and the manifest is
audited before the push step with a negative control proving a drifted lib file
turns it red.

Three payloads ran attacker code while the detector reported proceed. A
String-subclass hash key reaching a user #eql?, measured {eql: 1}; a gadget nested
in a bare Array key, measured {hash: 1}, 22 bytes hand-built; and a Range whose
endpoints dispatch #<=>, measured {cmp: 1}. All three were accepted under
deny_sinks_only and under strict with the class allowlisted, which is the documented
normal usage. They are blocked under every policy now.

The oracle that missed them dumped `{ key => nil }`. One key means no bucket
collision, so #eql? could never fire in the probe no matter how many key shapes were
added. Blind by construction, the exact defect class this project already had a rule
about. The corpus went further and asserted the String-subclass case was a precision
control, a positive claim that rejecting it would be a false positive. It is a
reject now, and the key rules are re-derived from research 02 section 4.2 rather than
grown case by case.

Range endpoints marshal as bare `begin`/`end`, not `@begin`/`@end`, because Range
uses a marshal compat dumper. The first constant was wrong and the test caught it.

The scanner's reachability filter contradicted its own thesis. Requiring zero arity
for ungated entry points excluded eql?, ==, <=>, []=, method_missing and
respond_to_missing? entirely: 85 candidates across those rows, 0 reachable. Only
hash and to_s survived, and research 02 section 4.2 verified to_s is never an entry
point, so 11 of 18 reachable results were a method Marshal.load does not invoke.
Entry points are now a table carrying gate, format and the arity the deserializer
supplies; links are a third gate value and are reported separately instead of
scored as entry points. Gated hooks are arity-checked too, so an arity-0
marshal_load that would raise ArgumentError is no longer called reachable.

Marshal.load reaches a private self._load through rb_funcallv, which ignores
visibility, while singleton_methods(false) does not report it. Adding
singleton_class.private_instance_methods immediately found Time._load on a stock
image, a real stdlib sink the scanner had never seen. Prism is error tolerant and
parse_definitions consumed .value without checking failure?, so a tree recovered
from four syntax errors produced a confident touches_state verdict; it is a
suppression now.

The parser accepted ivar-name and struct-member-name slots holding a fixnum, a
string or an array, the detector said proceed, and CRuby then raised
ArgumentError, so the defended route answered 500. The parser stays forensic on
purpose, because a sink hidden where a symbol belongs must stay visible, so the
anomaly is labelled on Result and the detector rejects on it. The target also
rescues the loader rather than trusting inspection.

Three things the contract promised and did not have.

The headline payload was not a chain. It built an ERB object past the @_init guard
and then both demonstrations called def_method themselves, so the canary was not a
consequence of Marshal.load. Research 04 line 292 and 05 line 982 already said the
real chain reaches def_module through
ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy, and 05 line 1143 listed
reproducing it as open. erb-def-module does that: the proxy sits in hash-key
position, ungated #hash dispatch lands in method_missing, target calls
@instance.__send__(:def_module), and ERB compiles the payload inside Marshal.load
with no application call. The old builder stays as erb-def-method and is labelled a
primitive. Three things only execution showed: the proxy undefines
instance_variable_set so setup has to go through a bound Object method; a real
deprecator holds a Proc and cannot be dumped, so the chain hand-builds one with
@silenced true and warn short-circuits before touching @behavior; and building
{proxy => 1} fires the payload in the builder's own process, so serialize splices a
key-position stream from a standalone dump and refuses any graph carrying an object
link, whose index would shift behind the hash node.

LoadGuard is the M6 runtime guard. A TracePoint on :call fires before a method body
runs, which is the veto a Marshal.load proc cannot give you. It watches the gated
hooks plus method_missing and respond_to_missing?, because a hook list without those
two is evaded by a respond_to_missing? proxy. hash and eql? are opt-in behind
strict:, since they are among the hottest methods in Ruby and BoundaryDetector
already catches key-position dispatch before any bytes load. It raises a
StandardError, never a SecurityError that would skip every rescue in the stack.

Its cost is not 1.4x. That figure is a property of the payload that was measured,
not of the guard. Enabling a TracePoint costs a near-constant ~46 microseconds per
load, so the ratio is decided by how much work the load does: 185.9x on 3 bytes,
40.4x on a 45-byte session cookie, 1.1x on 46 KB, 1.0x on 488 KB. The lab's own use
case is a session cookie, which is the worst case. A dated correction is written
back into research 03.

The Psych half exists now. Psych::Inspector reads a document through parse_stream,
revives nothing, and reports every !ruby/* tag with the method it would dispatch,
bounded on bytes, depth, nodes, aliases and documents. psych-init-with is the
matching chain. The target grows /yaml/unsafe and /yaml/safe so the spine of this
project is executable over HTTP: the same ERB object reaches code execution through
YAML.unsafe_load, and YAML.safe_load refuses it by checking the tag before revival.
The gate proves both layers independently, including a document the inspector
approves that Psych still refuses, so neither can alibi the other.

The target ran attacker Ruby on Docker's default bridge with outbound access and
installed sinatra, rackup and webrick unversioned. It now runs on an internal
network with cap-drop ALL, no-new-privileges, pid and memory ceilings and pinned
versions, with a control proving it cannot reach off the host. Creating that network
also proved --internal blocks the published port, so the gate drives the target from
a second container on the same network instead. The HTTP gate asserted body prefixes
and never captured status; it asserts exact status and body per endpoint now, which
immediately caught a bug in this very change where a nil sentinel conflated "the
loader refused" with the legitimate value nil.

Smaller: Chains.all filtered out a Base that was never registered, so the filter was
inert and its test vacuous; chains are discovered by directory glob now, per the
design's no-registry-to-rot contract. AFFECTED was shallow frozen, and mutating
metadata[:affected][2] flipped affects?("5.0.0") from true to false. Limits.permissive
keeps a depth cap on purpose and now says so, because lifting it trades a rescuable
DepthLimitError for an uncatchable SystemStackError. The README claimed a fixnum
width rejection its own test proves is unreachable. Regexp options were discarded
while the node still reported fully_decoded?.

The README is rebuilt to the repository's shape, and Deserialization Gadget Lab
takes project 41 in the root table, replacing Ghost on the Wire. CHANGELOG.md is
dropped from the gem manifest, the metadata and the packaging gate.

Full gate: 78 PASS, 0 FAIL across six stages, up from 58. 267 tests across seven
suites, from 194. Lint 0 across 37 files. Every rule added here ships with the
mutant that kills it.
2026-07-30 23:04:38 -04:00
..
lib feat(marshalsea): close both audits, then build the three halves the contract promised 2026-07-30 23:04:38 -04:00
scripts feat(marshalsea): close both audits, then build the three halves the contract promised 2026-07-30 23:04:38 -04:00
target feat(marshalsea): close both audits, then build the three halves the contract promised 2026-07-30 23:04:38 -04:00
test feat(marshalsea): close both audits, then build the three halves the contract promised 2026-07-30 23:04:38 -04:00
.gitignore feat(marshalsea): close both audits, then build the three halves the contract promised 2026-07-30 23:04:38 -04:00
.rubocop.yml feat(rube): M7 - a version floor is a measurement, not a preference 2026-07-29 15:04:21 -04:00
Gemfile fix(rube): clear the entire S2 backlog tier - a non-answer is never an answer 2026-07-29 14:35:48 -04:00
LICENSE feat(rube): M1 Marshal stream parser - inspect payloads without deserializing 2026-07-26 09:32:14 -04:00
README.md feat(marshalsea): close both audits, then build the three halves the contract promised 2026-07-30 23:04:38 -04:00
Rakefile feat(marshalsea): rename off a name taken since 2009, then build the release path 2026-07-29 15:29:25 -04:00
justfile feat(marshalsea): close both audits, then build the three halves the contract promised 2026-07-30 23:04:38 -04:00
marshalsea.gemspec feat(marshalsea): close both audits, then build the three halves the contract promised 2026-07-30 23:04:38 -04:00

README.md

███╗   ███╗ █████╗ ██████╗ ███████╗██╗  ██╗ █████╗ ██╗     ███████╗███████╗ █████╗
████╗ ████║██╔══██╗██╔══██╗██╔════╝██║  ██║██╔══██╗██║     ██╔════╝██╔════╝██╔══██╗
██╔████╔██║███████║██████╔╝███████╗███████║███████║██║     ███████╗█████╗  ███████║
██║╚██╔╝██║██╔══██║██╔══██╗╚════██║██╔══██║██╔══██║██║     ╚════██║██╔══╝  ██╔══██║
██║ ╚═╝ ██║██║  ██║██║  ██║███████║██║  ██║██║  ██║███████╗███████║███████╗██║  ██║
╚═╝     ╚═╝╚═╝  ╚═╝╚═╝  ╚═╝╚══════╝╚═╝  ╚═╝╚═╝  ╚═╝╚══════╝╚══════╝╚══════╝╚═╝  ╚═╝

Cybersecurity Projects Ruby Gem Formats CVE Tests License: AGPLv3

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.

Why deserialization is its own bug class

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 then the attacker chooses which methods run. String enough unrelated standard-library methods together and code execution falls out the far end. A gadget chain is a Rube Goldberg machine, and nowhere in those bytes is there an instruction that says "run a command."

This is not a Ruby quirk. It is the same shape as Java deserialization, PHP POP chains, and Python pickle. CWE-502 is the seventh most common weakness in the CISA Known Exploited Vulnerabilities catalog, at 69 of 1,653 entries, and 34.8% of those carry known ransomware use against a 20.1% baseline for the catalog as a whole. Ruby itself has shipped two advisories in this class recently: CVE-2024-27281 in RDoc and CVE-2026-41316 in ERB, the one this lab reproduces.

It is also the bug class the industry most consistently gets wrong in retellings. Equifax is cited as an insecure-deserialization breach in an enormous number of write-ups. It was OGNL injection, CWE-755. The Apache Software Foundation published that correction on 2017-09-14 and the correction lost. learn/01-CONCEPTS.md opens with that debunk on purpose, because a repository that teaches this class should be able to show its work.

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.

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
  • Extracts referenced class names and sink tags without instantiating anything at all
  • Rejects truncated streams, unsupported versions, unknown tags, out-of-bounds object links and symlinks, trailing bytes, and excessive nesting, and bounds fourteen separate resource axes by default rather than on request
  • Labels what it cannot decode instead of guessing: a legacy float mantissa becomes an undecoded_tail, and a role slot holding the wrong node type becomes a named anomaly rather than a silent pass

A reader that never loads (YAML)

  • Reads a document through Psych.parse_stream, which builds an AST and revives nothing
  • Reports every !ruby/* tag with the method that tag would dispatch: init_with, []=, or marshal_load
  • Counts aliases instead of expanding them, so an alias bomb costs nothing to inspect

A boundary detector with three states and no comforting lies

  • One state per decision, proceed / blocked / observed, mutually exclusive by construction. There is no accepted?, because "did the policy permit this" and "is this stream clean" have different answers under monitoring mode and one predicate cannot answer both
  • Rejects on sink tags, on hash keys whose #hash or #eql? would dispatch during load, on Range endpoints whose #<=> would dispatch, and on unapproved class names, each with a reason that is a true statement about that specific stream
  • Bounds how much attacker-controlled text reaches your logs, and escapes control bytes so a class name cannot forge a log line

A runtime guard that vetoes instead of reporting

  • A TracePoint on :call fires before a method body runs, which is exactly the veto point a Marshal.load allowlist proc denies you
  • Refuses an unpermitted marshal_load, _load, _load_data, method_missing, or respond_to_missing? before the body executes, thread-scoped so one request does not tax the process
  • Ships its own bypasses, including the one it deliberately leaves open by default

A reflection scanner over the loaded class graph

  • Sorts auto-invoked methods into entry points a deserializer dispatches directly and links a gadget calls once a chain is already moving, because to_s is a link and never an entry point and conflating them is a false positive
  • Each entry point carries the format that reaches it, the gate that guards it, and the argument count the deserializer supplies, so a marshal_load whose arity cannot accept the call is not reported as reachable
  • Counts every error it swallows, names the site, and treats a method it could not analyse as reachable rather than inert, so under-reporting is visible instead of silent

Payloads, labelled by what they actually are

  • erb-def-module is a chain: it reaches ERB#def_module through an ActiveSupport proxy in hash-key position, so it executes inside Marshal.load with no cooperation from the application
  • erb-def-method is a primitive: it forges an ERB object past the @_init guard and stays inert until the application calls a def_* method on it
  • The builder never runs its own payload. A key-position stream is spliced from a standalone dump instead of assembled by building the hash locally, and it refuses any object graph whose link indices would shift

A vulnerable target you can attack over HTTP

  • Sinatra on Rack 3 in a container with no route off the host, read-only root, dropped capabilities, and pinned dependencies
  • Four endpoints: load a Marshal cookie, inspect it first, then the same pair again over YAML

Quick Start

gem install marshalsea

Look at a payload without running it:

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:

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, ..."

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?.

Then run the lab itself from a checkout:

just gate        # everything: suites, matrix, exploit, detector, target, packaging
just target      # stand up the vulnerable app and attack it over HTTP
just scan        # run the gadget scanner over loaded modules

[!TIP] This project uses just as a command runner. Type just to see every recipe.

Install: curl -sSf https://just.systems/install.sh | bash -s -- --to ~/.local/bin

The two allowlists

This is the spine of the project and the reason it exists. Everyone teaches "do not deserialize untrusted input." Almost nobody explains why the obvious fix fails.

The obvious fix is handing Marshal.load an allowlist proc. It does not work, and the reason is one line of marshal.c: the proc runs in r_post_proc, which is invoked after load_funcall(... s_mload ...). Psych's allowlist genuinely is a veto, for exactly one reason: it checks the tag before revival.

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

Same intent, opposite outcome, decided entirely by where the check sits. The target exposes both so you can curl the difference: /render and /yaml/unsafe both reach code execution with the same ERB object, and /yaml/safe refuses it by tag while /render/safe can only inspect the bytes and hope.

The two payloads

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

Both target CVE-2026-41316 (published 2026-04-23, CVSS 8.1, CWE-502 plus CWE-693). Ruby 2.7.0 added an @_init guard to stop Marshal.load code execution on ERB objects, and def_method, def_module, and def_class never checked it. Six years of a correct defense with three doors left open. The exploit gate proves both halves: the chain fires on erb 6.0.1 and is blocked on 6.0.1.1, one docker pull apart.

Limits

Every defense here is a trade, and the code says so out loud rather than in a footnote.

  • 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 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

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 parser is deliberately forensic: it keeps parsing a stream that CRuby 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.

Build and Test

just check       # the seven suites plus the standalone controls
just gate        # check + matrix + exploit + detector + target + package
just lint        # rubocop, 37 files
just build       # build the gem into tmp/build

Everything runs in Docker against a pinned Ruby, and every gate container runs with --network none except the target, which gets its own internal network with no route off the host.

The discipline here is that a green suite proves nothing until the thing under test has been mutated. Every rule added to this project ships with the mutant that kills it, every gate carries an input it must reject, and the tests that matter most are differential: they execute real Marshal.load and real Psych, observe what actually dispatched, and assert the model agrees, with liveness guards on both directions so a dead oracle cannot pass quietly.

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 opt-out
│   │   ├── float_body.rb        # float decoding that labels what it cannot decode
│   │   └── constants.rb errors.rb
│   ├── psych/inspector.rb       # YAML AST reader, revives nothing
│   ├── chains/                  # 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/                       # the teaching track (public)
└── justfile

Learn

This project ships a full teaching track. Read it in order, or jump to what you need.

Doc What it covers
learn/00-OVERVIEW.md What the lab is, prerequisites, the project layout, and a quick tour
learn/01-CONCEPTS.md The deserialization bug class, opening with the Equifax debunk, grounded in verified incidents
learn/02-ARCHITECTURE.md The two readers, the gated versus ungated dispatch axis, and why the detector and parser disagree on purpose
learn/03-IMPLEMENTATION.md A code walkthrough from Marshal tags to a working chain, with the ActiveSupport proxy as the showpiece
learn/04-CHALLENGES.md Extension ideas, from a new chain to closing the guard's deferred-execution bypass

License

AGPL 3.0.