Every item contracted to clear before M7 is closed. 151 tests from 119, 58 corpus cases from 48, all six gates green. Depth accounting (B3, B4). TAG_IVAR charged no depth at all, so an I-chain of any length parsed under any ceiling. Proven end to end against a rebuilt target image: a 12,936-byte cookie returned HTTP 500 with a SystemStackError that no rescue StreamError can catch, and a 724,287-byte response body leaking absolute container paths for every file in lib/. Fixed, the same cookie returns 400 DepthLimitError, and so does a 53,340-byte one. read_userdef also hard-coded a depth of 1 for its class-name slot. Budget axes (B12, B13, B14). Bignum magnitude bypassed the scalar budget entirely and the sign byte accepted anything as positive where Marshal.load raises ArgumentError. Added max_symbol_references, max_symbol_name_bytes, max_class_name_bytes, max_instance_variables and max_struct_members. Parser.new now enforces Limits.new instead of resolving to an unbounded config; Limits.permissive became a class method. Hash-key dispatch (B11). Nothing rejected an allowlisted class used as a hash KEY, where #hash and #eql? run during load before any allowlist can act. Measured against real Marshal.load: a key dispatches iff it carries a class name and its underlying value is not a T_STRING. So TAG_REGEXP is not a key-position risk and TAG_USERCLASS only conditionally - rejecting either outright would have been a false positive. No opt-out allowlist was added, because the dispatch happens before any check could run. Fidelity (B15, B16, B9). The parser already matched Marshal.load on header versions, so the 4.8 contradiction was resolved by giving the detector the policy check and leaving the forensic parser permissive. Class-name slots now accept only a symbol, an ivar-wrapped symbol, or a symlink. Wrapper tags C and e no longer take an object-table slot, which Ruby does not give them - link index 3 resolved to "bbb" for us and "ccc" for Ruby. Gate soundness (B6, B7). Three discarded check() return values now register as failures; section 6 no longer reports a vacuous 0/0; section 7 requires reachable > 0, and prism absence is a named failure rather than a silent zero. version-matrix.sh exits non-zero when any image produces no probe result. control_check.rb no longer pulls in minitest, which was printing "0 runs, 0 assertions" directly under ALL CONTROLS PASSED. Rewrote the vacuous tests: the regexp options byte had zero minitest coverage and its mutant survived the whole suite, and read_count's negative guard was alibied by take's own guard. The target app (B5) lost its hand-rolled copy of the sink-plus-allowlist policy and now runs one BoundaryDetector with real limits, branching on rejected? rather than accepted?. Everything here is mutation-proven. Notable misses that mutation caught: B14 had no test at all until reverting it stayed green, and a struct-member test was vacuous on the first attempt because struct member names are always symbols. lib/rube/marshal/parser.rb carries eight backlog items at once and cannot be split without interactive hunk staging, so this is one commit rather than eight. |
||
|---|---|---|
| .. | ||
| 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
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.