---
id: "CVE-2026-62994"
kind: "vulnerability"
aliases: ["CVE-2026-62994", "GHSA-74w3-63xv-x9mv", "GO-2026-6805"]
title: "CoreDNS `k8s_external` headless AXFR can emit an empty transfer batch that panics the `transfer` plugin"
severity: "low"
cvss: 3.700000047683716
epss: 0.0045900000259280205
exploited: false
status: "open"
category: "package"
products: [{"name": "github.com/coredns/coredns", "fixed": ["1.14.5"], "affected": ">= 1.9.4, <= 1.14.4", "ecosystem": "go"}, {"name": "github.com/coredns/coredns", "fixed": ["1.14.5"], "affected": ">= 1.9.4, < 1.14.5", "ecosystem": "go"}, {"name": "coredns.io coredns", "fixed": ["1.14.5"], "affected": ">= 1.9.4, < 1.14.5", "ecosystem": "vendor"}]
ecosystems: ["go", "vendor"]
tags: ["coredns", "cwe-248", "cwe-755", "go", "kubernetes", "package", "self-hosted"]
not_affected_if: ["managed-hosting", "no-headless-k8s-external", "no-transfer-to-untrusted-clients", "version-1.14.5-or-later"]
published_at: "2026-07-16T20:16:47+00:00"
first_seen_at: "2026-10-23T19:11:32+00:00"
updated_at: "2026-10-23T23:44:52+00:00"
sources: [{"source": "ghsa", "url": "https://github.com/advisories/GHSA-74w3-63xv-x9mv", "first_seen_at": "2026-10-23T19:11:32+00:00"}, {"source": "govuln", "url": "https://pkg.go.dev/vuln/GO-2026-6805", "first_seen_at": "2026-10-23T19:13:08+00:00"}, {"source": "nvd", "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-62994", "first_seen_at": "2026-10-23T23:44:52+00:00"}]
---

# CoreDNS `k8s_external` headless AXFR can emit an empty transfer batch that panics the `transfer` plugin

CoreDNS panics on an AXFR request for a zone served by k8s_external with headless enabled when a headless service endpoint has no declared ports, because an empty record batch is indexed without a length check. A network client allowed to request zone transfers can trigger repeated panics, returning SERVFAIL in normal mode and crashing the process if the debug plugin disables panic recovery.

_Summary, check and fix written by a model from the sources; labelled as such._

## Affected

- go:github.com/coredns/coredns affected >= 1.9.4, <= 1.14.4 fixed 1.14.5
- go:github.com/coredns/coredns affected >= 1.9.4, < 1.14.5 fixed 1.14.5
- vendor:coredns.io coredns affected >= 1.9.4, < 1.14.5 fixed 1.14.5

## How to check

Check whether the Corefile has both a k8s_external block with headless and a transfer block with a to directive that allows clients, e.g. grep -nE 'k8s_external|headless|transfer' Corefile.
Check the CoreDNS version with coredns -version or the go.mod entry for github.com/coredns/coredns.
Check whether the debug plugin is enabled in the Corefile.

## Fix

Upgrade github.com/coredns/coredns to 1.14.5 or later.
Until upgraded, restrict transfer to specific IPs instead of '*' or remove the headless option from k8s_external.

## Timeline

- 2026-10-23T19:11:32+00:00 ghsa https://github.com/advisories/GHSA-74w3-63xv-x9mv
- 2026-10-23T19:13:08+00:00 govuln https://pkg.go.dev/vuln/GO-2026-6805
- 2026-10-23T23:44:52+00:00 nvd https://nvd.nist.gov/vuln/detail/CVE-2026-62994

## From the sources

### Summary

A network DNS client allowed to request AXFR for a CoreDNS zone can trigger a panic when CoreDNS is configured with `k8s_external` headless-service zone transfers and Kubernetes contains a headless service endpoint with no declared ports. The built-in `k8s_external` transferer can emit an empty `[]dns.RR` batch, and the shared `transfer` plugin indexes `records[0]` without checking that the batch is non-empty. In normal CoreDNS mode the panic is recovered and returned as `SERVFAIL`, but repeated triggering can disrupt requests and amplify panic logging; if the `debug` plugin disables recovery, the same path can crash the CoreDNS process.

