The subdomain that pointed at a name anyone could take
Metadata
- Title: The subdomain that pointed at a name anyone could take
- Source of the case: HackerOne report — Bime #121461
- AWS service(s): S3, Route 53 / DNS (CNAME)
- Risk archetype: Ghost reference — a live record pointing at a resource that no longer exists
- One-line hook: Can you prove this subdomain cannot be served by someone other than you?
0. The challenge (what the reader does first)
Scenario given to the reader:
A SaaS company hosts a marketing and app subdomain on S3. At some point the bucket behind app.bime.io was deleted during cleanup, but the DNS record was never touched. Visiting the subdomain returns an error page. Someone files it as a "broken link" ticket.
Evidence they're handed (and nothing else):
{
"dns_record": {"name": "app.bime.io", "type": "CNAME", "value": "app.bime.io.s3.amazonaws.com"},
"s3_bucket_exists": false,
"http_response": {"status": 404, "body": "NoSuchBucket"}
}
- The DNS record, the fact that the bucket no longer exists, and the HTTP response above.
- No AWS credentials. No live account. No scripts.
The questions they must answer from the evidence alone:
- What is the state now — what does a visitor to
app.bime.ioactually see, and is it currently harmful? - This is a harmless 404 today — so what is the latent risk the moment anyone, anywhere, creates an S3 bucket named
app.bime.io? - Which exposure comes from the DNS path — the CNAME still resolving to an S3 endpoint?
- Which exposure comes from the storage path — the bucket name being unowned and globally claimable?
- What single rule would have prevented this — a constraint binding DNS records to resources you actually own?
1. The manual problem
The evidence reads like a non-event. The page returns 404 NoSuchBucket. There is no data exposed, no open permission, no credential. Triage closes it as cosmetic.
To see the real problem you have to reason about a state that does not exist yet. S3 bucket names are a global namespace. The CNAME is still live and still points at the S3 service. The bucket it named is gone — which means the name is now available for anyone in any AWS account to create. The day someone creates app.bime.io as their own bucket, the still-live CNAME resolves straight to their content, served under your trusted domain.
Doing this by hand means cross-referencing two facts that live in different systems — DNS (the record still resolves) and S3 (the bucket is gone) — and then reasoning forward to an attacker action that hasn't happened. Nothing in the current state looks wrong, which is exactly why it gets dismissed.
2. The reasoning wall (capture, don't invent)
| What they hit | What they said / would say |
|---|---|
| The current response is a benign 404 | "It's just a broken page. There's nothing exposed — what's the finding?" |
| The risk depends on a future attacker action | "We can't flag something that hasn't happened yet, right?" |
| DNS and S3 are checked by different tools | "Our DNS check says the record resolves fine. Our S3 check has no bucket to look at. Each tool sees a clean state." |
The insight the reader should reach on their own:
A live record pointing at a name you no longer own is not a broken link — it is a takeover waiting for the first person to claim the name.
3. Why scanners miss or flatten it
A DNS scanner checks that app.bime.io resolves and that the CNAME target is well-formed. It does — so the scanner reports healthy. An S3 scanner enumerates buckets and evaluates their policies, ACLs, and public-access settings. There is no bucket here, so it has nothing to report at all.
What no per-setting tool sees is the combination: a live, resolving record whose target is a globally claimable name. The healthy DNS record and the absent bucket are each individually unremarkable. The takeover primitive is the edge between them — record points to name + name is unowned + namespace is open to anyone. A node-at-a-time scanner has no representation for "this reference can be hijacked by a third party," so it reports two clean states and misses the one dangerous fact that only exists when you read them together.
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 facts the reader had — no live cloud, no credentials:
{
"dns_record": {"name": "app.bime.io", "type": "CNAME", "value": "app.bime.io.s3.amazonaws.com"},
"s3_bucket_exists": false,
"http_response": {"status": 404, "body": "NoSuchBucket"}
}
- Normalized into an
obs.v0.1snapshot: the DNS record is one asset whose CNAME target is correlated against the S3 bucket inventory, where the referenced bucket is absent.
5. The reasoning Stave performs
- Control / invariant:
CTL.DNS.DANGLING.001— a DNS record that targets an AWS resource must point at a resource that exists and is owned by the account. Paired withCTL.S3.BUCKET.TAKEOVER.001— a referenced S3 bucket must exist. - What it evaluates: the predicate fails when a CNAME resolves to an S3 endpoint (DNS path) while the named bucket does not exist (storage path), making the name claimable by a third party — both paths from section 0 in one verdict.
- Verdict produced: NON_COMPLIANT. The record resolves but the target name is unowned, so any party can claim it and serve content under the domain.
control: CTL.DNS.DANGLING.001
asset: app.bime.io (CNAME -> app.bime.io.s3.amazonaws.com)
evidence: CNAME live; s3_bucket_exists=false; bucket name globally claimable
verdict: NON_COMPLIANT — dangling reference; subdomain takeover primitive
control: CTL.S3.BUCKET.TAKEOVER.001
asset: app.bime.io.s3.amazonaws.com
evidence: referenced bucket does not exist (http 404 NoSuchBucket)
verdict: NON_COMPLIANT — referenced bucket missing; name available to attacker
6. The prevention artifact Stave produces
- Artifact: a guardrail / SCP that requires every DNS record targeting an S3 bucket to reference a bucket owned by the account, and refuses CNAME creation toward a bucket name the account does not own.
- What it forecloses: the latent state from question 2 — a dangling record can never sit waiting to be hijacked, because a record pointing at an unowned bucket name is rejected (and the existing one is surfaced for removal or re-claim).
# Guardrail: DNS records to S3 must target a bucket this account owns.
rule require_owned_bucket_for_s3_cname:
for each dns_record where target matches "*.s3*.amazonaws.com":
assert s3_bucket(target).exists AND s3_bucket(target).owner == self.account
else: BLOCK "CNAME targets an S3 bucket not owned by this account"
# SCP companion: require bucket ownership before the name is wired into DNS.
{
"Sid": "NoCnameToUnownedBucket",
"Effect": "Deny",
"Action": "route53:ChangeResourceRecordSets",
"Resource": "*",
"Condition": { "Null": { "aws:ResourceTag/s3-bucket-owned": "true" } }
}
# Manual fix for the record in this case (do one, not neither):
# - Delete the dangling CNAME app.bime.io, OR
# - Re-create the S3 bucket app.bime.io in this account to re-claim the name.
7. What the team no longer does manually
| Before | After Stave |
|---|---|
| Decide whether a 404 subdomain is "just broken" or a takeover risk | A control proves the reference is claimable and emits NON_COMPLIANT |
| Cross-check DNS records against the live S3 inventory by hand | The correlation runs deterministically from a snapshot |
| Hope no future bucket-name claim turns a dead link live | A guardrail rejects records pointing at unowned bucket names |
Positioning line for this case
Stave proves that a live CNAME pointing at a deleted, globally claimable S3 bucket is a subdomain-takeover primitive — not a broken link — and emits the guardrail that forbids records targeting names you do not own.
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