Two independent audits of the same tree, one executed and one static. Both were
worth running: the static pass found seven real defects the executed pass missed,
including the worst one here, and the executed pass found three the static pass
could not see because seeing them required running Ruby.
The release workflow could publish from any branch. The publish job carried no ref
condition and its tag check read `[ tag != expected ] && [ event = push ]`, so on a
workflow_dispatch the second clause was false, the && never fired, and control fell
straight through to rubygems/release-gem. Anyone with the Actions tab could ship a
mutable branch checkout to rubygems.org. The job is now gated on a pushed
refs/tags/marshalsea-v* ref, the version check is unconditional, and the manifest is
audited before the push step with a negative control proving a drifted lib file
turns it red.
Three payloads ran attacker code while the detector reported proceed. A
String-subclass hash key reaching a user #eql?, measured {eql: 1}; a gadget nested
in a bare Array key, measured {hash: 1}, 22 bytes hand-built; and a Range whose
endpoints dispatch #<=>, measured {cmp: 1}. All three were accepted under
deny_sinks_only and under strict with the class allowlisted, which is the documented
normal usage. They are blocked under every policy now.
The oracle that missed them dumped `{ key => nil }`. One key means no bucket
collision, so #eql? could never fire in the probe no matter how many key shapes were
added. Blind by construction, the exact defect class this project already had a rule
about. The corpus went further and asserted the String-subclass case was a precision
control, a positive claim that rejecting it would be a false positive. It is a
reject now, and the key rules are re-derived from research 02 section 4.2 rather than
grown case by case.
Range endpoints marshal as bare `begin`/`end`, not `@begin`/`@end`, because Range
uses a marshal compat dumper. The first constant was wrong and the test caught it.
The scanner's reachability filter contradicted its own thesis. Requiring zero arity
for ungated entry points excluded eql?, ==, <=>, []=, method_missing and
respond_to_missing? entirely: 85 candidates across those rows, 0 reachable. Only
hash and to_s survived, and research 02 section 4.2 verified to_s is never an entry
point, so 11 of 18 reachable results were a method Marshal.load does not invoke.
Entry points are now a table carrying gate, format and the arity the deserializer
supplies; links are a third gate value and are reported separately instead of
scored as entry points. Gated hooks are arity-checked too, so an arity-0
marshal_load that would raise ArgumentError is no longer called reachable.
Marshal.load reaches a private self._load through rb_funcallv, which ignores
visibility, while singleton_methods(false) does not report it. Adding
singleton_class.private_instance_methods immediately found Time._load on a stock
image, a real stdlib sink the scanner had never seen. Prism is error tolerant and
parse_definitions consumed .value without checking failure?, so a tree recovered
from four syntax errors produced a confident touches_state verdict; it is a
suppression now.
The parser accepted ivar-name and struct-member-name slots holding a fixnum, a
string or an array, the detector said proceed, and CRuby then raised
ArgumentError, so the defended route answered 500. The parser stays forensic on
purpose, because a sink hidden where a symbol belongs must stay visible, so the
anomaly is labelled on Result and the detector rejects on it. The target also
rescues the loader rather than trusting inspection.
Three things the contract promised and did not have.
The headline payload was not a chain. It built an ERB object past the @_init guard
and then both demonstrations called def_method themselves, so the canary was not a
consequence of Marshal.load. Research 04 line 292 and 05 line 982 already said the
real chain reaches def_module through
ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy, and 05 line 1143 listed
reproducing it as open. erb-def-module does that: the proxy sits in hash-key
position, ungated #hash dispatch lands in method_missing, target calls
@instance.__send__(:def_module), and ERB compiles the payload inside Marshal.load
with no application call. The old builder stays as erb-def-method and is labelled a
primitive. Three things only execution showed: the proxy undefines
instance_variable_set so setup has to go through a bound Object method; a real
deprecator holds a Proc and cannot be dumped, so the chain hand-builds one with
@silenced true and warn short-circuits before touching @behavior; and building
{proxy => 1} fires the payload in the builder's own process, so serialize splices a
key-position stream from a standalone dump and refuses any graph carrying an object
link, whose index would shift behind the hash node.
LoadGuard is the M6 runtime guard. A TracePoint on :call fires before a method body
runs, which is the veto a Marshal.load proc cannot give you. It watches the gated
hooks plus method_missing and respond_to_missing?, because a hook list without those
two is evaded by a respond_to_missing? proxy. hash and eql? are opt-in behind
strict:, since they are among the hottest methods in Ruby and BoundaryDetector
already catches key-position dispatch before any bytes load. It raises a
StandardError, never a SecurityError that would skip every rescue in the stack.
Its cost is not 1.4x. That figure is a property of the payload that was measured,
not of the guard. Enabling a TracePoint costs a near-constant ~46 microseconds per
load, so the ratio is decided by how much work the load does: 185.9x on 3 bytes,
40.4x on a 45-byte session cookie, 1.1x on 46 KB, 1.0x on 488 KB. The lab's own use
case is a session cookie, which is the worst case. A dated correction is written
back into research 03.
The Psych half exists now. Psych::Inspector reads a document through parse_stream,
revives nothing, and reports every !ruby/* tag with the method it would dispatch,
bounded on bytes, depth, nodes, aliases and documents. psych-init-with is the
matching chain. The target grows /yaml/unsafe and /yaml/safe so the spine of this
project is executable over HTTP: the same ERB object reaches code execution through
YAML.unsafe_load, and YAML.safe_load refuses it by checking the tag before revival.
The gate proves both layers independently, including a document the inspector
approves that Psych still refuses, so neither can alibi the other.
The target ran attacker Ruby on Docker's default bridge with outbound access and
installed sinatra, rackup and webrick unversioned. It now runs on an internal
network with cap-drop ALL, no-new-privileges, pid and memory ceilings and pinned
versions, with a control proving it cannot reach off the host. Creating that network
also proved --internal blocks the published port, so the gate drives the target from
a second container on the same network instead. The HTTP gate asserted body prefixes
and never captured status; it asserts exact status and body per endpoint now, which
immediately caught a bug in this very change where a nil sentinel conflated "the
loader refused" with the legitimate value nil.
Smaller: Chains.all filtered out a Base that was never registered, so the filter was
inert and its test vacuous; chains are discovered by directory glob now, per the
design's no-registry-to-rot contract. AFFECTED was shallow frozen, and mutating
metadata[:affected][2] flipped affects?("5.0.0") from true to false. Limits.permissive
keeps a depth cap on purpose and now says so, because lifting it trades a rescuable
DepthLimitError for an uncatchable SystemStackError. The README claimed a fixnum
width rejection its own test proves is unreachable. Regexp options were discarded
while the node still reported fully_decoded?.
The README is rebuilt to the repository's shape, and Deserialization Gadget Lab
takes project 41 in the root table, replacing Ghost on the Wire. CHANGELOG.md is
dropped from the gem manifest, the metadata and the packaging gate.
Full gate: 78 PASS, 0 FAIL across six stages, up from 58. 267 tests across seven
suites, from 194. Lint 0 across 37 files. Every rule added here ships with the
mutant that kills it.
rube has been on rubygems.org since 2009-08-05: Richard LeBer, 12,305 downloads,
and it is an ERB front-end, which is funny given the flagship CVE here is an ERB
gadget. The name was never publishable, so publishing required a rename first.
marshalsea. The Marshalsea was a London debtors' prison, 1373 to 1842, and the
name is the job description: hold untrusted objects at the gate and decide what
gets through before Marshal.load turns bytes into behaviour. It also carries
"Marshal", so the gem reads as on-topic without a subtitle.
module Rube is module Marshalsea, lib/rube/ is lib/marshalsea/, require "rube" is
require "marshalsea", RUBE_TARGET_PORT is MARSHALSEA_TARGET_PORT, and the canary
moved to /tmp/marshalsea-canary. 31 files, roughly 163 occurrences, every one a
hand edit. The single deliberate survivor is the README's "a Rube Goldberg
machine", which describes the gadget chain and not the gem.
Publishing is trusted publishing over OIDC, so no long-lived API key exists in
this repository to leak. A marshalsea-v* tag runs the five suites and the
standalone controls on Ruby 3.4 and 4.0, refuses to continue if the tag disagrees
with Marshalsea::VERSION or if the gemspec floor stops matching the tested
matrix, and then publishes with a Sigstore attestation. The attestation is
recorded as an auditable record and explicitly NOT as an install-time protection,
because neither gem install nor bundle install verifies one today.
Two things the primary source settled that the docs did not. rubygems/release-gem
does accept working-directory, which neither its README nor the RubyGems guide
mentions, so a monorepo subdirectory works. And it runs bundle exec rake release,
which this Rakefile had no task for at all.
Adding bundler/gem_tasks exposed a monorepo trap: Bundler::GemHelper tags a bare
v0.1.0, which says nothing about which of sixty projects it belongs to. Fixed
with tag_prefix. The catch is that rake -T still PRINTS "Create tag v0.1.0",
because that description is built when gem_tasks is required and the prefix is
assigned after. The tag actually created is marshalsea-v0.1.0. The label is
wrong and the behaviour is right, so the gate asserts the runtime value and
carries a control proving a Rakefile without the prefix line really does produce
the bare tag.
Full gate: 58 PASS, 0 FAIL across six stages, package now 25 of 25. 194 tests.
Lint 0 across 30 files.
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.
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.