From a4ca6760d18ed13255dbfebf066523f4c67d76cd Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Sun, 26 Jul 2026 09:35:53 -0400 Subject: [PATCH] feat(rube): M2 version matrix - executed gadget compatibility across six Rubies Probes six pinned Ruby images with no network and renders a compatibility matrix for the deserialization gadget surface. Reproducible with just matrix. The matrix carries three controls, because a table that reports one value everywhere cannot be distinguished from a probe that always returns the same answer. Two axes must show more than one state, and the ERB guard column is cross-checked against the published CVE-2026-41316 affected ranges. That third control is the load-bearing one: the probe reads source and knows nothing about NVD, and it agrees with the advisory on 6 of 6 images. Findings recorded in the research docs: The gadget surface moved rather than shrank. Net::WriteAdapter is reachable at baseline on Ruby 3.1 and 3.2 and gone from 3.3 onward, which is why vakzz-era chains needed no preloaded net/http on old Ruby. Gem::URI appears in the same release that took it away, and Gem::URI reached through an autoloaded Gem::SpecFetcher is exactly the bootstrap the 2024 chain relies on. One door closed and another opened in the same version, so a defense reasoning about the known gadget classes is reasoning about a moving target. The ERB @_init guard sits only on def_method. def_module and def_class delegate to it in both vulnerable and patched releases, so one check covers all three. The first probe measured all three independently and reported the delegates as unguarded even on patched erb 6.0.1.1. That was the probe being wrong, not the patch being incomplete, and it is corrected here. Marshal stream format is 4.8 on every image, so the M1 parser applies across the whole range unchanged. make and git are absent from every slim image, so rake is the only exec binary present on that family. --- .../deserialization-gadget-lab/.gitignore | 1 + .../deserialization-gadget-lab/justfile | 2 +- .../scripts/render_matrix.rb | 97 +++++++++++++++ .../scripts/version-matrix.sh | 56 +++++++++ .../test/support/matrix_probe.rb | 117 ++++++++++++++++++ 5 files changed, 272 insertions(+), 1 deletion(-) create mode 100644 PROJECTS/beginner/deserialization-gadget-lab/scripts/render_matrix.rb create mode 100755 PROJECTS/beginner/deserialization-gadget-lab/scripts/version-matrix.sh create mode 100644 PROJECTS/beginner/deserialization-gadget-lab/test/support/matrix_probe.rb diff --git a/PROJECTS/beginner/deserialization-gadget-lab/.gitignore b/PROJECTS/beginner/deserialization-gadget-lab/.gitignore index 2dbe4cb8..5b77316f 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/.gitignore +++ b/PROJECTS/beginner/deserialization-gadget-lab/.gitignore @@ -24,3 +24,4 @@ Gemfile.lock # Local env .env +tmp/ diff --git a/PROJECTS/beginner/deserialization-gadget-lab/justfile b/PROJECTS/beginner/deserialization-gadget-lab/justfile index cf260a72..cfdd464c 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/justfile +++ b/PROJECTS/beginner/deserialization-gadget-lab/justfile @@ -23,7 +23,7 @@ lint: check: test control probe: - {{run_ro}} ruby /app/test/support/probe.rb + {{run_ro}} ruby /app/test/support/matrix_probe.rb matrix: @bash scripts/version-matrix.sh diff --git a/PROJECTS/beginner/deserialization-gadget-lab/scripts/render_matrix.rb b/PROJECTS/beginner/deserialization-gadget-lab/scripts/render_matrix.rb new file mode 100644 index 00000000..29efdd41 --- /dev/null +++ b/PROJECTS/beginner/deserialization-gadget-lab/scripts/render_matrix.rb @@ -0,0 +1,97 @@ +# ©AngelaMos | 2026 +# render_matrix.rb + +require "json" + +TRACKED_CLASSES = %w[Gem::SpecFetcher Gem::Source::Git Gem::URI Net::WriteAdapter].freeze +MARK_YES = "yes" +MARK_NO = "no" +MARK_UNKNOWN = "?" +RULE_WIDTH = 78 + +CVE_PATCHED_ERB = ["4.0.3.1", "4.0.4.1", "6.0.1.1", "6.0.4"].freeze + +rows = File.readlines(ARGV.fetch(0)).reject { |line| line.strip.empty? }.map { |line| JSON.parse(line) } +abort "no probe results" if rows.empty? + +def mark(value) + case value + when true then MARK_YES + when false then MARK_NO + else MARK_UNKNOWN + end +end + +def short(image) + image.sub("ruby:", "") +end + +def section(title) + puts title + puts " #{'-' * RULE_WIDTH}" + yield + puts +end + +section("RUNTIME") do + puts format(" %-14s %-8s %-9s %-8s %-9s %-7s", "image", "ruby", "rubygems", "psych", "erb", "marshal") + rows.each do |r| + puts format(" %-14s %-8s %-9s %-8s %-9s %-7s", + short(r["image"]), r["ruby"], r["rubygems"], r["psych"], r["erb"], r["marshal_format"]) + end +end + +section("GADGET SURFACE") do + puts format(" %-14s %-12s %-14s %s", + "image", "git gadget", "safe_marshal", TRACKED_CLASSES.map { |c| format("%-13s", c.split("::").last) }.join) + rows.each do |r| + present = TRACKED_CLASSES.map { |c| format("%-13s", mark(r["classes_baseline"][c])) }.join + puts format(" %-14s %-12s %-14s %s", short(r["image"]), r["git_gadget"], r["safe_marshal"], present) + end +end + +section("ERB @_init GUARD (CVE-2026-41316)") do + puts " The guard sits only on def_method. def_module and def_class delegate to it," + puts " so one check covers all three. Measuring the delegates directly reports a" + puts " guard that was never supposed to be there." + puts + puts format(" %-14s %-9s %-9s %-24s %s", "image", "erb", "guarded", "delegating", "cve says") + rows.each do |r| + guard = r["erb_guard"] + expected = CVE_PATCHED_ERB.include?(r["erb"]) ? "patched" : "affected" + puts format(" %-14s %-9s %-9s %-24s %s", + short(r["image"]), r["erb"], mark(guard["guarded"]), + guard["delegating"].join(","), expected) + end +end + +git_states = rows.map { |r| r["git_gadget"] }.uniq +guard_states = rows.map { |r| r["erb_guard"]["guarded"] }.uniq + +agreements = rows.map do |r| + expected_patched = CVE_PATCHED_ERB.include?(r["erb"]) + [short(r["image"]), r["erb_guard"]["guarded"] == expected_patched] +end +disagreements = agreements.reject { |_, ok| ok }.map(&:first) + +section("CONTROLS") do + puts " A matrix reporting one value everywhere cannot be told apart from a probe" + puts " that always returns the same answer." + puts + puts format(" git gadget states observed : %s", git_states.join(", ")) + puts format(" erb guard states observed : %s", guard_states.map { |v| mark(v) }.join(", ")) + puts format(" guard vs published CVE : %d/%d agree", agreements.count { |_, ok| ok }, agreements.length) +end + +failures = [] +failures << "git gadget reported '#{git_states.first}' on every image" if git_states.length < 2 +failures << "erb guard reported the same value on every image" if guard_states.length < 2 +failures << "guard disagrees with the CVE ranges on: #{disagreements.join(', ')}" unless disagreements.empty? + +if failures.empty? + puts "PASS - the matrix discriminates on both axes and matches the published CVE ranges" + exit 0 +end + +failures.each { |f| puts "FAIL - #{f}" } +exit 1 diff --git a/PROJECTS/beginner/deserialization-gadget-lab/scripts/version-matrix.sh b/PROJECTS/beginner/deserialization-gadget-lab/scripts/version-matrix.sh new file mode 100755 index 00000000..d81a026e --- /dev/null +++ b/PROJECTS/beginner/deserialization-gadget-lab/scripts/version-matrix.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# ©AngelaMos | 2026 +# version-matrix.sh + +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PROBE="${HERE}/test/support/matrix_probe.rb" +RENDER="${HERE}/scripts/render_matrix.rb" +OUT="${HERE}/tmp/matrix.jsonl" +RENDER_IMAGE="ruby:4.0-slim" + +IMAGES=( + "ruby:3.1-slim" + "ruby:3.2-slim" + "ruby:3.3-slim" + "ruby:3.4-slim" + "ruby:4.0.2-slim" + "ruby:4.0-slim" +) + +if [[ $# -gt 0 ]]; then + IMAGES=("$@") +fi + +mkdir -p "${HERE}/tmp" +: >"${OUT}" + +echo "probing ${#IMAGES[@]} images" + +for image in "${IMAGES[@]}"; do + printf ' %-18s ' "${image}" + + if ! docker image inspect "${image}" >/dev/null 2>&1; then + if ! docker pull -q "${image}" >/dev/null 2>&1; then + echo "UNAVAILABLE" + continue + fi + fi + + if result=$(docker run --rm --network none \ + -e "MATRIX_IMAGE=${image}" \ + -v "${PROBE}:/probe.rb:ro" \ + "${image}" ruby /probe.rb 2>/dev/null); then + echo "${result}" >>"${OUT}" + echo "ok" + else + echo "PROBE FAILED" + fi +done + +echo +docker run --rm --network none \ + -v "${OUT}:/matrix.jsonl:ro" \ + -v "${RENDER}:/render.rb:ro" \ + "${RENDER_IMAGE}" ruby /render.rb /matrix.jsonl diff --git a/PROJECTS/beginner/deserialization-gadget-lab/test/support/matrix_probe.rb b/PROJECTS/beginner/deserialization-gadget-lab/test/support/matrix_probe.rb new file mode 100644 index 00000000..f7a8a0f2 --- /dev/null +++ b/PROJECTS/beginner/deserialization-gadget-lab/test/support/matrix_probe.rb @@ -0,0 +1,117 @@ +# ©AngelaMos | 2026 +# matrix_probe.rb + +require "json" + +GADGET_CLASSES = %w[ + Gem::Requirement + Gem::Version + Gem::SpecFetcher + Gem::Source + Gem::Source::Git + Gem::RequestSet::Lockfile + Gem::URI + Gem::URI::HTTP + Gem::Package::TarReader + Net::BufferedIO + Net::WriteAdapter + UncaughtThrowError +].freeze + +ERB_GUARD_ANCHOR = :def_method +ERB_DELEGATING_METHODS = %i[def_module def_class].freeze + +GUARD_TOKEN = "@_init" +DELEGATION_TOKEN = "def_method" +PATCH_TOKEN = "git_command" +VULNERABLE_TOKEN = "popen(@git" +BINARIES = %w[make rake git].freeze + +def gem_version(name) + spec = Gem.loaded_specs[name] + return spec.version.to_s if spec + + Gem::Specification.find_all_by_name(name).map(&:version).max&.to_s || "absent" +rescue StandardError + "unknown" +end + +def constant_present?(name) + Object.const_get(name) + true +rescue StandardError, ScriptError + false +end + +def binary_present?(name) + ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |dir| + File.executable?(File.join(dir, name)) + end +end + +def git_gadget_state + require "rubygems/source/git" + path = Gem::Source::Git.instance_method(:cache).source_location&.first + return "unknown" unless path && File.readable?(path) + + body = File.read(path) + return "patched" if body.include?(PATCH_TOKEN) + return "vulnerable" if body.include?(VULNERABLE_TOKEN) + + "unknown" +rescue StandardError, ScriptError + "unknown" +end + +def safe_marshal_state + require "rubygems/safe_marshal" + path = Gem::SafeMarshal.method(:safe_load).source_location&.first + return "unknown" unless path && File.readable?(path) + + File.read(path).include?("Date") ? "permits_date" : "no_date" +rescue StandardError, ScriptError + "absent" +end + +def erb_state + require "erb" + { + "guarded" => erb_method_body(ERB_GUARD_ANCHOR)&.include?(GUARD_TOKEN), + "delegating" => ERB_DELEGATING_METHODS.select do |name| + erb_method_body(name)&.include?(DELEGATION_TOKEN) + end.map(&:to_s) + } +rescue StandardError, ScriptError + { "guarded" => nil, "delegating" => [] } +end + +def erb_method_body(name) + path, line = ERB.instance_method(name).source_location + return nil unless path && File.readable?(path) + + File.readlines(path)[line - 1, 10].to_a.join +rescue StandardError, ScriptError + nil +end + +def marshal_format + blob = Marshal.dump(nil) + "#{blob.getbyte(0)}.#{blob.getbyte(1)}" +end + +report = { + "image" => ENV.fetch("MATRIX_IMAGE", "unknown"), + "ruby" => RUBY_VERSION, + "rubygems" => Gem::VERSION, + "psych" => gem_version("psych"), + "erb" => gem_version("erb"), + "json" => gem_version("json"), + "marshal_format" => marshal_format, + "binaries" => BINARIES.to_h { |name| [name, binary_present?(name)] }, + "classes_baseline" => GADGET_CLASSES.to_h { |name| [name, constant_present?(name)] }, + "git_gadget" => git_gadget_state, + "safe_marshal" => safe_marshal_state, + "erb_guard" => erb_state +} + +puts JSON.generate(report)