---
id: CVE-2026-62994
aliases:
  - GHSA-74w3-63xv-x9mv
title: >-
  CoreDNS `k8s_external` headless AXFR can emit an empty transfer batch that
  panics the `transfer` plugin
summary: >-
  CoreDNS `k8s_external` headless AXFR can emit an empty transfer batch that
  panics the `transfer` plugin
severity: low
cvss: 3.7
cwe:
  - CWE-248
  - CWE-755
vendor: coredns
product: github.com/coredns/coredns
ecosystem: go
affected:
  - 'github.com/coredns/coredns >= 1.9.4, <= 1.14.4'
patched:
  - github.com/coredns/coredns 1.14.5
published: '2026-10-16'
updated: '2026-10-16'
sourceUpdated: '2026-10-16T19:26:43Z'
source: GHSA
sourceUrl: 'https://github.com/advisories/GHSA-74w3-63xv-x9mv'
references:
  - url: 'https://github.com/coredns/coredns/security/advisories/GHSA-74w3-63xv-x9mv'
  - url: 'https://nvd.nist.gov/vuln/detail/CVE-2026-62994'
  - url: 'https://github.com/coredns/coredns/pull/8207'
  - url: >-
      https://github.com/coredns/coredns/commit/ab318db7b4a3a19273852ed627f54888198c8efb
  - url: 'https://github.com/coredns/coredns/releases/tag/v1.14.5'
  - url: 'https://github.com/advisories/GHSA-74w3-63xv-x9mv'
tags:
  - ghsa
  - go
epss: 0.00459
epssPercentile: 0.38361
ingestedAt: '2026-10-16T19:46:24.282Z'
---

## Overview

### 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.

## Affected packages

- `github.com/coredns/coredns >= 1.9.4, <= 1.14.4`

## Remediation

Upgrade to a patched release:

- `github.com/coredns/coredns 1.14.5`
