cloud

Build a Dangling DNS Agent: Find Route 53 Records Pointing at Deleted S3 Buckets, CloudFront Distributions, Load Balancers, and Released IPs Before Someone Takes Over the Subdomain

Build a dangling DNS agent for Route 53: find CNAME, alias, and NS records pointing at deleted S3, CloudFront, ELB, and EIP targets, then fix via Terraform PR.

October 28, 2026·14 min read·
#ai#aws#security#cloud#automation#terraform#devops

What This Agent Does

A dangling DNS agent walks every Route 53 hosted zone you own, extracts every record whose target is something that can be deleted (an S3 website endpoint, a CloudFront distribution, a load balancer, an Elastic Beanstalk environment, an Elastic IP, a delegated child zone, a third-party SaaS hostname), and then checks whether the target still exists. Not "does the name resolve", which is the lazy check, but "does an account I control still own the thing this record points at". A record whose target is gone is a dead link at best and a subdomain takeover at worst: for S3 buckets, Beanstalk environments, and delegated zones, anyone who recreates the deleted resource under the same name now serves content from your domain, with your cookies in scope. The LLM gets the pre-chewed evidence, ranks findings by whether the target is actually claimable, and writes the pull request that deletes the records from Terraform. Its IAM role explicitly denies route53:ChangeResourceRecordSets, so the only way a record disappears is a human merging the PR.

It is the DNS sibling of the security group audit agent and the waste reclamation agent: inventory, prove, propose, let a human merge. The twist is that the waste agent deletes the load balancer and this agent finds the CNAME that nobody deleted along with it. Run both and the second one finds the first one's leftovers.

Which Records Are Dangerous and Which Are Just Dead

Record targetHow it danglesClaimable by a stranger?Why
bucket.s3-website-us-east-1.amazonaws.com or alias to S3Bucket deleted, static site decommissionedYesBucket names are global; anyone can create the deleted name
env.us-east-1.elasticbeanstalk.comEnvironment terminatedYesEnvironment CNAMEs are first come, first served per region
NS records delegating dev.example.com to a deleted hosted zoneChild zone deleted, delegation left in parentYesAttacker creates zones until Route 53 hands them one of your four NS names
A record to a released Elastic IPInstance terminated, EIP releasedYes, by luckReleased IPs return to the pool; attackers allocate and release in bulk to catch them
d1234abcd.cloudfront.netDistribution deletedMostly noCloudFront requires a cert proving domain ownership before an alternate domain attaches
my-alb-123456789.us-east-1.elb.amazonaws.comLoad balancer deletedPractically noThe numeric suffix is random; a collision is not a realistic attack
app.herokuapp.com, org.github.io, x.azurewebsites.netSaaS app deletedDepends on providerCheck the community fingerprint list; many are still claimable

The first four rows are security incidents waiting for a motivated stranger. The next two are dead links that break a customer bookmark and page nobody. The agent must classify them differently, because a PR titled "7 subdomain takeovers" gets reviewed today and a PR titled "212 stale DNS records" gets reviewed never.

Step 1: A Role That Can Read DNS and Nothing Else

Every permission is a List or Describe. The explicit Deny is the guardrail that survives someone adding an Allow later.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ReadDnsAndTargets",
      "Effect": "Allow",
      "Action": [
        "route53:ListHostedZones", "route53:ListResourceRecordSets",
        "route53:GetHostedZone", "route53:ListQueryLoggingConfigs",
        "s3:ListAllMyBuckets",
        "cloudfront:ListDistributions",
        "elasticloadbalancing:DescribeLoadBalancers",
        "ec2:DescribeAddresses", "ec2:DescribeRegions",
        "elasticbeanstalk:DescribeEnvironments",
        "logs:StartQuery", "logs:GetQueryResults", "logs:StopQuery"
      ],
      "Resource": "*"
    },
    {
      "Sid": "NeverTouchDns",
      "Effect": "Deny",
      "Action": [
        "route53:ChangeResourceRecordSets", "route53:DeleteHostedZone",
        "route53:CreateHostedZone", "s3:CreateBucket",
        "ec2:AllocateAddress", "ec2:AssociateAddress"
      ],
      "Resource": "*"
    }
  ]
}