### Details

`plugin/transfer/transfer.go` assumes that every `Transferer` emits only non-empty `[]dns.RR` batches. During AXFR/IXFR response generation it indexes `records[0]` without checking the slice length:

- `plugin/transfer/transfer.go:129-131`

```go
for records := range pchan {
    if x, ok := records[0].(*dns.SOA); ok && soa == nil {
        soa = x
    }
```

The built-in `k8s_external` transferer violates that contract for headless services whose endpoints have no declared ports. `plugin/k8s_external/transfer.go` sends the SRV result slice unconditionally:

- `plugin/k8s_external/transfer.go:104-106`

```go
recs, _ := e.srv(ctx, svcs, s)
ch <- recs
```

`plugin/k8s_external/msg_to_dns.go` intentionally skips services whose port is `-1`:

- `plugin/k8s_external/msg_to_dns.go:107-112`

```go
for _, s := range services {
    // Don't add the entry if the port is -1 (invalid). The kubernetes plugin uses port -1 when a service/endpoint
    // does not have any declared ports.
    if s.Port == -1 {
        continue
    }
```

The Kubernetes EndpointSlice conversion creates that sentinel when an EndpointSlice has no ports:

- `plugin/kubernetes/object/endpoint.go:62-66`

```go
if len(ends.Ports) == 0 {
    // Add sentinel if there are no ports.
    e.Subsets[0].Ports = []EndpointPort{{Port: -1}}
```

This creates a built-in source-to-sink chain:

1. Kubernetes EndpointSlice has ready addresses and no ports.
2. CoreDNS `kubernetes` converts it to an endpoint with `Port: -1`.
3. `k8s_external` with `headless` includes that service in transfer data.
4. `k8s_external.Transfer` sends an empty SRV `[]dns.RR` batch.
5. `transfer.ServeDNS` indexes `records[0]` and panics.

Suggested remediation:

1. In `plugin/transfer/transfer.go`, skip or reject empty `records` batches before indexing `records[0]`.
2. In `plugin/k8s_external/transfer.go`, only send `recs` when `len(recs) > 0` in the headless endpoint and port/protocol paths.
3. Add regression tests covering `Port: -1` headless services and empty transfer batches.

Example consumer-side guard:

```go
for records := range pchan {
    if len(records) == 0 {
        continue
    }
    if x, ok := records[0].(*dns.SOA); ok && soa == nil {
        soa = x
    }
    ...
}
```

### PoC

Safe local unit-level reproduction was performed. It does not contact external infrastructure and does not require a live Kubernetes cluster.

#### Reproducer A: the `transfer` sink panics on an empty batch

A temporary package test was added under `plugin/transfer`:

```go
package transfer

import (
    "context"
    "fmt"
    "testing"

    "github.com/coredns/coredns/plugin/pkg/dnstest"
    "github.com/coredns/coredns/plugin/test"

    "github.com/miekg/dns"
)

type auditEmptyBatchTransferer struct{}

func (auditEmptyBatchTransferer) Transfer(zone string, serial uint32) (<-chan []dns.RR, error) {
    if zone != "example.org." {
        return nil, ErrNotAuthoritative
    }
    ch := make(chan []dns.RR, 3)
    ch <- []dns.RR{test.SOA(fmt.Sprintf("%s 100 IN SOA ns.dns.%s hostmaster.%s %d 7200 1800 86400 100", zone, zone, zone, 1))}
    ch <- []dns.RR{}
    close(ch)
    return ch, nil
}

func TestAuditEmptyTransferBatchPanics(t *testing.T) {
    tr := &Transfer{
        Transferers: []Transferer{auditEmptyBatchTransferer{}},
        xfrs:        []*xfr{{Zones: []string{"example.org."}, to: []string{"*"}}},
    }

    req := new(dns.Msg)
    req.SetQuestion("example.org.", dns.TypeAXFR)
    rec := dnstest.NewRecorder(&test.ResponseWriter{TCP: true})

    defer func() {
        if rec := recover(); rec == nil {
            t.Fatal("expected empty transfer batch to panic")
        }
    }()

    _, _ = tr.ServeDNS(context.Background(), rec, req)
}
```

