The bucket labelled internal that handed out a table of contents
Metadata
- Title: The bucket labelled internal that handed out a table of contents
- Source of the case: Real HackerOne report #1021906 (Shopify)
- AWS service(s): S3
- Risk archetype: Read plus list — public access compounded by an enumeration grant the tag denies
- One-line hook: Can you prove a bucket tagged
internalis reachable from the open internet?
0. The challenge (what the reader does first)
Scenario given to the reader:
A production API bucket named ping-api-production is tagged environment: internal by the engineering team. The tag says internal; the team treats it as
internal. The bucket has a policy and no Public Access Block. You get the export
and have to decide whether "internal" is true and what an outside attacker could
actually do.
Evidence they're handed (and nothing else):
{
"bucket": "ping-api-production",
"tags": {"environment": "internal"},
"bucket_policy": {"Statement": [{"Effect": "Allow", "Principal": "*", "Action": ["s3:GetObject", "s3:ListBucket"], "Resource": ["arn:aws:s3:::ping-api-production", "arn:aws:s3:::ping-api-production/*"]}]},
"public_access_block": null
}
- The JSON export above. No AWS credentials. No live account. No scripts.
The questions they must answer from the evidence alone:
- Does the
environment: internaltag match what the policy actually grants, and what is the live access state right now? - The policy grants
s3:ListBucketas well ass3:GetObject. Does that change what an attacker can do, and could the absence of a PAB let this drift worse? - Which path opens the read — the policy's
Principal: *ons3:GetObject? - Which path opens enumeration — the policy's
Principal: *ons3:ListBucketagainst the bucket ARN? - What single rule would have made the
internaltag and the live access agree, and killed the listing grant?
1. The manual problem
By hand this is two reasoning steps, not one. First you confirm the policy
grants anonymous read — Principal: *, s3:GetObject, on .../*. Easy enough.
But the second action, s3:ListBucket, scoped to the bucket ARN (no /*), is
the one people miss because it reads like a duplicate of the read grant. It is
not. Listing means an attacker does not need to guess object keys — the bucket
hands them the index. Without listing you must know a key to fetch it; with
listing you enumerate everything and then fetch each one. Then you hold the
internal tag against all of it and realize the org's declared environment is
flatly contradicted. Three facts in three fields, and the worst one is the quiet
ListBucket.
2. The reasoning wall
| What they hit | What they said / would say |
|---|---|
s3:ListBucket looks like a near-duplicate of s3:GetObject and gets skimmed | "I read GetObject and stopped. I didn't register that ListBucket is a different, worse grant." |
The internal tag set the wrong expectation | "Everyone called it the internal bucket. The policy never agreed with that name." |
| Read-without-keys feels survivable until listing is in the picture | "If you need the key it's bad. If the bucket gives you the key list, it's over." |
The insight the reader should reach on their own:
Public read is a leak; public read plus public list is a downloadable inventory of everything you have.
3. Why scanners miss or flatten it
A per-setting scanner will flag "public bucket policy" and stop. It typically
does not distinguish the s3:GetObject grant from the s3:ListBucket grant, so
it cannot tell you the difference between an attacker who must guess keys and
an attacker handed the full key list. It also treats the environment: internal tag as unrelated metadata, so it cannot report the specific finding
that matters: the org declared this internal and the policy publishes its entire
object index. The combination — public read, public list, and a contradicting
classification tag, all on one bucket with no PAB — is exactly what a row-per-
setting checklist flattens into a single generic "public bucket" line.
Pivot point. Everything above is the gap. Everything below is Stave filling it. The reader has now done the work and hit the wall. Only now does the tool appear.
4. The evidence Stave consumes
The same static export the reader had — no new privileges, no live cloud:
{
"bucket": "ping-api-production",
"tags": {"environment": "internal"},
"bucket_policy": {"Statement": [{"Effect": "Allow", "Principal": "*", "Action": ["s3:GetObject", "s3:ListBucket"], "Resource": ["arn:aws:s3:::ping-api-production", "arn:aws:s3:::ping-api-production/*"]}]},
"public_access_block": null
}
Stave normalizes this into a bucket asset whose policy statement carries both actions and both resource ARNs (bucket and objects), so read and list are evaluated as distinct grants.
5. The reasoning Stave performs
- Control / invariant:
CTL.S3.PUBLIC.001— no public read.CTL.S3.PUBLIC.LIST.001— no public listing of the bucket. - What it evaluates:
CTL.S3.PUBLIC.001findsAllow Principal:* s3:GetObjecton the object ARN with no PAB.CTL.S3.PUBLIC.LIST.001findsAllow Principal:* s3:ListBucketscoped to the bucket ARN — the enumeration grant — and fires separately, covering the path a single public-read check would miss. - Verdict produced: Both fire and consolidate. Read and list are reported as distinct exposures, not one merged "public" verdict.
CTL.S3.PUBLIC.001 NON_COMPLIANT
asset: s3://ping-api-production
evidence: Allow Principal:* s3:GetObject on /*; public_access_block=null
verdict: anonymous read on every object
CTL.S3.PUBLIC.LIST.001 NON_COMPLIANT
asset: s3://ping-api-production
evidence: Allow Principal:* s3:ListBucket on bucket ARN
verdict: anonymous enumeration — attacker gets the full object index;
contradicts tag environment=internal
security_state: NON_COMPLIANT
6. The prevention artifact Stave produces
- Artifact: An account-level Public Access Block plus a policy diff that strips
s3:ListBucketfrom the public statement. - What it forecloses: The latent gap from question 2 — no PAB, so the public read+list state can persist and any future statement can re-add listing. PAB neutralizes both grants; removing
ListBucketkills enumeration even if read were intentional.
# Account-level Public Access Block — neutralizes public read AND list
aws s3control put-public-access-block \
--account-id <acct> \
--public-access-block-configuration \
BlockPublicPolicy=true,RestrictPublicBuckets=true,\
BlockPublicAcls=true,IgnorePublicAcls=true
# Policy diff — drop the enumeration grant
- "Action": ["s3:GetObject", "s3:ListBucket"]
- "Resource": ["arn:aws:s3:::ping-api-production", "arn:aws:s3:::ping-api-production/*"]
(statement removed entirely; internal data takes no Principal:* grant)
7. What the team no longer does manually
| Before | After Stave |
|---|---|
Distinguish s3:GetObject from s3:ListBucket by hand to gauge enumeration risk | Two controls report read and list as separate, named exposures |
Cross-check the environment: internal tag against the actual policy | The tag-vs-access contradiction is encoded and re-checked every run |
| Trust the name "production internal bucket" | A generated PAB makes the internal classification enforceable |
Positioning line for this case
Stave proves that
ping-api-productionis not internal at all — it is publicly readable and publicly listable, contradicting its owninternaltag — and emits the PAB plus policy diff that close both doors.
Reuse checklist
- A reader could attempt section 0 with zero Stave knowledge
- Stave is not named or shown before the pivot point
- Section 2 quotes are real (or honestly plausible), not slogans
- Section 3 names the specific thing per-setting tools can't see
- Section 6 closes the exact latent state raised in section 0, question 2
- The title names the failure, not the product