Commit Graph

6 Commits

Author SHA1 Message Date
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