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 ```