Cybersecurity-Projects/PROJECTS/beginner/deserialization-gadget-lab
CarterPerez-dev 582e032cc0 fix(rube): clear the entire S1 backlog tier - depth, budgets, gates, fidelity
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.
2026-07-29 05:12:25 -04:00
..
lib fix(rube): clear the entire S1 backlog tier - depth, budgets, gates, fidelity 2026-07-29 05:12:25 -04:00
scripts fix(rube): clear the entire S1 backlog tier - depth, budgets, gates, fidelity 2026-07-29 05:12:25 -04:00
target fix(rube): clear the entire S1 backlog tier - depth, budgets, gates, fidelity 2026-07-29 05:12:25 -04:00
test fix(rube): clear the entire S1 backlog tier - depth, budgets, gates, fidelity 2026-07-29 05:12: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): M1 Marshal stream parser - inspect payloads without deserializing 2026-07-26 09:32:14 -04:00
CHANGELOG.md feat(rube): M1 Marshal stream parser - inspect payloads without deserializing 2026-07-26 09:32:14 -04:00
Gemfile feat(rube): M1 Marshal stream parser - inspect payloads without deserializing 2026-07-26 09:32:14 -04:00
LICENSE feat(rube): M1 Marshal stream parser - inspect payloads without deserializing 2026-07-26 09:32:14 -04:00
README.md feat(rube): M1 Marshal stream parser - inspect payloads without deserializing 2026-07-26 09:32:14 -04:00
Rakefile fix(rube): clear the entire S1 backlog tier - depth, budgets, gates, fidelity 2026-07-29 05:12:25 -04:00
justfile fix(rube): B1/B2 - close both live detector bypasses, auxiliary owns traversal 2026-07-28 23:20:40 -04:00
rube.gemspec feat(rube): M1 Marshal stream parser - inspect payloads without deserializing 2026-07-26 09:32:14 -04:00

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

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.