s3:CreateBucket is denied on purpose. Recreating a deleted bucket to squat your own name is a legitimate mitigation, but it is a human's call with a cost line attached, not something an agent does because a prompt said "fix it". Verify the boundary before trusting it:

aws route53 list-resource-record-sets --hosted-zone-id Z0123EXAMPLE --max-items 5
aws route53 change-resource-record-sets --hosted-zone-id Z0123EXAMPLE --change-batch file://noop.json
# An error occurred (AccessDenied) ... explicit deny in an identity-based policy

Step 2: Inventory Every Record That Can Dangle

The collector reads every zone, keeps only the record types that can point at something deletable, and tags each with the kind of target it references. Alias records carry their target in AliasTarget.DNSName rather than ResourceRecords, which is the first thing every homegrown script misses.

# collect_dns.py — read-only inventory of records with deletable targets
import re, boto3

r53 = boto3.client("route53")

TARGET_KINDS = [
    ("s3",         re.compile(r"\.s3(-website)?[.-][a-z0-9-]+\.amazonaws\.com\.?$|\.s3\.amazonaws\.com\.?$")),
    ("beanstalk",  re.compile(r"\.elasticbeanstalk\.com\.?$")),
    ("cloudfront", re.compile(r"\.cloudfront\.net\.?$")),
    ("elb",        re.compile(r"\.elb\.([a-z0-9-]+\.)?amazonaws\.com\.?$")),
    ("saas",       re.compile(r"\.(herokuapp\.com|github\.io|azurewebsites\.net|readthedocs\.io|"
                              r"myshopify\.com|zendesk\.com|ghost\.io|surge\.sh)\.?$")),
]
PRIVATE_IP = re.compile(r"^(10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)")

def zones():
    for page in r53.get_paginator("list_hosted_zones").paginate():
        for z in page["HostedZones"]:
            yield z["Id"].split("/")[-1], z["Name"], z["Config"]["PrivateZone"]

def records(zone_id):
    for page in r53.get_paginator("list_resource_record_sets").paginate(HostedZoneId=zone_id):
        yield from page["ResourceRecordSets"]

def classify(rr, zone_name):
    name, rtype = rr["Name"], rr["Type"]
    if "AliasTarget" in rr:
        target = rr["AliasTarget"]["DNSName"]
        kind = next((k for k, rx in TARGET_KINDS if rx.search(target)), "alias-other")
        return {"name": name, "type": f"ALIAS-{rtype}", "target": target, "kind": kind}
    values = [v["Value"] for v in rr.get("ResourceRecords", [])]
    if rtype == "CNAME":
        kind = next((k for k, rx in TARGET_KINDS if rx.search(values[0])), "cname-other")
        return {"name": name, "type": "CNAME", "target": values[0], "kind": kind}
    if rtype == "A" and values and not PRIVATE_IP.match(values[0]):
        return {"name": name, "type": "A", "target": values[0], "kind": "public-ip"}
    if rtype == "NS" and name != zone_name:
        return {"name": name, "type": "NS", "target": values, "kind": "delegation"}
    return None

def inventory():
    for zone_id, zone_name, private in zones():
        for rr in records(zone_id):
            c = classify(rr, zone_name)
            if c:
                c.update(zone=zone_id, private_zone=private,
                         set_id=rr.get("SetIdentifier"), ttl=rr.get("TTL"))
                yield c

Two things to keep from this. SetIdentifier matters because weighted, latency, and failover record sets share a name, and a finding must name the exact set it wants removed. And NS records at the zone apex are the zone's own nameservers, never a delegation, so they are excluded by the name comparison.

