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:
- Kubernetes EndpointSlice has ready addresses and no ports.
- CoreDNS
kubernetes converts it to an endpoint with Port: -1.
k8s_external with headless includes that service in transfer data.
k8s_external.Transfer sends an empty SRV []dns.RR batch.
transfer.ServeDNS indexes records[0] and panics.
Suggested remediation:
- In
plugin/transfer/transfer.go, skip or reject empty records batches before indexing records[0].
- In
plugin/k8s_external/transfer.go, only send recs when len(recs) > 0 in the headless endpoint and port/protocol paths.
- 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.
References
Summary
A network DNS client allowed to request AXFR for a CoreDNS zone can trigger a panic when CoreDNS is configured with
k8s_externalheadless-service zone transfers and Kubernetes contains a headless service endpoint with no declared ports. The built-ink8s_externaltransferer can emit an empty[]dns.RRbatch, and the sharedtransferplugin indexesrecords[0]without checking that the batch is non-empty. In normal CoreDNS mode the panic is recovered and returned asSERVFAIL, but repeated triggering can disrupt requests and amplify panic logging; if thedebugplugin disables recovery, the same path can crash the CoreDNS process.Details
plugin/transfer/transfer.goassumes that everyTransfereremits only non-empty[]dns.RRbatches. During AXFR/IXFR response generation it indexesrecords[0]without checking the slice length:plugin/transfer/transfer.go:129-131The built-in
k8s_externaltransferer violates that contract for headless services whose endpoints have no declared ports.plugin/k8s_external/transfer.gosends the SRV result slice unconditionally:plugin/k8s_external/transfer.go:104-106plugin/k8s_external/msg_to_dns.gointentionally skips services whose port is-1:plugin/k8s_external/msg_to_dns.go:107-112The Kubernetes EndpointSlice conversion creates that sentinel when an EndpointSlice has no ports:
plugin/kubernetes/object/endpoint.go:62-66This creates a built-in source-to-sink chain:
kubernetesconverts it to an endpoint withPort: -1.k8s_externalwithheadlessincludes that service in transfer data.k8s_external.Transfersends an empty SRV[]dns.RRbatch.transfer.ServeDNSindexesrecords[0]and panics.Suggested remediation:
plugin/transfer/transfer.go, skip or reject emptyrecordsbatches before indexingrecords[0].plugin/k8s_external/transfer.go, only sendrecswhenlen(recs) > 0in the headless endpoint and port/protocol paths.Port: -1headless services and empty transfer batches.Example consumer-side guard:
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
transfersink panics on an empty batchA temporary package test was added under
plugin/transfer:Verification command and result:
Reproducer B: the built-in
k8s_externaltransferer emits the empty batchA temporary package test was added under
plugin/k8s_external:Verification command and result:
Additional focused verification:
Affected package suites:
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:
Additional prerequisite:
msg.Service{Port: -1}.In normal CoreDNS mode,
core/dnsserver/server.go:267-279recovers the panic and returnsSERVFAIL, so this is a request-level availability issue and panic/log amplification issue. If thedebugplugin is enabled, the server disables the recovery path and this can crash the CoreDNS process.References