Commit Graph

7 Commits

Author SHA1 Message Date
CarterPerez-dev 8d7b114fb9 fix(rube): clear the entire S2 backlog tier - a non-answer is never an answer
B17 through B30, fourteen items. Every one reproduced before it was touched
and mutation-proven after. 194 tests from 119, rubocop 903 offenses to 0,
all six gate stages green.

One rule runs through all of it: nothing may present a guess, a default, or
a swallowed error as a verdict.

Decision states (B19). Under POLICY_OBSERVE_AND_LOG a payload carrying a live
Gem::Requirement#marshal_load snapshot reported accepted? true AND rejected?
false, so both obvious caller shapes loaded it. The predicate pair could not
express the third outcome, so there was no safe branch to pick - the prior
note claiming target/app.rb sidestepped this by branching on rejected? was
wrong, and both forms were byte-for-byte equivalent in outcome. accepted?,
rejected? and would_reject? are removed rather than redefined, so copying
`if d.accepted?` now raises NoMethodError instead of silently changing
meaning. One state validated in the constructor, three exclusive predicates,
and proceed? is the only one that may gate a Marshal.load. Observe-and-log
stays non-blocking and still hands back its snapshot; the monitoring caller
writes `proceed? || observed?` and names the state out loud.

Scanner error accounting (B18). Five rescues returned nil, [] or false and
told nobody. They now record site, subject and error class, and Report
exposes suppressed_count, suppressions_by_site, complete? and
candidates_lost?. Wiring the counter immediately surfaced 3 suppressions on
a stock image that had always been invisible: <internal:symbol>,
<internal:pathname_builtin> and <internal:ractor> all fail Prism.parse_file
with ENOENT because Ruby hands out those paths but they are not files.

The fourth state (B29). Those 3 suppressions were also 7 wrong answers -
candidates scored "does not touch state", indistinguishable from analysed
and inert, and 4 zero-arity ungated ones silently dropped from reachable.
touches_state is now four-state. A source that was given and could not be
parsed fails OPEN and stays reachable, because a scanner that discards what
it failed to analyse is the exact failure mode B18 names. A C-defined method
with no Ruby source at all is reported as unanalysable instead: 132 of 173
candidates, and failing open there would take reachable from 25 to 74 of 165
ungated and stop the filter filtering. Report#unanalysable and
#fully_analysed? state the real coverage - 33 of 173 - rather than implying
the filter saw everything. The 5 recovered candidates were verified by
executing them, not by reading source we could not read; one of them,
ERB::Compiler::PercentLine#to_s, is an alias of an attr_reader, which is a
second and distinct analysis gap.

Reason escaping (B20). Reject reasons interpolated raw attacker bytes into a
caller-supplied reporter. A class name carrying CR, LF, ESC and NUL turned
one reporter call into three log lines, the middle one forging a successful
authentication. All three interpolation sites now truncate at 96 bytes and
inspect the binary form, so no byte below 0x20 survives and the value is
quote-delimited.

Target hardening (B21, B22). The defended endpoint returned HTTP 500 with a
source line for three roots the detector had just accepted, leaking paths the
same way the B3 mutant did. show_exceptions is off, the shape is checked, and
the gate now greps every error body for source paths. ALLOWED_CLASSES could
never match anything, measured: a benign session cookie carries zero class
names. It is PERMITTED_CLASS_NAMES = [] now, which is what the app actually
requires and is strictly tighter - the old list admitted a C-wrapped String,
and under a mutant restoring it only the new shape check stopped that payload.

Gated agreement (B23). GATED_SINK_TAGS omitted TAG_DATA while the scanner
listed _load_data. The scanner was right, and this is now execution evidence
rather than a reading of marshal.c: a hand-built d stream naming Thread::Mutex,
a real C-level T_DATA, raises TypeError naming the missing _load_data, while
the same stream naming String dies earlier at "dump format error" - which is
why the previous attempt could not see it. A test compares both definitions
directly so they cannot drift again.

