Mapping a cloud lateral-movement path to MITRE ATT&CK T1078.004
A walkthrough of how a single over-permissioned IAM role becomes a full lateral-movement chain across a multi-account AWS environment — and the control that stops it at step one.
The setup
Most cloud pentests find the same root cause wearing a different costume: a role trusted by more accounts than it needs to be. In this engagement, the client ran a fairly standard AWS Organizations layout — a security account, a shared-services account, and a dozen workload accounts. Nothing exotic.
The finding started in shared-services, in a CI/CD deployment role called `ci-deploy-shared`. It had `sts:AssumeRole` permission into every workload account's `deploy` role — reasonable, since the pipeline needed to ship to all of them. What wasn't reasonable: the trust policy on those `deploy` roles didn't scope the principal to the specific CI role ARN. It trusted the entire shared-services account.
The chain
That one gap turns a single compromised credential into org-wide access. Here's the actual path we walked, start to finish:
- Initial access: a leaked long-lived access key for an EC2 instance profile in shared-services, found in a public GitHub repo (not our target's repo — a contractor's).
- Discovery: `aws sts get-caller-identity` confirmed the key belonged to shared-services. `aws iam list-roles` and a quick `aws iam list-attached-role-policies` enumeration surfaced `ci-deploy-shared` and its assume-role scope.
- Privilege pivot: because every workload account's `deploy` role trusted the shared-services account (not the specific role), any principal in shared-services — not just the CI role — could call `sts:AssumeRole` into `deploy` in any workload account.
- Lateral movement: from `deploy` in a production workload account, the role had `iam:PassRole` plus `lambda:CreateFunction`. That's a privilege-escalation primitive on its own — deploy a Lambda with an attached execution role that has broader permissions, invoke it once.
- Impact: read access to production RDS credentials via Secrets Manager, reachable from the same execution role.
Where this maps in ATT&CK
This is a clean example of T1078.004 (Valid Accounts: Cloud Accounts) chained into T1548 (Abuse Elevation Control Mechanism) via the `iam:PassRole` + Lambda pattern — a technique common enough that it has its own name in cloud security circles: the confused deputy problem, cloud edition. The account trusted a role, the role trusted an account, and nobody checked whether the account contained anything it shouldn't.
None of this required a zero-day. It required an IAM trust policy that was scoped to an account ARN instead of a role ARN, and a Lambda execution role with more permissions than the function needed. Both are Terraform config, not code vulnerabilities — which is exactly why this class of issue survives SAST, DAST, and dependency scanning untouched.
The fix
Three changes closed the path, in priority order:
- Scope every cross-account trust policy to a specific role ARN, not an account ID — `arn:aws:iam::<account>:role/ci-deploy-shared`, not just the account.
- Add an external ID or session tag condition to the `sts:AssumeRole` call, so a leaked key alone isn't sufficient even if the trust policy is misconfigured elsewhere.
- Strip `iam:PassRole` from any execution role that doesn't explicitly need to hand off permissions to another service — and where it is needed, scope it to specific target role ARNs with an `iam:PassedToService` condition.
Why this matters
IAM trust policy misconfigurations don't show up in a vulnerability scanner. They show up in a threat model that actually traces what a compromised credential can reach — which is the difference between a pentest that checks boxes and one that finds what an attacker would find.