feat(rube): M6 boundary detector, implemented against criteria written before the code

Implements the detector Codex specified while it had no implementation to look
at, so neither model defined and graded the same thing.

Parser-level budgets rather than post-parse checks. Codex's architectural point
was that inspecting after parsing is too late because the allocation already
happened, so Limits and Budget enforce byte size, depth, node count, registered
objects, symbol definitions, collection entries, scalar bytes and object links
DURING recursive descent. The size ceiling is checked before the parser is
constructed and non-String input is rejected without ever calling to_s.

Three policies, STRICT_ALLOWLIST as the default, on the reasoning that people
keep defaults far longer than they intend. No enforcing mode accepts an
allowed_sinks option, because permitting a class-and-sink pair still authorizes
a callback during load. OBSERVE_AND_LOG refuses to construct without a
reporter. Allowlisting a class does NOT exempt its sinks, and that is a test.

No method is named safe?, trusted?, sanitized? or safe_load, and a test asserts
their absence. Those names claim a guarantee this cannot make.

The gate demonstrates the documented bypass rather than asserting it. Under
DENY_SINKS_ONLY the detector ACCEPTS the CVE-2026-41316 payload, and the gate
then loads that accepted snapshot on vulnerable erb and confirms the canary
fires. Our own detector, in a shipped mode, admits a payload that achieves code
execution. That is the limitation notice being true rather than decorative, and
if it ever stops being demonstrable the gate fails.

The notice ships verbatim and names the bypass concretely: a payload carrying
no sink tag can still reach dangerous code, the published chain produces zero
sink tags because ERB defines no marshal_load, and an application that
allowlists ERB will accept it.

106 tests, 235 assertions across four suites. Five gates: check, matrix,
exploit, detector, target.
This commit is contained in:
CarterPerez-dev 2026-07-26 11:36:43 -04:00
parent 66473a6007
commit b6b5c03d74
8 changed files with 558 additions and 11 deletions

View File

@ -15,6 +15,7 @@ test:
{{run_ro}} ruby -Ilib -Itest test/marshal/parser_test.rb
{{run_ro}} ruby -Ilib -Itest test/scanner_test.rb
{{run_ro}} ruby -Ilib -Itest test/chains_test.rb
{{run_ro}} ruby -Ilib -Itest test/marshal/boundary_detector_test.rb
scan namespace="":
{{run_ro}} ruby -Ilib -e 'require "rube"; ns = "{{namespace}}"; r = Rube::Scanner.new(namespace: ns.empty? ? nil : ns).scan; puts "modules=#{r.scanned_modules} candidates=#{r.candidates.length} gated=#{r.gated.length} reachable=#{r.reachable.length}"; puts; r.reachable.each { |c| puts format(" %-10s %-46s %s", c.gate, c.to_s, c.source_location) }'
@ -39,7 +40,10 @@ exploit:
target:
@bash scripts/target-gate.sh
gate: check matrix exploit target
detector:
@bash scripts/detector-gate.sh
gate: check matrix exploit detector target
build:
{{run}} sh -c "gem build --strict rube.gemspec"

View File

@ -5,7 +5,9 @@ require_relative "rube/version"
require_relative "rube/marshal/constants"
require_relative "rube/marshal/errors"
require_relative "rube/marshal/node"
require_relative "rube/marshal/limits"
require_relative "rube/marshal/parser"
require_relative "rube/marshal/boundary_detector"
require_relative "rube/scanner"
require_relative "rube/chains"

View File

