fix(outbounds): bound standard resolver CNAME chains (#1595)

The standardResolver's lookup4/lookup6 methods recursively follow CNAME
chains without any depth limit or cycle detection. A malicious or
misconfigured DNS server returning a self-referential CNAME record
(e.g. loop.example. CNAME loop.example.) causes infinite recursion,
leading to stack overflow and crash.

Add a max depth limit of 16 and a visited-set to detect cycles. The
original lookup4/lookup6 entry points are preserved; they delegate to
lookup4WithCNAMEDepth/lookup6WithCNAMEDepth which track depth and seen
hosts across recursive calls.
This commit is contained in:
白日梦主义 2026-06-05 09:16:18 +08:00 committed by GitHub
parent 8e78342c2b
commit 247c91321f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 75 additions and 2 deletions

View file

@ -2,7 +2,9 @@ package outbounds
import (
"crypto/tls"
"errors"
"net"
"strings"
"time"
"github.com/miekg/dns"
@ -11,8 +13,11 @@ import (
const (
resolverDefaultTimeout = 2 * time.Second
standardResolverRetryTimes = 2
maxCNAMEDepth = 16
)
var errCNAMEChainTooLong = errors.New("CNAME chain too long")
// standardResolver is a PluggableOutbound DNS resolver that resolves hostnames
// using the user-provided DNS server.
// Based on "github.com/miekg/dns", it supports UDP, TCP & DNS-over-TLS (TCP).
@ -109,6 +114,18 @@ func (r *standardResolver) skipCNAMEChain(answers []dns.RR) string {
// lookup4 resolves a hostname to an IPv4 address.
// If there's no IPv4 address, it returns (nil, nil), no error.
func (r *standardResolver) lookup4(host string) (net.IP, error) {
return r.lookup4WithCNAMEDepth(host, 0, make(map[string]struct{}))
}
func (r *standardResolver) lookup4WithCNAMEDepth(host string, depth int, seen map[string]struct{}) (net.IP, error) {
if depth > maxCNAMEDepth {
return nil, errCNAMEChainTooLong
}
key := strings.ToLower(dns.Fqdn(host))
if _, ok := seen[key]; ok {
return nil, errCNAMEChainTooLong
}
seen[key] = struct{}{}
m := new(dns.Msg)
m.SetQuestion(dns.Fqdn(host), dns.TypeA)
m.RecursionDesired = true
@ -129,7 +146,7 @@ func (r *standardResolver) lookup4(host string) (net.IP, error) {
}
}
if hasCNAME {
return r.lookup4(r.skipCNAMEChain(resp.Answer))
return r.lookup4WithCNAMEDepth(r.skipCNAMEChain(resp.Answer), depth+1, seen)
} else {
// Should not happen
return nil, nil
@ -139,6 +156,18 @@ func (r *standardResolver) lookup4(host string) (net.IP, error) {
// lookup6 resolves a hostname to an IPv6 address.
// If there's no IPv6 address, it returns (nil, nil), no error.
func (r *standardResolver) lookup6(host string) (net.IP, error) {
return r.lookup6WithCNAMEDepth(host, 0, make(map[string]struct{}))
}
func (r *standardResolver) lookup6WithCNAMEDepth(host string, depth int, seen map[string]struct{}) (net.IP, error) {
if depth > maxCNAMEDepth {
return nil, errCNAMEChainTooLong
}
key := strings.ToLower(dns.Fqdn(host))
if _, ok := seen[key]; ok {
return nil, errCNAMEChainTooLong
}
seen[key] = struct{}{}
m := new(dns.Msg)
m.SetQuestion(dns.Fqdn(host), dns.TypeAAAA)
m.RecursionDesired = true
@ -159,7 +188,7 @@ func (r *standardResolver) lookup6(host string) (net.IP, error) {
}
}
if hasCNAME {
return r.lookup6(r.skipCNAMEChain(resp.Answer))
return r.lookup6WithCNAMEDepth(r.skipCNAMEChain(resp.Answer), depth+1, seen)
} else {
// Should not happen
return nil, nil

View file

@ -0,0 +1,44 @@
package outbounds
import (
"errors"
"net"
"sync/atomic"
"testing"
"time"
"github.com/miekg/dns"
)
func TestStandardResolverRejectsCNAMECycle(t *testing.T) {
var queries atomic.Int32
mux := dns.NewServeMux()
mux.HandleFunc(".", func(w dns.ResponseWriter, req *dns.Msg) {
queries.Add(1)
q := req.Question[0]
resp := new(dns.Msg)
resp.SetReply(req)
resp.Answer = append(resp.Answer, &dns.CNAME{
Hdr: dns.RR_Header{Name: q.Name, Rrtype: dns.TypeCNAME, Class: dns.ClassINET, Ttl: 1},
Target: q.Name,
})
_ = w.WriteMsg(resp)
})
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
server := &dns.Server{PacketConn: pc, Handler: mux}
go func() { _ = server.ActivateAndServe() }()
defer server.Shutdown()
r := &standardResolver{Addr: pc.LocalAddr().String(), Client: &dns.Client{Timeout: time.Second}}
_, err = r.lookup4("loop.example")
if !errors.Is(err, errCNAMEChainTooLong) {
t.Fatalf("lookup4 error = %v, want %v", err, errCNAMEChainTooLong)
}
if got := queries.Load(); got > 2 {
t.Fatalf("lookup4 followed CNAME cycle for %d queries", got)
}
}