← mohdarif.com · All notes
Cloud Security · Incident Response

Investigating and Containing Compromised AWS STS Credentials Across Accounts

A worked incident: trace a bulk S3 download back to a session using one account's logs, then stop it without taking down the services that share the role.

An alert says a large number of objects were downloaded from a sensitive S3 bucket. The activity used a role assumed from another AWS account, and the temporary credentials are still valid. This post covers two questions an incident commander will ask: what happened, and can we stop it right now without causing an outage? The answers depend on knowing exactly what AWS logs prove and what IAM changes actually affect.

1. The scenario

ACCOUNT A: NOT VISIBLEEC2 instancerole: AppRoleIts logs and network data arenot available to us here.ACCOUNT B: WE HAVE CLOUDTRAILSTSAssumeRoleProductionDataReaderalso used by 5 servicesS3 bucketsensitive-data1session credentials2 GetObjectAttackerholds stolen credentialssame credentials, used from elsewhere?E1E2E1, E2 = CloudTrail events in Account B: the AssumeRole call, and the S3 data events.
Figure 1. The scenario. Everything in Account B is visible. Account A, and what the attacker did with the credentials, must be inferred from what Account B records.

2. Evidence: what logs prove, suggest and leave unknown

Keep these three apart in everything you write down. Mixing them up is how incident reports turn into guesses.

LevelExamples here
ProvesA specific STS session made specific API calls, at specific times, from a specific source IP and user agent.
SuggestsWhere the activity came from (a network, a tool such as the AWS CLI or a Python SDK), and which workload owned the credentials.
UnknownWhich process or person used the credentials, whether they were stolen or misused by an insider, and what else the attacker did outside the logs you can see.

3. Investigation, step by step

Start from what you know for sure (the suspicious S3 activity) and work backward to the identity. Do not start by searching for AssumeRole and hoping.

Step 0: Check that the evidence exists

CloudTrail records S3 object-level actions such as GetObject only if data events are enabled for the bucket. They are off by default and billed separately, and you select each resource type on its own (general purpose buckets and S3 directory buckets use different types). Without them you can see who assumed the role, but not which objects were read. Alternatives are S3 server access logs (if enabled) and GuardDuty S3 findings. Find out in the first minutes what you have, because it decides how well you can measure the damage.

Step 1: Read the S3 events

Look at the GetObject events for the bucket in the alert window and pull out the session identity:

FieldWhat it tells you
userIdentity.type, arnAn assumed-role session: arn:aws:sts::<account>:assumed-role/ProductionDataReader/<session-name>
userIdentity.accessKeyIdThe temporary access key (starts with ASIA). This is the key you will follow. AWS notes it can be missing or empty in some events, so also record the fields below.
userIdentity.principalId, sessionContextThe session ID (AROA...:session-name), the role that was assumed, when the credentials were issued (creationDate), and sourceIdentity if one was set
sourceIPAddress, userAgentWhere the request came from and what tool made it
requestParameters (bucket, key)Which objects were read
eventTimeWhen it started, how fast, and whether it is still going
-- Athena, table partitioned by region, year, month, day (adjust names to your table)
SELECT eventtime, sourceipaddress, useragent,
       useridentity.accesskeyid,
       json_extract_scalar(requestparameters, '$.key') AS object_key
FROM cloudtrail_logs
WHERE eventsource = 's3.amazonaws.com'
  AND eventname = 'GetObject'
  AND useridentity.sessioncontext.sessionissuer.arn LIKE '%/ProductionDataReader'
  AND region = 'us-east-1' AND year = '2026' AND month = '11' AND day = '04'
  AND eventtime > '2026-11-04T17:00:00Z'
ORDER BY eventtime;

The table that the CloudTrail console creates for you is not partitioned, so every query scans all the log files. For incident work, use a partitioned table (region, year, month and day, with the partitions loaded) or partition projection, and always filter on those columns. CloudTrail Lake is another option. Otherwise queries are slow and costly when you need answers fast.

Group the results by accessKeyId and sourceIPAddress. Five legitimate services and one attacker will usually form separate groups. The suspicious one is the key with the unusual IP, user agent, volume or object pattern.

Step 2: Link the key to its AssumeRole event

Now find the STS call that created that key. In Account B, the AssumeRole event includes:

FieldUse
responseElements.credentials.accessKeyIdThe link. Match it to the accessKeyId in the S3 events. This is exact, unlike matching by time or IP.
responseElements.assumedRoleUserThe assumed-role ARN and ID (AROA...:session-name). That ID is the value aws:userid takes for this session.
userIdentity (the caller)In the target account this entry is minimal: type AWSAccount, the calling account ID and a principal ID. The full caller ARN and access key are recorded in the calling account's own log.
requestParameters.roleArn, roleSessionNameWhich role, and the session name the caller chose
sourceIdentity (if used)A name that stays attached to the session, set at assume time
sourceIPAddress, userAgentNetwork origin and tool for the assume call
responseElements.credentials.expirationWhen the credentials die. Tells you how long you have.

If the key ID is missing from the S3 events, link on the session ID (principalId) and match creationDate to the AssumeRole event time. Do not link events by IP address or timestamp alone. Several sessions can share a NAT gateway, and many sessions can start in the same minute. The access key ID is the key.

Two things to know about where the STS events are. Calls to the global STS endpoint (sts.amazonaws.com) are logged in the us-east-1 region, while calls to a regional STS endpoint are logged in that region. If you do not see the AssumeRole event where you expect it, check us-east-1 and use a multi-region trail or CloudTrail Lake. Also, CloudTrail does not log denied cross-account AssumeRole requests in the target account, so failed attempts against your role will not appear in Account B.

Step 3: Work out who called, and what that does and does not prove