@ -0,0 +1,131 @@
# ©AngelaMos | 2026
# boundary_detector.rb
module Rube
module Marshal
class ReporterRequiredError < StandardError; end
class BoundaryDetector
POLICY_STRICT_ALLOWLIST = :strict_allowlist
POLICY_DENY_SINKS_ONLY = :deny_sinks_only
POLICY_OBSERVE_AND_LOG = :observe_and_log
POLICIES = [POLICY_STRICT_ALLOWLIST, POLICY_DENY_SINKS_ONLY, POLICY_OBSERVE_AND_LOG].freeze
PRIMITIVE_CLASS_NAMES = %w[].freeze
REASON_INPUT_TYPE = "input is not a String"
REASON_MALFORMED = "stream is not canonical Marshal: %s"
REASON_SINK = "stream reaches %s#%s during load, before any allowlist can run"
REASON_UNAPPROVED = "stream references unapproved class %s"
LIMITATION_NOTICE = <<~NOTICE.freeze
SECURITY LIMITATION
Rube::Marshal::BoundaryDetector examines a bounded snapshot of Marshal bytes and
applies a caller-selected policy before deserialization. An ACCEPT decision means
only that this snapshot matched that policy.
Acceptance does not make the payload safe, trusted, authenticated, or free of
gadget behavior. This detector does not sandbox Ruby, audit the current
implementations of allowlisted classes, freeze the runtime class graph, or prevent
callbacks and implicit method dispatch that its parser or policy fails to model.
Class allowlisting compares serialized names. It does not prove that the
corresponding Ruby code is harmless.
A payload carrying no sink tag can still reach dangerous code. The published
CVE-2026-41316 chain produces zero sink tags because ERB defines no marshal_load.
It is caught by class allowlisting alone, and an application that allowlists ERB
will accept it.
NOTICE
class Decision
attr_reader :reason, :snapshot, :result
def initialize(accepted:, reason: nil, snapshot: nil, result: nil, observed: false, would_reject: false)
@accepted = accepted
@reason = reason
@snapshot = snapshot
@result = result
@observed = observed
@would_reject = would_reject
end
def accepted?
@accepted
end
def rejected?
!@accepted
end
def observed?
@observed
end
def would_reject?
@would_reject
end
end
def initialize(policy: POLICY_STRICT_ALLOWLIST, allowed_class_names: [], limits: Limits.new, reporter: nil)
raise ArgumentError, "unknown policy #{policy}" unless POLICIES.include?(policy)
raise ReporterRequiredError, "#{POLICY_OBSERVE_AND_LOG} requires a reporter" if
policy == POLICY_OBSERVE_AND_LOG && reporter.nil?
@policy = policy
@allowed_class_names = allowed_class_names.map(&:to_s).freeze
@limits = limits
@reporter = reporter
end
def inspect_stream(input)
return reject(REASON_INPUT_TYPE) unless input.is_a?(String)
snapshot = input.dup.force_encoding(Encoding::BINARY).freeze
result = Parser.new(snapshot, limits: limits).parse
evaluate(result, snapshot)
rescue StreamError => e
reject(format(REASON_MALFORMED, e.class.name.split("::").last))
end
private
attr_reader :policy, :allowed_class_names, :limits, :reporter
def evaluate(result, snapshot)
violation = violation_for(result)
return accept(snapshot, result) unless violation
return observe(violation, snapshot, result) if policy == POLICY_OBSERVE_AND_LOG
reject(violation)
end
def violation_for(result)
sink = result.sinks.first
return format(REASON_SINK, sink.class_name, sink.sink_method) if sink
return nil if policy == POLICY_DENY_SINKS_ONLY
unapproved = result.class_names.reject { |name| allowed_class_names.include?(name) }
return format(REASON_UNAPPROVED, unapproved.join(", ")) unless unapproved.empty?
nil
end
def observe(violation, snapshot, result)
reporter.call(violation)
Decision.new(accepted: true, reason: violation, snapshot: snapshot,
result: result, observed: true, would_reject: true)
end
def accept(snapshot, result)
Decision.new(accepted: true, snapshot: snapshot, result: result)
end
def reject(reason)
Decision.new(accepted: false, reason: reason)
end
end
end
end

View File

@ -18,5 +18,9 @@ module Rube
class DepthLimitError < StreamError; end
class MalformedCountError < StreamError; end
class LimitExceededError < StreamError; end
class InputTypeError < StreamError; end
end
end

View File