Verification command and result:

```text
$ go test ./plugin/transfer -run TestAuditEmptyTransferBatchPanics -count=1 -v
=== RUN   TestAuditEmptyTransferBatchPanics
--- PASS: TestAuditEmptyTransferBatchPanics (0.00s)
PASS
ok  	github.com/coredns/coredns/plugin/transfer	0.005s
```

#### Reproducer B: the built-in `k8s_external` transferer emits the empty batch

A temporary package test was added under `plugin/k8s_external`:

```go
package external

import (
    "testing"
    "time"

    "github.com/coredns/coredns/plugin/etcd/msg"
    "github.com/coredns/coredns/plugin/kubernetes"
    "github.com/coredns/coredns/request"

    "github.com/miekg/dns"
)

func TestAuditHeadlessNoPortTransferEmitsEmptyBatch(t *testing.T) {
    e := New()
    e.Zones = []string{"example.com."}
    e.headless = true
    e.externalAddrFunc = func(request.Request, bool) []dns.RR { return nil }
    e.externalSerialFunc = func(string) uint32 { return 1 }
    e.externalServicesFunc = func(string, bool) ([]msg.Service, map[string][]msg.Service) {
        return nil, map[string][]msg.Service{
            "/coredns/com/example/testns/headless/" + kubernetes.Endpoint: {{Host: "192.0.2.10", Port: -1, TTL: 5, Key: "/coredns/com/example/testns/headless/endpoint-a"}},
        }
    }

    ch, err := e.Transfer("example.com.", 0)
    if err != nil {
        t.Fatalf("Transfer returned error: %v", err)
    }

    deadline := time.After(time.Second)
    for {
        select {
        case batch, ok := <-ch:
            if !ok {
                t.Fatal("transfer closed before emitting an empty batch")
            }
            if len(batch) == 0 {
                return
            }
        case <-deadline:
            t.Fatal("timed out waiting for an empty batch")
        }
    }
}
```

Verification command and result:

```text
$ go test ./plugin/k8s_external -run TestAuditHeadlessNoPortTransferEmitsEmptyBatch -count=1 -v
=== RUN   TestAuditHeadlessNoPortTransferEmitsEmptyBatch
--- PASS: TestAuditHeadlessNoPortTransferEmitsEmptyBatch (0.00s)
PASS
ok  	github.com/coredns/coredns/plugin/k8s_external	0.012s
```

Additional focused verification:

```text
$ go test ./plugin/transfer ./plugin/k8s_external ./plugin/kubernetes -run 'TestAudit|^$' -count=1 -v
ok  	github.com/coredns/coredns/plugin/transfer	0.006s
ok  	github.com/coredns/coredns/plugin/k8s_external	0.013s
ok  	github.com/coredns/coredns/plugin/kubernetes	0.116s
```

Affected package suites:

```text
$ go test ./plugin/transfer ./plugin/k8s_external ./plugin/kubernetes -count=1
ok  	github.com/coredns/coredns/plugin/transfer	0.010s
ok  	github.com/coredns/coredns/plugin/k8s_external	0.011s
ok  	github.com/coredns/coredns/plugin/kubernetes	0.194s
```

### Impact

A network client allowed to request AXFR for the affected zone can trigger a panic in CoreDNS when all prerequisites are present.

Required configuration/state:

```corefile
example.com:53 {
    kubernetes cluster.local
    k8s_external example.com {
        headless
    }
    transfer example.com {
        to *
    }
}
```

Additional prerequisite:

- The Kubernetes API state contains a headless-service endpoint represented by an EndpointSlice with ready addresses and no ports, producing CoreDNS `msg.Service{Port: -1}`.

In normal CoreDNS mode, `core/dnsserver/server.go:267-279` recovers the panic and returns `SERVFAIL`, so this is a request-level availability issue and panic/log amplification issue. If the `debug` plugin is enabled, the server disables the recovery path and this can crash the CoreDNS process.

---

_Free to read at a human pace. An API with keys and an MCP server are in preparation; early access: hello@infrastack.ai_
