Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ require (
github.com/projectdiscovery/utils v0.7.3
github.com/rs/xid v1.5.0
github.com/stretchr/testify v1.11.1
golang.org/x/net v0.47.0
)

require (
Expand Down Expand Up @@ -120,7 +121,6 @@ require (
golang.org/x/crypto v0.45.0 // indirect
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 // indirect
golang.org/x/mod v0.29.0 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/oauth2 v0.27.0 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/sys v0.38.0 // indirect
Expand Down
236 changes: 236 additions & 0 deletions internal/runner/autowildcard.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
package runner

import (
"strings"
"sync"

"github.com/projectdiscovery/dnsx/libs/dnsx"
"github.com/rs/xid"
"golang.org/x/net/publicsuffix"
)

// AutoWildcardDetector handles automatic wildcard detection across multiple domains
type AutoWildcardDetector struct {
dnsx *dnsx.DNSX
testCount int
mutex sync.RWMutex
wildcardRoots map[string]map[string]struct{} // root domain -> set of wildcard IPs
testedDomains map[string]struct{} // domains we've already tested for wildcards
inFlight map[string]chan struct{} // domains currently being tested (for concurrent waiters)
filteredCount int // count of filtered wildcard subdomains
}

// NewAutoWildcardDetector creates a new auto wildcard detector
func NewAutoWildcardDetector(dnsxClient *dnsx.DNSX, testCount int) *AutoWildcardDetector {
if testCount < 1 {
testCount = 3
}
return &AutoWildcardDetector{
dnsx: dnsxClient,
testCount: testCount,
wildcardRoots: make(map[string]map[string]struct{}),
testedDomains: make(map[string]struct{}),
inFlight: make(map[string]chan struct{}),
}
}
Comment on lines +23 to +35
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Clamp testCount to ≥2 to avoid a silent no‑op.

testWildcard requires at least two successful probes, so a value of 1 can never succeed and effectively disables detection.

🛠️ Proposed fix
-	if testCount < 1 {
+	if testCount < 2 {
 		testCount = 3
 	}
🤖 Prompt for AI Agents
In `@internal/runner/autowildcard.go` around lines 23 - 35,
NewAutoWildcardDetector currently clamps testCount to 3 only when <1, but
testWildcard requires at least 2 probes so passing 1 silently disables
detection; update the clamping in NewAutoWildcardDetector to ensure testCount is
at least 2 (not 1) — i.e. if testCount < 2 set testCount = 2 — and keep
references to dnsx, testCount, wildcardRoots, testedDomains, and inFlight
initialization unchanged so testWildcard receives a usable probe count.


// DetectAndFilter checks if a host is a wildcard subdomain
// Returns true if the host should be filtered (is wildcard), false otherwise
func (d *AutoWildcardDetector) DetectAndFilter(host string, hostIPs []string) bool {
if len(hostIPs) == 0 {
return false
}

// Extract parent domains to test for wildcards
parents := getParentDomains(host)
if len(parents) == 0 {
return false
}

// Ensure wildcard detection is done for all parent levels
for _, parent := range parents {
d.ensureWildcardTested(parent)
}

// Check if any of the host's IPs match known wildcard IPs
return d.isWildcardMatch(host, hostIPs)
}

// ensureWildcardTested tests a domain for wildcards if not already tested
func (d *AutoWildcardDetector) ensureWildcardTested(parent string) {
d.mutex.Lock()

// Check if already tested (complete)
if _, tested := d.testedDomains[parent]; tested {
d.mutex.Unlock()
return
}

// Check if another goroutine is currently testing this domain
if waitCh, inFlight := d.inFlight[parent]; inFlight {
d.mutex.Unlock()
// Wait for the in-flight test to complete
<-waitCh
return
}

// We'll be the one to test - create a channel for others to wait on
doneCh := make(chan struct{})
d.inFlight[parent] = doneCh
d.mutex.Unlock()

// Test for wildcard by querying random subdomains (outside lock)
wildcardIPs := d.testWildcard(parent)

// Re-acquire lock to update state
d.mutex.Lock()
d.testedDomains[parent] = struct{}{}
if len(wildcardIPs) > 0 {
d.wildcardRoots[parent] = wildcardIPs
}
delete(d.inFlight, parent)
d.mutex.Unlock()

// Signal waiting goroutines that testing is complete
close(doneCh)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// testWildcard tests if a domain has wildcard DNS by querying random subdomains
// Returns IPs only if they are CONSISTENT across multiple probe responses
func (d *AutoWildcardDetector) testWildcard(parent string) map[string]struct{} {
var probeSets []map[string]struct{}

// Query multiple random subdomains
for i := 0; i < d.testCount; i++ {
randomHost := xid.New().String() + "." + parent
result, err := d.dnsx.QueryOne(randomHost)
if err != nil || result == nil {
continue
}

// Collect IPs from this probe
probeIPs := make(map[string]struct{})
for _, ip := range result.A {
probeIPs[ip] = struct{}{}
}
for _, ip := range result.AAAA {
probeIPs[ip] = struct{}{}
}

// Only track probes that returned IPs
if len(probeIPs) > 0 {
probeSets = append(probeSets, probeIPs)
}
}

// Require at least 2 successful probes to declare wildcard
if len(probeSets) < 2 {
return nil
}

// Find intersection of all probe results (consistent IPs across all probes)
wildcardIPs := probeSets[0]
for i := 1; i < len(probeSets); i++ {
wildcardIPs = intersectIPSets(wildcardIPs, probeSets[i])
if len(wildcardIPs) == 0 {
return nil // No consistent IPs across probes
}
}

return wildcardIPs
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

// intersectIPSets returns the intersection of two IP sets
func intersectIPSets(a, b map[string]struct{}) map[string]struct{} {
result := make(map[string]struct{})
for ip := range a {
if _, ok := b[ip]; ok {
result[ip] = struct{}{}
}
}
return result
}

// isWildcardMatch checks if any of the host's IPs match wildcard patterns
func (d *AutoWildcardDetector) isWildcardMatch(host string, hostIPs []string) bool {
parents := getParentDomains(host)

d.mutex.RLock()
defer d.mutex.RUnlock()

for _, parent := range parents {
if wildcardIPs, ok := d.wildcardRoots[parent]; ok {
for _, ip := range hostIPs {
if _, isWildcard := wildcardIPs[ip]; isWildcard {
return true
}
}
}
}

return false
}

// GetWildcardRoots returns all detected wildcard root domains
func (d *AutoWildcardDetector) GetWildcardRoots() []string {
d.mutex.RLock()
defer d.mutex.RUnlock()

roots := make([]string, 0, len(d.wildcardRoots))
for root := range d.wildcardRoots {
roots = append(roots, root)
}
return roots
}

// GetWildcardRootCount returns the number of wildcard roots detected
func (d *AutoWildcardDetector) GetWildcardRootCount() int {
d.mutex.RLock()
defer d.mutex.RUnlock()
return len(d.wildcardRoots)
}

// IncrementFilteredCount increments the count of filtered wildcard subdomains
func (d *AutoWildcardDetector) IncrementFilteredCount() {
d.mutex.Lock()
defer d.mutex.Unlock()
d.filteredCount++
}

// GetFilteredCount returns the count of filtered wildcard subdomains
func (d *AutoWildcardDetector) GetFilteredCount() int {
d.mutex.RLock()
defer d.mutex.RUnlock()
return d.filteredCount
}

// getParentDomains extracts all parent domain levels from a hostname
// stopping at the registrable domain boundary (eTLD+1)
// e.g., "sub.example.com" returns ["example.com"]
// e.g., "a.b.example.com" returns ["b.example.com", "example.com"]
// e.g., "sub.example.co.uk" returns ["example.co.uk"] (not "co.uk")
func getParentDomains(host string) []string {
parts := strings.Split(host, ".")
if len(parts) <= 2 {
return nil // Already at apex or TLD
}

// Get the registrable domain (eTLD+1) to know where to stop
registrableDomain, err := publicsuffix.EffectiveTLDPlusOne(host)
if err != nil {
return nil // Cannot determine registrable domain
}

var parents []string
// Start from the immediate parent and go up to (and including) the registrable domain
for i := 1; i < len(parts); i++ {
parent := strings.Join(parts[i:], ".")
parents = append(parents, parent)
// Stop when we reach the registrable domain
if parent == registrableDomain {
break
}
}

return parents
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
11 changes: 11 additions & 0 deletions internal/runner/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ type Options struct {
TraceMaxRecursion int
WildcardThreshold int
WildcardDomain string
AutoWildcard bool
AutoWildcardTestCount int
ShowStatistics bool
rcodes map[int]struct{}
RCode string
Expand Down Expand Up @@ -189,6 +191,8 @@ func ParseOptions() *Options {
flagSet.StringVarP(&options.Resolvers, "resolver", "r", "", "list of resolvers to use (file or comma separated)"),
flagSet.IntVarP(&options.WildcardThreshold, "wildcard-threshold", "wt", 5, "wildcard filter threshold"),
flagSet.StringVarP(&options.WildcardDomain, "wildcard-domain", "wd", "", "domain name for wildcard filtering (other flags will be ignored - only json output is supported)"),
flagSet.BoolVarP(&options.AutoWildcard, "auto-wildcard", "aw", false, "automatic wildcard detection and filtering across all domains"),
flagSet.IntVar(&options.AutoWildcardTestCount, "auto-wildcard-tests", 3, "number of random subdomain tests for auto wildcard detection"),
flagSet.StringVar(&options.Proxy, "proxy", "", "proxy to use (eg socks5://127.0.0.1:8080)"),
)

Expand Down Expand Up @@ -307,10 +311,17 @@ func (options *Options) validateOptions() {
if options.WildcardDomain != "" {
gologger.Fatal().Msgf("wildcard not supported in stream mode")
}
if options.AutoWildcard {
gologger.Fatal().Msgf("auto-wildcard not supported in stream mode")
}
if options.ShowStatistics {
gologger.Fatal().Msgf("stats not supported in stream mode")
}
}

if options.AutoWildcard && options.WildcardDomain != "" {
gologger.Fatal().Msgf("auto-wildcard and wildcard-domain flags cannot be used together")
}
}

func argumentHasStdin(arg string) bool {
Expand Down
56 changes: 40 additions & 16 deletions internal/runner/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,22 +32,23 @@ import (

// Runner is a client for running the enumeration process.
type Runner struct {
options *Options
dnsx *dnsx.DNSX
wgoutputworker *sync.WaitGroup
wgresolveworkers *sync.WaitGroup
wgwildcardworker *sync.WaitGroup
workerchan chan string
outputchan chan string
wildcardworkerchan chan string
wildcards *mapsutil.SyncLockMap[string, struct{}]
wildcardscache map[string][]string
wildcardscachemutex sync.Mutex
limiter *ratelimit.Limiter
hm *hybrid.HybridMap
stats clistats.StatisticsClient
tmpStdinFile string
aurora aurora.Aurora
options *Options
dnsx *dnsx.DNSX
wgoutputworker *sync.WaitGroup
wgresolveworkers *sync.WaitGroup
wgwildcardworker *sync.WaitGroup
workerchan chan string
outputchan chan string
wildcardworkerchan chan string
wildcards *mapsutil.SyncLockMap[string, struct{}]
wildcardscache map[string][]string
wildcardscachemutex sync.Mutex
limiter *ratelimit.Limiter
hm *hybrid.HybridMap
stats clistats.StatisticsClient
tmpStdinFile string
aurora aurora.Aurora
autoWildcardDetector *AutoWildcardDetector
}

func New(options *Options) (*Runner, error) {
Expand Down Expand Up @@ -165,6 +166,11 @@ func New(options *Options) (*Runner, error) {
aurora: aurora.NewAurora(!options.NoColor),
}

// Initialize auto wildcard detector if enabled
if options.AutoWildcard {
r.autoWildcardDetector = NewAutoWildcardDetector(dnsX, options.AutoWildcardTestCount)
}

return &r, nil
}

Expand Down Expand Up @@ -548,6 +554,15 @@ func (r *Runner) run() error {
gologger.Print().Msgf("%d wildcard subdomains removed\n", numRemovedSubdomains)
}

// Print auto wildcard summary
if r.options.AutoWildcard && r.autoWildcardDetector != nil {
filteredCount := r.autoWildcardDetector.GetFilteredCount()
rootCount := r.autoWildcardDetector.GetWildcardRootCount()
if filteredCount > 0 || rootCount > 0 {
gologger.Print().Msgf("Auto wildcard detection: %d wildcard roots found, %d subdomains filtered\n", rootCount, filteredCount)
}
}

return nil
}

Expand Down Expand Up @@ -738,6 +753,15 @@ func (r *Runner) worker() {
continue
}

// auto wildcard detection and filtering
if r.options.AutoWildcard && r.autoWildcardDetector != nil {
if r.autoWildcardDetector.DetectAndFilter(domain, dnsData.A) {
r.autoWildcardDetector.IncrementFilteredCount()
gologger.Debug().Msgf("Filtered wildcard subdomain: %s\n", domain)
continue
}
}

// if response type filter is set, we don't want to ignore them
if len(r.options.responseTypeFilterMap) > 0 && r.shouldSkipRecord(&dnsData) {
continue
Expand Down
Loading