Pre-signed at your Service

The inherent risks when using AWS S3 pre-signed URLs

by Robert de Jager on 16 March, 2023

 

WIthSecure Intelligence 

Robert de Jager

Summary

The AWS S3 pre-sign URL functionality is pretty well documented. However, when combined with IAM role assumption, specifically with Service Roles, there are risks that present themselves that an average AWS admin might not be aware of. 

For example, should an EC2 instance with S3 access be compromised, a malicious actor could make use of a pre-signed URL to maintain temporary access to an S3 object. In my tests this allowed me to maintain access for just over 6 hours. The access was persistent even after the instance that created the pre-signed URL was terminated. Pre-signed URL usage is logged in the logs from AWS S3 and CloudTrail services. But requires specific logging events that are not turned on by default, which could result in malicious usage being impossible to track.

These risks are important to be aware of in the event that a malicious actor gains access to critical data stored in S3. Using a pre-signed URL a malicious actor might be able to exfiltrate data from S3 even after the original method of entry was removed. Combining this with potentially not having the required logging enabled, could result in making discovery of this exfiltration problematic. This article will cover these risks in more detail.  

What are pre-signed URLS?

Perhaps a lesser-known feature of the AWS S3 service, pre-signed URLs allow users to share access to specific S3 objects via a hyperlink. This means if an external party is given a pre-signed URL, for a limited time, they will be able to access this object even if the bucket and objects are private. AWS documentation recommends great care be taken when making use of pre-signed URLs and understandably so.

Pre-signed URLs can be generated with a specific time to live [1]. This means it’s not possible to generate a pre-signed URL for long-term use but the time it is active for is still quite lengthy. The maximum time to live for a pre-signed URL is broken down in the documentation as 6 hours for an instance profile, 36 hours for an IAM user and 7 days for an IAM user using Signature Version 4 [2].  Should a pre-signed URL ever be leaked or should a malicious actor gain access to creating a pre-signed URL, these timeframes could be enough time for them to carry out any work they have planned. Such as exfiltrating multiple sensitive S3 objects. Or in the event they have PUT access, overwriting existing S3 objects with malicious payloads.

Read-only access is often seen as a relatively safe level of access to give to users as they will be unable to actively change resources, only read them. Or in the case of AWS S3, access the objects in a controlled and expected fashion. However, read access given to an AWS S3 object has a caveat. The minimum permission required to generate a pre-signed URL is read-only access to the S3 bucket/object. This could lead to objects being accessed in ways that are not expected. For example, an admin could give read access for a console user to an S3 object. Expecting them to access the object from their work laptops, using the AWS console and MFA. However, after doing this once, the user could generate a pre-signed URL to avoid having to do this again for as long as the pre-signed URL is valid. This also allows users to be able to access the private S3 object from over the internet regardless of device/IP (unless the bucket policy explicitly denies this).

Apart from accessing objects, pre-signed URLs can also be used to delete or upload S3 objects. Due to time constraints I only cover accessing objects here, but there’s more to play around with.

Risk 1 - Observability

Currently there are no events logged or available for the creation of a pre-signed URL on AWS. Running tests, I found that both S3 and CloudTrail could not pick up the creation of a pre-signed URL only the use of one. This means if a malicious user created a pre-signed URL, they could store it for later use.

Once the created pre-signed URL was used, there are two ways to detect it. The first is CloudTrail Data Events [5], which is not enabled by default due to the increased costs it would create. The next was to enable S3 Access Logging [6]. This is another logging mechanism that is not enabled by default. An admin might not want to enable these for an S3 bucket that is set to private, as it would incur additional costs for arguably not much gain. However, without these logging mechanisms pre-signed URL usage wouldn’t be visible.

If CloudTrail data events and S3 access logging were enabled, it would show that a pre-signed URL was used to access a bucket, from some external IP using a role or user, but by that point the damage is already done. This logging information would only show what data the malicious actor accessed. As such logging should be seen as an analysis tool and not a safeguard in this situation. I cover some preventative measures in the caveats section below.

