Route53 record does not point to a dangling AWS resource
Route 53 records are evaluated for two subdomain takeover vectors: (1) non-alias A records using literal IPs in public AWS ranges that are not assigned to resources in the account (released EIPs/ENI public IPs); and (2) non-alias CNAME records targeting an S3 website endpoint (*.s3-website[.-]<region>.amazonaws.com) whose bucket no longer exists in the account.
Risk
Dangling DNS records pointing to released AWS resources enable subdomain takeover. An attacker who later claims the IP — or registers an S3 bucket with the same name in any AWS account — can:
- Redirect or alter content (integrity)
- Capture credentials/cookies (confidentiality)
- Disrupt or impersonate services (availability)
Run this check with Prowler
Run in Prowler Cloudprowler aws --checks route53_dangling_ip_subdomain_takeoverRecommendation
Remove or update any record that points to an unowned AWS resource: unassigned public IPs in A records and S3 website endpoints in CNAME records whose bucket has been deleted. Avoid hard-coding AWS public IPs in A records; prefer aliases to managed endpoints (ALB, CloudFront, S3) and delete CNAMEs as soon as the backing bucket is removed. Enforce asset lifecycle decommissioning, routine DNS-asset reconciliation, and change control with monitoring to prevent and detect drift.
Remediation
CLI
aws route53 change-resource-record-sets --hosted-zone-id <example_resource_id> --change-batch '{"Changes":[{"Action":"UPSERT","ResourceRecordSet":{"Name":"<example_resource_name>","Type":"A","AliasTarget":{"HostedZoneId":"<ALIAS_TARGET_HOSTED_ZONE_ID>","DNSName":"<ALIAS_TARGET_DNS_NAME>","EvaluateTargetHealth":false}}}]}'Terraform
# Terraform: convert A record to Alias to avoid dangling public IPs
resource "aws_route53_record" "<example_resource_name>" {
zone_id = "<example_resource_id>"
name = "<example_resource_name>"
type = "A"
alias { # CRITICAL: Alias to AWS resource (no direct IP)
name = "<ALIAS_TARGET_DNS_NAME>" # e.g., dualstack.<alb>.amazonaws.com
zone_id = "<ALIAS_TARGET_HOSTED_ZONE_ID>"
evaluate_target_health = false
}
}NativeIaC
# CloudFormation: convert A record to an Alias so it no longer points to a dangling IP
Resources:
<example_resource_name>:
Type: AWS::Route53::RecordSet
Properties:
HostedZoneId: <example_resource_id>
Name: <example_resource_name>
Type: A
AliasTarget:
HostedZoneId: <ALIAS_TARGET_HOSTED_ZONE_ID> # CRITICAL: use Alias to an AWS resource instead of an IP
DNSName: <ALIAS_TARGET_DNS_NAME> # CRITICAL: target AWS resource DNS (e.g., ALB/CloudFront)
EvaluateTargetHealth: falseOther
Check Code
import re
from ipaddress import ip_address
from prowler.lib.check.models import Check, Check_Report_AWS
from prowler.lib.utils.utils import validate_ip_address
from prowler.providers.aws.lib.ip_ranges.ip_ranges import get_public_ip_networks
from prowler.providers.aws.services.ec2.ec2_client import ec2_client
from prowler.providers.aws.services.route53.route53_client import route53_client
from prowler.providers.aws.services.s3.s3_client import s3_client
# S3 website endpoint formats:
# <bucket>.s3-website-<region>.amazonaws.com (legacy, dash)
# <bucket>.s3-website.<region>.amazonaws.com (newer, dot)
S3_WEBSITE_ENDPOINT_REGEX = re.compile(
r"^(?P<bucket>[^.]+(?:\.[^.]+)*)\.s3-website[.-](?P<region>[a-z0-9-]+)\.amazonaws\.com\.?$"
)
class route53_dangling_ip_subdomain_takeover(Check):
def execute(self) -> Check_Report_AWS:
findings = []
# AWS public IP prefixes are fetched lazily, at most once per run, only
# when a dangling-candidate public IP is found.
aws_ip_networks = None
# When --region is used, Route53 service gathers EIPs from all regions
# to avoid false positives. Otherwise, use ec2_client data directly.
if route53_client.all_account_elastic_ips:
public_ips = list(route53_client.all_account_elastic_ips)
else:
public_ips = [eip.public_ip for eip in ec2_client.elastic_ips]
# Add Network Interface public IPs from audited regions
for ni in ec2_client.network_interfaces.values():
if ni.association and ni.association.get("PublicIp"):
public_ips.append(ni.association.get("PublicIp"))
owned_bucket_names = {bucket.name for bucket in s3_client.buckets.values()}
for record_set in route53_client.record_sets:
hosted_zone = route53_client.hosted_zones[record_set.hosted_zone_id]
# A records: dangling-IP path (released EIPs / unowned AWS IPs)
if record_set.type == "A" and not record_set.is_alias:
for record in record_set.records:
if validate_ip_address(record):
record_ip = ip_address(record)
report = Check_Report_AWS(
metadata=self.metadata(), resource=record_set
)
report.resource_id = (
f"{record_set.hosted_zone_id}/{record_set.name}/{record}"
)
report.resource_arn = hosted_zone.arn
report.resource_tags = hosted_zone.tags
report.status = "PASS"
report.status_extended = f"Route53 record {record} (name: {record_set.name}) in Hosted Zone {hosted_zone.name} is not a dangling IP."
# If Public IP check if it is in the AWS Account
if not record_ip.is_private and record not in public_ips:
report.status_extended = f"Route53 record {record} (name: {record_set.name}) in Hosted Zone {hosted_zone.name} does not belong to AWS and it is not a dangling IP."
# Check if potential dangling IP is within AWS Ranges
if aws_ip_networks is None:
aws_ip_networks = get_public_ip_networks()
if any(record_ip in network for network in aws_ip_networks):
report.status = "FAIL"
report.status_extended = f"Route53 record {record} (name: {record_set.name}) in Hosted Zone {hosted_zone.name} is a dangling IP which can lead to a subdomain takeover attack."
findings.append(report)
# CNAME records: dangling S3 website endpoint
# (deleted bucket whose name can be re-registered by anyone)
elif record_set.type == "CNAME" and not record_set.is_alias:
for record in record_set.records:
match = S3_WEBSITE_ENDPOINT_REGEX.match(record.lower())
if not match:
continue
bucket_name = match.group("bucket")
report = Check_Report_AWS(
metadata=self.metadata(), resource=record_set
)
report.resource_id = (
f"{record_set.hosted_zone_id}/{record_set.name}/{record}"
)
report.resource_arn = hosted_zone.arn
report.resource_tags = hosted_zone.tags
if bucket_name in owned_bucket_names:
report.status = "PASS"
report.status_extended = f"Route53 CNAME {record_set.name} in Hosted Zone {hosted_zone.name} points to S3 website endpoint of bucket {bucket_name} which exists in the account."
else:
report.status = "FAIL"
report.status_extended = f"Route53 CNAME {record_set.name} in Hosted Zone {hosted_zone.name} points to S3 website endpoint of bucket {bucket_name} which does not exist in the account and can lead to a subdomain takeover attack."
findings.append(report)
return findingsReferences
- https://support.icompaas.com/support/solutions/articles/62000233461-ensure-route53-records-contains-dangling-ips-
- https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/aws/Route53/dangling-dns-records.html
- https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/resource-record-sets-deleting.html
- https://docs.aws.amazon.com/AmazonS3/latest/userguide/WebsiteEndpoints.html