GHSA-74W3-63XV-X9MV

Vulnerability from github – Published: 2026-10-16 19:26 – Updated: 2026-10-16 19:26
VLAI
Summary
CoreDNS `k8s_external` headless AXFR can emit an empty transfer batch that panics the `transfer` plugin
Details

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

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:

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:

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

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:

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

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

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

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.

Show details on source website

{
  "affected": [
    {
      "database_specific": {
        "last_known_affected_version_range": "\u003c= 1.14.4"
      },
      "package": {
        "ecosystem": "Go",
        "name": "github.com/coredns/coredns"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "1.9.4"
            },
            {
              "fixed": "1.14.5"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-62994"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-248",
      "CWE-755"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-10-16T19:26:42Z",
    "nvd_published_at": "2026-07-16T20:16:47Z",
    "severity": "LOW"
  },
  "details": "### Summary\n\nA 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.\n\n### Details\n\n`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:\n\n- `plugin/transfer/transfer.go:129-131`\n\n```go\nfor records := range pchan {\n    if x, ok := records[0].(*dns.SOA); ok \u0026\u0026 soa == nil {\n        soa = x\n    }\n```\n\nThe 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:\n\n- `plugin/k8s_external/transfer.go:104-106`\n\n```go\nrecs, _ := e.srv(ctx, svcs, s)\nch \u003c- recs\n```\n\n`plugin/k8s_external/msg_to_dns.go` intentionally skips services whose port is `-1`:\n\n- `plugin/k8s_external/msg_to_dns.go:107-112`\n\n```go\nfor _, s := range services {\n    // Don\u0027t add the entry if the port is -1 (invalid). The kubernetes plugin uses port -1 when a service/endpoint\n    // does not have any declared ports.\n    if s.Port == -1 {\n        continue\n    }\n```\n\nThe Kubernetes EndpointSlice conversion creates that sentinel when an EndpointSlice has no ports:\n\n- `plugin/kubernetes/object/endpoint.go:62-66`\n\n```go\nif len(ends.Ports) == 0 {\n    // Add sentinel if there are no ports.\n    e.Subsets[0].Ports = []EndpointPort{{Port: -1}}\n```\n\nThis creates a built-in source-to-sink chain:\n\n1. Kubernetes EndpointSlice has ready addresses and no ports.\n2. CoreDNS `kubernetes` converts it to an endpoint with `Port: -1`.\n3. `k8s_external` with `headless` includes that service in transfer data.\n4. `k8s_external.Transfer` sends an empty SRV `[]dns.RR` batch.\n5. `transfer.ServeDNS` indexes `records[0]` and panics.\n\nSuggested remediation:\n\n1. In `plugin/transfer/transfer.go`, skip or reject empty `records` batches before indexing `records[0]`.\n2. In `plugin/k8s_external/transfer.go`, only send `recs` when `len(recs) \u003e 0` in the headless endpoint and port/protocol paths.\n3. Add regression tests covering `Port: -1` headless services and empty transfer batches.\n\nExample consumer-side guard:\n\n```go\nfor records := range pchan {\n    if len(records) == 0 {\n        continue\n    }\n    if x, ok := records[0].(*dns.SOA); ok \u0026\u0026 soa == nil {\n        soa = x\n    }\n    ...\n}\n```\n\n### PoC\n\nSafe local unit-level reproduction was performed. It does not contact external infrastructure and does not require a live Kubernetes cluster.\n\n#### Reproducer A: the `transfer` sink panics on an empty batch\n\nA temporary package test was added under `plugin/transfer`:\n\n```go\npackage transfer\n\nimport (\n    \"context\"\n    \"fmt\"\n    \"testing\"\n\n    \"github.com/coredns/coredns/plugin/pkg/dnstest\"\n    \"github.com/coredns/coredns/plugin/test\"\n\n    \"github.com/miekg/dns\"\n)\n\ntype auditEmptyBatchTransferer struct{}\n\nfunc (auditEmptyBatchTransferer) Transfer(zone string, serial uint32) (\u003c-chan []dns.RR, error) {\n    if zone != \"example.org.\" {\n        return nil, ErrNotAuthoritative\n    }\n    ch := make(chan []dns.RR, 3)\n    ch \u003c- []dns.RR{test.SOA(fmt.Sprintf(\"%s 100 IN SOA ns.dns.%s hostmaster.%s %d 7200 1800 86400 100\", zone, zone, zone, 1))}\n    ch \u003c- []dns.RR{}\n    close(ch)\n    return ch, nil\n}\n\nfunc TestAuditEmptyTransferBatchPanics(t *testing.T) {\n    tr := \u0026Transfer{\n        Transferers: []Transferer{auditEmptyBatchTransferer{}},\n        xfrs:        []*xfr{{Zones: []string{\"example.org.\"}, to: []string{\"*\"}}},\n    }\n\n    req := new(dns.Msg)\n    req.SetQuestion(\"example.org.\", dns.TypeAXFR)\n    rec := dnstest.NewRecorder(\u0026test.ResponseWriter{TCP: true})\n\n    defer func() {\n        if rec := recover(); rec == nil {\n            t.Fatal(\"expected empty transfer batch to panic\")\n        }\n    }()\n\n    _, _ = tr.ServeDNS(context.Background(), rec, req)\n}\n```\n\nVerification command and result:\n\n```text\n$ go test ./plugin/transfer -run TestAuditEmptyTransferBatchPanics -count=1 -v\n=== RUN   TestAuditEmptyTransferBatchPanics\n--- PASS: TestAuditEmptyTransferBatchPanics (0.00s)\nPASS\nok  \tgithub.com/coredns/coredns/plugin/transfer\t0.005s\n```\n\n#### Reproducer B: the built-in `k8s_external` transferer emits the empty batch\n\nA temporary package test was added under `plugin/k8s_external`:\n\n```go\npackage external\n\nimport (\n    \"testing\"\n    \"time\"\n\n    \"github.com/coredns/coredns/plugin/etcd/msg\"\n    \"github.com/coredns/coredns/plugin/kubernetes\"\n    \"github.com/coredns/coredns/request\"\n\n    \"github.com/miekg/dns\"\n)\n\nfunc TestAuditHeadlessNoPortTransferEmitsEmptyBatch(t *testing.T) {\n    e := New()\n    e.Zones = []string{\"example.com.\"}\n    e.headless = true\n    e.externalAddrFunc = func(request.Request, bool) []dns.RR { return nil }\n    e.externalSerialFunc = func(string) uint32 { return 1 }\n    e.externalServicesFunc = func(string, bool) ([]msg.Service, map[string][]msg.Service) {\n        return nil, map[string][]msg.Service{\n            \"/coredns/com/example/testns/headless/\" + kubernetes.Endpoint: {{Host: \"192.0.2.10\", Port: -1, TTL: 5, Key: \"/coredns/com/example/testns/headless/endpoint-a\"}},\n        }\n    }\n\n    ch, err := e.Transfer(\"example.com.\", 0)\n    if err != nil {\n        t.Fatalf(\"Transfer returned error: %v\", err)\n    }\n\n    deadline := time.After(time.Second)\n    for {\n        select {\n        case batch, ok := \u003c-ch:\n            if !ok {\n                t.Fatal(\"transfer closed before emitting an empty batch\")\n            }\n            if len(batch) == 0 {\n                return\n            }\n        case \u003c-deadline:\n            t.Fatal(\"timed out waiting for an empty batch\")\n        }\n    }\n}\n```\n\nVerification command and result:\n\n```text\n$ go test ./plugin/k8s_external -run TestAuditHeadlessNoPortTransferEmitsEmptyBatch -count=1 -v\n=== RUN   TestAuditHeadlessNoPortTransferEmitsEmptyBatch\n--- PASS: TestAuditHeadlessNoPortTransferEmitsEmptyBatch (0.00s)\nPASS\nok  \tgithub.com/coredns/coredns/plugin/k8s_external\t0.012s\n```\n\nAdditional focused verification:\n\n```text\n$ go test ./plugin/transfer ./plugin/k8s_external ./plugin/kubernetes -run \u0027TestAudit|^$\u0027 -count=1 -v\nok  \tgithub.com/coredns/coredns/plugin/transfer\t0.006s\nok  \tgithub.com/coredns/coredns/plugin/k8s_external\t0.013s\nok  \tgithub.com/coredns/coredns/plugin/kubernetes\t0.116s\n```\n\nAffected package suites:\n\n```text\n$ go test ./plugin/transfer ./plugin/k8s_external ./plugin/kubernetes -count=1\nok  \tgithub.com/coredns/coredns/plugin/transfer\t0.010s\nok  \tgithub.com/coredns/coredns/plugin/k8s_external\t0.011s\nok  \tgithub.com/coredns/coredns/plugin/kubernetes\t0.194s\n```\n\n### Impact\n\nA network client allowed to request AXFR for the affected zone can trigger a panic in CoreDNS when all prerequisites are present.\n\nRequired configuration/state:\n\n```corefile\nexample.com:53 {\n    kubernetes cluster.local\n    k8s_external example.com {\n        headless\n    }\n    transfer example.com {\n        to *\n    }\n}\n```\n\nAdditional prerequisite:\n\n- 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}`.\n\nIn 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.",
  "id": "GHSA-74w3-63xv-x9mv",
  "modified": "2026-10-16T19:26:42Z",
  "published": "2026-10-16T19:26:42Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/coredns/coredns/security/advisories/GHSA-74w3-63xv-x9mv"
    },
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-62994"
    },
    {
      "type": "WEB",
      "url": "https://github.com/coredns/coredns/pull/8207"
    },
    {
      "type": "WEB",
      "url": "https://github.com/coredns/coredns/commit/ab318db7b4a3a19273852ed627f54888198c8efb"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/coredns/coredns"
    },
    {
      "type": "WEB",
      "url": "https://github.com/coredns/coredns/releases/tag/v1.14.5"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:L",
      "type": "CVSS_V3"
    }
  ],
  "summary": "CoreDNS `k8s_external` headless AXFR can emit an empty transfer batch that panics the `transfer` plugin"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Drag nodes to arrange them. Double-click a related vulnerability to open it, or a source to visit its link. The Physics button on the left tunes the layout and the simulation; the legend at the bottom left hides or shows nodes by kind and by sighting type.

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
Vulnerability timeline

Sightings and recorded dates, grouped by day · UTC

JSON
Events included in the timeline

Loading timeline…


    Loading…

    Loading…

    Loading…

    Related by attack behaviour

    Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.