Cloud Security Wire
AWS Azure GCP RSS
AWS Misconfiguration high

AWS Cross-Account Role Trust Misconfigurations and the Confused Deputy Attack

Missing ExternalId conditions on cross-account IAM roles leave AWS environments vulnerable to confused deputy attacks, where a third party tricks a trusted service into assuming a role it shouldn't. Here's how the attack works and how to audit and fix your trust policies.

By Cloud Security Wire · ·
#aws#iam#cross-account#confused-deputy#external-id#role-trust#privilege-escalation
High Severity

This issue has been assessed as high severity. Review affected configurations immediately.

Cross-account IAM roles are essential infrastructure in multi-account AWS environments. They’re how a centralised security tooling account accesses member accounts for log aggregation, how CI/CD pipelines deploy to production accounts, and how third-party SaaS vendors integrate with your infrastructure. The pattern is correct and necessary. The problem is how often the trust policies governing these roles are configured in a way that makes them exploitable.

The confused deputy attack on AWS cross-account roles is well-documented but still endemic. This guide covers the mechanics, how to identify vulnerable roles in your environment, and what a correct trust policy looks like.

How Cross-Account Trust Works

When Account A wants to allow Account B to perform actions in Account A, Account A creates an IAM role with a trust policy that permits Account B’s principal to assume it:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::ACCOUNT-B-ID:root"
      },
      "Action": "sts:AssumeRole"
    }
  ]
}

Account B can then call sts:AssumeRole targeting this role and receive temporary credentials.

The Confused Deputy Problem

The confusion arises when Account A grants trust to a service account — typically an AWS service, a partner, or a SaaS vendor — rather than a specific account. Consider a third-party monitoring vendor. They ask you to create an IAM role with their account ID in the principal, because their service needs to read your CloudWatch metrics. You create the role.

The problem: that vendor’s account now has permission to assume roles in your account on behalf of any of their customers. If a different customer of the same vendor tricks or manipulates the vendor’s service into assuming a role in your environment — a confused deputy situation — you have an unauthorised cross-account access scenario.

The real-world version of this attack:

  1. Vendor tells all customers: “Create a role with our account arn:aws:iam::VENDOR-ACCOUNT:root as principal”
  2. Attacker is also a customer of the vendor
  3. Attacker triggers the vendor’s integration against their own configuration, but specifies the ARN of your role rather than their own
  4. Vendor’s service, acting as a legitimate deputy, assumes your role
  5. Attacker’s data is now able to access or exfiltrate from your account through the vendor’s service

The ExternalId Fix

AWS built the ExternalId condition specifically to address this. When an ExternalId condition is included in the trust policy, the caller must supply a matching external ID when calling AssumeRole:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::VENDOR-ACCOUNT-ID:root"
      },
      "Action": "sts:AssumeRole",
      "Condition": {
        "StringEquals": {
          "sts:ExternalId": "your-unique-external-id-per-vendor"
        }
      }
    }
  ]
}

The ExternalId should be:

  • Unique per customer relationship (the vendor should generate one per customer, not reuse the same value)
  • Not predictable (a UUID or cryptographically random string, not your account number or company name)
  • Treated like a shared secret between you and the vendor

With a proper ExternalId in place, even if an attacker tricks the vendor’s service, they cannot supply your ExternalId — they don’t know it — so the AssumeRole call fails.

Auditing Your Environment

To find cross-account roles without ExternalId conditions, query your IAM roles directly:

