Cybersecurity-Projects/PROJECTS/beginner/deserialization-gadget-lab
CarterPerez-dev 9e616ad1a4 feat(marshalsea): rename off a name taken since 2009, then build the release path
rube has been on rubygems.org since 2009-08-05: Richard LeBer, 12,305 downloads,
and it is an ERB front-end, which is funny given the flagship CVE here is an ERB
gadget. The name was never publishable, so publishing required a rename first.

marshalsea. The Marshalsea was a London debtors' prison, 1373 to 1842, and the
name is the job description: hold untrusted objects at the gate and decide what
gets through before Marshal.load turns bytes into behaviour. It also carries
"Marshal", so the gem reads as on-topic without a subtitle.

module Rube is module Marshalsea, lib/rube/ is lib/marshalsea/, require "rube" is
require "marshalsea", RUBE_TARGET_PORT is MARSHALSEA_TARGET_PORT, and the canary
moved to /tmp/marshalsea-canary. 31 files, roughly 163 occurrences, every one a
hand edit. The single deliberate survivor is the README's "a Rube Goldberg
machine", which describes the gadget chain and not the gem.

Publishing is trusted publishing over OIDC, so no long-lived API key exists in
this repository to leak. A marshalsea-v* tag runs the five suites and the
standalone controls on Ruby 3.4 and 4.0, refuses to continue if the tag disagrees
with Marshalsea::VERSION or if the gemspec floor stops matching the tested
matrix, and then publishes with a Sigstore attestation. The attestation is
recorded as an auditable record and explicitly NOT as an install-time protection,
because neither gem install nor bundle install verifies one today.

Two things the primary source settled that the docs did not. rubygems/release-gem
does accept working-directory, which neither its README nor the RubyGems guide
mentions, so a monorepo subdirectory works. And it runs bundle exec rake release,
which this Rakefile had no task for at all.

Adding bundler/gem_tasks exposed a monorepo trap: Bundler::GemHelper tags a bare
v0.1.0, which says nothing about which of sixty projects it belongs to. Fixed
with tag_prefix. The catch is that rake -T still PRINTS "Create tag v0.1.0",
because that description is built when gem_tasks is required and the prefix is
assigned after. The tag actually created is marshalsea-v0.1.0. The label is
wrong and the behaviour is right, so the gate asserts the runtime value and
carries a control proving a Rakefile without the prefix line really does produce
the bare tag.

Full gate: 58 PASS, 0 FAIL across six stages, package now 25 of 25. 194 tests.
Lint 0 across 30 files.
2026-07-29 15:29:25 -04:00
..
lib feat(marshalsea): rename off a name taken since 2009, then build the release path 2026-07-29 15:29:25 -04:00
scripts feat(marshalsea): rename off a name taken since 2009, then build the release path 2026-07-29 15:29:25 -04:00
target feat(marshalsea): rename off a name taken since 2009, then build the release path 2026-07-29 15:29:25 -04:00
test feat(marshalsea): rename off a name taken since 2009, then build the release path 2026-07-29 15:29:25 -04:00
.gitignore feat(rube): M2 version matrix - executed gadget compatibility across six Rubies 2026-07-26 09:35:53 -04:00
.rubocop.yml feat(rube): M7 - a version floor is a measurement, not a preference 2026-07-29 15:04:21 -04:00
CHANGELOG.md feat(marshalsea): rename off a name taken since 2009, then build the release path 2026-07-29 15:29:25 -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): rename off a name taken since 2009, then build the release path 2026-07-29 15:29:25 -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): rename off a name taken since 2009, then build the release path 2026-07-29 15:29:25 -04:00
marshalsea.gemspec feat(marshalsea): rename off a name taken since 2009, then build the release path 2026-07-29 15:29:25 -04:00

README.md

marshalsea

A Ruby object-deserialization security lab.

A gadget chain is a Rube Goldberg machine. One untrusted blob goes in, a dozen unrelated standard-library methods knock each other over, and code execution falls out the far end. This project builds the machine, then builds the thing that stops it.

The Marshalsea was a London debtors' prison, in operation from 1373 to 1842. The name is the job: hold untrusted objects at the gate and decide what gets through, before Marshal.load turns bytes into behaviour.

Why this exists

Marshal.load on untrusted input is arbitrary code execution. So is YAML.unsafe_load, JSON.load with additions enabled, and Oj.load in its default mode. This is not a Ruby quirk. It is the same class of bug as Java deserialization, PHP POP chains, and Python pickle, and it sits at CWE-502 in the CISA Known Exploited Vulnerabilities catalog with a 34.8% known-ransomware rate against a 20.1% baseline across the catalog as a whole.

Most write-ups on this topic teach the exploit. Fewer teach why the obvious defense does not work. This one does both, because the second half is where the actual lesson lives:

You cannot make Marshal.load safe with an allowlist. The proc you pass runs in r_post_proc, which marshal.c invokes after load_funcall(... s_mload ...). By the time your allowlist sees the object, marshal_load has already run. The pattern widely copied off Stack Overflow is a post-mortem, not a veto.

Psych's allowlist genuinely is a veto — for exactly one reason. It checks the tag before revival, where Marshal checks the object after construction. Identical intent, opposite outcome, decided entirely by where the check sits.

Status

