Cloud Security Wire
AWS Azure GCP RSS
AWS Breach Analysis high

Anatomy of an S3 Data Exposure: ACLs, Bucket Policies, and the Public Access Block

A forensic breakdown of how S3 buckets end up publicly accessible — the interplay between ACLs, bucket policies, and Public Access Block settings — illustrated with real breach patterns and detection techniques.

By Cloud Security Wire · ·
#s3#data-exposure#acl#bucket-policy#public-access
High Severity

This issue has been assessed as high severity. Review affected configurations immediately.

S3 bucket exposure remains one of the most frequently reported causes of cloud data breaches. Despite years of awareness, billions of records have leaked through publicly accessible buckets — from voter registration databases to medical records to Fortune 500 source code. Understanding why requires understanding the three independent access control layers that AWS provides and how they interact.

The Three Layers of S3 Access Control

1. Bucket ACLs (Access Control Lists)

ACLs are the original S3 access control mechanism, predating IAM policies. They operate at the object and bucket level and use predefined “grantee” groups:

  • AllUsers — anyone on the internet, authenticated or not
  • AuthenticatedUsersany authenticated AWS account (widely misunderstood as “my account’s users”)
  • LogDelivery — for server access logging

A bucket ACL granting READ to AllUsers makes every object in the bucket publicly listable and downloadable. An ACL granting AuthenticatedUsers READ access is nearly as dangerous — any of the ~400M+ AWS accounts in existence can access the data.

The critical misunderstanding: AuthenticatedUsers does not mean “my IAM users.” It means any AWS account. Many engineers assume this setting provides organizational access control. It does not.

2. Bucket Policies

Bucket policies are JSON IAM-style policies attached to the bucket. A public-read bucket policy looks like this:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "PublicRead",
      "Effect": "Allow",
      "Principal": "*",
      "Action": ["s3:GetObject"],
      "Resource": "arn:aws:s3:::my-data-bucket/*"
    }
  ]
}

"Principal": "*" is the tell — this allows any entity, authenticated or not, to get objects. Bucket policies with wildcarded principals and no Condition constraints are the most common cause of public data exposure in breach reports.

3. Block Public Access (BPA)

Introduced in 2018, BPA is a set of four account-level or bucket-level settings designed to be an override that prevents accidental public access regardless of ACL or policy settings:

SettingWhat It Blocks
BlockPublicAclsRejects PUT requests that include public ACLs; removes public ACLs on existing objects
IgnorePublicAclsMakes the service ignore any public ACLs when evaluating access
BlockPublicPolicyRejects bucket policies that grant public access
RestrictPublicBucketsRestricts access to a bucket with a public policy to only AWS services and authorized users

With all four settings enabled, neither ACLs nor bucket policies can make a bucket public — the BPA settings override them. AWS now enables all four at the account level by default for new accounts created after April 2023.

How Buckets End Up Public: Common Breach Patterns

Pattern 1: ACL set to public during creation, BPA disabled

Pre-2023, the AWS Console allowed “public” bucket creation without warning. Infrastructure-as-code templates copied from tutorials often included acl = "public-read" in Terraform without setting BPA. Buckets created this way remained public until manually remediated.

Pattern 2: Bucket policy added for CDN access, scope too broad

A common architecture: an S3 bucket behind CloudFront, with a bucket policy allowing the CloudFront OAI (Origin Access Identity) to read objects. A misconfigured policy adds "Principal": "*" instead of the specific OAI ARN, inadvertently making the bucket public even without CloudFront:

{
  "Effect": "Allow",
  "Principal": "*",
  "Action": "s3:GetObject",
  "Resource": "arn:aws:s3:::my-cdn-bucket/*"
}

Correct version using Origin Access Control (OAC, the modern replacement for OAI):

{
  "Effect": "Allow",
  "Principal": {
    "Service": "cloudfront.amazonaws.com"
  },
  "Action": "s3:GetObject",
  "Resource": "arn:aws:s3:::my-cdn-bucket/*",
  "Condition": {
    "StringEquals": {
      "AWS:SourceArn": "arn:aws:cloudfront::123456789012:distribution/EDFDVBD6EXAMPLE"
    }
  }
}

Pattern 3: Cross-account access granted to * with condition

Conditions can be bypassed. A bucket policy like this is still dangerous if the condition key doesn’t cover all access paths:

{
  "Effect": "Allow",
  "Principal": "*",
  "Action": "s3:GetObject",
  "Resource": "arn:aws:s3:::my-bucket/*",
  "Condition": {
    "IpAddress": {
      "aws:SourceIp": "203.0.113.0/24"
    }
  }
}

If the IP range changes or is broader than intended, objects are exposed to the entire CIDR block.

Pattern 4: BPA disabled for legacy application compatibility

Engineering teams sometimes disable BPA to accommodate legacy applications that use public ACLs. Without immediately remediating those ACLs, the entire bucket (or account) becomes vulnerable.

Detecting Exposed Buckets

AWS Config rules3-bucket-public-read-prohibited and s3-bucket-public-write-prohibited run continuously and flag buckets with public access:

aws configservice get-compliance-details-by-config-rule \
  --config-rule-name s3-bucket-public-read-prohibited \
  --compliance-types NON_COMPLIANT

Security Hub consolidates findings from Config rules and Macie into a single view. Enable the CIS AWS Foundations Benchmark standard, which includes S3 public access controls.

Amazon Macie provides data classification — not just exposure detection, but identifying what is in exposed buckets (PII, credentials, financial data).

Manual audit script:

for bucket in $(aws s3api list-buckets --query 'Buckets[].Name' --output text); do
  bpa=$(aws s3api get-public-access-block --bucket "$bucket" 2>/dev/null \
    | jq '.PublicAccessBlockConfiguration | all(.[])' 2>/dev/null)
  echo "$bucket: BPA fully enabled = ${bpa:-false}"
done

Remediation

Enable BPA at the account level — this is the highest-leverage single action:

aws s3control put-public-access-block \
  --account-id 123456789012 \
  --public-access-block-configuration \
  BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true

Disable ACLs entirely using the Bucket Owner Enforced setting (Object Ownership):

aws s3api put-bucket-ownership-controls \
  --bucket my-bucket \
  --ownership-controls 'Rules=[{ObjectOwnership=BucketOwnerEnforced}]'

With BucketOwnerEnforced, all ACLs are disabled and ownership is always the bucket owner. This is the recommended default for any new bucket that doesn’t need cross-account object writes.

Enforce via SCP:

{
  "Effect": "Deny",
  "Action": "s3:PutBucketPublicAccessBlock",
  "Resource": "*",
  "Condition": {
    "Bool": {
      "s3:DataAccessPointPublic": "true"
    }
  }
}

The key insight from breach post-mortems: BPA is not enabled by default on older accounts or buckets created before 2023. Any organization running AWS workloads before that date should audit BPA status across all accounts and buckets as an immediate priority.

← All Analysis Subscribe via RSS