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.
This commit is contained in:
CarterPerez-dev 2026-07-29 05:12:39 -04:00
parent 582e032cc0
commit a866587f10
2 changed files with 74 additions and 5 deletions

View File

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

View File

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