test(rube): adversarial corpus, 40 cases spanning both verdicts

Forty explicit Marshal streams built from tag bytes rather than Marshal.dump,
so the corpus stays valid when Ruby changes what it emits. Thirteen must be
accepted, twenty-seven must be rejected across eight distinct error types.

The accept half is what gives the reject half meaning. A detector that
rejected everything would fail thirteen cases here, including legitimate
cycles, shared object references, symlink reuse, and fixnums at every encoding
width.

Provenance note, stated because it weakens the evidence: Codex was asked to
build this corpus and the request was blocked by its provider's content filter,
which reads constructing hostile streams as offensive tooling. The request was
not reworded to get around that. The corpus was therefore written by the same
model that wrote the detector, which is exactly the arrangement the split
exists to avoid, and it should be treated as weaker than the negative-count
findings Codex produced independently.

The boundary of what Codex can contribute here is now mapped. Defensive
research, packaging research, defensive design and acceptance criteria, and
probing the parser during a design task all succeeded. Researching gadget
chains and building a hostile corpus were both refused.

Also fixes a bug in the corpus fixnum encoder, which computed the width marker
as width plus 256 and raised RangeError for any positive multibyte value.

110 tests, 244 assertions across five suites.
This commit is contained in:
CarterPerez-dev 2026-07-26 12:00:11 -04:00
parent b6b5c03d74
commit 8ce093260c
3 changed files with 193 additions and 0 deletions

View File

@ -16,6 +16,10 @@ test:
{{run_ro}} ruby -Ilib -Itest test/scanner_test.rb
{{run_ro}} ruby -Ilib -Itest test/chains_test.rb
{{run_ro}} ruby -Ilib -Itest test/marshal/boundary_detector_test.rb
{{run_ro}} ruby -Ilib -Itest test/corpus_test.rb
corpus:
{{run_ro}} ruby -Ilib -Itest -e 'require "rube"; require "support/adversarial_corpus"; d = Rube::Marshal::BoundaryDetector.new; Rube::AdversarialCorpus::CASES.each { |k| dec = d.inspect_stream(k[:bytes]); puts format(" %-30s %-6s %s", k[:name], dec.accepted? ? "accept" : "reject", dec.reason.to_s[0, 60]) }'
scan namespace="":
{{run_ro}} ruby -Ilib -e 'require "rube"; ns = "{{namespace}}"; r = Rube::Scanner.new(namespace: ns.empty? ? nil : ns).scan; puts "modules=#{r.scanned_modules} candidates=#{r.candidates.length} gated=#{r.gated.length} reachable=#{r.reachable.length}"; puts; r.reachable.each { |c| puts format(" %-10s %-46s %s", c.gate, c.to_s, c.source_location) }'

View File

@ -0,0 +1,47 @@
# ©AngelaMos | 2026
# corpus_test.rb
require_relative "test_helper"
require_relative "support/adversarial_corpus"
module Rube
class CorpusTest < Minitest::Test
def detector
Marshal::BoundaryDetector.new
end
def test_corpus_covers_both_verdicts
verdicts = AdversarialCorpus::CASES.map { |c| c[:verdict] }.uniq
assert_includes verdicts, AdversarialCorpus::VERDICT_ACCEPT
assert_includes verdicts, AdversarialCorpus::VERDICT_REJECT
end
def test_corpus_case_names_are_unique
names = AdversarialCorpus::CASES.map { |c| c[:name] }
assert_equal names.length, names.uniq.length
end
def test_every_corpus_case_matches_its_verdict
disagreements = AdversarialCorpus::CASES.filter_map do |kase|
decision = detector.inspect_stream(kase[:bytes])
expected = kase[:verdict] == AdversarialCorpus::VERDICT_ACCEPT
next if decision.accepted? == expected
"#{kase[:name]}: expected #{kase[:verdict]}, got #{decision.accepted? ? 'accept' : 'reject'} (#{decision.reason})"
end
assert_empty disagreements, "corpus disagreements:\n #{disagreements.join("\n ")}"
end
def test_parser_never_raises_outside_the_stream_error_hierarchy
leaks = AdversarialCorpus::CASES.filter_map do |kase|
detector.inspect_stream(kase[:bytes])
nil
rescue StandardError => e
"#{kase[:name]}: #{e.class}"
end
assert_empty leaks, "unhandled exceptions escaped the detector:\n #{leaks.join("\n ")}"
end
end
end

