Skip to content

IAM policy mishaps: Case 1 - S3

Continuing with the series of posts related to IAM misconfigurations, we are going to delve a bit into its use focused on the AWS S3 service.

To do this, we will look at the different ways we can control the security of the service and how dangerous it is to apply a policy without clearly understanding what it does.

Info

If you want to directly try the examples we are going to present, take a look at our repo .

We have prepared different scenarios in Terraform .

📣 We recommend checking out the first part of the series, IAM policy mishaps: Intro to IAM, if you haven't done so yet.

Now, let's talk about Cloud ☁.

For the cloud is dark and full of terrors

The S3 (Simple Storage Service) service is one of the most well-known and used services of AWS. It allows us to store objects with all types of data, which is why it is used by many applications.

AWS S3

Scenario 1

Objective: Grant permission to our application's role, App_A_role, so it can read and write files in our invoices bucket, invoices-bucket.

Since our bucket will contain private information, we want to prevent other users from accessing our information.

Scenario 1

We have different ways to allow access to our S3 bucket:

  1. No! Not this! 🚫

For obvious reasons, we are going to discard the first option, and since this post is about IAM, we will focus on the second option. Additionally, it is the recommended option for what we need.

The easiest way to build an IAM policy is by using the AWS console itself.

In it, we can select the S3 service and we will find the 158 actions available at the time of writing this.

S3 actions

We could review them one by one...

Most likely (and least recommended) is that we become overwhelmed and end up with a policy like this:

{
    "Version": "2012-10-17",
    "Statement": {
        "Sid": "RWS3Access",
        "Effect": "Allow",
        "Action": [
            "s3:*", // (1)!
        ],
        "Resource": [
            "arn:aws:s3:::sh3llcon-invoices-bucket", // (2)!
            "arn:aws:s3:::sh3llcon-invoices-bucket/*", // (3)!
        ]
    }
}
  1. s3:* allows all available actions (now and in the future) on S3.
  2. Actions are allowed on the bucket resource itself.
  3. Actions are allowed on all objects within the bucket.

If you try this policy, you will find that your application can read and write to your bucket perfectly fine, but... did you know it can also do, among many other things, all of this?

  • Delete the bucket.
  • Make it public.
  • Disable logs, if they are enabled.
  • Disable versioning, if it's enabled.
  • Disable data retention protections, if they are enabled.

Our application only needs to read and write files, we don't want it to be able to perform any of these actions, whether by mistake or due to a security flaw in our application.

(You'd be surprised how often similar policies are seen. 🫤)

Let's improve the policy a bit.

In IAM actions, you have the possibility to use wildcards, so if we use s3:Put*, we will authorize all S3 actions that are or start with Put. Since we only want to allow read List/Get and write Put/Delete, let's try the following policy:

{
    "Version": "2012-10-17",
    "Statement": {
        "Sid": "RWS3Access",
        "Effect": "Allow",
        "Action": [
            "s3:Delete*", // (1)!
            "s3:Get*", // (2)!
            "s3:List*", // (3)!
            "s3:Put*", // (4)!
        ],
        "Resource": [
            "arn:aws:s3:::sh3llcon-invoices-bucket",
            "arn:aws:s3:::sh3llcon-invoices-bucket/*",
        ]
    }
}
  1. s3:Delete* includes s3:DeleteBucket, among other permissions.
  2. s3:Get* allows actions to obtain information about the bucket, for example s3:GetBucketPolicy.
  3. s3:List* only includes s3:ListBucket, but AWS could add new permissions in the future.
  4. s3:Put* includes s3:PutBucketPolicy, among other permissions.

With this policy, we have removed some unnecessary permissions for the application, but we can still perform the same actions as mentioned above:

Let's continue refining the policy:

{
    "Version": "2012-10-17",
    "Statement": {
        "Sid": "RWS3Access",
        "Effect": "Allow",
        "Action": [
            "s3:DeleteObject",
            "s3:GetObject",
            "s3:ListBucket",
            "s3:PutObject",
        ],
        "Resource": [
            "arn:aws:s3:::sh3llcon-invoices-bucket",
            "arn:aws:s3:::sh3llcon-invoices-bucket/*", // (1)!
        ]
    }
}
  1. This * applies to all objects within the bucket, it would be equivalent to specifying the full path of each file. You could also put the condition at the folder level. If you use shared buckets, be careful when applying these wildcards to the resource.

Remember

Asterisks * are dangerous, make sure you know what you are allowing before using them in your policies.

Tip

If the application should not delete files, remove s3:DeleteObject. We don't want to allow accidental deletions.

Security Check

Security Approves!

Scenario 2

Let's complicate our scenario a bit.

Objective: Now the bucket will be in a separate account and we will want to have read permission from two roles, App_A_role and App_B_role, located in different accounts.

Scenario 2

The applications should only be able to list and read the files available in the bucket.

Based on what we've seen before, we will assign the following policy to both roles:

{
    "Version": "2012-10-17",
    "Statement": {
        "Sid": "ROS3Access",
        "Effect": "Allow",
        "Action": [
            "s3:GetObject",
            "s3:ListBucket",
        ],
        "Resource": [
            "arn:aws:s3:::sh3llcon-invoices-bucket",
            "arn:aws:s3:::sh3llcon-invoices-bucket/*",
        ]
    }
}

Since the bucket is located in a separate account, we need to add another authorization mechanism. Because the policies assigned to our IAM roles have no effect outside of our account.

So, we will use resource policies (in this case called bucket policies).

We add the following bucket policy to the bucket:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "Sh3llconPolicy",
      "Effect": "Allow",
      "Principal": {
        "AWS": "*"
      },
      "Action": [
        "s3:ListBucket",
        "s3:GetObject"
      ],
      "Resource": [
        "arn:aws:s3:::sh3llcon-invoices-bucket/*",
        "arn:aws:s3:::sh3llcon-invoices-bucket"
      ],
      "Condition": {
        "ForAllValues:StringEquals": {
          "aws:PrincipalArn": [
            "arn:aws:iam::111111111111:role/App_A_role",
            "arn:aws:iam::222222222222:role/App_B_role"
          ]
        }
      }
    }
  ]
}

