From 582e032cc013e8813c7da2f3c576ecc636907e5a Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Wed, 29 Jul 2026 05:12:25 -0400 Subject: [PATCH] fix(rube): clear the entire S1 backlog tier - depth, budgets, gates, fidelity Every item contracted to clear before M7 is closed. 151 tests from 119, 58 corpus cases from 48, all six gates green. Depth accounting (B3, B4). TAG_IVAR charged no depth at all, so an I-chain of any length parsed under any ceiling. Proven end to end against a rebuilt target image: a 12,936-byte cookie returned HTTP 500 with a SystemStackError that no rescue StreamError can catch, and a 724,287-byte response body leaking absolute container paths for every file in lib/. Fixed, the same cookie returns 400 DepthLimitError, and so does a 53,340-byte one. read_userdef also hard-coded a depth of 1 for its class-name slot. Budget axes (B12, B13, B14). Bignum magnitude bypassed the scalar budget entirely and the sign byte accepted anything as positive where Marshal.load raises ArgumentError. Added max_symbol_references, max_symbol_name_bytes, max_class_name_bytes, max_instance_variables and max_struct_members. Parser.new now enforces Limits.new instead of resolving to an unbounded config; Limits.permissive became a class method. Hash-key dispatch (B11). Nothing rejected an allowlisted class used as a hash KEY, where #hash and #eql? run during load before any allowlist can act. Measured against real Marshal.load: a key dispatches iff it carries a class name and its underlying value is not a T_STRING. So TAG_REGEXP is not a key-position risk and TAG_USERCLASS only conditionally - rejecting either outright would have been a false positive. No opt-out allowlist was added, because the dispatch happens before any check could run. Fidelity (B15, B16, B9). The parser already matched Marshal.load on header versions, so the 4.8 contradiction was resolved by giving the detector the policy check and leaving the forensic parser permissive. Class-name slots now accept only a symbol, an ivar-wrapped symbol, or a symlink. Wrapper tags C and e no longer take an object-table slot, which Ruby does not give them - link index 3 resolved to "bbb" for us and "ccc" for Ruby. Gate soundness (B6, B7). Three discarded check() return values now register as failures; section 6 no longer reports a vacuous 0/0; section 7 requires reachable > 0, and prism absence is a named failure rather than a silent zero. version-matrix.sh exits non-zero when any image produces no probe result. control_check.rb no longer pulls in minitest, which was printing "0 runs, 0 assertions" directly under ALL CONTROLS PASSED. Rewrote the vacuous tests: the regexp options byte had zero minitest coverage and its mutant survived the whole suite, and read_count's negative guard was alibied by take's own guard. The target app (B5) lost its hand-rolled copy of the sink-plus-allowlist policy and now runs one BoundaryDetector with real limits, branching on rejected? rather than accepted?. Everything here is mutation-proven. Notable misses that mutation caught: B14 had no test at all until reverting it stayed green, and a struct-member test was vacuous on the first attempt because struct member names are always symbols. lib/rube/marshal/parser.rb carries eight backlog items at once and cannot be split without interactive hunk staging, so this is one commit rather than eight. --- .../deserialization-gadget-lab/Rakefile | 7 +- .../lib/rube/marshal/boundary_detector.rb | 13 + .../lib/rube/marshal/constants.rb | 5 + .../lib/rube/marshal/errors.rb | 2 + .../lib/rube/marshal/limits.rb | 75 +++- .../lib/rube/marshal/node.rb | 45 ++- .../lib/rube/marshal/parser.rb | 66 ++-- .../lib/rube/scanner.rb | 4 + .../scripts/render_matrix.rb | 12 +- .../scripts/version-matrix.sh | 23 +- .../deserialization-gadget-lab/target/app.rb | 30 +- .../test/chains_test.rb | 7 +- .../test/control_check.rb | 29 +- .../test/corpus_test.rb | 2 +- .../test/marshal/boundary_detector_test.rb | 147 +++++++- .../test/marshal/parser_test.rb | 339 ++++++++++++++++++ .../test/scanner_test.rb | 13 +- .../test/support/adversarial_corpus.rb | 100 +++++- 18 files changed, 827 insertions(+), 92 deletions(-) diff --git a/PROJECTS/beginner/deserialization-gadget-lab/Rakefile b/PROJECTS/beginner/deserialization-gadget-lab/Rakefile index 2b78c541..032347ee 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/Rakefile +++ b/PROJECTS/beginner/deserialization-gadget-lab/Rakefile @@ -9,4 +9,9 @@ Rake::TestTask.new(:test) do |t| t.warning = true end -task default: :test +desc "run the standalone control checks that the minitest suite cannot express" +task :control do + ruby "-Ilib -Itest test/control_check.rb" +end + +task default: %i[test control] diff --git a/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/marshal/boundary_detector.rb b/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/marshal/boundary_detector.rb index f908bb8b..34f42117 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/marshal/boundary_detector.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/marshal/boundary_detector.rb @@ -18,6 +18,10 @@ module Rube REASON_MALFORMED = "stream is not canonical Marshal: %s" REASON_SINK = "stream reaches %s#%s during load, before any allowlist can run" REASON_UNAPPROVED = "stream references unapproved class %s" + REASON_KEY_DISPATCH = "stream puts %s in a hash key, so its #hash and #eql? run during " \ + "load, before any allowlist can act" + REASON_NONCANONICAL_VERSION = "stream declares Marshal %d.%d; every Ruby that can produce " \ + "this format emits %d.%d" LIMITATION_NOTICE = <<~NOTICE.freeze SECURITY LIMITATION @@ -105,8 +109,17 @@ module Rube def violation_for(result) sink = result.sinks.first return format(REASON_SINK, sink.class_name, sink.sink_method) if sink + + key = result.dispatching_hash_keys.first + return format(REASON_KEY_DISPATCH, key.effective_class_name) if key + return nil if policy == POLICY_DENY_SINKS_ONLY + unless result.canonical_version? + return format(REASON_NONCANONICAL_VERSION, result.major, result.minor, + Constants::MAJOR_VERSION, Constants::MINOR_VERSION) + end + unapproved = result.class_names.reject { |name| allowed_class_names.include?(name) } return format(REASON_UNAPPROVED, unapproved.join(", ")) unless unapproved.empty? diff --git a/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/marshal/constants.rb b/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/marshal/constants.rb index 8f0302ba..096e7a1b 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/marshal/constants.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/marshal/constants.rb @@ -36,6 +36,7 @@ module Rube BIGNUM_SIGN_POSITIVE = "+" BIGNUM_SIGN_NEGATIVE = "-" + BIGNUM_SIGNS = [BIGNUM_SIGN_POSITIVE, BIGNUM_SIGN_NEGATIVE].freeze BIGNUM_WORD_BYTES = 2 FIXNUM_INLINE_OFFSET = 5 @@ -56,6 +57,10 @@ module Rube ROLE_STRUCT = "struct member" ROLE_BIGNUM = "bignum word" + CLASS_NAME_TYPES = %i[symbol symlink].freeze + + REGISTERED_WRAPPER_TYPES = %i[data].freeze + SINK_TAGS = [TAG_USERDEF, TAG_USERMARSHAL, TAG_DATA].freeze SINK_METHODS = { diff --git a/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/marshal/errors.rb b/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/marshal/errors.rb index 15d95320..21c31542 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/marshal/errors.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/marshal/errors.rb @@ -19,6 +19,8 @@ module Rube class MalformedCountError < StreamError; end + class MalformedValueError < StreamError; end + class LimitExceededError < StreamError; end class InputTypeError < StreamError; end diff --git a/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/marshal/limits.rb b/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/marshal/limits.rb index dbb898ec..60065c21 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/marshal/limits.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/marshal/limits.rb @@ -13,6 +13,11 @@ module Rube DEFAULT_MAX_SCALAR_BYTES = 262_144 DEFAULT_MAX_TOTAL_SCALAR_BYTES = 524_288 DEFAULT_MAX_OBJECT_LINKS = 2_048 + DEFAULT_MAX_SYMBOL_REFERENCES = 2_048 + DEFAULT_MAX_SYMBOL_NAME_BYTES = 1_024 + DEFAULT_MAX_CLASS_NAME_BYTES = 1_024 + DEFAULT_MAX_INSTANCE_VARIABLES = 256 + DEFAULT_MAX_STRUCT_MEMBERS = 256 ROLE_BYTES = "stream bytes" ROLE_NODES = "nodes" @@ -22,10 +27,19 @@ module Rube ROLE_SCALAR = "scalar bytes" ROLE_TOTAL_SCALAR = "total scalar bytes" ROLE_LINKS = "object links" + ROLE_SYMBOL_REFERENCES = "symbol references" + ROLE_SYMBOL_NAME = "symbol name bytes" + ROLE_CLASS_NAME = "class name bytes" + ROLE_INSTANCE_VARIABLES = "instance variables" + ROLE_STRUCT_MEMBERS = "struct members" + + UNBOUNDED = Float::INFINITY attr_reader :max_bytes, :max_depth, :max_nodes, :max_registered_objects, :max_symbol_definitions, :max_collection_entries, - :max_scalar_bytes, :max_total_scalar_bytes, :max_object_links + :max_scalar_bytes, :max_total_scalar_bytes, :max_object_links, + :max_symbol_references, :max_symbol_name_bytes, :max_class_name_bytes, + :max_instance_variables, :max_struct_members def initialize( max_bytes: DEFAULT_MAX_BYTES, @@ -36,7 +50,12 @@ module Rube max_collection_entries: DEFAULT_MAX_COLLECTION_ENTRIES, max_scalar_bytes: DEFAULT_MAX_SCALAR_BYTES, max_total_scalar_bytes: DEFAULT_MAX_TOTAL_SCALAR_BYTES, - max_object_links: DEFAULT_MAX_OBJECT_LINKS + max_object_links: DEFAULT_MAX_OBJECT_LINKS, + max_symbol_references: DEFAULT_MAX_SYMBOL_REFERENCES, + max_symbol_name_bytes: DEFAULT_MAX_SYMBOL_NAME_BYTES, + max_class_name_bytes: DEFAULT_MAX_CLASS_NAME_BYTES, + max_instance_variables: DEFAULT_MAX_INSTANCE_VARIABLES, + max_struct_members: DEFAULT_MAX_STRUCT_MEMBERS ) @max_bytes = max_bytes @max_depth = max_depth @@ -47,19 +66,29 @@ module Rube @max_scalar_bytes = max_scalar_bytes @max_total_scalar_bytes = max_total_scalar_bytes @max_object_links = max_object_links + @max_symbol_references = max_symbol_references + @max_symbol_name_bytes = max_symbol_name_bytes + @max_class_name_bytes = max_class_name_bytes + @max_instance_variables = max_instance_variables + @max_struct_members = max_struct_members end - def permissive - self.class.new( - max_bytes: Float::INFINITY, + def self.permissive + new( + max_bytes: UNBOUNDED, max_depth: Constants::DEFAULT_MAX_DEPTH, - max_nodes: Float::INFINITY, - max_registered_objects: Float::INFINITY, - max_symbol_definitions: Float::INFINITY, - max_collection_entries: Float::INFINITY, - max_scalar_bytes: Float::INFINITY, - max_total_scalar_bytes: Float::INFINITY, - max_object_links: Float::INFINITY + max_nodes: UNBOUNDED, + max_registered_objects: UNBOUNDED, + max_symbol_definitions: UNBOUNDED, + max_collection_entries: UNBOUNDED, + max_scalar_bytes: UNBOUNDED, + max_total_scalar_bytes: UNBOUNDED, + max_object_links: UNBOUNDED, + max_symbol_references: UNBOUNDED, + max_symbol_name_bytes: UNBOUNDED, + max_class_name_bytes: UNBOUNDED, + max_instance_variables: UNBOUNDED, + max_struct_members: UNBOUNDED ) end end @@ -71,6 +100,7 @@ module Rube @registered = 0 @symbols = 0 @links = 0 + @symbol_references = 0 @scalar_total = 0 end @@ -94,6 +124,27 @@ module Rube check(@links, limits.max_object_links, Limits::ROLE_LINKS) end + def symbol_reference! + @symbol_references += 1 + check(@symbol_references, limits.max_symbol_references, Limits::ROLE_SYMBOL_REFERENCES) + end + + def symbol_name!(size) + check(size, limits.max_symbol_name_bytes, Limits::ROLE_SYMBOL_NAME) + end + + def class_name!(size) + check(size, limits.max_class_name_bytes, Limits::ROLE_CLASS_NAME) + end + + def instance_variables!(count) + check(count, limits.max_instance_variables, Limits::ROLE_INSTANCE_VARIABLES) + end + + def struct_members!(count) + check(count, limits.max_struct_members, Limits::ROLE_STRUCT_MEMBERS) + end + def entries!(count) check(count, limits.max_collection_entries, Limits::ROLE_ENTRIES) end diff --git a/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/marshal/node.rb b/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/marshal/node.rb index cfd80bea..637beb59 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/marshal/node.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/marshal/node.rb @@ -4,8 +4,11 @@ module Rube module Marshal class Node + STRING_BACKED_TYPES = %i[string regexp].freeze + WRAPPER_TYPES = %i[user_class extended].freeze + attr_reader :type, :tag, :children, :instance_variables_map, :auxiliary - attr_accessor :value, :class_name + attr_accessor :value, :class_name, :link_target def initialize(type:, tag: nil, value: nil, class_name: nil) @type = type @@ -29,6 +32,25 @@ module Rube Constants::GATED_SINK_TAGS.include?(tag) end + def dispatches_key_methods? + return link_target ? link_target.dispatches_key_methods? : false if type == :object_link + return false unless class_name + return !string_backed? if WRAPPER_TYPES.include?(type) + + true + end + + def effective_class_name + link_target ? link_target.class_name : class_name + end + + def string_backed? + wrapped = children.first + return false unless wrapped + + STRING_BACKED_TYPES.include?(wrapped.type) + end + def each(&block) return enum_for(:each) unless block @@ -39,10 +61,16 @@ module Rube end class Result - attr_reader :root + attr_reader :root, :major, :minor - def initialize(root) + def initialize(root, major:, minor:) @root = root + @major = major + @minor = minor + end + + def canonical_version? + major == Constants::MAJOR_VERSION && minor == Constants::MINOR_VERSION end def nodes @@ -60,6 +88,17 @@ module Rube def gated_sinks sinks.select(&:gated?) end + + def hash_keys + nodes.select { |node| node.type == :hash } + .flat_map(&:children) + .select { |child| child.type == :pair } + .filter_map { |pair| pair.children.first } + end + + def dispatching_hash_keys + hash_keys.select(&:dispatches_key_methods?) + end end end end diff --git a/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/marshal/parser.rb b/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/marshal/parser.rb index 4e8dd949..d10dabdb 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/marshal/parser.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/marshal/parser.rb @@ -6,14 +6,14 @@ module Rube class Parser include Constants - def initialize(source, max_depth: DEFAULT_MAX_DEPTH, limits: nil) + def initialize(source, max_depth: nil, limits: Limits.new) raise InputTypeError, "expected String, got #{source.class}" unless source.is_a?(String) @limits = limits - @max_depth = limits ? limits.max_depth : max_depth + @max_depth = max_depth || limits.max_depth enforce_size(source) @source = source.dup.force_encoding(Encoding::BINARY) - @budget = Budget.new(limits || Limits.new.permissive) + @budget = Budget.new(limits) @position = 0 @symbols = [] @objects = [] @@ -24,7 +24,7 @@ module Rube root = read_value(1) raise TrailingBytesError, "#{remaining} unread bytes" unless remaining.zero? - Result.new(root) + Result.new(root, major: @major, minor: @minor) end private @@ -32,7 +32,6 @@ module Rube attr_reader :source, :max_depth, :symbols, :objects, :budget, :limits def enforce_size(candidate) - return unless limits return if candidate.bytesize <= limits.max_bytes raise LimitExceededError, "#{Limits::ROLE_BYTES} #{candidate.bytesize} exceeds #{limits.max_bytes}" @@ -68,11 +67,10 @@ module Rube end def read_header - header = take(HEADER_LENGTH) - major, minor = header.unpack("CC") - return if major == MAJOR_VERSION && minor <= MINOR_VERSION + @major, @minor = take(HEADER_LENGTH).unpack("CC") + return if @major == MAJOR_VERSION && @minor <= MINOR_VERSION - raise UnsupportedVersionError, "stream declares #{major}.#{minor}" + raise UnsupportedVersionError, "stream declares #{@major}.#{@minor}" end def read_fixnum @@ -133,7 +131,7 @@ module Rube when TAG_IVAR then read_ivar(tag, depth) when TAG_OBJECT then read_object(tag, depth) when TAG_STRUCT then read_struct(tag, depth) - when TAG_USERDEF then register(read_userdef(tag)) + when TAG_USERDEF then register(read_userdef(tag, depth)) when TAG_USERMARSHAL then read_usermarshal(tag, depth) when TAG_DATA then read_wrapped(tag, :data, depth) when TAG_USERCLASS then read_wrapped(tag, :user_class, depth) @@ -146,12 +144,16 @@ module Rube def read_symbol(tag) budget.symbol! - node = Node.new(type: :symbol, tag: tag, value: read_counted_bytes.to_sym) + size = read_count(ROLE_LENGTH) + budget.symbol_name!(size) + budget.scalar!(size) + node = Node.new(type: :symbol, tag: tag, value: take(size).to_sym) symbols << node.value node end def read_symlink(tag) + budget.symbol_reference! index = read_fixnum raise InvalidLinkError, "symlink #{index} of #{symbols.length}" unless symbols[index] && index >= 0 @@ -163,13 +165,20 @@ module Rube index = read_fixnum raise InvalidLinkError, "object link #{index} of #{objects.length}" unless objects[index] && index >= 0 - Node.new(type: :object_link, tag: tag, value: index) + node = Node.new(type: :object_link, tag: tag, value: index) + node.link_target = objects[index] + node end def read_bignum(tag) - negative = take(1) == BIGNUM_SIGN_NEGATIVE - magnitude = little_endian(take(read_count(ROLE_BIGNUM) * BIGNUM_WORD_BYTES)) - Node.new(type: :bignum, tag: tag, value: negative ? -magnitude : magnitude) + sign = take(1) + raise MalformedValueError, "bignum sign #{sign.inspect}" unless BIGNUM_SIGNS.include?(sign) + + size = read_count(ROLE_BIGNUM) * BIGNUM_WORD_BYTES + budget.scalar!(size) + magnitude = little_endian(take(size)) + Node.new(type: :bignum, tag: tag, + value: sign == BIGNUM_SIGN_NEGATIVE ? -magnitude : magnitude) end def read_float(tag) @@ -210,13 +219,20 @@ module Rube def read_class_name(node, depth) class_node = read_value(depth) + unless CLASS_NAME_TYPES.include?(class_node.type) + raise MalformedValueError, + "class name slot holds #{class_node.type}, not a symbol" + end + node.class_name = class_node.value.to_s node.auxiliary << class_node node end def read_instance_variables(node, depth) - read_entry_count(ROLE_IVAR).times do + count = read_entry_count(ROLE_IVAR) + budget.instance_variables!(count) + count.times do name = read_value(depth + 1) value = read_value(depth + 1) node.auxiliary << name @@ -227,7 +243,7 @@ module Rube end def read_ivar(tag, depth) - read_instance_variables(read_value(depth), depth) + read_instance_variables(read_value(depth + 1), depth) end def read_object(tag, depth) @@ -239,13 +255,15 @@ module Rube def read_struct(tag, depth) node = register(Node.new(type: :struct, tag: tag)) read_class_name(node, depth + 1) - read_entry_count(ROLE_STRUCT).times { node.children << read_pair(depth) } + count = read_entry_count(ROLE_STRUCT) + budget.struct_members!(count) + count.times { node.children << read_pair(depth) } node end - def read_userdef(tag) + def read_userdef(tag, depth) node = Node.new(type: :userdef, tag: tag) - read_class_name(node, 1) + read_class_name(node, depth + 1) node.value = read_counted_bytes node end @@ -258,14 +276,18 @@ module Rube end def read_wrapped(tag, type, depth) - node = register(Node.new(type: type, tag: tag)) + node = Node.new(type: type, tag: tag) + register(node) if REGISTERED_WRAPPER_TYPES.include?(type) read_class_name(node, depth + 1) node.children << read_value(depth + 1) node end def read_named(tag, type) - Node.new(type: type, tag: tag, class_name: read_counted_bytes) + size = read_count(ROLE_LENGTH) + budget.class_name!(size) + budget.scalar!(size) + Node.new(type: type, tag: tag, class_name: take(size)) end end end diff --git a/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/scanner.rb b/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/scanner.rb index 616e20cc..8edaf2ef 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/scanner.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/scanner.rb @@ -79,6 +79,10 @@ module Rube def reachable candidates.select(&:reachable?) end + + def prism_available? + PRISM_AVAILABLE + end end def initialize(namespace: nil) diff --git a/PROJECTS/beginner/deserialization-gadget-lab/scripts/render_matrix.rb b/PROJECTS/beginner/deserialization-gadget-lab/scripts/render_matrix.rb index 58b73b9f..a804aba0 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/scripts/render_matrix.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/scripts/render_matrix.rb @@ -2,6 +2,7 @@ # render_matrix.rb require "json" +require "rube" TRACKED_CLASSES = %w[Gem::SpecFetcher Gem::Source::Git Gem::URI Net::WriteAdapter].freeze MARK_YES = "yes" @@ -9,7 +10,11 @@ MARK_NO = "no" MARK_UNKNOWN = "?" RULE_WIDTH = 78 -CVE_PATCHED_ERB = ["4.0.3.1", "4.0.4.1", "6.0.1.1", "6.0.4"].freeze +CHAIN = Rube::Chains::ErbDefMethod + +def cve_patched?(version) + !CHAIN.affects?(version) +end rows = File.readlines(ARGV.fetch(0)).reject { |line| line.strip.empty? }.map { |line| JSON.parse(line) } abort "no probe results" if rows.empty? @@ -54,7 +59,7 @@ section("ERB @_init GUARD (CVE-2026-41316), anchor = def_method") do puts format(" %-14s %-9s %-9s %-24s %s", "image", "erb", "guarded", "delegating", "cve says") rows.each do |r| guard = r["erb_guard"] - expected = CVE_PATCHED_ERB.include?(r["erb"]) ? "patched" : "affected" + expected = cve_patched?(r["erb"]) ? "patched" : "affected" puts format(" %-14s %-9s %-9s %-24s %s", short(r["image"]), r["erb"], mark(guard["guarded"]), guard["delegating"].join(","), expected) @@ -65,8 +70,7 @@ git_states = rows.map { |r| r["git_gadget"] }.uniq guard_states = rows.map { |r| r["erb_guard"]["guarded"] }.uniq agreements = rows.map do |r| - expected_patched = CVE_PATCHED_ERB.include?(r["erb"]) - [short(r["image"]), r["erb_guard"]["guarded"] == expected_patched] + [short(r["image"]), r["erb_guard"]["guarded"] == cve_patched?(r["erb"])] end disagreements = agreements.reject { |_, ok| ok }.map(&:first) diff --git a/PROJECTS/beginner/deserialization-gadget-lab/scripts/version-matrix.sh b/PROJECTS/beginner/deserialization-gadget-lab/scripts/version-matrix.sh index d81a026e..36677690 100755 --- a/PROJECTS/beginner/deserialization-gadget-lab/scripts/version-matrix.sh +++ b/PROJECTS/beginner/deserialization-gadget-lab/scripts/version-matrix.sh @@ -28,12 +28,15 @@ mkdir -p "${HERE}/tmp" echo "probing ${#IMAGES[@]} images" +incomplete=() + for image in "${IMAGES[@]}"; do printf ' %-18s ' "${image}" if ! docker image inspect "${image}" >/dev/null 2>&1; then if ! docker pull -q "${image}" >/dev/null 2>&1; then echo "UNAVAILABLE" + incomplete+=("${image} unavailable") continue fi fi @@ -46,11 +49,29 @@ for image in "${IMAGES[@]}"; do echo "ok" else echo "PROBE FAILED" + incomplete+=("${image} probe failed") fi done echo +render_status=0 docker run --rm --network none \ -v "${OUT}:/matrix.jsonl:ro" \ -v "${RENDER}:/render.rb:ro" \ - "${RENDER_IMAGE}" ruby /render.rb /matrix.jsonl + -v "${HERE}/lib:/app/lib:ro" \ + -w /app \ + "${RENDER_IMAGE}" ruby -Ilib /render.rb /matrix.jsonl || render_status=$? + +echo +if [[ ${#incomplete[@]} -gt 0 ]]; then + echo "GATE FAILED - ${#incomplete[@]} of ${#IMAGES[@]} images produced no probe result:" + printf ' %s\n' "${incomplete[@]}" + echo " a matrix built from a subset cannot certify a version boundary" + exit 1 +fi + +if [[ ${render_status} -ne 0 ]]; then + echo "GATE FAILED - renderer rejected the matrix" +fi + +exit ${render_status} diff --git a/PROJECTS/beginner/deserialization-gadget-lab/target/app.rb b/PROJECTS/beginner/deserialization-gadget-lab/target/app.rb index 95ca7696..9ca7c8f1 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/target/app.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/target/app.rb @@ -19,11 +19,15 @@ module Rube ALLOWED_CLASSES = %w[Hash String Symbol Integer Array].freeze BENIGN_TEMPLATE = "hello" - REJECTED_SINK = "rejected: payload reaches %s#%s before any allowlist can run" - REJECTED_CLASS = "rejected: payload references %s" + DETECTOR = Rube::Marshal::BoundaryDetector.new( + policy: Rube::Marshal::BoundaryDetector::POLICY_STRICT_ALLOWLIST, + allowed_class_names: ALLOWED_CLASSES, + limits: Rube::Marshal::Limits.new + ) + + REJECTED = "rejected: %s" RENDERED = "rendered template for %s" NO_SESSION = "no session cookie" - MALFORMED = "rejected: malformed stream (%s)" class App < Sinatra::Base set :host_authorization, permitted_hosts: [] @@ -63,10 +67,10 @@ module Rube blob = decode(request.cookies[COOKIE_NAME]) halt STATUS_BAD_REQUEST, NO_SESSION unless blob - verdict = inspect_stream(blob) - halt STATUS_BAD_REQUEST, verdict if verdict + decision = DETECTOR.inspect_stream(blob) + halt STATUS_BAD_REQUEST, format(REJECTED, decision.reason) if decision.rejected? - compile(::Marshal.load(blob)) + compile(::Marshal.load(decision.snapshot)) end get "/canary" do @@ -93,20 +97,6 @@ module Rube template.def_method(Module.new, "render_it") if template.respond_to?(:def_method) format(RENDERED, state[:user]) end - - def inspect_stream(blob) - result = Rube::Marshal::Parser.new(blob).parse - - sink = result.sinks.first - return format(REJECTED_SINK, sink.class_name, sink.sink_method) if sink - - unknown = result.class_names.reject { |name| ALLOWED_CLASSES.include?(name) } - return format(REJECTED_CLASS, unknown.join(", ")) unless unknown.empty? - - nil - rescue Rube::Marshal::StreamError => e - format(MALFORMED, e.class.name.split("::").last) - end end end end diff --git a/PROJECTS/beginner/deserialization-gadget-lab/test/chains_test.rb b/PROJECTS/beginner/deserialization-gadget-lab/test/chains_test.rb index 8c54043b..01118e22 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/test/chains_test.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/test/chains_test.rb @@ -65,10 +65,11 @@ module Rube assert_match(/\A#\nend\n/, chain.src) end - def test_serialize_produces_a_loadable_marshal_stream + def test_serialize_produces_a_parseable_marshal_stream blob = chain.serialize - assert_equal 4, blob.getbyte(0) - assert_equal 8, blob.getbyte(1) + assert_equal [Rube::Marshal::Constants::MAJOR_VERSION, Rube::Marshal::Constants::MINOR_VERSION], + [blob.getbyte(0), blob.getbyte(1)] + assert_equal :object, Rube::Marshal::Parser.new(blob).parse.root.type end def test_payload_is_visible_to_the_parser_without_deserializing diff --git a/PROJECTS/beginner/deserialization-gadget-lab/test/control_check.rb b/PROJECTS/beginner/deserialization-gadget-lab/test/control_check.rb index 41f902d2..1a334c90 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/test/control_check.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/test/control_check.rb @@ -1,7 +1,9 @@ # ©AngelaMos | 2026 # control_check.rb -require_relative "test_helper" +$LOAD_PATH.unshift(File.expand_path("../lib", __dir__)) + +require "rube" Pair = Struct.new(:x, :y) @@ -77,15 +79,24 @@ puts puts "=== 5 payload inspection ===" result = Rube::Marshal::Parser.new(Marshal.dump(Gem::Requirement.new(">= 0"))).parse from_stream = result.gated_sinks.map { |s| "#{s.class_name}##{s.sink_method}" }.uniq.sort -check("classes extracted", !result.class_names.empty?, result.class_names.join(", ")) -check("gated sinks flagged", !from_stream.empty?, from_stream.join(", ")) +failures << "class names" unless check("classes extracted", !result.class_names.empty?, + result.class_names.join(", ")) +failures << "gated sinks" unless check("gated sinks flagged", !from_stream.empty?, + from_stream.join(", ")) puts puts "=== 6 parser and scanner agreement ===" scanned = Rube::Scanner.new(namespace: "Gem").scan.gated.map(&:to_s).sort missing = from_stream - scanned -failures << "agreement" unless check("parser sinks located by reflection", missing.empty?, - missing.empty? ? "#{from_stream.length}/#{from_stream.length}" : "missing #{missing.join(', ')}") +located = !from_stream.empty? && missing.empty? +agreement_detail = if from_stream.empty? + "vacuous, section 5 produced no sinks to locate" + elsif missing.empty? + "#{from_stream.length}/#{from_stream.length}" + else + "missing #{missing.join(', ')}" + end +failures << "agreement" unless check("parser sinks located by reflection", located, agreement_detail) puts puts "=== 7 scanner precision ===" @@ -93,9 +104,13 @@ full = Rube::Scanner.new.scan ungated = full.ungated.length reachable = full.reachable.reject(&:gated?).length kept = ungated.zero? ? 0 : (100.0 * reachable / ungated).round(1) -failures << "precision" unless check("reachability filter discriminates", reachable < ungated, +failures << "prism" unless check("prism backend live, so reachability is real", full.prism_available?, + full.prism_available? ? "Prism.parse_file in use" : "ABSENT, touches_state? is always false") +failures << "precision" unless check("reachability filter discriminates", + reachable.positive? && reachable < ungated, "#{ungated} ungated -> #{reachable} reachable, #{kept}% kept") -check("gated sinks located", !full.gated.empty?, full.gated.map(&:to_s).join(", ")) +failures << "gated located" unless check("gated sinks located", !full.gated.empty?, + full.gated.map(&:to_s).join(", ")) puts format(" %-6s %-46s %s", "INFO", "ObjectSpace coverage is load-bounded", "#{full.scanned_modules} modules loaded") diff --git a/PROJECTS/beginner/deserialization-gadget-lab/test/corpus_test.rb b/PROJECTS/beginner/deserialization-gadget-lab/test/corpus_test.rb index 0def6ce4..9249d559 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/test/corpus_test.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/test/corpus_test.rb @@ -37,7 +37,7 @@ module Rube leaks = AdversarialCorpus::CASES.filter_map do |kase| detector(kase[:allowed]).inspect_stream(kase[:bytes]) nil - rescue StandardError => e + rescue Exception => e "#{kase[:name]}: #{e.class}" end diff --git a/PROJECTS/beginner/deserialization-gadget-lab/test/marshal/boundary_detector_test.rb b/PROJECTS/beginner/deserialization-gadget-lab/test/marshal/boundary_detector_test.rb index 0386f627..81219758 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/test/marshal/boundary_detector_test.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/test/marshal/boundary_detector_test.rb @@ -2,6 +2,7 @@ # boundary_detector_test.rb require_relative "../test_helper" +require_relative "../support/adversarial_corpus" module Rube module Marshal @@ -108,6 +109,28 @@ module Rube assert_predicate decision, :rejected? end + def test_rejects_a_version_the_parser_accepts_but_no_ruby_emits + older = "\x04\x07\x30".b + + assert_predicate Parser.new(older).parse, :root, + "control: the parser must still accept it, it is a forensic tool" + decision = detector.inspect_stream(older) + assert_predicate decision, :rejected? + assert_includes decision.reason, "4.7" + end + + def test_canonical_version_is_accepted + assert_predicate detector.inspect_stream(benign_blob), :accepted? + end + + def test_deny_sinks_only_does_not_police_the_version + decision = detector(policy: BoundaryDetector::POLICY_DENY_SINKS_ONLY) + .inspect_stream("\x04\x07\x30".b) + assert_predicate decision, :accepted?, + "version canonicality runs no code during load, so it belongs with " \ + "the allowlist, not with the sink checks" + end + def test_rejects_unknown_policy assert_raises(ArgumentError) { detector(policy: :yolo) } end @@ -130,36 +153,132 @@ module Rube refute_includes decision.snapshot, "tampered" end - def test_enforces_a_byte_ceiling_before_parsing - limits = Limits.new(max_bytes: 8) - decision = detector(limits: limits).inspect_stream(benign_blob) + def assert_ceiling_rejects(blob, error_name, allowed: [], **narrow) + assert_predicate detector(allowed_class_names: allowed).inspect_stream(blob), :accepted?, + "control: this payload must be accepted under default limits, " \ + "or the ceiling is not what rejected it" + + decision = detector(allowed_class_names: allowed, limits: Limits.new(**narrow)).inspect_stream(blob) assert_predicate decision, :rejected? - assert_includes decision.reason, "LimitExceededError" + assert_includes decision.reason, error_name + decision + end + + def test_enforces_a_byte_ceiling_before_parsing + assert_ceiling_rejects(benign_blob, "LimitExceededError", max_bytes: 8) end def test_enforces_a_depth_ceiling deep = "\x04\x08" + ("[\x06" * 80) + "0" + shallow = "\x04\x08" + ("[\x06" * 8) + "0" + + assert_predicate detector.inspect_stream(shallow), :accepted?, + "control: nesting inside the ceiling must be accepted" decision = detector.inspect_stream(deep) assert_predicate decision, :rejected? + assert_includes decision.reason, "DepthLimitError" end def test_enforces_a_collection_entry_ceiling - limits = Limits.new(max_collection_entries: 2) - decision = detector(limits: limits).inspect_stream(::Marshal.dump([1, 2, 3, 4, 5])) - assert_predicate decision, :rejected? + assert_ceiling_rejects(::Marshal.dump([1, 2, 3, 4, 5]), "LimitExceededError", + max_collection_entries: 2) end def test_enforces_a_symbol_ceiling - limits = Limits.new(max_symbol_definitions: 2) - blob = ::Marshal.dump(%i[a b c d e f]) - decision = detector(limits: limits).inspect_stream(blob) - assert_predicate decision, :rejected? + assert_ceiling_rejects(::Marshal.dump(%i[a b c d e f]), "LimitExceededError", + max_symbol_definitions: 2) end def test_enforces_a_node_ceiling - limits = Limits.new(max_nodes: 5) - decision = detector(limits: limits).inspect_stream(::Marshal.dump((1..50).to_a)) - assert_predicate decision, :rejected? + assert_ceiling_rejects(::Marshal.dump((1..50).to_a), "LimitExceededError", + max_nodes: 5) + end + + def test_enforces_a_bignum_magnitude_ceiling + words = AdversarialCorpus::BIGNUM_HUGE_WORDS + blob = AdversarialCorpus.stream(AdversarialCorpus.bignum("+", words)) + decision = detector.inspect_stream(blob) + + assert_predicate decision, :rejected?, + "#{words * AdversarialCorpus::BIGNUM_WORD_BYTES} magnitude bytes must be " \ + "charged the same budget a string of that size is charged" + assert_includes decision.reason, "LimitExceededError" + end + + def test_enforces_a_symbol_reference_ceiling + count = AdversarialCorpus::BULK_ENTRY_COUNT + blob = AdversarialCorpus.stream( + "[#{AdversarialCorpus.fixnum(count + 1)}#{AdversarialCorpus.symlink_run(count)}" + ) + assert_ceiling_rejects(blob, "LimitExceededError", max_symbol_references: 8) + end + + def test_enforces_a_symbol_name_byte_ceiling + blob = AdversarialCorpus.stream(AdversarialCorpus.sym("A" * AdversarialCorpus::MODEST_NAME_BYTES)) + assert_ceiling_rejects(blob, "LimitExceededError", max_symbol_name_bytes: 32) + end + + def test_enforces_a_class_name_byte_ceiling + name = "A" * AdversarialCorpus::MODEST_NAME_BYTES + blob = AdversarialCorpus.stream("c#{AdversarialCorpus.fixnum(name.bytesize)}#{name}") + assert_ceiling_rejects(blob, "LimitExceededError", allowed: [name], max_class_name_bytes: 32) + end + + def test_enforces_an_instance_variable_ceiling + blob = AdversarialCorpus.stream( + "o#{AdversarialCorpus.sym('F')}#{AdversarialCorpus.ivar_run(AdversarialCorpus::MODEST_ENTRY_COUNT)}" + ) + assert_ceiling_rejects(blob, "LimitExceededError", allowed: %w[F], max_instance_variables: 8) + end + + def test_enforces_a_struct_member_ceiling + blob = AdversarialCorpus.stream( + "S#{AdversarialCorpus.sym('F')}#{AdversarialCorpus.ivar_run(AdversarialCorpus::MODEST_ENTRY_COUNT)}" + ) + assert_ceiling_rejects(blob, "LimitExceededError", allowed: %w[F], max_struct_members: 8) + end + + def default_limit_probes + long = "A" * AdversarialCorpus::LONG_NAME_BYTES + bulk = AdversarialCorpus::BULK_ENTRY_COUNT + + { + "bignum magnitude" => + [AdversarialCorpus.stream(AdversarialCorpus.bignum("+", AdversarialCorpus::BIGNUM_HUGE_WORDS)), []], + "symbol references" => + [AdversarialCorpus.stream( + AdversarialCorpus.symlink_groups(AdversarialCorpus::SYMLINK_BULK_GROUPS, + AdversarialCorpus::SYMLINK_BULK_PER_GROUP) + ), []], + "symbol name bytes" => + [AdversarialCorpus.stream(AdversarialCorpus.sym(long)), []], + "class name bytes" => + [AdversarialCorpus.stream("c#{AdversarialCorpus.fixnum(long.bytesize)}#{long}"), [long]], + "instance variables" => + [AdversarialCorpus.stream("o#{AdversarialCorpus.sym('F')}#{AdversarialCorpus.ivar_run(bulk)}"), %w[F]], + "struct members" => + [AdversarialCorpus.stream("S#{AdversarialCorpus.sym('F')}#{AdversarialCorpus.ivar_run(bulk)}"), %w[F]] + } + end + + def test_default_limits_bound_every_axis_without_being_asked + admitted = default_limit_probes.reject do |_axis, (blob, allowed)| + decision = detector(allowed_class_names: allowed).inspect_stream(blob) + decision.rejected? && decision.reason.include?("LimitExceededError") + end + + assert_empty admitted.keys, + "Limits.new must bound these without a caller opting in: #{admitted.keys.join(', ')}" + end + + def test_permissive_limits_admit_what_the_defaults_reject + admitted = default_limit_probes.count do |_axis, (blob, allowed)| + detector(allowed_class_names: allowed, limits: Limits.permissive).inspect_stream(blob).accepted? + end + + assert_operator admitted, :>, 0, + "control: permissive must actually differ from the defaults, " \ + "otherwise the previous test proves nothing about the ceilings" end def test_never_exposes_a_safety_claiming_api diff --git a/PROJECTS/beginner/deserialization-gadget-lab/test/marshal/parser_test.rb b/PROJECTS/beginner/deserialization-gadget-lab/test/marshal/parser_test.rb index a831c361..9fa8c87c 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/test/marshal/parser_test.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/test/marshal/parser_test.rb @@ -35,6 +35,85 @@ module Rube assert_equal :nil, parse("\x04\x07\x30").root.type end + def ruby_accepts_stream?(bytes) + ::Marshal.load(bytes) + true + rescue ArgumentError, TypeError + false + end + + def parser_accepts_stream?(bytes) + parse(bytes) + true + rescue StreamError + false + end + + def test_header_version_agreement_with_real_ruby + probes = (0..9).to_h { |minor| ["4.#{minor}", "\x04#{minor.chr}0".b] } + observed = probes.transform_values { |bytes| ruby_accepts_stream?(bytes) } + + assert_includes observed.values, true, "no version accepted, so the oracle is dead" + assert_includes observed.values, false, "every version accepted, so the oracle proves nothing" + + mismatches = probes.filter_map do |label, bytes| + mine = parser_accepts_stream?(bytes) + next if mine == observed.fetch(label) + + "#{label}: ruby=#{observed.fetch(label)} parser=#{mine}" + end + + assert_empty mismatches, + "the parser is a forensic tool and must accept exactly what Marshal.load " \ + "accepts:\n #{mismatches.join("\n ")}" + end + + def test_result_reports_the_declared_version + result = parse("\x04\x07\x30") + assert_equal [4, 7], [result.major, result.minor] + refute_predicate result, :canonical_version? + + assert_predicate parse(::Marshal.dump(nil)), :canonical_version? + end + + class RoleFixture; end + + def class_name_slot_streams + name = "Rube::Marshal::ParserTest::RoleFixture" + symbol = AdversarialCorpus.sym(name) + + { + "symbol" => AdversarialCorpus.stream("o#{symbol}#{AdversarialCorpus.fixnum(0)}"), + "ivar wrapped symbol" => AdversarialCorpus.stream( + "oI#{symbol}#{AdversarialCorpus.fixnum(1)}#{AdversarialCorpus.sym('E')}T#{AdversarialCorpus.fixnum(0)}" + ), + "symlink" => AdversarialCorpus.stream( + "[#{AdversarialCorpus.fixnum(2)}#{symbol}o#{AdversarialCorpus.symlink(0)}#{AdversarialCorpus.fixnum(0)}" + ), + "fixnum" => AdversarialCorpus.stream("oi\x0a#{AdversarialCorpus.fixnum(0)}"), + "string" => AdversarialCorpus.stream("o#{AdversarialCorpus.str(name)}#{AdversarialCorpus.fixnum(0)}"), + "nil" => AdversarialCorpus.stream("o0#{AdversarialCorpus.fixnum(0)}") + } + end + + def test_class_name_slot_role_agreement_with_real_ruby + observed = class_name_slot_streams.transform_values { |bytes| ruby_accepts_stream?(bytes) } + + assert_includes observed.values, true, "no slot accepted, so the oracle is dead" + assert_includes observed.values, false, "every slot accepted, so the oracle proves nothing" + + mismatches = class_name_slot_streams.filter_map do |label, bytes| + mine = parser_accepts_stream?(bytes) + next if mine == observed.fetch(label) + + "#{label}: ruby=#{observed.fetch(label)} parser=#{mine}" + end + + assert_empty mismatches, + "a class-name slot accepts only a symbol, an ivar-wrapped symbol, or a " \ + "symlink:\n #{mismatches.join("\n ")}" + end + def test_rejects_unknown_tag assert_raises(UnknownTagError) { parse("\x04\x08\x00") } end @@ -65,6 +144,55 @@ module Rube end end + def ruby_rejects_bignum_sign?(bytes) + ::Marshal.load(bytes) + false + rescue ArgumentError, TypeError + true + end + + def parser_rejects_bignum_sign?(bytes) + parse(bytes) + false + rescue StreamError + true + end + + def test_bignum_sign_byte_agreement_with_real_ruby + probes = AdversarialCorpus::BIGNUM_SIGNS.to_h do |sign| + [sign, AdversarialCorpus.stream(AdversarialCorpus.bignum(sign, 1))] + end + observed = probes.transform_values { |bytes| ruby_rejects_bignum_sign?(bytes) } + + assert_includes observed.values, true, "no sign was rejected, so the oracle is dead" + assert_includes observed.values, false, "every sign was rejected, so the oracle proves nothing" + + mismatches = probes.filter_map do |sign, bytes| + mine = parser_rejects_bignum_sign?(bytes) + next if mine == observed.fetch(sign) + + "#{sign.inspect}: ruby_rejects=#{observed.fetch(sign)} parser_rejects=#{mine}" + end + + assert_empty mismatches, "bignum sign handling diverges from Marshal.load:\n #{mismatches.join("\n ")}" + end + + def test_bignum_magnitude_is_charged_the_same_budget_as_a_string + words = AdversarialCorpus::BIGNUM_HUGE_WORDS + magnitude = words * AdversarialCorpus::BIGNUM_WORD_BYTES + limits = Limits.new + + big = AdversarialCorpus.stream(AdversarialCorpus.bignum("+", words)) + str = AdversarialCorpus.stream(AdversarialCorpus.str("A" * magnitude)) + + assert_raises(LimitExceededError, "a #{magnitude}-byte string is rejected") do + Parser.new(str, limits: limits).parse + end + assert_raises(LimitExceededError, "so #{magnitude} bignum magnitude bytes must be too") do + Parser.new(big, limits: limits).parse + end + end + def test_parses_float assert_in_delta 3.5, roundtrip(3.5).value, 0.0 end @@ -105,6 +233,40 @@ module Rube assert_equal 0, link.value end + class LinkStringSubclass < String; end + + module LinkExtension; end + + def link_probe(first) + shared = +"ccc" + ::Marshal.dump([first, +"bbb", shared, shared]) + end + + def link_probes + { + "plain object (o)" => link_probe(Object.new), + "user_class (C)" => link_probe(LinkStringSubclass.new("aaa")), + "extended (e)" => link_probe((+"aaa").extend(LinkExtension)) + } + end + + def test_object_link_indices_match_ruby_for_wrapper_tags + mismatches = link_probes.filter_map do |label, blob| + link = parse(blob).nodes.find { |node| node.type == :object_link } + flunk "#{label}: fixture carries no object link, so it proves nothing" unless link + + ruby_target = ::Marshal.load(blob)[3] + next if link.link_target&.value == ruby_target + + "#{label}: index #{link.value} resolves to " \ + "#{link.link_target&.value.inspect}, Ruby resolves it to #{ruby_target.inspect}" + end + + assert_empty mismatches, + "a wrapper tag must occupy the same object-table slot Ruby gives it:\n " \ + "#{mismatches.join("\n ")}" + end + def test_rejects_out_of_bounds_object_link assert_raises(InvalidLinkError) { parse("\x04\x08@\x0a") } end @@ -184,9 +346,35 @@ module Rube assert_raises(MalformedCountError) { parse("\x04\x08S:\x06A\xFA") } end + def test_negative_length_is_caught_by_the_count_guard_not_the_take_guard + error = assert_raises(MalformedCountError) { parse("\x04\x08\"\xFA") } + assert_includes error.message, Constants::ROLE_LENGTH, + "take's own guard caught it, so read_count is not enforcing anything" + end + def test_negative_counts_never_rewind_the_cursor parser = Parser.new("\x04\x08\"\xFA") + before = parser.instance_variable_get(:@position) assert_raises(MalformedCountError) { parser.parse } + assert_operator parser.instance_variable_get(:@position), :>=, before, + "the cursor moved backwards, which is a loop primitive" + end + + def test_parses_regexp_and_consumes_its_options_byte + node = roundtrip(/pattern/i) + assert_equal :regexp, node.type + assert_equal "pattern", node.value + end + + def test_parses_regexp_nested_in_a_collection + node = roundtrip([/first/, /second/mx]) + assert_equal %w[first second], node.children.map(&:value) + end + + def test_regexp_options_byte_is_not_mistaken_for_the_next_value + node = roundtrip([/pattern/ix, :sentinel]) + assert_equal :symbol, node.children.last.type + assert_equal :sentinel, node.children.last.value end def test_every_malformed_count_stays_inside_the_stream_error_hierarchy @@ -261,11 +449,61 @@ module Rube assert_raises(DepthLimitError) { parse(deep) } end + def test_parser_defaults_to_bounded_limits_not_permissive + blob = AdversarialCorpus.stream(AdversarialCorpus.sym("A" * AdversarialCorpus::LONG_NAME_BYTES)) + + assert_raises(LimitExceededError, "a bare Parser.new must not be unbounded") do + Parser.new(blob).parse + end + assert_equal :symbol, Parser.new(blob, limits: Limits.permissive).parse.root.type, + "control: permissive must admit it, or a ceiling is not what rejected it" + end + + def test_default_depth_ceiling_is_the_limits_value_not_the_permissive_one + deep = AdversarialCorpus.stream(AdversarialCorpus.array_nest(Limits::DEFAULT_MAX_DEPTH + 5) + "0") + + assert_raises(DepthLimitError) { Parser.new(deep).parse } + assert_operator Limits::DEFAULT_MAX_DEPTH, :<, Constants::DEFAULT_MAX_DEPTH, + "control: the two depth constants must differ, or this test cannot discriminate" + end + def test_honours_custom_depth_limit blob = ::Marshal.dump([[[1]]]) assert_raises(DepthLimitError) { Parser.new(blob, max_depth: 2).parse } end + def test_instance_variable_wrapper_counts_toward_the_depth_limit + chain = AdversarialCorpus.stream( + AdversarialCorpus.ivar_chain(Constants::DEFAULT_MAX_DEPTH + 5) + ) + assert_raises(DepthLimitError) { parse(chain) } + end + + def test_deep_instance_variable_chain_rejects_before_the_ruby_stack_runs_out + chain = AdversarialCorpus.stream( + AdversarialCorpus.ivar_chain(AdversarialCorpus::IVAR_CHAIN_DEPTH) + ) + assert_raises(DepthLimitError) { parse(chain) } + end + + def parse_class_name_slot_at_ceiling(tail) + depth = AdversarialCorpus::USERDEF_NEST_DEPTH + blob = AdversarialCorpus.stream(AdversarialCorpus.array_nest(depth) + tail) + Parser.new(blob, max_depth: depth + 1).parse + end + + def test_object_class_name_slot_is_charged_the_enclosing_depth + tail = "o#{AdversarialCorpus.sym(AdversarialCorpus::OBJECT_CLASS)}#{AdversarialCorpus.fixnum(0)}" + assert_raises(DepthLimitError, "control failed, so the userdef test proves nothing") do + parse_class_name_slot_at_ceiling(tail) + end + end + + def test_userdef_class_name_slot_does_not_reset_the_depth_budget + tail = "u#{AdversarialCorpus.sym(AdversarialCorpus::OBJECT_CLASS)}#{AdversarialCorpus.fixnum(1)}x" + assert_raises(DepthLimitError) { parse_class_name_slot_at_ceiling(tail) } + end + def load_watcher fired = false tracer = TracePoint.new(:call, :c_call) do |tp| @@ -290,6 +528,107 @@ module Rube refute load_watcher { parse(blob) }, "parser invoked Marshal.load" end + FIRED = [] + + module RecordsHash + def hash + FIRED << self.class.to_s + super + end + end + + class PlainKey + include RecordsHash + end + + class StringKey < String + include RecordsHash + end + + class ArrayKey < Array + include RecordsHash + end + + StructKey = Struct.new(:a) { include RecordsHash } + + def dispatch_probes + { + "plain object (o)" => PlainKey.new, + "struct (S)" => StructKey.new(1), + "Array subclass (C)" => ArrayKey.new, + "String subclass (C)" => StringKey.new("x"), + "extended object (e)" => PlainKey.new.extend(RecordsHash), + "extended string (e)" => (+"x").extend(RecordsHash), + "plain string" => "x", + "symbol" => :x, + "integer" => 7, + "regexp" => /ab/ + } + end + + def ruby_dispatches?(key) + FIRED.clear + ::Marshal.load(::Marshal.dump({ key => nil })) + !FIRED.empty? + end + + def parser_predicts_dispatch?(key) + parse(::Marshal.dump({ key => nil })).dispatching_hash_keys.any? + end + + def test_hash_key_dispatch_prediction_matches_real_ruby + observed = dispatch_probes.to_h { |label, key| [label, ruby_dispatches?(key)] } + + assert_includes observed.values, true, "no probe dispatched, so the oracle is dead" + assert_includes observed.values, false, "every probe dispatched, so the oracle proves nothing" + + mismatches = dispatch_probes.filter_map do |label, key| + predicted = parser_predicts_dispatch?(key) + next if predicted == observed.fetch(label) + + "#{label}: ruby=#{observed.fetch(label)} parser=#{predicted}" + end + + assert_empty mismatches, "parser disagrees with Marshal.load:\n #{mismatches.join("\n ")}" + end + + def test_the_same_class_is_only_flagged_in_key_position + in_key = parse(AdversarialCorpus::HASH_KEY_SHAPES_THAT_DISPATCH[:object]) + in_value = parse(AdversarialCorpus::OBJECT_IN_VALUE_POSITION) + + assert_equal 1, in_key.dispatching_hash_keys.length + assert_empty in_value.dispatching_hash_keys, + "position is the whole signal, so a value-position object must not be flagged" + end + + def test_every_dispatching_key_shape_is_detected + blind = AdversarialCorpus::HASH_KEY_SHAPES_THAT_DISPATCH.reject do |_shape, bytes| + parse(bytes).dispatching_hash_keys.any? + end + + assert_empty blind.keys, "key-position dispatch missed in: #{blind.keys.join(', ')}" + end + + def test_non_dispatching_key_shapes_are_not_flagged + noisy = AdversarialCorpus::HASH_KEY_SHAPES_THAT_DO_NOT_DISPATCH.select do |_shape, bytes| + parse(bytes).dispatching_hash_keys.any? + end + + assert_empty noisy.keys, "false positive on: #{noisy.keys.join(', ')}" + end + + def test_struct_member_name_slot_is_not_scanned_as_a_hash_key + blob = AdversarialCorpus.stream( + "S#{AdversarialCorpus.sym(AdversarialCorpus::OBJECT_CLASS)}" \ + "#{AdversarialCorpus.fixnum(1)}#{AdversarialCorpus::PLAIN_OBJECT}0" + ) + + assert_equal 1, parse(blob).nodes.count { |node| node.type == :pair }, + "control: the fixture must actually contain a struct pair" + assert_empty parse(blob).dispatching_hash_keys, + "read_pair is shared with read_struct, so struct pairs must not be scanned" + end + class Fixture @instantiated = false diff --git a/PROJECTS/beginner/deserialization-gadget-lab/test/scanner_test.rb b/PROJECTS/beginner/deserialization-gadget-lab/test/scanner_test.rb index 701250f8..86c64ab9 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/test/scanner_test.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/test/scanner_test.rb @@ -113,6 +113,8 @@ module Rube def test_report_partitions_gated_and_ungated report = local_scan + refute_empty report.gated, "a partition test proves nothing if one side is empty" + refute_empty report.ungated, "a partition test proves nothing if one side is empty" assert_equal report.candidates.length, report.gated.length + report.ungated.length assert_empty(report.gated & report.ungated) end @@ -123,9 +125,14 @@ module Rube assert_equal first, second end - def test_anonymous_classes_are_skipped - Class.new { def marshal_load(data); end } - refute(local_scan.candidates.any? { |c| c.class_name.nil? || c.class_name.empty? }) + def test_anonymous_classes_contribute_no_candidate + anonymous = Class.new { def marshal_load(data); end } + location = anonymous.instance_method(:marshal_load).source_location.join(":") + + report = scan + refute_empty report.gated, "the global scan found nothing, so absence proves nothing" + refute_includes report.candidates.map(&:source_location), location, + "a class with no name reached the report" end def test_scanning_does_not_instantiate_anything diff --git a/PROJECTS/beginner/deserialization-gadget-lab/test/support/adversarial_corpus.rb b/PROJECTS/beginner/deserialization-gadget-lab/test/support/adversarial_corpus.rb index c3c863db..b945a5b8 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/test/support/adversarial_corpus.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/test/support/adversarial_corpus.rb @@ -52,10 +52,50 @@ module Rube allowed: allowed.freeze }.freeze end + def ivar_chain(depth) + (("I" * depth) + "0" + ("\x00" * depth)).b + end + + def array_nest(depth) + ("[#{fixnum(1)}" * depth).b + end + + def regexp(source) + "/#{fixnum(source.bytesize)}#{source}\x00".b + end + + def bignum(sign, word_count, fill = "\x01") + "l#{sign}#{fixnum(word_count)}#{fill * (word_count * BIGNUM_WORD_BYTES)}".b + end + + def symlink_run(count) + (sym("s") + (symlink(0) * count)).b + end + + def symlink_groups(groups, per_group) + inner = "[#{fixnum(per_group)}#{symlink(0) * per_group}" + "[#{fixnum(groups + 1)}#{sym('s')}#{inner * groups}".b + end + + def ivar_run(count) + "#{fixnum(count)}#{sym('@a')}0#{"#{symlink(1)}0" * (count - 1)}".b + end + NESTED_DEPTH = 80 WIDE_COUNT = 4_096 MANY_SYMBOLS = 400 HUGE_SCALAR = 300_000 + IVAR_CHAIN_DEPTH = 6_000 + USERDEF_NEST_DEPTH = 3 + BIGNUM_WORD_BYTES = 2 + BIGNUM_HUGE_WORDS = 200_000 + LONG_NAME_BYTES = 100_000 + BULK_ENTRY_COUNT = 1_000 + MODEST_NAME_BYTES = 64 + MODEST_ENTRY_COUNT = 16 + SYMLINK_BULK_GROUPS = 3 + SYMLINK_BULK_PER_GROUP = 1_000 + BIGNUM_SIGNS = ["-", "+", "!", "\x00", "\xFF", "0"].freeze TRIPWIRE_CLASS = "Tripwire" HOST_CLASS = "Comparable" @@ -95,6 +135,27 @@ module Rube IVAR_DISTINCT_NAMES_CONTROL = stream("I#{str('hello')}#{fixnum(2)}#{sym(COLLIDING_IVAR)}#{TRIPWIRE}#{sym(HIDDEN_IVAR)}0") + PLAIN_OBJECT = "o#{sym(OBJECT_CLASS)}#{fixnum(0)}".b + + HASH_KEY_SHAPES_THAT_DISPATCH = { + object: stream("{#{fixnum(1)}#{PLAIN_OBJECT}0"), + object_link: stream("[#{fixnum(2)}#{PLAIN_OBJECT}{#{fixnum(1)}@#{fixnum(1)}0"), + struct: stream("{#{fixnum(1)}S#{sym(OBJECT_CLASS)}#{fixnum(0)}0"), + extended: stream("{#{fixnum(1)}e#{sym(HOST_CLASS)}#{PLAIN_OBJECT}0"), + user_class_over_array: stream("{#{fixnum(1)}C#{sym(OBJECT_CLASS)}[#{fixnum(0)}0") + }.freeze + + HASH_KEY_SHAPES_THAT_DO_NOT_DISPATCH = { + user_class_over_string: stream("{#{fixnum(1)}C#{sym(OBJECT_CLASS)}#{str('x')}0"), + extended_over_string: stream("{#{fixnum(1)}e#{sym(HOST_CLASS)}#{str('x')}0"), + regexp: stream("{#{fixnum(1)}#{regexp('ab')}0"), + symbol: stream("{#{fixnum(1)}#{sym('k')}0"), + string: stream("{#{fixnum(1)}#{str('k')}0") + }.freeze + + OBJECT_IN_VALUE_POSITION = + stream("{#{fixnum(1)}#{sym('k')}#{PLAIN_OBJECT}") + CASES = [ entry(:nil_literal, stream("0"), VERDICT_ACCEPT, "the smallest legal stream"), @@ -161,6 +222,9 @@ module Rube "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(:deep_instance_variable_chain, stream(ivar_chain(IVAR_CHAIN_DEPTH)), VERDICT_REJECT, + "the I wrapper must charge depth, or a 12 KB cookie exhausts the Ruby stack " \ + "with a SystemStackError that no rescue StreamError can catch"), 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}"), @@ -207,7 +271,41 @@ module Rube allowed: [HOST_CLASS]), entry(:class_name_slot_extended, CLASS_NAME_SLOTS[:extended], VERDICT_REJECT, "byte-identical gadget in an extend wrapper slot", - allowed: [HOST_CLASS]) + allowed: [HOST_CLASS]), + + entry(:object_in_value_position_control, OBJECT_IN_VALUE_POSITION, VERDICT_ACCEPT, + "control: an allowlisted class is fine as a hash VALUE, which is what makes the " \ + "key-position cases below a statement about position rather than about the class", + allowed: [OBJECT_CLASS]), + + entry(:hash_key_object, HASH_KEY_SHAPES_THAT_DISPATCH[:object], VERDICT_REJECT, + "an allowlisted class in KEY position runs its #hash during load", + allowed: [OBJECT_CLASS]), + entry(:hash_key_object_link, HASH_KEY_SHAPES_THAT_DISPATCH[:object_link], VERDICT_REJECT, + "the same dispatch reached through an object link instead of a fresh object", + allowed: [OBJECT_CLASS]), + entry(:hash_key_struct, HASH_KEY_SHAPES_THAT_DISPATCH[:struct], VERDICT_REJECT, + "a struct in key position dispatches too", + allowed: [OBJECT_CLASS]), + entry(:hash_key_extended, HASH_KEY_SHAPES_THAT_DISPATCH[:extended], VERDICT_REJECT, + "an extended object in key position dispatches the module's #hash", + allowed: [OBJECT_CLASS, HOST_CLASS]), + entry(:hash_key_user_class_over_array, HASH_KEY_SHAPES_THAT_DISPATCH[:user_class_over_array], + VERDICT_REJECT, + "an Array subclass in key position dispatches the subclass #hash", + allowed: [OBJECT_CLASS]), + + entry(:hash_key_user_class_over_string, HASH_KEY_SHAPES_THAT_DO_NOT_DISPATCH[:user_class_over_string], + VERDICT_ACCEPT, + "precision control: rb_any_hash keys on T_STRING, so a String subclass never " \ + "reaches a user #hash and rejecting it would be a false positive", + allowed: [OBJECT_CLASS]), + entry(:hash_key_extended_over_string, HASH_KEY_SHAPES_THAT_DO_NOT_DISPATCH[:extended_over_string], + VERDICT_ACCEPT, + "same exemption when the string is reached through an extend wrapper", + allowed: [HOST_CLASS]), + entry(:hash_key_regexp, HASH_KEY_SHAPES_THAT_DO_NOT_DISPATCH[:regexp], VERDICT_ACCEPT, + "a regexp key names no class, so the payload cannot choose whose #hash runs") ].freeze end end