feat(marshalsea): rename off a name taken since 2009, then build the release path
rube has been on rubygems.org since 2009-08-05: Richard LeBer, 12,305 downloads, and it is an ERB front-end, which is funny given the flagship CVE here is an ERB gadget. The name was never publishable, so publishing required a rename first. marshalsea. The Marshalsea was a London debtors' prison, 1373 to 1842, and the name is the job description: hold untrusted objects at the gate and decide what gets through before Marshal.load turns bytes into behaviour. It also carries "Marshal", so the gem reads as on-topic without a subtitle. module Rube is module Marshalsea, lib/rube/ is lib/marshalsea/, require "rube" is require "marshalsea", RUBE_TARGET_PORT is MARSHALSEA_TARGET_PORT, and the canary moved to /tmp/marshalsea-canary. 31 files, roughly 163 occurrences, every one a hand edit. The single deliberate survivor is the README's "a Rube Goldberg machine", which describes the gadget chain and not the gem. Publishing is trusted publishing over OIDC, so no long-lived API key exists in this repository to leak. A marshalsea-v* tag runs the five suites and the standalone controls on Ruby 3.4 and 4.0, refuses to continue if the tag disagrees with Marshalsea::VERSION or if the gemspec floor stops matching the tested matrix, and then publishes with a Sigstore attestation. The attestation is recorded as an auditable record and explicitly NOT as an install-time protection, because neither gem install nor bundle install verifies one today. Two things the primary source settled that the docs did not. rubygems/release-gem does accept working-directory, which neither its README nor the RubyGems guide mentions, so a monorepo subdirectory works. And it runs bundle exec rake release, which this Rakefile had no task for at all. Adding bundler/gem_tasks exposed a monorepo trap: Bundler::GemHelper tags a bare v0.1.0, which says nothing about which of sixty projects it belongs to. Fixed with tag_prefix. The catch is that rake -T still PRINTS "Create tag v0.1.0", because that description is built when gem_tasks is required and the prefix is assigned after. The tag actually created is marshalsea-v0.1.0. The label is wrong and the behaviour is right, so the gate asserts the runtime value and carries a control proving a Rakefile without the prefix line really does produce the bare tag. Full gate: 58 PASS, 0 FAIL across six stages, package now 25 of 25. 194 tests. Lint 0 across 30 files.
This commit is contained in:
parent
bfb0ab36e8
commit
9e616ad1a4
|
|
@ -0,0 +1,98 @@
|
|||
# =============================================================================
|
||||
# AngelaMos | 2026
|
||||
# publish-marshalsea.yml
|
||||
# =============================================================================
|
||||
|
||||
name: Publish marshalsea to RubyGems
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'marshalsea-v*'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
PROJECT_DIR: PROJECTS/beginner/deserialization-gadget-lab
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Test on Ruby ${{ matrix.ruby }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
ruby: ['3.4', '4.0']
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Ruby ${{ matrix.ruby }}
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: ${{ matrix.ruby }}
|
||||
bundler-cache: true
|
||||
working-directory: PROJECTS/beginner/deserialization-gadget-lab
|
||||
|
||||
- name: Run the suites
|
||||
working-directory: PROJECTS/beginner/deserialization-gadget-lab
|
||||
run: |
|
||||
for suite in test/marshal/parser_test.rb test/scanner_test.rb test/chains_test.rb \
|
||||
test/marshal/boundary_detector_test.rb test/corpus_test.rb; do
|
||||
echo "::group::${suite}"
|
||||
ruby -Ilib -Itest "${suite}"
|
||||
echo "::endgroup::"
|
||||
done
|
||||
|
||||
- name: Run the standalone controls
|
||||
working-directory: PROJECTS/beginner/deserialization-gadget-lab
|
||||
run: ruby -Ilib -Itest test/control_check.rb
|
||||
|
||||
- name: Confirm the declared floor matches this matrix
|
||||
working-directory: PROJECTS/beginner/deserialization-gadget-lab
|
||||
run: |
|
||||
floor=$(ruby -e 'print Gem::Specification.load("marshalsea.gemspec").required_ruby_version.to_s')
|
||||
echo "gemspec declares ${floor}, tested on ${{ matrix.ruby }}"
|
||||
test "${floor}" = ">= 3.4" || {
|
||||
echo "::error::gemspec floor ${floor} no longer matches the tested matrix"
|
||||
exit 1
|
||||
}
|
||||
|
||||
release:
|
||||
name: Push marshalsea to RubyGems
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: rubygems
|
||||
url: https://rubygems.org/gems/marshalsea
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Ruby
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: '4.0'
|
||||
bundler-cache: true
|
||||
working-directory: PROJECTS/beginner/deserialization-gadget-lab
|
||||
|
||||
- name: Confirm the tag matches the gem version
|
||||
working-directory: PROJECTS/beginner/deserialization-gadget-lab
|
||||
run: |
|
||||
version=$(ruby -e 'require "./lib/marshalsea/version"; print Marshalsea::VERSION')
|
||||
expected="marshalsea-v${version}"
|
||||
echo "tag=${GITHUB_REF_NAME} gemspec=${expected}"
|
||||
if [ "${GITHUB_REF_NAME}" != "${expected}" ] && [ "${GITHUB_EVENT_NAME}" = "push" ]; then
|
||||
echo "::error::tag ${GITHUB_REF_NAME} does not match ${expected}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Release to RubyGems with trusted publishing and attestation
|
||||
uses: rubygems/release-gem@v1
|
||||
with:
|
||||
working-directory: PROJECTS/beginner/deserialization-gadget-lab
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
# Changelog
|
||||
|
||||
All notable changes to rube are documented here.
|
||||
All notable changes to marshalsea are documented here.
|
||||
|
||||
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
|
@ -38,6 +38,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
- 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?`
|
||||
- Release workflow publishing to RubyGems by trusted publishing, so no long-lived API
|
||||
key exists in repository secrets to leak. It fires on a `marshalsea-v*` tag, runs the
|
||||
suites and the standalone controls on both Ruby 3.4 and 4.0 first, refuses to publish
|
||||
if the tag disagrees with `Marshalsea::VERSION` or if the gemspec floor no longer
|
||||
matches the tested matrix, and emits a Sigstore attestation. The attestation is a
|
||||
publicly auditable record of which workflow built the artifact; it is **not** an
|
||||
install-time protection, because neither `gem install` nor `bundle install` verifies
|
||||
one today
|
||||
- Packaging gate that builds the gem from its declared manifest alone, audits
|
||||
what shipped, installs the artifact on the floor and current images, and
|
||||
exercises it from the installed copy rather than the worktree. It asserts every
|
||||
|
|
@ -90,6 +98,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
of a stream you already trust
|
||||
- `Limits.permissive` is a class method; it was an instance method that ignored
|
||||
its receiver and allocated twice
|
||||
- **Renamed from `rube` to `marshalsea`.** `rube` has been taken on rubygems.org since
|
||||
2009-08-05, so the original name could never have been published. The module is
|
||||
`Marshalsea`, the library path is `lib/marshalsea/`, and `require "rube"` becomes
|
||||
`require "marshalsea"`. The Marshalsea was a London debtors' prison; the name is the
|
||||
job description, since the tool holds untrusted objects at the gate and decides what
|
||||
gets through
|
||||
- `Rakefile` gained `bundler/gem_tasks` and, with it, the `release` task the publishing
|
||||
action invokes. It sets `Bundler::GemHelper.tag_prefix = "marshalsea-"`, because the
|
||||
default produces a bare `v0.1.0` tag and this gem lives in a repository shared by
|
||||
sixty projects. Note that `rake -T` still *prints* `Create tag v0.1.0`: that
|
||||
description string is built when `bundler/gem_tasks` is required, before the prefix is
|
||||
assigned. The tag actually created is `marshalsea-v0.1.0`, and `just package` asserts
|
||||
the real value rather than the printed one
|
||||
- `required_ruby_version` raised from `>= 3.3` to `>= 3.4`. The old floor was
|
||||
never tested: every gate stage ran on Ruby 4.0 images only. Ruby 3.3 turns out
|
||||
to fail the suite, because `Marshal.load` did not validate the bignum sign byte
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# rube
|
||||
# marshalsea
|
||||
|
||||
A Ruby object-deserialization security lab.
|
||||
|
||||
|
|
@ -6,6 +6,10 @@ A gadget chain is a Rube Goldberg machine. One untrusted blob goes in, a dozen
|
|||
unrelated standard-library methods knock each other over, and code execution falls out
|
||||
the far end. This project builds the machine, then builds the thing that stops it.
|
||||
|
||||
The Marshalsea was a London debtors' prison, in operation from 1373 to 1842. The name is
|
||||
the job: hold untrusted objects at the gate and decide what gets through, before
|
||||
`Marshal.load` turns bytes into behaviour.
|
||||
|
||||
## Why this exists
|
||||
|
||||
`Marshal.load` on untrusted input is arbitrary code execution. So is `YAML.unsafe_load`,
|
||||
|
|
@ -59,7 +63,7 @@ same stream raises `ArgumentError: invalid Bignum sign`:
|
|||
| `+` and `-` | accept | accept | accept | accept |
|
||||
| `!`, `\x00`, `\xFF`, `0` | accept | accept | **reject** | **reject** |
|
||||
|
||||
rube's parser accepts `+` and `-` only, so it models 3.4 and newer. Run it on 3.3 and it
|
||||
marshalsea's parser accepts `+` and `-` only, so it models 3.4 and newer. Run it on 3.3 and it
|
||||
disagrees with the interpreter it exists to model on four of those six bytes. A stream
|
||||
inspector that disagrees with the loader it guards is not worth shipping, so the floor sits
|
||||
where the agreement starts. `just package` re-proves this in both directions on every run:
|
||||
|
|
@ -67,13 +71,17 @@ on the floor image Ruby and the parser agree, one version below it they diverge.
|
|||
|
||||
## Installation
|
||||
|
||||
rube is not published to rubygems.org. Build it from this checkout and install the artifact:
|
||||
The first release has not been cut yet, so there is nothing on rubygems.org to install from.
|
||||
Build it from this checkout:
|
||||
|
||||
```
|
||||
just build
|
||||
gem install --local tmp/build/rube-0.1.0.gem
|
||||
gem install --local tmp/build/marshalsea-0.1.0.gem
|
||||
```
|
||||
|
||||
Releases are published from CI by trusted publishing, so no long-lived API key exists to
|
||||
leak. `gem install marshalsea` starts working once the first tag ships.
|
||||
|
||||
The gem carries `lib/`, the README, the changelog, and the license. Nothing else. The
|
||||
vulnerable target, the adversarial corpus, the gate scripts, and the research notes stay in
|
||||
the repository, and `just package` fails if any of them turn up inside a built artifact.
|
||||
|
|
@ -81,10 +89,10 @@ the repository, and `just package` fails if any of them turn up inside a built a
|
|||
## Usage
|
||||
|
||||
```ruby
|
||||
require "rube"
|
||||
require "marshalsea"
|
||||
|
||||
payload = Marshal.dump(Gem::Requirement.new(">= 0"))
|
||||
result = Rube::Marshal::Parser.new(payload).parse
|
||||
result = Marshalsea::Marshal::Parser.new(payload).parse
|
||||
|
||||
result.class_names
|
||||
# => ["Gem::Requirement", "Gem::Version"]
|
||||
|
|
@ -93,15 +101,15 @@ 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
|
||||
`Parser.new` enforces `Marshalsea::Marshal::Limits.new` unless you say otherwise. Every ceiling
|
||||
is opt-out, never opt-in — pass `limits: Marshalsea::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])
|
||||
detector = Marshalsea::Marshal::BoundaryDetector.new(allowed_class_names: %w[Hash String])
|
||||
decision = detector.inspect_stream(untrusted_bytes)
|
||||
|
||||
decision.blocked? # => true
|
||||
|
|
@ -129,7 +137,7 @@ There is no `accepted?`. The question "did the policy permit this" and the quest
|
|||
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?`.
|
||||
Read `Marshalsea::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`.
|
||||
|
|
@ -158,9 +166,34 @@ just manifest list exactly what would ship in the .gem
|
|||
on disk still matches the source it claims to be built from:
|
||||
|
||||
```
|
||||
just package tmp/build/rube-0.1.0.gem
|
||||
just package tmp/build/marshalsea-0.1.0.gem
|
||||
```
|
||||
|
||||
## Releasing
|
||||
|
||||
Bump `Marshalsea::VERSION`, then push a tag:
|
||||
|
||||
```
|
||||
git tag marshalsea-v0.1.0
|
||||
git push origin marshalsea-v0.1.0
|
||||
```
|
||||
|
||||
That is the whole release. CI runs the suites and the standalone controls on Ruby 3.4 and
|
||||
4.0, refuses to continue if the tag disagrees with `Marshalsea::VERSION` or if the gemspec
|
||||
floor no longer matches the tested matrix, and then publishes through RubyGems trusted
|
||||
publishing. There is no API key anywhere in this repository, and none to rotate or leak:
|
||||
the job proves its identity to rubygems.org with a short-lived OIDC token issued by GitHub
|
||||
for that specific workflow.
|
||||
|
||||
The tag is prefixed because sixty projects share this repository and a bare `v0.1.0` would
|
||||
not say which one it belongs to. `rake -T` still prints `Create tag v0.1.0` because that
|
||||
description is built before the prefix is applied; the tag actually created is
|
||||
`marshalsea-v0.1.0`, and `just package` asserts the real value rather than the printed one.
|
||||
|
||||
Each release also publishes a Sigstore attestation recording which workflow built the
|
||||
artifact and from which commit. Treat it as an auditable record, not as protection: neither
|
||||
`gem install` nor `bundle install` verifies attestations today.
|
||||
|
||||
## A note on the object-link index
|
||||
|
||||
Ruby's Marshal format documentation states that object links are one-indexed. **They are
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require "rake/testtask"
|
||||
require "bundler/gem_tasks"
|
||||
|
||||
Bundler::GemHelper.tag_prefix = "marshalsea-"
|
||||
|
||||
Rake::TestTask.new(:test) do |t|
|
||||
t.libs = %w[lib test]
|
||||
|
|
|
|||
|
|
@ -22,10 +22,10 @@ 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.proceed? ? "accept" : "reject", k[:allowed].join(","), dec.reason.to_s[0, 52]) }'
|
||||
{{run_ro}} ruby -Ilib -Itest -e 'require "marshalsea"; require "support/adversarial_corpus"; Marshalsea::AdversarialCorpus::CASES.each { |k| d = Marshalsea::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} 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'
|
||||
{{run_ro}} ruby -Ilib -e 'require "marshalsea"; ns = "{{namespace}}"; r = Marshalsea::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
|
||||
|
|
@ -57,11 +57,11 @@ gate: check matrix exploit detector target package
|
|||
|
||||
build:
|
||||
@mkdir -p tmp/build
|
||||
{{build_run}} -v $PWD:/src:ro -v $PWD/tmp/build:/out -w /out {{image}} sh -c "set -e; cd /src && ruby -e 'puts Gem::Specification.load(%q{rube.gemspec}).files' >/out/declared.txt; cd /out && tar -C /src -T declared.txt -cf - | tar -xf -; cp /src/rube.gemspec /out/; gem build --strict rube.gemspec"
|
||||
{{build_run}} -v $PWD:/src:ro -v $PWD/tmp/build:/out -w /out {{image}} sh -c "set -e; cd /src && ruby -e 'puts Gem::Specification.load(%q{marshalsea.gemspec}).files' >/out/declared.txt; cd /out && tar -C /src -T declared.txt -cf - | tar -xf -; cp /src/marshalsea.gemspec /out/; gem build --strict marshalsea.gemspec"
|
||||
@ls -l tmp/build/*.gem
|
||||
|
||||
manifest:
|
||||
{{run_ro}} ruby -e 'spec = Gem::Specification.load("rube.gemspec"); puts spec.files.sort; puts; puts "#{spec.files.length} files"'
|
||||
{{run_ro}} ruby -e 'spec = Gem::Specification.load("marshalsea.gemspec"); puts spec.files.sort; puts; puts "#{spec.files.length} files"'
|
||||
|
||||
shell:
|
||||
docker run --rm -it --network none -v $PWD:/app -w /app {{image}} bash
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
# ©AngelaMos | 2026
|
||||
# marshalsea.rb
|
||||
# frozen_string_literal: true
|
||||
|
||||
require_relative "marshalsea/version"
|
||||
require_relative "marshalsea/marshal/constants"
|
||||
require_relative "marshalsea/marshal/errors"
|
||||
require_relative "marshalsea/marshal/node"
|
||||
require_relative "marshalsea/marshal/float_body"
|
||||
require_relative "marshalsea/marshal/limits"
|
||||
require_relative "marshalsea/marshal/parser"
|
||||
require_relative "marshalsea/marshal/boundary_detector"
|
||||
require_relative "marshalsea/scanner"
|
||||
require_relative "marshalsea/chains"
|
||||
|
||||
module Marshalsea
|
||||
end
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
# chains.rb
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Rube
|
||||
module Marshalsea
|
||||
module Chains
|
||||
class UnknownChainError < StandardError; end
|
||||
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
# base.rb
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Rube
|
||||
module Marshalsea
|
||||
module Chains
|
||||
class ChainError < StandardError; end
|
||||
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
require "erb"
|
||||
|
||||
module Rube
|
||||
module Marshalsea
|
||||
module Chains
|
||||
class ErbDefMethod < Base
|
||||
CHAIN_NAME = "erb-def-method"
|
||||
|
|
@ -20,7 +20,7 @@ module Rube
|
|||
].freeze
|
||||
|
||||
SRC_PREFIX = "#\nend\n"
|
||||
SRC_SUFFIX = "\ndef _rube_unused\n"
|
||||
SRC_SUFFIX = "\ndef _marshalsea_unused\n"
|
||||
DEFAULT_FILENAME = "(erb)"
|
||||
DEFAULT_LINENO = 0
|
||||
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
# boundary_detector.rb
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Rube
|
||||
module Marshalsea
|
||||
module Marshal
|
||||
class ReporterRequiredError < StandardError; end
|
||||
|
||||
|
|
@ -32,7 +32,7 @@ module Rube
|
|||
LIMITATION_NOTICE = <<~NOTICE
|
||||
SECURITY LIMITATION
|
||||
|
||||
Rube::Marshal::BoundaryDetector examines a bounded snapshot of Marshal bytes and
|
||||
Marshalsea::Marshal::BoundaryDetector examines a bounded snapshot of Marshal bytes and
|
||||
applies a caller-selected policy before deserialization. An ACCEPT decision means
|
||||
only that this snapshot matched that policy.
|
||||
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
# constants.rb
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Rube
|
||||
module Marshalsea
|
||||
module Marshal
|
||||
module Constants
|
||||
MAJOR_VERSION = 4
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
# errors.rb
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Rube
|
||||
module Marshalsea
|
||||
module Marshal
|
||||
class StreamError < StandardError; end
|
||||
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
# float_body.rb
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Rube
|
||||
module Marshalsea
|
||||
module Marshal
|
||||
module FloatBody
|
||||
module_function
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
# limits.rb
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Rube
|
||||
module Marshalsea
|
||||
module Marshal
|
||||
class Limits
|
||||
DEFAULT_MAX_BYTES = 1_048_576
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
# node.rb
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Rube
|
||||
module Marshalsea
|
||||
module Marshal
|
||||
class Node
|
||||
STRING_BACKED_TYPES = %i[string regexp].freeze
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
# parser.rb
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Rube
|
||||
module Marshalsea
|
||||
module Marshal
|
||||
class Parser
|
||||
include Constants
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
# scanner.rb
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Rube
|
||||
module Marshalsea
|
||||
class Scanner
|
||||
GATED_METHODS = %w[marshal_load _load_data].freeze
|
||||
GATED_SINGLETON_METHODS = %w[_load].freeze
|
||||
|
|
@ -2,6 +2,6 @@
|
|||
# version.rb
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Rube
|
||||
module Marshalsea
|
||||
VERSION = "0.1.0"
|
||||
end
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
# ©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"
|
||||
require_relative "rube/scanner"
|
||||
require_relative "rube/chains"
|
||||
|
||||
module Rube
|
||||
end
|
||||
|
|
@ -1,19 +1,19 @@
|
|||
# ©AngelaMos | 2026
|
||||
# rube.gemspec
|
||||
# marshalsea.gemspec
|
||||
# frozen_string_literal: true
|
||||
|
||||
require_relative "lib/rube/version"
|
||||
require_relative "lib/marshalsea/version"
|
||||
|
||||
Gem::Specification.new do |spec|
|
||||
spec.name = "rube"
|
||||
spec.version = Rube::VERSION
|
||||
spec.name = "marshalsea"
|
||||
spec.version = Marshalsea::VERSION
|
||||
spec.authors = ["Carter Perez"]
|
||||
spec.email = ["carterperez2222@gmail.com"]
|
||||
|
||||
spec.summary = "Ruby object-deserialization security lab: inspect, understand, and defend against gadget chains"
|
||||
spec.description = "rube parses Ruby Marshal streams without deserializing them, discovers gadget " \
|
||||
"sinks by runtime reflection, and ships the defenses that stop them. Built as a " \
|
||||
"teaching artifact for the deserialization vulnerability class."
|
||||
spec.description = "marshalsea parses Ruby Marshal streams without deserializing them, discovers " \
|
||||
"gadget sinks by runtime reflection, and ships the defenses that stop them. Built " \
|
||||
"as a teaching artifact for the deserialization vulnerability class."
|
||||
spec.homepage = "https://github.com/CarterPerez-dev/Cybersecurity-Projects"
|
||||
spec.license = "AGPL-3.0-or-later"
|
||||
|
||||
|
|
@ -13,11 +13,11 @@ echo
|
|||
output="$(docker run --rm --network none --read-only --tmpfs /tmp:rw,noexec,nosuid,size=1m \
|
||||
--user nobody \
|
||||
-v "${HERE}/lib:/app/lib:ro" -w /app "${VULNERABLE_IMAGE}" ruby -Ilib -e '
|
||||
require "rube"
|
||||
D = Rube::Marshal::BoundaryDetector
|
||||
CANARY = "/tmp/rube-canary"
|
||||
require "marshalsea"
|
||||
D = Marshalsea::Marshal::BoundaryDetector
|
||||
CANARY = "/tmp/marshalsea-canary"
|
||||
|
||||
hostile = Rube::Chains::ErbDefMethod.canary(CANARY, "fired").serialize
|
||||
hostile = Marshalsea::Chains::ErbDefMethod.canary(CANARY, "fired").serialize
|
||||
benign = Marshal.dump({ "user" => "guest", "roles" => [1, 2, 3] })
|
||||
sinky = Marshal.dump(Gem::Requirement.new(">= 0"))
|
||||
|
||||
|
|
|
|||
|
|
@ -42,15 +42,15 @@ if [[ -n "${GIVEN}" ]]; then
|
|||
echo "gem_built=true" | record
|
||||
else
|
||||
GEM_DIR="${WORK}/build"
|
||||
GEM_FILE="rube-$(run -v "${HERE}:/src:ro" -w /src "${BUILD_IMAGE}" \
|
||||
ruby -e 'require "./lib/rube/version"; print Rube::VERSION').gem"
|
||||
GEM_FILE="marshalsea-$(run -v "${HERE}:/src:ro" -w /src "${BUILD_IMAGE}" \
|
||||
ruby -e 'require "./lib/marshalsea/version"; print Marshalsea::VERSION').gem"
|
||||
|
||||
run -v "${HERE}:/src:ro" -v "${WORK}/build:/out" -w /out "${BUILD_IMAGE}" sh -c '
|
||||
set -e
|
||||
cd /src && ruby -e "puts Gem::Specification.load(%q{rube.gemspec}).files" >/out/declared.txt
|
||||
cd /src && ruby -e "puts Gem::Specification.load(%q{marshalsea.gemspec}).files" >/out/declared.txt
|
||||
cd /out && tar -C /src -T declared.txt -cf - | tar -xf -
|
||||
cp /src/rube.gemspec /out/
|
||||
gem build --strict rube.gemspec
|
||||
cp /src/marshalsea.gemspec /out/
|
||||
gem build --strict marshalsea.gemspec
|
||||
' 2>&1 | sed 's/^/ /'
|
||||
|
||||
if [[ -f "${GEM_DIR}/${GEM_FILE}" ]]; then
|
||||
|
|
@ -75,16 +75,16 @@ install_and_use() {
|
|||
set -e
|
||||
gem install --local --no-document /gem/${GEM_FILE} >/dev/null
|
||||
ruby -e '
|
||||
require \"rube\"
|
||||
raise \"loaded from the worktree\" unless Gem.loaded_specs[\"rube\"]
|
||||
require \"marshalsea\"
|
||||
raise \"loaded from the worktree\" unless Gem.loaded_specs[\"marshalsea\"]
|
||||
blob = Marshal.dump(Gem::Requirement.new(\">= 0\"))
|
||||
result = Rube::Marshal::Parser.new(blob).parse
|
||||
result = Marshalsea::Marshal::Parser.new(blob).parse
|
||||
sinks = result.sinks.map { |s| \"#{s.class_name}##{s.sink_method}\" }
|
||||
decision = Rube::Marshal::BoundaryDetector.new.inspect_stream(blob)
|
||||
decision = Marshalsea::Marshal::BoundaryDetector.new.inspect_stream(blob)
|
||||
ok = result.class_names.include?(\"Gem::Requirement\") &&
|
||||
sinks.include?(\"Gem::Requirement#marshal_load\") &&
|
||||
decision.blocked? &&
|
||||
!defined?(Rube::Marshal::FloatBody).nil?
|
||||
!defined?(Marshalsea::Marshal::FloatBody).nil?
|
||||
puts \"installed_gem_works_on_${label}=#{ok}\"
|
||||
'
|
||||
" 2>&1 | tail -1
|
||||
|
|
@ -94,7 +94,23 @@ install_and_use "${FLOOR_IMAGE}" floor | record
|
|||
install_and_use "${BUILD_IMAGE}" current | record
|
||||
echo
|
||||
|
||||
echo "=== 4 the floor is measured, not asserted ==="
|
||||
echo "=== 4 release identity ==="
|
||||
release_tag="$(run -v "${HERE}:/app:ro" -w /app "${BUILD_IMAGE}" \
|
||||
ruby -e 'require "rake"; load "Rakefile"; print Bundler::GemHelper.instance.send(:version_tag)' 2>/dev/null)"
|
||||
gem_version="$(run -v "${HERE}:/app:ro" -w /app "${BUILD_IMAGE}" \
|
||||
ruby -e 'require "./lib/marshalsea/version"; print Marshalsea::VERSION' 2>/dev/null)"
|
||||
echo " rake release would tag: ${release_tag}"
|
||||
echo "release_tag_is_namespaced=$([[ ${release_tag} == "marshalsea-v${gem_version}" ]] && echo true || echo false)" | record
|
||||
|
||||
bare_tag="$(run -v "${HERE}:/app:ro" -w /tmp "${BUILD_IMAGE}" sh -c '
|
||||
cp -r /app/lib /app/marshalsea.gemspec /app/README.md /app/CHANGELOG.md /app/LICENSE /tmp/ 2>/dev/null
|
||||
printf "require \"rake\"\nrequire \"bundler/gem_tasks\"\n" >/tmp/Rakefile
|
||||
ruby -e "require \"rake\"; load \"Rakefile\"; print Bundler::GemHelper.instance.send(:version_tag)"
|
||||
' 2>/dev/null)"
|
||||
echo "control_bare_rakefile_tags_the_whole_monorepo=$([[ ${bare_tag} == "v${gem_version}" ]] && echo true || echo false)" | record
|
||||
echo
|
||||
|
||||
echo "=== 5 the floor is measured, not asserted ==="
|
||||
suite_status=0
|
||||
for suite in marshal/parser_test scanner_test chains_test marshal/boundary_detector_test corpus_test; do
|
||||
if ! docker run --rm --network none -v "${HERE}:/app:ro" -w /app "${FLOOR_IMAGE}" \
|
||||
|
|
@ -114,7 +130,7 @@ differential() {
|
|||
local label="$2"
|
||||
|
||||
docker run --rm --network none -v "${HERE}:/app:ro" -w /app "${image}" ruby -Ilib -e "
|
||||
require \"rube\"
|
||||
require \"marshalsea\"
|
||||
bytes = ${INVALID_SIGN_STREAM}
|
||||
ruby_accepts = begin
|
||||
Marshal.load(bytes)
|
||||
|
|
@ -123,9 +139,9 @@ differential() {
|
|||
false
|
||||
end
|
||||
parser_accepts = begin
|
||||
Rube::Marshal::Parser.new(bytes).parse
|
||||
Marshalsea::Marshal::Parser.new(bytes).parse
|
||||
true
|
||||
rescue Rube::Marshal::StreamError
|
||||
rescue Marshalsea::Marshal::StreamError
|
||||
false
|
||||
end
|
||||
puts \"${label}_ruby_accepts_invalid_sign=#{ruby_accepts}\"
|
||||
|
|
@ -137,13 +153,13 @@ differential "${FLOOR_IMAGE}" floor | record
|
|||
differential "${BELOW_FLOOR_IMAGE}" below_floor | record
|
||||
echo
|
||||
|
||||
echo "=== 5 negative controls ==="
|
||||
cat >"${WORK}/ships-target/rube.gemspec" <<'SPEC'
|
||||
require_relative "lib/rube/version"
|
||||
echo "=== 6 negative controls ==="
|
||||
cat >"${WORK}/ships-target/marshalsea.gemspec" <<'SPEC'
|
||||
require_relative "lib/marshalsea/version"
|
||||
|
||||
Gem::Specification.new do |spec|
|
||||
spec.name = "rube"
|
||||
spec.version = Rube::VERSION
|
||||
spec.name = "marshalsea"
|
||||
spec.version = Marshalsea::VERSION
|
||||
spec.authors = ["Carter Perez"]
|
||||
spec.email = ["carterperez2222@gmail.com"]
|
||||
spec.summary = "control fixture that deliberately ships the vulnerable target"
|
||||
|
|
@ -159,7 +175,7 @@ SPEC
|
|||
run -v "${HERE}:/src:ro" -v "${WORK}/ships-target:/out" -w /out "${BUILD_IMAGE}" sh -c '
|
||||
set -e
|
||||
tar -C /src -cf - lib target README.md CHANGELOG.md LICENSE | tar -xf -
|
||||
gem build rube.gemspec
|
||||
gem build marshalsea.gemspec
|
||||
' >/dev/null 2>&1
|
||||
|
||||
control_target="$(run -v "${HERE}:/src:ro" -v "${WORK}/ships-target:/gem:ro" "${BUILD_IMAGE}" \
|
||||
|
|
@ -169,12 +185,12 @@ echo "control_auditor_rejects_a_gem_shipping_the_target=$([[ ${control_target} =
|
|||
|
||||
run -v "${HERE}:/src:ro" -v "${WORK}/drifted:/out" -w /out "${BUILD_IMAGE}" sh -c '
|
||||
set -e
|
||||
cd /src && ruby -e "puts Gem::Specification.load(%q{rube.gemspec}).files" >/out/declared.txt
|
||||
cd /src && ruby -e "puts Gem::Specification.load(%q{marshalsea.gemspec}).files" >/out/declared.txt
|
||||
cd /out && tar -C /src -T declared.txt -cf - | tar -xf -
|
||||
cp /src/rube.gemspec /out/
|
||||
ruby -e "File.write(%q{lib/rube/version.rb}, File.read(%q{lib/rube/version.rb}) + %q{
|
||||
cp /src/marshalsea.gemspec /out/
|
||||
ruby -e "File.write(%q{lib/marshalsea/version.rb}, File.read(%q{lib/marshalsea/version.rb}) + %q{
|
||||
})"
|
||||
gem build rube.gemspec
|
||||
gem build marshalsea.gemspec
|
||||
' >/dev/null 2>&1
|
||||
|
||||
control_drift="$(run -v "${HERE}:/src:ro" -v "${WORK}/drifted:/gem:ro" "${BUILD_IMAGE}" \
|
||||
|
|
@ -225,6 +241,8 @@ expect container_files_absent "Dockerfile and rack config are absent"
|
|||
expect lab_artifacts_absent "no canary, payload or nested gem artifact shipped"
|
||||
expect installed_gem_works_on_floor "the installed gem parses, classifies and blocks on the floor"
|
||||
expect installed_gem_works_on_current "the installed gem parses, classifies and blocks on current"
|
||||
expect release_tag_is_namespaced "rake release tags marshalsea-vX, not a bare vX the monorepo shares"
|
||||
expect control_bare_rakefile_tags_the_whole_monorepo "without the tag_prefix line the tag really is bare, so that check is live"
|
||||
expect suite_green_on_floor "every suite is green on the floor image"
|
||||
reject floor_ruby_accepts_invalid_sign "on the floor, real Marshal rejects an invalid bignum sign"
|
||||
reject floor_parser_accepts_invalid_sign "on the floor, the parser rejects it too, so they agree"
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require "json"
|
||||
require "rube"
|
||||
require "marshalsea"
|
||||
|
||||
TRACKED_CLASSES = %w[Gem::SpecFetcher Gem::Source::Git Gem::URI Net::WriteAdapter].freeze
|
||||
MARK_YES = "yes"
|
||||
|
|
@ -11,7 +11,7 @@ MARK_NO = "no"
|
|||
MARK_UNKNOWN = "?"
|
||||
RULE_WIDTH = 78
|
||||
|
||||
CHAIN = Rube::Chains::ErbDefMethod
|
||||
CHAIN = Marshalsea::Chains::ErbDefMethod
|
||||
|
||||
def cve_patched?(version)
|
||||
!CHAIN.affects?(version)
|
||||
|
|
|
|||
|
|
@ -6,9 +6,9 @@ set -uo pipefail
|
|||
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
IMAGE="rube-target:local"
|
||||
CONTAINER="rube-target-gate"
|
||||
PORT="${RUBE_TARGET_PORT:-47823}"
|
||||
IMAGE="marshalsea-target:local"
|
||||
CONTAINER="marshalsea-target-gate"
|
||||
PORT="${MARSHALSEA_TARGET_PORT:-47823}"
|
||||
BASE="http://127.0.0.1:${PORT}"
|
||||
CANARY_MARKER="fired"
|
||||
|
||||
|
|
@ -48,9 +48,9 @@ echo
|
|||
|
||||
payload="$(docker run --rm --network none -v "${HERE}/lib:/app/lib:ro" -w /app ruby:4.0-slim \
|
||||
ruby -Ilib -e '
|
||||
require "rube"
|
||||
require "marshalsea"
|
||||
require "base64"
|
||||
chain = Rube::Chains::ErbDefMethod.canary("/tmp/rube-canary", "fired")
|
||||
chain = Marshalsea::Chains::ErbDefMethod.canary("/tmp/marshalsea-canary", "fired")
|
||||
state = { user: "attacker", template: chain.generate }
|
||||
print Base64.strict_encode64(Marshal.dump(state))
|
||||
')"
|
||||
|
|
@ -76,7 +76,7 @@ else
|
|||
failures=$((failures + 1))
|
||||
fi
|
||||
|
||||
docker exec "${CONTAINER}" rm -f /tmp/rube-canary >/dev/null 2>&1 || true
|
||||
docker exec "${CONTAINER}" rm -f /tmp/marshalsea-canary >/dev/null 2>&1 || true
|
||||
|
||||
reset="$(curl -s "${BASE}/canary")"
|
||||
safe_body="$(curl -s --cookie "session_state=${payload}" "${BASE}/render/safe")"
|
||||
|
|
@ -136,7 +136,7 @@ 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
|
||||
if grep -qE "app\.rb|/app/lib|marshalsea/marshal" "${leak_file}" "${leak_file}.v"; then
|
||||
echo " FAIL an error response leaked source paths or source lines"
|
||||
failures=$((failures + 1))
|
||||
else
|
||||
|
|
@ -168,11 +168,11 @@ done
|
|||
echo
|
||||
sinks="$(docker run --rm --network none -v "${HERE}/lib:/app/lib:ro" -w /app ruby:4.0-slim \
|
||||
ruby -Ilib -e '
|
||||
require "rube"
|
||||
require "marshalsea"
|
||||
require "base64"
|
||||
chain = Rube::Chains::ErbDefMethod.canary("/tmp/rube-canary", "fired")
|
||||
chain = Marshalsea::Chains::ErbDefMethod.canary("/tmp/marshalsea-canary", "fired")
|
||||
blob = Marshal.dump({ user: "attacker", template: chain.generate })
|
||||
result = Rube::Marshal::Parser.new(blob).parse
|
||||
result = Marshalsea::Marshal::Parser.new(blob).parse
|
||||
print result.sinks.length
|
||||
')"
|
||||
|
||||
|
|
|
|||
|
|
@ -5,12 +5,12 @@
|
|||
require "sinatra/base"
|
||||
require "base64"
|
||||
require "erb"
|
||||
require "rube"
|
||||
require "marshalsea"
|
||||
|
||||
module Rube
|
||||
module Marshalsea
|
||||
module Target
|
||||
COOKIE_NAME = "session_state"
|
||||
CANARY_PATH = "/tmp/rube-canary"
|
||||
CANARY_PATH = "/tmp/marshalsea-canary"
|
||||
|
||||
STATUS_OK = 200
|
||||
STATUS_BAD_REQUEST = 400
|
||||
|
|
@ -22,10 +22,10 @@ module Rube
|
|||
|
||||
SESSION_KEYS = %i[user template].freeze
|
||||
|
||||
DETECTOR = Rube::Marshal::BoundaryDetector.new(
|
||||
policy: Rube::Marshal::BoundaryDetector::POLICY_STRICT_ALLOWLIST,
|
||||
DETECTOR = Marshalsea::Marshal::BoundaryDetector.new(
|
||||
policy: Marshalsea::Marshal::BoundaryDetector::POLICY_STRICT_ALLOWLIST,
|
||||
allowed_class_names: PERMITTED_CLASS_NAMES,
|
||||
limits: Rube::Marshal::Limits.new
|
||||
limits: Marshalsea::Marshal::Limits.new
|
||||
)
|
||||
|
||||
REJECTED = "rejected: %s"
|
||||
|
|
@ -41,7 +41,7 @@ module Rube
|
|||
get "/" do
|
||||
content_type CONTENT_TYPE
|
||||
[
|
||||
"rube target",
|
||||
"marshalsea target",
|
||||
"erb #{Gem::Specification.find_all_by_name('erb').map(&:version).max}",
|
||||
"ruby #{RUBY_VERSION}",
|
||||
"",
|
||||
|
|
|
|||
|
|
@ -4,4 +4,4 @@
|
|||
|
||||
require_relative "target/app"
|
||||
|
||||
run Rube::Target::App
|
||||
run Marshalsea::Target::App
|
||||
|
|
|
|||
|
|
@ -4,10 +4,10 @@
|
|||
|
||||
require_relative "test_helper"
|
||||
|
||||
module Rube
|
||||
module Marshalsea
|
||||
module Chains
|
||||
class ChainsTest < Minitest::Test
|
||||
CANARY_PATH = "/tmp/rube-canary"
|
||||
CANARY_PATH = "/tmp/marshalsea-canary"
|
||||
CANARY_MARKER = "fired"
|
||||
|
||||
def chain
|
||||
|
|
@ -68,13 +68,13 @@ module Rube
|
|||
|
||||
def test_serialize_produces_a_parseable_marshal_stream
|
||||
blob = chain.serialize
|
||||
assert_equal [Rube::Marshal::Constants::MAJOR_VERSION, Rube::Marshal::Constants::MINOR_VERSION],
|
||||
assert_equal [Marshalsea::Marshal::Constants::MAJOR_VERSION, Marshalsea::Marshal::Constants::MINOR_VERSION],
|
||||
[blob.getbyte(0), blob.getbyte(1)]
|
||||
assert_equal :object, Rube::Marshal::Parser.new(blob).parse.root.type
|
||||
assert_equal :object, Marshalsea::Marshal::Parser.new(blob).parse.root.type
|
||||
end
|
||||
|
||||
def test_payload_is_visible_to_the_parser_without_deserializing
|
||||
result = Rube::Marshal::Parser.new(chain.serialize).parse
|
||||
result = Marshalsea::Marshal::Parser.new(chain.serialize).parse
|
||||
assert_includes result.class_names, "ERB"
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
$LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
|
||||
|
||||
require "rube"
|
||||
require "marshalsea"
|
||||
|
||||
Pair = Struct.new(:x, :y)
|
||||
|
||||
|
|
@ -28,7 +28,7 @@ puts "=== 2 object link indexing ==="
|
|||
cyclic = []
|
||||
cyclic << cyclic
|
||||
blob = Marshal.dump(cyclic)
|
||||
link = Rube::Marshal::Parser.new(blob).parse.root.children.first
|
||||
link = Marshalsea::Marshal::Parser.new(blob).parse.root.children.first
|
||||
bytes = blob.bytes.map { |b| format("%02x", b) }.join(" ")
|
||||
failures << "link index" unless check("self-referential array links to index 0", link.value.zero?, bytes)
|
||||
|
||||
|
|
@ -50,7 +50,7 @@ corpus = [
|
|||
:sym, :"with spaces", :marshal_load,
|
||||
[], {}, [1, [2, [3, [4]]]], { a: { b: { c: 1 } } },
|
||||
(1..5).to_a, Pair.new(1, 2), Pair.new(nil, [1, 2]),
|
||||
Object.new, Time.now, /regex/i, //mx, String, Comparable, Rube,
|
||||
Object.new, Time.now, /regex/i, //mx, String, Comparable, Marshalsea,
|
||||
aliased, cyclic_hash, deep,
|
||||
{ "mixed" => [1, :two, 3.0, nil, true] },
|
||||
Hash.new(0).tap { |h| h[:k] = 1 },
|
||||
|
|
@ -58,7 +58,7 @@ corpus = [
|
|||
]
|
||||
|
||||
parsed = corpus.count do |item|
|
||||
Rube::Marshal::Parser.new(Marshal.dump(item)).parse.root
|
||||
Marshalsea::Marshal::Parser.new(Marshal.dump(item)).parse.root
|
||||
true
|
||||
rescue StandardError => e
|
||||
puts " #{item.class}: #{e.class}: #{e.message}"
|
||||
|
|
@ -69,16 +69,16 @@ failures << "corpus" unless check("every corpus entry parsed", parsed == corpus.
|
|||
puts
|
||||
puts "=== 4 stream rejection ==="
|
||||
rejected = begin
|
||||
Rube::Marshal::Parser.new("\x04\x08[\x06@\x63").parse
|
||||
Marshalsea::Marshal::Parser.new("\x04\x08[\x06@\x63").parse
|
||||
false
|
||||
rescue Rube::Marshal::InvalidLinkError
|
||||
rescue Marshalsea::Marshal::InvalidLinkError
|
||||
true
|
||||
end
|
||||
failures << "bounds" unless check("out-of-range object link rejected", rejected, "InvalidLinkError")
|
||||
|
||||
puts
|
||||
puts "=== 5 payload inspection ==="
|
||||
result = Rube::Marshal::Parser.new(Marshal.dump(Gem::Requirement.new(">= 0"))).parse
|
||||
result = Marshalsea::Marshal::Parser.new(Marshal.dump(Gem::Requirement.new(">= 0"))).parse
|
||||
from_stream = result.gated_sinks.map { |s| "#{s.class_name}##{s.sink_method}" }.uniq.sort
|
||||
failures << "class names" unless check("classes extracted", !result.class_names.empty?,
|
||||
result.class_names.join(", "))
|
||||
|
|
@ -87,7 +87,7 @@ failures << "gated sinks" unless check("gated sinks flagged", !from_stream.empty
|
|||
|
||||
puts
|
||||
puts "=== 6 parser and scanner agreement ==="
|
||||
scanned = Rube::Scanner.new(namespace: "Gem").scan.gated.map(&:to_s).sort
|
||||
scanned = Marshalsea::Scanner.new(namespace: "Gem").scan.gated.map(&:to_s).sort
|
||||
missing = from_stream - scanned
|
||||
located = !from_stream.empty? && missing.empty?
|
||||
agreement_detail = if from_stream.empty?
|
||||
|
|
@ -101,7 +101,7 @@ failures << "agreement" unless check("parser sinks located by reflection", locat
|
|||
|
||||
puts
|
||||
puts "=== 7 scanner precision ==="
|
||||
full = Rube::Scanner.new.scan
|
||||
full = Marshalsea::Scanner.new.scan
|
||||
ungated = full.ungated.length
|
||||
reachable = full.reachable.count { |c| !c.gated? }
|
||||
kept = ungated.zero? ? 0 : (100.0 * reachable / ungated).round(1)
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
require_relative "test_helper"
|
||||
require_relative "support/adversarial_corpus"
|
||||
|
||||
module Rube
|
||||
module Marshalsea
|
||||
class CorpusTest < Minitest::Test
|
||||
def detector(allowed = [])
|
||||
Marshal::BoundaryDetector.new(allowed_class_names: allowed)
|
||||
|
|
|
|||
|
|
@ -5,11 +5,11 @@
|
|||
require_relative "../test_helper"
|
||||
require_relative "../support/adversarial_corpus"
|
||||
|
||||
module Rube
|
||||
module Marshalsea
|
||||
module Marshal
|
||||
class BoundaryDetectorTest < Minitest::Test
|
||||
BENIGN = { "user" => "guest", "roles" => [1, 2, 3], "flag" => true }.freeze
|
||||
CANARY_PATH = "/tmp/rube-canary"
|
||||
CANARY_PATH = "/tmp/marshalsea-canary"
|
||||
CANARY_MARKER = "fired"
|
||||
|
||||
def detector(**)
|
||||
|
|
@ -25,7 +25,7 @@ module Rube
|
|||
end
|
||||
|
||||
def cve_blob
|
||||
Rube::Chains::ErbDefMethod.canary(CANARY_PATH, CANARY_MARKER).serialize
|
||||
Marshalsea::Chains::ErbDefMethod.canary(CANARY_PATH, CANARY_MARKER).serialize
|
||||
end
|
||||
|
||||
def test_default_policy_is_strict_allowlist
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
require_relative "../test_helper"
|
||||
require_relative "../support/adversarial_corpus"
|
||||
|
||||
module Rube
|
||||
module Marshalsea
|
||||
module Marshal
|
||||
class ParserTest < Minitest::Test
|
||||
def parse(blob)
|
||||
|
|
@ -80,7 +80,7 @@ module Rube
|
|||
class RoleFixture; end
|
||||
|
||||
def class_name_slot_streams
|
||||
name = "Rube::Marshal::ParserTest::RoleFixture"
|
||||
name = "Marshalsea::Marshal::ParserTest::RoleFixture"
|
||||
symbol = AdversarialCorpus.sym(name)
|
||||
|
||||
{
|
||||
|
|
@ -285,7 +285,7 @@ module Rube
|
|||
def test_extracts_class_name_from_plain_object
|
||||
blob = ::Marshal.dump(Fixture.new)
|
||||
result = parse(blob)
|
||||
assert_includes result.class_names, "Rube::Marshal::ParserTest::Fixture"
|
||||
assert_includes result.class_names, "Marshalsea::Marshal::ParserTest::Fixture"
|
||||
end
|
||||
|
||||
def test_extracts_class_name_without_instantiating
|
||||
|
|
@ -299,7 +299,7 @@ module Rube
|
|||
sink = result.sinks.first
|
||||
refute_nil sink
|
||||
assert_equal "marshal_load", sink.sink_method
|
||||
assert_equal "Rube::Marshal::ParserTest::UserMarshalFixture", sink.class_name
|
||||
assert_equal "Marshalsea::Marshal::ParserTest::UserMarshalFixture", sink.class_name
|
||||
end
|
||||
|
||||
def test_flags_userdef_as_sink
|
||||
|
|
@ -410,7 +410,7 @@ module Rube
|
|||
def test_class_name_node_is_traversable
|
||||
result = parse(::Marshal.dump(Fixture.new))
|
||||
names = result.nodes.select { |node| node.type == :symbol }.map(&:value)
|
||||
assert_includes names, :"Rube::Marshal::ParserTest::Fixture"
|
||||
assert_includes names, :"Marshalsea::Marshal::ParserTest::Fixture"
|
||||
end
|
||||
|
||||
def test_class_name_slot_gadget_is_reachable_in_every_slot
|
||||
|
|
@ -678,12 +678,12 @@ module Rube
|
|||
end
|
||||
|
||||
def test_the_chain_registry_cannot_be_appended_to_by_a_caller
|
||||
assert_raises(FrozenError) { Rube::Chains.registry << Object }
|
||||
assert_raises(FrozenError) { Marshalsea::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
|
||||
refute_empty Marshalsea::Chains.all
|
||||
assert_includes Marshalsea::Chains.all, Marshalsea::Chains::ErbDefMethod
|
||||
end
|
||||
|
||||
FIXNUM_WIDTH_PROBES = [
|
||||
|
|
@ -782,7 +782,7 @@ module Rube
|
|||
|
||||
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
|
||||
from_scanner = Marshalsea::Scanner::GATED_METHODS + Marshalsea::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,
|
||||
|
|
|
|||
|
|
@ -4,13 +4,13 @@
|
|||
|
||||
require_relative "test_helper"
|
||||
|
||||
module Rube
|
||||
module Marshalsea
|
||||
class ScannerTest < Minitest::Test
|
||||
SUPPRESSION_NAMESPACE = "Rube::ScannerSuppressionFixture"
|
||||
MISSING_SOURCE_PATH = "/nonexistent/rube-scanner-fixture.rb"
|
||||
SUPPRESSION_NAMESPACE = "Marshalsea::ScannerSuppressionFixture"
|
||||
MISSING_SOURCE_PATH = "/nonexistent/marshalsea-scanner-fixture.rb"
|
||||
|
||||
Object.class_eval(<<~SOURCE, MISSING_SOURCE_PATH, 1)
|
||||
module Rube
|
||||
module Marshalsea
|
||||
module ScannerSuppressionFixture
|
||||
class VanishedSource
|
||||
def hash
|
||||
|
|
@ -41,7 +41,7 @@ module Rube
|
|||
end
|
||||
|
||||
def local_scan
|
||||
scan(namespace: "Rube::ScannerTest")
|
||||
scan(namespace: "Marshalsea::ScannerTest")
|
||||
end
|
||||
|
||||
def candidates_for(class_name)
|
||||
|
|
@ -49,71 +49,71 @@ module Rube
|
|||
end
|
||||
|
||||
def test_finds_gated_sink_defined_on_a_class
|
||||
found = candidates_for("Rube::ScannerTest::GatedFixture")
|
||||
found = candidates_for("Marshalsea::ScannerTest::GatedFixture")
|
||||
assert_equal ["marshal_load"], found.map(&:method_name)
|
||||
assert_equal :gated, found.first.gate
|
||||
end
|
||||
|
||||
def test_finds_singleton_load_as_gated_sink
|
||||
found = candidates_for("Rube::ScannerTest::UserDefFixture")
|
||||
found = candidates_for("Marshalsea::ScannerTest::UserDefFixture")
|
||||
assert_includes found.map(&:method_name), "_load"
|
||||
end
|
||||
|
||||
def test_finds_ungated_dispatch_method
|
||||
found = candidates_for("Rube::ScannerTest::UngatedFixture")
|
||||
found = candidates_for("Marshalsea::ScannerTest::UngatedFixture")
|
||||
assert_equal ["hash"], found.map(&:method_name)
|
||||
assert_equal :ungated, found.first.gate
|
||||
end
|
||||
|
||||
def test_finds_method_missing_as_ungated
|
||||
found = candidates_for("Rube::ScannerTest::ProxyFixture")
|
||||
found = candidates_for("Marshalsea::ScannerTest::ProxyFixture")
|
||||
assert_includes found.map(&:method_name), "method_missing"
|
||||
end
|
||||
|
||||
def test_negative_control_class_with_no_auto_invoked_methods_is_not_reported
|
||||
assert_empty candidates_for("Rube::ScannerTest::InertFixture")
|
||||
assert_empty candidates_for("Marshalsea::ScannerTest::InertFixture")
|
||||
end
|
||||
|
||||
def test_precision_control_inherited_methods_are_not_reported
|
||||
assert_empty candidates_for("Rube::ScannerTest::InheritsOnlyFixture")
|
||||
assert_empty candidates_for("Marshalsea::ScannerTest::InheritsOnlyFixture")
|
||||
end
|
||||
|
||||
def test_candidates_carry_source_location
|
||||
candidate = candidates_for("Rube::ScannerTest::GatedFixture").first
|
||||
candidate = candidates_for("Marshalsea::ScannerTest::GatedFixture").first
|
||||
refute_nil candidate.source_location
|
||||
assert_includes candidate.source_location, "scanner_test.rb"
|
||||
end
|
||||
|
||||
def test_candidates_report_arity
|
||||
candidate = candidates_for("Rube::ScannerTest::GatedFixture").first
|
||||
candidate = candidates_for("Marshalsea::ScannerTest::GatedFixture").first
|
||||
assert_equal 1, candidate.arity
|
||||
end
|
||||
|
||||
def test_ungated_methods_report_zero_arity
|
||||
candidate = candidates_for("Rube::ScannerTest::UngatedFixture").first
|
||||
candidate = candidates_for("Marshalsea::ScannerTest::UngatedFixture").first
|
||||
assert_predicate candidate, :zero_arity?
|
||||
end
|
||||
|
||||
def test_reachability_via_instance_variable_read
|
||||
candidate = candidates_for("Rube::ScannerTest::StatefulFixture").first
|
||||
candidate = candidates_for("Marshalsea::ScannerTest::StatefulFixture").first
|
||||
assert_predicate candidate, :touches_state?
|
||||
assert_predicate candidate, :reachable?
|
||||
end
|
||||
|
||||
def test_reachability_via_implicit_self_call
|
||||
candidate = candidates_for("Rube::ScannerTest::AccessorFixture").first
|
||||
candidate = candidates_for("Marshalsea::ScannerTest::AccessorFixture").first
|
||||
assert_predicate candidate, :touches_state?, "attr_reader access must count as touching state"
|
||||
assert_predicate candidate, :reachable?
|
||||
end
|
||||
|
||||
def test_negative_control_stateless_method_is_not_reachable
|
||||
candidate = candidates_for("Rube::ScannerTest::StatelessFixture").first
|
||||
candidate = candidates_for("Marshalsea::ScannerTest::StatelessFixture").first
|
||||
refute_predicate candidate, :touches_state?
|
||||
refute_predicate candidate, :reachable?
|
||||
end
|
||||
|
||||
def test_gated_sinks_are_reachable_regardless_of_state
|
||||
candidate = candidates_for("Rube::ScannerTest::GatedFixture").first
|
||||
candidate = candidates_for("Marshalsea::ScannerTest::GatedFixture").first
|
||||
assert_predicate candidate, :reachable?
|
||||
end
|
||||
|
||||
|
|
@ -138,8 +138,8 @@ module Rube
|
|||
end
|
||||
|
||||
def test_namespace_filter_excludes_everything_else
|
||||
report = scan(namespace: "Rube::ScannerTest")
|
||||
assert(report.candidates.all? { |c| c.class_name.start_with?("Rube::ScannerTest") })
|
||||
report = scan(namespace: "Marshalsea::ScannerTest")
|
||||
assert(report.candidates.all? { |c| c.class_name.start_with?("Marshalsea::ScannerTest") })
|
||||
end
|
||||
|
||||
def test_report_partitions_gated_and_ungated
|
||||
|
|
@ -182,7 +182,7 @@ module Rube
|
|||
end
|
||||
|
||||
def test_a_lost_candidate_is_counted_and_names_the_class_it_came_from
|
||||
control = candidates_for("Rube::ScannerTest::ExplodingHandleFixture")
|
||||
control = candidates_for("Marshalsea::ScannerTest::ExplodingHandleFixture")
|
||||
assert_equal ["marshal_load"], control.map(&:method_name),
|
||||
"control: this fixture must be discoverable when it is not exploding"
|
||||
|
||||
|
|
@ -192,7 +192,7 @@ module Rube
|
|||
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_equal "Marshalsea::ScannerTest::ExplodingHandleFixture#marshal_load", lost.first.subject
|
||||
assert_predicate report, :candidates_lost?
|
||||
refute_predicate report, :complete?
|
||||
end
|
||||
|
|
@ -305,7 +305,7 @@ module Rube
|
|||
|
||||
def test_an_analysed_candidate_reports_its_state_as_known
|
||||
%w[StatefulFixture StatelessFixture].each do |fixture|
|
||||
candidate = candidates_for("Rube::ScannerTest::#{fixture}").first
|
||||
candidate = candidates_for("Marshalsea::ScannerTest::#{fixture}").first
|
||||
|
||||
assert_predicate candidate, :state_known?,
|
||||
"control: a readable source must produce a verdict, or unknown means nothing"
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
# adversarial_corpus.rb
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Rube
|
||||
module Marshalsea
|
||||
module AdversarialCorpus
|
||||
HEADER = "\x04\x08"
|
||||
INLINE_OFFSET = 5
|
||||
|
|
|
|||
|
|
@ -3,25 +3,25 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require "fileutils"
|
||||
require "rube"
|
||||
require "marshalsea"
|
||||
|
||||
CANARY_PATH = "/tmp/rube-canary"
|
||||
CANARY_PATH = "/tmp/marshalsea-canary"
|
||||
CANARY_MARKER = "fired"
|
||||
RESULT_FIRED = "FIRED"
|
||||
RESULT_BLOCKED = "BLOCKED"
|
||||
RESULT_INERT = "INERT"
|
||||
|
||||
erb_version = Gem::Specification.find_all_by_name("erb").map(&:version).max.to_s
|
||||
chain = Rube::Chains::ErbDefMethod.canary(CANARY_PATH, CANARY_MARKER)
|
||||
chain = Marshalsea::Chains::ErbDefMethod.canary(CANARY_PATH, CANARY_MARKER)
|
||||
blob = chain.serialize
|
||||
|
||||
inspection = Rube::Marshal::Parser.new(blob).parse
|
||||
inspection = Marshalsea::Marshal::Parser.new(blob).parse
|
||||
|
||||
FileUtils.rm_f(CANARY_PATH)
|
||||
|
||||
revived = Marshal.load(blob)
|
||||
detail = begin
|
||||
revived.def_method(Module.new, "rube_probe")
|
||||
revived.def_method(Module.new, "marshalsea_probe")
|
||||
"def_method returned"
|
||||
rescue StandardError => e
|
||||
"#{e.class}: #{e.message}"
|
||||
|
|
@ -36,7 +36,7 @@ outcome = if fired
|
|||
RESULT_INERT
|
||||
end
|
||||
|
||||
predicted = Rube::Chains::ErbDefMethod.affects?(erb_version) ? RESULT_FIRED : RESULT_BLOCKED
|
||||
predicted = Marshalsea::Chains::ErbDefMethod.affects?(erb_version) ? RESULT_FIRED : RESULT_BLOCKED
|
||||
|
||||
puts format("%-9s erb=%-9s outcome=%-8s predicted=%-8s classes=%-6s %s",
|
||||
ENV.fetch("MATRIX_IMAGE", "?"), erb_version, outcome, predicted,
|
||||
|
|
|
|||
|
|
@ -5,4 +5,4 @@
|
|||
$LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
|
||||
|
||||
require "minitest/autorun"
|
||||
require "rube"
|
||||
require "marshalsea"
|
||||
|
|
|
|||
Loading…
Reference in New Issue