Multi-account estates run this per account via sts:AssumeRole and merge the inventories before Step 3, because the bucket your CNAME points at may legitimately live in a different account. A finding of "bucket not in this account" is not a finding of "bucket does not exist".

Step 3: Prove the Target Is Gone

Resolution is the weakest evidence. A CNAME to a deleted bucket still resolves (the S3 endpoint name exists, the bucket behind it does not), and an alias to a deleted CloudFront distribution returns an empty answer that looks like an ordinary misconfiguration. The agent checks ownership directly, per target kind, across every region.

# prove_targets.py — does an account we control still own each target?
import boto3, dns.resolver
from botocore.exceptions import ClientError

ec2 = boto3.client("ec2")
REGIONS = [r["RegionName"] for r in ec2.describe_regions()["Regions"]]

def owned_buckets():
    return {b["Name"] for b in boto3.client("s3").list_buckets()["Buckets"]}

def owned_elb_names():
    out = set()
    for region in REGIONS:
        for api in ("elbv2", "elb"):
            c = boto3.client(api, region_name=region)
            for page in c.get_paginator("describe_load_balancers").paginate():
                for lb in page.get("LoadBalancers", []) + page.get("LoadBalancerDescriptions", []):
                    out.add(lb["DNSName"].lower())
    return out

def owned_eips():
    return {a["PublicIp"] for r in REGIONS
            for a in boto3.client("ec2", region_name=r).describe_addresses()["Addresses"]}

def owned_cloudfront():
    out = set()
    for page in boto3.client("cloudfront").get_paginator("list_distributions").paginate():
        for d in page["DistributionList"].get("Items", []):
            out.add(d["DomainName"].lower())
    return out

def owned_beanstalk_cnames():
    return {e["CNAME"].lower() for r in REGIONS
            for e in boto3.client("elasticbeanstalk", region_name=r)
                        .describe_environments(IncludeDeleted=False)["Environments"]
            if e.get("CNAME")}

def bucket_state(bucket):
    """200 = ours, 403 = exists and is NOT ours, 404 = nobody owns it (claimable)."""
    try:
        boto3.client("s3").head_bucket(Bucket=bucket)
        return "ours"
    except ClientError as e:
        code = e.response["Error"]["Code"]
        return {"403": "exists-not-ours", "404": "claimable"}.get(code, f"unknown-{code}")

def delegation_state(name, ns_list):
    """Ask the delegated servers for the child zone's SOA. REFUSED means the zone is gone."""
    res = dns.resolver.Resolver(configure=False)
    for ns in ns_list:
        res.nameservers = [str(dns.resolver.resolve(ns.rstrip("."), "A")[0])]
        try:
            res.resolve(name.rstrip("."), "SOA")
            return "served"
        except (dns.resolver.NoNameservers, dns.resolver.NXDOMAIN):
            continue
    return "unserved"

The 403 case for buckets deserves its own paragraph. A bucket that exists but is not yours is either a sibling account you forgot to include, or the takeover already happened. The agent cannot tell which. It reports exists-not-ours, ranks it at the top, and a human checks the Organization's account list. That is the single most valuable line the whole system produces.

For unserved delegations the attack is mechanical: create hosted zones for dev.example.com repeatedly until Route 53 assigns a nameserver set that overlaps one of the four names in your parent's NS record. It takes an attacker minutes. Delete the delegation or recreate the child zone, nothing in between.

Who Still Asks for This Name?

Route 53 query logging, when enabled on a public zone, tells you whether anyone still resolves the dangling name. It is the DNS equivalent of VPC Flow Logs in the security group agent, with the same caveat: no logging configured means "insufficient evidence", not "nobody uses it".

