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.
- checksums.yaml +7 -0
- data/LICENSE +661 -0
- data/README.md +220 -0
- data/lib/marshalsea/chains/base.rb +102 -0
- data/lib/marshalsea/chains/erb_def_method.rb +72 -0
- data/lib/marshalsea/chains/erb_def_module.rb +129 -0
- data/lib/marshalsea/chains/psych_init_with.rb +57 -0
- data/lib/marshalsea/chains.rb +38 -0
- data/lib/marshalsea/marshal/boundary_detector.rb +186 -0
- data/lib/marshalsea/marshal/constants.rb +91 -0
- data/lib/marshalsea/marshal/errors.rb +29 -0
- data/lib/marshalsea/marshal/float_body.rb +40 -0
- data/lib/marshalsea/marshal/limits.rb +172 -0
- data/lib/marshalsea/marshal/load_guard.rb +120 -0
- data/lib/marshalsea/marshal/node.rb +174 -0
- data/lib/marshalsea/marshal/parser.rb +309 -0
- data/lib/marshalsea/psych/inspector.rb +277 -0
- data/lib/marshalsea/scanner.rb +397 -0
- data/lib/marshalsea/version.rb +7 -0
- data/lib/marshalsea.rb +19 -0
- metadata +66 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# ©AngelaMos | 2026
|
|
2
|
+
# boundary_detector.rb
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
module Marshalsea
|
|
6
|
+
module Marshal
|
|
7
|
+
class ReporterRequiredError < StandardError; end
|
|
8
|
+
|
|
9
|
+
class BoundaryDetector
|
|
10
|
+
POLICY_STRICT_ALLOWLIST = :strict_allowlist
|
|
11
|
+
POLICY_DENY_SINKS_ONLY = :deny_sinks_only
|
|
12
|
+
POLICY_OBSERVE_AND_LOG = :observe_and_log
|
|
13
|
+
|
|
14
|
+
POLICIES = [POLICY_STRICT_ALLOWLIST, POLICY_DENY_SINKS_ONLY, POLICY_OBSERVE_AND_LOG].freeze
|
|
15
|
+
|
|
16
|
+
REASON_INPUT_TYPE = "input is not a String"
|
|
17
|
+
REASON_MALFORMED = "stream is not canonical Marshal: %s"
|
|
18
|
+
REASON_SINK = "stream reaches %s#%s during load, before any allowlist can run"
|
|
19
|
+
REASON_UNAPPROVED = "stream references unapproved class %s"
|
|
20
|
+
REASON_ROLE_ANOMALY = "stream is not canonical Marshal: %s, so Marshal.load refuses it " \
|
|
21
|
+
"and there is nothing here to permit"
|
|
22
|
+
REASON_KEY_HASH = "stream puts %s in a hash key, so its #hash runs during load, before " \
|
|
23
|
+
"any allowlist can act"
|
|
24
|
+
REASON_KEY_EQL = "stream puts %s in a hash key, so its #eql? runs during load as soon as " \
|
|
25
|
+
"two keys collide, before any allowlist can act"
|
|
26
|
+
REASON_RANGE_ENDPOINT = "stream puts %s in a Range endpoint, so its #<=> runs during " \
|
|
27
|
+
"load, before any allowlist can act"
|
|
28
|
+
REASON_NONCANONICAL_VERSION = "stream declares Marshal %d.%d; every Ruby that can produce " \
|
|
29
|
+
"this format emits %d.%d"
|
|
30
|
+
|
|
31
|
+
REASON_MAX_NAME_BYTES = 96
|
|
32
|
+
REASON_MAX_NAMES = 8
|
|
33
|
+
REASON_TRUNCATED_MARKER = "[truncated"
|
|
34
|
+
REASON_TRUNCATED = "#{REASON_TRUNCATED_MARKER}, +%d bytes]".freeze
|
|
35
|
+
REASON_ELIDED_NAMES = ", and %d more"
|
|
36
|
+
REASON_NAME_SEPARATOR = ", "
|
|
37
|
+
|
|
38
|
+
LIMITATION_NOTICE = <<~NOTICE
|
|
39
|
+
SECURITY LIMITATION
|
|
40
|
+
|
|
41
|
+
Marshalsea::Marshal::BoundaryDetector examines a bounded snapshot of Marshal bytes and
|
|
42
|
+
applies a caller-selected policy before deserialization. An ACCEPT decision means
|
|
43
|
+
only that this snapshot matched that policy.
|
|
44
|
+
|
|
45
|
+
Acceptance does not make the payload safe, trusted, authenticated, or free of
|
|
46
|
+
gadget behavior. This detector does not sandbox Ruby, audit the current
|
|
47
|
+
implementations of allowlisted classes, freeze the runtime class graph, or prevent
|
|
48
|
+
callbacks and implicit method dispatch that its parser or policy fails to model.
|
|
49
|
+
Class allowlisting compares serialized names. It does not prove that the
|
|
50
|
+
corresponding Ruby code is harmless.
|
|
51
|
+
|
|
52
|
+
A payload carrying no sink tag can still reach dangerous code. The published
|
|
53
|
+
CVE-2026-41316 chain produces zero sink tags because ERB defines no marshal_load.
|
|
54
|
+
It is caught by class allowlisting alone, and an application that allowlists ERB
|
|
55
|
+
will accept it.
|
|
56
|
+
NOTICE
|
|
57
|
+
|
|
58
|
+
class Decision
|
|
59
|
+
STATE_PROCEED = :proceed
|
|
60
|
+
STATE_BLOCKED = :blocked
|
|
61
|
+
STATE_OBSERVED = :observed
|
|
62
|
+
|
|
63
|
+
STATES = [STATE_PROCEED, STATE_BLOCKED, STATE_OBSERVED].freeze
|
|
64
|
+
STATE_PREDICATES = %i[proceed? blocked? observed?].freeze
|
|
65
|
+
|
|
66
|
+
UNKNOWN_STATE = "unknown decision state %p, expected one of %s"
|
|
67
|
+
|
|
68
|
+
attr_reader :state, :reason, :snapshot, :result
|
|
69
|
+
|
|
70
|
+
def initialize(state:, reason: nil, snapshot: nil, result: nil)
|
|
71
|
+
raise ArgumentError, format(UNKNOWN_STATE, state, STATES.join(", ")) unless STATES.include?(state)
|
|
72
|
+
|
|
73
|
+
@state = state
|
|
74
|
+
@reason = reason
|
|
75
|
+
@snapshot = snapshot
|
|
76
|
+
@result = result
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def proceed?
|
|
80
|
+
state == STATE_PROCEED
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def blocked?
|
|
84
|
+
state == STATE_BLOCKED
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def observed?
|
|
88
|
+
state == STATE_OBSERVED
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def initialize(policy: POLICY_STRICT_ALLOWLIST, allowed_class_names: [], limits: Limits.new, reporter: nil)
|
|
93
|
+
raise ArgumentError, "unknown policy #{policy}" unless POLICIES.include?(policy)
|
|
94
|
+
raise ReporterRequiredError, "#{POLICY_OBSERVE_AND_LOG} requires a reporter" if
|
|
95
|
+
policy == POLICY_OBSERVE_AND_LOG && reporter.nil?
|
|
96
|
+
|
|
97
|
+
@policy = policy
|
|
98
|
+
@allowed_class_names = allowed_class_names.map(&:to_s).freeze
|
|
99
|
+
@limits = limits
|
|
100
|
+
@reporter = reporter
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def inspect_stream(input)
|
|
104
|
+
return reject(REASON_INPUT_TYPE) unless input.is_a?(String)
|
|
105
|
+
|
|
106
|
+
snapshot = input.dup.force_encoding(Encoding::BINARY).freeze
|
|
107
|
+
result = Parser.new(snapshot, limits: limits).parse
|
|
108
|
+
evaluate(result, snapshot)
|
|
109
|
+
rescue StreamError => e
|
|
110
|
+
reject(format(REASON_MALFORMED, e.class.name.split("::").last))
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private
|
|
114
|
+
|
|
115
|
+
attr_reader :policy, :allowed_class_names, :limits, :reporter
|
|
116
|
+
|
|
117
|
+
def evaluate(result, snapshot)
|
|
118
|
+
violation = violation_for(result)
|
|
119
|
+
return accept(snapshot, result) unless violation
|
|
120
|
+
|
|
121
|
+
return observe(violation, snapshot, result) if policy == POLICY_OBSERVE_AND_LOG
|
|
122
|
+
|
|
123
|
+
reject(violation)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def quoted(name)
|
|
127
|
+
raw = name.to_s.dup.force_encoding(Encoding::BINARY)
|
|
128
|
+
return raw.inspect if raw.bytesize <= REASON_MAX_NAME_BYTES
|
|
129
|
+
|
|
130
|
+
"#{raw.byteslice(0, REASON_MAX_NAME_BYTES).inspect}" \
|
|
131
|
+
"#{format(REASON_TRUNCATED, raw.bytesize - REASON_MAX_NAME_BYTES)}"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def quoted_list(names)
|
|
135
|
+
shown = names.first(REASON_MAX_NAMES).map { |name| quoted(name) }.join(REASON_NAME_SEPARATOR)
|
|
136
|
+
elided = names.length - REASON_MAX_NAMES
|
|
137
|
+
return shown unless elided.positive?
|
|
138
|
+
|
|
139
|
+
"#{shown}#{format(REASON_ELIDED_NAMES, elided)}"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def violation_for(result)
|
|
143
|
+
anomaly = result.role_anomalies.first
|
|
144
|
+
return format(REASON_ROLE_ANOMALY, anomaly) if anomaly
|
|
145
|
+
|
|
146
|
+
sink = result.sinks.first
|
|
147
|
+
return format(REASON_SINK, quoted(sink.class_name), sink.sink_method) if sink
|
|
148
|
+
|
|
149
|
+
hashed = result.hash_dispatching_keys.first
|
|
150
|
+
return format(REASON_KEY_HASH, quoted(hashed.effective_class_name)) if hashed
|
|
151
|
+
|
|
152
|
+
compared = result.eql_dispatching_keys.first
|
|
153
|
+
return format(REASON_KEY_EQL, quoted(compared.effective_class_name)) if compared
|
|
154
|
+
|
|
155
|
+
endpoint = result.range_endpoint_dispatchers.first
|
|
156
|
+
return format(REASON_RANGE_ENDPOINT, quoted(endpoint.effective_class_name)) if endpoint
|
|
157
|
+
|
|
158
|
+
return nil if policy == POLICY_DENY_SINKS_ONLY
|
|
159
|
+
|
|
160
|
+
unless result.canonical_version?
|
|
161
|
+
return format(REASON_NONCANONICAL_VERSION, result.major, result.minor,
|
|
162
|
+
Constants::MAJOR_VERSION, Constants::MINOR_VERSION)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
unapproved = result.class_names.reject { |name| allowed_class_names.include?(name) }
|
|
166
|
+
return format(REASON_UNAPPROVED, quoted_list(unapproved)) unless unapproved.empty?
|
|
167
|
+
|
|
168
|
+
nil
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def observe(violation, snapshot, result)
|
|
172
|
+
reporter.call(violation)
|
|
173
|
+
Decision.new(state: Decision::STATE_OBSERVED, reason: violation,
|
|
174
|
+
snapshot: snapshot, result: result)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def accept(snapshot, result)
|
|
178
|
+
Decision.new(state: Decision::STATE_PROCEED, snapshot: snapshot, result: result)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def reject(reason)
|
|
182
|
+
Decision.new(state: Decision::STATE_BLOCKED, reason: reason)
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# ©AngelaMos | 2026
|
|
2
|
+
# constants.rb
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
module Marshalsea
|
|
6
|
+
module Marshal
|
|
7
|
+
module Constants
|
|
8
|
+
MAJOR_VERSION = 4
|
|
9
|
+
MINOR_VERSION = 8
|
|
10
|
+
HEADER_LENGTH = 2
|
|
11
|
+
|
|
12
|
+
TAG_NIL = "0"
|
|
13
|
+
TAG_TRUE = "T"
|
|
14
|
+
TAG_FALSE = "F"
|
|
15
|
+
TAG_FIXNUM = "i"
|
|
16
|
+
TAG_BIGNUM = "l"
|
|
17
|
+
TAG_FLOAT = "f"
|
|
18
|
+
TAG_STRING = '"'
|
|
19
|
+
TAG_SYMBOL = ":"
|
|
20
|
+
TAG_SYMLINK = ";"
|
|
21
|
+
TAG_OBJECT_LINK = "@"
|
|
22
|
+
TAG_ARRAY = "["
|
|
23
|
+
TAG_HASH = "{"
|
|
24
|
+
TAG_HASH_DEFAULT = "}"
|
|
25
|
+
TAG_REGEXP = "/"
|
|
26
|
+
TAG_OBJECT = "o"
|
|
27
|
+
TAG_USERDEF = "u"
|
|
28
|
+
TAG_USERMARSHAL = "U"
|
|
29
|
+
TAG_USERCLASS = "C"
|
|
30
|
+
TAG_EXTENDED = "e"
|
|
31
|
+
TAG_CLASS = "c"
|
|
32
|
+
TAG_MODULE = "m"
|
|
33
|
+
TAG_MODULE_OLD = "M"
|
|
34
|
+
TAG_STRUCT = "S"
|
|
35
|
+
TAG_DATA = "d"
|
|
36
|
+
TAG_IVAR = "I"
|
|
37
|
+
|
|
38
|
+
NUL_BYTE = "\x00"
|
|
39
|
+
|
|
40
|
+
FLOAT_NAN = "nan"
|
|
41
|
+
FLOAT_INFINITY = "inf"
|
|
42
|
+
FLOAT_NEGATIVE_INFINITY = "-inf"
|
|
43
|
+
|
|
44
|
+
FLOAT_LEADING_SPACE = "[ \t\n\v\f\r]*"
|
|
45
|
+
FLOAT_HEX_PREFIX = /\A#{FLOAT_LEADING_SPACE}[+-]?0[xX](?:\h+(?:\.\h*)?|\.\h+)(?:[pP][+-]?\d+)?/
|
|
46
|
+
FLOAT_DECIMAL_PREFIX = /\A#{FLOAT_LEADING_SPACE}[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?/
|
|
47
|
+
|
|
48
|
+
BIGNUM_SIGN_POSITIVE = "+"
|
|
49
|
+
BIGNUM_SIGN_NEGATIVE = "-"
|
|
50
|
+
BIGNUM_SIGNS = [BIGNUM_SIGN_POSITIVE, BIGNUM_SIGN_NEGATIVE].freeze
|
|
51
|
+
BIGNUM_WORD_BYTES = 2
|
|
52
|
+
|
|
53
|
+
FIXNUM_INLINE_OFFSET = 5
|
|
54
|
+
FIXNUM_MAX_INLINE = 4
|
|
55
|
+
FIXNUM_MIN_INLINE = -4
|
|
56
|
+
FIXNUM_MAX_WIDTH = 4
|
|
57
|
+
|
|
58
|
+
BYTE_SIGN_THRESHOLD = 128
|
|
59
|
+
BYTE_MODULUS = 256
|
|
60
|
+
BITS_PER_BYTE = 8
|
|
61
|
+
|
|
62
|
+
DEFAULT_MAX_DEPTH = 256
|
|
63
|
+
|
|
64
|
+
ROLE_LENGTH = "byte length"
|
|
65
|
+
ROLE_ARRAY = "array element"
|
|
66
|
+
ROLE_HASH = "hash entry"
|
|
67
|
+
ROLE_IVAR = "instance variable"
|
|
68
|
+
ROLE_STRUCT = "struct member"
|
|
69
|
+
ROLE_BIGNUM = "bignum word"
|
|
70
|
+
|
|
71
|
+
ROLE_ANOMALY = "%s name slot holds %s, not a symbol"
|
|
72
|
+
|
|
73
|
+
CLASS_NAME_TYPES = %i[symbol symlink].freeze
|
|
74
|
+
|
|
75
|
+
RANGE_CLASS_NAME = "Range"
|
|
76
|
+
RANGE_ENDPOINT_IVARS = %i[begin end].freeze
|
|
77
|
+
|
|
78
|
+
REGISTERED_WRAPPER_TYPES = %i[data].freeze
|
|
79
|
+
|
|
80
|
+
SINK_TAGS = [TAG_USERDEF, TAG_USERMARSHAL, TAG_DATA].freeze
|
|
81
|
+
|
|
82
|
+
SINK_METHODS = {
|
|
83
|
+
TAG_USERDEF => "_load",
|
|
84
|
+
TAG_USERMARSHAL => "marshal_load",
|
|
85
|
+
TAG_DATA => "_load_data"
|
|
86
|
+
}.freeze
|
|
87
|
+
|
|
88
|
+
GATED_SINK_TAGS = [TAG_USERDEF, TAG_USERMARSHAL, TAG_DATA].freeze
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# ©AngelaMos | 2026
|
|
2
|
+
# errors.rb
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
module Marshalsea
|
|
6
|
+
module Marshal
|
|
7
|
+
class StreamError < StandardError; end
|
|
8
|
+
|
|
9
|
+
class TruncatedStreamError < StreamError; end
|
|
10
|
+
|
|
11
|
+
class UnsupportedVersionError < StreamError; end
|
|
12
|
+
|
|
13
|
+
class UnknownTagError < StreamError; end
|
|
14
|
+
|
|
15
|
+
class TrailingBytesError < StreamError; end
|
|
16
|
+
|
|
17
|
+
class InvalidLinkError < StreamError; end
|
|
18
|
+
|
|
19
|
+
class DepthLimitError < StreamError; end
|
|
20
|
+
|
|
21
|
+
class MalformedCountError < StreamError; end
|
|
22
|
+
|
|
23
|
+
class MalformedValueError < StreamError; end
|
|
24
|
+
|
|
25
|
+
class LimitExceededError < StreamError; end
|
|
26
|
+
|
|
27
|
+
class InputTypeError < StreamError; end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# ©AngelaMos | 2026
|
|
2
|
+
# float_body.rb
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
module Marshalsea
|
|
6
|
+
module Marshal
|
|
7
|
+
module FloatBody
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def decode(body)
|
|
11
|
+
text = body.b
|
|
12
|
+
named = named_value(text)
|
|
13
|
+
return [named, nil] if named
|
|
14
|
+
|
|
15
|
+
token = prefix(text)
|
|
16
|
+
tail = text.byteslice(token.bytesize..)
|
|
17
|
+
[value_of(token), tail.empty? ? nil : tail]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def named_value(text)
|
|
21
|
+
case text.byteslice(0, text.index(Constants::NUL_BYTE) || text.bytesize)
|
|
22
|
+
when Constants::FLOAT_NAN then Float::NAN
|
|
23
|
+
when Constants::FLOAT_INFINITY then Float::INFINITY
|
|
24
|
+
when Constants::FLOAT_NEGATIVE_INFINITY then -Float::INFINITY
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def prefix(text)
|
|
29
|
+
match = Constants::FLOAT_HEX_PREFIX.match(text) ||
|
|
30
|
+
Constants::FLOAT_DECIMAL_PREFIX.match(text)
|
|
31
|
+
match ? match[0] : ""
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def value_of(token)
|
|
35
|
+
stripped = token.strip
|
|
36
|
+
stripped.empty? ? 0.0 : Float(stripped)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# ©AngelaMos | 2026
|
|
2
|
+
# limits.rb
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
module Marshalsea
|
|
6
|
+
module Marshal
|
|
7
|
+
class Limits
|
|
8
|
+
DEFAULT_MAX_BYTES = 1_048_576
|
|
9
|
+
DEFAULT_MAX_DEPTH = 64
|
|
10
|
+
DEFAULT_MAX_NODES = 10_000
|
|
11
|
+
DEFAULT_MAX_REGISTERED_OBJECTS = 4_096
|
|
12
|
+
DEFAULT_MAX_SYMBOL_DEFINITIONS = 256
|
|
13
|
+
DEFAULT_MAX_COLLECTION_ENTRIES = 1_024
|
|
14
|
+
DEFAULT_MAX_SCALAR_BYTES = 262_144
|
|
15
|
+
DEFAULT_MAX_TOTAL_SCALAR_BYTES = 524_288
|
|
16
|
+
DEFAULT_MAX_OBJECT_LINKS = 2_048
|
|
17
|
+
DEFAULT_MAX_SYMBOL_REFERENCES = 2_048
|
|
18
|
+
DEFAULT_MAX_SYMBOL_NAME_BYTES = 1_024
|
|
19
|
+
DEFAULT_MAX_CLASS_NAME_BYTES = 1_024
|
|
20
|
+
DEFAULT_MAX_INSTANCE_VARIABLES = 256
|
|
21
|
+
DEFAULT_MAX_STRUCT_MEMBERS = 256
|
|
22
|
+
|
|
23
|
+
ROLE_BYTES = "stream bytes"
|
|
24
|
+
ROLE_NODES = "nodes"
|
|
25
|
+
ROLE_REGISTERED = "registered objects"
|
|
26
|
+
ROLE_SYMBOLS = "symbol definitions"
|
|
27
|
+
ROLE_ENTRIES = "collection entries"
|
|
28
|
+
ROLE_SCALAR = "scalar bytes"
|
|
29
|
+
ROLE_TOTAL_SCALAR = "total scalar bytes"
|
|
30
|
+
ROLE_LINKS = "object links"
|
|
31
|
+
ROLE_SYMBOL_REFERENCES = "symbol references"
|
|
32
|
+
ROLE_SYMBOL_NAME = "symbol name bytes"
|
|
33
|
+
ROLE_CLASS_NAME = "class name bytes"
|
|
34
|
+
ROLE_INSTANCE_VARIABLES = "instance variables"
|
|
35
|
+
ROLE_STRUCT_MEMBERS = "struct members"
|
|
36
|
+
|
|
37
|
+
UNBOUNDED = Float::INFINITY
|
|
38
|
+
|
|
39
|
+
attr_reader :max_bytes, :max_depth, :max_nodes, :max_registered_objects,
|
|
40
|
+
:max_symbol_definitions, :max_collection_entries,
|
|
41
|
+
:max_scalar_bytes, :max_total_scalar_bytes, :max_object_links,
|
|
42
|
+
:max_symbol_references, :max_symbol_name_bytes, :max_class_name_bytes,
|
|
43
|
+
:max_instance_variables, :max_struct_members
|
|
44
|
+
|
|
45
|
+
def initialize(
|
|
46
|
+
max_bytes: DEFAULT_MAX_BYTES,
|
|
47
|
+
max_depth: DEFAULT_MAX_DEPTH,
|
|
48
|
+
max_nodes: DEFAULT_MAX_NODES,
|
|
49
|
+
max_registered_objects: DEFAULT_MAX_REGISTERED_OBJECTS,
|
|
50
|
+
max_symbol_definitions: DEFAULT_MAX_SYMBOL_DEFINITIONS,
|
|
51
|
+
max_collection_entries: DEFAULT_MAX_COLLECTION_ENTRIES,
|
|
52
|
+
max_scalar_bytes: DEFAULT_MAX_SCALAR_BYTES,
|
|
53
|
+
max_total_scalar_bytes: DEFAULT_MAX_TOTAL_SCALAR_BYTES,
|
|
54
|
+
max_object_links: DEFAULT_MAX_OBJECT_LINKS,
|
|
55
|
+
max_symbol_references: DEFAULT_MAX_SYMBOL_REFERENCES,
|
|
56
|
+
max_symbol_name_bytes: DEFAULT_MAX_SYMBOL_NAME_BYTES,
|
|
57
|
+
max_class_name_bytes: DEFAULT_MAX_CLASS_NAME_BYTES,
|
|
58
|
+
max_instance_variables: DEFAULT_MAX_INSTANCE_VARIABLES,
|
|
59
|
+
max_struct_members: DEFAULT_MAX_STRUCT_MEMBERS
|
|
60
|
+
)
|
|
61
|
+
@max_bytes = max_bytes
|
|
62
|
+
@max_depth = max_depth
|
|
63
|
+
@max_nodes = max_nodes
|
|
64
|
+
@max_registered_objects = max_registered_objects
|
|
65
|
+
@max_symbol_definitions = max_symbol_definitions
|
|
66
|
+
@max_collection_entries = max_collection_entries
|
|
67
|
+
@max_scalar_bytes = max_scalar_bytes
|
|
68
|
+
@max_total_scalar_bytes = max_total_scalar_bytes
|
|
69
|
+
@max_object_links = max_object_links
|
|
70
|
+
@max_symbol_references = max_symbol_references
|
|
71
|
+
@max_symbol_name_bytes = max_symbol_name_bytes
|
|
72
|
+
@max_class_name_bytes = max_class_name_bytes
|
|
73
|
+
@max_instance_variables = max_instance_variables
|
|
74
|
+
@max_struct_members = max_struct_members
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
STACK_SAFE_MAX_DEPTH = Constants::DEFAULT_MAX_DEPTH
|
|
78
|
+
|
|
79
|
+
def self.permissive
|
|
80
|
+
new(
|
|
81
|
+
max_bytes: UNBOUNDED,
|
|
82
|
+
max_depth: STACK_SAFE_MAX_DEPTH,
|
|
83
|
+
max_nodes: UNBOUNDED,
|
|
84
|
+
max_registered_objects: UNBOUNDED,
|
|
85
|
+
max_symbol_definitions: UNBOUNDED,
|
|
86
|
+
max_collection_entries: UNBOUNDED,
|
|
87
|
+
max_scalar_bytes: UNBOUNDED,
|
|
88
|
+
max_total_scalar_bytes: UNBOUNDED,
|
|
89
|
+
max_object_links: UNBOUNDED,
|
|
90
|
+
max_symbol_references: UNBOUNDED,
|
|
91
|
+
max_symbol_name_bytes: UNBOUNDED,
|
|
92
|
+
max_class_name_bytes: UNBOUNDED,
|
|
93
|
+
max_instance_variables: UNBOUNDED,
|
|
94
|
+
max_struct_members: UNBOUNDED
|
|
95
|
+
)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
class Budget
|
|
100
|
+
def initialize(limits)
|
|
101
|
+
@limits = limits
|
|
102
|
+
@nodes = 0
|
|
103
|
+
@registered = 0
|
|
104
|
+
@symbols = 0
|
|
105
|
+
@links = 0
|
|
106
|
+
@symbol_references = 0
|
|
107
|
+
@scalar_total = 0
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def node!
|
|
111
|
+
@nodes += 1
|
|
112
|
+
check(@nodes, limits.max_nodes, Limits::ROLE_NODES)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def registered!
|
|
116
|
+
@registered += 1
|
|
117
|
+
check(@registered, limits.max_registered_objects, Limits::ROLE_REGISTERED)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def symbol!
|
|
121
|
+
@symbols += 1
|
|
122
|
+
check(@symbols, limits.max_symbol_definitions, Limits::ROLE_SYMBOLS)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def link!
|
|
126
|
+
@links += 1
|
|
127
|
+
check(@links, limits.max_object_links, Limits::ROLE_LINKS)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def symbol_reference!
|
|
131
|
+
@symbol_references += 1
|
|
132
|
+
check(@symbol_references, limits.max_symbol_references, Limits::ROLE_SYMBOL_REFERENCES)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def symbol_name!(size)
|
|
136
|
+
check(size, limits.max_symbol_name_bytes, Limits::ROLE_SYMBOL_NAME)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def class_name!(size)
|
|
140
|
+
check(size, limits.max_class_name_bytes, Limits::ROLE_CLASS_NAME)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def instance_variables!(count)
|
|
144
|
+
check(count, limits.max_instance_variables, Limits::ROLE_INSTANCE_VARIABLES)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def struct_members!(count)
|
|
148
|
+
check(count, limits.max_struct_members, Limits::ROLE_STRUCT_MEMBERS)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def entries!(count)
|
|
152
|
+
check(count, limits.max_collection_entries, Limits::ROLE_ENTRIES)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def scalar!(size)
|
|
156
|
+
check(size, limits.max_scalar_bytes, Limits::ROLE_SCALAR)
|
|
157
|
+
@scalar_total += size
|
|
158
|
+
check(@scalar_total, limits.max_total_scalar_bytes, Limits::ROLE_TOTAL_SCALAR)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
private
|
|
162
|
+
|
|
163
|
+
attr_reader :limits
|
|
164
|
+
|
|
165
|
+
def check(value, ceiling, role)
|
|
166
|
+
return if value <= ceiling
|
|
167
|
+
|
|
168
|
+
raise LimitExceededError, "#{role} #{value} exceeds #{ceiling}"
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# ©AngelaMos | 2026
|
|
2
|
+
# load_guard.rb
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
module Marshalsea
|
|
6
|
+
module Marshal
|
|
7
|
+
class GuardedLoadError < StandardError; end
|
|
8
|
+
|
|
9
|
+
class LoadGuard
|
|
10
|
+
GATED_HOOKS = %i[marshal_load _load _load_data].freeze
|
|
11
|
+
DISPATCH_HOOKS = %i[method_missing respond_to_missing?].freeze
|
|
12
|
+
KEY_HOOKS = %i[hash eql?].freeze
|
|
13
|
+
|
|
14
|
+
DEFAULT_HOOKS = (GATED_HOOKS + DISPATCH_HOOKS).freeze
|
|
15
|
+
STRICT_HOOKS = (DEFAULT_HOOKS + KEY_HOOKS).freeze
|
|
16
|
+
|
|
17
|
+
EVENTS = %i[call c_call].freeze
|
|
18
|
+
|
|
19
|
+
REASON = "deserialization hook %s#%s is not permitted"
|
|
20
|
+
ANONYMOUS_OWNER = "(class with no name)"
|
|
21
|
+
|
|
22
|
+
LIMITATION_NOTICE = <<~NOTICE
|
|
23
|
+
SECURITY LIMITATION
|
|
24
|
+
|
|
25
|
+
Marshalsea::Marshal::LoadGuard vetoes a deserialization hook before its body runs,
|
|
26
|
+
which is the thing a Marshal.load allowlist proc cannot do. It is defense in depth
|
|
27
|
+
and a tripwire. It is not a boundary, and it never makes Marshal.load on untrusted
|
|
28
|
+
input safe.
|
|
29
|
+
|
|
30
|
+
It covers the load window only. A class carrying no hook at all is instantiated
|
|
31
|
+
freely and fires whenever the application later touches it, which is outside any
|
|
32
|
+
window this guard can see.
|
|
33
|
+
|
|
34
|
+
With the default hook set it does not watch #hash or #eql?. Rebuilding a Hash
|
|
35
|
+
rehashes its keys, so a key object's #hash runs inside Marshal.load with no
|
|
36
|
+
deserialization hook involved. Pass strict: true to watch those two as well, and
|
|
37
|
+
accept that they are among the hottest methods in Ruby: the cost and the
|
|
38
|
+
false-positive profile both change completely. Marshalsea::Marshal::BoundaryDetector
|
|
39
|
+
catches that same shape before any bytes are loaded, which is the cheaper place
|
|
40
|
+
to catch it.
|
|
41
|
+
|
|
42
|
+
The guard is thread-scoped. A load on another thread is not covered.
|
|
43
|
+
|
|
44
|
+
Its cost is not a multiplier. Enabling a TracePoint costs roughly 46 microseconds
|
|
45
|
+
per load on a stock ruby:4.0-slim, near enough constant, so the ratio is decided by
|
|
46
|
+
how much work the load itself does. Measured 2026-07-30: 40x on a 45-byte session
|
|
47
|
+
cookie, 21x on a 142-byte session, 1.1x on a 46 KB document, 1.0x on a 488 KB one.
|
|
48
|
+
Guarding a large payload is close to free. Guarding a session cookie on every
|
|
49
|
+
request is not, and a cookie is exactly what this lab deserializes.
|
|
50
|
+
NOTICE
|
|
51
|
+
|
|
52
|
+
class Observation
|
|
53
|
+
attr_reader :class_name, :method_name
|
|
54
|
+
|
|
55
|
+
def initialize(class_name:, method_name:, permitted:)
|
|
56
|
+
@class_name = class_name
|
|
57
|
+
@method_name = method_name
|
|
58
|
+
@permitted = permitted
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def permitted?
|
|
62
|
+
@permitted
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def to_s
|
|
66
|
+
"#{class_name}##{method_name}"
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
attr_reader :observations
|
|
71
|
+
|
|
72
|
+
def initialize(permitted_class_names: [], strict: false)
|
|
73
|
+
@permitted_class_names = permitted_class_names.map(&:to_s).freeze
|
|
74
|
+
@hooks = strict ? STRICT_HOOKS : DEFAULT_HOOKS
|
|
75
|
+
@observations = [].freeze
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def load(blob)
|
|
79
|
+
seen = []
|
|
80
|
+
tracer = TracePoint.new(*EVENTS) { |event| inspect_event(event, seen) }
|
|
81
|
+
result = nil
|
|
82
|
+
begin
|
|
83
|
+
tracer.enable { result = ::Marshal.load(blob) }
|
|
84
|
+
ensure
|
|
85
|
+
@observations = seen.freeze
|
|
86
|
+
end
|
|
87
|
+
result
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def watches?(hook)
|
|
91
|
+
hooks.include?(hook)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
attr_reader :permitted_class_names, :hooks
|
|
97
|
+
|
|
98
|
+
def inspect_event(event, seen)
|
|
99
|
+
return unless hooks.include?(event.method_id)
|
|
100
|
+
|
|
101
|
+
owner = owner_name(event.self)
|
|
102
|
+
label = owner || ANONYMOUS_OWNER
|
|
103
|
+
permitted = !owner.nil? && permitted_class_names.include?(owner)
|
|
104
|
+
seen << Observation.new(class_name: label, method_name: event.method_id,
|
|
105
|
+
permitted: permitted)
|
|
106
|
+
return if permitted
|
|
107
|
+
|
|
108
|
+
raise GuardedLoadError, format(REASON, label, event.method_id)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def owner_name(receiver)
|
|
112
|
+
owner = receiver.is_a?(Module) ? receiver : receiver.class
|
|
113
|
+
name = owner.name
|
|
114
|
+
name if name.is_a?(String) && !name.empty?
|
|
115
|
+
rescue StandardError
|
|
116
|
+
nil
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|