# 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 `@_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. ## Usage ```ruby 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: ```ruby 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: ```ruby 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: ```ruby 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 gate everything above, in order 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](LICENSE).