In Account B, the caller appears as the source account (Account A) and a principal ID. For a role session, the principal ID normally ends with the session name, and for EC2 instance profile credentials the session name is commonly the instance ID (I did not find this stated in AWS's documentation, so confirm it in your own logs). So it may point to the instance whose credentials were used. Check the real value in your logs, because the full caller ARN is only in Account A's own CloudTrail. Treat it as a strong lead, not a conclusion.

It is still not proof of who acted. Credentials can be copied off an instance and used from anywhere. Compare the sourceIPAddress in the assume call with the instance's known public or NAT address (from Account A):

An IP address is a network location, not an identity. It can belong to a NAT gateway, proxy, VPN or other shared infrastructure.

Step 4: Scope everything that key did

Search all events for that access key ID, in all services and regions, not only S3:

Write down the object list, count and bytes read. Legal and privacy teams will need the exact scope, and it is only as good as the logging you confirmed in Step 0.

Can Account B alone identify the instance or the attacker?

Partly. Account B can tell you the calling account, a principal ID that may include the session name (often the instance ID), the source IP and the tool. It cannot tell you what ran on the instance, who the attacker is, or how they got the credentials. For that you need Account A: its CloudTrail, GuardDuty findings, VPC flow logs, EC2 inventory and logs from the host itself.

4. Containment: stop it without an outage

The credentials still have 45 minutes left and five legitimate services use the same role. First, the facts that decide what works.

Changing the trust policy does not stop existing sessions

The trust policy controls who can assume the role. Tightening it blocks new assumptions only. Credentials already issued keep working until they expire. That is 1 hour by default, up to 12 hours if the role's maximum session duration allows, and role chaining caps a session at 1 hour. In this scenario an instance role is calling AssumeRole, which is role chaining, so the session in Account B lasts at most 1 hour. Tightening the trust policy is worth doing to stop the attacker getting fresh credentials, but it does nothing about the session they already hold.

Your options

MechanismWhat it doesTrade-off
Change the trust policyStops new role assumptionsExisting credentials keep working
Targeted explicit denyBlocks one session using an identity conditionNeeds an attribute that uniquely identifies the bad session
Deny by aws:TokenIssueTimeBlocks every session issued before a cutoff timeAlso blocks legitimate sessions issued before the cutoff until they refresh
S3 bucket policy explicit denyProtects the data immediately, whatever the roleCan block real applications if too broad. Keep an admin exception.
Network conditions (IP, VPC endpoint)Limits requests to approved network pathsFails if the attacker shares an approved path
Delete the roleRemoves the accessCauses the outage you were trying to avoid, and loses configuration you need

Option 1: Deny the specific session

If the bad session can be told apart from the good ones, deny just that. For an assumed-role session, aws:userid has the form <role-id>:<session-name>, so a unique session name works. aws:SourceIdentity and session tags can work too, if you set them up beforehand.

{
  "Effect": "Deny",
  "Action": "*",
  "Resource": "*",
  "Condition": {
    "StringLike": { "aws:userid": "AROAEXAMPLEROLEID:suspicious-session-name" }
  }
}

This only works if the attribute is genuinely unique. The caller chooses the session name, and it can change when roles are chained. If legitimate services use the same name (for example a default like app), a deny on it would cut them off as well. Check the real values in CloudTrail before relying on this.

Tip: the exact value to deny is already in your evidence. Copy userIdentity.principalId from the suspicious S3 event. It has the form AROA...:session-name, where AROA... is the role's unique ID, not its name. It also appears as assumedRoleUser.assumedRoleId in the AssumeRole event. You can look up the role ID with aws iam get-role --role-name ProductionDataReader --query Role.RoleId.

This does not stop the attacker from getting a new session. The caller chooses the session name (2 to 64 characters). If they still hold valid credentials for the calling role, they can assume the role again under a different name, and this deny will not match. Pair it with a change to the role's trust policy that blocks the compromised caller, and with isolating the host (section 5).

Option 2: Deny everything issued before a cutoff

If you cannot isolate the session, deny all sessions issued before a chosen time. The condition key aws:TokenIssueTime records when the temporary credentials were created.

{
  "Effect": "Deny",
  "Action": "s3:GetObject",
  "Resource": "arn:aws:s3:::sensitive-data/*",
  "Condition": {
    "DateLessThan": { "aws:TokenIssueTime": "2026-11-04T18:00:00Z" }
  }
}
CUTOFF 18:00 UTC Attacker17:30 Service A17:45 Service B18:15 Issued before the cutoff: denied Issued after: not covered by this deny 17:0017:3018:0018:3019:00 The deny blocks any request signed with credentials issued before 18:00. The attacker is blocked, and so is Service A, even though it is legitimate. Service A works again once it gets fresh credentials.
Figure 2. A time-based deny. It cannot tell attacker from legitimate sessions, only old from new, so legitimate services must refresh their credentials.

Walk through three sessions with a cutoff at 18:00 UTC:

SessionIssuedResult
Attacker17:30Denied. Issued before the cutoff.
Service A17:45Denied. Issued before the cutoff, even though it is legitimate.
Service B18:15Not affected by this statement. It still needs permission from the rest of the policies.

Three limits you must state out loud:

Use an explicit deny, not just a removal of permissions. If a bucket policy also allows the role, removing the role's own permissions does not stop access. An explicit deny wins over any allow.

The safe order for the legitimate services

  1. List the workloads that use the role and how each gets its credentials.
  2. Have them obtain fresh sessions after the cutoff: through their normal credential refresh, or a controlled restart where needed. People using the AWS CLI may need to clear its credential cache (~/.aws/cli/cache).
  3. Verify in CloudTrail that new AssumeRole events appeared with creation times after the cutoff, and check application health.
  4. Apply the deny. AWS says policy updates can take a few minutes to take effect, so do not assume it is instant.
  5. Confirm the attacker's access key now gets AccessDenied in CloudTrail and the services are still healthy.

If data is leaving right now, containment comes first. Apply the deny, or restrict the bucket, immediately, and accept a short, planned disruption. Agree this with the incident commander and the service owners. A disruption-free rollout is not worth more confidential data being downloaded.

The fast, narrow option: protect the bucket

An explicit deny in the bucket policy protects the data at once and does not depend on the role. For example, deny GetObject except for approved roles and expected network paths, and keep an exception for incident responders so you do not lock yourself out. It buys time while you work out the precise session deny.

What about IP and network restrictions?

They can help as an extra layer, but do not rely on them:

If you cannot tell the attacker from legitimate sessions

Then no precise deny exists. Stop the exfiltration first (bucket-level deny or the cutoff deny), accept a controlled disruption, and rebuild the legitimate services' access in the right order afterwards.

5. Contain the source in Account A

You cannot see Account A, but you can ask its owners to act:

6. Make the next incident easier

7. Briefing the incident commander

Use this order: decision, mechanism, verification, impact. For example:

"I would deny the compromised session in Account B now. I identified it from the S3 events by its access key and linked it to the AssumeRole call. Its session name is shared with a legitimate service, so I can't deny it individually. Instead I'll deny all sessions issued before 18:00, which also blocks the five services until they refresh. They need to restart or refresh their credentials, which the owners can do in about ten minutes. Because data is still leaving, I recommend applying the deny first and accepting that short disruption. Afterwards I'll confirm the attacker's key gets AccessDenied and that the services are healthy. In parallel, Account A's owners should isolate the instance and preserve its disk."

8. Things that sound right but are not

What you hearWhy it is not enough
"Update the trust policy to stop them."It only blocks new assumptions. Existing sessions keep working.
"Rotate the role."A role has no password to rotate. You can cut off existing sessions or force new ones.
"Block the attacker's IP."They can change network. The IP may also be shared with legitimate traffic.
"That IP is our EC2 instance, so the instance did it."An IP is a location. Credentials can be used from elsewhere, and addresses are often shared.
"Delete the role."It causes the outage and removes evidence of how it was configured.
"Our alert will tell us if it happens again."Detection does not undo a download that already happened.

Summary

References