@ -0,0 +1,118 @@
# ©AngelaMos | 2026
# limits.rb
module Rube
module Marshal
class Limits
DEFAULT_MAX_BYTES = 1_048_576
DEFAULT_MAX_DEPTH = 64
DEFAULT_MAX_NODES = 10_000
DEFAULT_MAX_REGISTERED_OBJECTS = 4_096
DEFAULT_MAX_SYMBOL_DEFINITIONS = 256
DEFAULT_MAX_COLLECTION_ENTRIES = 1_024
DEFAULT_MAX_SCALAR_BYTES = 262_144
DEFAULT_MAX_TOTAL_SCALAR_BYTES = 524_288
DEFAULT_MAX_OBJECT_LINKS = 2_048
ROLE_BYTES = "stream bytes"
ROLE_NODES = "nodes"
ROLE_REGISTERED = "registered objects"
ROLE_SYMBOLS = "symbol definitions"
ROLE_ENTRIES = "collection entries"
ROLE_SCALAR = "scalar bytes"
ROLE_TOTAL_SCALAR = "total scalar bytes"
ROLE_LINKS = "object links"
attr_reader :max_bytes, :max_depth, :max_nodes, :max_registered_objects,
:max_symbol_definitions, :max_collection_entries,
:max_scalar_bytes, :max_total_scalar_bytes, :max_object_links
def initialize(
max_bytes: DEFAULT_MAX_BYTES,
max_depth: DEFAULT_MAX_DEPTH,
max_nodes: DEFAULT_MAX_NODES,
max_registered_objects: DEFAULT_MAX_REGISTERED_OBJECTS,
max_symbol_definitions: DEFAULT_MAX_SYMBOL_DEFINITIONS,
max_collection_entries: DEFAULT_MAX_COLLECTION_ENTRIES,
max_scalar_bytes: DEFAULT_MAX_SCALAR_BYTES,
max_total_scalar_bytes: DEFAULT_MAX_TOTAL_SCALAR_BYTES,
max_object_links: DEFAULT_MAX_OBJECT_LINKS
)
@max_bytes = max_bytes
@max_depth = max_depth
@max_nodes = max_nodes
@max_registered_objects = max_registered_objects
@max_symbol_definitions = max_symbol_definitions
@max_collection_entries = max_collection_entries
@max_scalar_bytes = max_scalar_bytes
@max_total_scalar_bytes = max_total_scalar_bytes
@max_object_links = max_object_links
end
def permissive
self.class.new(
max_bytes: Float::INFINITY,
max_depth: Constants::DEFAULT_MAX_DEPTH,
max_nodes: Float::INFINITY,
max_registered_objects: Float::INFINITY,
max_symbol_definitions: Float::INFINITY,
max_collection_entries: Float::INFINITY,
max_scalar_bytes: Float::INFINITY,
max_total_scalar_bytes: Float::INFINITY,
max_object_links: Float::INFINITY
)
end
end
class Budget
def initialize(limits)
@limits = limits
@nodes = 0
@registered = 0
@symbols = 0
@links = 0
@scalar_total = 0
end
def node!
@nodes += 1
check(@nodes, limits.max_nodes, Limits::ROLE_NODES)
end
def registered!
@registered += 1
check(@registered, limits.max_registered_objects, Limits::ROLE_REGISTERED)
end
def symbol!
@symbols += 1
check(@symbols, limits.max_symbol_definitions, Limits::ROLE_SYMBOLS)
end
def link!
@links += 1
check(@links, limits.max_object_links, Limits::ROLE_LINKS)
end
def entries!(count)
check(count, limits.max_collection_entries, Limits::ROLE_ENTRIES)
end
def scalar!(size)
check(size, limits.max_scalar_bytes, Limits::ROLE_SCALAR)
@scalar_total += size
check(@scalar_total, limits.max_total_scalar_bytes, Limits::ROLE_TOTAL_SCALAR)
end
private
attr_reader :limits
def check(value, ceiling, role)
return if value <= ceiling
raise LimitExceededError, "#{role} #{value} exceeds #{ceiling}"
end
end
end
end

View File