# List all roles and filter those with cross-account trust and no ExternalId
aws iam list-roles --query 'Roles[*].[RoleName,Arn]' --output text | while read role_name role_arn; do
  trust_policy=$(aws iam get-role --role-name "$role_name" \
    --query 'Role.AssumeRolePolicyDocument' --output json 2>/dev/null)
  
  # Check if cross-account trust exists
  has_cross_account=$(echo "$trust_policy" | python3 -c "
import json, sys
doc = json.load(sys.stdin)
stmts = doc.get('Statement', [])
for s in stmts:
    p = s.get('Principal', {})
    aws_p = p.get('AWS', '') if isinstance(p, dict) else ''
    if isinstance(aws_p, str) and ':root' in aws_p:
        print('yes')
        break
    elif isinstance(aws_p, list):
        for a in aws_p:
            if ':root' in a:
                print('yes')
                break
" 2>/dev/null)
  
  # Check if ExternalId condition exists
  has_external_id=$(echo "$trust_policy" | python3 -c "
import json, sys
doc = json.load(sys.stdin)
stmts = doc.get('Statement', [])
for s in stmts:
    cond = s.get('Condition', {})
    if 'StringEquals' in cond and 'sts:ExternalId' in cond['StringEquals']:
        print('yes')
        break
" 2>/dev/null)
  
  if [ "$has_cross_account" = "yes" ] && [ "$has_external_id" != "yes" ]; then
    echo "VULNERABLE: $role_name ($role_arn)"
  fi
done

At scale, AWS Config custom rules are more practical. Here’s a Config rule that flags cross-account trust policies missing ExternalId:

import boto3
import json

def lambda_handler(event, context):
    config = boto3.client('config')
    iam = boto3.client('iam')
    
    invoking_event = json.loads(event['invokingEvent'])
    config_item = invoking_event['configurationItem']
    
    if config_item['resourceType'] != 'AWS::IAM::Role':
        return
    
    role_name = config_item['resourceName']
    trust_policy = config_item['configuration']['assumeRolePolicyDocument']
    
    compliance_type = 'COMPLIANT'
    
    for statement in trust_policy.get('Statement', []):
        principal = statement.get('Principal', {})
        aws_principal = principal.get('AWS', '') if isinstance(principal, dict) else ''
        
        is_cross_account = False
        if isinstance(aws_principal, str) and ':root' in aws_principal:
            is_cross_account = True
        elif isinstance(aws_principal, list):
            is_cross_account = any(':root' in p for p in aws_principal)
        
        if is_cross_account:
            condition = statement.get('Condition', {})
            has_external_id = (
                'StringEquals' in condition and
                'sts:ExternalId' in condition['StringEquals']
            )
            if not has_external_id:
                compliance_type = 'NON_COMPLIANT'
                break
    
    config.put_evaluations(
        Evaluations=[{
            'ComplianceResourceType': config_item['resourceType'],
            'ComplianceResourceId': config_item['resourceId'],
            'ComplianceType': compliance_type,
            'OrderingTimestamp': config_item['configurationItemCaptureTime']
        }],
        ResultToken=event['resultToken']
    )

Additional Trust Policy Hardening

Beyond ExternalId, consider these controls:

Use sts:ExternalId combined with aws:PrincipalOrgID: If the trusted party is within your AWS Organisation, you can scope the trust to your org entirely rather than relying on a specific account ID:

"Condition": {
  "StringEquals": {
    "aws:PrincipalOrgID": "o-exampleorgid"
  }
}

This ensures even if an attacker compromises the specified account ID through account hijacking, they still need to be within your organisation’s structure.

Audit third-party role permissions regularly: The trust policy controls who can assume the role; the permission policy controls what they can do once assumed. Third-party vendor roles frequently accumulate excessive permissions over time as integrations expand. Quarterly review of third-party role permissions against the principle of least privilege is good hygiene.

Use CloudTrail to monitor cross-account AssumeRole calls:

# Find AssumeRole calls where the calling principal is from a different account
aws logs filter-log-events \
  --log-group-name CloudTrail/YourLogGroup \
  --filter-pattern '{ ($.eventName = AssumeRole) && ($.userIdentity.accountId != YOUR-ACCOUNT-ID) }'

Unexpected cross-account assumptions — particularly outside business hours, from unfamiliar IP ranges, or targeting sensitive roles — should be alerted on.

The confused deputy attack is exploitable today in many AWS environments that have deployed third-party integrations following vendor documentation that omits ExternalId. The audit takes minutes; the fix is a single trust policy update. It’s low-effort remediation for a real, documented attack path.

← All Analysis Subscribe via RSS