✓ Human-authored analysis; AI used for formatting and proofreading.
A researcher pointed at a Slack S3 bucket named slack-ios-test-builds and ran aws s3 ls --no-sign-request. The bucket replied with a directory listing of internal iOS test builds. Then aws s3 cp retrieved them. The bucket was set to public-read and public-list. AWS Account-level Public Access Block was disabled. Bucket-level Public Access Block was disabled. The bucket policy and the ACL did not even matter, because nothing was blocking the request from reaching either of them.
HackerOne #404822 was filed in September 2018. By February 2019 the bucket was reconfigured: public-read off, public-list off, Public Access Block enabled at every layer. Two settings changed. Two findings closed. Five months of exposure in the window between disclosure and remediation.
The configuration
{
"storage": {
"kind": "bucket",
"id": "aws:s3:::slack-ios-test-builds",
"access": {
"public_read": true,
"public_list": true,
"public_write": false,
"read_via_identity": true,
"list_via_identity": true
},
"controls": {
"public_access_fully_blocked": false,
"account_public_access_fully_blocked": false,
"public_access_block": {
"block_public_acls": false,
"ignore_public_acls": false,
"block_public_policy": false,
"restrict_public_buckets": false
}
},
"tags": {
"report": "404822",
"bucket-purpose": "build-artifacts",
"artifact-type": "ios_test_build"
}
}
}
Two facts are doing the work here, and they fail at different layers:
-
access.public_read: true— the bucket policy or ACL says "anyone can calls3:GetObject." -
controls.public_access_block.*: false— none of the four Public Access Block flags is set. There is no account-level or bucket-level mask in front of the access decision.
Either one of these is a finding on its own. Both together is the breach scenario.
Two failures, one bucket
S3 has two layers of access control by design. The lower layer is the bucket policy and ACL. The place where you say "this principal can do this action on this resource." The upper layer is Public Access Block. The place where you say "regardless of what the bucket policy says, do not honour requests that would result in public exposure."
PAB exists because operators get bucket policies wrong. The team that owns the bucket might intend to grant access to a specific IAM role, accidentally type Principal: "*" instead, and ship the policy. With PAB enabled, the bucket still refuses public requests. The policy is wrong, but the safety net catches it. With PAB disabled, the policy is the only barrier, and the policy is also wrong.
In the Slack case both layers failed:
- The lower layer was incorrectly configured to allow public read and list. This is the original misconfiguration that exposed the data.
- The upper layer was disabled. There was no safety net. PAB would have masked the lower-layer misconfiguration without requiring any policy fix.
A scanner that flags only the public-read finding tells you one fix is needed. A scanner that flags both findings tells you the bucket and the account are both in an unsafe configuration. Even after you fix the bucket, the account is still one mistake away from the same exposure on a different bucket.
The data
The bucket name and tags speak for themselves: slack-ios-test-builds, bucket-purpose: build-artifacts, artifact-type: ios_test_build. iOS test builds contain:
- The full application binary with same code paths as production, often with debug symbols and unstripped strings.
- Configuration files with API endpoints, sometimes including staging/QA hostnames that are themselves less defended.
- Embedded API tokens or service-account credentials that the build needs at startup.
- Build-time secrets that should have lived in a secrets manager but ended up in
Info.plistor a hard-coded constant.
The content sensitivity of the bucket is out of scope for the configuration evaluator. Stave does not read the objects. The configuration finding is sufficient on its own: a bucket containing build artifacts must not be readable by the public internet, regardless of whether the auditor has read every object in it.
Why teams miss this
The bucket name says "test builds." The mental model says "test builds are not production data." The configuration says "publicly accessible." Three statements that each look plausible until you put them next to each other.
The likely operational sequence is straightforward:
- A mobile engineer needs a way to distribute test builds to a small group of internal users.
- The simplest path is a public S3 bucket with a long, unguessable name. Security through obscurity.
- The bucket gets
public_readset so testers can download builds via direct URL without needing AWS credentials. - The bucket gets
public_listset because the testers also need to see which builds are available. - Nobody removes the configuration when better tooling arrives (TestFlight, App Center, Firebase App Distribution).
- The bucket sits exposed for years, accruing more builds, until a researcher with
aws s3 ls --no-sign-requestfinds it.
Each individual step is defensible at the moment it happens. The compound state where a public bucket containing build artifacts with no PAB safety net gets disclosed five years later.
CSPM tools catch step 3 today. Many do not catch step 5, because their public-bucket dashboards list dozens of buckets and the security team learns to skim them. A finding ranked alongside fifty intentionally-public CDN buckets does not get triaged.
The system invariant
Two related invariants:
No S3 bucket may grant anonymous read access via policy or ACL.
Every S3 bucket must have Public Access Block fully enabled (all four flags).
The second invariant is not redundant with the first. The first catches the bucket-level configuration error. The second catches the absence of the safety net. Both must hold, and the chain engine surfaces the compound finding when both fail at once.
What Stave detects
The bucket-public control:
id: CTL.S3.PUBLIC.001
name: No Public S3 Bucket Read
severity: critical
compliance:
hipaa: "164.312(a)(1)"
cis_aws_v1.4.0: "2.1.5"
pci_dss_v4.0: "7.2.1"
unsafe_predicate:
any:
- field: properties.storage.access.public_read
op: eq
value: true
The Public Access Block control:
id: CTL.S3.CONTROLS.001
name: Public Access Block Must Be Enabled
severity: high
unsafe_predicate:
all:
- field: properties.storage.controls.public_access_fully_blocked
op: eq
value: false
Two predicates, both small, both evaluating exactly one field each. The first fires when the bucket is in fact public. The second fires when the bucket has no safety net regardless of its current public state. They are intentionally separate controls because the remediation is different:
- For
CTL.S3.PUBLIC.001: change the bucket policy or ACL to remove the public grant. - For
CTL.S3.CONTROLS.001: enable Public Access Block at the bucket level (and ideally at the account level too).
A bucket can fix one without the other. The bucket-level PAB might be enabled while the bucket policy still grants Principal: "*". PAB will mask it, but the policy itself is still wrong and should be cleaned up. Conversely, the policy might be correct while PAB is disabled with no exposure today, but a one-line policy change tomorrow becomes a public bucket without warning.
Run the fixture
cd stave && make build
# The fixture ships two snapshots (T1 unsafe Sep 2018, T2 remediated Feb 2019).
# Evaluate T1 alone to reproduce the finding the report describes.
tmp=$(mktemp -d)
cp testdata/e2e/e2e-h1-slack-404822/observations/2018-09-03T000000Z.json "$tmp/"
./stave apply \
--controls testdata/e2e/e2e-h1-slack-404822/controls \
--observations "$tmp" \
--max-unsafe 168h \
--eval-time 2019-01-01T00:00:00Z \
--allow-unknown-input \
--format json | jq '.findings[] | {control_id, control_severity, property: .evidence.misconfigurations[0].property}'
{
"control_id": "CTL.S3.CONTROLS.001",
"control_severity": "high",
"property": "storage.controls.public_access_fully_blocked"
}
{
"control_id": "CTL.S3.PUBLIC.001",
"control_severity": "critical",
"property": "storage.access.public_read"
}
Two findings on one bucket. The critical-severity finding is the actual exposure. The high-severity finding is the missing safety net. A remediation that addresses only one of them leaves the other as a latent risk; a remediation that addresses both produces a bucket that cannot become public even if a future policy change accidentally re-introduces the public grant.
The remediation
Two commands close both findings:
# Enable PAB at the bucket level
aws s3api put-public-access-block \
--bucket slack-ios-test-builds \
--public-access-block-configuration \
BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true
# Remove any public grants from the bucket ACL and policy
aws s3api put-bucket-acl --bucket slack-ios-test-builds --acl private
aws s3api delete-bucket-policy --bucket slack-ios-test-builds # if no policy is needed
The structural fix is to also enable PAB at the account level. Once enabled, no bucket in the account can ever be made public regardless of bucket-level configuration. Stave models this as the account_public_access_fully_blocked flag, and the same CTL.S3.CONTROLS.001 predicate can be extended to require both account-level and bucket-level PAB on every bucket.
For a longer-term fix, deprecate the public-bucket build-distribution pattern. TestFlight, Firebase App Distribution, and App Center all provide signed-URL or token-based distribution without requiring a public S3 bucket. The cost of the migration is one sprint; the cost of the bucket sitting public for years is a HackerOne report and a five-month remediation window.
Why this case generalises
Two controls, one bucket. The compound finding makes the case useful as a training example. A scanner that only knows about public_read: true would mark this bucket as "public" alongside every CDN bucket in the org, and the operator would have to manually triage which public bucket is intentional and which is the iOS test-build leak.
A reasoning engine that knows about two predicates and reports both simultaneously with public-read enabled and PAB disabled. This produces a finding shape that a triage engineer can act on without re-reading the bucket policy. The presence of both findings is the signal that this was not intended; an intentional public CDN bucket would typically have PAB disabled, but not necessarily report a public_read violation depending on how the org tags expected-public buckets.
Stave ships both predicates and the compound chain that fires when both are true at once. The fixture for this report is testdata/e2e/e2e-h1-slack-404822/. The same predicate pair catches the same configuration shape on every other "test builds" bucket, "internal QA" bucket, "staging assets" bucket, and "backup" bucket in the wild.
Top comments (0)