@ -6,9 +6,14 @@ module Rube
class Parser
include Constants
def initialize(source, max_depth: DEFAULT_MAX_DEPTH)
@source = source.to_s.dup.force_encoding(Encoding::BINARY)
@max_depth = max_depth
def initialize(source, max_depth: DEFAULT_MAX_DEPTH, limits: nil)
raise InputTypeError, "expected String, got #{source.class}" unless source.is_a?(String)
@limits = limits
@max_depth = limits ? limits.max_depth : max_depth
enforce_size(source)
@source = source.dup.force_encoding(Encoding::BINARY)
@budget = Budget.new(limits || Limits.new.permissive)
@position = 0
@symbols = []
@objects = []
@ -24,7 +29,14 @@ module Rube
private
attr_reader :source, :max_depth, :symbols, :objects
attr_reader :source, :max_depth, :symbols, :objects, :budget, :limits
def enforce_size(candidate)
return unless limits
return if candidate.bytesize <= limits.max_bytes
raise LimitExceededError, "#{Limits::ROLE_BYTES} #{candidate.bytesize} exceeds #{limits.max_bytes}"
end
def remaining
source.bytesize - @position
@ -81,10 +93,19 @@ module Rube
end
def read_counted_bytes
take(read_count(ROLE_LENGTH))
size = read_count(ROLE_LENGTH)
budget.scalar!(size)
take(size)
end
def read_entry_count(role)
count = read_count(role)
budget.entries!(count)
count
end
def register(node)
budget.registered!
objects << node
node
end
@ -92,6 +113,7 @@ module Rube
def read_value(depth)
raise DepthLimitError, "exceeded depth #{max_depth}" if depth > max_depth
budget.node!
tag = take(1)
case tag
@ -123,6 +145,7 @@ module Rube
end
def read_symbol(tag)
budget.symbol!
node = Node.new(type: :symbol, tag: tag, value: read_counted_bytes.to_sym)
symbols << node.value
node
@ -136,6 +159,7 @@ module Rube
end
def read_object_link(tag)
budget.link!
index = read_fixnum
raise InvalidLinkError, "object link #{index} of #{objects.length}" unless objects[index] && index >= 0
@ -166,13 +190,13 @@ module Rube
def read_array(tag, depth)
node = register(Node.new(type: :array, tag: tag))
read_count(ROLE_ARRAY).times { node.children << read_value(depth + 1) }
read_entry_count(ROLE_ARRAY).times { node.children << read_value(depth + 1) }
node
end
def read_hash(tag, depth)
node = register(Node.new(type: :hash, tag: tag))
read_count(ROLE_HASH).times { node.children << read_pair(depth) }
read_entry_count(ROLE_HASH).times { node.children << read_pair(depth) }
node.children << read_value(depth + 1) if tag == TAG_HASH_DEFAULT
node
end
@ -186,7 +210,7 @@ module Rube
def read_ivar(tag, depth)
inner = read_value(depth)
read_count(ROLE_IVAR).times do
read_entry_count(ROLE_IVAR).times do
name = read_value(depth + 1)
inner.auxiliary << name
inner.instance_variables_map[name.value] = read_value(depth + 1)
@ -199,7 +223,7 @@ module Rube
class_node = read_value(depth + 1)
node.class_name = class_node.value.to_s
node.auxiliary << class_node
read_count(ROLE_IVAR).times do
read_entry_count(ROLE_IVAR).times do
name = read_value(depth + 1)
node.auxiliary << name
node.instance_variables_map[name.value] = read_value(depth + 1)
@ -210,7 +234,7 @@ module Rube
def read_struct(tag, depth)
node = register(Node.new(type: :struct, tag: tag))
node.class_name = read_value(depth + 1).value.to_s
read_count(ROLE_STRUCT).times { node.children << read_pair(depth) }
read_entry_count(ROLE_STRUCT).times { node.children << read_pair(depth) }
node
end

View File

