Every role assumption in the Qubole incident carried a valid External ID, and every one succeeded. That isn't a bypass. The External ID separates a vendor's customers from each other; it was never a control against the vendor itself. Whoever holds the vendor's keys holds your role, for as long as the trust policy exists.
What Happened
Wiz, working with Sentra and Mandiant, traced it. Between October 14 and 26, someone used IAM user credentials from AWS account 805246085872, which Wiz describes as Qubole's, to call AssumeRole into customer accounts. Qubole is a data lake platform owned by Idera; its integration typically works the standard way, with a role in your account that trusts Qubole's account and checks an sts:ExternalId. How the credentials were obtained hasn't been published, and when THN checked, Qubole and Idera had posted no statement.
Once inside, the attacker enumerated buckets, instances, databases and KMS keys, then went for secrets: Lambda environment variables, Secrets Manager, SSM Parameter Store. The attacker ran what it found through TruffleHog to check which credentials were live: AWS access keys, Entra ID client secrets, Okta client credentials. S3 objects were downloaded, sometimes whole buckets in one session.
The detail that matters most: most of the roles were "not otherwise in use". In many environments Wiz observed, the attacker's calls were the only role assumptions from Qubole's account that month. The trust policies were still there, so they still worked.
The Mechanism
AWS is plain about what the External ID is for. Its confused deputy page says: "The primary function of the external ID is to address and prevent the confused deputy problem." The deputy is the vendor. The threat is another customer tricking the vendor into using your role. The External ID makes the vendor prove which customer it's acting for.
It says nothing about who is operating the vendor's account. If an attacker holds the vendor's credentials, the External ID is just one more value they read, and Wiz notes it is logged in plaintext in CloudTrail. A trust policy that names arn:aws:iam::<vendor>:root trusts every principal the vendor's account allows to call AssumeRole, including a stolen IAM user.
So an integration role is a standing credential you handed to a vendor, and it doesn't expire when the contract does. It's the same intuition behind Preflight, and the hash post: trust enforced on the vendor's side breaks the day the vendor is compromised, so the condition has to live on your side. And dormant trust is the supply chain problem I keep coming back to: it isn't a missing tool, it's a policy nobody owns, and it stays open.
The Check
List every role another account can assume, and when it was last used:
#!/usr/bin/env bash
# roles that another AWS account can assume, with last-used date
me=
for; do
doc=
accts=
[ || continue
last=
done
It prints the role, the foreign account IDs it trusts, and the last-used date (list-roles doesn't return that field, so it calls get-role per role). Two things to read in the output. A vendor you stopped using shouldn't be there at all. And a vendor role you thought was idle with a recent date is the one to open CloudTrail for: an attacker's session counts as use. It skips trust policies with "AWS": "*"; if one of those exists, it's a bigger problem than this post. Run it in every account, not just the one you're signed in to.
The Fix
- Delete trust for integrations you no longer use. Offboarding a vendor means removing the role, not cancelling the invoice.
- Narrow the principal. Trust the vendor's specific role ARN, not
:root. - Pin the network. Add
aws:SourceIpfor the vendor's published egress ranges, oraws:SourceVpceif it calls through a VPC endpoint. Wiz says "a source IP condition alone would have blocked every role assumption we observed." The attacker came from Mullvad and OVH addresses, not from wherever the vendor's real traffic runs. - If you're the vendor: short-lived credentials instead of long-lived IAM user keys, and publish your egress ranges so customers can pin them.
The Tradeoff
Network conditions break when the vendor changes infrastructure, and not every vendor publishes stable ranges, so some integrations will simply refuse them. Narrowing to a role ARN needs the vendor to tell you which role, and to keep it. The detection side costs money: several victims couldn't see which objects were read, because S3 object reads aren't in management events. Data events fix that and show up on the bill.
None of that is as cheap as the first bullet. Run the script, and delete every line you can't name an owner for.