required_ruby_version claimed ">= 3.3" while every gate stage ran on Ruby 4.0 images only. The claim was false. Marshal.load did not validate the bignum sign byte until 3.4, so on 3.3 real Ruby accepts "!", "\x00", "\xFF" and "0" in the sign position and reads them all as positive, where 3.4 and 4.0 raise ArgumentError. The parser accepts "+" and "-" only, so it models 3.4+, and on 3.3 parser_test.rb goes red at its own liveness guard: the differential oracle finds nothing rejected and says so instead of passing vacuously. 3.4.10 runs all five suites green at the same counts as 4.0 and prints ALL CONTROLS PASSED. That makes 3.4 the oldest release actually proven, so the floor is ">= 3.4". TargetRubyVersion moves with it, since those two must stay equal. Teaching the parser two Marshal models to keep 3.3 was rejected. It buys a branch in security maintenance only, and it pays with a second sign-validation path in the one component whose whole job is modelling Marshal.load correctly. The untracked rube-0.1.0.gem sitting in the repo root turned out to be built from pre-B17 source: 12 lib files instead of 13, no float_body.rb, read_float still using Float() with a bare rescue, no frozen_string_literal lines, declaring ">= 3.3". It installed and required without error, so nothing caught it. Two artifacts with the same name and version and no way to tell them apart. Deleted. package-gate.sh therefore asserts every shipped lib file is byte-identical to the worktree rather than merely present, builds from the declared manifest alone so an omitted file cannot produce a gem that builds anyway, installs the artifact on the floor and current images and exercises it from the installed copy, and re-proves the floor in both directions each run. Three negative controls: a gem shipping the vulnerable target must be rejected, a gem with a drifted lib file must be rejected, and RubyGems must refuse to install below the declared floor. Aimed at the stale artifact it fails 6 of 23; on a fresh build it passes 23 of 23. Both executed. just build now writes to tmp/build as the invoking user instead of leaving a root-owned gem in the tree, and just package audits an artifact you already have. Full gate: 56 PASS, 0 FAIL across six stages. 194 tests. Lint 0 across 30 files. |
||
|---|---|---|
| .. | ||
| lib | ||
| scripts | ||
| target | ||
| test | ||
| .gitignore | ||
| .rubocop.yml | ||
| CHANGELOG.md | ||
| Gemfile | ||
| LICENSE | ||
| README.md | ||
| Rakefile | ||
| justfile | ||
| rube.gemspec | ||
README.md
rube
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.
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
@_initguard actually change. - Reflection-based gadget scanner — walks
ObjectSpacefor auto-invoked methods and classifies them by whetherMarshal.loadcan 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 |
rube'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
rube is not published to rubygems.org. Build it from this checkout and install the artifact:
just build
gem install --local tmp/build/rube-0.1.0.gem
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 "rube"
payload = Marshal.dump(Gem::Requirement.new(">= 0"))
result = Rube::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 Rube::Marshal::Limits.new unless you say otherwise. Every ceiling
is opt-out, never opt-in — pass limits: Rube::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 = Rube::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 Rube::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/rube-0.1.0.gem
A note on the object-link index
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.