The address that pointed at a chair nobody was sitting in
Metadata
- Title: The address that pointed at a chair nobody was sitting in
- Source of the case: Real HackerOne report #1295497 (Shopify)
- AWS service(s): EC2 (Elastic IP), DNS (Route 53 / A record)
- Risk archetype: Ghost reference — an A record points at an IP the org no longer owns and that any AWS account can re-acquire
- One-line hook: Can you prove this A record points at an address you no longer control?
0. The challenge (what the reader does first)
Scenario given to the reader:
A staging API host, api-staging.shopify.com, used to run on an EC2 instance
with an Elastic IP. The instance was terminated and the Elastic IP was released
back to AWS. But the DNS A record was never updated — it still hard-codes the
old address, 54.x.x.x. You have the export below and nothing else.
Evidence they're handed (and nothing else):
{
"dns_record": {"name": "api-staging.shopify.com", "type": "A", "value": "54.x.x.x"},
"ec2_instance_exists": false,
"elastic_ip_allocated": false,
"ip_claimable": true
}
- The JSON export above. No AWS credentials. No live account. No scripts.
The questions they must answer from the evidence alone:
- The A record still resolves, but the instance is gone and the IP is released (
elastic_ip_allocated: false) — is this record dangling, and is the address free to re-acquire right now? - Nobody has grabbed the IP yet, but
ip_claimableis true and AWS hands public IPs back to the shared pool — what is the blast radius the moment any account allocates it into the addressapi-staging.shopify.comalready trusts? - Which path makes this exploitable — the live A record hard-coding an address the org has released?
- Is there a second amplifier — does the subdomain's relationship to the parent
shopify.comdomain extend trust (cookies, CORS, allow-lists) to whatever answers on that IP? - What single rule, enforced at teardown, would have made it impossible to release an Elastic IP while a DNS record still points at it?
1. The manual problem
To answer by hand you have to reconcile three independent facts from three
different systems. DNS says the name resolves to 54.x.x.x. EC2 says the
instance is gone. The Elastic IP service says the allocation was released. Each
system is internally consistent and each looks fine on its own dashboard. The
DNS record is syntactically perfect. The terminated instance and the released
IP are routine teardown events — they are supposed to disappear, so their
absence raises no alarm. The danger only appears when you combine "the record
still points here" with "we no longer own here" with the background fact that
AWS recycles released public IPs into a shared pool. None of those three systems
owns that join, and the recycling fact is knowledge you carry in, not data in
the export.
2. The reasoning wall
| What they hit | What they said / would say |
|---|---|
| The A record resolved cleanly, so it looked maintained | "It pointed at a real, routable IP. Nothing about the record itself said the IP wasn't ours anymore." |
| Terminating the instance and releasing the IP felt like finished cleanup | "We tore down the box and freed the address. That's the correct thing to do — it never occurred to me the DNS still trusted that number." |
| The risk lived in the seam between DNS and IP ownership | "Three teams, three green states, and the hole was the IP we gave back while DNS kept vouching for it." |
The insight the reader should reach on their own:
Releasing an address you still point at is not cleanup — it is handing a stranger a name your domain already trusts, and proving it requires joining the DNS record to current IP ownership, not checking either alone.
3. Why scanners miss or flatten it
A per-setting scanner validates resources one at a time. The DNS scanner
confirms api-staging.shopify.com is a well-formed A record pointing at a
routable public IP — it passes. The EC2 scanner enumerates instances you own; a
terminated instance and a released Elastic IP simply drop out of inventory, so
there is nothing left to flag. No single-resource check can express the edge
that matters here: a live record pointing at an IP that has fallen back into
AWS's shared allocation pool and is therefore claimable by any account. Unlike a
bucket takeover, there is no name to match — the takeover succeeds purely
because the org released an address it still advertises. The specific thing a
scanner cannot see is "this A record's target is no longer owned by us and is
re-allocatable by a stranger" — a relationship between a record that exists and
an ownership state that no longer does.
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:
{
"dns_record": {"name": "api-staging.shopify.com", "type": "A", "value": "54.x.x.x"},
"ec2_instance_exists": false,
"elastic_ip_allocated": false,
"ip_claimable": true
}
Stave normalizes this into an observation snapshot that binds the A record to the ownership state of its target address: the record's value, whether the backing instance exists, whether the Elastic IP is still allocated to the account, and whether the address is claimable.
5. The reasoning Stave performs
- Control / invariant:
CTL.DNS.DANGLING.001— no DNS record may point at a target the account no longer owns. - What it evaluates: The control joins the A record's value to the ownership facts on the other end. It fires when the record resolves but the backing instance is gone, the Elastic IP is released (
elastic_ip_allocated: false), and the address is claimable — encoding the dangling-and-recyclable condition the reader had to assemble by hand. Unlike the bucket-takeover variant there is no name to register; ownership of the raw address is the only join that matters. - Verdict produced: The control fires NON_COMPLIANT. If IP ownership is unknown rather than provably released, the control reports the finding as a violation rather than clearing it, so a missing ownership fact never silently passes.
Issue: api-staging.shopify.com — dangling A record to a released, claimable IP
CTL.DNS.DANGLING.001 NON_COMPLIANT
asset: dns://api-staging.shopify.com (A -> 54.x.x.x)
evidence: A record resolves to 54.x.x.x; ec2_instance_exists = false;
elastic_ip_allocated = false; ip_claimable = true
verdict: live A record points at an address the account no longer owns and
that any account can re-allocate from the shared pool
security_state: NON_COMPLIANT
6. The prevention artifact Stave produces
- Artifact: A teardown guardrail (SCP-style policy) that forbids releasing an Elastic IP while any DNS record still points at it, plus the manual remediation: delete or re-target the dangling A record before the address is released, or re-allocate the address into the owning account.
- What it forecloses: The latent state from question 2 — the moment an attacker, looping IP allocations, lands
54.x.x.xand starts answering every request bound forapi-staging.shopify.comunder Shopify's trusted subdomain. With the guardrail, the address cannot be released until the record that points at it is gone, so the name is never left vouching for an unowned IP.
# Teardown guardrail: deny EIP release while a DNS record points at it (SCP)
{
"Sid": "DenyReleaseWhileReferenced",
"Effect": "Deny",
"Action": "ec2:ReleaseAddress",
"Resource": "*",
"Condition": {
"StringEquals": {"stave:dns-reference-count": "0"}
}
}
# stave:dns-reference-count is derived from the DNS reference index for the
# address; release is permitted only once no A record targets it.
# Manual fix until the address is freed safely — choose one:
# (a) remove or re-target the dangling A record first
aws route53 change-resource-record-sets --hosted-zone-id <zone> \
--change-batch '{"Changes":[{"Action":"DELETE","ResourceRecordSet":{
"Name":"api-staging.shopify.com","Type":"A","TTL":300,
"ResourceRecords":[{"Value":"54.x.x.x"}]}}]}'
# (b) re-allocate the address into the owning account so no stranger can
aws ec2 allocate-address --domain vpc --address 54.x.x.x
7. What the team no longer does manually
| Before | After Stave |
|---|---|
| Cross-check DNS zones against live EC2 and Elastic IP inventory to find records pointing at released addresses | One control joins record-to-ownership and proves the dangling A record deterministically |
| Recall that AWS recycles released public IPs into a shared pool any account can draw from | The dangling invariant encodes "released, claimable address = takeover risk" and re-checks it every run |
| Trust that terminating an instance and releasing its IP was complete cleanup | A teardown guardrail blocks IP release until no DNS record still points at it |
Positioning line for this case
Stave proves that
api-staging.shopify.comstill points at an address Shopify has released, proves that address is re-allocatable by any account into a trusted subdomain, and emits the teardown guardrail that makes releasing a still-referenced IP impossible.
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