Demystifying AWS' AssumeRole and sts:ExternalId

Amazon Web Services’ AssumeRole operation accepts an optional parameter called “sts:ExternalId” which is intended to mitigate certain types of attacks. However, both the attacks that sts:ExternalId mitigates and how to properly use it are widely misunderstood, resulting in large numbers of vulnerable AWS-based applications. This post aims to describe what std:ExternalId does, when to use it, and how to use it.

Background

In AWS, each principal (user, service, etc.) has a certain set of privileges defined by the policies attached to that principal and to the resources that the principal is trying to access. In some cases, it is necessary for one principal to temporarily run with a different set of privileges. AWS’ solution to this problem is to allow principals to use “sts:AssumeRole1 to acquire temporary credentials for a “role”2; a role is a special type of principal that has its own policies and is intended to be used in this scenario. Like other principals, roles are uniquely identified by ARNs3; the role to assume in a call to AssumeRole is identified by an ARN.

Principals may assume roles that are either in the same account as the caller or in different accounts. This allows principals to perform actions on behalf of other principals, regardless of where those principals are defined. A common pattern is for some user to pass a role ARN to another service, which then calls AssumeRole and performs some action on behalf of its caller under that role’s credentials.

It is important to restrict the principals that are permitted to assume a role; otherwise, any principal that can learn or guess the ARN of a role would be able to assume it. Control of what principals are permitted to assume a role is provided by the role’s trust policy. For example, the following trust policy allows both EC2 and the role “ALICE” (the principal running some service) to assume the role to which the policy is attached:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "sts:AssumeRole",
      "Principal": {
        "Service": [ "ec2.amazonaws.com" ],
        "AWS": [ "arn:aws:iam::123412341234:role/ALICE" ]
      }
    }
  ]
}

Using ExternalId to avoid confused deputy problems

In many cases, it is not sufficient for a trust policy to simply list the principals that may assume a role. Consider some hypothetical service ALICE that accepts a role ARN from its users, performs some operation under that role, and returns the result to its caller. If both Bob and Carol are users of ALICE, then both Bob and Carol have roles in their accounts whose trust policies permit ALICE to assume them. This does not prevent Carol from calling ALICE with Bob’s role, which could lead to ALICE revealing details within Bob’s account to Carol. This is an instance of the Confused Deputy Problem.

A more general view of this problem is that ALICE needs a way to prove to the owner of Bob’s role (which may not be Bob — there are legitimate use cases for cross-account roles) that it is assuming Bob’s role on behalf of Bob, rather than on behalf of Carol or some other principal. This check is implemented by adding a “condition” to the trust policy of Bob’s role verifying that sts:ExternalId is equal to a string known by ALICE4. An example of such a trust policy follows:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "sts:AssumeRole",
      "Principal": {
        "AWS": [ "arn:aws:iam::123412341234:role/ALICE" ]
      },
      "Condition": {
        "StringEquals": {"sts:ExternalId": "12345"}
      }
    }
  ]
}

Before ALICE attempts to assume the role, it must authenticate Bob to its satisfaction, then provide the ExternalId in its call to AssumeRole.

ExternalId values are not passwords and it is not necessary that they be secret. So long as ALICE guarantees the uniqueness of the ExternalId values that it uses to assume roles (i.e., ALICE will never attempt to assume two different roles using the same ExternalId), and ALICE properly authenticates its callers, then ALICE can never be persuaded to successfully assume Bob’s role using Bob’s ExternalId on behalf of Carol. (Consider the converse: then either ALICE believes that Carol is Bob, contradicting ALICE’s authentication of callers, or Carol’s ExternalId is the same as Bob’s, contradicting the uniqueness of ExternalIds, or the ExternalIds do not match, contradicting the success of the operation.)

If ALICE does not guarantee the uniqueness of ExternalIds, then it is necessary for them to be secret in order to prevent Carol from asking ALICE to assume Bob’s role using an ExternalId that is trusted by Bob’s role. However, ExternalIds are stored in plain text in roles’ trust policies and are passed as plain text in calls to AssumeRole; they are not afforded the protections usually expected for secrets such as passwords.

Using ExternalId in your application

The best practice for generating and configuring ExternalIds is for ALICE to choose a unique ExternalId when Bob registers a role with it, then return this ExternalId to Bob and request that Bob incorporate it into its role’s trust policy. Using this best-practice design, Bob’s workflow might be the following:

  1. Bob registers a role ARN with ALICE.
  2. ALICE responds with a unique ExternalId, which it stores in Bob’s user record alongside the role ARN.
  3. Bob updates the trust policy of its role to allow ALICE to assume the role provided that ALICE passes the ExternalId obtained in the previous step.
  4. Bob calls an operation within ALICE, which assumes Bob’s role.

Consider now what happens if Carol tries to register Bob’s role with ALICE. ALICE will accept Bob’s role in step 1 and return an ExternalId. However, Carol has no way to update the trust policy of Bob’s role in step 3. So when Carol attempts step 4, ALICE’s call to AssumeRole will fail because the role’s trust policy either doesn’t permit ALICE to assume it at all (if Bob has not also registered the role with ALICE) or will expect a different ExternalId (if Bob has registered the role and received its own ExternalId). Since Carol has no control over the ExternalId that ALICE assigned to it and will use while attempting to assume the role that it registered, knowing or guessing Bob’s ExternalId is of no benefit to Carol.

The best-practice design does have a few limitations. It requires a separate registration step and prevents ALICE from being stateless. While Amazon states that changes to policies take place “almost immediately”5, it does not guarantee any particular upper bound, so the call to AssumeRole within step 4 may fail if insufficient time has passed after step 3. If your system cannot accept these limitations, then it is possible for Bob to generate its own random string, configure its role to trust ALICE as long as it passes this string as an ExternalId, then pass both the role ARN and ExternalId to ALICE, provided that all parties maintain the secrecy of the ExternalId. However, since ExternalIds need to be available in plain text in several places, this option should be avoided: services should avoid designs that prevent them from guaranteeing the uniqueness of the ExternalIds that they use.

When to use ExternalId

AWS does not require that all calls to AssumeRole pass an ExternalId value. In general, if ALICE can assume roles provided by more than one third party, then using ExternalId is probably necessary to avoid problems. If ALICE uses only roles specified in some trusted configuration file, or only assumes roles on behalf of mutually trusted services, then ExternalId is probably not necessary. However, it’s still good to use as defence in depth.

Certain AWS services, such as EC2 and Lambda, avoid this problem by requiring that the role belong to the same AWS account as the caller and that the caller have the iam:PassRole permission with respect to the role. However, this mechanism cannot control legitimate access to cross-account roles; even if it could, AWS cannot force all possible services (including third-party ones) that access roles in other accounts to perform proper PassRole checks.

AssumeRole also supports an option to require multi-factor authentication (MFA). This feature is occasionally confused with ExternalId, but it in fact solves a different problem. MFA AssumeRole strengthens ALICE’s authentication to AWS, whereas ExternalId proves to the owner of Bob’s role that Bob has authenticated to ALICE.

Footnotes

  1. https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRole.html
  2. https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles.html
  3. https://docs.aws.amazon.com/general/latest/gr/aws-arns-and-namespaces.html
  4. https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_create_for-user_externalid.html
  5. https://aws.amazon.com/iam/faqs/