From 8d7b114fb9f9285f5f45ee9561abc18202c0e070 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Wed, 29 Jul 2026 14:35:48 -0400 Subject: [PATCH] fix(rube): clear the entire S2 backlog tier - a non-answer is never an answer B17 through B30, fourteen items. Every one reproduced before it was touched and mutation-proven after. 194 tests from 119, rubocop 903 offenses to 0, all six gate stages green. One rule runs through all of it: nothing may present a guess, a default, or a swallowed error as a verdict. Decision states (B19). Under POLICY_OBSERVE_AND_LOG a payload carrying a live Gem::Requirement#marshal_load snapshot reported accepted? true AND rejected? false, so both obvious caller shapes loaded it. The predicate pair could not express the third outcome, so there was no safe branch to pick - the prior note claiming target/app.rb sidestepped this by branching on rejected? was wrong, and both forms were byte-for-byte equivalent in outcome. accepted?, rejected? and would_reject? are removed rather than redefined, so copying `if d.accepted?` now raises NoMethodError instead of silently changing meaning. One state validated in the constructor, three exclusive predicates, and proceed? is the only one that may gate a Marshal.load. Observe-and-log stays non-blocking and still hands back its snapshot; the monitoring caller writes `proceed? || observed?` and names the state out loud. Scanner error accounting (B18). Five rescues returned nil, [] or false and told nobody. They now record site, subject and error class, and Report exposes suppressed_count, suppressions_by_site, complete? and candidates_lost?. Wiring the counter immediately surfaced 3 suppressions on a stock image that had always been invisible: , and all fail Prism.parse_file with ENOENT because Ruby hands out those paths but they are not files. The fourth state (B29). Those 3 suppressions were also 7 wrong answers - candidates scored "does not touch state", indistinguishable from analysed and inert, and 4 zero-arity ungated ones silently dropped from reachable. touches_state is now four-state. A source that was given and could not be parsed fails OPEN and stays reachable, because a scanner that discards what it failed to analyse is the exact failure mode B18 names. A C-defined method with no Ruby source at all is reported as unanalysable instead: 132 of 173 candidates, and failing open there would take reachable from 25 to 74 of 165 ungated and stop the filter filtering. Report#unanalysable and #fully_analysed? state the real coverage - 33 of 173 - rather than implying the filter saw everything. The 5 recovered candidates were verified by executing them, not by reading source we could not read; one of them, ERB::Compiler::PercentLine#to_s, is an alias of an attr_reader, which is a second and distinct analysis gap. Reason escaping (B20). Reject reasons interpolated raw attacker bytes into a caller-supplied reporter. A class name carrying CR, LF, ESC and NUL turned one reporter call into three log lines, the middle one forging a successful authentication. All three interpolation sites now truncate at 96 bytes and inspect the binary form, so no byte below 0x20 survives and the value is quote-delimited. Target hardening (B21, B22). The defended endpoint returned HTTP 500 with a source line for three roots the detector had just accepted, leaking paths the same way the B3 mutant did. show_exceptions is off, the shape is checked, and the gate now greps every error body for source paths. ALLOWED_CLASSES could never match anything, measured: a benign session cookie carries zero class names. It is PERMITTED_CLASS_NAMES = [] now, which is what the app actually requires and is strictly tighter - the old list admitted a C-wrapped String, and under a mutant restoring it only the new shape check stopped that payload. Gated agreement (B23). GATED_SINK_TAGS omitted TAG_DATA while the scanner listed _load_data. The scanner was right, and this is now execution evidence rather than a reading of marshal.c: a hand-built d stream naming Thread::Mutex, a real C-level T_DATA, raises TypeError naming the missing _load_data, while the same stream naming String dies earlier at "dump format error" - which is why the previous attempt could not see it. A test compares both definitions directly so they cannot drift again. Float fidelity (B17). read_float returned nil for seven body forms Marshal.load accepts, two more than the finding listed. Ruby uses its own ruby_strtod, so "INF" is 0.0 while "inf" is Infinity, and String#to_f turns out to be that same function. Ruby's legacy binary mantissa is NOT decoded: a model fitted to four oracle points passed a 25-case table and then failed 930 of 5000 randomised cases, and marshal.c is not available in these images. Since 0 of 209 Marshal.dump outputs contain a NUL, no living Ruby emits that form, so the parser records the strtod prefix and flags Node#undecoded_tail instead. A plausible wrong number is worse than a labelled non-answer. Final differential: 2919 agreed exactly, 2081 flagged, 0 claimed-and-wrong. Hygiene (B24, B25, B27, B28). PRIMITIVE_CLASS_NAMES and NAMESPACE_SEPARATOR had one reference each, their own definition. BIGNUM_SIGN_POSITIVE is live and stays. The width > FIXNUM_MAX_WIDTH guard is unreachable for all 256 possible marker bytes, checked exhaustively, and raised the wrong error class; it is replaced by a test that derives widths from real Marshal.dump output. The symlink and object-link bounds checks no longer lean on negative-index wraparound. The parse graph is sealed before it is returned - every node, its collections and its scalars frozen - and the whole suite stayed green first try, which proves nothing downstream was mutating it. exploit-gate.sh pins both sides now, 4.0.2-slim erb 6.0.1 FIRED against 4.0.6-slim erb 6.0.1.1 BLOCKED; the finding's claim about detector-gate.sh was wrong, it never had a patched side. Lint (B30). just lint used a --network none runner, so gem install could never reach RubyGems, the && short-circuited, and the recipe exited 2 while printing absolutely nothing. That is the fourth instance of a dropped return value hiding a failure in this project. It is loud now. The config had never been validated against a real run: 903 offenses, dominated by a quote style the codebase does not use. frozen_string_literal was verified safe by running the whole suite under RUBYOPT=--enable-frozen-string-literal BEFORE the change, so ~247 offenses were retired by fixing code rather than silencing a cop. Every remaining disabled cop carries a reason. --- .../deserialization-gadget-lab/.rubocop.yml | 65 ++++- .../deserialization-gadget-lab/CHANGELOG.md | 45 +++- .../deserialization-gadget-lab/Gemfile | 1 + .../deserialization-gadget-lab/README.md | 31 ++- .../deserialization-gadget-lab/Rakefile | 1 + .../deserialization-gadget-lab/justfile | 7 +- .../deserialization-gadget-lab/lib/rube.rb | 2 + .../lib/rube/chains.rb | 7 +- .../lib/rube/chains/base.rb | 2 +- .../lib/rube/chains/erb_def_method.rb | 3 +- .../lib/rube/marshal/boundary_detector.rb | 75 ++++-- .../lib/rube/marshal/constants.rb | 13 +- .../lib/rube/marshal/errors.rb | 1 + .../lib/rube/marshal/float_body.rb | 40 +++ .../lib/rube/marshal/limits.rb | 1 + .../lib/rube/marshal/node.rb | 20 +- .../lib/rube/marshal/parser.rb | 19 +- .../lib/rube/scanner.rb | 153 ++++++++--- .../lib/rube/version.rb | 1 + .../deserialization-gadget-lab/rube.gemspec | 1 + .../scripts/detector-gate.sh | 25 +- .../scripts/exploit-gate.sh | 2 +- .../scripts/render_matrix.rb | 9 +- .../scripts/target-gate.sh | 52 ++++ .../deserialization-gadget-lab/target/app.rb | 21 +- .../target/config.ru | 1 + .../test/chains_test.rb | 1 + .../test/control_check.rb | 38 ++- .../test/corpus_test.rb | 10 +- .../test/marshal/boundary_detector_test.rb | 198 ++++++++++++-- .../test/marshal/parser_test.rb | 169 +++++++++++- .../test/scanner_test.rb | 243 +++++++++++++++++- .../test/support/adversarial_corpus.rb | 1 + .../test/support/exploit_probe.rb | 4 +- .../test/support/matrix_probe.rb | 1 + .../test/test_helper.rb | 1 + 36 files changed, 1124 insertions(+), 140 deletions(-) create mode 100644 PROJECTS/beginner/deserialization-gadget-lab/lib/rube/marshal/float_body.rb 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__))