Skip to main content

The payment-processor bucket with two front doors and a fix that only closed one

Metadata

  • Title: The payment-processor bucket with two front doors and a fix that only closed one
  • Source of the case: Real HackerOne report #1474017 (Omise)
  • AWS service(s): S3
  • Risk archetype: Dual exposure — independent ACL and policy paths to the same public state
  • One-line hook: If you fix the obvious public path, can you prove the bucket is actually private now?

0. The challenge (what the reader does first)

Scenario given to the reader:

A CDN origin bucket, omise-cdn-assets, stores assets for a payment processor. It has both an ACL granting AllUsers READ and a bucket policy granting Principal: * read and list, and no Public Access Block. You get the export. The trap: imagine you fix the part you notice first — does the bucket become private, or is there a second open door you didn't close?

Evidence they're handed (and nothing else):

{
"bucket": "omise-cdn-assets",
"acl": {"grants": [{"grantee": "AllUsers", "permission": "READ"}]},
"bucket_policy": {"Statement": [{"Effect": "Allow", "Principal": "*", "Action": ["s3:GetObject", "s3:ListBucket"], "Resource": ["arn:aws:s3:::omise-cdn-assets", "arn:aws:s3:::omise-cdn-assets/*"]}]},
"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. How many independent paths grant public read right now — and is the bucket public if even one of them is removed?
  2. If someone fixes the ACL but not the policy (or vice versa), is the bucket still public — and could a "we fixed it" ticket close silently while the exposure persists?
  3. Which exposure comes from the bucket policy — Principal: * read and list?
  4. Which exposure comes from the ACL — AllUsers READ?
  5. What single setting blocks both paths at once, regardless of which one a partial fix forgot?

1. The manual problem

The hard part is not finding a public path — it is counting them and proving none is load-bearing alone. Here there are two: the ACL's AllUsers: READ and the policy's Principal: * read+list. They are independent. Remove the ACL grant and the policy still serves the world; remove the policy and the ACL still does. A reviewer who notices one, fixes it, and re-checks the thing they fixed will see it gone and declare victory — while the other door stays open. To answer correctly by hand you have to enumerate every grant mechanism S3 has (policy, bucket ACL, object ACL), confirm each independently, and resist the urge to stop at the first one. For a payment processor's asset origin, "we removed the public ACL" is a dangerous half-truth.


2. The reasoning wall

What they hitWhat they said / would say
Two independent public paths, easy to fix only the first one found"I killed the public ACL, retested the ACL, it was clean. I never re-checked the policy."
Partial remediation reads as complete remediation"The ticket said 'made bucket private.' It made one path private."
Each mechanism has to be reasoned about separately"ACL and policy are different systems. Fixing one tells you nothing about the other."

The insight the reader should reach on their own:

A bucket with two doors isn't private until both are shut — and closing the one you noticed proves nothing about the one you didn't.


3. Why scanners miss or flatten it

A per-setting scanner that reports "bucket is public" gives you one row and no sense that two independent mechanisms produced it. After a partial fix, the scanner may still say "public" — but it cannot tell you which path remains, so the team cannot tell whether their fix worked or which door is still open. The thing a setting-by-setting tool cannot express is the multiplicity: that the ACL grant and the policy grant are separate causes, each sufficient on its own, and that remediation must close every cause, not the first one found. It also cannot prove the negative the team actually needs — "after this change, no path makes this bucket public" — because it has no notion of the bucket's combined state across mechanisms.


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": "omise-cdn-assets",
"acl": {"grants": [{"grantee": "AllUsers", "permission": "READ"}]},
"bucket_policy": {"Statement": [{"Effect": "Allow", "Principal": "*", "Action": ["s3:GetObject", "s3:ListBucket"], "Resource": ["arn:aws:s3:::omise-cdn-assets", "arn:aws:s3:::omise-cdn-assets/*"]}]},
"public_access_block": null
}

Stave normalizes this into a bucket asset carrying both the ACL grants and the policy statements as distinct, independently evaluable facts.


5. The reasoning Stave performs

  • Control / invariant: CTL.S3.PUBLIC.001 (public read via policy), CTL.S3.PUBLIC.LIST.001 (public list via policy), CTL.S3.ACL.OBJECT.001 (ACL grants AllUsers READ).
  • What it evaluates: Each control evaluates its own grant mechanism independently. The policy controls fire on Principal: *; the ACL control fires on the AllUsers grantee — so each open door produces its own finding. A fix that removes only one mechanism leaves the other control still firing on the next run, which is exactly the proof a partial fix lacks.
  • Verdict produced: Three findings, dual exposure. Re-running after a one-path fix still returns NON_COMPLIANT until both paths are closed.
CTL.S3.PUBLIC.001       NON_COMPLIANT
asset: s3://omise-cdn-assets
evidence: policy Allow Principal:* s3:GetObject; public_access_block=null
verdict: public read via policy

CTL.S3.PUBLIC.LIST.001 NON_COMPLIANT
asset: s3://omise-cdn-assets
evidence: policy Allow Principal:* s3:ListBucket on bucket ARN
verdict: public enumeration via policy

CTL.S3.ACL.OBJECT.001 NON_COMPLIANT
asset: s3://omise-cdn-assets
evidence: acl grant AllUsers READ
verdict: public read via ACL — independent of the policy path

security_state: NON_COMPLIANT (two independent public-read paths)

6. The prevention artifact Stave produces

  • Artifact: A full four-flag Public Access Block, generated from the violating state.
  • What it forecloses: The latent gap from question 2 — a partial fix that closes one path and leaves the other live. PAB with all four flags suppresses public policy and public ACLs simultaneously, so no single-mechanism remediation can leave a door open.
# One setting that blocks BOTH the policy path and the ACL path
aws s3control put-public-access-block \
--account-id <acct> \
--public-access-block-configuration \
BlockPublicAcls=true,\ # kills the AllUsers READ ACL path
IgnorePublicAcls=true,\ # ignores any object-level public ACL
BlockPublicPolicy=true,\ # kills the Principal:* policy path
RestrictPublicBuckets=true # locks the combined state shut

7. What the team no longer does manually

BeforeAfter Stave
Enumerate every grant mechanism (policy, ACL) by hand to count public pathsEach mechanism is a separate control; every open path gets its own finding
Re-check only the path you fixed and assume the bucket is now privateA re-run still flags any remaining path — partial fixes can't pass silently
Hope "made the bucket private" closed all the doorsA four-flag PAB closes policy and ACL paths at once and proves it

Positioning line for this case

Stave proves that omise-cdn-assets is public through two independent paths — the ACL and the policy — so fixing one cannot close it, and emits the single Public Access Block that shuts both at once.


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