def query_volume(log_group, qname, days=30):
    logs = boto3.client("logs")
    q = f'''
    parse @message "* * * * * * * * *" as ver, ts, zone, name, qtype, rcode, proto, edge, resolver
    | filter name = "{qname.rstrip(".")}"
    | stats count(*) as queries, count_distinct(resolver) as resolvers by rcode
    '''
    import time
    end = int(time.time()); qid = logs.start_query(logGroupName=log_group,
        startTime=end - days * 86400, endTime=end, queryString=q)["queryId"]
    while (r := logs.get_query_results(queryId=qid))["status"] not in ("Complete", "Failed", "Timeout"):
        time.sleep(1)
    return r["results"]

Thousands of queries for a record whose target is claimable raises the severity, because every one of those resolvers is a browser that will happily render whatever the squatter uploads. Zero queries lowers the urgency of a dead CloudFront alias to housekeeping.

Step 4: What the Model Decides

The model sees one JSON object per record: the target kind, the ownership check result, the query-log summary, the zone's ownership tags, and whether a TXT record with heritage=external-dns sits next to it. Its job is classification and prose inside rules it cannot reinterpret.

You are reviewing Route 53 records whose targets may no longer exist. Output one verdict each:
  takeover_risk    — target kind is s3, beanstalk, delegation, or public-ip AND ownership
                     check is claimable, unserved, or exists-not-ours. Severity: high.
                     Say in one sentence what an attacker would get.
  dead_record      — target kind is cloudfront, elb, or alias-other AND no account we control
                     owns the target. Severity: low. Batch into one PR.
  saas_check       — target kind is saas and the hostname returns the provider's "no such
                     app" fingerprint. Severity: high. Name the provider's claim procedure.
  hold             — anything else. Say exactly what evidence would resolve it.

Rules:
- A record with a sibling TXT containing heritage=external-dns is owned by a Kubernetes
  controller. The fix is in the cluster, not in Route 53. Output hold and name the cluster.
- Records created in the last 7 days are deployments in progress. Output hold.
- exists-not-ours on an S3 bucket is always takeover_risk, never hold, even with zero queries.
- Never propose recreating the deleted target. Propose deleting the record. A human may
  choose to squat the name; that is their decision and their cost.
- Private hosted zones have no query logs. Say "no query evidence" rather than "no traffic".

A takeover_risk finding comes back shaped for a reviewer who is about to interrupt their day:

{
  "record": "downloads.example.com. CNAME downloads-example.s3-website-us-east-1.amazonaws.com",
  "zone": "Z0123EXAMPLE",
  "verdict": "takeover_risk",
  "evidence": "head_bucket downloads-example: 404 (claimable). Not in any of 6 org accounts. Query logs 30d: 18,420 queries from 1,133 resolvers, rcode NOERROR.",
  "impact": "Anyone creating bucket downloads-example serves arbitrary files at downloads.example.com with example.com cookies in scope.",
  "fix": "Delete the record (PR #418). Optionally recreate the bucket with Block Public Access on.",
  "confidence": 0.95
}

Confidence under 0.7 goes in the PR body as a note, not a diff. The same threshold the secret leak triage agent uses, for the same reason: a false positive that deletes a record someone needs is an outage, and the agent's credibility is spent the first time it causes one.

Step 5: The Fix Is a Pull Request, Gated by Its Own Plan

The agent writes to one branch in the Terraform repo, following the pull requests, not API calls rule. High-severity findings get one PR each so they can merge in minutes. Dead records get batched weekly.

# Before: the finding
resource "aws_route53_record" "downloads" {
  zone_id = aws_route53_zone.example.zone_id
  name    = "downloads.example.com"
  type    = "CNAME"
  ttl     = 300
  records = ["downloads-example.s3-website-us-east-1.amazonaws.com"]
}

# After: removed. Bucket downloads-example was deleted on 2026-09-30 (CloudTrail
# DeleteBucket by terraform-apply role). 30-day query logs and the head_bucket 404
# are attached in the PR body. Resource block deleted entirely.

Before the PR opens, CI runs terraform plan -out=tfplan and the gate is strict: every change must be a delete of an aws_route53_record. Anything else, including a replacement because a zone ID moved, aborts the PR.