@ -0,0 +1,75 @@
#!/usr/bin/env bash
# ©AngelaMos | 2026
# detector-gate.sh
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
VULNERABLE_IMAGE="ruby:4.0.2-slim"
echo "boundary detector gate"
echo
output="$(docker run --rm --network none --read-only --tmpfs /tmp:rw,noexec,nosuid,size=1m \
--user nobody \
-v "${HERE}/lib:/app/lib:ro" -w /app "${VULNERABLE_IMAGE}" ruby -Ilib -e '
require "rube"
D = Rube::Marshal::BoundaryDetector
CANARY = "/tmp/rube-canary"
hostile = Rube::Chains::ErbDefMethod.canary(CANARY, "fired").serialize
benign = Marshal.dump({ "user" => "guest", "roles" => [1, 2, 3] })
sinky = Marshal.dump(Gem::Requirement.new(">= 0"))
strict_hostile = D.new.inspect_stream(hostile)
strict_benign = D.new.inspect_stream(benign)
strict_sinky = D.new.inspect_stream(sinky)
allowlisted = D.new(allowed_class_names: %w[Gem::Requirement Gem::Version]).inspect_stream(sinky)
loose_hostile = D.new(policy: D::POLICY_DENY_SINKS_ONLY).inspect_stream(hostile)
puts "strict_rejects_cve=#{strict_hostile.rejected?}"
puts "strict_accepts_benign=#{strict_benign.accepted?}"
puts "strict_rejects_sink=#{strict_sinky.rejected?}"
puts "allowlist_does_not_exempt_sink=#{allowlisted.rejected?}"
puts "deny_sinks_only_accepts_cve=#{loose_hostile.accepted?}"
fired = false
if loose_hostile.accepted?
begin
Marshal.load(loose_hostile.snapshot).def_method(Module.new, "x")
rescue StandardError
nil
end
fired = File.exist?(CANARY)
end
puts "documented_bypass_executes=#{fired}"
' 2>&1)"
echo "${output}" | sed 's/^/ /'
echo
failures=0
expect() {
if echo "${output}" | grep -q "^$1=true$"; then
echo " PASS $2"
else
echo " FAIL $2"
failures=$((failures + 1))
fi
}
expect strict_rejects_cve "strict policy rejects the CVE-2026-41316 payload"
expect strict_accepts_benign "strict policy still accepts a benign primitive stream"
expect strict_rejects_sink "strict policy rejects a sink-bearing stream"
expect allowlist_does_not_exempt_sink "allowlisting a class does not exempt its sink"
expect deny_sinks_only_accepts_cve "deny-sinks-only accepts the CVE payload as documented"
expect documented_bypass_executes "the documented bypass actually executes, so the notice is honest"
echo
if [[ ${failures} -eq 0 ]]; then
echo "GATE PASSED"
exit 0
fi
echo "GATE FAILED (${failures})"
exit 1

View File

