Bypassing AWS Trust Relationships with Session Token Stealing

Fingerprint Icon

Introduction

When setting up an AWS environment, it’s often desirable to prevent administrative access from untrusted locations. This is important not just to prevent external attackers from gaining access to the environment, but also to provide control over the data within it, and to prevent legitimate users from accessing the environment from personal or unauthorised systems. One of the common ways that this is done is by configuring Trust Relationships (also known as Trust Policies).

Trust Relationships

Trust Relationships are used to define which individuals and services are able to assume IAM roles. A common pattern is to have the IAM users in a parent account, and then requiring users to assume a role within another AWS account to gain access to the actual resources. These are often used with a source, so that the role can only be assumed from specific IP addresses or VPC endpoints owned by the organisations, as shown in the example below (they’re RFC 5737 addresses to save you looking them up):

"Statement": [
    {
        "Effect": "Allow",
        "Principal": {
            "AWS": "arn:aws:iam::111111111111:root"
        },
        "Action": "sts:AssumeRole",
        "Condition": {
            "IpAddress": {
                "aws:SourceIp": [
                    "198.51.100.0/24",
                    "203.0.113.0/24",
                ]
            }
        },
        {
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::111111111111:root"
            },
            "Action": "sts:AssumeRole",
            "Condition": {
                "StringEquals": {
                    "aws:SourceVpce": [
                        "vpce-aaaaaaaaaaaaaaaaa",
                        "vpce-bbbbbbbbbbbbbbbbb"
                    ]
                }
            }
        }
    }

This Trust Relationship would allow users in the parent AWS account (111111111111) to assume the role, as long as they had been granted permission to do so, and they were coming from either one of the trusted IP ranges or the trusted VPC endpoints. At first glance, this policy looks like it would prevent the role from being used from anywhere outside of those trusted locations, but unfortunately it’s not quite that simple. As the AWS blog post on Trust Policies says:

By using aws:SourceIP in the trust policy, you limit where the role can be assumed from, but this doesn’t limit where the credentials can be used from after they are assumed.

However, this is a slightly subtle distinction that many people aren’t familiar with, which can lead to some interesting an unintended behaviour.

Assuming Roles

To understand what this means, first we need to take a look at how assuming roles works. When authenticating with the AWS CLI, we normally use our long-term access key ID and secret key for our IAM user in our ~/.aws/credentials file:

[profile]
aws_access_key_id = AK[...]
aws_secret_access_key = AB[...]

We can use these credentials to assume another role (as long as the Trust Relationship allows us to), which returns an access key ID, a secret key and a session token:

$ aws sts assume-role --role-arn arn:aws:iam::222222222222:role/some-role --role-session-name testing

{
[...]
    "Credentials": {
        "SecretAccessKey": "Xm[...]",
        "SessionToken": "AQ[...]",
        "Expiration": "2024-01-16T00:05:00Z",
        "AccessKeyId": "AS[...]"
    }
}

And then use these new credentials in our ~/.aws/credentials to perform actions with the permissions of the assumed role

[assumedrole]
aws_access_key_id = AS[...]
aws_secret_access_key = Xm[...]
aws_session_token = AQ[...]

But the crucial thing to note here is that while we could only assume the role from one of the trusted locations, once we have the session token it can be used from anywhere. This is because the Trust Relationship only applies to the sts:assumeRole action, and not to any subsequent actions performed using that role.

Impact

This can be useful to an external attacker if they are able to obtain a session token. They may be able to do this by obtaining credentials from the instance metadata service, which will return a session token for the IAM role associated with the EC2 instance. This can sometimes be obtained by exploiting server-side request forgery (SSRF), or compromising an EC2 instance and obtaining the token directly with curl. It may also be possible to obtain these tokens if systems or applications within the AWS environment allow users to execute commands, such as through a Jenkins pipeline (which often runs with a highly privileged IAM role).

But a more interesting case is where Trust Relationships are used to create a perimeter around the AWS environment so that internal users can only access it from approved locations - typically corporate owned systems connected to the organisation network, or hosted VDI setups such as AWS Workspace. This setup can provide a reasonable level of control over the environment, and crucially can make it difficult for a malicious inside to exfiltrate data from the environment. However, they can get around these controls by:

  • Authenticating with the AWS CLI from a trusted location.
  • Assuming a privileged IAM role within the environment.
  • Copying the access key, secret key and session token to an external untrusted system (using a pen and paper if required).
  • Re-authenticating using these credentials.

At this point, depending on the permissions of the role, there are a variety of ways that they could exfiltrate data - the simplest being to simply download it with the aws s3 cp command. The session tokens don’t last forever (usually a few hours), but that can be plenty of time for a malicious user to do some damage.

Whether or not this is a serious issue will depend completely on the specific context of the environment, and the extent to which the lack of external access is being relied upon as a security control.

Preventing External Token Usage

In order to prevent session tokens from being used outside of the trusted locations, the restrictions need to be moved from the Trust Relationships into either:

There are a variety of different ways that this can be done, but one of the more robust ones is to block any action that doesn’t come from one of the trust locations, using a condition such as the one shown below:

{
    "Condition": {
        "BoolIfExists": {
            "aws:PrincipalIsAWSService": "false",
            "aws:ViaAWSService": "false"
        },
        "NotIpAddress": {
            "aws:SourceIp": [
                "198.51.100.0/24",
                "203.0.113.0/24",
            ]
        },
        "StringNotEquals": {
            "aws:SourceVpce": [
                "vpce-aaaaaaaaaaaaaaaaa",
                "vpce-bbbbbbbbbbbbbbbbb"
            ]
        }
    },
    "Effect": "Deny",
    "Resource": "*",
},

Note that the aws:SourceIp key only works with public IP addresses. There is also an aws:VpcSourceIp key allows private IP addresses; but this shouldn’t be used because an attacker can simply create their own AWS environment that uses the same private IP range, and then use the session token from there.

In theory this is very simple. In practice, it’s rather more complicated, and will require significant testing in order to implement in a complex environment without things breaking. In particular, it needs a good understanding of every location the AWS environment will be accessed from, VPC endpoints configured for all services, and potentially having to add exceptions for any actions that can’t use VPC endpoints.

For some further reading, the AWS blog post on Establishing a Data Perimeter contains some useful guidance and steps that should be implemented to provide a defence-in-depth approach.

Thanks to Luke for all his knowledge and expertise around this issue, and the many discussions we’ve had about it.