29 KiB
marshalsea: Architecture
This chapter is the design. It explains the pieces, the seams between them, and the handful of decisions that look strange until you know what they are protecting against. The code walkthrough is in 03-IMPLEMENTATION.md; this is the map you want open while you read it.
The shape
Two readers, one vocabulary. Nothing in the inspection path ever revives an object.
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)
The two halves of the library never call each other. The offensive half builds payloads and hunts for gadgets; the defensive half reads bytes and makes decisions. They meet only in the test suite and the gate, where each one's output is the other one's input. That is deliberate: a detector whose author also wrote the payloads will only catch the payloads its author imagined, so the adversarial corpus exists as a third artifact that both halves are measured against.
Reader one: the Marshal parser
Marshal is a binary format. Two header bytes for the version, then a tree of tagged values. The parser reads it byte by byte and builds a node graph. It never calls Marshal.load, never resolves a class name to a real class, and never allocates anything from the stream except strings and integers.
The tags it understands, with the ones that matter marked:
0 nil T true F false i fixnum l bignum f float
" string : symbol ; symlink @ object link / regexp
[ array { hash } hash+default S struct
I ivar o object e extended C userclass
c class m module M module (old)
u userdef <- SINK, dispatches Klass._load
U usermarshal <- SINK, dispatches obj.marshal_load
d data <- SINK, dispatches Klass._load_data
Three tags are sinks: u, U, and d. Seeing one in a stream is a true statement that loading the stream will dispatch a specific method on a specific class name, before any allowlist proc can run. That is the highest-confidence signal the parser produces, and it is also, importantly, not sufficient. The published CVE chain produces zero sink tags, because ERB defines no marshal_load. Sink detection alone never catches it.
The parser is forensic on purpose
Here is the design decision that looks wrong at first.
CRuby refuses a stream where the instance-variable name slot holds something that is not a symbol. It raises and stops. This parser keeps going, records the problem as a named anomaly, and returns a complete graph anyway.
Watch the difference on the same 17 bytes:
Marshal.load: ArgumentError: dump format error for symbol(0x69)
parser: class_names=["Object"] anomalies=["instance variable name slot holds fixnum, not a symbol"]
detector: blocked: stream is not canonical Marshal: instance variable name slot holds fixnum, not a
symbol, so Marshal.load refuses it and there is nothing here to permit
Why bother, if Marshal.load would refuse it anyway? Because the goal of the parser is description, not admission control. If a hostile stream hides a sink in a slot where a symbol belongs, a parser that raises on the first structural surprise reports "malformed" and tells you nothing about what was in there. A parser that keeps going reports the class name, the sink, and the anomaly. You get a forensic record instead of an error message.
The strictness lives one layer up. The detector treats any recorded anomaly as an immediate rejection, and its reason string says precisely why: not "this is dangerous" but "Marshal.load refuses this and there is nothing here to permit." Those are different claims and only the second one is true.
That split is the single most important structural idea in the library. The parser labels, the detector decides. It also means the two components are allowed to disagree about the same stream, and that disagreement is a feature. There is a test named test_sink_in_an_instance_variable_name_position_is_still_reported that exists to lock it in place, and a proposal to make the parser strict on role slots was rejected specifically because it would delete that test.
The same instinct shows up in float decoding. A legacy Marshal float carries a mantissa extension after a NUL byte that modern Ruby still reads:
canonical 1.5: "\x04\bf\b1.5"
legacy stream: value=1.5 undecoded_tail="\x00abcde" fully_decoded=false
Marshal.load: 1.5000000000055356
The parser does not guess and it does not pretend. It decodes what it can, labels the rest as an undecoded_tail, and answers fully_decoded? honestly. A reader that silently returned 1.5 would be claiming agreement with the interpreter that it has not earned.
Sealing
Once parsing finishes, the whole graph is frozen depth-first and the Result wrapping it is frozen too. A parse result is an immutable description of bytes that already happened. Nothing downstream, including a caller who gets it back from Decision#result, can mutate the record that a policy decision was made from.
Reader two: the Psych inspector
YAML needs no hand-written parser, because Psych already ships one that revives nothing. Psych.parse_stream builds an AST of Psych::Nodes::* objects and stops. The inspector walks that AST.
What it extracts is one Reference per !ruby/* tag, carrying three things:
!ruby/object:Gem::Version -> class_name: "Gem::Version"
kind: "object"
revival: "init_with" <- what Psych WOULD call
key_position: false
The mapping from tag kind to revival method is the whole value of the inspector, because it turns "this document mentions a class" into "this document would dispatch init_with on that class":
| Tag kind | Method Psych dispatches |
|---|---|
!ruby/object |
init_with |
!ruby/array, !ruby/string, !ruby/struct, !ruby/exception |
init_with |
!ruby/hash |
[]= |
!ruby/marshalable |
marshal_load |
Aliases are counted, never expanded. An alias bomb costs nothing to inspect, which is the point: an inspector that expanded aliases in order to report on them would have imported the exact denial-of-service it exists to warn about. The count is bounded and reported instead of ignored, because it still costs whatever the eventual loader spends.
The inspector's own limitation notice is blunt about where it sits, and it is worth internalizing:
"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_loadwithpermitted_classesis a boundary and this inspector is only detection and reporting on top of it."
A detector that positioned itself as a replacement for safe_load would be selling a downgrade. This one says so in a constant.
The decision object
Three states, mutually exclusive by construction:
proceed the bytes matched the configured policy
blocked the bytes violated it, and `reason` says which rule and why
observed the bytes violated it, the reporter was called, and the caller
was handed the snapshot anyway (monitoring mode)
There is no accepted? predicate, and its absence is deliberate. Under observe_and_log, "did the policy permit this" and "is this stream clean" have different answers, and a single predicate named accepted? cannot answer both. Callers who write Marshal.load(d.snapshot) if d.accepted? would be silently loading everything the monitoring mode reported on. Forcing the caller to name the state they actually mean is worth the extra six characters.
The state is validated in the constructor against a frozen list, so an invalid state is an ArgumentError at construction rather than a predicate that quietly returns false everywhere.
The violation ladder
The detector checks rules in a fixed order and returns the first violation it finds. The order is not arbitrary; it goes from "this is not even loadable" through "this dispatches during load" to "this mentions a class you did not approve":
1. role anomaly the parser recorded a malformed slot -> Marshal.load refuses it anyway
2. sink tag u / U / d -> _load, marshal_load, or _load_data dispatches
3. hash-dispatching key #hash runs when the Hash is rebuilt
4. eql?-dispatching key #eql? runs as soon as two keys collide
5. Range endpoint #<=> runs when Range#marshal_load validates its ends
────────── deny_sinks_only stops here ──────────
6. non-canonical version declares a Marshal version no real Ruby emits
7. unapproved class name strict_allowlist only
Rules 1 through 5 are statements about dispatch: they are true regardless of which classes you trust, which is why they run under every policy including deny_sinks_only. Rules 6 and 7 are policy, and only strict_allowlist enforces them.
Rules 3, 4, and 5 exist because of three bypasses that shipped and were found later. All three were accepted under deny_sinks_only and under strict allowlisting with the class allowlisted, because neither rule was looking at dispatch through key position:
- A
String-subclass hash key reaching#eql?on bucket collision. - A gadget nested inside a bare
Arrayused as a key. Nineteen bytes:"\x04\b{\x06[\x06o:\fUngated\x00i\x06". - A
Rangewhose endpoints dispatch#<=>.
That is the argument for the ladder being data rather than a chain of ad-hoc conditionals: each rule is a separate, individually testable claim about what the interpreter will do, and the corpus carries a payload for each one.
Reason strings are attacker-controlled output
A reason string quotes a class name, and a class name comes from the stream. That makes every reason string a log-injection surface, so three things happen to it before it is emitted:
- Names are truncated at 96 bytes with an explicit
[truncated, +N bytes]marker rather than silently. - Lists show at most 8 names with an explicit
, and N more. - Everything goes through
String#inspecton binary-forced bytes, so a class name containing a newline cannot forge a log line.
A detector that pasted an unbounded attacker-controlled string into your logs would have turned a defense into a delivery mechanism.
The scanner
The scanner answers a different question from the detector: not "is this stream dangerous" but "which classes currently loaded in this process could be used as gadgets."
It walks ObjectSpace.each_object(Module), and for every named module it collects the auto-invoked methods the module defines itself (not inherited), then scores each one.
The taxonomy is a table, not a list of method names
This is where the concepts chapter's gated-versus-ungated axis becomes code. Every candidate method carries three facts:
| Method | Gate | Arity the deserializer supplies | Formats |
|---|---|---|---|
marshal_load |
gated | 1 | Marshal, Psych |
_load_data |
gated | 1 | Marshal |
_load |
gated (singleton) | 1 | Marshal |
init_with |
soft | 1 | Psych |
hash |
ungated | 0 | Marshal, Psych |
eql? |
ungated | 1 | Marshal, Psych |
<=> |
ungated | 1 | Marshal |
== |
ungated | 1 | Psych |
[]= |
ungated | 2 | Psych |
method_missing |
ungated | variadic | Marshal, Psych |
respond_to_missing? |
ungated | 2 | Marshal, Psych |
respond_to? |
ungated | variadic | Psych |
to_s |
link | 0 | none |
coerce |
link | 1 | none |
Three columns and each one earns its place.
The gate column is the axis from 01-CONCEPTS.md. A gated method needs a truthful respond_to?; an ungated one is dispatched blind.
The format column is why entry points are scored per format rather than globally. A method-erased proxy is a valid Psych entry point and an invalid Marshal one, so a single global "is this reachable" answer would be wrong for one of the two. Today's scan finds 29 entry points reachable through Marshal and 33 through Psych, and neither set contains the other.
The arity column is the subtle one. A method named init_with that takes three arguments cannot be called by a deserializer that supplies one. Reporting it is a false positive. On a stock image the arity check rejects exactly one candidate, and it is a good one:
entry points whose arity cannot accept the deserializer's call: 1
Psych::Visitors::ToRuby#init_with arity=3 needs=1
That is Psych's own visitor method, which happens to share a name with the hook it dispatches. Without the arity column it would sit at the top of every scan as a permanent, confusing false positive.
The link rows are the ones with no formats at all. to_s is a real step in the published universal chain, but Marshal never calls it. It is a method a gadget calls once a chain is already moving, not a method a deserializer dispatches to start one. Conflating the two is the single largest source of false positives in gadget scanning, so links are collected, counted, and excluded from reachability. Today's scan finds 53 of them alongside 140 entry points.
Reachability, and what it deliberately refuses to conclude
Adding the taxonomy up:
reachable? = is an entry point (not a link)
AND its arity can accept the deserializer's call
AND ( it is gated or soft-gated <- the tag alone reaches it
OR its body references object state
OR its source could not be read ) <- read that last one twice
The last clause is the interesting one. To decide whether an ungated method like #hash is interesting, the scanner parses the method's source with Prism and asks whether the body references an instance variable or makes a receiverless call. A #hash that returns a literal is inert; a #hash that reads @name is a potential pivot.
But if the source cannot be read, the scanner scores the method reachable, not inert. That is the correct direction for a security tool to be wrong in. On a stock image 8 candidates are in that state, and the scan says so.
Under-reporting is reported, loudly
A gadget scanner that silently swallows errors is worse than no scanner, because it produces a short clean list that reads as "nothing to see here." So every swallowed error is counted and attributed to a named site:
suppressed errors (this scan under-reports):
source_parse 3
142 candidates have no Ruby source and were never analysed; the reachability filter does not cover them
There are five suppression sites and three of them are marked lossy, meaning a failure there means a candidate was never even created. Report#candidates_lost? is the predicate that distinguishes "this scan is slightly less precise" from "this scan is missing entries entirely," and complete? and fully_analysed? answer two separate questions rather than one blurred one.
The headline number from a stock ruby:4.0-slim (Ruby 4.0.6), re-measured on 2026-07-31:
modules=691 candidates=193 entry_points=140 links=53
gated=11 soft=5 ungated=124
reachable=43 (28 of them ungated) marshal=29 psych=33
unanalysable=142 unreadable=8 suppressed=3
Those numbers are not facts about Ruby. ObjectSpace cannot report a class nobody has required yet, so they are a statement about what this specific process had loaded. Requiring active_support moves all of them. Re-run it yourself rather than quoting these.
And the honest framing, stated in the project rather than implied: a gadget-discovery tool tells you what chains exist today, in this process. It is not a control.
The chains
Three payloads, and the directory is the identity. Each file under lib/marshalsea/chains/ subclasses Base, and Base.inherited registers it. There is no central registry file listing chain names, because a registry file is a thing that rots when someone adds a chain and forgets to update it.
erb-def-method primitive CVE-2026-41316 def_method
erb-def-module chain CVE-2026-41316 hash
psych-init-with chain none init_with
Primitive and chain are different labels and the difference is load-bearing. A chain fires inside the deserializer with no cooperation from the application. A primitive forges an object past a guard and stays inert until the application does something with it. Labelling erb-def-method a chain would overstate it; deleting it would delete the lesson that the dangerous call site can live in your own code.
Each chain declares the versions it affects as real Gem::Requirement constraints, so the boundary is queryable rather than prose:
erb 4.0.3 affected? true
erb 4.0.3.1 affected? false
erb 6.0.1 affected? true
erb 6.0.1.1 affected? false
erb 6.0.4 affected? false
The builder must never run its own payload
This is the constraint that shapes the offensive half, and it is not obvious until it bites you.
The erb-def-module chain enters through hash-key position. So the natural way to build it is Marshal.dump({ proxy => 1 }). That calls #hash on the proxy while your builder is constructing the literal, which fires the chain locally, in the process that was supposed to be generating a payload for somewhere else.
The fix is to never put the object in a hash at all. Base#in_hash_key_position dumps the object standalone, slices off its two header bytes, and splices the body into a hand-written one-entry hash frame:
Marshal.dump(nil) "\x04\b" "0"
^^^^^^ take the header
Marshal.dump(proxy) "\x04\b" <body>
^^^^^^ take the body
result "\x04\b" "{\x06" <body> "0"
^^^^^^ ^^^ nil value
one-entry hash frame
There is one way that splice can go wrong, and the code refuses rather than risking it. Marshal object links are positional: @6 means "the sixth registered object." Splicing a body behind a hash node shifts every index by one, so a payload graph containing a back-reference would silently decode into a different graph than the one you built. So after splicing, the builder parses its own output and raises ObjectLinkRefusedError if any object link survived. A payload generator that can produce a graph it did not intend is worse than one that refuses.
The runtime guard
LoadGuard does the thing the allowlist proc cannot: it vetoes before the method body runs. A TracePoint on :call fires at method entry, so raising from the handler means the body never executes.
Marshal.load
│
├─ allocate Klass
├─ dispatch marshal_load ──> TracePoint :call fires HERE
│ owner not permitted -> raise
│ (body never runs)
└─ r_post_proc ──> your allowlist proc would have run HERE, too late
The hook list is derived by enumeration, not guessed. Tracing every :call and :c_call during a load of a payload containing a plain object, a marshal_load class, a _load class, a Struct, an extended object, subclassed Hash/Array/String, a custom-#hash key, a Range, Time, Rational, Regexp, and an Exception produces this complete dispatch surface:
ALL distinct method_ids seen: [:_load, :hash, :initialize, :load, :marshal_load]
Small, which is why the guard is tractable at all.
One structural rule holds the whole thing up: the guard never dispatches a method on the receiver it is inspecting. It resolves the owner's name through Object.instance_method(:class), Object.instance_method(:is_a?), and Module.instance_method(:name), bound to the receiver rather than called on it. The receiver is a gadget, a method-erased proxy answers .class and .is_a? through method_missing, and method_missing is exactly what the shipped chain enters through. A guard that asks the object what it is fires the chain it was about to veto, inside a TracePoint handler that does not trace its own nested calls. 03-IMPLEMENTATION.md has the transcript of that failure and the test that pins it.
It ships its own bypasses, including one left open by default
The default hook set watches marshal_load, _load, _load_data, method_missing, and respond_to_missing?. It does not watch #hash and #eql?. That is a deliberate, documented hole, and here is the three-way comparison on the same 19-byte payload:
default guard -> LOADED, #hash fired, watches?(:hash)=false
strict guard -> blocked: deserialization hook OKey#hash is not permitted, #hash never fired
detector -> blocked, nothing loaded at all
The default guard lets the ungated key shape through. Why leave it open? Because #hash and #eql? are among the hottest methods in Ruby, and watching them changes both the cost and the false-positive profile completely. strict: true closes it and accepts that cost. The detector catches the same shape before any bytes are loaded, which is the cheaper place to catch it, and the guard's limitation notice points at it explicitly.
The other limits, stated in the same notice rather than in a footnote:
- It covers the load window only. A class carrying no hook at all is instantiated freely and fires whenever the application later touches it. That is outside any window this guard can see, and it is exactly how the
erb-def-methodprimitive works. - It is thread-scoped. A load on another thread is not covered.
- Its cost is not a multiplier. Enabling a
TracePointcosts roughly 46 microseconds per load, near enough constant, so the ratio is decided by how much work the load itself does: 1.0x on a 488 KB document, 1.1x on 46 KB, 20.7x on a 142-byte session, 40.4x on a 45-byte session cookie. A session cookie is exactly what this lab deserializes, so the number is published with the payload size attached. An earlier version of this project's research recorded "1.4x" from a single large-payload measurement, and that figure is now marked with a dated correction, because a ratio quoted without its payload size reads as an endorsement it has not earned.
The target
A Sinatra app on Rack 3, in a container with no route off the host, read-only root filesystem, dropped capabilities, no-new-privileges, a pids limit, and one writable tmpfs for the canary file.
Four endpoints, arranged as two matched pairs so the difference is one curl apart:
POST /session issue a benign session cookie
GET /render Marshal.load, then compile the template VULNERABLE
GET /render/safe inspect the stream first, then load DEFENDED
GET /yaml/unsafe YAML.unsafe_load the same session VULNERABLE
GET /yaml/safe inspect, then YAML.safe_load DEFENDED
GET /canary report whether the canary file exists
The pairing is the lesson from 01-CONCEPTS.md made executable. /render and /yaml/unsafe both reach code execution with the same ERB object. /yaml/safe refuses it by tag, before revival, because Psych's allowlist is a real veto. /render/safe can only inspect the bytes and hope, because Marshal's is not.
Note what /render/safe does after the detector accepts: it still calls Marshal.load inside a rescue, still checks the result is actually a session hash, and only then compiles. Three layers, because the first one is explicitly not a boundary.
The target gate drives all of this from a second container on the same internal network rather than from a host port. --internal Docker networks block published ports, so a gate that tried to curl from the host would either fail or force the network to be non-isolated. Attacking the target from inside the network keeps the egress isolation real.
Limits: fourteen axes, all on by default
The parser bounds fourteen separate resources, and every one of them is enforced unless you opt out:
stream bytes nodes registered objects symbol definitions
collection entries scalar bytes total scalar bytes object links
symbol references symbol name bytes class name bytes instance variables
struct members nesting depth
Two design notes. The limits are on by default and opt-out, not off by default and opt-in, because the caller who most needs them is the one who never read this page. And Limits.permissive still pins max_depth, because unbounded recursion in a recursive-descent parser is a stack overflow rather than a slow parse, and "permissive" should not mean "crashes the process."
They fail before they allocate:
DepthLimitError: exceeded depth 4
LimitExceededError: stream bytes 110 exceeds 8
The gate
Six stages, run by just gate, 79 assertions, all of which must pass:
| Stage | What it proves |
|---|---|
check |
the seven test suites plus standalone control scripts |
matrix |
which Ruby versions the chain fires on, by running it in each |
exploit |
the CVE boundary in both directions: fires on the vulnerable image, blocked on the patched one, one docker pull apart |
detector |
the adversarial corpus, every payload and what the detector decided |
target |
end-to-end exploitation over real HTTP, plus isolation, error-leak, and the sink-tag check |
package |
the built artifact, the manifest audit, the install path, release identity, negative controls |
Every stage carries an input it must reject. A gate with only positive cases cannot detect a checker that always says yes. The detector corpus is the clearest example, because the accepts and the rejects sit in the same table:
object_in_value_position_control accept Foo
hash_key_object reject Foo stream puts "Foo" in a hash key, so its #hash runs d
hash_key_object_link reject Foo stream puts "Foo" in a hash key, so its #hash runs d
hash_key_struct reject Foo stream puts "Foo" in a hash key, so its #hash runs d
hash_key_extended reject Foo,Comparable stream puts "Comparable" in a hash key, so its #hash
hash_key_user_class_over_array reject Foo stream puts "Foo" in a hash key, so its #hash runs d
hash_key_bare_array reject Foo stream puts "Foo" in a hash key, so its #hash runs d
hash_key_nested_bare_array reject Foo stream puts "Foo" in a hash key, so its #hash runs d
hash_key_user_class_over_string reject Foo stream puts "Foo" in a hash key, so its #eql? runs d
hash_key_extended_over_string reject Comparable stream puts "Comparable" in a hash key, so its #eql?
hash_key_regexp accept
hash_key_array_of_primitives accept
hash_key_empty_array accept
range_with_object_endpoints reject Foo,Range stream puts "Foo" in a Range endpoint, so its #<=> r
range_with_primitive_endpoints accept Range
Read the accepts as carefully as the rejects. hash_key_regexp, hash_key_array_of_primitives, and hash_key_empty_array are all key-position payloads that must not be rejected, and they are what stop the key-position rules from degenerating into "reject anything in a key."
object_in_value_position_control is the sharpest of them. The same class, in the same stream, in value position instead of key position, must be accepted. Without it, a detector that simply rejected every stream mentioning Foo would pass every rejection case in the table.
There is a further refinement worth naming, because it took a real bug to learn: two checks that both reject the same input alibi each other. If a control is refused by the tag check and by the class check, gutting either one leaves the gate green. So the target gate carries a YAML document that the inspector approves and Psych still refuses, specifically to isolate the two layers from each other.
Where to go next
03-IMPLEMENTATION.md walks the code: bytes into a node graph, a node graph into a decision, a live object into a payload, and the ActiveSupport proxy chain end to end as the showpiece.