All six pieces are built and tested.

  • Marshal stream parser — parses the binary format, extracts referenced class names and gadget sinks, and validates structure, all without ever calling Marshal.load. Rejects truncated streams, unsupported versions, unknown tags, out-of-bounds object links and symlinks, oversized fixnum widths, trailing bytes, and excessive nesting.
  • Version-compatibility matrix — probes six pinned Ruby images and reports where the published git gadget and the ERB @_init guard actually change.
  • Reflection-based gadget scanner — walks ObjectSpace for auto-invoked methods and classifies them by whether Marshal.load can reach them. It 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.
  • Payload builder — version-scoped chains carrying their own affected ranges.
  • Vulnerable containerized target — a Sinatra app with one endpoint that loads a session cookie and one that inspects it first.
  • Boundary detector — the defensive layer, with an explicit written statement of what it cannot do.

Requirements

Ruby 3.4 or newer. That floor is measured, not picked for tidiness.

Ruby changed Marshal.load between 3.3 and 3.4. Through 3.3, any byte in a bignum's sign position is accepted and anything that is not - is read as positive. From 3.4 onward the same stream raises ArgumentError: invalid Bignum sign:

sign byte 3.2.11 3.3.12 3.4.10 4.0.6
+ and - accept accept accept accept
!, \x00, \xFF, 0 accept accept reject reject

marshalsea's 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 on four of those six bytes. A stream inspector that disagrees with the loader it guards is not worth shipping, so the floor sits where the agreement starts. just package re-proves this in both directions on every run: on the floor image Ruby and the parser agree, one version below it they diverge.

Installation

The first release has not been cut yet, so there is nothing on rubygems.org to install from. Build it from this checkout:

just build
gem install --local tmp/build/marshalsea-0.1.0.gem

Releases are published from CI by trusted publishing, so no long-lived API key exists to leak. gem install marshalsea starts working once the first tag ships.

The gem carries lib/, the README, the changelog, and the license. Nothing else. The vulnerable target, the adversarial corpus, the gate scripts, and the research notes stay in the repository, and just package fails if any of them turn up inside a built artifact.

Usage

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"]

Parser.new enforces Marshalsea::Marshal::Limits.new unless you say otherwise. Every ceiling is opt-out, never opt-in — pass limits: Marshalsea::Marshal::Limits.permissive if you are doing forensics on a stream you already trust and want it parsed whole.

To make a decision rather than inspect a stream, use the detector, which applies a policy and hands back a frozen snapshot:

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

A decision is in exactly one of three states, and proceed? is the only one that gates a load:

Marshal.load(decision.snapshot) if decision.proceed?

proceed? means the policy found no violation. blocked? means it found one and refused. observed? is the third state, and it exists because POLICY_OBSERVE_AND_LOG is non-blocking by design: a violation was found, reported, and deliberately not enforced. Such a decision still carries its snapshot, so a caller running in monitoring mode opts in by naming that state out loud:

Marshal.load(decision.snapshot) if decision.proceed? || decision.observed?

There is no accepted?. The question "did the policy permit this" and the question "is this stream free of violations" have different answers under observe-and-log, and one predicate cannot answer both.

Read Marshalsea::Marshal::BoundaryDetector::LIMITATION_NOTICE before relying on proceed?. A stream that proceeds is not a safe one, and the notice says so in detail.

Nothing above instantiates a class, calls a constructor, or invokes Marshal.load.

Development

Everything runs in Docker against a pinned Ruby.

just test       run the minitest suites
just control    run the negative controls
just check      both
just corpus     print every adversarial corpus case and its verdict
just scan       run the gadget scanner over loaded modules
just matrix     probe six pinned Ruby images and render the compatibility matrix
just exploit    prove the chain fires on a vulnerable image and is blocked on a patched one
just target     stand up the vulnerable app and attack it over HTTP
just detector   prove the defensive layer rejects the payload the target executes
just package    build the gem, audit what shipped, install it, prove the version floor
just gate       everything above, in order
just build      build the gem with --strict into tmp/build
just manifest   list exactly what would ship in the .gem

just package also audits an artifact you already have, which is how you check that a gem on disk still matches the source it claims to be built from:

just package tmp/build/marshalsea-0.1.0.gem

Releasing

Bump Marshalsea::VERSION, then push a tag:

git tag marshalsea-v0.1.0
git push origin marshalsea-v0.1.0

That is the whole release. CI runs the suites and the standalone controls on Ruby 3.4 and 4.0, refuses to continue if the tag disagrees with Marshalsea::VERSION or if the gemspec floor no longer matches the tested matrix, and then publishes through RubyGems trusted publishing. There is no API key anywhere in this repository, and none to rotate or leak: the job proves its identity to rubygems.org with a short-lived OIDC token issued by GitHub for that specific workflow.

The tag is prefixed because sixty projects share this repository and a bare v0.1.0 would not say which one it belongs to. rake -T still prints Create tag v0.1.0 because that description is built before the prefix is applied; the tag actually created is marshalsea-v0.1.0, and just package asserts the real value rather than the printed one.

Each release also publishes a Sigstore attestation recording which workflow built the artifact and from which commit. Treat it as an auditable record, not as protection: neither gem install nor bundle install verifies attestations today.

Ruby's Marshal format documentation states that object links are one-indexed. They are zero-indexed. A self-referential array dumps as 04 08 5b 06 40 00, where the trailing 00 is a link to the outermost object at index 0. The parser is written against the observed bytes, not the documentation.

License

AGPL-3.0-or-later. See LICENSE.