B17 through B30, fourteen items. Every one reproduced before it was touched and mutation-proven after. 194 tests from 119, rubocop 903 offenses to 0, all six gate stages green. One rule runs through all of it: nothing may present a guess, a default, or a swallowed error as a verdict. Decision states (B19). Under POLICY_OBSERVE_AND_LOG a payload carrying a live Gem::Requirement#marshal_load snapshot reported accepted? true AND rejected? false, so both obvious caller shapes loaded it. The predicate pair could not express the third outcome, so there was no safe branch to pick - the prior note claiming target/app.rb sidestepped this by branching on rejected? was wrong, and both forms were byte-for-byte equivalent in outcome. accepted?, rejected? and would_reject? are removed rather than redefined, so copying `if d.accepted?` now raises NoMethodError instead of silently changing meaning. One state validated in the constructor, three exclusive predicates, and proceed? is the only one that may gate a Marshal.load. Observe-and-log stays non-blocking and still hands back its snapshot; the monitoring caller writes `proceed? || observed?` and names the state out loud. Scanner error accounting (B18). Five rescues returned nil, [] or false and told nobody. They now record site, subject and error class, and Report exposes suppressed_count, suppressions_by_site, complete? and candidates_lost?. Wiring the counter immediately surfaced 3 suppressions on a stock image that had always been invisible: <internal:symbol>, <internal:pathname_builtin> and <internal:ractor> all fail Prism.parse_file with ENOENT because Ruby hands out those paths but they are not files. The fourth state (B29). Those 3 suppressions were also 7 wrong answers - candidates scored "does not touch state", indistinguishable from analysed and inert, and 4 zero-arity ungated ones silently dropped from reachable. touches_state is now four-state. A source that was given and could not be parsed fails OPEN and stays reachable, because a scanner that discards what it failed to analyse is the exact failure mode B18 names. A C-defined method with no Ruby source at all is reported as unanalysable instead: 132 of 173 candidates, and failing open there would take reachable from 25 to 74 of 165 ungated and stop the filter filtering. Report#unanalysable and #fully_analysed? state the real coverage - 33 of 173 - rather than implying the filter saw everything. The 5 recovered candidates were verified by executing them, not by reading source we could not read; one of them, ERB::Compiler::PercentLine#to_s, is an alias of an attr_reader, which is a second and distinct analysis gap. Reason escaping (B20). Reject reasons interpolated raw attacker bytes into a caller-supplied reporter. A class name carrying CR, LF, ESC and NUL turned one reporter call into three log lines, the middle one forging a successful authentication. All three interpolation sites now truncate at 96 bytes and inspect the binary form, so no byte below 0x20 survives and the value is quote-delimited. Target hardening (B21, B22). The defended endpoint returned HTTP 500 with a source line for three roots the detector had just accepted, leaking paths the same way the B3 mutant did. show_exceptions is off, the shape is checked, and the gate now greps every error body for source paths. ALLOWED_CLASSES could never match anything, measured: a benign session cookie carries zero class names. It is PERMITTED_CLASS_NAMES = [] now, which is what the app actually requires and is strictly tighter - the old list admitted a C-wrapped String, and under a mutant restoring it only the new shape check stopped that payload. Gated agreement (B23). GATED_SINK_TAGS omitted TAG_DATA while the scanner listed _load_data. The scanner was right, and this is now execution evidence rather than a reading of marshal.c: a hand-built d stream naming Thread::Mutex, a real C-level T_DATA, raises TypeError naming the missing _load_data, while the same stream naming String dies earlier at "dump format error" - which is why the previous attempt could not see it. A test compares both definitions directly so they cannot drift again. Float fidelity (B17). read_float returned nil for seven body forms Marshal.load accepts, two more than the finding listed. Ruby uses its own ruby_strtod, so "INF" is 0.0 while "inf" is Infinity, and String#to_f turns out to be that same function. Ruby's legacy binary mantissa is NOT decoded: a model fitted to four oracle points passed a 25-case table and then failed 930 of 5000 randomised cases, and marshal.c is not available in these images. Since 0 of 209 Marshal.dump outputs contain a NUL, no living Ruby emits that form, so the parser records the strtod prefix and flags Node#undecoded_tail instead. A plausible wrong number is worse than a labelled non-answer. Final differential: 2919 agreed exactly, 2081 flagged, 0 claimed-and-wrong. Hygiene (B24, B25, B27, B28). PRIMITIVE_CLASS_NAMES and NAMESPACE_SEPARATOR had one reference each, their own definition. BIGNUM_SIGN_POSITIVE is live and stays. The width > FIXNUM_MAX_WIDTH guard is unreachable for all 256 possible marker bytes, checked exhaustively, and raised the wrong error class; it is replaced by a test that derives widths from real Marshal.dump output. The symlink and object-link bounds checks no longer lean on negative-index wraparound. The parse graph is sealed before it is returned - every node, its collections and its scalars frozen - and the whole suite stayed green first try, which proves nothing downstream was mutating it. exploit-gate.sh pins both sides now, 4.0.2-slim erb 6.0.1 FIRED against 4.0.6-slim erb 6.0.1.1 BLOCKED; the finding's claim about detector-gate.sh was wrong, it never had a patched side. Lint (B30). just lint used a --network none runner, so gem install could never reach RubyGems, the && short-circuited, and the recipe exited 2 while printing absolutely nothing. That is the fourth instance of a dropped return value hiding a failure in this project. It is loud now. The config had never been validated against a real run: 903 offenses, dominated by a quote style the codebase does not use. frozen_string_literal was verified safe by running the whole suite under RUBYOPT=--enable-frozen-string-literal BEFORE the change, so ~247 offenses were retired by fixing code rather than silencing a cop. Every remaining disabled cop carries a reason. |
||
|---|---|---|
| .. | ||
| 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
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
@_initguard actually change. - Reflection-based gadget scanner — walks
ObjectSpacefor auto-invoked methods and classifies them by whetherMarshal.loadcan 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
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:
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:
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:
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.