Cybersecurity-Projects/PROJECTS/beginner/deserialization-gadget-lab/scripts/target-gate.sh

197 lines
6.0 KiB
Bash
Executable File

#!/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
encode() {
docker run --rm --network none ruby:4.0-slim \
ruby -e "require \"base64\"; print Base64.strict_encode64(Marshal.dump($1))"
}
for root in '"plain string"' 'nil' '[1, 2]' '{ user: "x" }'; do
body_file="$(mktemp)"
code="$(curl -s -o "${body_file}" -w '%{http_code}' \
-H "Cookie: session_state=$(encode "${root}")" "${BASE}/render/safe")"
body="$(head -c 80 "${body_file}")"
rm -f "${body_file}"
echo " defended on root ${root} : HTTP ${code} ${body}"
if [[ "${code}" == "500" ]]; then
echo " FAIL the defence accepted this root and then crashed compiling it"
failures=$((failures + 1))
fi
done
echo
leak_file="$(mktemp)"
curl -s -o "${leak_file}" -H "Cookie: session_state=$(encode 'nil')" "${BASE}/render/safe"
curl -s -o "${leak_file}.v" -H "Cookie: session_state=$(encode 'nil')" "${BASE}/render"
if grep -qE "app\.rb|/app/lib|rube/marshal" "${leak_file}" "${leak_file}.v"; then
echo " FAIL an error response leaked source paths or source lines"
failures=$((failures + 1))
else
echo " PASS error responses leak no source path or source line"
fi
rm -f "${leak_file}" "${leak_file}.v"
echo
class_named() {
docker run --rm --network none ruby:4.0-slim ruby -e "
require \"base64\"
def sym(n) = \":\" + (n.bytesize + 5).chr + n
def str(s) = %q(\") + (s.bytesize + 5).chr + s
print Base64.strict_encode64($1)
"
}
for probe in 'Marshal.dump(Object.new)' '("\x04\x08C" + sym("String") + str("hi")).b'; do
named_body="$(curl -s -H "Cookie: session_state=$(class_named "${probe}")" "${BASE}/render/safe")"
echo " class-named stream : ${named_body}"
if [[ "${named_body}" == *"unapproved class"* ]]; then
echo " PASS refused on the class name itself, not on a parse error"
else
echo " FAIL PERMITTED_CLASS_NAMES admitted a class name, or something else rejected it first"
failures=$((failures + 1))
fi
done
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