Commit Graph

3 Commits

Author SHA1 Message Date
CarterPerez-dev 43ea87dffc fix(rube): B1/B2 - close both live detector bypasses, auxiliary owns traversal
Duplicate ivar names deleted subtrees and five of six readers threw away the
class-name node, so a gadget in either position was invisible to the detector
while Marshal.load still fired it. Both had working proofs; the suite was green
the whole time.

The parse graph now has exactly one traversal owner. read_class_name and
read_instance_variables push every class-name node, ivar name and ivar value
into auxiliary, and Node#each no longer walks instance_variables_map. The map
stays as a lookup convenience with last-write-wins semantics, it just is not
load-bearing for security any more. Walking both would have double-counted
every ivar value.

Corpus entries take an optional allowlist. Without one every case ran through
an empty strict allowlist where any class name rejects, which is why the corpus
could not express B2 at all. The 40 existing cases default to [] and are
unchanged.

At the tag level there are seven class-name slots, not six: o S u U d C e, and
only o retained its node. u, U and d are themselves sink tags so a corpus case
there can never fail; those three are asserted at the parser level instead and
a test pins the exclusion as deliberate rather than an oversight.

Verified by mutation, since green means nothing on this project. Dropping the
class-name push, dropping the ivar value push, and restoring the map walk each
now fail 2, 4 and 3 tests. The first two previously survived the entire suite.

119 tests from 110, 48 corpus cases from 40. test, control, exploit, detector,
target and matrix all pass.
2026-07-28 23:20:40 -04:00
CarterPerez-dev 66473a6007 fix(rube): reject negative counts and stop discarding name nodes
Codex, working the defensive half without having written the parser, probed M1
with adversarial input and found defects my own tests missed. Verified
independently before fixing, and two more were found while confirming:

  negative_array_count   ACCEPTED as an empty array
  negative_hash_count    ACCEPTED as an empty hash
  negative_bignum_words  NoMethodError leaked outside StreamError
  negative_ivar_count    ACCEPTED
  negative_string_len    cursor moved BACKWARD, wrong error raised

The last is the worst. take(-5) does not trip the count > remaining guard, so
byteslice returns nil and @position decreases. A parser whose cursor can rewind
on attacker input is a loop primitive, not merely a wrong error.

The M1 gate claimed bignum length confusion was covered. It was not. Oversized
widths were tested and negative counts never were, because the same author
chose both the implementation and the cases it would face. That is the
negative-control failure one level up, and it is exactly what an author cannot
catch alone.

Fixes: every count and length now flows through read_count with a role label
and a nonnegative check, take rejects negative byte counts outright, and
MalformedCountError joins the StreamError hierarchy so nothing leaks a raw
NoMethodError. Negative link indices were already guarded; regression tests now
pin that.

Also closes a detection blind spot Codex identified. read_ivar and read_object
discarded the parsed name nodes after taking their values, so a sink tag placed
in an instance-variable-name position vanished from Result#sinks. Node now
carries an auxiliary collection that Node#each traverses, and name and class
nodes are retained. Proven: a stream with a userdef tag in the name position
now reports Evil#_load where it previously reported nothing.

46 parser tests, 80 assertions. All controls pass, exploit gate still passes.
2026-07-26 11:02:58 -04:00
CarterPerez-dev f5196252e9 feat(rube): M1 Marshal stream parser - inspect payloads without deserializing
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.
2026-07-26 09:32:14 -04:00