Cybersecurity-Projects/PROJECTS/beginner/deserialization-gadget-lab
CarterPerez-dev 21ecc0eece feat(rube): M5 vulnerable target - end-to-end RCE over HTTP, with the defense beside it
Sinatra on webrick in a container, storing session state as a base64 Marshal
blob in a cookie. Three endpoints: /render deserializes and compiles the
session template, /render/safe inspects the stream first, /canary reports
execution. Runs read-only, unprivileged, with a 1MB noexec tmpfs, on a high
configurable host port.

Gate proves three things and the third is what stops the defense being a brick:

  PASS  HTTP request achieved code execution through Marshal.load
  PASS  defended endpoint rejected the identical payload
  PASS  defended endpoint still serves a legitimate session

Two findings that change the defensive design.

Sink tags do not catch this chain. The working payload produces ZERO sink-tag
hits. ERB defines no marshal_load, so it serializes as a plain object with
instance variables and carries no u, U or d tag. The defended endpoint rejected
it on the class allowlist, and an application that allowlisted ERB as a
legitimate template class would have passed it through untouched. A gadget does
not need a marshal_load hook, it needs an object whose ivars the application
later feeds to a dangerous method. The dangerous call site lives in the
application, not in the serialized class. Any policy treating absence of sink
tags as safe is defeated by this exact public payload.

A legitimately initialized ERB cannot be serialized at all. @_init holds
self.class.singleton_class and Marshal raises TypeError: singleton class can't
be dumped. So the guard is not a flag an attacker might satisfy, it is anchored
to a value the serializer physically cannot reproduce. Any ERB an attacker can
serialize necessarily lacks a valid @_init. The generalized pattern for learn/:
do not validate the untrusted object, anchor trust to something unreachable
through the channel.

Also fixes a gate that skipped a control silently. The benign-session check
produced no output because POST with no body returns WEBrick LengthRequired,
and the script treated an empty result as nothing to test rather than as a
failure. It now fails loudly.

70 tests, 151 assertions, 0 failures. Target app excluded from the gem
manifest, verified at 0 files.
2026-07-26 10:17:23 -04:00
..
lib fix(rube): load erb at require time, not inside generate 2026-07-26 10:09:11 -04:00
scripts feat(rube): M5 vulnerable target - end-to-end RCE over HTTP, with the defense beside it 2026-07-26 10:17:23 -04:00
target feat(rube): M5 vulnerable target - end-to-end RCE over HTTP, with the defense beside it 2026-07-26 10:17:23 -04:00
test feat(rube): M4 payload builder - working CVE-2026-41316 chain with version-predicted gate 2026-07-26 10:07:16 -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 feat(rube): M1 Marshal stream parser - inspect payloads without deserializing 2026-07-26 09:32:14 -04:00
justfile feat(rube): M5 vulnerable target - end-to-end RCE over HTTP, with the defense beside it 2026-07-26 10:17:23 -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.