@ -0,0 +1,189 @@
# ©AngelaMos | 2026
# boundary_detector_test.rb
require_relative "../test_helper"
module Rube
module Marshal
class BoundaryDetectorTest < Minitest::Test
BENIGN = { "user" => "guest", "roles" => [1, 2, 3], "flag" => true }.freeze
CANARY_PATH = "/tmp/rube-canary"
CANARY_MARKER = "fired"
def detector(**options)
BoundaryDetector.new(**options)
end
def benign_blob
::Marshal.dump(BENIGN)
end
def sink_blob
::Marshal.dump(Gem::Requirement.new(">= 0"))
end
def cve_blob
Rube::Chains::ErbDefMethod.canary(CANARY_PATH, CANARY_MARKER).serialize
end
def test_default_policy_is_strict_allowlist
assert_equal BoundaryDetector::POLICY_STRICT_ALLOWLIST,
detector.send(:policy)
end
def test_accepts_primitive_only_stream_with_an_empty_allowlist
assert_predicate detector.inspect_stream(benign_blob), :accepted?
end
def test_rejects_non_string_input_without_converting_it
hostile = Object.new
def hostile.to_s
raise "to_s must never be called on untrusted input"
end
decision = detector.inspect_stream(hostile)
assert_predicate decision, :rejected?
assert_equal BoundaryDetector::REASON_INPUT_TYPE, decision.reason
end
def test_rejects_a_sink_bearing_stream
decision = detector.inspect_stream(sink_blob)
assert_predicate decision, :rejected?
assert_includes decision.reason, "Gem::Requirement#marshal_load"
end
def test_allowlisting_a_class_does_not_exempt_its_sink
decision = detector(allowed_class_names: %w[Gem::Requirement Gem::Version])
.inspect_stream(sink_blob)
assert_predicate decision, :rejected?
assert_includes decision.reason, "marshal_load"
end
def test_rejects_unapproved_class_names
decision = detector.inspect_stream(cve_blob)
assert_predicate decision, :rejected?
assert_includes decision.reason, "ERB"
end
def test_accepts_an_approved_class
decision = detector(allowed_class_names: %w[ERB]).inspect_stream(cve_blob)
assert_predicate decision, :accepted?
end
def test_documented_bypass_is_real_deny_sinks_only_accepts_the_cve_payload
decision = detector(policy: BoundaryDetector::POLICY_DENY_SINKS_ONLY)
.inspect_stream(cve_blob)
assert_predicate decision, :accepted?,
"the limitation notice claims this bypass exists, so it must be demonstrable"
end
def test_deny_sinks_only_still_rejects_sinks
decision = detector(policy: BoundaryDetector::POLICY_DENY_SINKS_ONLY)
.inspect_stream(sink_blob)
assert_predicate decision, :rejected?
end
def test_observe_and_log_requires_a_reporter
assert_raises(ReporterRequiredError) do
detector(policy: BoundaryDetector::POLICY_OBSERVE_AND_LOG)
end
end
def test_observe_and_log_accepts_but_records_the_violation
seen = []
decision = detector(policy: BoundaryDetector::POLICY_OBSERVE_AND_LOG,
reporter: ->(reason) { seen << reason })
.inspect_stream(cve_blob)
assert_predicate decision, :accepted?
assert_predicate decision, :observed?
assert_predicate decision, :would_reject?
assert_equal 1, seen.length
end
def test_observe_and_log_still_rejects_malformed_input
decision = detector(policy: BoundaryDetector::POLICY_OBSERVE_AND_LOG,
reporter: ->(_) {})
.inspect_stream("\x04\x08[\xFA")
assert_predicate decision, :rejected?
end
def test_rejects_unknown_policy
assert_raises(ArgumentError) { detector(policy: :yolo) }
end
def test_rejects_malformed_stream_with_a_named_reason
decision = detector.inspect_stream("\x04\x08[\xFA")
assert_predicate decision, :rejected?
assert_includes decision.reason, "MalformedCountError"
end
def test_returns_a_frozen_snapshot_on_accept
decision = detector.inspect_stream(benign_blob)
assert_predicate decision.snapshot, :frozen?
end
def test_snapshot_is_independent_of_a_mutated_original
original = +benign_blob
decision = detector.inspect_stream(original)
original << "tampered"
refute_includes decision.snapshot, "tampered"
end
def test_enforces_a_byte_ceiling_before_parsing
limits = Limits.new(max_bytes: 8)
decision = detector(limits: limits).inspect_stream(benign_blob)
assert_predicate decision, :rejected?
assert_includes decision.reason, "LimitExceededError"
end
def test_enforces_a_depth_ceiling
deep = "\x04\x08" + ("[\x06" * 80) + "0"
decision = detector.inspect_stream(deep)
assert_predicate decision, :rejected?
end
def test_enforces_a_collection_entry_ceiling
limits = Limits.new(max_collection_entries: 2)
decision = detector(limits: limits).inspect_stream(::Marshal.dump([1, 2, 3, 4, 5]))
assert_predicate decision, :rejected?
end
def test_enforces_a_symbol_ceiling
limits = Limits.new(max_symbol_definitions: 2)
blob = ::Marshal.dump(%i[a b c d e f])
decision = detector(limits: limits).inspect_stream(blob)
assert_predicate decision, :rejected?
end
def test_enforces_a_node_ceiling
limits = Limits.new(max_nodes: 5)
decision = detector(limits: limits).inspect_stream(::Marshal.dump((1..50).to_a))
assert_predicate decision, :rejected?
end
def test_never_exposes_a_safety_claiming_api
%i[safe? trusted? sanitized? safe_load].each do |forbidden|
refute_respond_to detector, forbidden,
"#{forbidden} implies a guarantee this detector cannot make"
end
end
def test_ships_a_limitation_notice_that_names_the_bypass
notice = BoundaryDetector::LIMITATION_NOTICE
assert_includes notice, "does not make the payload safe"
assert_includes notice, "CVE-2026-41316"
assert_includes notice, "zero sink tags"
end
def test_detector_never_calls_marshal_load
fired = false
tracer = TracePoint.new(:call, :c_call) do |tp|
fired = true if tp.method_id == :load && tp.self.equal?(::Marshal)
end
tracer.enable { detector.inspect_stream(cve_blob) }
refute fired
end
end
end
end