An AXFR query asks a DNS server for the entire contents of a zone: every record, not one. Against a Kubernetes cluster running CoreDNS, a successful zone transfer hands the requester a complete map of internal service topology: every Service name, every namespace, every headless Service endpoint, every pod hostname you publish. That is exactly the map an attacker wants before lateral movement.

Stock CoreDNS does not serve AXFR. Unless the transfer plugin is explicitly configured in the Corefile, zone transfer requests are refused, and default kubeadm, EKS, GKE, and AKS Corefiles do not include it. In a typical deployment, an AXFR query is not a data-loss event. It is a tripwire.

Most teams never look at the tripwire. coredns_dns_requests_total{type="AXFR"} sits at zero for months, nobody graphs it, and the first time anyone notices it is during a post-incident review. This guide covers what the signal means, how to investigate the source, and how to lock transfers down if you actually need them.

What this means

AXFR is full zone transfer, carried over TCP. Its legitimate purpose is replication: a secondary DNS server pulls the zone from the primary so it can serve authoritative answers. IXFR is the incremental variant.

CoreDNS only answers transfer requests when two things are true: the transfer plugin is present in the Corefile, and the requesting address matches the plugin’s allowlist. Without the plugin, requests get REFUSED or SERVFAIL depending on version:

flowchart TD
  A[AXFR request arrives] --> B{transfer plugin in Corefile?}
  B -->|No| C[Request refused - REFUSED or SERVFAIL]
  B -->|Yes| D{Source IP in transfer allowlist?}
  D -->|No| E[Request denied]
  D -->|Yes| F[Full zone contents served]
  C --> G[Recon signal: investigate the source]
  E --> G
  F --> H[Acceptable only from designated secondaries]

Three operational consequences:

  • Failed attempts are still signal. A refused AXFR tells you something in your network is probing for zone contents. The refusal protected the data; it did not explain the probe.
  • A successful transfer is a data event. If coredns_dns_requests_total{type="AXFR"} is nonzero and the transfer plugin is configured with a broad allowlist, assume the zone contents are known to whoever asked.
  • The zone is more sensitive than it looks. cluster.local records reveal naming conventions, namespace layout, and which services exist. That shortens an attacker’s search for unauthenticated internal APIs, databases, and control endpoints considerably.

Quick checks

All read-only. Run these before changing anything. CoreDNS container images ship no shell or wget, so scrape metrics through the API server proxy instead of kubectl exec:

# Count AXFR and IXFR requests on one CoreDNS pod
kubectl get --raw "/api/v1/namespaces/kube-system/pods/<coredns-pod>:9153/proxy/metrics" | grep -E 'type="(AXFR|IXFR)"'
# Same check across all CoreDNS replicas (per-replica numbers matter)
for pod in $(kubectl get pods -n kube-system -l k8s-app=kube-dns -o jsonpath='{.items[*].metadata.name}'); do
  echo "== $pod =="
  kubectl get --raw "/api/v1/namespaces/kube-system/pods/${pod}:9153/proxy/metrics" 2>/dev/null | grep -E 'type="(AXFR|IXFR)"'
done
# Inspect the live Corefile for a transfer plugin and its allowlist
kubectl get cm -n kube-system coredns -o yaml
# Check whether refusals are happening (policy rejections, any cause)
kubectl get --raw "/api/v1/namespaces/kube-system/pods/<coredns-pod>:9153/proxy/metrics" | grep 'coredns_dns_responses_total' | grep REFUSED
# Reproduce the request yourself from inside the cluster
dig @<coredns-clusterip> cluster.local AXFR
# Expected on a default deployment: REFUSED, NOTAUTH, or SERVFAIL depending on version
# Search recent CoreDNS logs for transfer attempts (only useful if the log plugin is enabled)
kubectl logs -n kube-system <coredns-pod> --tail=5000 | grep -iE 'AXFR|IXFR'

Per-replica numbers matter because kube-dns load-balances across pods. A probe that hit one replica is invisible in an average.

How to investigate the source

  1. Confirm the signal is real and scope it. Note which replica saw the requests, the rate, and the zone label on the metric. AXFR against cluster.local targets internal topology. AXFR against a forwarded zone is a different, usually less sensitive, story.

  2. Check whether the transfer plugin is configured. Read the Corefile from the ConfigMap. If there is no transfer stanza, CoreDNS refused the requests. The data is safe; the question is who asked and why. If there is a transfer stanza, check the allowlist immediately (next step).

  3. Evaluate the allowlist if transfers are configured. to * or a broad pod CIDR means every pod in the cluster can pull the zone. Treat any successful transfer in that state as disclosure and tighten the allowlist to the specific secondary servers that legitimately replicate the zone. AXFR runs over TCP, so the proto="tcp" label on the request metric confirms the transport.

  4. Reproduce from a pod. dig @<coredns-ip> cluster.local AXFR from a test pod tells you what an attacker at pod-network level would get. If you get records back and you did not expect to, that is the finding.

  5. Identify the source IP. This is the hard part: CoreDNS exposes no per-source-IP Prometheus metrics. Your options are the log plugin (expensive at production QPS; enable briefly and deliberately, then turn it off), dnstap if you already ship it, or correlating the timestamp with flow logs, CNI audit logs, or your service mesh’s telemetry. Once you have an IP, map it:

