Back to Checks
aws

Route53 record does not point to a dangling AWS resource

route53_dangling_ip_subdomain_takeover
Service: route53Severity: high
OfficialBuilt-inVerified
Categories:internet-exposed
Resource Type:AwsRoute53HostedZone

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 Cloud
Run
prowler aws --checks route53_dangling_ip_subdomain_takeover

Recommendation

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: false

Other

1Open AWS Console > Route 53 > Hosted zones
2Select the hosted zone and locate the failing non-alias A record
3If not needed: click Delete and confirm
4If needed: select the record, click Edit, enable Alias, choose the correct AWS resource (e.g., ALB/CloudFront), then Save changes
5Wait for propagation (~60s) and re-run the check

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 findings