terraform show -json tfplan | jq -e '
  [.resource_changes[]
    | select(.change.actions != ["no-op"])
    | select((.type == "aws_route53_record" and .change.actions == ["delete"]) | not)
  ] | length == 0' >/dev/null || { echo "plan touches something other than record deletes; aborting"; exit 1; }

If you already run the Terraform plan review agent, hand it this allowlist as policy instead of duplicating the check.

Records Terraform does not own are a separate pile. ClickOps records get an issue with an import block proposal and the evidence attached. ExternalDNS records get an issue against the cluster's repo, pointing at the Ingress or Service whose hostname annotation still names a backend that no longer exists, because deleting the Route 53 record by hand only works until the controller's next sync puts it back.

After Merge: Watch the Name You Removed

For seven days after a merge the agent re-runs the query-log check for the deleted name. Queries now return NXDOMAIN, and the resolvers asking are the clients you just broke. A spike from internal resolver ranges, or from a CIDR tagged as a customer, opens a revert PR with the evidence attached and pages the owning team. A trickle from random residential addresses is the internet remembering a bookmark, and goes in the weekly summary. The takeover is only closed when the watch window ends clean.

Where It Will Be Wrong

Run it in shadow mode for a month first and expect these:

  • Accounts you did not enumerate look like strangers. An S3 403 from a partner's account or a forgotten sandbox account is indistinguishable from a takeover. Feed the agent the full Organization account list and keep exists-not-ours at the top of the queue anyway. Better a false alarm than a quiet squatter.
  • Wildcards hide everything. A *.example.com CNAME to a deleted bucket dangles every hostname nobody has defined. The inventory flags wildcards separately and never batches them.
  • The SaaS fingerprint list rots. Providers change their "no such app" page every year, and some have closed takeover entirely. Pin the list to a dated version of the community-maintained reference and re-verify fingerprints with a real HTTP fetch before calling anything saas_check.
  • CloudFront is not fully safe. Domain-ownership verification stopped the classic attack, but a deleted distribution still means your hostname serves nothing, and a distribution in an account you lost control of still serves whatever that account decides. Treat it as dead, not harmless.
  • Private zones have no query logs. Internal dangling records are usually low risk, since the attacker needs to be inside the VPC already, but the agent must say "no evidence" and not invent a zero.
  • Health-check failover sets are supposed to point at things that are down. A secondary record whose target is deliberately cold is not dangling. The SetIdentifier and Failover fields are in the inventory for exactly this reason; the prompt should treat failover secondaries as hold.
  • Release-and-reallocate is faster than your scan. An Elastic IP released at 09:00 can be allocated to a stranger by 09:05. Run the public-ip check daily, not monthly, and treat any A record whose IP is in AWS ranges but not in your accounts as takeover_risk immediately.

Takeaway

Dangling DNS survives for years because the resource is deleted in one place (a Terraform module, a console, a Beanstalk teardown) and the record lives in another, and nothing joins the two. That join is deterministic code: list the records, check who owns the targets, count who still asks for the name. The judgment layer, separating the S3 bucket a stranger can claim tonight from the CloudFront alias that is merely embarrassing, and writing a PR body the on-call reviewer trusts, is a narrow task a model does well when the ownership evidence is pinned and the rules it cannot override are written down. Keep the role's explicit Deny, make the Terraform plan the final gate, and watch the NXDOMAINs after merge. The agent earns its keep the first time it finds a downloads. hostname with eighteen thousand monthly queries pointing at a bucket anyone on the internet can create.

#ai#aws#security#cloud#automation#terraform#devops
D
DevToCashAuthor

Senior DevOps/SRE Engineer · 10+ years · Professional Trader (IDX, Crypto, US Equities)

I write about real infrastructure patterns and trading strategies I use in production and in live markets. No courses, no affiliate hype — just documentation of what actually works.

More about me →