Skip to content

Commit ce6beb3

Browse files
authored
Merge pull request #732 from Veirt/refactor/extract-shuffledns-wildcard-resolver
feat: add shared wildcard detector package (dnsx#924 1/2)
2 parents b26a080 + 6631f53 commit ce6beb3

7 files changed

Lines changed: 536 additions & 12 deletions

File tree

dns/wildcard/resolver.go

Lines changed: 303 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,303 @@
1+
package wildcard
2+
3+
import (
4+
"errors"
5+
"strings"
6+
"sync"
7+
8+
mapsutil "github.com/projectdiscovery/utils/maps"
9+
sliceutil "github.com/projectdiscovery/utils/slice"
10+
stringsutil "github.com/projectdiscovery/utils/strings"
11+
"github.com/rs/xid"
12+
)
13+
14+
const DefaultWildcardProbeCount = 3
15+
const reProbeCount = 2
16+
17+
var errDomainFound = errors.New("domain found")
18+
19+
type probeState uint8
20+
21+
const (
22+
probeStateError probeState = iota
23+
probeStateNoAnswers
24+
probeStateResolved
25+
)
26+
27+
// Resolver represents a wildcard resolver extracted from shuffledns.
28+
type Resolver struct {
29+
Domains *sliceutil.SyncSlice[string]
30+
lookup LookupFunc
31+
32+
levelAnswersNormalCache *mapsutil.SyncLockMap[string, struct{}]
33+
wildcardAnswersCache *mapsutil.SyncLockMap[string, wildcardAnswerCacheValue]
34+
35+
probeCount int
36+
}
37+
38+
type wildcardAnswerCacheValue struct {
39+
IPS *mapsutil.SyncLockMap[string, struct{}]
40+
}
41+
42+
func mapValues(m *mapsutil.SyncLockMap[string, struct{}]) map[string]struct{} {
43+
values := make(map[string]struct{})
44+
if m == nil {
45+
return values
46+
}
47+
48+
_ = m.Iterate(func(key string, value struct{}) error {
49+
values[key] = value
50+
return nil
51+
})
52+
53+
return values
54+
}
55+
56+
// NewResolver initializes and creates a new resolver to find wildcards.
57+
func NewResolver(domains []string, lookup LookupFunc) *Resolver {
58+
fqdns := sliceutil.NewSyncSlice[string]()
59+
fqdns.Append(domains...)
60+
return NewResolverWithDomains(fqdns, lookup)
61+
}
62+
63+
// NewResolverWithDomains initializes a resolver with a pre-built domain slice.
64+
func NewResolverWithDomains(domains *sliceutil.SyncSlice[string], lookup LookupFunc) *Resolver {
65+
if domains == nil {
66+
domains = sliceutil.NewSyncSlice[string]()
67+
}
68+
69+
return &Resolver{
70+
Domains: domains,
71+
lookup: lookup,
72+
levelAnswersNormalCache: mapsutil.NewSyncLockMap[string, struct{}](),
73+
wildcardAnswersCache: mapsutil.NewSyncLockMap[string, wildcardAnswerCacheValue](),
74+
probeCount: DefaultWildcardProbeCount,
75+
}
76+
}
77+
78+
// SetProbeCount sets the number of probes to use for wildcard detection.
79+
// Higher values improve detection of wildcards using DNS round-robin.
80+
func (w *Resolver) SetProbeCount(count int) {
81+
if count > 0 {
82+
w.probeCount = count
83+
}
84+
}
85+
86+
// probeWildcardIPs probes the given wildcard pattern multiple times concurrently and returns all IPs found.
87+
// If the first probe returns no answers, the level is treated as a normal level.
88+
// Transport or resolver errors are returned separately so callers do not cache them as normal answers.
89+
// First query is executed sequentially for early exit, remaining queries run in parallel.
90+
func (w *Resolver) probeWildcardIPs(pattern string, count int) ([]string, probeState) {
91+
if count <= 0 {
92+
return nil, probeStateNoAnswers
93+
}
94+
95+
ips := sliceutil.NewSyncSlice[string]()
96+
97+
probe := func() ([]string, probeState) {
98+
probeHost := strings.ReplaceAll(pattern, "*.", xid.New().String()+".")
99+
answers, err := w.lookup(probeHost)
100+
if err != nil {
101+
return nil, probeStateError
102+
}
103+
if len(answers) == 0 {
104+
return nil, probeStateNoAnswers
105+
}
106+
return answers, probeStateResolved
107+
}
108+
109+
resultIPs, state := probe()
110+
if state != probeStateResolved {
111+
return nil, state
112+
}
113+
if len(resultIPs) > 0 {
114+
ips.Append(resultIPs...)
115+
}
116+
117+
if count == 1 {
118+
if ips.Len() == 0 {
119+
return nil, probeStateNoAnswers
120+
}
121+
return sliceutil.Dedupe(ips.Slice), probeStateResolved
122+
}
123+
124+
var wg sync.WaitGroup
125+
for i := 1; i < count; i++ {
126+
wg.Add(1)
127+
go func() {
128+
defer wg.Done()
129+
130+
resultIPs, state := probe()
131+
if state == probeStateResolved && len(resultIPs) > 0 {
132+
ips.Append(resultIPs...)
133+
}
134+
}()
135+
}
136+
137+
wg.Wait()
138+
139+
if ips.Len() == 0 {
140+
return nil, probeStateNoAnswers
141+
}
142+
143+
return sliceutil.Dedupe(ips.Slice), probeStateResolved
144+
}
145+
146+
// generateWildcardPermutations generates wildcard permutations for a given subdomain
147+
// and domain. It generates permutations for each level of the subdomain
148+
// in reverse order.
149+
func generateWildcardPermutations(subdomain, domain string) []string {
150+
var hosts []string
151+
subdomainTokens := strings.Split(subdomain, ".")
152+
153+
var builder strings.Builder
154+
builder.Grow(len(subdomain) + len(domain) + 5)
155+
156+
// Iterate from the reverse order. This way we generate the roots
157+
// first and allows us to do filtering faster, by trying out the root
158+
// like *.example.com first, and *.child.example.com in that order.
159+
// If we get matches for the root, we can skip the child and rest.
160+
builder.WriteString("*.")
161+
builder.WriteString(domain)
162+
hosts = append(hosts, builder.String())
163+
builder.Reset()
164+
165+
for i := len(subdomainTokens); i > 1; i-- {
166+
_, _ = builder.WriteString("*.")
167+
_, _ = builder.WriteString(strings.Join(subdomainTokens[i-1:], "."))
168+
_, _ = builder.WriteRune('.')
169+
_, _ = builder.WriteString(domain)
170+
hosts = append(hosts, builder.String())
171+
builder.Reset()
172+
}
173+
return hosts
174+
}
175+
176+
// LookupHost returns wildcard IP addresses of a wildcard if it's a wildcard.
177+
// To determine this, we split the target host by dots, generate wildcard
178+
// permutations for each level of the matched domain, and probe those levels.
179+
// If any of the host IPs overlap with wildcard answers collected for those
180+
// levels, the host is treated as wildcard-backed.
181+
func (w *Resolver) LookupHost(host string, knownIPs []string) (bool, map[string]struct{}) {
182+
wildcards := make(map[string]struct{})
183+
184+
var domain string
185+
w.Domains.Each(func(i int, domainCandidate string) error {
186+
if stringsutil.HasSuffixAny(host, "."+domainCandidate) {
187+
domain = domainCandidate
188+
return errDomainFound
189+
}
190+
return nil
191+
})
192+
193+
// Ignore records without a matching domain. This may be interesting for
194+
// dangling-domain detection later, but wildcard matching intentionally skips it.
195+
if domain == "" {
196+
return false, nil
197+
}
198+
199+
subdomainPart := strings.TrimSuffix(host, "."+domain)
200+
201+
// create the wildcard generation prefix.
202+
// We use a rand prefix at the beginning like %rand%.domain.tld
203+
// A permutation is generated for each level of the subdomain.
204+
hosts := generateWildcardPermutations(subdomainPart, domain)
205+
206+
// Iterate over all the hosts generated for rand.
207+
for _, h := range hosts {
208+
h = strings.TrimSuffix(h, ".")
209+
210+
original := h
211+
212+
// Check if we have already resolved this host level successfully
213+
// and if so, use the cached answer
214+
//
215+
// ex. *.campaigns.google.com is a wildcard so we cache it
216+
// and it is used always for resolutions in future.
217+
cachedValue, cachedValueOk := w.wildcardAnswersCache.Get(original)
218+
if cachedValueOk {
219+
for _, knownIP := range knownIPs {
220+
if _, ipExists := cachedValue.IPS.Get(knownIP); ipExists {
221+
return true, mapValues(cachedValue.IPS)
222+
}
223+
}
224+
if extraIPs, state := w.probeWildcardIPs(original, reProbeCount); state == probeStateResolved && len(extraIPs) > 0 {
225+
for _, record := range extraIPs {
226+
wildcards[record] = struct{}{}
227+
_ = cachedValue.IPS.Set(record, struct{}{})
228+
}
229+
_ = w.wildcardAnswersCache.Set(original, cachedValue)
230+
for _, knownIP := range knownIPs {
231+
if _, ipExists := cachedValue.IPS.Get(knownIP); ipExists {
232+
return true, mapValues(cachedValue.IPS)
233+
}
234+
}
235+
}
236+
}
237+
238+
// Check if this level already produced a normal response with no wildcard answers.
239+
// Example: *.google.com is not a wildcard and returns NXDOMAIN,
240+
// so future checks at that level can be skipped.
241+
if _, ok := w.levelAnswersNormalCache.Get(original); ok {
242+
continue
243+
}
244+
245+
probeIPs, state := w.probeWildcardIPs(original, w.probeCount)
246+
if state == probeStateNoAnswers {
247+
_ = w.levelAnswersNormalCache.Set(original, struct{}{})
248+
continue
249+
}
250+
if state != probeStateResolved {
251+
continue
252+
}
253+
254+
if len(probeIPs) > 0 {
255+
if !cachedValueOk {
256+
cachedValue.IPS = mapsutil.NewSyncLockMap[string, struct{}]()
257+
}
258+
for _, record := range probeIPs {
259+
wildcards[record] = struct{}{}
260+
_ = cachedValue.IPS.Set(record, struct{}{})
261+
}
262+
_ = w.wildcardAnswersCache.Set(original, cachedValue)
263+
for _, knownIP := range knownIPs {
264+
if _, ipExists := cachedValue.IPS.Get(knownIP); ipExists {
265+
return true, mapValues(cachedValue.IPS)
266+
}
267+
}
268+
269+
for i := 0; i < w.probeCount; i++ {
270+
answers, err := w.lookup(host)
271+
if err == nil {
272+
for _, record := range answers {
273+
if _, ipExists := cachedValue.IPS.Get(record); ipExists {
274+
return true, mapValues(cachedValue.IPS)
275+
}
276+
}
277+
}
278+
}
279+
}
280+
}
281+
282+
for _, knownIP := range knownIPs {
283+
if _, ok := wildcards[knownIP]; ok {
284+
return true, wildcards
285+
}
286+
}
287+
288+
return false, wildcards
289+
}
290+
291+
func (w *Resolver) GetAllWildcardIPs() map[string]struct{} {
292+
ips := make(map[string]struct{})
293+
294+
_ = w.wildcardAnswersCache.Iterate(func(key string, value wildcardAnswerCacheValue) error {
295+
for ip := range mapValues(value.IPS) {
296+
if _, ok := ips[ip]; !ok {
297+
ips[ip] = struct{}{}
298+
}
299+
}
300+
return nil
301+
})
302+
return ips
303+
}

0 commit comments

Comments
 (0)