✓ Human-authored analysis; AI used for formatting and proofreading.
s3:ListBucket does not return object contents. It returns the key inventory — the filenames, prefixes, sizes, and timestamps of every object in the bucket. That inventory is the map for an attacker. Knowing what to look for makes everything that comes next cheaper.
Three HackerOne cases on file demonstrate the pattern: Shopify report 57505 (public list), Zomato report 507097 (public list paired with read), and Sriram's 2017 disclosure (LIST + WRITE on bucket-level ACL). Each one is the same policy:
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": "*",
"Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::<bucket>"
}]
}
Principal: "*" on s3:ListBucket. No read permission required.
Anyone on the internet can call:
aws s3 ls s3://<bucket>/ --no-sign-request
…and receive every key in the bucket, paginated.
Why Listing is The Discovery Signal
Public read disclosure tells you what to grab. Public list disclosure tells you what exists. The two failure modes are different:
| What you have | Public read alone | Public list alone | Both |
|---|---|---|---|
| Random object URL guess | Sometimes works | Doesn't help | Works |
| Targeted by name | Works | Returns the name | Works |
| Inventory recon | None | Full | Full |
| Time to reproduce | High | Low | Low |
Listing without reading is enough on its own when the keys themselves carry information: customer email addresses in filenames, dump-<date>.sql patterns that imply a database backup cadence, prefixes like users/12345/profile.json that leak ID schemes, or build-artefact paths that reveal the software stack. The Zomato report (507097) describes this — public listing paired with predictable key naming meant the bucket was effectively public-read once an attacker knew the schema.
Listing is also a stable signal. A public-read bucket whose operators rotate object keys after every incident is a moving target. A public-list bucket cannot rotate the schema cheaply — that would break every consumer.
The System Invariant
No S3 bucket may resolve a
ListBucketrequest from an unauthenticated principal.
Same shape as the public-read invariant. A fold of (a) bucket policy, (b) ACL, (c) Public Access Block, (d) account-level PAB — with s3:ListBucket as the action of interest. In the observation schema the engine's verdict surfaces under storage.access.public_list, with the raw policy text alongside under storage.policy_json.
Stave catalogs two versions of this invariant:
-
CTL.S3.PUBLIC.LIST.001— the strict rule. Listing must not be public, period. Severityhigh. -
CTL.S3.PUBLIC.LIST.002— the graduated rule. Listing may be public only when the bucket carriespublic_list_intended=trueas a tag. The tag forces the intent to be explicit and auditable; it does not weaken the default.
This article focuses on the .001 form. The .002 graduated rule is the right control for buckets that legitimately serve as public archives (open-data publishing, package mirrors). It turns "is listing public" from a binary unsafe-state question into "is the public listing intended and labelled."
The Control Predicate
id: CTL.S3.PUBLIC.LIST.001
name: No Public S3 Bucket Listing
severity: high
unsafe_predicate:
any:
- field: properties.storage.access.public_list
op: eq
value: true
One leaf clause. The engine's public_list fold is the answer the predicate consults. The raw policy_json field carries the evidence behind that fold for downstream solver use.
Reproducing The Detection
The repository ships a self-contained example at stave/examples/s3-public-list-policy/. From the stave/ directory:
go run ./examples/s3-public-list-policy before
The fixture under fixtures/before/ is a bucket called acme-public-archive whose policy admits Principal: "*" for s3:ListBucket on the bucket ARN. Two snapshots, captured a week apart, both unsafe. The captured stdout (in expected/before-output.txt) is byte-for-byte:
=== before (vulnerable) ===
status: NON_COMPLIANT total_assets=1 violations=1
CTL.S3.PUBLIC.LIST.001 fired on 1 asset(s):
- arn:aws:s3:::acme-public-archive severity=high exposure_score=100.00
assertion: fires=true (expected) ✓
severity=high rather than critical — listing is the recon step, not the breach itself. The control fires because the listing exposure makes every subsequent stage cheaper, not because listing alone exfiltrated data.
Authoring Note: ListBucket Resource ARNs
When operators try to scope a list policy and get it wrong, the most common mistake is the Resource ARN's arity:
{
"Effect": "Allow",
"Principal": {"AWS": "arn:aws:iam::111122223333:role/AcmeArchiveReader"},
"Action": "s3:ListBucket",
- "Resource": "arn:aws:s3:::acme-public-archive/*"
+ "Resource": "arn:aws:s3:::acme-public-archive"
}
s3:ListBucket operates on the bucket, not on objects. The trailing /* makes the Resource pattern fail to match the bucket itself. Policies that mix this up silently fail to grant listing, the operator panics, and the safest emergency fix gets applied: open up the policy to Principal: "*". That's how several of the disclosed reports got there. The bug that produced the policy was a typo. The bug that exposed the data was the panic fix.
The Remediation
Two changes:
1. Scope the policy — name the role that legitimately needs to list, and use the bucket-level Resource:
{
"Version": "2012-10-17",
"Statement": [{
- "Sid": "PublicList",
+ "Sid": "AppRoleListOnly",
"Effect": "Allow",
- "Principal": "*",
+ "Principal": {"AWS": "arn:aws:iam::111122223333:role/AcmeArchiveReader"},
"Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::acme-public-archive"
}]
}
2. Turn on the full Public Access Block — same four flags as for public read:
aws s3api put-public-access-block \
--bucket acme-public-archive \
--public-access-block-configuration \
BlockPublicAcls=true,IgnorePublicAcls=true,\
BlockPublicPolicy=true,RestrictPublicBuckets=true
If the bucket genuinely needs to be a public archive, leave the public listing in place but tag it explicitly:
aws s3api put-bucket-tagging \
--bucket acme-public-archive \
--tagging 'TagSet=[{Key=public_list_intended,Value=true}]'
That tag flips the bucket from CTL.S3.PUBLIC.LIST.001 (strict) to CTL.S3.PUBLIC.LIST.002 (graduated) — explicit intent, the posture audit can show it was deliberate.
Verifying the Fix
go run ./examples/s3-public-list-policy after
Captured output (expected/after-output.txt):
=== after (remediated) ===
status: COMPLIANT total_assets=1 violations=0
CTL.S3.PUBLIC.LIST.001: no findings
assertion: fires=false (expected) ✓
The control is silent. The example exits 0.
The Prevention Lesson
Listing is the stable part of the breach. Object names change as new content lands. The bucket's public-list disposition does not. That makes prevention worth investing in:
Service Control Policy denying public-list-creating actions:
{
"Sid": "DenyPublicListPolicies",
"Effect": "Deny",
"Action": [
"s3:PutBucketPolicy",
"s3:PutBucketAcl"
],
"Resource": "*",
"Condition": {
"Bool": {
"s3:PublicAccessBlockConfiguration.RestrictPublicBuckets": "false"
}
}
}
Module-level enforcement — the IaC bucket module wraps every bucket with PAB on, and a separate public-archive module exists for the rare cases where listing is intentional. The existence of two distinct modules makes the choice visible in review.
CI invariant check — stave apply runs against the pre-merge snapshot. A PR that publishes a bucket to public list without setting public_list_intended=true either fails the strict .001 control (if it shouldn't be public) or graduates to the .002 control with the tag in place.
The CI gate catches changes made outside IaC. A console click that opens listing for "just one bucket, just for debugging" trips the predicate the same way it trips for any other path.
Checklist
- Every bucket carries
public_list=falseunless thepublic_list_intended=truetag is also set - Bucket policies that grant
s3:ListBucketuse the bucket ARN as Resource (notbucket/*) - PAB has all four flags on for non-archive buckets
- An organization-level SCP denies bucket-policy / bucket-ACL changes that would re-enable public listing
- CI runs
stave applyand blocks onCTL.S3.PUBLIC.LIST.001findings; graduated buckets surface asCTL.S3.PUBLIC.LIST.002instead
The reports differ in industry and in what data the keys revealed. The configuration that exposed the inventory was identical. Listing is the cheap, stable half of the breach — the half that prevention pays the most to close.
The example is at stave/examples/s3-public-list-policy/ — a self-contained Go program that loads two fixture snapshots, runs pkg/stave.Apply, asserts that CTL.S3.PUBLIC.LIST.001 fires on the vulnerable fixture and is silent on the remediated one, and exits zero when both assertions hold. Stave detects this pattern and 31 other H1-grounded scenarios from local AWS configuration snapshots, with no cloud credentials.
Top comments (0)