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.
This commit is contained in:
CarterPerez-dev 2026-07-26 09:35:53 -04:00
parent f5196252e9
commit a4ca6760d1
5 changed files with 272 additions and 1 deletions

View File

@ -24,3 +24,4 @@ Gemfile.lock
# Local env
.env
tmp/

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)