Float fidelity (B17). read_float returned nil for seven body forms
Marshal.load accepts, two more than the finding listed. Ruby uses its own
ruby_strtod, so "INF" is 0.0 while "inf" is Infinity, and String#to_f turns
out to be that same function. Ruby's legacy binary mantissa is NOT decoded:
a model fitted to four oracle points passed a 25-case table and then failed
930 of 5000 randomised cases, and marshal.c is not available in these images.
Since 0 of 209 Marshal.dump outputs contain a NUL, no living Ruby emits that
form, so the parser records the strtod prefix and flags Node#undecoded_tail
instead. A plausible wrong number is worse than a labelled non-answer.
Final differential: 2919 agreed exactly, 2081 flagged, 0 claimed-and-wrong.

Hygiene (B24, B25, B27, B28). PRIMITIVE_CLASS_NAMES and NAMESPACE_SEPARATOR
had one reference each, their own definition. BIGNUM_SIGN_POSITIVE is live
and stays. The width > FIXNUM_MAX_WIDTH guard is unreachable for all 256
possible marker bytes, checked exhaustively, and raised the wrong error class;
it is replaced by a test that derives widths from real Marshal.dump output.
The symlink and object-link bounds checks no longer lean on negative-index
wraparound. The parse graph is sealed before it is returned - every node, its
collections and its scalars frozen - and the whole suite stayed green first
try, which proves nothing downstream was mutating it. exploit-gate.sh pins
both sides now, 4.0.2-slim erb 6.0.1 FIRED against 4.0.6-slim erb 6.0.1.1
BLOCKED; the finding's claim about detector-gate.sh was wrong, it never had
a patched side.

Lint (B30). just lint used a --network none runner, so gem install could
never reach RubyGems, the && short-circuited, and the recipe exited 2 while
printing absolutely nothing. That is the fourth instance of a dropped return
value hiding a failure in this project. It is loud now. The config had never
been validated against a real run: 903 offenses, dominated by a quote style
the codebase does not use. frozen_string_literal was verified safe by running
the whole suite under RUBYOPT=--enable-frozen-string-literal BEFORE the change,
so ~247 offenses were retired by fixing code rather than silencing a cop.
Every remaining disabled cop carries a reason.
2026-07-29 14:35:48 -04:00
CarterPerez-dev 582e032cc0 fix(rube): clear the entire S1 backlog tier - depth, budgets, gates, fidelity
Every item contracted to clear before M7 is closed. 151 tests from 119,
58 corpus cases from 48, all six gates green.

Depth accounting (B3, B4). TAG_IVAR charged no depth at all, so an I-chain
of any length parsed under any ceiling. Proven end to end against a rebuilt
target image: a 12,936-byte cookie returned HTTP 500 with a SystemStackError
that no rescue StreamError can catch, and a 724,287-byte response body
leaking absolute container paths for every file in lib/. Fixed, the same
cookie returns 400 DepthLimitError, and so does a 53,340-byte one.
read_userdef also hard-coded a depth of 1 for its class-name slot.

Budget axes (B12, B13, B14). Bignum magnitude bypassed the scalar budget
entirely and the sign byte accepted anything as positive where Marshal.load
raises ArgumentError. Added max_symbol_references, max_symbol_name_bytes,
max_class_name_bytes, max_instance_variables and max_struct_members.
Parser.new now enforces Limits.new instead of resolving to an unbounded
config; Limits.permissive became a class method.

Hash-key dispatch (B11). Nothing rejected an allowlisted class used as a
hash KEY, where #hash and #eql? run during load before any allowlist can
act. Measured against real Marshal.load: a key dispatches iff it carries a
class name and its underlying value is not a T_STRING. So TAG_REGEXP is not
a key-position risk and TAG_USERCLASS only conditionally - rejecting either
outright would have been a false positive. No opt-out allowlist was added,
because the dispatch happens before any check could run.

