Cybersecurity-Projects/PROJECTS/beginner/deserialization-gadget-lab/test/chains_test.rb

204 lines
7.5 KiB
Ruby

# ©AngelaMos | 2026
# chains_test.rb
# frozen_string_literal: true
require_relative "test_helper"
module Marshalsea
module Chains
class ChainsTest < Minitest::Test
CANARY_PATH = "/tmp/marshalsea-canary"
CANARY_MARKER = "fired"
def chain
ErbDefMethod.canary(CANARY_PATH, CANARY_MARKER)
end
def test_the_registry_holds_only_descendants_of_base
refute_empty Chains.all, "control: an empty registry would make this vacuous"
assert(Chains.all.all? { |chain| chain < Base },
"Base never registers itself, so filtering it out afterwards was never live code")
end
def test_every_chain_file_on_disk_is_registered
root = File.expand_path("../lib/marshalsea/chains", __dir__)
on_disk = Dir[File.join(root, "*.rb")].map { |path| File.basename(path, ".rb") } - ["base"]
registered = Chains.all.map(&:chain_name)
refute_empty on_disk, "control: the directory must contain a chain to discover"
missing = on_disk.reject { |file| registered.include?(file.tr("_", "-")) }
assert_empty missing,
"the directory is the chain identity, so a file nobody requires by hand " \
"must still be discovered: #{missing.join(', ')}"
end
def test_the_published_ranges_cannot_be_rewritten_through_metadata
assert_predicate ErbDefMethod::AFFECTED, :frozen?
assert(ErbDefMethod::AFFECTED.all?(&:frozen?),
"Array#freeze is shallow, so a nested range stays writable unless frozen too")
assert_raises(FrozenError) { ErbDefMethod.metadata[:affected][2] << "< 4.0.0" }
assert_raises(FrozenError) { ErbDefMethod.metadata[:affected] << ["< 9.9.9"] }
assert_raises(FrozenError) { ErbDefMethod.metadata[:cve] = "CVE-0000-0000" }
end
def test_the_ranges_still_classify_after_a_rejected_mutation
assert ErbDefMethod.affects?("5.0.0"),
"control: a mutation that silently succeeded would move this boundary"
refute ErbDefMethod.affects?("6.0.4")
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
class KeyPositionProbe < Base
PROBE_METADATA = {
name: "key-position-probe", vector: "hash", cve: "none", gem: "none",
affected: [["> 0"]].freeze, kind: Base::KIND_CHAIN
}.freeze
def self.metadata = PROBE_METADATA
def generate = Object.new
def serialize = in_hash_key_position(generate)
end
class RepeatedObjectProbe < KeyPositionProbe
def generate
shared = +"repeated"
[shared, shared]
end
end
def test_a_spliced_key_position_stream_is_byte_correct
blob = KeyPositionProbe.new.serialize
revived = ::Marshal.load(blob)
assert_kind_of Hash, revived
assert_equal 1, revived.length
assert_kind_of Object, revived.keys.first
assert_nil revived.values.first
end
def test_a_spliced_stream_really_puts_the_payload_in_key_position
result = Marshalsea::Marshal::Parser.new(KeyPositionProbe.new.serialize).parse
refute_empty result.hash_dispatching_keys,
"the whole point of the splice is that #hash runs on load"
end
def test_the_splice_refuses_a_graph_whose_link_indices_would_shift
error = assert_raises(ObjectLinkRefusedError) { RepeatedObjectProbe.new.serialize }
assert_includes error.message, "object link"
end
def test_control_the_refused_graph_really_does_carry_an_object_link
graph = Marshalsea::Marshal::Parser.new(::Marshal.dump(RepeatedObjectProbe.new.generate)).parse
assert(graph.nodes.any? { |node| node.type == :object_link },
"control: without a link in the fixture the guard test proves nothing")
end
def test_the_two_erb_payloads_are_labelled_by_kind
assert_predicate ErbDefMethod, :primitive?
refute_predicate ErbDefMethod, :chain?
assert_predicate ErbDefModule, :chain?
refute_predicate ErbDefModule, :primitive?
end
def test_the_chain_declares_the_gem_it_needs_and_the_primitive_does_not
assert_equal ["activesupport"], ErbDefModule.required_gems
assert_empty ErbDefMethod.required_gems
end
def test_the_two_payloads_enter_through_different_methods
assert_equal "def_method", ErbDefMethod.vector
assert_equal "hash", ErbDefModule.vector,
"the chain enters through an ungated #hash, which is why it needs no " \
"application call"
end
def test_dispatcher_availability_predicts_whether_the_chain_can_build
available = ErbDefModule.dispatcher_available?
built = begin
ErbDefModule.canary(CANARY_PATH, CANARY_MARKER).generate
true
rescue ChainError
false
end
assert_equal available, built,
"the predicate must match reality in whichever environment this runs, " \
"or a caller cannot tell whether activesupport is present"
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_parseable_marshal_stream
blob = chain.serialize
assert_equal [Marshalsea::Marshal::Constants::MAJOR_VERSION, Marshalsea::Marshal::Constants::MINOR_VERSION],
[blob.getbyte(0), blob.getbyte(1)]
assert_equal :object, Marshalsea::Marshal::Parser.new(blob).parse.root.type
end
def test_payload_is_visible_to_the_parser_without_deserializing
result = Marshalsea::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