Risk 2 - Service Roles

Let us assume that a malicious actor gained unauthorized access to an EC2 instance that had read access to an S3 bucket by way of a service role (attached by IAM Instance Profile). The malicious actor generates a pre-signed URL on the EC2 instance for an object they want to exfiltrate.

A sharp-eyed admin notices strange behavior and either isolates, shuts down, or panics and terminates the EC2 instance completely. The admin or investigation team may think that the malicious actor no longer has access to anything as the server that made use of the role is gone. This is an incorrect assumption. The malicious actor can no longer generate a new pre-signed URL but unless the current service role sessions are revoked, the malicious actor can still make use of the existing pre-signed URL to access the S3 object from outside of the estate. This access would last for 6 hours according to the pre-sign documentation [3] and when running tests I found it to be between 6 – 6.5 hours. 

As an aside the AWS documentation does mention revoking role sessions for any roles that are no longer required[4]. However, what happens if the service role is still required by other running workloads and revoking all current sessions would impact workloads detrimentally? An admin might not be able to quickly revoke sessions in this case.

Additionally, I could see no easy way to find active role sessions in the AWS IAM service to allow me to pick and choose which sessions I wanted to revoke. Revoking an active session in AWS means clicking a button in the IAM service for the role, that causes AWS to generate an inline policy. Once this policy is approved by an admin, it will then be applied to the role in question. An example of the inline policy can be seen in the appendix below.

An admin or investigator would need to use AWS CloudTrail with data events enabled to see which role was being misused. Once done they can navigate to the role in the AWS IAM service, select revoke sessions and revoke all current sessions. This meant that all role sessions up until a specific time had to be revoked. This could be a bit of a sticky situation for services that assume the same role. The scope of this blanket revocation could be made smaller by noting the timestamp the of the malicious role assumption in CloudTrail and editing the inline policy that is created when revoking sessions, specifying a more detailed TokenIssueTime.

Risk 3 – IAM User

Even though it was a Service Role attached to an EC2 instance that piqued my interest I ran some additional tests looking at IAM Users, the two most noteworthy:

Creating a pre-signed URL using a User Account

Using a User account with S3 access given directly by policy, I generated a pre-signed URL. Once the user was deleted, the pre-signed URL no longer worked as it was dependent on the IAM user and not a role. This was as expected.

Creating a pre-signed URL Using a role

Using a sts assume role that the IAM user assumed, I generated a pre-signed URL. After deleting the user, the pre-signed URL continued to work for 1 hour as is documented in the AWS documentation [3]. This one hour is a narrow window to do anything interesting but could still be useful.

 

Caveats

There are some caveats to using pre-signed URLs. Network control affects pre-signed URLs. Meaning that network access control lists, security groups, firewalls or any network traffic management to the S3 bucket will be able to block pre-signed URLs from being used. For example, ensuring that S3 bucket policies only allow access from expected IPs. However S3 buckets are often not configured to sit behind these network tools due to the assumption that their native permissions management will deal with any threat. (For example if the bucket is private, the objects are private and the public-access-block is locked down, then it could be assumed there isn’t any risk but as we’ve seen above this is not necessarily true).

There is a caveat to keep in mind with S3 access logs. When reading an S3 access log looking for pre-signed URL usage, it is expected to see at least one entry marked as ‘Access Denied’. This can be confusing at a glance as it could be incorrectly assumed the get request originating from the pre-signed URL was denied. This may not be correct. It is important to pay attention to the object that was being accessed. For example, when investigating S3 access logs there will be an access denied message as the request attempts to grab a favicon.ico along with the actual payload object. The favicon.ico will have access denied as the pre-signed URL can only access the S3 object it was given access to. When investigating S3 access logs, special attention should be taken to narrow down on the S3 object being investigated.

Future Work

What follows may be some areas of interest for additional research for a security researcher to investigate in the future. These areas were identified while pre-signed URLs were being investigated but did not fit the scope of this article.

Experiment with AWS Service-Linked roles