# Map a source IP to a pod
kubectl get pods -A -o wide | grep <source-ip>
  1. Classify the source. Common benign explanations: a vulnerability scanner doing its routine AXFR check, an operator who ran dig AXFR while debugging, or a misconfigured secondary DNS server polling for a zone. Common hostile explanations: a compromised pod doing service discovery, or a red team. The distinction comes from what else that source is doing. An AXFR attempt followed by NXDOMAIN-heavy enumeration of plausible service names is enumeration, not scanning hygiene.

One instrumentation gotcha: on denied transfers, CoreDNS has a known logging quirk where the log plugin can record NOERROR even though the client actually received a failure rcode, because the log plugin runs before the error reply is written. Trust coredns_dns_responses_total rcode counters over the logged rcode when reconstructing what the requester received.

Restricting zone transfers

Group the fix by what you found.

Default deployment, no transfer plugin. There is nothing to fix in CoreDNS. Do not add the plugin unless you run secondaries. Your work is investigation and detection: alert on any AXFR query so the next probe does not go unnoticed.

Transfers required, allowlist too broad. Restrict the transfer plugin to the exact addresses of your secondary servers:

cluster.local {
    kubernetes cluster.local
    transfer {
        to 10.0.0.11 10.0.0.12
    }
}

Never use to * in production. The allowlist should name secondary IPs, not CIDR ranges that cover general pod space. Apply Corefile changes through a rolling restart and re-test with dig AXFR from both an allowed and a disallowed address before calling it done.

Nested zones on older CoreDNS. Versions before 1.14.3 had a transfer plugin bug (CVE-2026-33489) where a permissive parent-zone rule could override a restrictive subzone rule due to lexicographic zone comparison, allowing unauthorized transfers of the subzone. If you serve nested zones with different transfer policies, upgrade to 1.14.3 or later.

Additional source-IP filtering. The acl plugin can filter by query type and source network, letting you block AXFR from everything except a small range before the transfer plugin even evaluates the request. Consult the acl plugin documentation for the exact directive syntax for your version, and test with dig after any change: a misordered acl rule can block legitimate queries or silently permit what you meant to block.

Network-layer backstop. AXFR requires TCP/53. A NetworkPolicy that restricts which pods can reach CoreDNS over TCP shrinks the probing surface without touching DNS configuration. This is defense in depth, not a substitute for a correct allowlist.

Metrics and signals to monitor

SignalWhy it mattersWarning sign
coredns_dns_requests_total{type="AXFR"}Direct count of full zone transfer attempts, per replica and zoneAny nonzero rate where AXFR is not expected
coredns_dns_requests_total{type="IXFR"}Incremental transfer attempts, same exposure classAny nonzero rate
coredns_dns_responses_total{rcode="REFUSED"}Confirms policy rejections are happening; use this, not log rcodes, to verify denialSustained rate correlating with AXFR queries
coredns_dns_requests_total{proto="tcp"} shareAXFR rides TCP; normal Kubernetes DNS is overwhelmingly UDPUnexplained rise in TCP share
NXDOMAIN rate per source (log or flow-level analysis; there is no per-source metric)Enumeration often follows a failed AXFR: probing which service names existSingle source generating a high NXDOMAIN rate

The threshold for AXFR and IXFR is binary: any nonzero rate in an environment that does not run secondaries is a ticket. Do not wait for volume.

Prevention

  • Keep the transfer plugin out of cluster Corefiles. Stock Kubernetes CoreDNS refuses AXFR by default, and that default is the correct posture for cluster.local.
  • Allowlist specific secondaries only. If replication is genuinely needed, transfer { to <ip> } with exact addresses, never to *.
  • Alert on the tripwire. Generate a ticket (not a page) on any nonzero AXFR/IXFR rate, evaluated per replica so a single-pod probe still fires.
  • Backstop with NetworkPolicy. Restrict TCP/53 reachability to CoreDNS from only the workloads that need it.
  • Plan source identification before you need it. CoreDNS has no per-client metrics, so decide now whether dnstap, flow logs, or a brief log plugin enablement is your path, and document the runbook step.
  • Upgrade past the nested-zone bug. If you use the transfer plugin with parent and child zones, run 1.14.3 or later.

How Netdata helps

  • Netdata charts coredns_dns_requests_total broken out by query type per CoreDNS instance, so a first-ever AXFR or IXFR blip is visible on the dashboard instead of buried in a counter nobody reads.
  • Per-instance views match the per-replica reality of kube-dns: a probe that touched one of three pods shows up on that pod’s charts, not averaged away.
  • Correlating AXFR attempts with coredns_dns_responses_total{rcode="REFUSED"} on the same timeline confirms whether the attempts were denied, without trusting the misleading log rcode.
  • Alerts on any nonzero AXFR/IXFR rate turn the tripwire into a ticket automatically, closing the gap between “metric exists” and “someone investigates.”