False Order
CloudTrail and S3 version-history forensics unmask an attacker who impersonated an internal clerk's session name to forge a custody order.
CyberApocalypse2026 — Cloud — False Order
- Category: Cloud (AWS — S3 object versioning / IAM / CloudTrail forensics)
- Flag format: 12 graded answers submitted via the portal (no
HTB{...}string) - Target:
154.57.164.78:31188(AWS API endpoint) +154.57.164.78:30840(briefing dashboard) - Status: Solved. All 12 answers derived from two artifacts — S3 version history and a full local CloudTrail dump. Root cause identified (see [§3](#3-root-cause--rolesessionname-is-attacker-controlled)).
---
1. Challenge overview
> Caldrin Vowmark reaches an Ashguard checkpoint with a sealed order that tells Stormbound's soldiers to leave the east gate and report to Crownspire. The officer in charge believes the order came from Garran Voss, and he will move his soldiers as soon as the seal is checked. If they leave, Vaultrune can take the gate before help arrives. Caldrin knows Garran's orders carry small marks that copyists miss. He has to inspect the order, show the officer that it is false, and stop the unit from leaving before Vaultrune's soldiers reach the gate.
The prose maps directly onto the technical findings:
- "the officer in charge believes the order came from Garran Voss" → the audit log attributes the write to the wrong principal. This is the central trick, not flavour.
- "Garran's orders carry small marks that copyists miss" → an integrity field that was hand-edited rather than recomputed (
ledger_hash), plus an object-metadata mismatch (Content-Type). - "copyists" → the attacker identity is literally
seal-copyist-contractor. - "a sealed order … that tells soldiers to leave" → the tampered field is
custody_status: SEALED → RELEASED.
The briefing names the two artifacts outright: CloudTrail trail coalition-gate-audit-trail and versioned bucket ashguard-order-custody, starting at custody/east-gate-order.json, with the instruction to correlate version history with audit events. That is the intended methodology and it is the whole challenge.
Win condition
Twelve portal questions. Nine are single-field extractions; three (#1, #9, #11) require the log to be filtered on fields the naive query drops — see [§5.3](#53-error-bearing-events) and [§9](#9-lab-noise--false-positives).
---
2. Access layout
The briefing's "Clerk's Margin" letter supplies credentials and, critically, the correct port:
export AWS_ENDPOINT_URL=http://154.57.164.78:31188 # API port, NOT the briefing port
export AWS_DEFAULT_REGION=us-east-1
export AWS_ACCESS_KEY_ID=AKIAALOG8LKMYBRXR11F
export AWS_SECRET_ACCESS_KEY=<REDACTED>
unset AWS_SESSION_TOKEN
AWS_ENDPOINT_URL requires CLI ≥ v2.13; older builds silently ignore it and need --endpoint-url per invocation. Browsing the API port returns AccessDeniedException / MissingAuthentication — an unsigned request, i.e. the endpoint is healthy.
$ aws sts get-caller-identity
{ "UserId": "AIDA5AF1ROG7S48Z7ZC3",
"Account": "638291047582",
"Arn": "arn:aws:iam::638291047582:user/gate-investigator" }
2.1 Permission enumeration
An IAM policy is a whitelist, so denials define the intended path. Probing yields exactly three allows:
| | Action | Consequence |
|---|---|---|
| ❌ | s3:ListAllMyBuckets | bucket name must come from the briefing |
| ❌ | s3:GetBucketVersioning | versioning status must be inferred from output shape |
| ❌ | cloudtrail:DescribeTrails / GetTrailStatus / GetEventSelectors | trail config and data-event selectors unavailable |
| ✅ | s3:ListBucketVersions | full version + delete-marker history |
| ✅ | s3:GetObjectVersion | retrieval of any historical version |
| ✅ | cloudtrail:LookupEvents | the entire audit log |
⇒ Three primitives. Every answer must be reachable through them.
Note the error-string asymmetry, which matters for question 11: S3 returns AccessDenied; CloudTrail and STS return AccessDeniedException. Both forms appear in the same log.
---
3. Root cause — RoleSessionName is attacker-controlled
The write that swapped the order carries this principal:
arn:aws:sts::638291047582:assumed-role/ashguard-order-scanner/coalition-gate-clerk
^^^^^^^^^^^^^^^^^^^^^^ role name (fixed by the role)
^^^^^^^^^^^^^^^^^^^^ session name (chosen by CALLER)
sts:AssumeRole takes RoleSessionName as a free-form caller-supplied string. Nothing validates it against any identity. CloudTrail then surfaces that string in the summary Username field of lookup-events:
$ aws cloudtrail lookup-events --max-results 50 \
| jq -r '.Events[] | [.EventTime, .Username, .EventName] | @tsv'
...
coalition-gate-clerk PutObject <-- WRONG. This is attacker-supplied text.
⇒ Any SIEM rule, dashboard, or first-pass triage that keys on CloudTrail's summary Username is spoofable by anyone who can assume any role. The real principal lives inside .CloudTrailEvent, which is a JSON string requiring a second parse.
The second half of the root cause is the role itself. ashguard-order-scanner — semantically read-only by name — holds s3:PutObject and s3:DeleteObject, and its trust policy accepts an external contractor identity. The name documented an intent the policy contradicted.
---
4. Artifact 1 — S3 version history
4.1 Version listing
$ aws s3api list-object-versions --bucket ashguard-order-custody \
--prefix custody/east-gate-order.json \
| jq -r '.Versions[] | [.LastModified, .VersionId, .IsLatest, .ETag, .Size] | @tsv' | sort
11:29:49Z 921d9092-5f74-4806-8455-a4ab08519389 false "382c450d..." 429
11:29:56Z 940a521c-c41a-48e4-8ce7-24b86b8713d4 true "1be261e8..." 436
The same response carries a delete marker at 11:29:56Z (e52187d8-34ea-4a45-869c-b1482eab4f5c) — a tombstone, not a destruction. Its presence alongside a new version proves the sequence was DeleteObject then PutObject, not a plain overwrite. That distinction is question 12's premise.
4.2 Content diff
$ diff <(jq -S . order-921d9092....json) <(jq -S . order-940a521c....json)
| Field | Genuine (921d9092) | Forged (940a521c) |
|---|---|---|
| custody_status | SEALED | RELEASED |
| order_status | PENDING_APPROVAL | RELEASED |
| total_units | 1840 | 920 |
| witness_line | clerk attested before dawn watch | attestation waived, emergency writ WR-4412 |
| ledger_hash | sha256:4f8c…c6b5a4 | sha256:4f8c…c6b5a5 |
4.3 The mark the copyist missed
genuine: sha256:4f8c2a91…d7c6b5a4
forged: sha256:4f8c2a91…d7c6b5a5
^ one nibble
A SHA-256 recomputed over modified content differs in essentially every position — avalanche property. A single trailing-nibble delta is arithmetically impossible as a real rehash and is the signature of a hand-incremented placeholder.
⇒ The forger could not recompute the digest. [§5.3](#53-error-bearing-events) explains why: they never had read access to the original.
Object metadata carries a second discriminator:
921d9092 "ContentType": "application/octet-stream" <-- legitimate pipeline
940a521c "ContentType": "application/json" <-- SDK-guessed, different tool
---
5. Artifact 2 — CloudTrail correlation
5.1 Local dump
lookup-events caps --max-results at 50 (InvalidMaxResultsException above that) and paginates via NextToken:
token=""
while :; do
if [ -z "$token" ]; then out=$(aws cloudtrail lookup-events --max-results 50)
else out=$(aws cloudtrail lookup-events --max-results 50 --next-token "$token"); fi
echo "$out" | jq -r '.Events[].CloudTrailEvent' >> raw.txt
token=$(echo "$out" | jq -r '.NextToken // empty'); [ -z "$token" ] && break
done
jq -s 'sort_by(.eventTime)' raw.txt > events.json # 618 events
Note the double parse: .Events[].CloudTrailEvent is a string, so jq -r emits it, and a second jq -s slurps the emitted lines as objects.
5.2 Behavioural baseline
coalition-gate-clerk — the identity the log tries to blame — across 22–26 July:
| Property | Value |
|---|---|
| Source IP | 10.41.53.22, invariant across ~400 events |
| Credential | AKIAGVNHCSOYSJOURZ1U (long-lived, AKIA prefix) |
| Hours | 07:00–19:00 only |
| Actions | GetObject, HeadObject, ListObjectsV2, ListObjectVersions, GetBucketVersioning, ListBuckets |
| Writes | zero, in five days |
⇒ The baseline alone falsifies the attribution before any credential analysis.
5.3 Error-bearing events
The naive timeline query omits errorCode, which conceals both denied probes. Adding it is what corrects the narrative:
$ jq -r '.[] | select(.errorCode) | [.eventTime, (.userIdentity.arn//"-"), .eventName,
.errorCode, (.requestParameters.key//"-"), .sourceIPAddress] | @tsv' events.json
09:10:54Z …user/seal-copyist-contractor GetObject AccessDenied custody/east-gate-order.json 198.18.44.91
10:06:26Z …user/seal-copyist-contractor AssumeRole AccessDenied - 198.18.44.91
⇒ The direct read failed. The forgery was authored without ever seeing the genuine document, which is precisely why ledger_hash was faked rather than recomputed ([§4.3](#43-the-mark-the-copyist-missed)). The failed read is the causal explanation for the detectable artifact.
5.4 Role enumeration
$ jq -r '.[] | select(.eventName=="AssumeRole") | [.eventTime, .requestParameters.roleArn,
.requestParameters.roleSessionName, (.errorCode//"SUCCESS")] | @tsv' events.json
10:06:26Z …role/ashguard-order-auditor seal-copyist-session AccessDenied
11:50:21Z …role/ashguard-order-auditor coalition-gate-clerk AccessDenied
11:50:21Z …role/ashguard-order-scanner coalition-gate-clerk SUCCESS
Two observations:
1. Role-name guessing against a convention. ashguard-order-auditor was attempted twice before ashguard-order-scanner succeeded — the attacker was brute-forcing the ashguard-order-* namespace for a role whose trust policy would accept them.
2. The session name changes between attempts. seal-copyist-session at 10:06 → coalition-gate-clerk at 11:50. The first attempt made no attempt to hide. ⇒ The impersonation was a deliberate decision taken between those timestamps, recorded in a field the attacker chose themselves. This is the strongest available evidence of intent.
The failed attempt's errorMessage names the true principal in AWS's own words:
User: arn:aws:iam::638291047582:user/seal-copyist-contractor is not authorized to perform:
sts:AssumeRole on resource: arn:aws:iam::638291047582:role/ashguard-order-auditor
---
6. Full attack sequence
08:30:52 seal-copyist-contractor sts:GetCallerIdentity 198.18.44.91 # identity check
08:44:04 seal-copyist-contractor s3:ListBuckets 198.18.44.91
08:56:17 seal-copyist-contractor s3:ListObjectsV2 198.18.44.91
09:10:54 seal-copyist-contractor s3:GetObject AccessDenied # <-- direct read refused
09:32–09:48 s3:ListObjectsV2 x2 # continued recon
10:06:26 seal-copyist-contractor sts:AssumeRole AccessDenied # <-- ashguard-order-auditor,
# session "seal-copyist-session"
10:24–11:11 ListBuckets, ListObjectsV2 x3
[replayed compressed batch — millisecond ordering is authoritative, see §9]
.721 sts:AssumeRole -> ashguard-order-auditor as "coalition-gate-clerk" AccessDenied
.722 sts:AssumeRole -> ashguard-order-scanner as "coalition-gate-clerk" SUCCESS <-- pivot
.723 sts:GetCallerIdentity (assumed-role session, ASIAHZ37IB3ETA0EVKG4)
.724 s3:ListBucketVersions
.725 s3:DeleteObject -> delete marker e52187d8-…
.726 s3:PutObject -> x-amz-version-id: 940a521c-… <-- forged order lands
Payload delta: SEALED/PENDING_APPROVAL → RELEASED, garrison 1840 → 920, witness attestation waived under invented writ WR-4412, ledger_hash incremented by one nibble.
---
7. Distinguishing evidence — why not the clerk
Five properties the attacker could not forge, any one of which breaks the frame:
| Discriminator | Impersonated session | Real clerk |
|---|---|---|
| sourceIPAddress | 198.18.44.91 (reserved benchmarking range 198.18.0.0/15) | 10.41.53.22, invariant |
| Credential prefix | ASIAHZ37IB3ETA0EVKG4 — temporary STS | AKIAGVNHCSOYSJOURZ1U — long-lived |
| Immediate provenance | AssumeRole by seal-copyist-contractor ms earlier | n/a |
| Behaviour | write | zero writes in 5 days |
| sessionContext.attributes.mfaAuthenticated | false | n/a |
| userAgent | Boto3/1.29.7 … python#3.10.12 … linux | distinct from clerk's |
⇒ RoleSessionName controls only the display string. Every platform-recorded field contradicts it.
---
8. Answers
| # | Question | Answer |
|---|---|---|
| 1 | Last action from the internal gatehouse IP immediately before the attacker session | ListObjectsV2 — 10.41.53.22 @ 08:29:52.613Z (60 s prior) |
| 2 | First action from the attacker IP | GetCallerIdentity — 198.18.44.91 @ 08:30:52.236Z |
| 3 | S3 action explicitly denied before role assumption | GetObject |
| 4 | Full path of the tampered object | s3://ashguard-order-custody/custody/east-gate-order.json |
| 5 | Role assumed for the destructive session | arn:aws:iam::638291047582:role/ashguard-order-scanner |
| 6 | Full STS principal ARN on DeleteObject | arn:aws:sts::638291047582:assumed-role/ashguard-order-scanner/coalition-gate-clerk |
| 7 | Source IP of the AssumeRole + destructive calls | 198.18.44.91 |
| 8 | IAM username owning the long-lived creds used for AssumeRole | seal-copyist-contractor |
| 9 | Role the attacker failed to assume (name only) | ashguard-order-auditor |
| 10 | roleSessionName on the successful AssumeRole | coalition-gate-clerk |
| 11 | errorCode on the denied GetObject probe | AccessDenied |
| 12 | S3 action marking the forged upload after DeleteObject | PutObject |
---
9. Lab noise & false positives
Four classes of event in the 618 must be excluded; citing them costs answers.
root / 127.0.0.1 / key AKIA6FALSEORDER01 — the provisioning harness. The key encodes the challenge name. Sequence at 11:29:33:
StopLogging InternalFailure <-- did NOT succeed
DeleteTrail InternalFailure <-- did NOT succeed
CreateTrail (11:29:53)
StartLogging (11:29:54)
StopLogging + DeleteTrail is genuine anti-forensics and deserves the check — but both returned InternalFailure, and they precede object creation at 11:29:49. Nothing was disabled. The trailing ListAccessKeys / DeleteAccessKey / CreateUser / PutUserPolicy / CreateAccessKey burst at 11:50:21.74x is the harness minting the gate-investigator user.
root / 10.244.x.x — Kubernetes pod network; the briefing SPA fetching its own content. Unknown event names, no accessKeyId.
GetObject on Trinity.txt.bak (11:31:35, AccessDeniedException) — a flaws.cloud easter egg, unrelated.
gate-investigator @ 11:39–11:45 — the investigator's own footprint, including the AccessDenied responses catalogued in [§2.1](#21-permission-enumeration).
9.1 Timestamp fidelity
The attack appears twice: spread across 08:30–11:11, then replayed compressed into 11:50:21.716–.726. Identical actions and error codes. S3 LastModified says 11:29:49–11:29:56; the seeded CloudTrail burst says 11:50:21.
⇒ Absolute times in the compressed batch are synthetic. Use millisecond ordering for sequence only; use object LastModified and sessionContext.attributes.creationDate (11:29:56.182Z) for chronology.
---
10. Defences
| Control | Rationale |
|---|---|
| Least privilege matching role semantics | A role named *-scanner holding s3:PutObject + s3:DeleteObject is the entire vulnerability. Scanner needs GetObject + ListBucket. |
| Scoped trust policies | External contractor principals should not be assumable-into internal roles; where required, gate on sts:ExternalId, aws:SourceIp, aws:MultiFactorAuthPresent. |
| Never treat RoleSessionName as identity | Attacker-controlled string. Correlate on sourceIPAddress, accessKeyId, and sessionContext.sessionIssuer instead. |
| Alert on writes from unexpected sources | One anomalous IP against five days of invariant baseline. A single rule — any PutObject/DeleteObject on custody/* from outside 10.41.53.22 — catches this. |
| Enforce MFA on privileged role assumption | mfaAuthenticated: false on a session carrying delete rights. |
| Verify integrity fields automatically | An unverified ledger_hash is decoration. Any recomputation would have failed instantly. |
| S3 Object Lock / MFA Delete on custody prefixes | Versioning preserved the evidence, but did not prevent the tamper. |
---
Appendix A: query set
# identity (requires no permissions)
aws sts get-caller-identity
# full version history incl. delete markers
aws s3api list-object-versions --bucket ashguard-order-custody --prefix custody/
# fetch a specific historical version
aws s3api get-object --bucket <b> --key <k> --version-id <v> out.json
aws s3api head-object --bucket <b> --key <k> --version-id <v> # ETag, ContentType, metadata
# semantic diff of two versions (-S sorts keys, suppressing reorder noise)
diff <(jq -S . old.json) <(jq -S . new.json)
# real identity timeline — never the summary Username field
jq -r '.[] | [.eventTime, (.userIdentity.arn//"-"), .eventName, .sourceIPAddress,
(.userIdentity.accessKeyId//"-"), (.errorCode//"OK")] | @tsv' events.json
# all failures (intent evidence)
jq -r '.[] | select(.errorCode) | [.eventTime, .eventName, .errorCode,
(.requestParameters.key//"-")] | @tsv' events.json
# all mutations in the log
jq -r '.[] | select(.readOnly==false) | [.eventTime, .userIdentity.arn, .eventName] | @tsv' events.json
# per-identity behavioural profile
jq -r '.[] | [(.userIdentity.arn|split("/")|last), .sourceIPAddress, .eventName] | @tsv' \
events.json | sort | uniq -c | sort -rn
# role-assumption attempts with outcome
jq -r '.[] | select(.eventName=="AssumeRole") | [.eventTime, .requestParameters.roleArn,
.requestParameters.roleSessionName, (.errorCode//"SUCCESS")] | @tsv' events.json
Appendix B: key identifiers
| Name | Value |
|---|---|
| Account ID | 638291047582 |
| Bucket | ashguard-order-custody (versioning enabled) |
| Trail | coalition-gate-audit-trail |
| Tampered key | custody/east-gate-order.json |
| Genuine version | 921d9092-5f74-4806-8455-a4ab08519389 (429 B, octet-stream) |
| Forged version | 940a521c-c41a-48e4-8ce7-24b86b8713d4 (436 B, application/json) |
| Delete marker | e52187d8-34ea-4a45-869c-b1482eab4f5c |
| Over-permissioned role | arn:aws:iam::638291047582:role/ashguard-order-scanner |
| Failed target role | arn:aws:iam::638291047582:role/ashguard-order-auditor |
| Attacker principal | arn:aws:iam::638291047582:user/seal-copyist-contractor |
| Attacker long-lived key | AKIAYK9Y69YQBYNPBCE6 |
| Attacker STS session key | ASIAHZ37IB3ETA0EVKG4 |
| Impersonated session name | coalition-gate-clerk |
| First (unconcealed) session name | seal-copyist-session |
| Attacker source IP | 198.18.44.91 (198.18.0.0/15, reserved) |
| Clerk source IP | 10.41.53.22 |
| Clerk key | AKIAGVNHCSOYSJOURZ1U |
| Investigator principal | arn:aws:iam::638291047582:user/gate-investigator |
| Harness key (noise) | AKIA6FALSEORDER01 |
| Total CloudTrail events | 618 |
| Invented writ reference | WR-4412 |
Something wrong with this page?
Wrong details, a stolen writeup, or something that should not be published here — tell a moderator. This does not go to the author.