Cloud Security Wire
AWS Azure GCP RSS
AWS Breach Analysis high

AWS Cross-Account Role Pivoting: How Attackers Lateral-Move Between Accounts via sts:AssumeRole

AWS's cross-account role assumption mechanism -- essential for multi-account architectures -- is also a primary lateral movement path when attackers compromise a single account. This guide covers the attack chain from initial compromise to cross-account escalation, CloudTrail detection queries, and the SCP controls that contain the blast radius.

By Cloud Security Wire · ·
#aws#iam#cross-account#assume-role#lateral-movement#sts#cloudtrail#scp#organizations#privilege-escalation
High Severity

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 AdministratorAccess in 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

  1. Audit all cross-account trust relationships with aws iam list-roles across every account. Use an AWS Config rule or IAM Access Analyzer to flag external trust principals.
  2. Enforce ExternalID on third-party trust policies — the confused deputy attack uses the lack of ExternalID on roles trusted by external accounts.
  3. 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.
  4. Enable IAM Access Analyzer on each account and at the Organisation level. It flags all roles with cross-account or external access within minutes.
  5. Limit who can modify trust policiesiam:UpdateAssumeRolePolicy should be restricted to break-glass accounts and automated provisioning pipelines only.
  6. 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.
← All Analysis Subscribe via RSS