As a future work, looking at the AWS documentation it states that service-linked roles cannot be revoked. Even though I could see no way of using a service-linked role with pre-signed URLs, if it was ever possible, the admin would have no choice but to wait out the role sessions time to live.

Explore AWS Signature Version 4 and IAM users

Making use of AWS Signature Version 4 could allow pre-signed URLs to remain valid for 7 days. However as the time to live for pre-signed URLs are overruled by the temporary credential that created them this might not be as useful. Further investigation into AWS Signature Version 4 could be interesting.

Experiment with PUT object by way of pre-signed URL in S3 combined with AWS Lambda

In theory, if a developer who was working with lambda functions, accidentally leaked, or committed credentials, a malicious actor could use these to overwrite code if the credentials allowed PUT in S3. The malicious actor could generate a pre-signed URL and use it to overwrite a lambda archive that was stored in S3, with a malicious one. If the pre-signed URL was created by assuming a role first, then the malicious actor would have 1 hour to do this. Even after the developer credentials were deleted or rotated.

Conclusion

Even though this is a pretty niche use case, I’d like to end the article by stating that if any external access is given to an S3 bucket, regardless of methodology, access logging should always be enabled on the S3 bucket. Additionally ensure the S3 bucket policy limits network access to expected sources. When it comes to compromised users and services, rotating credentials is a good first step. But it is also important to revoke any current role sessions that might have ties to the compromised entity to ensure malicious access is fully purged.

References

[1] https://awscli.amazonaws.com/v2/documentation/api/latest/reference/s3/presign.html

[2] https://docs.aws.amazon.com/AmazonS3/latest/userguide/ShareObjectPreSignedURL.html

[3] https://repost.aws/knowledge-center/presigned-url-s3-bucket-expiration

[4] https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_use_revoke-sessions.html

[5] https://docs.aws.amazon.com/awscloudtrail/latest/userguide/logging-data-events-with-cloudtrail.html

[6] https://docs.aws.amazon.com/AmazonS3/latest/userguide/enable-server-access-logging.html

Appendix

The use of the pre-signed URL after it was generated was visible in CloudTrail, if the CloudTrail in question had Data Events enabled. This makes sense as behind the scenes the pre-sign URL makes an S3 GetObject request. Below is an example of what a pre-signed URL looks like when captured as a CloudTrail data event. Following it is an example of a pre-signed URL being captured by an S3 access logging event. Finally, as mentioned earlier, is an example of what an autogenerated revoke inline policy looks like.

CloudTrail Data Event S3 Object Access Sample

    {
      "eventVersion": "1.08",
      "userIdentity": {
        "type": "IAMUser",
        "principalId": "AIDA5WBGGGGGGG5GGMG5G",
        "arn": "arn:aws:iam:: 112233445566:user/personal_research",
        "accountId": "112233445566",
        "accessKeyId": "AKIA5GGGGGGGGGGGGG5C",
        "userName": "personal_research"
      },
      "eventTime": "2023-01-30T16:48:09Z",
      "eventSource": "s3.amazonaws.com",
      "eventName": "GetObject",
      "awsRegion": "eu-west-1",
      "sourceIPAddress": "xx.xx.xx.xxx",
      "userAgent": "[Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/109.0]",
      "requestParameters": {
        "X-Amz-Date": "20230130T161448Z",
        "bucketName": "rob-presign-test",
        "X-Amz-Algorithm": "AWS4-HMAC-SHA256",
        "X-Amz-SignedHeaders": "host",
        "Host": "bucket-presign-test.s3.eu-west-1.amazonaws.com",
        "X-Amz-Expires": "3600",
        "key": "payload.txt"
      },
      "responseElements": null,
      "additionalEventData": {
        "SignatureVersion": "SigV4",
        "CipherSuite": "ECDHE-RSA-AES128-GCM-SHA256",
        "bytesTransferredIn": 0,
        "AuthenticationMethod": "QueryString",
        "x-amz-id-2": "obn7nIzyKIEQobBYk4SSTteqhZnuAlkaR9ryBeJVSrRrgJFCr2GBjsj2qrFXfmkGJdr6CQ8yyOQ=",
        "bytesTransferredOut": 36
      },
      "requestID": "FR5VM8F8DNSQZZ1X",
      "eventID": "08a86633-23ac-42ca-a8d5-635c44b247d6",
      "readOnly": true,
      "resources": [
        {
          "type": "AWS::S3::Object",
          "ARN": "arn:aws:s3:::bucket-presign-test/payload.txt"
        },
        {
          "accountId": "112233445566",
          "type": "AWS::S3::Bucket",
          "ARN": "arn:aws:s3:::bucket-presign-test"
        }
      ],
      "eventType": "AwsApiCall",
      "managementEvent": false,
      "recipientAccountId": "112233445566",
      "eventCategory": "Data",
      "tlsDetails": {
        "tlsVersion": "TLSv1.2",
        "cipherSuite": "ECDHE-RSA-AES128-GCM-SHA256",
        "clientProvidedHostHeader": "bucket-presign-test.s3.eu-west-1.amazonaws.com"
      }
    },

