With more and more adversaries targeting the cloud and installing various infostealers to gain credentials, I thought I’d take some time to reiterate previous work by the community and expand upon our previous blog post about how adversaries abuse AWS Secure Token Service (STS) by exfiltrating short-term credentials and leveraging them to gain access to AWS accounts.
Previously, we discussed stealing access keys, both short-term and long-term, to achieve access to AWS accounts. Now I’d like to take some time to discuss clients that are configured to use the AWS command-line interface (CLI) with single sign-on (SSO). With SSO configurations, adversaries can’t directly make use of STS access keys from plaintext in the credentials file. They can, however, abuse access and refresh tokens, which may be even more fruitful because these tokens can have wildly different durations than STS tokens.
How does SSO work on the AWS CLI?
While there are various methods to authenticate to AWS, the basic AWS CLI leverages OpenID Connect (OIDC), a protocol based on the OAuth2.0 framework. While I won’t dive into the specifics of how this auth flow works, here is an excellent post about the requests and responses involved. Ultimately, AWS offers an accessToken
that is default valid for one hour. This may be changed in IAM and by default is refreshable with the refreshToken
for eight hours and maximum of 90 days.
The accessToken
will be valid for the specific role on the account that the SSO session was created for, following the steps outlined in the documentation. Then, by calling GetRoleCredentials you can obtain STS token information such as accessKeyId
, accessKeySecret
and sessionToken
.
Where does AWS CLI store credentials?
SSO configuration files will be found under ~/.aws/sso/cache/
(macOS, Linux) or %USERPROFILE%/.aws/sso/cache
(Windows). That directory will contain multiple JSON files that are the SHA-1 hash of the start URL for which they are valid. Below is an example file:
{
"startUrl": "https://{identity-store-id}.awsapps.com/start/#",
"region": "us-east-1",
"accessToken": "aoaAAAAAGdA-oR4Ho2oh4SNe5rNkyzmV+r1owcOOZMi/hR6Xg",
"expiresAt": "2024-11-22T21:32:52Z",
"clientId": "uB_WVhc3QtMQ",
"clientSecret": "eyJraWQiOiJrZXktMTWRcIjpudWxsBdicCjXROHa7",
"registrationExpiresAt": "2025-02-05T14:53:52Z",
"refreshToken": "aorAAAAAGdBYYs9eZPfLI19YR831XRcnSUegArg"
}
All the data in this file will be stored in plaintext by default. As we will see in the following sections, these tokens are just as critical to protect as any information stored in the ~/.aws/credentials
or %USERPROFILE%/.aws/credentials
file. By default, as with the credentials file, the JSON files will be only viewable by the root user. To add in some further protection, we recommend leveraging tools such as aws-vault to encrypt and store these credentials in local keychains to prevent accidental credential leaks and to make it that much more difficult for the adversary. Access tokens should be protected with the same care as passwords.
Access tokens should be protected with the same care as passwords.
How adversaries can leverage stored SSO tokens
Lets consider the scenario that an adversary has somehow obtained full access to the .aws directory or has somehow gained access to the accessToken
or refreshToken
. How can they use this to gain access to the AWS environment?
Like I mentioned before, the most direct method is to perform the GetRoleCredentials API call.
response = client.get_role_credentials(
roleName='string',
accountId='string',
accessToken='string'
)
But wait a minute, the JSON file in the cache doesn’t contain the accountId
or the role associated with the credentials. Don’t worry, that is stored, plaintext, in the ~/.aws/config file
automatically when you configure the SSO registration! This API call will then send back the STS accessKeyId
, accessSecret
, and sessionToken
. The adversary can then start leveraging the role assigned and even pivot to other roles with AssumeRole
, depending on their level of access.
But let’s say the accessToken
has expired after the default one-hour period. What can an adversary do now? They simply have to send a CreateToken request through the CLI or the SDK.
response = client.create_token(
clientId='string',
clientSecret='string',
grantType='refresh_token',
refreshToken='string',
)
As you can see, all this information is present in the JSON file as well, so no need to collect data from anywhere else. Once they grab that new token they then can go back to GetRoleCredentals
and grab STS tokens for the configured role.
How to defend against SSO credential theft
Ok, we have a breach and the adversary is in the environment requesting tokens all over the place. We need to kick them out. What can we do? Well, that is entirely dependent on the configuration of IAM Identity Center. Consider the timing settings for the following:
- IAM Identity Center sign-in session
- SSO
accessToken
duration AssumeRole
sessions
First, you can revoke the sign-in session for the affected user. This can be done by running aws sso logout on the affected device, which in turn will just send a POST request to https://{service-endpoint}/logout
with the accessToken
as the x-amz-sso_bearer_token
. This will sign the user out of their current IAM Identity Center sign-in session and prevent them from requesting new access tokens to continue their sessions. It will also remove the locally stored cache file that contains the token information.
Revoking the sign-in session does not prevent the user from accessing services they have already authenticated to. They will continue to have access to those services for the duration of the accessToken
configuration, which by default, is one hour. Whenever the next refresh cycle comes through they will be denied access to the resource.
Revoking the sign-in session does not prevent the user from accessing services they have already authenticated to.
If the adversary has exchanged that accessToken
for role credentials then they will also have, at maximum, 12 hours for the first AssumeRole
. If they then pivot to another role the maximum duration of that, or any, subsequent AssumeRole
calls will be limited to one hour.
Simply signing the user out of IAM Identity Center is not sufficient for an effective defense as that leaves significant time for the adversary to continue to persist in the environment. So what can we do?
Session tracking
Currently, the most comprehensive method to revoke access is to apply deny policies to the affected resources. One key thing to remember is when AWS checks user access. When you deny or revoke a user from the IdP, it will only be validated when the user attempts to gain a new token, which as we just learned, may leave large time gaps for adversaries to operate. Applying specific deny policies on resources such as Roles will have an immediate effect. AWS has an excellent blog post for how to effectively apply these policies to prevent further activity by the adversary.
To summarize, you must locate the principalID
for the affected user. This can be done by looking at the CloudTrail logs (more on those in a minute). If you have enabled sourceIdentity
(which you should!) you can also grab that. With these two fields, you can set conditional deny policies at the organizational level in AWS. This allows you to blanket deny specific entities without disrupting the workflow of others and potentially interrupting business.
Crucially, you must also separately apply policies to the management account, as the policies at the organizational level will only affect the member accounts. More importantly, if the adversary were to chain roles and rename the session, then the principalId
will be different and thereby subvert the previously applied deny policy. This is where sourceIdentity
will come in handy! If applied appropriately at the IdP, sourceIdentity
will persist between AssumeRole
calls and will not be able to be modified.
How to detect SSO credential theft
What can we see in CloudTrail logs for this type of activity? Various events show up, and some examples can be found in AWS documentation found here. Lets consider a user who runs aws sso login
and then checks their permissions with GetCallerIdentity
. This scenario presents three main events for the authentication:
CreateToken
GetRoleCredentials
AssumeRoleWithSAML
First, CreateToken
will provide the information related to the IAM Identity Center login event, which issues the accessToken
and the refreshToken
. Both of these are redacted from the event to prevent accidental token theft. This event is triggered from the aws sso login
call, which is evident by an sso.login
found in the userAgent
string when leveraging the aws-cli
.
Subsequently, the user executes aws sts get-caller-identity
. When this happens, aws-cli
will perform GetRoleCredentials
and AssumeRoleWithSAML
transparently.
These two events will provide the role information provided with the SSO configuration. We can see the roleName
from GetRoleCredentials
and the access key information in AssumeRoleWithSAML
. As I stated before, these events are triggered transparently to the user. All they see is a seamless transition from login to performing actions in AWS.
For detection strategies, it is always important to provide sufficient context and narrative for analysts. For SSO, this can include anomalous CreateToken
calls which may be indicated by more than one IP performing CreateToken
in a short time frame. Reconnaissance actions such as ListAccountRoles may also indicate an adversary attempting to understand their level of access after stealing access tokens. These events may not be sufficient for detection by themselves but can inform on activity downstream in the roles they eventually assume.
As with most services in the cloud, AWS SSO has many interdependencies. If you have federated identity to AWS through Okta or similar products, there are also detection opportunities there as well. Even better is the ability to combine log sources and tell a cohesive story of the use behavior. Using Okta as an example, when the user executes aws sso login they are routed to authenticate to Okta, which will then allow the redirect to AWS to get the proper accessToken
. This results in a user.authentication.sso
that will correspond to the same start URL from the SSO configuration.
Example logs
CreateToken
This event details a user requesting tokens from the configured identity store. The userIdentity
section details the user involved and which identity store they authenticated to. The requestParameters
section shows what client was used and the grant type. It is recommended to leverage the new PCKE method rather than device_code
. Collapse the box below to see the full log example:
CreateToken log
{ "eventVersion": "1.09", "userIdentity": { "type": "Unknown", "principalId": "a8010330-4001-74c1-7db505edc2f1", "accountId": "012345678901", "userName": "John Smith", "onBehalfOf": { "userId": "a8010330-4001-7084-74c1-7db505edc2f1", "identityStoreArn": "arn:aws:identitystore::012345678901:identitystore/d-0123456789" }, "credentialId": "75732d7765b92360d0ae1b8092a3d2df97" }, "eventTime": "2024-11-26T16:04:31Z", "eventSource": "sso.amazonaws.com", "eventName": "CreateToken", "awsRegion": "us-west-2", "sourceIPAddress": "x.x.x.x", "userAgent": "aws-cli/2.22.2 md/awscrt#0.22.0 ua/2.0 os/macos#23.6.0 md/arch#arm64 lang/python#3.12.7 md/pyimpl#CPython cfg/retry-mode#standard md/installer#source md/prompt#off md/command#sso.login", "requestParameters": { "clientId": "ljZriqDp1GYOdlc3QtMg", "clientSecret": "HIDDEN_DUE_TO_SECURITY_REASONS", "grantType": "urn:ietf:params:oauth:grant-type:device_code", "deviceCode": "fmpqzNA0bcn5UkoyA-3xGfQKv3sJyiDnvaMN60-RTHNlrkSWauw", "platformSessionExpiryRequired": false }, "responseElements": { "accessToken": "HIDDEN_DUE_TO_SECURITY_REASONS", "tokenType": "Bearer", "expiresIn": 28795, "refreshToken": "HIDDEN_DUE_TO_SECURITY_REASONS", "idToken": "HIDDEN_DUE_TO_SECURITY_REASONS" }, "requestID": "8e13fb2f-6076-4563-b25f-5fef2383dfac", "eventID": "174ef6d3-2b70-49a0-84b4-913f8227f4c4", "readOnly": false, "resources": [ { "accountId": "012345678901", "type": "IdentityStoreId", "ARN": "d-0123456789" } ], "eventType": "AwsApiCall", "managementEvent": true, "recipientAccountId": "012345678901", "eventCategory": "Management", "tlsDetails": { "tlsVersion": "TLSv1.3", "cipherSuite": "TLS_AES_128_GCM_SHA256", "clientProvidedHostHeader": "oidc.us-west-2.amazonaws.com" } }
GetRoleCredentials
This event details the user requesting STS tokens by using the previously created access token. The userIdentity
section also details the user and identity store involved in the request. The serviceEventDetails
shows which role was accessed, which in this case was AWSEnginerOnCall
. You can also see in the userAgent field
that they ran get-caller-identity
. Collapse the box below to see the full log example:
GetRoleCredentials log
{ "eventVersion": "1.10", "userIdentity": { "type": "Unknown", "principalId": "a8010330-4001-74c1-7db505edc2f1", "accountId": "012345678901", "userName": "john.smith@example.com", "onBehalfOf": { "userId": "a8010330-4001-7084-74c1-7db505edc2f1", "identityStoreArn": "arn:aws:identitystore::012345678901:identitystore/d-0123456789" }, "credentialId": "AAAAAGdGYgsPqdS-Xv_uDvEpglBX-mMNwbg" }, "eventTime": "2024-11-26T16:04:32Z", "eventSource": "sso.amazonaws.com", "eventName": "GetRoleCredentials", "awsRegion": "us-west-2", "sourceIPAddress": "x.x.x.x", "userAgent": "aws-cli/2.22.2 md/awscrt#0.22.0 ua/2.0 os/macos#23.6.0 md/arch#arm64 lang/python#3.12.7 md/pyimpl#CPython cfg/retry-mode#standard md/installer#source md/prompt#off md/command#sts.get-caller-identity", "requestID": "6bd622ec-e905-4c25-9736-208d3b7342ee", "eventID": "4665f570-cac7-4428-acb4-34b1e6e794f6", "readOnly": true, "eventType": "AwsServiceEvent", "managementEvent": true, "recipientAccountId": "012345678901", "serviceEventDetails": { "role_name": "AWSEngineerOncall", "account_id": "012345678901" }, "eventCategory": "Management" }
AssumeRoleWithSAML
This event allows us to track the end user through the role that is being used to perform the activity. The userIdentity
shows which user is assuming the role. The requestParameters
show the roleARN
that is being assumed. Finally, the responseElements
show us the accessKeyID
that can be used to track behavior going forward. This is also where sourceIdentity
would be found if it is being used. Collapse the box below to see the full log example:
AssumeRoleWithSAML log
{ "eventVersion": "1.08", "userIdentity": { "type": "SAMLUser", "principalId": "cm8+fako67v/SeH7bjJuS8k=:john.smith@example.com", "userName": "john.smith@example.com", "identityProvider": "cm8+fako67v/SeH7bjJuS8k=" }, "eventTime": "2024-11-26T16:04:32Z", "eventSource": "sts.amazonaws.com", "eventName": "AssumeRoleWithSAML", "awsRegion": "us-west-2", "sourceIPAddress": "x.x.x.x", "userAgent": "aws-internal/3 aws-sdk-java/1.12.772 Linux/4.14.353-270.569.amzn2.x86_64 OpenJDK_64-Bit_Server_VM/17.0.12+9-LTS java/17.0.12 vendor/Amazon.com_Inc. cfg/retry-mode/standard cfg/auth-source#imds", "requestParameters": { "sAMLAssertionID": "_ad58cd28-4f8b-417d-b3db-33444239431b", "roleSessionName": "john.smith@example.com", "roleArn": "arn:aws:iam::012345678901:role/aws-reserved/sso.amazonaws.com/us-west-2/AWSReservedSSO_AWSEngineerOncall_e8d216ba820", "principalArn": "arn:aws:iam::012345678901:saml-provider/AWSSSO_98ab640e0_DO_NOT_DELETE", "durationSeconds": 3600 }, "responseElements": { "credentials": { "accessKeyId": "ASIAYTFIGNHUQMTRGJ2F", "sessionToken": "IQoJb3JpZ2luX2VjEI7//////////wEaGDp9uzlG7THvuxxd4AuIAMXszYERfF7s3w=", "expiration": "Nov 26, 2024, 11:02:14 PM" }, "assumedRoleUser": { "assumedRoleId": "AROAYTFIGNHUXG2TUA6YE:john.smith@example.com", "arn": "arn:aws:sts::012345678901:assumed-role/AWSReservedSSO_AWSEngineerOncall_e8d216ba820/john.smith@example.com" }, "packedPolicySize": 24, "subject": "john.smith@example.com", "subjectType": "persistent", "issuer": "https://portal.sso.us-west-2.amazonaws.com/saml/assertion/MTExMjI4MzA5MDUxX22MTM4YjFiNjlkNzUz", "audience": "https://signin.aws.amazon.com/saml", "nameQualifier": "cm8+fako67v/SeH7bjJuS8k=" }, "requestID": "846b0031-e3c1-4dad-b011-6068b8ae140a", "eventID": "e526f68b-1bd7-4b37-b947-52fe8b7c54e4", "readOnly": true, "resources": [ { "accountId": "012345678901", "type": "AWS::IAM::Role", "ARN": "arn:aws:iam::012345678901:role/aws-reserved/sso.amazonaws.com/us-west-2/AWSReservedSSO_AWSEngineerOncall_e8d216ba820" }, { "accountId": "012345678901", "type": "AWS::IAM::SAMLProvider", "ARN": "arn:aws:iam::012345678901:saml-provider/AWSSSO_98ab640e0_DO_NOT_DELETE" } ], "eventType": "AwsApiCall", "managementEvent": true, "recipientAccountId": "012345678901", "eventCategory": "Management", "tlsDetails": { "tlsVersion": "TLSv1.3", "cipherSuite": "TLS_AES_128_GCM_SHA256", "clientProvidedHostHeader": "sts.us-west-2.amazonaws.com" } } }
user.authentication.sso
This is an example Okta log that shows the user authenticating through Okta for the federated identity. The event.type
is user.authentication.sso
and we can see in the securitycontext.domain
is amazonaws.com
. Finally, we can correlate this to the previous sign-on events with the debugContext.debugData.audience
field, which shows the same sign in URL with the identity store. Collapse the box below to see the full log example:
user.authentication.sso log
{ "_source": { "actor": { "id": "00uf2zx1a0MuizBIe697", "type": "User", "alternateId": "john.smith@example.com", "displayName": "John Smith" }, "client": { "userAgent": { "rawUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", "os": "Mac OS X", "browser": "CHROME" }, "zone": "null", "device": "Computer", "ipAddress": "X.X.X.X", "geographicalContext": { "city": "", "state": "", "country": "", "postalCode": "", "geolocation": { "lat": "", "lon": "" } } }, "device": { "id": "guofi5gu4aLTGFEBR697", "name": "Mac15,7", "os_platform": "OSX", "os_version": "15.1.1", "managed": true, "registered": true, "device_integrator": "{\"WSC\":{},\"CALLER_CONTEXT\":{},\"CROWDSTRIKE\":{}}", "disk_encryption_type": "ALL_INTERNAL_VOLUMES", "screen_lock_type": "BIOMETRIC", "secure_hardware_present": true }, "authenticationContext": { "authenticationStep": 0, "rootSessionId": "idx8f6n8IUiQ_GUH57SdqGyMg", "externalSessionId": "idx8f6n8IUiQ_GUH57SdqGyMg" }, "displayMessage": "User single sign on to app", "eventType": "user.authentication.sso", "outcome": { "result": "SUCCESS" }, "published": "2024-11-26T16:04:32Z", "securityContext": { "asNumber": 14618, "asOrg": "amazon technologies inc.", "isp": "amazon.com inc.", "domain": "amazonaws.com", "isProxy": false }, "severity": "INFO", "debugContext": { "debugData": { "traceId": "b975fa62-9f77-48ce-aaa4-03f88ac78cf5", "audience": "https://us-east-1.signin.aws.amazon.com/platform/saml/d-0123456789", "behaviors": "{New_Country=NEGATIVE, New_Geo-Location=NEGATIVE, New_IP=NEGATIVE, New_City=NEGATIVE, New_State=NEGATIVE, New_Device=NEGATIVE, Velocity=POSITIVE}", "subject": "john.smith@example.com", "signOnMode": "SAML 2.0", "authenticationClassRef": "urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport", "authTime": "2024-11-26T16:04:32Z", "requestUri": "/login/token/redirect", "issuer": "http://www.okta.com/exk75z4wfxurxBBAB697", "url": "/login/token/redirect?stateToken=02.id.4hotlpRZUniII0MWJckcjPjARncZcY3eo_UCgv2P", "initiationType": "SP_INITIATED", "authnRequestId": "Z1G4aTtCLALEa-oJxWmqQwAABlw", "requestId": "Z1G4bTtCLALEa-oJxWmqbgAABlw", "dtHash": "38fefb8b8032d371f15cbf8d233b28a78dadbddea4d86f4baf50ca8", "expiryTime": "2024-11-26T17:04:32Z", "risk": "{reasons=Anomalous Location, level=LOW}", "issuedAt": "2024-11-26T16:04:32Z", "threatSuspected": "false", "jti": "id10844978010567401387857716" } }, "legacyEventType": "app.auth.sso", "transaction": { "type": "WEB", "id": "Z1G4bTtCLALEa-oJxWmqbgAABlw", "detail": {} }, "uuid": "1fcdf143-b315-11ef-8550-6701f6b0b522", "version": "0", "request": { "ipChain": [ { "ip": "X.X.X.X", "geographicalContext": { "city": "", "state": "", "country": "", "postalCode": "", "geolocation": { "lat": "", "lon": "" } }, "version": "V4" } ] }, "target": [ { "id": "0oa75z4wfydBX0NUl697", "type": "AppInstance", "alternateId": "AWS SSO - Test Org", "displayName": "AWS IAM Identity Center", "detailEntry": { "signOnModeType": "SAML_2_0" } }, { "id": "0uaf2zx1ksG2IOK3S697", "type": "AppUser", "alternateId": "john.smith@example.com", "displayName": "John Smith" } ] } }
Conclusion
While SSO has been a huge boon for centralized identity management, access tokens and their validity periods should be a prime focus for security teams. Regular evaluations of the minimum required validity periods can save defenders precious minutes or even hours in the wake of an incident.
It is also critical to understand where the tokens themselves live and to protect them as you would any password. With the rise of infostealers, adversaries are becoming increasingly adept at finding credentials and capitalizing on them. A comprehensive understanding of your attack surface and a proactive defense plan will help keep credentials or other sensitive information out of adversaries’ hands.
Big shoutout to the previous work by Chaim Sanders, Christophe Tafani-Dereeper and others that provided excellent references and research into how these systems work and putting into perspective the security implications.