Fidelity (B15, B16, B9). The parser already matched Marshal.load on header
versions, so the 4.8 contradiction was resolved by giving the detector the
policy check and leaving the forensic parser permissive. Class-name slots
now accept only a symbol, an ivar-wrapped symbol, or a symlink. Wrapper
tags C and e no longer take an object-table slot, which Ruby does not give
them - link index 3 resolved to "bbb" for us and "ccc" for Ruby.

Gate soundness (B6, B7). Three discarded check() return values now register
as failures; section 6 no longer reports a vacuous 0/0; section 7 requires
reachable > 0, and prism absence is a named failure rather than a silent
zero. version-matrix.sh exits non-zero when any image produces no probe
result. control_check.rb no longer pulls in minitest, which was printing
"0 runs, 0 assertions" directly under ALL CONTROLS PASSED. Rewrote the
vacuous tests: the regexp options byte had zero minitest coverage and its
mutant survived the whole suite, and read_count's negative guard was
alibied by take's own guard.

The target app (B5) lost its hand-rolled copy of the sink-plus-allowlist
policy and now runs one BoundaryDetector with real limits, branching on
rejected? rather than accepted?.

Everything here is mutation-proven. Notable misses that mutation caught:
B14 had no test at all until reverting it stayed green, and a struct-member
test was vacuous on the first attempt because struct member names are always
symbols.

lib/rube/marshal/parser.rb carries eight backlog items at once and cannot be
split without interactive hunk staging, so this is one commit rather than
eight.
2026-07-29 05:12:25 -04:00
CarterPerez-dev b6b5c03d74 feat(rube): M6 boundary detector, implemented against criteria written before the code
Implements the detector Codex specified while it had no implementation to look
at, so neither model defined and graded the same thing.

Parser-level budgets rather than post-parse checks. Codex's architectural point
was that inspecting after parsing is too late because the allocation already
happened, so Limits and Budget enforce byte size, depth, node count, registered
objects, symbol definitions, collection entries, scalar bytes and object links
DURING recursive descent. The size ceiling is checked before the parser is
constructed and non-String input is rejected without ever calling to_s.

Three policies, STRICT_ALLOWLIST as the default, on the reasoning that people
keep defaults far longer than they intend. No enforcing mode accepts an
allowed_sinks option, because permitting a class-and-sink pair still authorizes
a callback during load. OBSERVE_AND_LOG refuses to construct without a
reporter. Allowlisting a class does NOT exempt its sinks, and that is a test.

No method is named safe?, trusted?, sanitized? or safe_load, and a test asserts
their absence. Those names claim a guarantee this cannot make.

The gate demonstrates the documented bypass rather than asserting it. Under
DENY_SINKS_ONLY the detector ACCEPTS the CVE-2026-41316 payload, and the gate
then loads that accepted snapshot on vulnerable erb and confirms the canary
fires. Our own detector, in a shipped mode, admits a payload that achieves code
execution. That is the limitation notice being true rather than decorative, and
if it ever stops being demonstrable the gate fails.

The notice ships verbatim and names the bypass concretely: a payload carrying
no sink tag can still reach dangerous code, the published chain produces zero
sink tags because ERB defines no marshal_load, and an application that
allowlists ERB will accept it.

106 tests, 235 assertions across four suites. Five gates: check, matrix,
exploit, detector, target.
2026-07-26 11:36:43 -04:00
CarterPerez-dev 21ecc0eece feat(rube): M5 vulnerable target - end-to-end RCE over HTTP, with the defense beside it
Sinatra on webrick in a container, storing session state as a base64 Marshal
blob in a cookie. Three endpoints: /render deserializes and compiles the
session template, /render/safe inspects the stream first, /canary reports
execution. Runs read-only, unprivileged, with a 1MB noexec tmpfs, on a high
configurable host port.