If we try from our apps we will have access, but let's try another thing:

curl https://sh3llcon-invoices-bucket.s3-us-west-2.amazonaws.com/
Output
<?xml version="1.0" encoding="UTF-8"?>
<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01">
    <Name>sh3llcon-invoices-bucket</Name>
    <Prefix></Prefix><Marker></Marker><MaxKeys>1000</MaxKeys>
    <IsTruncated>false</IsTruncated>
    <Contents><key>flag.txt</key>
    <LastModified>2024-01-24T09:03:12.000Z</LastModified>
    <ETag>&quot;e82274fac36cf343b567493489557f84&quot;</ETag><Size>22</Size>
    <Owner><ID>e8c8232133f49c01bd96e5dc81337b571fb3daa0819936e9d3f470bd1ff21f60</ID><DisplayName>cloudsec+shellcon</DisplayName></Owner>
    <StorageClass>STANDARD</StorageClass></Contents>
</ListBucketResult>
curl https://sh3llcon-invoices-bucket.s3-us-west-2.amazonaws.com/flag.txt
Output
FLAG{Hello sh3llcon!}

We can also try this using the AWS CLI, but adding the flag --no-sign-request to be sure we are not using our authorized role.

aws s3 cp s3://sh3llcon-invoices-bucket.s3-us-west-2.amazonaws.com/flag.txt - --no-sign-request
Output
FLAG{Hello sh3llcon!}

Ouch... It looks that our bucket has been exposed, let's fix the policy:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "Sh3llconPolicy",
      "Effect": "Allow",
      "Principal": {
        "AWS": "*"
      },
      "Action": [
        "s3:ListBucket",
        "s3:GetObject"
      ],
      "Resource": [
        "arn:aws:s3:::sh3llcon-invoices-bucket/*",
        "arn:aws:s3:::sh3llcon-invoices-bucket"
      ],
      "Condition": {
        "StringEquals": {
          "aws:PrincipalArn": [
            "arn:aws:iam::111111111111:role/App_A_role",
            "arn:aws:iam::222222222222:role/App_B_role"
          ],
          "aws:PrincipalOrgID": "0-XXXXXXXX" // (1)!
        }
      }
    }
  ]
}
  1. This is optional, but this way we ensure we only allow access from our AWS Organization.

You should not use ForAllValues or ForAnyValue with single-value context keys as it can lead to misconfigurations.

As in the case of ForAllValues, which as explained in the documentation:

It also returns true if there are no context keys in the request, or if the context key value resolves to a null dataset, such as an empty string.

This means that in our tests, where we are not using any roles (and therefore there is no aws:PrincipalArn), the condition evaluates to True.

Here is an article by Michael Kirchner that explains it in detail.

Tip

To avoid these problems, we must ensure that if we do not need to access the bucket from outside of AWS, we enable block public access.

Block Public Access

Conclusion

Not everything are problems, AWS has been including new measures to help protect our buckets:

  • Buckets are private by default.
  • Block public Access enabled by default.
  • ACLs are disabled by default.
  • Access Analyzer (a service that detects findings in IAM policies) in the bucket policy editor.

IAM Access Analyzer

But they have also added new ways to grant access to a bucket, so we will always have to stay up to date to review how to keep our buckets secure.

Tweet from @Frichette_n

So in the end, the important thing is to keep your systems and yourself updated 😉

Old man yells at cloud...

And if not, we can always yell at the cloud...

See you in the next episode with SNS.

Saludos, and may the force be with you.