The chat images anyone could browse, one key at a time
Metadata
- Title: The chat images anyone could browse, one key at a time
- Source of the case: Real HackerOne report #507097 (Zomato)
- AWS service(s): S3
- Risk archetype: Public user content plus enumeration — listing turns a key-guessing problem into a download-everything problem
- One-line hook: Can you prove that users' private chat images are reachable — and discoverable — by anyone?
0. The challenge (what the reader does first)
Scenario given to the reader:
A bucket, zomato-chat-images, holds user-uploaded images from a food
delivery app's in-app messaging feature. These are private images people sent
to each other. The bucket has an ACL granting AllUsers READ, a bucket policy
granting Principal: * read and list, and no Public Access Block. You get the
export. Decide what kind of user data is exposed and whether an attacker even
needs to know the file names.
Evidence they're handed (and nothing else):
{
"bucket": "zomato-chat-images",
"acl": {"grants": [{"grantee": "AllUsers", "permission": "READ"}]},
"bucket_policy": {"Statement": [{"Effect": "Allow", "Principal": "*", "Action": ["s3:GetObject", "s3:ListBucket"], "Resource": ["arn:aws:s3:::zomato-chat-images", "arn:aws:s3:::zomato-chat-images/*"]}]},
"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:
- What type of user data is exposed right now, and through how many independent paths?
- Can an attacker discover image file names without knowing them in advance — and does the missing PAB mean this state can persist indefinitely?
- Which path grants the read — the ACL's
AllUsersREAD, the policy'sPrincipal: *read, or both? - Which grant turns "you must know the key" into "here is every key" — the policy's
s3:ListBucket? - What single rule would have made these private images unreachable and undiscoverable from the open internet?
1. The manual problem
The instinct is to rank this as "public bucket, medium severity" and move on.
That undersells it twice. First, the data is private user chat images — every
finding here is a real person's content. Second, and the part people miss: the
policy grants s3:ListBucket on the bucket ARN, not just s3:GetObject on the
objects. Without listing, an attacker would have to guess opaque object keys —
slow, noisy, often hopeless. With listing, the bucket hands over the complete
key index, and the attacker fetches every image in order. To reason this through
by hand you must separate read from list, recognize the bucket is also opened
independently by the ACL, and then translate all of that into the human
consequence: every user who ever sent a chat image is exposed, browsably.
2. The reasoning wall
| What they hit | What they said / would say |
|---|---|
| "Public images" sounds low-stakes until you remember whose images they are | "I almost logged it as low. Then I realized these are private messages between real users." |
| Read and list get collapsed into one "public" judgment | "I kept reading it as 'public read.' The ListBucket is what makes it browsable, not just reachable." |
| Two paths (ACL + policy) and no PAB to backstop either | "Even if one grant were a mistake, nothing was set to catch it. There was no floor." |
The insight the reader should reach on their own:
Public read leaks the files you can name; public read plus list leaks every file, including the ones nobody should know exist.
3. Why scanners miss or flatten it
A per-setting scanner reports "public bucket" and assigns a generic severity. It
has no concept of what the bucket contains, so it cannot tell you this is
private user chat content rather than public marketing assets — the privacy
harm is invisible to it. Worse, it typically does not separate the s3:GetObject
grant from the s3:ListBucket grant, so it cannot distinguish a bucket an
attacker must brute-force from one that publishes its own directory. The specific
thing it cannot see here is the enumeration property: that ListBucket on the
bucket ARN converts a hard key-guessing attack into a trivial download-everything
attack against real users' images. And like every dual-path case, it cannot prove
both the ACL and policy paths are closed at once.
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": "zomato-chat-images",
"acl": {"grants": [{"grantee": "AllUsers", "permission": "READ"}]},
"bucket_policy": {"Statement": [{"Effect": "Allow", "Principal": "*", "Action": ["s3:GetObject", "s3:ListBucket"], "Resource": ["arn:aws:s3:::zomato-chat-images", "arn:aws:s3:::zomato-chat-images/*"]}]},
"public_access_block": null
}
Stave normalizes this into a bucket asset carrying the ACL grants and the policy statement, with the read action and the list action evaluated as distinct grants against their respective ARNs.
5. The reasoning Stave performs
- Control / invariant:
CTL.S3.PUBLIC.001— no public read on user content.CTL.S3.PUBLIC.LIST.001— no public listing that exposes object keys. - What it evaluates:
CTL.S3.PUBLIC.001fires on thePrincipal: *read grant (and the parallelAllUsersREAD ACL path) with no PAB to neutralize it.CTL.S3.PUBLIC.LIST.001fires on thes3:ListBucketgrant against the bucket ARN — flagging enumeration specifically, the property that turns a key-guessing attack into a directory walk. - Verdict produced: Both fire and consolidate. The listing finding is reported as a distinct, higher-consequence exposure, not folded into "public read."
CTL.S3.PUBLIC.001 NON_COMPLIANT
asset: s3://zomato-chat-images
evidence: policy Allow Principal:* s3:GetObject AND acl AllUsers READ;
public_access_block=null
verdict: public read on user chat images (two independent paths)
CTL.S3.PUBLIC.LIST.001 NON_COMPLIANT
asset: s3://zomato-chat-images
evidence: policy Allow Principal:* s3:ListBucket on bucket ARN
verdict: public enumeration — every image key is discoverable, then
downloadable; privacy violation for every user who sent images
security_state: NON_COMPLIANT
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 — no PAB, so this read+list state can persist indefinitely and an attacker can enumerate then download every image. PAB suppresses the policy and ACL grants together, making the images both unreachable and undiscoverable.
# Make private user content unreachable AND undiscoverable from the internet
aws s3control put-public-access-block \
--account-id <acct> \
--public-access-block-configuration \
BlockPublicAcls=true,\ # kills the AllUsers READ ACL path
IgnorePublicAcls=true,\
BlockPublicPolicy=true,\ # kills the Principal:* read + list policy
RestrictPublicBuckets=true # locks the combined state shut
# (Listing is foreclosed with read: with PAB on, the s3:ListBucket
# grant to Principal:* no longer takes effect — no key enumeration.)
7. What the team no longer does manually
| Before | After Stave |
|---|---|
| Eyeball a "public bucket" and guess its severity without knowing what's inside | The read and list findings stand on their own; enumeration is flagged as its own exposure |
Separate s3:GetObject from s3:ListBucket by hand to gauge discoverability | One control names the listing grant as the enumeration risk it is |
| Trust that removing one grant made private images private | A four-flag PAB closes the ACL and policy paths together and proves it |
Positioning line for this case
Stave proves that
zomato-chat-imageslets anyone not only read users' private chat images but enumerate every one of them — and emits the Public Access Block that makes those images unreachable and undiscoverable 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