The test builds that shipped their own secrets to the public internet
Metadata
- Title: The test builds that shipped their own secrets to the public internet
- Source of the case: Real HackerOne report #404822 (Slack)
- AWS service(s): S3
- Risk archetype: False protection — a Public Access Block object exists but every flag that matters is off
- One-line hook: Can you prove this bucket is protected just because it has a Public Access Block?
0. The challenge (what the reader does first)
Scenario given to the reader:
Slack's iOS test build artifacts are stored in an S3 bucket. There is a Public Access Block on the bucket, so at a glance the team checked the "protect against public access" box. The bucket also has a policy. You have the export and nothing else — decide whether the build artifacts are actually safe.
Evidence they're handed (and nothing else):
{
"bucket": "slack-ios-builds",
"bucket_policy": {"Statement": [{"Effect": "Allow", "Principal": "*", "Action": "s3:GetObject", "Resource": "arn:aws:s3:::slack-ios-builds/*"}]},
"public_access_block": {"BlockPublicPolicy": false, "BlockPublicAcls": false}
}
- The JSON export above. No AWS credentials. No live account. No scripts.
The questions they must answer from the evidence alone:
- Is
slack-ios-buildsanonymously readable right now, even though a Public Access Block object is present? - The PAB exists but
BlockPublicPolicyisfalse. Is the bucket only safe by luck, and could a routine policy edit make it worse without anyone touching the PAB? - Which path grants the exposure — the bucket policy's
Principal: *read? - Is the PAB doing anything at all here, or is it a no-op that creates false confidence?
- What single PAB flag, flipped to
true, would have neutralized this policy regardless of what the policy said?
1. The manual problem
The presence of a public_access_block object is a trap for the eye. You see
the key and assume the bucket is locked down. To answer correctly you have to
ignore the existence of the PAB and read its contents: BlockPublicPolicy: false means the PAB does not override the policy, and BlockPublicAcls: false
means it does not override ACLs either. So the PAB is present and inert. Then
you read the policy and find Principal: * read on every object. The bucket is
public. The hard part is not the policy — it is refusing to be reassured by a
toggle that is set to off. And then you have to reason about blast radius: test
builds carry debug symbols, internal endpoints, service names, sometimes tokens.
2. The reasoning wall
| What they hit | What they said / would say |
|---|---|
| A PAB object is present, so the bucket "has protection" | "I saw it had a public access block and moved on. I didn't read the flags inside it." |
BlockPublicPolicy: false is the whole story and it's easy to miss | "The protection was there but switched off. Present and useless are not the same thing." |
| Knowing it's a test bucket understates the risk | "It's just test builds — until you remember test builds ship endpoints and tokens we never meant to publish." |
The insight the reader should reach on their own:
A protection that exists but is turned off is more dangerous than no protection, because it stops people from looking.
3. Why scanners miss or flatten it
A per-setting scanner checks "does this bucket have a Public Access Block?" and
the honest answer is yes — so a naive check passes. The danger lives one level
down, in the relationship between the PAB flags and the policy: the PAB is
present but its BlockPublicPolicy flag is false, so it does not neutralize
the Principal: * policy. A checklist that scores each setting independently
sees "PAB: present (good)" and "policy: public (bad)" as two unrelated rows,
when the real finding is that the PAB fails to cover the policy. The thing a
setting-by-setting tool cannot express is "this protection exists but does not
apply to the exposure that exists." That is a cross-setting fact, not a single
toggle.
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": "slack-ios-builds",
"bucket_policy": {"Statement": [{"Effect": "Allow", "Principal": "*", "Action": "s3:GetObject", "Resource": "arn:aws:s3:::slack-ios-builds/*"}]},
"public_access_block": {"BlockPublicPolicy": false, "BlockPublicAcls": false}
}
Stave normalizes this into a bucket asset carrying both the policy statements and the PAB flag values, so the evaluator can reason about whether the PAB actually covers the policy.
5. The reasoning Stave performs
- Control / invariant:
CTL.S3.PUBLIC.001— no bucket may serve read toPrincipal: *unless a Public Access Block actually neutralizes that grant. - What it evaluates: The predicate finds the
Allow Principal:* s3:GetObjectstatement, then checks the PAB flags. BecauseBlockPublicPolicyisfalse, the PAB does not suppress the policy, so the public-read state is live. The control treats a present-but-disabled PAB exactly as it treats an absent one. - Verdict produced: Violation. The "protection exists" defense is rejected because the relevant flag is off.
CTL.S3.PUBLIC.001 NON_COMPLIANT
asset: s3://slack-ios-builds
evidence: bucket_policy Allow Principal:* s3:GetObject on /*;
public_access_block present but BlockPublicPolicy=false
verdict: PAB does not cover the policy — bucket is anonymously readable
security_state: NON_COMPLIANT
6. The prevention artifact Stave produces
- Artifact: A Terraform guardrail that pins the Public Access Block flags to
true, generated from the violating state. - What it forecloses: The latent gap from question 2 — a PAB that is present but disabled, so any policy edit silently re-opens the bucket. With
block_public_policy = true, the policy'sPrincipal: *grant is neutralized regardless of what the policy says.
resource "aws_s3_bucket_public_access_block" "slack_ios_builds" {
bucket = "slack-ios-builds"
block_public_policy = true # was false — the bug
block_public_acls = true # was false
ignore_public_acls = true
restrict_public_buckets = true
}
7. What the team no longer does manually
| Before | After Stave |
|---|---|
| Read PAB flags by hand to decide whether the block actually applies to the policy | One control proves PAB-vs-policy coverage every run |
| Be reassured by the mere presence of a Public Access Block | A present-but-disabled PAB is treated as no protection and flagged |
| Guess the blast radius of "just test builds" | The public-read finding stands on its own; the artifact closes the toggle that allowed it |
Positioning line for this case
Stave proves that
slack-ios-buildsis publicly readable despite having a Public Access Block, because the flag that mattered was off — and emits the Terraform that turns it on for good.
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