Gate proves three things and the third is what stops the defense being a brick:

  PASS  HTTP request achieved code execution through Marshal.load
  PASS  defended endpoint rejected the identical payload
  PASS  defended endpoint still serves a legitimate session

Two findings that change the defensive design.

Sink tags do not catch this chain. The working payload produces ZERO sink-tag
hits. ERB defines no marshal_load, so it serializes as a plain object with
instance variables and carries no u, U or d tag. The defended endpoint rejected
it on the class allowlist, and an application that allowlisted ERB as a
legitimate template class would have passed it through untouched. A gadget does
not need a marshal_load hook, it needs an object whose ivars the application
later feeds to a dangerous method. The dangerous call site lives in the
application, not in the serialized class. Any policy treating absence of sink
tags as safe is defeated by this exact public payload.

A legitimately initialized ERB cannot be serialized at all. @_init holds
self.class.singleton_class and Marshal raises TypeError: singleton class can't
be dumped. So the guard is not a flag an attacker might satisfy, it is anchored
to a value the serializer physically cannot reproduce. Any ERB an attacker can
serialize necessarily lacks a valid @_init. The generalized pattern for learn/:
do not validate the untrusted object, anchor trust to something unreachable
through the channel.

Also fixes a gate that skipped a control silently. The benign-session check
produced no output because POST with no body returns WEBrick LengthRequired,
and the script treated an empty result as nothing to test rather than as a
failure. It now fails loudly.

70 tests, 151 assertions, 0 failures. Target app excluded from the gem
manifest, verified at 0 files.
2026-07-26 10:17:23 -04:00
CarterPerez-dev bd89c6704a feat(rube): M4 payload builder - working CVE-2026-41316 chain with version-predicted gate
Chain registry modelled on PHPGGC: the class is the chain identity, metadata
carries the CVE and its affected version ranges, generate returns an object
rather than bytes, and serialization is a separate step.

Ships the ErbDefMethod chain for CVE-2026-41316. Ruby 2.7.0 added an @_init
guard to stop Marshal.load code execution on ERB objects, and def_method never
checked it. def_module and def_class delegate to def_method, so the single
missing check exposed all three entry points for six years.

The payload is an ERB built by allocate with @src, @filename and @lineno set
and @_init deliberately absent. @src opens with a comment line and a bare end
so that the def wrapper def_method injects is closed before the payload runs,
which puts execution at eval time rather than at call time.

Gate proves both halves and neither alone is sufficient:

  4.0.2-slim  erb=6.0.1    outcome=FIRED    predicted=FIRED
  4.0-slim    erb=6.0.1.1  outcome=BLOCKED  predicted=BLOCKED

The prediction column is the load-bearing one. affects? evaluates the CVE
ranges encoded in the chain metadata against the erb version present in the
image, before the payload runs. Observed behaviour matched on both, so the
registry is making falsifiable claims rather than carrying documentation.

Exploit containers run with no network, a read-only root filesystem, a 1MB
noexec tmpfs and an unprivileged user. The parser also inspects the payload
and reports ERB without deserializing it, so the offensive and defensive
halves meet on the same artifact.

70 tests, 151 assertions across four suites.
2026-07-26 10:07:16 -04:00
CarterPerez-dev 60375675fd refactor(rube): strip narration from control and matrix output
Explanatory prose in puts statements is commentary living in code. Removed
from control_check.rb and render_matrix.rb, leaving facts and verdicts.

Controls now report as a uniform PASS/FAIL table with a single exit status,
which also makes them usable as a gate rather than something a human reads.

Records the resolution on the two third-party findings: both dropped, no
disclosure, no further investigation. Neither was independently verified, no
obligation attaches to unpublished observations, and the lab has a stronger
flagship in CVE-2026-41316. Kept as unverified leads in gitignored docs.
2026-07-26 10:05:01 -04:00
CarterPerez-dev a4ca6760d1 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.
2026-07-26 09:35:53 -04:00