Leading telecoms provider Forela uses AWS S3 as an essential part of their infrastructure. They can deploy applications quickly and do effective analytics on their sizable dataset thanks to it acting as both an application storage and a data lake storage.
Recently, a user reported an urgent issue to the helpdesk: an inability to access files within a designated S3 directory. This disruption has not only impeded critical operations but has also raised immediate security concerns.
The urgency of this situation demands a security-focused approach. Reports of a misconfigured S3 Bucket policy for the forela-fileshare bucket, resulting in unintended public access, highlight a potential security vulnerability that calls for immediate corrective measures. Consequently, a thorough investigation is paramount.
In order to rectify this situation, you are tasked to diligently identify who initiated the actions, how these actions were executed, and what specifically transpired with the S3 objects, leading to their inaccessibility. This comprehensive understanding is essential for a targeted and effective resolution.
Initiating with a comprehensive analysis of the AWS CloudTrail logs will provide a detailed record of all interactions with the affected S3 bucket. This step is pivotal in comprehending the entire extent of the incident and ensuring the necessary actions are taken to address it from a security perspective.
An attacker found a publicly readable S3 bucket and downloaded files containing IAM access keys without authenticating. They then used the leaked keys from an aws-cli client, probing EC2 and IAM with dozens of denied calls until one user was able to write an inline policy to itself. That policy granted full administrative rights. With admin access the attacker re-encrypted bucket objects under a KMS key, dropped a ransom note into the bucket, and minted a fresh access key on a second IAM user for persistent access. The helpdesk ticket about inaccessible files was the visible result: the data was readable only with a key the victims could not use.
evidence/nubilum_2/949622803460/CloudTrail/<region>/... → gzipped JSON, one folder per region. Merge every file into a single list sorted by eventTime before doing anything else, then query with jq or PythonTimestamp note: CloudTrail times are UTC in ISO format, which matches the
YYYY-MM-DDTHH:MM:SSZanswer format directly.
Start with the forela-fileshare S3 events. Unauthenticated GetObject calls against the key files, followed by authenticated API use from the same address with an aws-cli user agent identifying a Kali client, point at one external IP. That address is the attacker.
jq -r '.[] | select(.eventSource=="s3.amazonaws.com") | [.eventTime,.sourceIPAddress,.userIdentity.type,.userAgent,.eventName] | @tsv' all.json
Filter the attacker IP from Q1 to GetObject and take the earliest hit. The file is a CSV of EC2 read-only keys, and the request carries no credentials at all, which is what makes the bucket policy the entry point.
jq -r '.[] | select(.sourceIPAddress=="54.242.59.197" and .eventName=="GetObject") | [.eventTime,.requestParameters.key,.userIdentity.accountId] | @tsv' all.json | sort | head -1
Gotcha: The account ID field is not the bucket owner’s account. The request was anonymous, and
userIdentity.accountIdliterally holdsanonymous, which is what the answer wants.
Count distinct access key IDs that appear for the attacker IP and add the keys whose files were pulled from the bucket. The minimum is the number of keys the attacker demonstrably downloaded or used.
jq -r '.[] | select(.sourceIPAddress=="54.242.59.197") | .userIdentity.accessKeyId // empty' all.json | sort -u | wc -l
Gotcha: A first count of 6 was one short. Include every key the attacker downloaded or used, not just the ones that signed requests in a single view.
DescribeInstances events from the attacker IP carry the filter in requestParameters.filterSet. At 15:05:58Z the filter selects running instances only.
jq -r '.[] | select(.eventName=="DescribeInstances") | [.eventTime,(.requestParameters.filterSet|tostring)] | @tsv' all.json
Count attacker events with an AccessDenied error that occur before the first successful PutUserPolicy (Q7). The keyed calls account for 41 of them.
jq -r '.[] | select(.sourceIPAddress=="54.242.59.197" and (.errorCode // "" | test("AccessDenied|UnauthorizedOperation")) and .eventTime < "2023-11-02T15:21:32Z") | .eventName' all.json | wc -l
Gotcha: The count is 42, not 41. The extra one is the anonymous
ListObjectsagainstforela-storagethat was denied, which also counts as a failed discovery attempt.
The pivot from Q5 is the first successful IAM write after the denials: a PutUserPolicy where the caller and the target user are the same account. That user granted itself the policy.
jq -r '.[] | select(.eventName=="PutUserPolicy") | [.eventTime,.userIdentity.userName,.requestParameters.userName,.requestParameters.policyName] | @tsv' all.json
The event is the PutUserPolicy call from Q6, logged at 15:21:32Z. This one was reasoned rather than read off a dedicated artifact: the logs show the write succeeding right after the denied attempts, and the policy body in Q8 confirms it carries admin rights. The user could attach a policy to itself because it held iam:PutUserPolicy.
jq -r '.[] | select(.eventName=="PutUserPolicy") | [.eventTime,.eventName,.errorCode] | @tsv' all.json
requestParameters of the same PutUserPolicy event holds both the policy name and the JSON document. The statement allows every action on every resource, which is full administrator.
jq '.[] | select(.eventName=="PutUserPolicy") | .requestParameters' all.json
Gotcha: The answer format is
name,[policyStatement]with the statement copied as it appears in the log’srequestParameters.
With admin rights the attacker re-encrypted bucket objects with CopyObject with SSE-KMS. The KMS key ARN sits in the request parameters of those events, and it is a multi-Region key in us-east-1.
jq -r '.[] | select(.eventName=="CopyObject") | [.eventTime,.requestParameters["x-amz-server-side-encryption-aws-kms-key-id"]] | @tsv' all.json
A PutObject at 15:26:22Z from the attacker IP, right after the re-encryption, writes the ransom note.
jq -r '.[] | select(.eventName=="PutObject" and .sourceIPAddress=="54.242.59.197") | [.eventTime,.requestParameters.key] | @tsv' all.json
This is the helpdesk symptom. At 16:02:34Z forela-john called GetObject and got AccessDenied. The error message names the missing permission, kms:Decrypt, because the object is now encrypted under the attacker’s KMS key from Q9.
jq -r '.[] | select(.eventName=="GetObject" and .userIdentity.userName=="forela-john" and .errorCode=="AccessDenied") | [.eventTime,.errorMessage] | @tsv' all.json
Seconds after dropping the note (15:26:53Z), a CreateAccessKey event creates a new long-lived key on another IAM user. A key that the attacker generated survives the cleanup of the leaked ones.
jq -r '.[] | select(.eventName=="CreateAccessKey") | [.eventTime,.userIdentity.userName,.requestParameters.userName] | @tsv' all.json
| Phase | Action |
|---|---|
| Initial Access | Anonymous GetObject of an access-key CSV from a publicly readable bucket |
| Credential Access | Seven leaked access keys downloaded or used from an aws-cli client |
| Discovery | DescribeInstances filtered to running instances; dozens of denied discovery and IAM calls |
| Privilege Escalation | dev-policy-specialist writes an allow-all inline policy to itself with PutUserPolicy |
| Impact | Bucket objects rewritten with CopyObject under an SSE-KMS key |
| Impact | README2DECRYPT.txt uploaded to the bucket |
| Persistence | CreateAccessKey on forela-admin |
| Impact | forela-john denied kms:Decrypt on the encrypted objects |
| Timestamp (UTC) | Event |
|---|---|
| 2023-11-02 14:52:03 | First anonymous GetObject of prod-EC2-readonly_accessKeys.csv |
| 15:05:58 | DescribeInstances filtered on instance-state-name:running |
| 15:21:32 | PutUserPolicy grants the allow-all policy |
| 15:26:22 | README2DECRYPT.txt uploaded with PutObject |
| 15:26:53 | CreateAccessKey on forela-admin |
| 16:02:34 | forela-john denied kms:Decrypt on GetObject |
Times after the first row are taken from the same CloudTrail export; their date is not restated in the case notes.
| Type | Value |
|---|---|
| IP | 54.242.59[.]197 |
| User agent | aws-cli (Kali) |
| File | README2DECRYPT.txt |
| IAM policy | sbhyy79zky (inline, Action: *, Resource: *) |
| IAM user (escalated) | dev-policy-specialist |
| IAM user (new key) | forela-admin |
| KMS key ARN | arn:aws:kms:us-east-1:263954014653:key/mrk-85e24f85d964469cba9e4589335dd0f4 |
| Technique | ID | Description |
|---|---|---|
| Data from Cloud Storage | T1530 | Anonymous download of access-key files from the public bucket |
| Valid Accounts: Cloud Accounts | T1078.004 | Leaked IAM keys used from aws-cli |
| Cloud Infrastructure Discovery | T1580 | DescribeInstances with a running-state filter |
| Account Manipulation: Additional Cloud Roles | T1098.003 | Allow-all inline policy added with PutUserPolicy |
| Data Encrypted for Impact | T1486 | Objects re-encrypted under an attacker-chosen KMS key, ransom note dropped |
| Account Manipulation: Additional Cloud Credentials | T1098.001 | New access key created for forela-admin |
A public bucket held the keys to the account. Credential files should never sit in S3, and forela-fileshare should have had Block Public Access on. The whole intrusion began with an unauthenticated GetObject, which S3 data events log and which would have been a clear alert.
Dozens of AccessDenied events from one key is a signal, not noise. The attacker burned 41 keyed calls guessing at permissions before anything worked. An alert on a burst of AccessDenied or UnauthorizedOperation from a single principal or IP would have fired well before the escalation.
Users should not be able to write policy to themselves. iam:PutUserPolicy on its own principal turned a standard user into an administrator in one call. Deny self-targeted IAM writes, set permission boundaries, and alert on every PutUserPolicy, AttachUserPolicy and CreateAccessKey.
Ransomware in the cloud is a key-management event. Nothing was deleted: CopyObject with a KMS key the victims could not use was enough. Restrict who can use kms:Encrypt and kms:GenerateDataKey on new keys, enable object versioning so the original versions survive, and watch for unexpected KMS key IDs in S3 write requests.
An anonymous download from one misconfigured bucket gave the attacker the keys, a self-granted policy gave them admin, and a KMS key they controlled locked the data away, all within roughly an hour of the first request. CloudTrail recorded every step, and the missed signal was the wall of denied calls before the escalation.