S3 misconfiguration greatest hits
Public buckets, ACLs, and Block Public Access.
Rent a storage unit the size of the internet, fill it with your company's files, and lose track of which doors you left unlocked. That is the whole shape of the problem. An S3 bucket (Simple Storage Service, the file store inside Amazon Web Services, or AWS) is one of those units: a named container that holds your files. Almost every "company leaks 100 million records" headline of the past decade, Verizon, Accenture, Booz Allen, whole state voter rolls, was not a clever break-in. It was an unlocked unit that someone found by walking the corridor with an automated scanner. S3 exposure is a short, boring list of repeat offenders. Here is the list, and the commands that find each open door and shut it.
Two words before the list. An object is a file plus its metadata (its name, size, content type, and tags). A bucket holds objects and carries a name that has to be unique across all of AWS worldwide, so acme-invoices is yours only if nobody else on the planet claimed it first. Every bucket answers to two separate permission languages, and mixing them up is where a lot of the trouble starts. The first is the bucket policy: a JSON (JavaScript Object Notation, a plain-text format for structured data) document attached to the bucket that spells out who may do what. The second is ACLs (access control lists), a grant system from 2006 that predates IAM (Identity and Access Management, the main permission system in AWS) and that AWS now tells you to switch off. When someone calls a bucket public, they mean anyone on the internet can read or write it with no account and no credentials. Nothing to steal, nothing to phish. They ask, and S3 answers.
Buckets created since April 2023 start life with ACLs disabled and Block Public Access switched on, and every new object has been encrypted at rest by default since January 2023. A bucket you make today is fairly safe on its own. The danger lives in the old estate: the buckets built before those defaults, still running on whatever their creator clicked in 2019, still holding data nobody has opened since.
The greatest hits, ranked by blast radius
Hit 1, the open bucket. A bucket policy whose Principal is "*" (everyone, no exceptions) paired with s3:GetObject, or an old public-read ACL, makes every object anonymously downloadable. Scanners crawl the S3 namespace around the clock. Time from "made public" to "indexed by a stranger" is measured in hours, not weeks.
Hit 2, the over-broad role. The bucket is private, but an EC2 (Elastic Compute Cloud, a virtual machine you rent in AWS) instance carries a role granting s3:* on arn:aws:s3:::*, which is every action on every bucket. One SSRF (Server-Side Request Forgery, tricking a server into making requests for the attacker) bug later, the attacker pulls that instance's credentials and "private" means nothing. This is the shape of the Capital One breach, and it never touched a public bucket.
Hit 3, no versioning, no lock. Anyone holding s3:DeleteObject, whether a disgruntled person or a buggy deploy script, can erase data for good. S3 ransomware is exactly this move: encrypt or delete the objects, then charge you to get them back. Versioning (keeping the previous copy of every object) and Object Lock (making a copy undeletable for a set window of time) are what turn "gone" into "recoverable."
Hit 4, the immortal presigned URL. A presigned URL is a link with a signature baked into it that hands its bearer temporary access to one object, no login required. Generate one with the maximum seven-day life, paste it into a Slack thread, and you have set a bearer token loose in the world with a week to live and no button to call it back.
Hit 5, the dangling bucket. You point assets.example.com at a bucket with a DNS (Domain Name System, the internet's address book) CNAME (Canonical Name, an alias from one hostname to another) record, then delete the bucket and forget the record. Now anyone can create a fresh bucket by that exact name and serve their files from your domain. That is subdomain takeover.
How S3 decides who gets in
A nightclub, several bouncers, one house rule: if any single one of them says no, you are not getting in, no matter how many of the others wave you through. That is S3 authorization. When a request lands, S3 gathers every rule that could apply to it: your IAM identity policy, the bucket policy, the ACLs if they are still switched on, plus the organization SCPs (Service Control Policies, account-wide guardrails) and VPC (Virtual Private Cloud, your own private network inside AWS) endpoint policies from earlier lessons. One explicit Deny anywhere in that pile ends the whole thing on the spot.
No Deny in the pile? Then S3 goes hunting for an Allow, and where that Allow has to live depends on whether the caller and the bucket sit in the same account. For a request inside one account, an Allow in either the identity policy or the bucket policy is enough on its own. For a cross-account request (a principal in account A reaching into a bucket in account B), you need an Allow on both sides at once: account A's identity policy has to permit it, and account B's bucket policy has to permit it too. Miss either half and the request dies. Block Public Access sits above all of this as a filter. It does not cast a vote the way a Deny does. It reaches in and throws out public grants before they can even be counted, so a public-read ACL or a Principal: "*" policy can be sitting right there and never fire.
The four switches inside Block Public Access
"Block Public Access" (BPA for short) sounds like a single light switch on the wall. It is actually four, and the way they split is the part people miss. Two of them stop new public grants from ever being created. BlockPublicAcls rejects any request that tries to attach a public ACL. BlockPublicPolicy rejects any attempt to save a bucket policy that would open the bucket to the world. The other two neutralize public grants that already exist. IgnorePublicAcls makes S3 act as if every public ACL on the bucket were not there. RestrictPublicBuckets takes a bucket that already carries a public policy and clamps it, letting only AWS services and callers inside the owning account through.
Here is why that split bites. Turn on only the two "block new" switches on a bucket that is already public, and your current exposure does not budge one inch. You have stopped the hole from getting any bigger while leaving today's hole wide open. You want all four on. The "ignore" and "restrict" pair are the ones that actually shut a door that is already standing open.
The lockdown, step by step
The right order depends on the account. A greenfield account (brand new, nothing public in it yet)? Turn on account-wide Block Public Access right now, first command, done. A brownfield account (an old one, full of buckets nobody has audited since 2019)? Sweep first, or you will hand yourself a 403 outage on your own production site. Here is the brownfield order, which is the one that saves you.
First, find out what you actually have. This loop asks AWS's own policy-analysis engine whether each bucket is public, and asks the ownership setting whether legacy ACLs are still alive on it.
for b in $(aws s3api list-buckets --query 'Buckets[].Name' --output text); dopub=$(aws s3api get-bucket-policy-status --bucket "$b" \--query 'PolicyStatus.IsPublic' --output text 2>/dev/null || echo "no-policy")own=$(aws s3api get-bucket-ownership-controls --bucket "$b" \--query 'OwnershipControls.Rules[0].ObjectOwnership' --output text 2>/dev/null || echo "ACLs-live")printf '%-24s public=%-10s ownership=%s\n' "$b" "$pub" "$own"done
app-logs-prod public=False ownership=BucketOwnerEnforcedmarketing-assets-2019 public=True ownership=ObjectWritertf-state-prod public=no-policy ownership=BucketOwnerEnforced
Read that middle line as the 2 a.m. page waiting to happen. marketing-assets-2019 is public and still honoring ACLs, so it has two independent ways to be wide open at once. BucketOwnerEnforced on the other two means ACLs are already off there, one permission language to reason about instead of two. And no-policy is not a warning at all. It only means the bucket has no bucket policy attached, so it cannot be public through one.
Next, harden the buckets that are staying. Three controls cover most of the hit list. Deny plaintext HTTP (HyperText Transfer Protocol, web traffic sent with no encryption) so that no object and no signature ever crosses the network in the clear. Turn ACLs off so there is one permission language instead of two arguing with each other. Turn on versioning so a delete leaves the old copy behind instead of wiping it.
{"Version": "2012-10-17","Statement": [{"Sid": "DenyInsecureTransport","Effect": "Deny","Principal": "*","Action": "s3:*","Resource": ["arn:aws:s3:::app-logs-prod","arn:aws:s3:::app-logs-prod/*"],"Condition": { "Bool": { "aws:SecureTransport": "false" } }}]}
aws s3api put-bucket-policy --bucket app-logs-prod --policy file://deny-insecure.json# Turn ACLs off: BucketOwnerEnforced makes S3 ignore every ACL grantaws s3api put-bucket-ownership-controls --bucket app-logs-prod \--ownership-controls 'Rules=[{ObjectOwnership=BucketOwnerEnforced}]'# Versioning: overwrites and deletes leave a recoverable copy behindaws s3api put-bucket-versioning --bucket app-logs-prod \--versioning-configuration Status=Enabled# only the last command returns anything; confirm it took:aws s3api get-bucket-versioning --bucket app-logs-prod
{"Status": "Enabled"}
One honest caveat on versioning. It saves you from an overwrite or an ordinary delete, because the previous version is still sitting there underneath. It does not save you from an attacker who holds s3:DeleteObjectVersion and deletes the old versions too. For data you genuinely cannot afford to lose, add Object Lock in compliance mode, which makes a version undeletable by anyone, the account root user included, until its retention timer runs out. Versioning is an undo button. Object Lock is a safe with a timer you cannot rush.
RestrictPublicBuckets=true on an old account instantly returns 403 (HTTP Forbidden, the server refusing the request) for any static website or asset bucket you serve straight from S3. Sweep with get-bucket-policy-status first. Move genuinely public content behind CloudFront (Amazon's CDN, or Content Delivery Network) using Origin Access Control (OAC), which keeps the bucket private and lets only the CDN read it. Enable the account-wide block last. S3 website endpoints cannot speak HTTPS (HTTP Secure, the encrypted version) anyway, so they have no business in production. Do these in the wrong order and you page yourself.Now, with the sweep clean and anything public tucked behind CloudFront, throw the account-wide switch. Account-level Block Public Access overrides every bucket setting underneath it, so this one command reaches buckets you have not even found yet. In a greenfield account you run it on day one. In a brownfield account it goes last, after the two steps above, and never first.
aws s3control put-public-access-block \--account-id 111122223333 \--public-access-block-configuration \BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true# put returns nothing on success; confirm with get:aws s3control get-public-access-block --account-id 111122223333
{"PublicAccessBlockConfiguration": {"BlockPublicAcls": true,"IgnorePublicAcls": true,"BlockPublicPolicy": true,"RestrictPublicBuckets": true}}
What the guardrails still miss
Block Public Access only ever deals with one thing: unauthenticated, anonymous access. Read Hit 2 again. A leaked or over-privileged credential makes an authenticated request, and it walks straight past BPA, which has no opinion at all about signed callers, only anonymous ones. That is why the least-privilege habit from the IAM lesson protects you more than any single S3 setting does. Presigned URLs slip through for the same reason. They carry a real signature from whoever generated them, so S3 sees an ordinary authenticated request and honors it. Cap their lifetime in code (minutes, not days) and treat handing one out as a deliberate, logged act.
A sweep is a photograph. What you want is a live feed. Turn on IAM Access Analyzer, which watches without ever stopping and files a finding the instant any bucket policy, ACL, or access point grants access outside your zone of trust (your own account, or your organization).
aws accessanalyzer create-analyzer --analyzer-name acct-public --type ACCOUNTaws accessanalyzer list-findings \--analyzer-arn arn:aws:access-analyzer:us-east-1:111122223333:analyzer/acct-public \--query "findings[?resourceType=='AWS::S3::Bucket' && status=='ACTIVE'].resource" \--output text
{"arn": "arn:aws:access-analyzer:us-east-1:111122223333:analyzer/acct-public"}arn:aws:s3:::marketing-assets-2019
Then pin the switch so nobody can quietly flip it back. An SCP that denies s3:PutAccountPublicAccessBlock and the bucket-level s3:PutBucketPublicAccessBlock to every principal except one break-glass role means your kill switch cannot be turned off by a tired engineer or a stolen credential. The ArnNotLike condition is what carves out that single escape hatch. Attach it in Organizations, the same way you attached the guardrails in the earlier lesson.
{"Version": "2012-10-17","Statement": [{"Sid": "ProtectBlockPublicAccess","Effect": "Deny","Action": ["s3:PutAccountPublicAccessBlock","s3:PutBucketPublicAccessBlock"],"Resource": "*","Condition": {"ArnNotLike": {"aws:PrincipalArn": "arn:aws:iam::*:role/break-glass-s3"}}}]}
Prevention loses a round eventually. An Access Analyzer finding fires, or GuardDuty (Amazon's threat-detection service that watches your account activity) reports Exfiltration:S3/AnomalousBehavior, and the question stops being "is the bucket public?" and becomes "what left it, when, and under which credential?" Answering that against the clock, containing the bucket, revoking the credential, reading CloudTrail (the audit log of every API call in your account) data events to see exactly which objects got touched, is incident response. That is the next lesson.
BlockPublicAcls and BlockPublicPolicy on a bucket that is already serving objects through a public-read ACL. What happens to the current public access?marketing-assets-2019 public=True ownership=ObjectWriter and tf-state-prod public=no-policy ownership=BucketOwnerEnforced. You are about to enable account-wide Block Public Access, all four settings. Which statement is correct?