Cybersecurity-Projects/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/scanner.rb

398 lines
11 KiB
Ruby

# ©AngelaMos | 2026
# scanner.rb
# frozen_string_literal: true
module Marshalsea
class ParseRecoveredError < StandardError
def initialize(errors)
super("Prism recovered an incomplete tree from #{errors} syntax errors")
end
end
class Scanner
GATE_GATED = :gated
GATE_SOFT = :soft
GATE_UNGATED = :ungated
GATE_LINK = :link
FORMAT_MARSHAL = :marshal
FORMAT_PSYCH = :psych
VARIADIC = -1
ENTRY_POINTS = {
"marshal_load" => { gate: GATE_GATED, arity: 1, formats: [FORMAT_MARSHAL, FORMAT_PSYCH] },
"_load_data" => { gate: GATE_GATED, arity: 1, formats: [FORMAT_MARSHAL] },
"_load" => { gate: GATE_GATED, arity: 1, formats: [FORMAT_MARSHAL], singleton: true },
"init_with" => { gate: GATE_SOFT, arity: 1, formats: [FORMAT_PSYCH] },
"hash" => { gate: GATE_UNGATED, arity: 0, formats: [FORMAT_MARSHAL, FORMAT_PSYCH] },
"eql?" => { gate: GATE_UNGATED, arity: 1, formats: [FORMAT_MARSHAL, FORMAT_PSYCH] },
"<=>" => { gate: GATE_UNGATED, arity: 1, formats: [FORMAT_MARSHAL] },
"==" => { gate: GATE_UNGATED, arity: 1, formats: [FORMAT_PSYCH] },
"[]=" => { gate: GATE_UNGATED, arity: 2, formats: [FORMAT_PSYCH] },
"method_missing" => { gate: GATE_UNGATED, arity: VARIADIC, formats: [FORMAT_MARSHAL, FORMAT_PSYCH] },
"respond_to_missing?" => { gate: GATE_UNGATED, arity: 2, formats: [FORMAT_MARSHAL, FORMAT_PSYCH] },
"respond_to?" => { gate: GATE_UNGATED, arity: VARIADIC, formats: [FORMAT_PSYCH] },
"to_s" => { gate: GATE_LINK, arity: 0, formats: [] },
"coerce" => { gate: GATE_LINK, arity: 1, formats: [] }
}.freeze
GATED_METHODS = ENTRY_POINTS.select { |_, spec| spec[:gate] == GATE_GATED && !spec[:singleton] }
.keys.freeze
GATED_SINGLETON_METHODS = ENTRY_POINTS.select { |_, spec| spec[:singleton] }.keys.freeze
UNGATED_METHODS = ENTRY_POINTS.select { |_, spec| spec[:gate] == GATE_UNGATED }.keys.freeze
LINK_METHODS = ENTRY_POINTS.select { |_, spec| spec[:gate] == GATE_LINK }.keys.freeze
INSTANCE_METHODS = ENTRY_POINTS.reject { |_, spec| spec[:singleton] }.keys.freeze
SINGLETON_METHODS = GATED_SINGLETON_METHODS
PRISM_AVAILABLE = begin
require "prism"
true
rescue LoadError
false
end
LOCATION_SEPARATOR = ":"
UNKNOWN_LOCATION = nil
STATE_UNREADABLE = :unreadable
STATE_UNANALYSABLE = :unanalysable
STATE_VERDICTS = [true, false].freeze
SITE_MODULE_NAME = :module_name
SITE_OWN_METHODS = :own_methods
SITE_CANDIDATE = :candidate
SITE_SOURCE_PARSE = :source_parse
SITE_STATE_ANALYSIS = :state_analysis
SITES = [SITE_MODULE_NAME, SITE_OWN_METHODS, SITE_CANDIDATE,
SITE_SOURCE_PARSE, SITE_STATE_ANALYSIS].freeze
LOSSY_SITES = [SITE_MODULE_NAME, SITE_OWN_METHODS, SITE_CANDIDATE].freeze
SUBJECT_UNNAMED = "(module that cannot report a name)"
class Candidate
attr_reader :class_name, :method_name, :gate, :source_location, :arity, :formats
def initialize(class_name:, method_name:, gate:, source_location:, arity:, singleton:,
touches_state:, formats:)
@class_name = class_name
@method_name = method_name
@gate = gate
@source_location = source_location
@arity = arity
@singleton = singleton
@touches_state = touches_state
@formats = formats
end
def singleton?
@singleton
end
def gated?
gate == GATE_GATED
end
def soft_gated?
gate == GATE_SOFT
end
def link?
gate == GATE_LINK
end
def entry_point?
!link?
end
def dispatch_arity
ENTRY_POINTS.fetch(method_name).fetch(:arity)
end
def accepts_dispatch?
required = dispatch_arity
return true if required == VARIADIC
return arity == required unless arity.negative?
required >= (arity.abs - 1)
end
def zero_arity?
arity.zero?
end
def touches_state?
@touches_state == true
end
def state_known?
STATE_VERDICTS.include?(@touches_state)
end
def unanalysable?
@touches_state == STATE_UNANALYSABLE
end
def unreadable_source?
@touches_state == STATE_UNREADABLE
end
def reachable?
return false unless entry_point?
return false unless accepts_dispatch?
return true if gated? || soft_gated?
touches_state? || unreadable_source?
end
def to_s
"#{class_name}#{singleton? ? '.' : '#'}#{method_name}"
end
end
class Suppression
attr_reader :site, :subject, :error_class
def initialize(site:, subject:, error_class:)
@site = site
@subject = subject
@error_class = error_class
end
def lossy?
LOSSY_SITES.include?(site)
end
def to_s
"#{site} #{subject} (#{error_class})"
end
end
class Report
attr_reader :candidates, :scanned_modules, :suppressions
def initialize(candidates, scanned_modules, suppressions)
@candidates = candidates
@scanned_modules = scanned_modules
@suppressions = suppressions
end
def suppressed_count
suppressions.length
end
def suppressions_by_site
suppressions.each_with_object({}) do |suppression, counts|
counts[suppression.site] = counts.fetch(suppression.site, 0) + 1
end
end
def complete?
suppressions.empty?
end
def candidates_lost?
suppressions.any?(&:lossy?)
end
def unanalysable
candidates.select(&:unanalysable?)
end
def fully_analysed?
candidates.all?(&:state_known?)
end
def gated
candidates.select(&:gated?)
end
def ungated
candidates.select { |candidate| candidate.gate == GATE_UNGATED }
end
def links
candidates.select(&:link?)
end
def entry_points
candidates.select(&:entry_point?)
end
def reachable
candidates.select(&:reachable?)
end
def reachable_in(format)
reachable.select { |candidate| candidate.formats.include?(format) }
end
def prism_available?
PRISM_AVAILABLE
end
end
def initialize(namespace: nil)
@namespace = namespace
@candidates = []
@definition_cache = {}
@scanned_modules = 0
@suppressions = []
end
def scan
each_named_module do |mod, name|
@scanned_modules += 1
collect_instance_methods(mod, name)
collect_singleton_methods(mod, name)
end
Report.new(@candidates.sort_by(&:to_s), @scanned_modules, @suppressions.freeze)
end
private
attr_reader :namespace
def suppress(site, subject, error)
@suppressions << Suppression.new(site: site, subject: subject, error_class: error.class.name)
end
def each_named_module
ObjectSpace.each_object(Module) do |mod|
name = safe_name(mod)
next unless name
next unless in_namespace?(name)
yield mod, name
end
end
def safe_name(mod)
name = mod.name
name if name.is_a?(String) && !name.empty?
rescue StandardError => e
suppress(SITE_MODULE_NAME, SUBJECT_UNNAMED, e)
nil
end
def in_namespace?(name)
namespace.nil? || name == namespace || name.start_with?("#{namespace}::")
end
def collect_instance_methods(mod, name)
(own_instance_methods(mod, name) & INSTANCE_METHODS).each do |method_name|
record(mod, name, method_name, singleton: false)
end
end
def collect_singleton_methods(mod, name)
(own_singleton_methods(mod, name) & SINGLETON_METHODS).each do |method_name|
record(mod, name, method_name, singleton: true)
end
end
def own_instance_methods(mod, name)
(mod.instance_methods(false) +
mod.private_instance_methods(false) +
mod.protected_instance_methods(false)).map(&:to_s)
rescue StandardError => e
suppress(SITE_OWN_METHODS, name, e)
[]
end
def own_singleton_methods(mod, name)
(mod.singleton_methods(false) +
mod.singleton_class.private_instance_methods(false)).map(&:to_s)
rescue StandardError => e
suppress(SITE_OWN_METHODS, name, e)
[]
end
def qualified(name, method_name, singleton)
"#{name}#{singleton ? '.' : '#'}#{method_name}"
end
def record(mod, name, method_name, singleton:)
spec = ENTRY_POINTS.fetch(method_name)
handle = singleton ? mod.singleton_class.instance_method(method_name) : mod.instance_method(method_name)
@candidates << Candidate.new(
class_name: name,
method_name: method_name,
gate: spec.fetch(:gate),
source_location: format_location(handle.source_location),
arity: handle.arity,
singleton: singleton,
touches_state: state_reference_in(handle, qualified(name, method_name, singleton)),
formats: spec.fetch(:formats)
)
rescue StandardError, ScriptError => e
suppress(SITE_CANDIDATE, qualified(name, method_name, singleton), e)
nil
end
def format_location(location)
return UNKNOWN_LOCATION unless location
location.join(LOCATION_SEPARATOR)
end
def state_reference_in(handle, subject)
return STATE_UNANALYSABLE unless PRISM_AVAILABLE
path, line = handle.source_location
return STATE_UNANALYSABLE unless path && line
definitions = definitions_for(path)
return STATE_UNREADABLE unless definitions
node = definitions[line]
return STATE_UNREADABLE unless node
node.compact_child_nodes.any? { |child| state_reference?(child) }
rescue StandardError, ScriptError => e
suppress(SITE_STATE_ANALYSIS, subject, e)
STATE_UNREADABLE
end
def definitions_for(path)
return @definition_cache[path] if @definition_cache.key?(path)
@definition_cache[path] = parse_definitions(path)
end
def parse_definitions(path)
parsed = Prism.parse_file(path)
if parsed.failure?
suppress(SITE_SOURCE_PARSE, path, ParseRecoveredError.new(parsed.errors.length))
return nil
end
found = {}
collect_definitions(parsed.value, found)
found
rescue StandardError, ScriptError => e
suppress(SITE_SOURCE_PARSE, path, e)
nil
end
def collect_definitions(node, found)
return unless node.is_a?(Prism::Node)
found[node.location.start_line] = node if node.is_a?(Prism::DefNode)
node.compact_child_nodes.each { |child| collect_definitions(child, found) }
end
def state_reference?(node)
return false unless node.is_a?(Prism::Node)
return true if node.is_a?(Prism::InstanceVariableReadNode)
return true if node.is_a?(Prism::CallNode) && node.receiver.nil?
node.compact_child_nodes.any? { |child| state_reference?(child) }
end
end
end