Walks the live class graph and classifies auto-invoked methods along the
gated/ungated dispatch axis. Marshal calls respond_to? before invoking
marshal_load and _load, while hash, eql?, <=>, []= and to_s are dispatched
blind, so a class can be dead as a Marshal entry point and live as a #hash
entry point.
Rediscovers Gem::Requirement#marshal_load, Gem::Version#marshal_load and
Gem::Specification._load with source locations, from reflection alone, with no
class names hardcoded anywhere in the scanner.
A new control asserts the M1 parser and the M3 scanner agree. The parser reads
bytes off a payload, the scanner walks the class graph, neither consults the
other, and every sink the parser finds in a real Gem::Requirement payload is
one the scanner independently located. Two routes to the same fact.
Raw output was unusable at 163 ungated candidates dominated by to_s and hash,
which nearly every class defines. The reachability predicate from the research
narrows that to methods that are zero-arity AND touch instance state, cutting
163 to 12 and keeping 7.4 percent. Gated sinks are always reachable.
Two corrections found while building the predicate:
RubyVM::AbstractSyntaxTree.of does not work on Ruby 4.0. It raises
'cannot get AST for ISEQ compiled by prism' because prism is now the default
parser. Any tool reaching for that API on modern Ruby is broken. Replaced with
Prism, which ships as a default gem.
The literal reads-its-own-ivars predicate is too narrow. Gem::Requirement#hash
calls the requirements attr_reader rather than @requirements, so an instance
variable check reports false on a method that plainly operates on instance
state. Widened to instance variable reads OR implicit-self calls.
55 tests, 112 assertions, 0 failures across both suites.
Probes six pinned Ruby images with no network and renders a compatibility
matrix for the deserialization gadget surface. Reproducible with just matrix.
The matrix carries three controls, because a table that reports one value
everywhere cannot be distinguished from a probe that always returns the same
answer. Two axes must show more than one state, and the ERB guard column is
cross-checked against the published CVE-2026-41316 affected ranges. That third
control is the load-bearing one: the probe reads source and knows nothing about
NVD, and it agrees with the advisory on 6 of 6 images.
Findings recorded in the research docs:
The gadget surface moved rather than shrank. Net::WriteAdapter is reachable at
baseline on Ruby 3.1 and 3.2 and gone from 3.3 onward, which is why vakzz-era
chains needed no preloaded net/http on old Ruby. Gem::URI appears in the same
release that took it away, and Gem::URI reached through an autoloaded
Gem::SpecFetcher is exactly the bootstrap the 2024 chain relies on. One door
closed and another opened in the same version, so a defense reasoning about
the known gadget classes is reasoning about a moving target.
The ERB @_init guard sits only on def_method. def_module and def_class delegate
to it in both vulnerable and patched releases, so one check covers all three.
The first probe measured all three independently and reported the delegates as
unguarded even on patched erb 6.0.1.1. That was the probe being wrong, not the
patch being incomplete, and it is corrected here.
Marshal stream format is 4.8 on every image, so the M1 parser applies across
the whole range unchanged. make and git are absent from every slim image, so
rake is the only exec binary present on that family.
Scaffolds the Ruby deserialization security lab and lands its defensive core
first: a parser that extracts structure, referenced class names, and gadget
sinks from a Marshal stream without ever calling Marshal.load.
Sinks are classified along the gated/ungated dispatch axis. Marshal checks
respond_to? before invoking marshal_load and _load, while hash, eql?, <=> and
[]= are dispatched blind, so the same class can be dead as a Marshal entry
point and live as a #hash entry point.
Object links are ZERO-indexed. Ruby's Marshal format documentation says
one-indexed and is wrong: a self-referential array dumps as 04 08 5b 06 40 00
with the trailing 00 linking to the outermost object. Written against observed
bytes rather than the docs.
Validation rejects truncated streams, unsupported version bytes, unknown type
tags, out-of-bounds object links and symlinks, oversized fixnum widths,
trailing bytes, and nesting past a configurable depth limit.
A negative-control script accompanies the suite and caught a test that was
passing vacuously: the TracePoint oracle watched :c_call, but Marshal.load is
a Ruby-level method in Ruby 4.0 (<internal:marshal>:33) and fires :call, so
the test could never have failed. The suite now asserts the oracle observes a
real Marshal.load before the negative assertion is allowed to mean anything.
Gem manifest is an explicit allowlist rather than git ls-files, so the
deliberately vulnerable target cannot be swept into a published gem later.
34 tests, 62 assertions, 0 failures. 52/52 corpus round-trip. gem build
--strict clean. All execution in ruby:4.0-slim with --network none.