View File

@ -0,0 +1,142 @@
# ©AngelaMos | 2026
# adversarial_corpus.rb
module Rube
module AdversarialCorpus
HEADER = "\x04\x08"
INLINE_OFFSET = 5
INLINE_CEILING = 122
INLINE_FLOOR = -123
BYTE_MODULUS = 256
VERDICT_ACCEPT = :accept
VERDICT_REJECT = :reject
module_function
def fixnum(value)
return "\x00".b if value.zero?
return (value + INLINE_OFFSET).chr if value.positive? && value <= INLINE_CEILING
return (value - INLINE_OFFSET + BYTE_MODULUS).chr if value.negative? && value >= INLINE_FLOOR
wide(value)
end
def wide(value)
width = 1
width += 1 while value >= (1 << (8 * width)) || value < -(1 << (8 * width))
stored = value.negative? ? value + (1 << (8 * width)) : value
bytes = (0...width).map { |index| (stored >> (8 * index)) & 0xFF }
marker = value.negative? ? BYTE_MODULUS - width : width
(marker.chr + bytes.map(&:chr).join).b
end
def sym(name)
":#{fixnum(name.bytesize)}#{name}".b
end
def str(text)
"\"#{fixnum(text.bytesize)}#{text}".b
end
def stream(body)
"#{HEADER}#{body}".b
end
def entry(name, bytes, verdict, rationale)
{ name: name, bytes: bytes.b, verdict: verdict, rationale: rationale }.freeze
end
NESTED_DEPTH = 80
WIDE_COUNT = 4_096
MANY_SYMBOLS = 400
HUGE_SCALAR = 300_000
CASES = [
entry(:nil_literal, stream("0"), VERDICT_ACCEPT,
"the smallest legal stream"),
entry(:boolean_true, stream("T"), VERDICT_ACCEPT,
"primitive tag carrying no class reference"),
entry(:fixnum_zero, stream("i\x00"), VERDICT_ACCEPT,
"zero uses the dedicated single-byte encoding"),
entry(:fixnum_inline_max, stream("i#{fixnum(122)}"), VERDICT_ACCEPT,
"largest value expressible without a width prefix"),
entry(:fixnum_wide_positive, stream("i#{fixnum(65_536)}"), VERDICT_ACCEPT,
"three-byte little-endian encoding"),
entry(:fixnum_wide_negative, stream("i#{fixnum(-65_536)}"), VERDICT_ACCEPT,
"negative widths sign-extend rather than being malformed"),
entry(:bare_symbol, stream(sym("marshal_load")), VERDICT_ACCEPT,
"a symbol naming a sink is not itself a sink"),
entry(:empty_array, stream("[\x00"), VERDICT_ACCEPT,
"zero-length collection"),
entry(:nested_arrays, stream("[\x06[\x06[\x060"), VERDICT_ACCEPT,
"ordinary nesting well inside the depth ceiling"),
entry(:empty_hash, stream("{\x00"), VERDICT_ACCEPT,
"zero-entry hash"),
entry(:shared_reference, stream("[\x07#{str('shared')}@\x06"), VERDICT_ACCEPT,
"a legitimate object link to a previously registered string"),
entry(:self_referential_array, stream("[\x06@\x00"), VERDICT_ACCEPT,
"zero-indexed cycle, the shape Ruby itself emits"),
entry(:symlink_reuse, stream("[\x07#{sym('same')};\x00"), VERDICT_ACCEPT,
"second occurrence of a symbol is a symlink"),
entry(:empty_stream, "", VERDICT_REJECT,
"no header at all"),
entry(:header_only, HEADER, VERDICT_REJECT,
"header with no value follows"),
entry(:wrong_major_version, "\x05\x080", VERDICT_REJECT,
"major version has never been anything but 4"),
entry(:future_minor_version, "\x04\x090", VERDICT_REJECT,
"a minor above 8 is a format this parser has not seen"),
entry(:unknown_tag, stream("\x00"), VERDICT_REJECT,
"byte zero is not a type tag"),
entry(:trailing_bytes, stream("0junk"), VERDICT_REJECT,
"unread bytes after the root value indicate a smuggled second stream"),
entry(:negative_array_count, stream("[\xFA"), VERDICT_REJECT,
"a negative count silently yields an empty collection instead of failing"),
entry(:negative_hash_count, stream("{\xFA"), VERDICT_REJECT,
"same confusion in hash position"),
entry(:negative_ivar_count, stream("I#{str('a')}\xFA"), VERDICT_REJECT,
"negative instance variable count"),
entry(:negative_struct_count, stream("S#{sym('A')}\xFA"), VERDICT_REJECT,
"negative struct member count"),
entry(:negative_bignum_words, stream("l+\xFA"), VERDICT_REJECT,
"negative word count previously leaked a raw NoMethodError"),
entry(:negative_string_length, stream("\"\xFA"), VERDICT_REJECT,
"a negative length rewinds the cursor, which is a loop primitive"),
entry(:out_of_range_object_link, stream("[\x06@\x63"), VERDICT_REJECT,
"link index beyond the object table"),
entry(:negative_object_link, stream("[\x06@\xFA"), VERDICT_REJECT,
"negative index would count backwards from the end of the table"),
entry(:negative_symlink, stream("[\x07#{sym('a')};\xFA"), VERDICT_REJECT,
"negative symbol index"),
entry(:truncated_string_body, stream("\"\x0aab"), VERDICT_REJECT,
"declared length exceeds the bytes present"),
entry(:truncated_array_elements, stream("[\x08i\x06"), VERDICT_REJECT,
"declares three elements and supplies one"),
entry(:truncated_multibyte_fixnum, stream("i\x03\x01"), VERDICT_REJECT,
"declares a three-byte integer and supplies one byte"),
entry(:deep_nesting, stream(("[\x06" * NESTED_DEPTH) + "0"), VERDICT_REJECT,
"nesting past the boundary depth ceiling"),
entry(:wide_collection, stream("[#{fixnum(WIDE_COUNT)}#{'0' * WIDE_COUNT}"), VERDICT_REJECT,
"one collection consuming the whole node budget"),
entry(:many_symbols, stream("[#{fixnum(MANY_SYMBOLS)}#{(0...MANY_SYMBOLS).map { |i| sym("s#{i}") }.join}"),
VERDICT_REJECT,
"attacker-chosen symbol creation past the definition ceiling"),
entry(:huge_scalar, stream(str("A" * HUGE_SCALAR)), VERDICT_REJECT,
"single scalar past the per-scalar byte ceiling"),
entry(:userdef_sink, stream("u#{sym('Evil')}#{fixnum(1)}x"), VERDICT_REJECT,
"_load runs during deserialization before any allowlist can act"),
entry(:usermarshal_sink, stream("U#{sym('Evil')}0"), VERDICT_REJECT,
"marshal_load runs during deserialization"),
entry(:data_sink, stream("d#{sym('Evil')}0"), VERDICT_REJECT,
"_load_data runs during deserialization"),
entry(:sink_in_ivar_name_position, stream("I#{str('a')}\x06u#{sym('Evil')}#{fixnum(1)}x0"),
VERDICT_REJECT,
"a sink hidden where a symbol is expected must not disappear from the report"),
entry(:plain_object_no_sink, stream("o#{sym('ERB')}\x06#{sym('@src')}#{str('payload')}"),
VERDICT_REJECT,
"carries no sink tag at all, which is exactly why sink detection is insufficient")
].freeze
end
end