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