# 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: ```bash 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= 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. ```console $ 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`: ```console $ 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 ```console $ 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 ```console $ 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`: ```bash 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: ```console $ 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 ```console $ 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 ```bash # 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 --key --version-id out.json aws s3api head-object --bucket --key --version-id # 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` |