S3 Access Log Pre-Signed URL Data Download Example

133ad5f654eff80f3d0499b45ec198edd71a6055a67bc8be16e8a8dfd9f26d0f bucket-presign-test [07/Mar/2023:17:49:10 +0000] 60.95.35.155 - 02X7MFB8JN5CAVHN REST.GET.OBJECT favicon.ico "GET /favicon.ico HTTP/1.1" 403 AccessDenied   243 - 8 - "https://bucket-presign-test.s3.eu-west-1.amazonaws.com/payload.txt?response-content-disposition=inline&X-Amz-Security-Token=[TOKEN REDACTED]&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Date=20230307T174602Z&X-Amz-SignedHeaders=host&X-Amz-Expires=21599&X-Amz-Credential=ASIAXXXXXXXXXXXXXXXX%2F20230307%2Feu-west-1%2Fs3%2Faws4_request&X-Amz-Signature=6073c2a55069f0d82810e4f57a56ed666bbfe0c78836c72358a2906215f799aa" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36" - dQRw6wN7WgdfU7O8Sv5DiOB4HbMViH3ICL/GwoT84nh1sf0186bKghbFSEpmaJV/ZLEyUXmLu7M= - ECDHE-RSA-AES128-GCM-SHA256 - bucket-presign-test.s3.eu-west-1.amazonaws.com TLSv1.2 - -
133ad5f654eff80f3d0499b45ec198edd71a6055a67bc8be16e8a8dfd9f26d0f bucket-presign-test [07/Mar/2023:17:49:10 +0000] 60.95.35.155 133ad5f654eff80f3d0499b45ec198edd71a6055a67bc8be16e8a8dfd9f26d0f 02XBV9B4643K7RPQ REST.GET.OBJECT payload.txt "GET /payload.txt?response-content-disposition=inline&X-Amz-Security-Token=[TOKEN REDACTED]&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Date=20230307T174602Z&X-Amz-SignedHeaders=host&X-Amz-Expires=21599&X-Amz-Credential=ASIAXXXXXXXXXXXXXXXX%2F20230307%2Feu-west-1%2Fs3%2Faws4_request&X-Amz-Signature=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX HTTP/1.1" 200 - 11 11 16 15 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36" - /7m1K+gVNSZFEWcw0FjvMD38KykKc+RVzIwK8CxayuM0LUTOmkM7yy4htJm9/RUv/br3iVLBc5g= SigV4 ECDHE-RSA-AES128-GCM-SHA256 QueryString bucket-presign-test.s3.eu-west-1.amazonaws.com TLSv1.2 - -

Example of Revoke Inline Policy

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Deny",
            "Action": [
                "*"
            ],
            "Resource": [
                "*"
            ],
            "Condition": {
                "DateLessThan": {
                    "aws:TokenIssueTime": "[policy creation time]"
                }
            }
        }
    ]
}