Cybersecurity-Projects/PROJECTS/beginner/deserialization-gadget-lab/learn/02-ARCHITECTURE.md

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_load with permitted_classes is 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 Array used as a key. Nineteen bytes: "\x04\b{\x06[\x06o:\fUngated\x00i\x06".
  • A Range whose 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#inspect on 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-method primitive works.
  • It 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, 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.