Skip to main content

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:

  1. What type of user data is exposed right now, and through how many independent paths?
  2. Can an attacker discover image file names without knowing them in advance — and does the missing PAB mean this state can persist indefinitely?
  3. Which path grants the read — the ACL's AllUsers READ, the policy's Principal: * read, or both?
  4. Which grant turns "you must know the key" into "here is every key" — the policy's s3:ListBucket?
  5. 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 hitWhat 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.001 fires on the Principal: * read grant (and the parallel AllUsers READ ACL path) with no PAB to neutralize it. CTL.S3.PUBLIC.LIST.001 fires on the s3:ListBucket grant 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

BeforeAfter Stave
Eyeball a "public bucket" and guess its severity without knowing what's insideThe 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 discoverabilityOne control names the listing grant as the enumeration risk it is
Trust that removing one grant made private images privateA four-flag PAB closes the ACL and policy paths together and proves it

Positioning line for this case

Stave proves that zomato-chat-images lets 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