This issue has been assessed as high severity. Review affected configurations immediately.
AWS multi-account architectures are a security best practice. Separate accounts for production, staging, logging, security tooling, and shared services limit the blast radius of any single compromise. But the trust relationships that make those accounts work together — the role assumption chains that let workloads and users access resources across account boundaries — are also the paths an attacker walks after their initial foothold.
A developer whose credentials are compromised in a dev account may have assume-role permissions into staging or even prod. A CI/CD pipeline runner in a tooling account may have deployment rights across the entire organisation. Understanding where these trust relationships exist, and where they’re over-permissive, is one of the most important things a cloud security team can do.
How Cross-Account Role Assumption Works
The standard mechanism is sts:AssumeRole. A principal (user, role, or service) in Account A calls sts:AssumeRole specifying a role ARN in Account B. Account B’s role must have a trust policy that explicitly permits Account A’s principal to assume it. On success, STS returns temporary credentials (access key, secret key, session token) scoped to Account B’s role.
# Attacker compromises IAM user in dev account (111111111111)
# and discovers a cross-account role in prod (999999999999)
aws sts assume-role \
--role-arn arn:aws:iam::999999999999:prod-admin \
--role-session-name "totally-legit-pipeline" \
--profile compromised-dev-user
# Returns: AccessKeyId, SecretAccessKey, SessionToken for prod-admin role
The new credentials are then exported as environment variables or written to ~/.aws/credentials to access prod resources.
The Attack Chain
Step 1: Enumerate trust relationships from the initial foothold
# List roles the compromised principal can assume
aws iam list-roles --query 'Roles[*].[RoleName,Arn,AssumeRolePolicyDocument]'
# Check role-by-role what each trust policy allows
aws iam get-role --role-name prod-deployment-role \
--query 'Role.AssumeRolePolicyDocument'
# Use enumerate-iam or Pacu to automate this
python3 enumerate-iam.py --access-key AKIA... --secret-key ...
Step 2: Identify high-value targets across accounts
Attackers specifically look for:
- Roles with
AdministratorAccessin production or security accounts - Roles with access to S3 buckets containing secrets or data
- Roles that can access centralised logging or security tooling accounts (to disrupt incident response)
- Roles attached to Lambda functions that have broad permissions
Step 3: Enumerate the Organisation from a management account
If the attacker reaches an account with organizations:ListAccounts permission — common in logging, security, or management accounts — they can enumerate all accounts in the organisation and systematically search for exploitable trust relationships:
aws organizations list-accounts --query 'Accounts[*].[Id,Name,Status]'
Step 4: Chain assumptions
AWS permits role chaining — assuming a role from a session that was itself obtained via AssumeRole. This allows paths like:
dev-user -> dev-deployment-role -> shared-services-admin -> prod-admin
Each hop may cross an account boundary.
CloudTrail Detection
Every AssumeRole call generates a CloudTrail event in both the calling account (the source) and the target account. The key signals are:
Cross-account assumptions from unexpected principals:
-- Athena query on CloudTrail S3 logs
SELECT
eventtime,
useridentity.arn AS caller,
requestparameters.roleArn AS assumed_role,
sourceIPAddress,
requestparameters.roleSessionName AS session_name
FROM cloudtrail_logs
WHERE eventsource = 'sts.amazonaws.com'
AND eventname = 'AssumeRole'
AND useridentity.accountid != '999999999999' -- not same account
AND errorcode IS NULL
ORDER BY eventtime DESC;
Sentinel KQL for cross-account assumptions:
AWSCloudTrail
| where TimeGenerated > ago(24h)
| where EventSource == "sts.amazonaws.com"
| where EventName == "AssumeRole"
| where isempty(ErrorCode)
| extend CallerAccount = tostring(parse_json(UserIdentityArn).account)
| extend TargetRoleArn = tostring(parse_json(RequestParameters).roleArn)
| extend TargetAccount = extract(@"arn:aws:iam::(\d+):", 1, TargetRoleArn)
| where CallerAccount != TargetAccount
| summarize
AssumptionCount = count(),
TargetRoles = make_set(TargetRoleArn),
SourceIPs = make_set(SourceIPAddress)
by UserIdentityArn, CallerAccount, TargetAccount, bin(TimeGenerated, 1h)
| where AssumptionCount > 3
| sort by AssumptionCount desc
New external principal in a trust policy:
# Detect changes to trust policies (look for external account IDs)
aws cloudtrail lookup-events \
--lookup-attributes AttributeKey=EventName,AttributeValue=UpdateAssumeRolePolicy \
--query 'Events[*].{Time:EventTime,User:Username,Resources:Resources}'
Containment: SCPs That Limit Blast Radius
Block assumption of roles from non-organisation accounts:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyExternalRoleAssumption",
"Effect": "Deny",
"Action": "sts:AssumeRole",
"Resource": "*",
"Condition": {
"StringNotEquals": {
"aws:PrincipalOrgID": "o-xxxxxxxxxx"
},
"BoolIfExists": {
"aws:PrincipalIsAWSService": "false"
}
}
}
]
}
Require MFA for sensitive cross-account assumptions (for human principals):
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "RequireMFAForCrossAccountAdmin",
"Effect": "Deny",
"Action": "sts:AssumeRole",
"Resource": "arn:aws:iam::*:role/*admin*",
"Condition": {
"BoolIfExists": {
"aws:MultiFactorAuthPresent": "false"
},
"StringNotLike": {
"aws:PrincipalArn": "arn:aws:iam::*:role/service-*"
}
}
}
]
}
Hardening Recommendations
- Audit all cross-account trust relationships with
aws iam list-rolesacross every account. Use an AWS Config rule or IAM Access Analyzer to flag external trust principals. - Enforce ExternalID on third-party trust policies — the confused deputy attack uses the lack of ExternalID on roles trusted by external accounts.
- Use permission boundaries on roles in child accounts that can be assumed from other accounts. Even if a principal assumes a powerful role, the boundary caps what it can do.
- Enable IAM Access Analyzer on each account and at the Organisation level. It flags all roles with cross-account or external access within minutes.
- Limit who can modify trust policies —
iam:UpdateAssumeRolePolicyshould be restricted to break-glass accounts and automated provisioning pipelines only. - Monitor for role chaining more than two hops deep — in CloudTrail, each AssumeRole event records the chain in
userIdentity.sessionContext.sessionIssuer. A long chain is unusual and warrants investigation.