diff --git a/PROJECTS/beginner/deserialization-gadget-lab/.rubocop.yml b/PROJECTS/beginner/deserialization-gadget-lab/.rubocop.yml index 232b5dfc..ca78cb86 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/.rubocop.yml +++ b/PROJECTS/beginner/deserialization-gadget-lab/.rubocop.yml @@ -11,20 +11,40 @@ AllCops: TargetRubyVersion: 3.3 Exclude: - "vendor/**/*" - - "target/**/*" - "docs/**/*" Style/Documentation: Enabled: false -Style/FrozenStringLiteralComment: +Style/StringLiterals: + EnforcedStyle: double_quotes + +Style/StringLiteralsInInterpolation: + EnforcedStyle: single_quotes + +Style/FormatStringToken: Enabled: false +Style/RedundantFormat: + Enabled: false + +Style/StringConcatenation: + Enabled: false + +Security/MarshalLoad: + Enabled: false + +Minitest/EmptyLineBeforeAssertionMethods: + Enabled: false + +Minitest/MultipleAssertions: + Max: 12 + Metrics/MethodLength: Max: 30 Metrics/AbcSize: - Max: 30 + Max: 50 Metrics/CyclomaticComplexity: Max: 30 @@ -32,8 +52,47 @@ Metrics/CyclomaticComplexity: Metrics/PerceivedComplexity: Max: 30 +Metrics/ParameterLists: + CountKeywordArgs: false + Metrics/ClassLength: Max: 250 + Exclude: + - "test/**/*" + +Metrics/ModuleLength: + Exclude: + - "test/**/*" Layout/LineLength: Max: 120 + +Lint/RescueException: + Exclude: + - "test/**/*" + +Lint/EmptyClass: + Exclude: + - "test/**/*" + +Lint/UselessMethodDefinition: + Exclude: + - "test/**/*" + +Style/MutableConstant: + Exclude: + - "test/**/*" + +Minitest/AssertTruthy: + Enabled: false + +Minitest/RefuteFalse: + Enabled: false + +Style/EvalWithLocation: + Exclude: + - "test/**/*" + +Style/OptionalBooleanParameter: + Exclude: + - "test/**/*" diff --git a/PROJECTS/beginner/deserialization-gadget-lab/CHANGELOG.md b/PROJECTS/beginner/deserialization-gadget-lab/CHANGELOG.md index 99b77ba1..f10b70f8 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/CHANGELOG.md +++ b/PROJECTS/beginner/deserialization-gadget-lab/CHANGELOG.md @@ -26,8 +26,49 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 CVE-2026-41316 (ERB `@_init`) as the reference chain - Deliberately vulnerable Sinatra target with one endpoint that loads a session cookie directly and one that inspects the stream first -- `BoundaryDetector` with three policies, a frozen accepted snapshot, and a - written `LIMITATION_NOTICE` naming a bypass it cannot catch +- `BoundaryDetector` with three policies, a frozen snapshot on any decision that + is not blocked, and a written `LIMITATION_NOTICE` naming a bypass it cannot + catch +- `Decision` reports exactly one of three states — `proceed?`, `blocked?`, + `observed?` — validated in the constructor so they cannot overlap. `proceed?` + is the only predicate that should gate a `Marshal.load`; `observed?` is the + non-blocking observe-and-log outcome and a caller opts into it by name. There + is no `accepted?`, because one predicate cannot answer both "did the policy + permit this" and "is this stream free of violations" +- Scanner error accounting: every swallowed rescue is recorded with its site, + subject and error class, and `Report` exposes `suppressed_count`, + `suppressions_by_site`, `complete?` and `candidates_lost?` +- Four-state reachability analysis. A method is analysed (touches state or does + not), `unreadable_source?` (a path was given and could not be parsed, so it + fails open and stays reachable), or `unanalysable?` (no Ruby source exists at + all, so it is reported rather than guessed at). `Report#unanalysable` and + `#fully_analysed?` state the filter's real coverage instead of implying it saw + everything +- Reject reasons escape and bound every attacker-controlled class name before it + reaches a caller-supplied reporter, so a name carrying CR, LF, ESC or NUL can + no longer forge log lines + +- Float bodies decode to the same value `Marshal.load` produces, including + `inf`, `-inf` and `nan`, hex literals, and the prefix-and-stop behaviour that + makes `"1_0"` parse as 1.0 and `"abc"` as 0.0. A body carrying Ruby's legacy + binary mantissa is reported through `Node#undecoded_tail` rather than guessed + at, so `fully_decoded?` is false instead of a plausible wrong number +- The parse graph is sealed before it is returned. Every node, its collections + and its scalar values are frozen, so a caller cannot rewrite a verdict field + or splice a node into a graph the parser already reported on + +### Fixed + +- `read_float` returned `nil` for seven classes of body that `Marshal.load` + accepts, including the `inf`/`-inf`/`nan` forms Ruby emits today +- Bounds checks on symlink and object-link indices relied on negative-index + wraparound being caught by a second clause; they now say what they mean +- `Constants::GATED_SINK_TAGS` omitted `TAG_DATA` while `Scanner::GATED_METHODS` + listed `_load_data`, so the two halves disagreed about which sinks are gated. + `Marshal.load` does check `respond_to?(:_load_data)` before dispatching, which + a hand-built `d` stream naming a real C-level `T_DATA` demonstrates directly +- The defended target endpoint returned HTTP 500 with a source snippet for any + root the detector accepted that was not a session hash - Detection of objects placed in **hash key** position, where `#hash` and `#eql?` are dispatched during load before any allowlist can act. Scoped to keys whose reconstructed value is not a `T_STRING`, matching what `Marshal.load` actually diff --git a/PROJECTS/beginner/deserialization-gadget-lab/Gemfile b/PROJECTS/beginner/deserialization-gadget-lab/Gemfile index da97d140..5f5468fc 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/Gemfile +++ b/PROJECTS/beginner/deserialization-gadget-lab/Gemfile @@ -1,5 +1,6 @@ # ©AngelaMos | 2026 # Gemfile +# frozen_string_literal: true source "https://rubygems.org" diff --git a/PROJECTS/beginner/deserialization-gadget-lab/README.md b/PROJECTS/beginner/deserialization-gadget-lab/README.md index a4796c09..e44310fd 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/README.md +++ b/PROJECTS/beginner/deserialization-gadget-lab/README.md @@ -37,7 +37,9 @@ All six pieces are built and tested. - **Version-compatibility matrix** — probes six pinned Ruby images and reports where the published git gadget and the ERB `@_init` guard actually change. - **Reflection-based gadget scanner** — walks `ObjectSpace` for auto-invoked methods and - classifies them by whether `Marshal.load` can reach them. + classifies them by whether `Marshal.load` can reach them. It counts every error it + swallows, names the site, and treats a method it could not analyse as reachable rather + than inert, so under-reporting is visible instead of silent. - **Payload builder** — version-scoped chains carrying their own affected ranges. - **Vulnerable containerized target** — a Sinatra app with one endpoint that loads a session cookie and one that inspects it first. @@ -70,12 +72,33 @@ and hands back a frozen snapshot: detector = Rube::Marshal::BoundaryDetector.new(allowed_class_names: %w[Hash String]) decision = detector.inspect_stream(untrusted_bytes) -decision.rejected? # => true +decision.blocked? # => true decision.reason # => "stream reaches Gem::Requirement#marshal_load during load, ..." ``` -Read `Rube::Marshal::BoundaryDetector::LIMITATION_NOTICE` before relying on an accept. -An accepted stream is not a safe one, and the notice says so in detail. +A decision is in exactly one of three states, and `proceed?` is the only one that gates a +load: + +```ruby +Marshal.load(decision.snapshot) if decision.proceed? +``` + +`proceed?` means the policy found no violation. `blocked?` means it found one and refused. +`observed?` is the third state, and it exists because `POLICY_OBSERVE_AND_LOG` is +non-blocking by design: a violation was found, reported, and deliberately not enforced. Such +a decision still carries its snapshot, so a caller running in monitoring mode opts in by +naming that state out loud: + +```ruby +Marshal.load(decision.snapshot) if decision.proceed? || decision.observed? +``` + +There is no `accepted?`. The question "did the policy permit this" and the question "is +this stream free of violations" have different answers under observe-and-log, and one +predicate cannot answer both. + +Read `Rube::Marshal::BoundaryDetector::LIMITATION_NOTICE` before relying on `proceed?`. +A stream that proceeds is not a safe one, and the notice says so in detail. Nothing above instantiates a class, calls a constructor, or invokes `Marshal.load`. diff --git a/PROJECTS/beginner/deserialization-gadget-lab/Rakefile b/PROJECTS/beginner/deserialization-gadget-lab/Rakefile index 032347ee..60586ca9 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/Rakefile +++ b/PROJECTS/beginner/deserialization-gadget-lab/Rakefile @@ -1,5 +1,6 @@ # ©AngelaMos | 2026 # Rakefile +# frozen_string_literal: true require "rake/testtask" diff --git a/PROJECTS/beginner/deserialization-gadget-lab/justfile b/PROJECTS/beginner/deserialization-gadget-lab/justfile index 068bfc58..6abf887a 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/justfile +++ b/PROJECTS/beginner/deserialization-gadget-lab/justfile @@ -7,6 +7,7 @@ image := "ruby:4.0-slim" vuln_image := "ruby:4.0.2-slim" run := "docker run --rm --network none -v $PWD:/app -w /app " + image run_ro := "docker run --rm --network none -v $PWD:/app:ro -w /app " + image +lint_run := "docker run --rm -v $PWD:/app -w /app " + image default: @just --list @@ -19,16 +20,16 @@ test: {{run_ro}} ruby -Ilib -Itest test/corpus_test.rb corpus: - {{run_ro}} ruby -Ilib -Itest -e 'require "rube"; require "support/adversarial_corpus"; Rube::AdversarialCorpus::CASES.each { |k| d = Rube::Marshal::BoundaryDetector.new(allowed_class_names: k[:allowed]); dec = d.inspect_stream(k[:bytes]); puts format(" %-38s %-6s %-10s %s", k[:name], dec.accepted? ? "accept" : "reject", k[:allowed].join(","), dec.reason.to_s[0, 52]) }' + {{run_ro}} ruby -Ilib -Itest -e 'require "rube"; require "support/adversarial_corpus"; Rube::AdversarialCorpus::CASES.each { |k| d = Rube::Marshal::BoundaryDetector.new(allowed_class_names: k[:allowed]); dec = d.inspect_stream(k[:bytes]); puts format(" %-38s %-6s %-10s %s", k[:name], dec.proceed? ? "accept" : "reject", k[:allowed].join(","), dec.reason.to_s[0, 52]) }' scan namespace="": - {{run_ro}} ruby -Ilib -e 'require "rube"; ns = "{{namespace}}"; r = Rube::Scanner.new(namespace: ns.empty? ? nil : ns).scan; puts "modules=#{r.scanned_modules} candidates=#{r.candidates.length} gated=#{r.gated.length} reachable=#{r.reachable.length}"; puts; r.reachable.each { |c| puts format(" %-10s %-46s %s", c.gate, c.to_s, c.source_location) }' + {{run_ro}} ruby -Ilib -e 'require "rube"; ns = "{{namespace}}"; r = Rube::Scanner.new(namespace: ns.empty? ? nil : ns).scan; puts "modules=#{r.scanned_modules} candidates=#{r.candidates.length} gated=#{r.gated.length} reachable=#{r.reachable.length} suppressed=#{r.suppressed_count} candidates_lost=#{r.candidates_lost?}"; puts "analysed=#{r.candidates.count(&:state_known?)} unanalysable=#{r.unanalysable.length} unreadable=#{r.candidates.count(&:unreadable_source?)}"; puts; r.reachable.each { |c| puts format(" %-10s %-46s %s", c.gate, c.to_s, c.source_location) }; unless r.complete?; puts; puts "suppressed errors (this scan under-reports):"; r.suppressions_by_site.each { |site, n| puts format(" %-16s %d", site, n) }; end; unless r.fully_analysed?; puts; puts "#{r.unanalysable.length} candidates have no Ruby source and were never analysed; the reachability filter does not cover them"; end' control: {{run_ro}} ruby -Ilib -Itest test/control_check.rb lint: - {{run}} sh -c "gem install --no-document rubocop rubocop-minitest rubocop-performance rubocop-rake >/dev/null 2>&1 && rubocop --force-exclusion" + {{lint_run}} sh -c "set -e; gem install --no-document rubocop rubocop-minitest rubocop-performance rubocop-rake >/dev/null; rubocop --force-exclusion" check: test control diff --git a/PROJECTS/beginner/deserialization-gadget-lab/lib/rube.rb b/PROJECTS/beginner/deserialization-gadget-lab/lib/rube.rb index 9b6061ab..4bd18c08 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/lib/rube.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/lib/rube.rb @@ -1,10 +1,12 @@ # ©AngelaMos | 2026 # rube.rb +# frozen_string_literal: true require_relative "rube/version" require_relative "rube/marshal/constants" require_relative "rube/marshal/errors" require_relative "rube/marshal/node" +require_relative "rube/marshal/float_body" require_relative "rube/marshal/limits" require_relative "rube/marshal/parser" require_relative "rube/marshal/boundary_detector" diff --git a/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/chains.rb b/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/chains.rb index 42cc3002..eafe027e 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/chains.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/chains.rb @@ -1,5 +1,6 @@ # ©AngelaMos | 2026 # chains.rb +# frozen_string_literal: true module Rube module Chains @@ -8,14 +9,16 @@ module Rube @registry = [] class << self - attr_reader :registry + def registry + @registry.dup.freeze + end def register(chain) @registry << chain unless @registry.include?(chain) end def all - registry.reject { |chain| chain == Base } + @registry.reject { |chain| chain == Base } end def find(name) diff --git a/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/chains/base.rb b/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/chains/base.rb index 0732465d..133298f5 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/chains/base.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/chains/base.rb @@ -1,5 +1,6 @@ # ©AngelaMos | 2026 # base.rb +# frozen_string_literal: true module Rube module Chains @@ -8,7 +9,6 @@ module Rube class NotImplementedByChainError < ChainError; end class Base - NAMESPACE_SEPARATOR = "::" SUBCLASS_MUST_DEFINE = "chain must define" class << self diff --git a/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/chains/erb_def_method.rb b/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/chains/erb_def_method.rb index 08fb5dcb..5193b060 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/chains/erb_def_method.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/chains/erb_def_method.rb @@ -1,5 +1,6 @@ # ©AngelaMos | 2026 # erb_def_method.rb +# frozen_string_literal: true require "erb" @@ -27,7 +28,7 @@ module Rube IVAR_FILENAME = :@filename IVAR_LINENO = :@lineno - CANARY_TEMPLATE = 'File.write(%p, %p)' + CANARY_TEMPLATE = "File.write(%p, %p)" def self.metadata { 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 34f42117..e36db2cc 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 @@ -1,5 +1,6 @@ # ©AngelaMos | 2026 # boundary_detector.rb +# frozen_string_literal: true module Rube module Marshal @@ -12,8 +13,6 @@ module Rube POLICIES = [POLICY_STRICT_ALLOWLIST, POLICY_DENY_SINKS_ONLY, POLICY_OBSERVE_AND_LOG].freeze - PRIMITIVE_CLASS_NAMES = %w[].freeze - REASON_INPUT_TYPE = "input is not a String" REASON_MALFORMED = "stream is not canonical Marshal: %s" REASON_SINK = "stream reaches %s#%s during load, before any allowlist can run" @@ -23,7 +22,14 @@ module Rube REASON_NONCANONICAL_VERSION = "stream declares Marshal %d.%d; every Ruby that can produce " \ "this format emits %d.%d" - LIMITATION_NOTICE = <<~NOTICE.freeze + REASON_MAX_NAME_BYTES = 96 + REASON_MAX_NAMES = 8 + REASON_TRUNCATED_MARKER = "[truncated" + REASON_TRUNCATED = "#{REASON_TRUNCATED_MARKER}, +%d bytes]".freeze + REASON_ELIDED_NAMES = ", and %d more" + REASON_NAME_SEPARATOR = ", " + + LIMITATION_NOTICE = <<~NOTICE SECURITY LIMITATION Rube::Marshal::BoundaryDetector examines a bounded snapshot of Marshal bytes and @@ -44,31 +50,36 @@ module Rube NOTICE class Decision - attr_reader :reason, :snapshot, :result + STATE_PROCEED = :proceed + STATE_BLOCKED = :blocked + STATE_OBSERVED = :observed - def initialize(accepted:, reason: nil, snapshot: nil, result: nil, observed: false, would_reject: false) - @accepted = accepted + STATES = [STATE_PROCEED, STATE_BLOCKED, STATE_OBSERVED].freeze + STATE_PREDICATES = %i[proceed? blocked? observed?].freeze + + UNKNOWN_STATE = "unknown decision state %p, expected one of %s" + + attr_reader :state, :reason, :snapshot, :result + + def initialize(state:, reason: nil, snapshot: nil, result: nil) + raise ArgumentError, format(UNKNOWN_STATE, state, STATES.join(", ")) unless STATES.include?(state) + + @state = state @reason = reason @snapshot = snapshot @result = result - @observed = observed - @would_reject = would_reject end - def accepted? - @accepted + def proceed? + state == STATE_PROCEED end - def rejected? - !@accepted + def blocked? + state == STATE_BLOCKED end def observed? - @observed - end - - def would_reject? - @would_reject + state == STATE_OBSERVED end end @@ -106,12 +117,28 @@ module Rube reject(violation) end + def quoted(name) + raw = name.to_s.dup.force_encoding(Encoding::BINARY) + return raw.inspect if raw.bytesize <= REASON_MAX_NAME_BYTES + + "#{raw.byteslice(0, REASON_MAX_NAME_BYTES).inspect}" \ + "#{format(REASON_TRUNCATED, raw.bytesize - REASON_MAX_NAME_BYTES)}" + end + + def quoted_list(names) + shown = names.first(REASON_MAX_NAMES).map { |name| quoted(name) }.join(REASON_NAME_SEPARATOR) + elided = names.length - REASON_MAX_NAMES + return shown unless elided.positive? + + "#{shown}#{format(REASON_ELIDED_NAMES, elided)}" + end + def violation_for(result) sink = result.sinks.first - return format(REASON_SINK, sink.class_name, sink.sink_method) if sink + return format(REASON_SINK, quoted(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 format(REASON_KEY_DISPATCH, quoted(key.effective_class_name)) if key return nil if policy == POLICY_DENY_SINKS_ONLY @@ -121,23 +148,23 @@ module Rube end unapproved = result.class_names.reject { |name| allowed_class_names.include?(name) } - return format(REASON_UNAPPROVED, unapproved.join(", ")) unless unapproved.empty? + return format(REASON_UNAPPROVED, quoted_list(unapproved)) unless unapproved.empty? nil end def observe(violation, snapshot, result) reporter.call(violation) - Decision.new(accepted: true, reason: violation, snapshot: snapshot, - result: result, observed: true, would_reject: true) + Decision.new(state: Decision::STATE_OBSERVED, reason: violation, + snapshot: snapshot, result: result) end def accept(snapshot, result) - Decision.new(accepted: true, snapshot: snapshot, result: result) + Decision.new(state: Decision::STATE_PROCEED, snapshot: snapshot, result: result) end def reject(reason) - Decision.new(accepted: false, reason: reason) + Decision.new(state: Decision::STATE_BLOCKED, reason: reason) end end end 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 096e7a1b..de4bff54 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/marshal/constants.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/marshal/constants.rb @@ -1,5 +1,6 @@ # ©AngelaMos | 2026 # constants.rb +# frozen_string_literal: true module Rube module Marshal @@ -34,6 +35,16 @@ module Rube TAG_DATA = "d" TAG_IVAR = "I" + NUL_BYTE = "\x00" + + FLOAT_NAN = "nan" + FLOAT_INFINITY = "inf" + FLOAT_NEGATIVE_INFINITY = "-inf" + + FLOAT_LEADING_SPACE = "[ \t\n\v\f\r]*" + FLOAT_HEX_PREFIX = /\A#{FLOAT_LEADING_SPACE}[+-]?0[xX](?:\h+(?:\.\h*)?|\.\h+)(?:[pP][+-]?\d+)?/ + FLOAT_DECIMAL_PREFIX = /\A#{FLOAT_LEADING_SPACE}[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?/ + BIGNUM_SIGN_POSITIVE = "+" BIGNUM_SIGN_NEGATIVE = "-" BIGNUM_SIGNS = [BIGNUM_SIGN_POSITIVE, BIGNUM_SIGN_NEGATIVE].freeze @@ -69,7 +80,7 @@ module Rube TAG_DATA => "_load_data" }.freeze - GATED_SINK_TAGS = [TAG_USERDEF, TAG_USERMARSHAL].freeze + GATED_SINK_TAGS = [TAG_USERDEF, TAG_USERMARSHAL, TAG_DATA].freeze end end end 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 21c31542..3b202023 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/marshal/errors.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/marshal/errors.rb @@ -1,5 +1,6 @@ # ©AngelaMos | 2026 # errors.rb +# frozen_string_literal: true module Rube module Marshal diff --git a/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/marshal/float_body.rb b/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/marshal/float_body.rb new file mode 100644 index 00000000..41814534 --- /dev/null +++ b/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/marshal/float_body.rb @@ -0,0 +1,40 @@ +# ©AngelaMos | 2026 +# float_body.rb +# frozen_string_literal: true + +module Rube + module Marshal + module FloatBody + module_function + + def decode(body) + text = body.b + named = named_value(text) + return [named, nil] if named + + token = prefix(text) + tail = text.byteslice(token.bytesize..) + [value_of(token), tail.empty? ? nil : tail] + end + + def named_value(text) + case text.byteslice(0, text.index(Constants::NUL_BYTE) || text.bytesize) + when Constants::FLOAT_NAN then Float::NAN + when Constants::FLOAT_INFINITY then Float::INFINITY + when Constants::FLOAT_NEGATIVE_INFINITY then -Float::INFINITY + end + end + + def prefix(text) + match = Constants::FLOAT_HEX_PREFIX.match(text) || + Constants::FLOAT_DECIMAL_PREFIX.match(text) + match ? match[0] : "" + end + + def value_of(token) + stripped = token.strip + stripped.empty? ? 0.0 : Float(stripped) + end + end + end +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 60065c21..ff1f97e0 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/marshal/limits.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/marshal/limits.rb @@ -1,5 +1,6 @@ # ©AngelaMos | 2026 # limits.rb +# frozen_string_literal: true module Rube module Marshal 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 637beb59..8dfefa04 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/marshal/node.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/marshal/node.rb @@ -1,5 +1,6 @@ # ©AngelaMos | 2026 # node.rb +# frozen_string_literal: true module Rube module Marshal @@ -7,19 +8,24 @@ module Rube STRING_BACKED_TYPES = %i[string regexp].freeze WRAPPER_TYPES = %i[user_class extended].freeze - attr_reader :type, :tag, :children, :instance_variables_map, :auxiliary + attr_reader :type, :tag, :children, :instance_variables_map, :auxiliary, :undecoded_tail attr_accessor :value, :class_name, :link_target - def initialize(type:, tag: nil, value: nil, class_name: nil) + def initialize(type:, tag: nil, value: nil, class_name: nil, undecoded_tail: nil) @type = type @tag = tag @value = value @class_name = class_name + @undecoded_tail = undecoded_tail @children = [] @instance_variables_map = {} @auxiliary = [] end + def fully_decoded? + undecoded_tail.nil? + end + def sink? Constants::SINK_TAGS.include?(tag) end @@ -58,6 +64,16 @@ module Rube children.each { |child| child.each(&block) } auxiliary.each { |child| child.each(&block) } end + + def seal + value.freeze + class_name.freeze + undecoded_tail.freeze + children.freeze + auxiliary.freeze + instance_variables_map.freeze + freeze + end end class Result 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 d10dabdb..d18c957a 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/marshal/parser.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/marshal/parser.rb @@ -1,5 +1,6 @@ # ©AngelaMos | 2026 # parser.rb +# frozen_string_literal: true module Rube module Marshal @@ -24,7 +25,8 @@ module Rube root = read_value(1) raise TrailingBytesError, "#{remaining} unread bytes" unless remaining.zero? - Result.new(root, major: @major, minor: @minor) + root.each(&:seal) + Result.new(root, major: @major, minor: @minor).freeze end private @@ -80,8 +82,6 @@ module Rube return marker + FIXNUM_INLINE_OFFSET if marker < FIXNUM_MIN_INLINE width = marker.abs - raise TruncatedStreamError, "fixnum width #{width}" if width > FIXNUM_MAX_WIDTH - value = little_endian(take(width)) marker.negative? ? value - (1 << (BITS_PER_BYTE * width)) : value end @@ -155,7 +155,8 @@ module Rube def read_symlink(tag) budget.symbol_reference! index = read_fixnum - raise InvalidLinkError, "symlink #{index} of #{symbols.length}" unless symbols[index] && index >= 0 + raise InvalidLinkError, "symlink #{index} of #{symbols.length}" if + index.negative? || index >= symbols.length Node.new(type: :symlink, tag: tag, value: symbols[index]) end @@ -163,7 +164,8 @@ module Rube def read_object_link(tag) budget.link! index = read_fixnum - raise InvalidLinkError, "object link #{index} of #{objects.length}" unless objects[index] && index >= 0 + raise InvalidLinkError, "object link #{index} of #{objects.length}" if + index.negative? || index >= objects.length node = Node.new(type: :object_link, tag: tag, value: index) node.link_target = objects[index] @@ -182,9 +184,8 @@ module Rube end def read_float(tag) - Node.new(type: :float, tag: tag, value: Float(read_counted_bytes)) - rescue ArgumentError - Node.new(type: :float, tag: tag) + value, tail = FloatBody.decode(read_counted_bytes) + Node.new(type: :float, tag: tag, value: value, undecoded_tail: tail) end def read_string(tag) @@ -242,7 +243,7 @@ module Rube node end - def read_ivar(tag, depth) + def read_ivar(_tag, depth) read_instance_variables(read_value(depth + 1), depth) end diff --git a/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/scanner.rb b/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/scanner.rb index 8edaf2ef..34484d1c 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/scanner.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/scanner.rb @@ -1,5 +1,6 @@ # ©AngelaMos | 2026 # scanner.rb +# frozen_string_literal: true module Rube class Scanner @@ -20,6 +21,23 @@ module Rube LOCATION_SEPARATOR = ":" UNKNOWN_LOCATION = nil + STATE_UNREADABLE = :unreadable + STATE_UNANALYSABLE = :unanalysable + STATE_VERDICTS = [true, false].freeze + + SITE_MODULE_NAME = :module_name + SITE_OWN_METHODS = :own_methods + SITE_CANDIDATE = :candidate + SITE_SOURCE_PARSE = :source_parse + SITE_STATE_ANALYSIS = :state_analysis + + SITES = [SITE_MODULE_NAME, SITE_OWN_METHODS, SITE_CANDIDATE, + SITE_SOURCE_PARSE, SITE_STATE_ANALYSIS].freeze + + LOSSY_SITES = [SITE_MODULE_NAME, SITE_OWN_METHODS, SITE_CANDIDATE].freeze + + SUBJECT_UNNAMED = "(module that cannot report a name)" + class Candidate attr_reader :class_name, :method_name, :gate, :source_location, :arity @@ -46,13 +64,26 @@ module Rube end def touches_state? - @touches_state + @touches_state == true + end + + def state_known? + STATE_VERDICTS.include?(@touches_state) + end + + def unanalysable? + @touches_state == STATE_UNANALYSABLE + end + + def unreadable_source? + @touches_state == STATE_UNREADABLE end def reachable? return true if gated? + return false unless zero_arity? - zero_arity? && touches_state? + touches_state? || unreadable_source? end def to_s @@ -60,12 +91,57 @@ module Rube end end - class Report - attr_reader :candidates, :scanned_modules + class Suppression + attr_reader :site, :subject, :error_class - def initialize(candidates, scanned_modules) + def initialize(site:, subject:, error_class:) + @site = site + @subject = subject + @error_class = error_class + end + + def lossy? + LOSSY_SITES.include?(site) + end + + def to_s + "#{site} #{subject} (#{error_class})" + end + end + + class Report + attr_reader :candidates, :scanned_modules, :suppressions + + def initialize(candidates, scanned_modules, suppressions) @candidates = candidates @scanned_modules = scanned_modules + @suppressions = suppressions + end + + def suppressed_count + suppressions.length + end + + def suppressions_by_site + suppressions.each_with_object({}) do |suppression, counts| + counts[suppression.site] = counts.fetch(suppression.site, 0) + 1 + end + end + + def complete? + suppressions.empty? + end + + def candidates_lost? + suppressions.any?(&:lossy?) + end + + def unanalysable + candidates.select(&:unanalysable?) + end + + def fully_analysed? + candidates.all?(&:state_known?) end def gated @@ -90,6 +166,7 @@ module Rube @candidates = [] @definition_cache = {} @scanned_modules = 0 + @suppressions = [] end def scan @@ -99,13 +176,17 @@ module Rube collect_singleton_methods(mod, name) end - Report.new(@candidates.sort_by(&:to_s), @scanned_modules) + Report.new(@candidates.sort_by(&:to_s), @scanned_modules, @suppressions.freeze) end private attr_reader :namespace + def suppress(site, subject, error) + @suppressions << Suppression.new(site: site, subject: subject, error_class: error.class.name) + end + def each_named_module ObjectSpace.each_object(Module) do |mod| name = safe_name(mod) @@ -119,7 +200,8 @@ module Rube def safe_name(mod) name = mod.name name if name.is_a?(String) && !name.empty? - rescue StandardError + rescue StandardError => e + suppress(SITE_MODULE_NAME, SUBJECT_UNNAMED, e) nil end @@ -128,7 +210,7 @@ module Rube end def collect_instance_methods(mod, name) - own = own_instance_methods(mod) + own = own_instance_methods(mod, name) (own & GATED_METHODS).each do |method_name| record(mod, name, method_name, GATE_GATED, singleton: false) @@ -147,12 +229,17 @@ module Rube end end - def own_instance_methods(mod) + def own_instance_methods(mod, name) (mod.instance_methods(false) + mod.private_instance_methods(false)).map(&:to_s) - rescue StandardError + rescue StandardError => e + suppress(SITE_OWN_METHODS, name, e) [] end + def qualified(name, method_name, singleton) + "#{name}#{singleton ? '.' : '#'}#{method_name}" + end + def record(mod, name, method_name, gate, singleton:) handle = singleton ? mod.singleton_method(method_name) : mod.instance_method(method_name) @@ -163,9 +250,10 @@ module Rube source_location: format_location(handle.source_location), arity: handle.arity, singleton: singleton, - touches_state: touches_state?(handle) + touches_state: state_reference_in(handle, qualified(name, method_name, singleton)) ) - rescue StandardError, ScriptError + rescue StandardError, ScriptError => e + suppress(SITE_CANDIDATE, qualified(name, method_name, singleton), e) nil end @@ -175,32 +263,37 @@ module Rube location.join(LOCATION_SEPARATOR) end - def touches_state?(handle) - return false unless PRISM_AVAILABLE + def state_reference_in(handle, subject) + return STATE_UNANALYSABLE unless PRISM_AVAILABLE path, line = handle.source_location - return false unless path && line + return STATE_UNANALYSABLE unless path && line - node = definition_at(path, line) - return false unless node + definitions = definitions_for(path) + return STATE_UNREADABLE unless definitions + + node = definitions[line] + return STATE_UNREADABLE unless node node.compact_child_nodes.any? { |child| state_reference?(child) } - rescue StandardError, ScriptError - false - end - - def definition_at(path, line) - definitions_for(path)[line] + rescue StandardError, ScriptError => e + suppress(SITE_STATE_ANALYSIS, subject, e) + STATE_UNREADABLE end def definitions_for(path) - @definition_cache[path] ||= begin - found = {} - collect_definitions(Prism.parse_file(path).value, found) - found - rescue StandardError, ScriptError - {} - end + return @definition_cache[path] if @definition_cache.key?(path) + + @definition_cache[path] = parse_definitions(path) + end + + def parse_definitions(path) + found = {} + collect_definitions(Prism.parse_file(path).value, found) + found + rescue StandardError, ScriptError => e + suppress(SITE_SOURCE_PARSE, path, e) + nil end def collect_definitions(node, found) diff --git a/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/version.rb b/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/version.rb index 2af11c23..9aa3f7d3 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/version.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/version.rb @@ -1,5 +1,6 @@ # ©AngelaMos | 2026 # version.rb +# frozen_string_literal: true module Rube VERSION = "0.1.0" diff --git a/PROJECTS/beginner/deserialization-gadget-lab/rube.gemspec b/PROJECTS/beginner/deserialization-gadget-lab/rube.gemspec index 35c2b86d..47ada9b3 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/rube.gemspec +++ b/PROJECTS/beginner/deserialization-gadget-lab/rube.gemspec @@ -1,5 +1,6 @@ # ©AngelaMos | 2026 # rube.gemspec +# frozen_string_literal: true require_relative "lib/rube/version" diff --git a/PROJECTS/beginner/deserialization-gadget-lab/scripts/detector-gate.sh b/PROJECTS/beginner/deserialization-gadget-lab/scripts/detector-gate.sh index 8542440f..2579aeec 100755 --- a/PROJECTS/beginner/deserialization-gadget-lab/scripts/detector-gate.sh +++ b/PROJECTS/beginner/deserialization-gadget-lab/scripts/detector-gate.sh @@ -27,14 +27,23 @@ strict_sinky = D.new.inspect_stream(sinky) allowlisted = D.new(allowed_class_names: %w[Gem::Requirement Gem::Version]).inspect_stream(sinky) loose_hostile = D.new(policy: D::POLICY_DENY_SINKS_ONLY).inspect_stream(hostile) -puts "strict_rejects_cve=#{strict_hostile.rejected?}" -puts "strict_accepts_benign=#{strict_benign.accepted?}" -puts "strict_rejects_sink=#{strict_sinky.rejected?}" -puts "allowlist_does_not_exempt_sink=#{allowlisted.rejected?}" -puts "deny_sinks_only_accepts_cve=#{loose_hostile.accepted?}" +observed_reporter = ->(_reason) {} +observing = D.new(policy: D::POLICY_OBSERVE_AND_LOG, reporter: observed_reporter) +observed_sinky = observing.inspect_stream(sinky) +observed_benign = observing.inspect_stream(benign) + +puts "strict_rejects_cve=#{strict_hostile.blocked?}" +puts "strict_accepts_benign=#{strict_benign.proceed?}" +puts "strict_rejects_sink=#{strict_sinky.blocked?}" +puts "allowlist_does_not_exempt_sink=#{allowlisted.blocked?}" +puts "deny_sinks_only_accepts_cve=#{loose_hostile.proceed?}" +puts "observe_and_log_never_reports_proceed=#{!observed_sinky.proceed?}" +puts "observe_and_log_reports_observed=#{observed_sinky.observed?}" +puts "observe_and_log_proceeds_on_benign=#{observed_benign.proceed?}" +puts "blocked_is_not_also_observed=#{!strict_sinky.observed?}" fired = false -if loose_hostile.accepted? +if loose_hostile.proceed? begin Marshal.load(loose_hostile.snapshot).def_method(Module.new, "x") rescue StandardError @@ -63,6 +72,10 @@ expect strict_accepts_benign "strict policy still accepts a benign primitive str expect strict_rejects_sink "strict policy rejects a sink-bearing stream" expect allowlist_does_not_exempt_sink "allowlisting a class does not exempt its sink" expect deny_sinks_only_accepts_cve "deny-sinks-only accepts the CVE payload as documented" +expect observe_and_log_never_reports_proceed "observe-and-log never reports proceed for a flagged stream" +expect observe_and_log_reports_observed "observe-and-log reports the third state instead of hiding it" +expect observe_and_log_proceeds_on_benign "observe-and-log still proceeds on a clean stream" +expect blocked_is_not_also_observed "the three decision states stay mutually exclusive" expect documented_bypass_executes "the documented bypass actually executes, so the notice is honest" echo diff --git a/PROJECTS/beginner/deserialization-gadget-lab/scripts/exploit-gate.sh b/PROJECTS/beginner/deserialization-gadget-lab/scripts/exploit-gate.sh index 1599321d..c523e418 100755 --- a/PROJECTS/beginner/deserialization-gadget-lab/scripts/exploit-gate.sh +++ b/PROJECTS/beginner/deserialization-gadget-lab/scripts/exploit-gate.sh @@ -7,7 +7,7 @@ set -uo pipefail HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" VULNERABLE_IMAGE="ruby:4.0.2-slim" -PATCHED_IMAGE="ruby:4.0-slim" +PATCHED_IMAGE="ruby:4.0.6-slim" run_probe() { local image="$1" diff --git a/PROJECTS/beginner/deserialization-gadget-lab/scripts/render_matrix.rb b/PROJECTS/beginner/deserialization-gadget-lab/scripts/render_matrix.rb index a804aba0..9aa2f58d 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/scripts/render_matrix.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/scripts/render_matrix.rb @@ -1,5 +1,6 @@ # ©AngelaMos | 2026 # render_matrix.rb +# frozen_string_literal: true require "json" require "rube" @@ -39,7 +40,7 @@ def section(title) end section("RUNTIME") do - puts format(" %-14s %-8s %-9s %-8s %-9s %-7s", "image", "ruby", "rubygems", "psych", "erb", "marshal") + puts format(" %-14s %-8s %-9s %-8s %-9s %s", "image", "ruby", "rubygems", "psych", "erb", "marshal") rows.each do |r| puts format(" %-14s %-8s %-9s %-8s %-9s %-7s", short(r["image"]), r["ruby"], r["rubygems"], r["psych"], r["erb"], r["marshal_format"]) @@ -47,8 +48,8 @@ section("RUNTIME") do end section("GADGET SURFACE") do - puts format(" %-14s %-12s %-14s %s", - "image", "git gadget", "safe_marshal", TRACKED_CLASSES.map { |c| format("%-13s", c.split("::").last) }.join) + heads = TRACKED_CLASSES.map { |c| format("%-13s", c.split("::").last) }.join + puts format(" %-14s %-12s %-14s %s", "image", "git gadget", "safe_marshal", heads) rows.each do |r| present = TRACKED_CLASSES.map { |c| format("%-13s", mark(r["classes_baseline"][c])) }.join puts format(" %-14s %-12s %-14s %s", short(r["image"]), r["git_gadget"], r["safe_marshal"], present) @@ -56,7 +57,7 @@ section("GADGET SURFACE") do end section("ERB @_init GUARD (CVE-2026-41316), anchor = def_method") do - puts format(" %-14s %-9s %-9s %-24s %s", "image", "erb", "guarded", "delegating", "cve says") + puts format(" %-14s %-9s %-9s %-24s %-8s", "image", "erb", "guarded", "delegating", "cve says") rows.each do |r| guard = r["erb_guard"] expected = cve_patched?(r["erb"]) ? "patched" : "affected" diff --git a/PROJECTS/beginner/deserialization-gadget-lab/scripts/target-gate.sh b/PROJECTS/beginner/deserialization-gadget-lab/scripts/target-gate.sh index ad3f0f85..cbb31ce1 100755 --- a/PROJECTS/beginner/deserialization-gadget-lab/scripts/target-gate.sh +++ b/PROJECTS/beginner/deserialization-gadget-lab/scripts/target-gate.sh @@ -113,6 +113,58 @@ else fi fi +echo +encode() { + docker run --rm --network none ruby:4.0-slim \ + ruby -e "require \"base64\"; print Base64.strict_encode64(Marshal.dump($1))" +} + +for root in '"plain string"' 'nil' '[1, 2]' '{ user: "x" }'; do + body_file="$(mktemp)" + code="$(curl -s -o "${body_file}" -w '%{http_code}' \ + -H "Cookie: session_state=$(encode "${root}")" "${BASE}/render/safe")" + body="$(head -c 80 "${body_file}")" + rm -f "${body_file}" + echo " defended on root ${root} : HTTP ${code} ${body}" + if [[ "${code}" == "500" ]]; then + echo " FAIL the defence accepted this root and then crashed compiling it" + failures=$((failures + 1)) + fi +done + +echo +leak_file="$(mktemp)" +curl -s -o "${leak_file}" -H "Cookie: session_state=$(encode 'nil')" "${BASE}/render/safe" +curl -s -o "${leak_file}.v" -H "Cookie: session_state=$(encode 'nil')" "${BASE}/render" +if grep -qE "app\.rb|/app/lib|rube/marshal" "${leak_file}" "${leak_file}.v"; then + echo " FAIL an error response leaked source paths or source lines" + failures=$((failures + 1)) +else + echo " PASS error responses leak no source path or source line" +fi +rm -f "${leak_file}" "${leak_file}.v" + +echo +class_named() { + docker run --rm --network none ruby:4.0-slim ruby -e " +require \"base64\" +def sym(n) = \":\" + (n.bytesize + 5).chr + n +def str(s) = %q(\") + (s.bytesize + 5).chr + s +print Base64.strict_encode64($1) +" +} + +for probe in 'Marshal.dump(Object.new)' '("\x04\x08C" + sym("String") + str("hi")).b'; do + named_body="$(curl -s -H "Cookie: session_state=$(class_named "${probe}")" "${BASE}/render/safe")" + echo " class-named stream : ${named_body}" + if [[ "${named_body}" == *"unapproved class"* ]]; then + echo " PASS refused on the class name itself, not on a parse error" + else + echo " FAIL PERMITTED_CLASS_NAMES admitted a class name, or something else rejected it first" + failures=$((failures + 1)) + fi +done + echo sinks="$(docker run --rm --network none -v "${HERE}/lib:/app/lib:ro" -w /app ruby:4.0-slim \ ruby -Ilib -e ' diff --git a/PROJECTS/beginner/deserialization-gadget-lab/target/app.rb b/PROJECTS/beginner/deserialization-gadget-lab/target/app.rb index 9ca7c8f1..3c19fa9a 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/target/app.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/target/app.rb @@ -1,5 +1,6 @@ # ©AngelaMos | 2026 # app.rb +# frozen_string_literal: true require "sinatra/base" require "base64" @@ -16,21 +17,26 @@ module Rube CONTENT_TYPE = "text/plain" - ALLOWED_CLASSES = %w[Hash String Symbol Integer Array].freeze + PERMITTED_CLASS_NAMES = [].freeze BENIGN_TEMPLATE = "hello" + SESSION_KEYS = %i[user template].freeze + DETECTOR = Rube::Marshal::BoundaryDetector.new( policy: Rube::Marshal::BoundaryDetector::POLICY_STRICT_ALLOWLIST, - allowed_class_names: ALLOWED_CLASSES, + allowed_class_names: PERMITTED_CLASS_NAMES, limits: Rube::Marshal::Limits.new ) REJECTED = "rejected: %s" RENDERED = "rendered template for %s" NO_SESSION = "no session cookie" + NOT_A_SESSION = "payload is not a session hash" class App < Sinatra::Base set :host_authorization, permitted_hosts: [] + set :show_exceptions, false + set :dump_errors, false get "/" do content_type CONTENT_TYPE @@ -68,9 +74,12 @@ module Rube halt STATUS_BAD_REQUEST, NO_SESSION unless blob decision = DETECTOR.inspect_stream(blob) - halt STATUS_BAD_REQUEST, format(REJECTED, decision.reason) if decision.rejected? + halt STATUS_BAD_REQUEST, format(REJECTED, decision.reason) unless decision.proceed? - compile(::Marshal.load(decision.snapshot)) + state = ::Marshal.load(decision.snapshot) + halt STATUS_BAD_REQUEST, format(REJECTED, NOT_A_SESSION) unless session?(state) + + compile(state) end get "/canary" do @@ -92,6 +101,10 @@ module Rube nil end + def session?(state) + state.is_a?(Hash) && SESSION_KEYS.all? { |key| state.key?(key) } + end + def compile(state) template = state[:template] template.def_method(Module.new, "render_it") if template.respond_to?(:def_method) diff --git a/PROJECTS/beginner/deserialization-gadget-lab/target/config.ru b/PROJECTS/beginner/deserialization-gadget-lab/target/config.ru index ffc43b6f..907193c3 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/target/config.ru +++ b/PROJECTS/beginner/deserialization-gadget-lab/target/config.ru @@ -1,5 +1,6 @@ # ©AngelaMos | 2026 # config.ru +# frozen_string_literal: true require_relative "target/app" diff --git a/PROJECTS/beginner/deserialization-gadget-lab/test/chains_test.rb b/PROJECTS/beginner/deserialization-gadget-lab/test/chains_test.rb index 01118e22..9d5d22d5 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/test/chains_test.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/test/chains_test.rb @@ -1,5 +1,6 @@ # ©AngelaMos | 2026 # chains_test.rb +# frozen_string_literal: true require_relative "test_helper" diff --git a/PROJECTS/beginner/deserialization-gadget-lab/test/control_check.rb b/PROJECTS/beginner/deserialization-gadget-lab/test/control_check.rb index 1a334c90..5a756e4f 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/test/control_check.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/test/control_check.rb @@ -1,5 +1,6 @@ # ©AngelaMos | 2026 # control_check.rb +# frozen_string_literal: true $LOAD_PATH.unshift(File.expand_path("../lib", __dir__)) @@ -102,17 +103,46 @@ puts puts "=== 7 scanner precision ===" full = Rube::Scanner.new.scan ungated = full.ungated.length -reachable = full.reachable.reject(&:gated?).length +reachable = full.reachable.count { |c| !c.gated? } kept = ungated.zero? ? 0 : (100.0 * reachable / ungated).round(1) -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") +prism_detail = full.prism_available? ? "Prism.parse_file in use" : "ABSENT, every state verdict is a guess" +failures << "prism" unless check("prism backend live, so reachability is real", + full.prism_available?, prism_detail) failures << "precision" unless check("reachability filter discriminates", reachable.positive? && reachable < ungated, "#{ungated} ungated -> #{reachable} reachable, #{kept}% kept") failures << "gated located" unless check("gated sinks located", !full.gated.empty?, - full.gated.map(&:to_s).join(", ")) + full.gated.join(", ")) + +lossy = full.candidates_lost? ? full.suppressions.join(", ") : "0 lossy suppressions" +failures << "candidates lost" unless check("no candidate was silently dropped", + !full.candidates_lost?, lossy) + +unreadable = full.candidates.select(&:unreadable_source?) +fails_open = !unreadable.empty? && + unreadable.all? { |c| c.gated? || !c.zero_arity? || c.reachable? } +failures << "unreadable fails open" unless check("an unreadable source fails open, never to inert", + fails_open, + "#{unreadable.length} candidates whose source could not be parsed") + +unanalysable = full.unanalysable +owned = !unanalysable.empty? && unanalysable.none?(&:state_known?) && !full.fully_analysed? +failures << "unanalysable owned" unless check("C-defined methods are named unanalysable, not inert", + owned, + "#{unanalysable.length} of #{full.candidates.length} have no Ruby source") + +flooded = unanalysable.reject(&:gated?).select(&:reachable?) +flood_detail = flooded.empty? ? "0 of #{unanalysable.length} promoted" : "#{flooded.length} promoted with no evidence" +failures << "unanalysable flood" unless check("unanalysable never buys its way into reachable", + !unanalysable.empty? && flooded.empty?, flood_detail) + +coverage = "#{full.candidates.count(&:state_known?)} analysed, " \ + "#{unanalysable.length} unanalysable, #{unreadable.length} unreadable" +suppressed = full.complete? ? "none" : full.suppressions_by_site.map { |site, n| "#{site}=#{n}" }.join(" ") puts format(" %-6s %-46s %s", "INFO", "ObjectSpace coverage is load-bounded", "#{full.scanned_modules} modules loaded") +puts format(" %-6s %-46s %s", "INFO", "state analysis coverage", coverage) +puts format(" %-6s %-46s %s", "INFO", "suppressed errors this scan", suppressed) puts if failures.empty? diff --git a/PROJECTS/beginner/deserialization-gadget-lab/test/corpus_test.rb b/PROJECTS/beginner/deserialization-gadget-lab/test/corpus_test.rb index 9249d559..779f6f3b 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/test/corpus_test.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/test/corpus_test.rb @@ -1,5 +1,6 @@ # ©AngelaMos | 2026 # corpus_test.rb +# frozen_string_literal: true require_relative "test_helper" require_relative "support/adversarial_corpus" @@ -25,9 +26,10 @@ module Rube disagreements = AdversarialCorpus::CASES.filter_map do |kase| decision = detector(kase[:allowed]).inspect_stream(kase[:bytes]) expected = kase[:verdict] == AdversarialCorpus::VERDICT_ACCEPT - next if decision.accepted? == expected + next if decision.proceed? == expected - "#{kase[:name]}: expected #{kase[:verdict]}, got #{decision.accepted? ? 'accept' : 'reject'} (#{decision.reason})" + verdict = decision.proceed? ? "accept" : "reject" + "#{kase[:name]}: expected #{kase[:verdict]}, got #{verdict} (#{decision.reason})" end assert_empty disagreements, "corpus disagreements:\n #{disagreements.join("\n ")}" @@ -48,7 +50,7 @@ module Rube allowlisted = AdversarialCorpus::CASES.reject { |kase| kase[:allowed].empty? } refute_empty allowlisted - leaks = allowlisted.reject { |kase| detector.inspect_stream(kase[:bytes]).rejected? } + leaks = allowlisted.reject { |kase| detector.inspect_stream(kase[:bytes]).blocked? } assert_empty leaks.map { |kase| kase[:name] }, "an allowlist must widen what is accepted, never what is rejected" end @@ -79,7 +81,7 @@ module Rube hosts.each do |slot| refute_includes names, :"class_name_slot_#{slot}", "#{slot} rejects on its own tag either way, so a corpus case cannot fail" - assert detector.inspect_stream(AdversarialCorpus::CLASS_NAME_SLOTS[slot]).rejected? + assert_predicate detector.inspect_stream(AdversarialCorpus::CLASS_NAME_SLOTS[slot]), :blocked? end end 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 81219758..9b099208 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 @@ -1,5 +1,6 @@ # ©AngelaMos | 2026 # boundary_detector_test.rb +# frozen_string_literal: true require_relative "../test_helper" require_relative "../support/adversarial_corpus" @@ -11,8 +12,8 @@ module Rube CANARY_PATH = "/tmp/rube-canary" CANARY_MARKER = "fired" - def detector(**options) - BoundaryDetector.new(**options) + def detector(**) + BoundaryDetector.new(**) end def benign_blob @@ -33,7 +34,7 @@ module Rube end def test_accepts_primitive_only_stream_with_an_empty_allowlist - assert_predicate detector.inspect_stream(benign_blob), :accepted? + assert_predicate detector.inspect_stream(benign_blob), :proceed? end def test_rejects_non_string_input_without_converting_it @@ -43,45 +44,47 @@ module Rube end decision = detector.inspect_stream(hostile) - assert_predicate decision, :rejected? + assert_predicate decision, :blocked? assert_equal BoundaryDetector::REASON_INPUT_TYPE, decision.reason end def test_rejects_a_sink_bearing_stream decision = detector.inspect_stream(sink_blob) - assert_predicate decision, :rejected? - assert_includes decision.reason, "Gem::Requirement#marshal_load" + assert_predicate decision, :blocked? + assert_includes decision.reason, '"Gem::Requirement"#marshal_load', + "the class name is attacker-controlled and stays quoted, so an operator " \ + "can see where it starts and stops" end def test_allowlisting_a_class_does_not_exempt_its_sink decision = detector(allowed_class_names: %w[Gem::Requirement Gem::Version]) .inspect_stream(sink_blob) - assert_predicate decision, :rejected? + assert_predicate decision, :blocked? assert_includes decision.reason, "marshal_load" end def test_rejects_unapproved_class_names decision = detector.inspect_stream(cve_blob) - assert_predicate decision, :rejected? + assert_predicate decision, :blocked? assert_includes decision.reason, "ERB" end def test_accepts_an_approved_class decision = detector(allowed_class_names: %w[ERB]).inspect_stream(cve_blob) - assert_predicate decision, :accepted? + assert_predicate decision, :proceed? end def test_documented_bypass_is_real_deny_sinks_only_accepts_the_cve_payload decision = detector(policy: BoundaryDetector::POLICY_DENY_SINKS_ONLY) .inspect_stream(cve_blob) - assert_predicate decision, :accepted?, + assert_predicate decision, :proceed?, "the limitation notice claims this bypass exists, so it must be demonstrable" end def test_deny_sinks_only_still_rejects_sinks decision = detector(policy: BoundaryDetector::POLICY_DENY_SINKS_ONLY) .inspect_stream(sink_blob) - assert_predicate decision, :rejected? + assert_predicate decision, :blocked? end def test_observe_and_log_requires_a_reporter @@ -90,15 +93,15 @@ module Rube end end - def test_observe_and_log_accepts_but_records_the_violation + def test_observe_and_log_records_the_violation_without_reporting_proceed seen = [] decision = detector(policy: BoundaryDetector::POLICY_OBSERVE_AND_LOG, reporter: ->(reason) { seen << reason }) .inspect_stream(cve_blob) - assert_predicate decision, :accepted? assert_predicate decision, :observed? - assert_predicate decision, :would_reject? + refute_predicate decision, :proceed? + refute_predicate decision, :blocked? assert_equal 1, seen.length end @@ -106,7 +109,7 @@ module Rube decision = detector(policy: BoundaryDetector::POLICY_OBSERVE_AND_LOG, reporter: ->(_) {}) .inspect_stream("\x04\x08[\xFA") - assert_predicate decision, :rejected? + assert_predicate decision, :blocked? end def test_rejects_a_version_the_parser_accepts_but_no_ruby_emits @@ -115,18 +118,18 @@ module Rube 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_predicate decision, :blocked? assert_includes decision.reason, "4.7" end def test_canonical_version_is_accepted - assert_predicate detector.inspect_stream(benign_blob), :accepted? + assert_predicate detector.inspect_stream(benign_blob), :proceed? 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?, + assert_predicate decision, :proceed?, "version canonicality runs no code during load, so it belongs with " \ "the allowlist, not with the sink checks" end @@ -137,7 +140,7 @@ module Rube def test_rejects_malformed_stream_with_a_named_reason decision = detector.inspect_stream("\x04\x08[\xFA") - assert_predicate decision, :rejected? + assert_predicate decision, :blocked? assert_includes decision.reason, "MalformedCountError" end @@ -154,12 +157,12 @@ module Rube end def assert_ceiling_rejects(blob, error_name, allowed: [], **narrow) - assert_predicate detector(allowed_class_names: allowed).inspect_stream(blob), :accepted?, + assert_predicate detector(allowed_class_names: allowed).inspect_stream(blob), :proceed?, "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_predicate decision, :blocked? assert_includes decision.reason, error_name decision end @@ -172,10 +175,10 @@ module Rube deep = "\x04\x08" + ("[\x06" * 80) + "0" shallow = "\x04\x08" + ("[\x06" * 8) + "0" - assert_predicate detector.inspect_stream(shallow), :accepted?, + assert_predicate detector.inspect_stream(shallow), :proceed?, "control: nesting inside the ceiling must be accepted" decision = detector.inspect_stream(deep) - assert_predicate decision, :rejected? + assert_predicate decision, :blocked? assert_includes decision.reason, "DepthLimitError" end @@ -199,7 +202,7 @@ module Rube blob = AdversarialCorpus.stream(AdversarialCorpus.bignum("+", words)) decision = detector.inspect_stream(blob) - assert_predicate decision, :rejected?, + assert_predicate decision, :blocked?, "#{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" @@ -264,7 +267,7 @@ module Rube 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") + decision.blocked? && decision.reason.include?("LimitExceededError") end assert_empty admitted.keys, @@ -273,7 +276,7 @@ module Rube 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? + detector(allowed_class_names: allowed, limits: Limits.permissive).inspect_stream(blob).proceed? end assert_operator admitted, :>, 0, @@ -303,6 +306,149 @@ module Rube tracer.enable { detector.inspect_stream(cve_blob) } refute fired end + + def policy_options(policy) + return { policy: policy, reporter: ->(_reason) {} } if + policy == BoundaryDetector::POLICY_OBSERVE_AND_LOG + + { policy: policy } + end + + def decision_per_policy(blob, allowed: []) + BoundaryDetector::POLICIES.to_h do |policy| + [policy, detector(allowed_class_names: allowed, **policy_options(policy)).inspect_stream(blob)] + end + end + + def state_matrix_blobs + { + "benign" => benign_blob, + "sink" => sink_blob, + "unapproved class" => cve_blob, + "malformed" => "\x04\x08[\xFA" + } + end + + def test_no_policy_reports_proceed_for_a_payload_it_flagged + admitted = decision_per_policy(sink_blob).select { |_policy, decision| decision.proceed? } + + assert_empty admitted.keys, + "every policy flags a marshal_load sink, so no policy may report proceed. " \ + "Marshal.load(d.snapshot) if d.proceed? would load it under " \ + "#{admitted.keys.join(', ')}" + end + + def test_control_every_policy_reports_proceed_for_a_benign_payload + admitted = decision_per_policy(benign_blob).select { |_policy, decision| decision.proceed? } + + assert_equal BoundaryDetector::POLICIES.length, admitted.length, + "control: a proceed? that is never true would pass the previous test vacuously" + end + + def test_exactly_one_state_predicate_holds_for_every_policy_and_payload + seen = [] + + state_matrix_blobs.each do |name, blob| + decision_per_policy(blob).each do |policy, decision| + held = BoundaryDetector::Decision::STATE_PREDICATES.select { |predicate| decision.public_send(predicate) } + assert_equal 1, held.length, + "#{name} under #{policy} reported #{held.length} states: #{held.join(', ')}" + seen.concat(held) + end + end + + assert_equal BoundaryDetector::Decision::STATE_PREDICATES.sort, seen.uniq.sort, + "control: the matrix must exercise every state, or exclusivity proves nothing" + end + + def test_the_ambiguous_accept_predicates_no_longer_exist + decision = detector(policy: BoundaryDetector::POLICY_OBSERVE_AND_LOG, + reporter: ->(_reason) {}).inspect_stream(sink_blob) + + %i[accepted? rejected?].each do |ambiguous| + refute_respond_to decision, ambiguous, + "#{ambiguous} cannot answer whether this payload may be deserialized, " \ + "and a reader who copies it loads a flagged stream" + end + end + + def test_observe_and_log_still_hands_back_bytes_for_an_explicit_opt_in + decision = detector(policy: BoundaryDetector::POLICY_OBSERVE_AND_LOG, + reporter: ->(_reason) {}).inspect_stream(sink_blob) + + assert_predicate decision, :observed? + refute_nil decision.snapshot, + "observe-and-log must stay non-blocking, so a caller who writes " \ + "proceed? || observed? can still load" + assert_equal ::Marshal.load(sink_blob), ::Marshal.load(decision.snapshot) + end + + def test_a_blocked_decision_carries_no_snapshot_to_load + decision = detector.inspect_stream(sink_blob) + + assert_predicate decision, :blocked? + assert_nil decision.snapshot + end + + def test_decision_rejects_an_unknown_state + assert_raises(ArgumentError) { BoundaryDetector::Decision.new(state: :yolo) } + end + + def hostile_named_streams(name) + sym = AdversarialCorpus.sym(name) + { + "unapproved class" => AdversarialCorpus.stream("o#{sym}#{AdversarialCorpus.fixnum(0)}"), + "sink" => AdversarialCorpus.stream("U#{sym}#{AdversarialCorpus.fixnum(0)}"), + "hash key dispatch" => + AdversarialCorpus.stream( + "{#{AdversarialCorpus.fixnum(1)}o#{sym}#{AdversarialCorpus.fixnum(0)}0" + ) + } + end + + def test_no_reject_reason_repeats_a_raw_control_byte_from_the_stream + forged = "Evil\r\n2026-07-29 INFO session validated user=admin\e[0m\x00" + raw = hostile_named_streams(forged).transform_values do |blob| + detector.inspect_stream(blob).reason.to_s + end + + assert_equal 3, raw.values.count { |reason| !reason.empty? }, + "control: every reason kind must actually fire, or this proves nothing" + raw.each do |kind, reason| + offending = reason.bytes.select { |byte| byte < 0x20 } + assert_empty offending, + "#{kind} reason carried raw control bytes #{offending.inspect} straight " \ + "into a caller-supplied logger" + end + end + + def test_a_reject_reason_still_identifies_the_class_it_refused + blob = hostile_named_streams("Evil\nInjected").fetch("unapproved class") + + assert_includes detector.inspect_stream(blob).reason, 'Evil\nInjected', + "escaping must not cost the operator the name that was refused" + end + + def test_a_reject_reason_bounds_how_much_attacker_text_it_repeats + ceiling = Limits::DEFAULT_MAX_CLASS_NAME_BYTES + long = "A" * ceiling + blob = hostile_named_streams(long).fetch("unapproved class") + decision = detector.inspect_stream(blob) + + assert_predicate decision, :blocked? + assert_includes decision.reason, BoundaryDetector::REASON_TRUNCATED_MARKER + assert_operator decision.reason.bytesize, :<, ceiling, + "a 1 KiB class name is inside the parser ceiling, so the reason is the " \ + "only thing bounding what reaches the log" + end + + def test_control_a_short_class_name_is_not_truncated + blob = hostile_named_streams("Evil").fetch("unapproved class") + reason = detector.inspect_stream(blob).reason + + assert_includes reason, '"Evil"' + refute_includes reason, BoundaryDetector::REASON_TRUNCATED_MARKER + end end end end 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 9fa8c87c..812e0252 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/test/marshal/parser_test.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/test/marshal/parser_test.rb @@ -1,5 +1,6 @@ # ©AngelaMos | 2026 # parser_test.rb +# frozen_string_literal: true require_relative "../test_helper" require_relative "../support/adversarial_corpus" @@ -139,7 +140,7 @@ module Rube end def test_parses_bignum_both_signs - [2**64, -(2**64), 2**128 + 7].each do |n| + [2**64, -(2**64), (2**128) + 7].each do |n| assert_equal n, roundtrip(n).value, "bignum #{n} did not round-trip" end end @@ -276,7 +277,7 @@ module Rube end def test_resolves_symlink_to_prior_symbol - node = roundtrip([:same, :same]) + node = roundtrip(%i[same same]) assert_equal :symlink, node.children.last.type assert_equal :same, node.children.last.value end @@ -399,7 +400,7 @@ module Rube def test_sink_in_an_instance_variable_name_position_is_still_reported result = parse("\x04\x08I\"\x06a\x06u:\x09Evil\x06x0") assert_includes result.class_names, "Evil" - assert_equal ["Evil#_load"], result.sinks.map { |s| "#{s.class_name}##{s.sink_method}" } + assert_equal(["Evil#_load"], result.sinks.map { |s| "#{s.class_name}##{s.sink_method}" }) end def tripwires(blob) @@ -504,12 +505,12 @@ module Rube assert_raises(DepthLimitError) { parse_class_name_slot_at_ceiling(tail) } end - def load_watcher + def load_watcher(&) fired = false tracer = TracePoint.new(:call, :c_call) do |tp| fired = true if tp.method_id == :load && tp.self.equal?(::Marshal) end - tracer.enable { yield } + tracer.enable(&) fired end @@ -641,6 +642,164 @@ module Rube end end + def nested_result + parse(::Marshal.dump({ "user" => "guest", "roles" => [1, 2, 3], "on" => true })) + end + + def test_a_returned_parse_graph_cannot_be_mutated_by_its_consumer + result = nested_result + node = result.root + + assert_raises(FrozenError) { node.children << Node.new(type: :nil) } + assert_raises(FrozenError) { node.auxiliary.clear } + assert_raises(FrozenError) { node.instance_variables_map[:injected] = Node.new(type: :nil) } + end + + def test_a_returned_node_cannot_have_its_verdict_fields_rewritten + node = nested_result.root + + assert_raises(FrozenError) { node.value = "rewritten" } + assert_raises(FrozenError) { node.class_name = "Innocuous" } + end + + def test_every_node_in_the_graph_is_frozen_not_just_the_root + unfrozen = nested_result.nodes.reject(&:frozen?) + + assert_empty unfrozen.map(&:type) + assert_operator nested_result.nodes.count, :>, 5, + "control: a one-node graph would not prove the walk reaches children" + end + + def test_scalar_values_in_the_graph_are_frozen_too + strings = nested_result.nodes.filter_map { |n| n.value if n.value.is_a?(String) } + + refute_empty strings, "control: the probe must contain string values" + assert(strings.all?(&:frozen?)) + end + + def test_the_chain_registry_cannot_be_appended_to_by_a_caller + assert_raises(FrozenError) { Rube::Chains.registry << Object } + end + + def test_control_the_registry_still_reports_its_chains + refute_empty Rube::Chains.all + assert_includes Rube::Chains.all, Rube::Chains::ErbDefMethod + end + + FIXNUM_WIDTH_PROBES = [ + 0, 1, -1, 122, -123, 123, -124, 255, -256, 256, -257, 65_535, -65_536, + 65_536, 16_777_215, -16_777_216, 16_777_216, 1_073_741_823, -1_073_741_824 + ].freeze + + def test_fixnum_round_trips_every_width_the_format_allows + mismatched = FIXNUM_WIDTH_PROBES.reject { |n| parse(::Marshal.dump(n)).root.value == n } + + assert_empty mismatched.map(&:inspect) + end + + def test_no_fixnum_marker_can_request_a_width_beyond_the_format_ceiling + payloads = FIXNUM_WIDTH_PROBES.map { |n| ::Marshal.dump(n).bytesize - Constants::HEADER_LENGTH - 2 } + + assert_equal (0..Constants::FIXNUM_MAX_WIDTH).to_a, payloads.uniq.sort, + "control: the probe set must exercise the inline form and every width" + assert_operator payloads.max, :<=, Constants::FIXNUM_MAX_WIDTH, + "the format cannot express a wider fixnum, so a runtime width guard " \ + "is unreachable and must not pretend otherwise" + end + + def float_stream(body) + AdversarialCorpus.stream("f#{AdversarialCorpus.fixnum(body.bytesize)}#{body}") + end + + def float_value(body) + parse(float_stream(body)).root.value + end + + def marshal_float(body) + ::Marshal.load(float_stream(body)) + end + + def same_float?(left, right) + return false unless left.is_a?(Float) && right.is_a?(Float) + + (left.nan? && right.nan?) || left == right + end + + EMITTED_FLOAT_BODIES = [ + "inf", "-inf", "nan", "0", "-0", "1.5", "-2.5", "1e400", "-1e400", + "0.0001", "3.141592653589793", "1.7976931348623157e+308", "5.0e-324" + ].freeze + + HAND_BUILT_FLOAT_BODIES = [ + "1_0", "abc", "", " 1.5", "0x10", "1.5abc", "+2.5", ".5", + "nan\x00j", "inf\x00x", "INF", "NaN" + ].freeze + + def test_float_decoding_agrees_with_marshal_load_on_every_body + bodies = EMITTED_FLOAT_BODIES + HAND_BUILT_FLOAT_BODIES + disagreements = bodies.reject { |b| same_float?(float_value(b), marshal_float(b)) } + + assert_empty(disagreements.map { |b| "#{b.inspect}: #{float_value(b).inspect} vs #{marshal_float(b).inspect}" }) + + observed = bodies.map { |b| marshal_float(b) } + assert(observed.any?(&:nan?), "control: the table must exercise nan") + assert(observed.any?(&:infinite?), "control: the table must exercise infinity") + assert(observed.any?(&:finite?), "control: the table must exercise finite values") + end + + def test_float_never_reports_nil_for_a_body_marshal_load_accepts + blind = (EMITTED_FLOAT_BODIES + HAND_BUILT_FLOAT_BODIES).select { |b| float_value(b).nil? } + + assert_empty blind, + "a nil value is indistinguishable from a float of zero and hides what the " \ + "stream actually carried" + end + + def test_every_float_ruby_dumps_round_trips_through_the_parser + values = [0.0, -0.0, 1.5, -2.5, 1e308, 1e-308, 0.1, Float::INFINITY, + -Float::INFINITY, Float::NAN, Float::MAX, Float::MIN] + mismatched = values.reject do |v| + same_float?(parse(::Marshal.dump(v)).root.value, v) + end + + assert_empty mismatched.map(&:inspect) + end + + def test_a_legacy_mantissa_tail_is_preserved_rather_than_guessed + node = parse(float_stream("3.5\x00junk")).root + + assert_in_delta 3.5, node.value, 0.0 + assert_equal "\x00junk".b, node.undecoded_tail + refute_predicate node, :fully_decoded? + end + + def test_control_a_float_ruby_actually_emits_is_fully_decoded + node = parse(::Marshal.dump(3.5)).root + + assert_predicate node, :fully_decoded? + assert_nil node.undecoded_tail + end + + def test_the_parser_and_the_scanner_agree_on_which_sinks_are_gated + from_tags = Constants::GATED_SINK_TAGS.map { |tag| Constants::SINK_METHODS.fetch(tag) } + from_scanner = Rube::Scanner::GATED_METHODS + Rube::Scanner::GATED_SINGLETON_METHODS + + refute_empty from_tags, "control: an empty gated set would make this vacuous" + assert_equal from_scanner.sort, from_tags.sort, + "a sink method gated in one half and ungated in the other is a contradiction, " \ + "and the two halves are the only two definitions of gated in this project" + end + + def test_every_sink_tag_declares_the_method_marshal_load_dispatches + assert_equal Constants::SINK_TAGS.sort, Constants::SINK_METHODS.keys.sort + end + + def test_data_tag_is_gated_because_marshal_load_checks_respond_to_first + assert_includes Constants::GATED_SINK_TAGS, Constants::TAG_DATA, + "Thread::Mutex is a real T_DATA with no _load_data and Marshal.load raises " \ + "TypeError naming the missing method, which is the gate" + end + class UserMarshalFixture def marshal_dump ["payload"] diff --git a/PROJECTS/beginner/deserialization-gadget-lab/test/scanner_test.rb b/PROJECTS/beginner/deserialization-gadget-lab/test/scanner_test.rb index 86c64ab9..a24247dc 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/test/scanner_test.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/test/scanner_test.rb @@ -1,12 +1,43 @@ # ©AngelaMos | 2026 # scanner_test.rb +# frozen_string_literal: true require_relative "test_helper" module Rube class ScannerTest < Minitest::Test - def scan(**options) - Scanner.new(**options).scan + SUPPRESSION_NAMESPACE = "Rube::ScannerSuppressionFixture" + MISSING_SOURCE_PATH = "/nonexistent/rube-scanner-fixture.rb" + + Object.class_eval(<<~SOURCE, MISSING_SOURCE_PATH, 1) + module Rube + module ScannerSuppressionFixture + class VanishedSource + def hash + @seed.to_i + end + + def to_s + "vanished" + end + end + end + end + SOURCE + + def scan(**) + Scanner.new(**).scan + end + + def with_exploding(*fixtures) + fixtures.each { |fixture| fixture.explode = true } + yield + ensure + fixtures.each { |fixture| fixture.explode = false } + end + + def suppressions_at(report, site) + report.suppressions.select { |suppression| suppression.site == site } end def local_scan @@ -141,6 +172,212 @@ module Rube refute GatedFixture.instantiated, "scanner constructed a candidate class" end + def test_a_clean_scan_reports_no_suppressions + report = local_scan + + assert_empty report.suppressions + assert_equal 0, report.suppressed_count + assert_predicate report, :complete? + refute_predicate report, :candidates_lost? + end + + def test_a_lost_candidate_is_counted_and_names_the_class_it_came_from + control = candidates_for("Rube::ScannerTest::ExplodingHandleFixture") + assert_equal ["marshal_load"], control.map(&:method_name), + "control: this fixture must be discoverable when it is not exploding" + + with_exploding(ExplodingHandleFixture) do + report = local_scan + + assert_empty(report.candidates.select { |c| c.class_name.end_with?("ExplodingHandleFixture") }) + lost = suppressions_at(report, Scanner::SITE_CANDIDATE) + assert_equal 1, lost.length + assert_equal "Rube::ScannerTest::ExplodingHandleFixture#marshal_load", lost.first.subject + assert_predicate report, :candidates_lost? + refute_predicate report, :complete? + end + end + + def test_a_suppressed_script_error_is_recorded_by_class + with_exploding(ExplodingHandleFixture) do + suppression = suppressions_at(local_scan, Scanner::SITE_CANDIDATE).first + + assert_equal "ScriptError", suppression.error_class, + "record rescues ScriptError as well as StandardError, so it must report which" + end + end + + def test_an_unreadable_method_list_is_counted_as_a_lost_candidate + with_exploding(ExplodingMethodListFixture) do + report = local_scan + + assert_empty(report.candidates.select { |c| c.class_name.end_with?("ExplodingMethodListFixture") }) + assert_equal 1, suppressions_at(report, Scanner::SITE_OWN_METHODS).length + assert_predicate report, :candidates_lost? + end + end + + def test_a_module_that_cannot_report_its_name_is_counted + with_exploding(ExplodingNameFixture) do + named = suppressions_at(local_scan, Scanner::SITE_MODULE_NAME) + + refute_empty named + assert_equal Scanner::SUBJECT_UNNAMED, named.first.subject + end + end + + def test_unparseable_source_is_counted_without_losing_the_candidate + report = scan(namespace: SUPPRESSION_NAMESPACE) + + assert_equal ["#{SUPPRESSION_NAMESPACE}::VanishedSource#hash", + "#{SUPPRESSION_NAMESPACE}::VanishedSource#to_s"], + report.candidates.map(&:to_s), + "control: the candidates must survive, only their state analysis failed" + assert_equal 1, suppressions_at(report, Scanner::SITE_SOURCE_PARSE).length + refute_predicate report, :candidates_lost? + refute_predicate report, :complete? + end + + def test_a_candidate_whose_source_cannot_be_read_stays_reachable + candidate = scan(namespace: SUPPRESSION_NAMESPACE).candidates.first + + refute_predicate candidate, :state_known? + refute_predicate candidate, :touches_state? + assert_predicate candidate, :reachable?, + "an unreadable source cannot prove a method inert, and a scanner that " \ + "drops what it failed to analyse under-reports silently" + end + + def test_a_c_defined_method_is_reported_as_unanalysable_not_as_inert + candidate = scan(namespace: "Gem").candidates.find { |c| c.source_location.nil? } || + scan.candidates.find { |c| c.source_location.nil? } + + refute_nil candidate, "control: the stdlib must supply at least one C-defined candidate" + assert_predicate candidate, :unanalysable? + refute_predicate candidate, :state_known? + refute_predicate candidate, :touches_state?, + "no Ruby source exists, so the answer is not false, it is unavailable" + end + + def test_unanalysable_is_distinct_from_an_analysis_that_failed + vanished = scan(namespace: SUPPRESSION_NAMESPACE).candidates.first + c_defined = scan.candidates.find { |c| c.source_location.nil? } + + refute_predicate vanished, :unanalysable?, + "a source that exists but could not be read is a failure, not an absence" + assert_predicate c_defined, :unanalysable? + refute_predicate vanished, :state_known? + refute_predicate c_defined, :state_known? + end + + def test_an_unanalysable_candidate_is_never_reachable_on_that_basis + report = scan + flooded = report.unanalysable.reject(&:gated?).select(&:reachable?) + + refute_empty report.unanalysable, "control: a stock image must have C-defined candidates" + assert_empty flooded.first(5).map(&:to_s), + "#{flooded.length} C-defined candidates were called reachable purely because " \ + "they could not be analysed; that is a pass-through, not a filter" + end + + def test_control_an_unreadable_candidate_is_reachable_on_exactly_that_basis + unreadable = scan.candidates.select(&:unreadable_source?).reject(&:gated?).select(&:zero_arity?) + + refute_empty unreadable, "control: without one of these the previous test is vacuous" + assert(unreadable.all?(&:reachable?), + "the two non-verdicts must behave differently, or splitting them bought nothing") + end + + def test_the_report_counts_what_it_could_not_analyse + report = scan + + assert_equal report.candidates.count(&:unanalysable?), report.unanalysable.length + assert_operator report.unanalysable.length, :>, 0 + refute_predicate report, :fully_analysed? + end + + def test_a_report_over_analysable_code_only_is_fully_analysed + report = local_scan + + assert_empty report.unanalysable + assert_predicate report, :fully_analysed? + end + + def test_an_analysed_candidate_reports_its_state_as_known + %w[StatefulFixture StatelessFixture].each do |fixture| + candidate = candidates_for("Rube::ScannerTest::#{fixture}").first + + assert_predicate candidate, :state_known?, + "control: a readable source must produce a verdict, or unknown means nothing" + end + end + + def test_one_unreadable_file_is_counted_once_not_once_per_candidate + report = scan(namespace: SUPPRESSION_NAMESPACE) + + assert_operator report.candidates.length, :>, 1, + "control: one candidate cannot expose per-candidate inflation" + assert_equal 1, report.suppressed_count, + "the parse cache must remember a failure, or the count inflates per candidate" + end + + def test_suppressions_by_site_accounts_for_every_suppression + with_exploding(ExplodingHandleFixture, ExplodingMethodListFixture, ExplodingNameFixture) do + report = local_scan + by_site = report.suppressions_by_site + + assert_equal report.suppressed_count, by_site.values.sum + assert_equal 3, by_site.keys.length + assert(by_site.keys.all? { |site| Scanner::SITES.include?(site) }) + end + end + + class ExplodingNameFixture + @explode = false + + class << self + attr_accessor :explode + + def name + raise NameError, "name unavailable" if @explode + + super + end + end + end + + class ExplodingMethodListFixture + @explode = false + + class << self + attr_accessor :explode + + def instance_methods(include_super = true) + raise NoMethodError, "method list unavailable" if @explode + + super + end + end + + def marshal_load(data); end + end + + class ExplodingHandleFixture + @explode = false + + class << self + attr_accessor :explode + + def instance_method(name) + raise ScriptError, "handle unavailable" if @explode + + super + end + end + + def marshal_load(data); end + end + class GatedFixture @instantiated = false @@ -152,7 +389,7 @@ module Rube end class UserDefFixture - def self._load(data) + def self._load(_data) allocate end end 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 b945a5b8..690b7fe9 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/test/support/adversarial_corpus.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/test/support/adversarial_corpus.rb @@ -1,5 +1,6 @@ # ©AngelaMos | 2026 # adversarial_corpus.rb +# frozen_string_literal: true module Rube module AdversarialCorpus diff --git a/PROJECTS/beginner/deserialization-gadget-lab/test/support/exploit_probe.rb b/PROJECTS/beginner/deserialization-gadget-lab/test/support/exploit_probe.rb index 6812d23e..f7c62b13 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/test/support/exploit_probe.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/test/support/exploit_probe.rb @@ -1,6 +1,8 @@ # ©AngelaMos | 2026 # exploit_probe.rb +# frozen_string_literal: true +require "fileutils" require "rube" CANARY_PATH = "/tmp/rube-canary" @@ -15,7 +17,7 @@ blob = chain.serialize inspection = Rube::Marshal::Parser.new(blob).parse -File.delete(CANARY_PATH) if File.exist?(CANARY_PATH) +FileUtils.rm_f(CANARY_PATH) revived = Marshal.load(blob) detail = begin diff --git a/PROJECTS/beginner/deserialization-gadget-lab/test/support/matrix_probe.rb b/PROJECTS/beginner/deserialization-gadget-lab/test/support/matrix_probe.rb index f7a8a0f2..1c81458f 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/test/support/matrix_probe.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/test/support/matrix_probe.rb @@ -1,5 +1,6 @@ # ©AngelaMos | 2026 # matrix_probe.rb +# frozen_string_literal: true require "json" diff --git a/PROJECTS/beginner/deserialization-gadget-lab/test/test_helper.rb b/PROJECTS/beginner/deserialization-gadget-lab/test/test_helper.rb index bdefdac0..c0f17fc7 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/test/test_helper.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/test/test_helper.rb @@ -1,5 +1,6 @@ # ©AngelaMos | 2026 # test_helper.rb +# frozen_string_literal: true $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))