Skip to main content

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 internal is 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:

  1. Does the environment: internal tag match what the policy actually grants, and what is the live access state right now?
  2. The policy grants s3:ListBucket as well as s3:GetObject. Does that change what an attacker can do, and could the absence of a PAB let this drift worse?
  3. Which path opens the read — the policy's Principal: * on s3:GetObject?
  4. Which path opens enumeration — the policy's Principal: * on s3:ListBucket against the bucket ARN?
  5. What single rule would have made the internal tag 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 hitWhat 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.001 finds Allow Principal:* s3:GetObject on the object ARN with no PAB. CTL.S3.PUBLIC.LIST.001 finds Allow Principal:* s3:ListBucket scoped 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:ListBucket from 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 ListBucket kills 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

BeforeAfter Stave
Distinguish s3:GetObject from s3:ListBucket by hand to gauge enumeration riskTwo controls report read and list as separate, named exposures
Cross-check the environment: internal tag against the actual policyThe 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-production is not internal at all — it is publicly readable and publicly listable, contradicting its own internal tag — 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