Walks the live class graph and classifies auto-invoked methods along the gated/ungated dispatch axis. Marshal calls respond_to? before invoking marshal_load and _load, while hash, eql?, <=>, []= and to_s are dispatched blind, so a class can be dead as a Marshal entry point and live as a #hash entry point. Rediscovers Gem::Requirement#marshal_load, Gem::Version#marshal_load and Gem::Specification._load with source locations, from reflection alone, with no class names hardcoded anywhere in the scanner. A new control asserts the M1 parser and the M3 scanner agree. The parser reads bytes off a payload, the scanner walks the class graph, neither consults the other, and every sink the parser finds in a real Gem::Requirement payload is one the scanner independently located. Two routes to the same fact. Raw output was unusable at 163 ungated candidates dominated by to_s and hash, which nearly every class defines. The reachability predicate from the research narrows that to methods that are zero-arity AND touch instance state, cutting 163 to 12 and keeping 7.4 percent. Gated sinks are always reachable. Two corrections found while building the predicate: RubyVM::AbstractSyntaxTree.of does not work on Ruby 4.0. It raises 'cannot get AST for ISEQ compiled by prism' because prism is now the default parser. Any tool reaching for that API on modern Ruby is broken. Replaced with Prism, which ships as a default gem. The literal reads-its-own-ivars predicate is too narrow. Gem::Requirement#hash calls the requirements attr_reader rather than @requirements, so an instance variable check reports false on a method that plainly operates on instance state. Widened to instance variable reads OR implicit-self calls. 55 tests, 112 assertions, 0 failures across both suites. |
||
|---|---|---|
| .. | ||
| lib | ||
| scripts | ||
| 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
Under construction. What exists and is 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.
Planned: version-compatibility matrix, reflection-based gadget scanner, payload builder, a deliberately vulnerable containerized target, and the defensive layer.
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"]
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 parser suite
just control run the negative controls
just check both
just build build the gem with --strict
just manifest list exactly what would ship in the .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.