From a866587f102990059474f981878904f162029099 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Wed, 29 Jul 2026 05:12:39 -0400 Subject: [PATCH] docs(rube): README and CHANGELOG describe what actually shipped README's Status section listed the scanner, matrix, payload builder, target and detector as "planned". All five have shipped. It also taught Parser.new(blob) as the API, which was the unbounded form until this branch made bounded limits the default. Status now names all six components. Usage teaches the bounded default, points at Limits.permissive for forensic parsing of a stream you already trust, shows the detector alongside the parser, and points readers at LIMITATION_NOTICE before they rely on an accept. Development lists all eleven just recipes instead of five. Every code example in the README was executed verbatim before this commit and produces exactly the output it claims. CHANGELOG gains Changed and Fixed sections covering the default-limits change and the six defects closed on this branch. --- .../deserialization-gadget-lab/CHANGELOG.md | 38 +++++++++++++++++ .../deserialization-gadget-lab/README.md | 41 ++++++++++++++++--- 2 files changed, 74 insertions(+), 5 deletions(-) diff --git a/PROJECTS/beginner/deserialization-gadget-lab/CHANGELOG.md b/PROJECTS/beginner/deserialization-gadget-lab/CHANGELOG.md index d59ef371..99b77ba1 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/CHANGELOG.md +++ b/PROJECTS/beginner/deserialization-gadget-lab/CHANGELOG.md @@ -17,3 +17,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Stream validation rejecting truncated payloads, unsupported version bytes, unknown type tags, out-of-bounds object links and symlinks, oversized fixnum widths, trailing bytes, and nesting beyond a configurable depth limit +- Reflection-based gadget scanner that walks `ObjectSpace` for auto-invoked + methods and reports whether a Prism-backed reachability filter considers each + one reachable from a deserialized object +- Version-compatibility matrix probing six pinned Ruby images, indexed by + RubyGems version rather than Ruby version because the gadget lives in RubyGems +- Version-scoped payload chains carrying their own affected ranges, with + 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 +- 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 + dispatches + +### Changed + +- `Parser.new` now enforces `Limits.new` by default instead of resolving to an + unbounded configuration. Pass `limits: Limits.permissive` for forensic parsing + of a stream you already trust +- `Limits.permissive` is a class method; it was an instance method that ignored + its receiver and allocated twice + +### Fixed + +- `TAG_IVAR` did not increment depth, so an `I`-chain of any length parsed under + any ceiling and a 13 KB payload exhausted the Ruby stack with a + `SystemStackError` that no `rescue StreamError` could catch +- `read_userdef` hard-coded a depth of 1 for its class-name slot, handing that + subtree a fresh depth budget mid-stream +- Bignum magnitude bytes bypassed the scalar budget entirely, so 400,000 of them + were accepted where a 400,000-byte string was rejected +- Bignum sign byte was treated as negative-or-positive with no validation, so any + byte other than `-` read as positive where `Marshal.load` raises `ArgumentError` +- Symbol references, symbol name bytes, class name bytes, instance variable + counts, and struct member counts were charged to no budget or to an overly + generous shared one diff --git a/PROJECTS/beginner/deserialization-gadget-lab/README.md b/PROJECTS/beginner/deserialization-gadget-lab/README.md index 408f87f0..a4796c09 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/README.md +++ b/PROJECTS/beginner/deserialization-gadget-lab/README.md @@ -28,15 +28,21 @@ opposite outcome, decided entirely by where the check sits. ## Status -Under construction. What exists and is tested: +All six pieces are built and tested. - **Marshal stream parser** — parses the binary format, extracts referenced class names and gadget sinks, and validates structure, all without ever calling `Marshal.load`. Rejects truncated streams, unsupported versions, unknown tags, out-of-bounds object links and symlinks, oversized fixnum widths, trailing bytes, and excessive nesting. - -Planned: version-compatibility matrix, reflection-based gadget scanner, payload builder, -a deliberately vulnerable containerized target, and the defensive layer. +- **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. +- **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. +- **Boundary detector** — the defensive layer, with an explicit written statement of what + it cannot do. ## Usage @@ -53,6 +59,24 @@ result.sinks.map { |s| "#{s.class_name}##{s.sink_method}" } # => ["Gem::Requirement#marshal_load", "Gem::Version#marshal_load"] ``` +`Parser.new` enforces `Rube::Marshal::Limits.new` unless you say otherwise. Every ceiling +is opt-out, never opt-in — pass `limits: Rube::Marshal::Limits.permissive` if you are doing +forensics on a stream you already trust and want it parsed whole. + +To make a decision rather than inspect a stream, use the detector, which applies a policy +and hands back a frozen snapshot: + +```ruby +detector = Rube::Marshal::BoundaryDetector.new(allowed_class_names: %w[Hash String]) +decision = detector.inspect_stream(untrusted_bytes) + +decision.rejected? # => 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. + Nothing above instantiates a class, calls a constructor, or invokes `Marshal.load`. ## Development @@ -60,9 +84,16 @@ Nothing above instantiates a class, calls a constructor, or invokes `Marshal.loa Everything runs in Docker against a pinned Ruby. ``` -just test run the parser suite +just test run the minitest suites just control run the negative controls just check both +just corpus print every adversarial corpus case and its verdict +just scan run the gadget scanner over loaded modules +just matrix probe six pinned Ruby images and render the compatibility matrix +just exploit prove the chain fires on a vulnerable image and is blocked on a patched one +just target stand up the vulnerable app and attack it over HTTP +just detector prove the defensive layer rejects the payload the target executes +just gate everything above, in order just build build the gem with --strict just manifest list exactly what would ship in the .gem ```