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
9 changes: 9 additions & 0 deletions internal/runner/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ type Options struct {
TraceMaxRecursion int
WildcardThreshold int
WildcardDomain string
AutoWildcard bool
ShowStatistics bool
rcodes map[int]struct{}
RCode string
Expand Down Expand Up @@ -189,6 +190,7 @@ 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, "automatically detect and filter wildcard DNS records across multiple domains"),
flagSet.StringVar(&options.Proxy, "proxy", "", "proxy to use (eg socks5://127.0.0.1:8080)"),
)

Expand Down Expand Up @@ -271,6 +273,10 @@ func (options *Options) validateOptions() {
gologger.Fatal().Msgf("retries must be at least 1")
}

if options.WildcardDomain != "" && options.AutoWildcard {
gologger.Fatal().Msgf("wildcard-domain and auto-wildcard can't be used at the same time")
}

wordListPresent := options.WordList != ""
domainsPresent := options.Domains != ""
hostsPresent := options.Hosts != ""
Expand Down Expand Up @@ -307,6 +313,9 @@ 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")
}
Expand Down
109 changes: 108 additions & 1 deletion internal/runner/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ type Runner struct {
wildcards *mapsutil.SyncLockMap[string, struct{}]
wildcardscache map[string][]string
wildcardscachemutex sync.Mutex
autoWildcardDomains *mapsutil.SyncLockMap[string, bool]
limiter *ratelimit.Limiter
hm *hybrid.HybridMap
stats clistats.StatisticsClient
Expand Down Expand Up @@ -159,6 +160,7 @@ func New(options *Options) (*Runner, error) {
wildcardworkerchan: make(chan string),
wildcards: mapsutil.NewSyncLockMap[string, struct{}](),
wildcardscache: make(map[string][]string),
autoWildcardDomains: mapsutil.NewSyncLockMap[string, bool](),
limiter: limiter,
hm: hm,
stats: stats,
Expand Down Expand Up @@ -548,6 +550,11 @@ func (r *Runner) run() error {
gologger.Print().Msgf("%d wildcard subdomains removed\n", numRemovedSubdomains)
}

if r.options.AutoWildcard {
gologger.Print().Msgf("Starting auto wildcard detection and filtering\n")
r.runAutoWildcardFiltering()
}

return nil
}

Expand All @@ -572,6 +579,91 @@ func (r *Runner) lookupAndOutput(host string) error {
return nil
}

func (r *Runner) runAutoWildcardFiltering() {
ipDomain := make(map[string]map[string]struct{})
domainHosts := make(map[string][]string)
listIPs := []string{}

// Scan all stored DNS data and group by IP and domain
r.hm.Scan(func(k, v []byte) error {
var dnsdata retryabledns.DNSData
if err := json.Unmarshal(v, &dnsdata); err != nil {
return nil
}

host := string(k)
baseDomain := r.GetBaseDomain(host)

// Track hosts per base domain
if _, ok := domainHosts[baseDomain]; !ok {
domainHosts[baseDomain] = make([]string, 0)
}
domainHosts[baseDomain] = append(domainHosts[baseDomain], host)

for _, a := range dnsdata.A {
_, ok := ipDomain[a]
if !ok {
ipDomain[a] = make(map[string]struct{})
listIPs = append(listIPs, a)
}
ipDomain[a][host] = struct{}{}
}

return nil
})

gologger.Debug().Msgf("Found %d unique IPs across %d domains\n", len(listIPs), len(domainHosts))

// Start wildcard workers for auto detection
numThreads := r.options.Threads
if numThreads > len(listIPs) {
numThreads = len(listIPs)
}
for i := 0; i < numThreads; i++ {
r.wgwildcardworker.Add(1)
go r.wildcardWorkerAuto()
}

// Send hosts to wildcard worker for checking
seen := make(map[string]struct{})
for _, hosts := range domainHosts {
for _, host := range hosts {
if _, ok := seen[host]; !ok {
seen[host] = struct{}{}
r.wildcardworkerchan <- host
}
}
}
close(r.wildcardworkerchan)
Comment on lines +617 to +637
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 | 🔴 Critical

Potential deadlock: worker count is bounded by len(listIPs), but work is driven by domainHosts.

numThreads is capped at len(listIPs) (Line 619), but listIPs only contains A-record IPs. If all resolved hosts have only AAAA records (or other non-A types), listIPs is empty → numThreads == 0 → no workers started. The subsequent loop (Lines 629–636) then tries to send on the unbuffered wildcardworkerchan with no reader, causing a deadlock.

Even outside the deadlock scenario, bounding threads by IP count doesn't make sense for auto mode — the work unit is hosts, not IPs.

🐛 Proposed fix
 	// Start wildcard workers for auto detection
 	numThreads := r.options.Threads
-	if numThreads > len(listIPs) {
-		numThreads = len(listIPs)
+	totalHosts := 0
+	for _, hosts := range domainHosts {
+		totalHosts += len(hosts)
+	}
+	if numThreads > totalHosts {
+		numThreads = totalHosts
+	}
+	if numThreads == 0 {
+		return
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Start wildcard workers for auto detection
numThreads := r.options.Threads
if numThreads > len(listIPs) {
numThreads = len(listIPs)
}
for i := 0; i < numThreads; i++ {
r.wgwildcardworker.Add(1)
go r.wildcardWorkerAuto()
}
// Send hosts to wildcard worker for checking
seen := make(map[string]struct{})
for _, hosts := range domainHosts {
for _, host := range hosts {
if _, ok := seen[host]; !ok {
seen[host] = struct{}{}
r.wildcardworkerchan <- host
}
}
}
close(r.wildcardworkerchan)
// Start wildcard workers for auto detection
numThreads := r.options.Threads
totalHosts := 0
for _, hosts := range domainHosts {
totalHosts += len(hosts)
}
if numThreads > totalHosts {
numThreads = totalHosts
}
if numThreads == 0 {
return
}
// Send hosts to wildcard worker for checking
seen := make(map[string]struct{})
for _, hosts := range domainHosts {
for _, host := range hosts {
if _, ok := seen[host]; !ok {
seen[host] = struct{}{}
r.wildcardworkerchan <- host
}
}
}
close(r.wildcardworkerchan)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/runner/runner.go` around lines 617 - 637, The loop that starts
wildcard workers uses numThreads derived from r.options.Threads and capped by
len(listIPs), which can be zero when listIPs has no A records, causing no
goroutines to run while domainHosts are later sent on r.wildcardworkerchan and
deadlock; fix by basing numThreads on the number of hosts (len(domainHosts) or
total host count across domainHosts) with a sensible max of r.options.Threads
(or at least 1), ensure r.wgwildcardworker.Add is called for that computed
numThreads and that r.wildcardWorkerAuto is launched that many times so
consumers exist before sending into r.wildcardworkerchan, and keep closing
r.wildcardworkerchan after all producers have sent.

r.wgwildcardworker.Wait()

// Restart output worker
r.startOutputWorker()

// Output non-wildcard results
seen = make(map[string]struct{})
seenRemovedSubdomains := make(map[string]struct{})
numRemovedSubdomains := 0

for _, hosts := range domainHosts {
for _, host := range hosts {
if _, ok := seen[host]; !ok {
seen[host] = struct{}{}
if !r.wildcards.Has(host) {
_ = r.lookupAndOutput(host)
} else {
numRemovedSubdomains++
seenRemovedSubdomains[host] = struct{}{}
}
}
}
}

close(r.outputchan)
r.wgoutputworker.Wait()
gologger.Print().Msgf("%d wildcard subdomains removed\n", numRemovedSubdomains)
}

func (r *Runner) runStream() error {
r.startWorkers()

Expand Down Expand Up @@ -731,7 +823,7 @@ func (r *Runner) worker() {
}
}
// if wildcard filtering just store the data
if r.options.WildcardDomain != "" {
if r.options.WildcardDomain != "" || r.options.AutoWildcard {
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 | 🟠 Major

A-record query type is not enforced when AutoWildcard is enabled.

Line 119 ensures A records are queried when WildcardDomain is set, but the same guard is missing for AutoWildcard. The wildcard detection logic (IsWildcardAuto, IsWildcardDomain) exclusively inspects A records. If a user runs dnsx --aaaa --auto-wildcard, no A records are stored, and the wildcard detection is effectively a no-op.

🐛 Proposed fix (in the `New` function around line 119)
-	if len(questionTypes) == 0 || options.WildcardDomain != "" {
+	if len(questionTypes) == 0 || options.WildcardDomain != "" || options.AutoWildcard {
 		options.A = true
 		questionTypes = append(questionTypes, dns.TypeA)
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/runner/runner.go` at line 826, The code only forces A-record
querying when r.options.WildcardDomain is set but misses the same enforcement
for r.options.AutoWildcard; update the New function so that the conditional that
checks r.options.WildcardDomain also triggers when r.options.AutoWildcard is
true (i.e., if r.options.WildcardDomain != "" || r.options.AutoWildcard) and
then ensure the options are adjusted to query A records (add/ensure 'A' record
type is present and/or remove conflicting types like 'AAAA') before wildcard
detection runs (references: New function, r.options.WildcardDomain,
r.options.AutoWildcard, and the wildcard detection helpers
IsWildcardAuto/IsWildcardDomain).

if err := r.storeDNSData(dnsData.DNSData); err != nil {
gologger.Debug().Msgf("Failed to store DNS data for %s: %v\n", domain, err)
}
Expand Down Expand Up @@ -945,3 +1037,18 @@ func (r *Runner) wildcardWorker() {
}
}
}

func (r *Runner) wildcardWorkerAuto() {
defer r.wgwildcardworker.Done()

for {
host, more := <-r.wildcardworkerchan
if !more {
break
}
if r.IsWildcardAuto(host) {
// mark this host as a wildcard subdomain
_ = r.wildcards.Set(host, struct{}{})
}
}
}
118 changes: 118 additions & 0 deletions internal/runner/wildcard.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"strings"

"github.com/rs/xid"
"github.com/weppos/publicsuffix-go/publicsuffix"
)

// IsWildcard checks if a host is wildcard
Expand Down Expand Up @@ -69,3 +70,120 @@ func (r *Runner) IsWildcard(host string) bool {

return false
}

// IsWildcardDomain checks if a domain has wildcard DNS records by querying a random subdomain
func (r *Runner) IsWildcardDomain(domain string) bool {
// Check cache first
if hasWildcard, ok := r.autoWildcardDomains.Get(domain); ok {
return hasWildcard
}

// Generate a random subdomain to test for wildcards
randomSubdomain := xid.New().String() + "." + domain

in, err := r.dnsx.QueryOne(randomSubdomain)
if err != nil || in == nil {
// Random subdomain doesn't resolve, not a wildcard domain
_ = r.autoWildcardDomains.Set(domain, false)
return false
Comment on lines +84 to +88
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 | 🟠 Major

Avoid caching false on transient DNS errors.

If QueryOne fails (timeouts/servfail), caching false disables wildcard detection for the entire domain and can cause persistent false negatives. Consider only caching on successful negative lookups and skip caching on err != nil.

🔧 Proposed adjustment
 	in, err := r.dnsx.QueryOne(randomSubdomain)
-	if err != nil || in == nil {
-		// Random subdomain doesn't resolve, not a wildcard domain
-		_ = r.autoWildcardDomains.Set(domain, false)
-		return false
-	}
+	if err != nil {
+		// Transient failure: don't cache
+		return false
+	}
+	if in == nil {
+		// Random subdomain doesn't resolve, not a wildcard domain
+		_ = r.autoWildcardDomains.Set(domain, false)
+		return false
+	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/runner/wildcard.go` around lines 84 - 88, The current logic calls
r.dnsx.QueryOne(randomSubdomain) and caches false via
r.autoWildcardDomains.Set(domain, false) whenever err != nil || in == nil, which
can store transient DNS failures as permanent non-wildcard; change it to only
cache false when the query succeeded but returned nil (err == nil && in == nil).
If err != nil (transient timeout/servfail), do not write to
r.autoWildcardDomains and simply return false (or propagate the error if
preferred), ensuring r.autoWildcardDomains.Set(domain, false) is only invoked on
a successful negative lookup.

}

// Random subdomain resolved, this is a wildcard domain
_ = r.autoWildcardDomains.Set(domain, true)
return true
}

// GetBaseDomain extracts the base domain from a subdomain
func (r *Runner) GetBaseDomain(host string) string {
// Trim trailing dot if present
host = strings.TrimSuffix(host, ".")

// Use publicsuffix library for accurate base domain extraction
if domain, err := publicsuffix.Parse(host); err == nil {
// domain has fields: TLD, SLD, TRD
// For co.uk, TLD is "co.uk", SLD is "example", TRD is "sub"
// We want eTLD+1: SLD + "." + TLD
if domain.TLD != "" && domain.SLD != "" {
return domain.SLD + "." + domain.TLD
}
}

// Fallback to original logic if publicsuffix parsing fails
parts := strings.Split(host, ".")
if len(parts) <= 2 {
return host
}
// Return the last two parts as the base domain
return strings.Join(parts[len(parts)-2:], ".")
}
Comment on lines +97 to +118
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 | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# First, find and read the wildcard.go file
find . -name "wildcard.go" -path "*/runner/*" -type f

Repository: projectdiscovery/dnsx

Length of output: 94


🏁 Script executed:

# Check the file exists and get its line count
wc -l internal/runner/wildcard.go 2>/dev/null || echo "File not found at expected path"

Repository: projectdiscovery/dnsx

Length of output: 96


🏁 Script executed:

# Read the entire file to verify the code and function implementations
cat -n internal/runner/wildcard.go

Repository: projectdiscovery/dnsx

Length of output: 5729


🏁 Script executed:

# Check publicsuffix dependency in go.mod
rg -A2 -B2 "publicsuffix" go.mod go.sum 2>/dev/null || echo "publicsuffix dependency not found"

Repository: projectdiscovery/dnsx

Length of output: 1251


🏁 Script executed:

# Also check what DNS/resolver libraries are imported in the wildcard.go file
rg "^import" -A20 internal/runner/wildcard.go

Repository: projectdiscovery/dnsx

Length of output: 538


🏁 Script executed:

# Check if publicsuffix-go is imported anywhere
rg "publicsuffix-go|weppos/publicsuffix" --type=go

Repository: projectdiscovery/dnsx

Length of output: 47


🏁 Script executed:

# Also check for publicsuffix imports
rg "import.*publicsuffix" --type=go

Repository: projectdiscovery/dnsx

Length of output: 47


🏁 Script executed:

# Check what publicsuffix variant is available
rg "golang.org/x/net/publicsuffix" --type=go

Repository: projectdiscovery/dnsx

Length of output: 47


🏁 Script executed:

# Check the documentation/usage of publicsuffix-go package
# First, find if there's any documentation or examples in go.mod comments
rg "weppos" go.mod go.sum

# Also search for any comment about the publicsuffix-go dependency
rg -B5 -A5 "publicsuffix-go" go.mod

Repository: projectdiscovery/dnsx

Length of output: 1239


🏁 Script executed:

# Let's check if there are any tests or examples using publicsuffix
find . -name "*.go" -type f -exec grep -l "publicsuffix\|publicSuffix" {} \;

Repository: projectdiscovery/dnsx

Length of output: 47


🏁 Script executed:

# Since publicsuffix-go is in the dependency, let's check what the actual API should be
# by looking at the common patterns in similar projects
# First, let me verify the exact import path and common function names
rg -n "github.com/weppos" . --type go 2>/dev/null | head -20

Repository: projectdiscovery/dnsx

Length of output: 47


Fix two critical bugs in GetBaseDomain and IsWildcardAuto loop bounds.

Issue 1: GetBaseDomain breaks for multi-part TLDs (lines 96-103)

Taking the last two labels fails for domains like sub.example.co.uk, returning co.uk instead of example.co.uk. This causes IsWildcardAuto to query wildcards against the TLD itself, producing incorrect results.

Issue 2: IsWildcardAuto off-by-one loop bug (line 134)

The loop uses i <= len(subdomainTokens) instead of i < len(subdomainTokens) (compare to IsWildcard line 33). When i == len(subdomainTokens), subdomainTokens[i:] returns an empty slice, and strings.Join([], ".") produces "", resulting in newhost = "." + baseDomain. This creates invalid DNS queries with a leading dot (e.g., .example.co.uk). The check on line 136 passes because the empty string is concatenated, not the result itself.

Suggested fixes

For GetBaseDomain, use a public suffix parser to correctly extract the registrable domain (eTLD+1):

import "github.com/weppos/publicsuffix-go/publicsuffix"

func (r *Runner) GetBaseDomain(host string) string {
	rule, err := publicsuffix.DefaultList.Find(host)
	if err != nil || rule == nil {
		// Fallback: return the last two parts
		parts := strings.Split(host, ".")
		if len(parts) <= 2 {
			return host
		}
		return strings.Join(parts[len(parts)-2:], ".")
	}
	return rule.Prevailing(host)
}

For IsWildcardAuto, change line 134 to use strict less-than:

for i := 1; i < len(subdomainTokens); i++ {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/runner/wildcard.go` around lines 96 - 103, GetBaseDomain is
incorrect for multi-part TLDs and IsWildcardAuto has an off-by-one loop bug; fix
GetBaseDomain by using a public suffix parser (e.g.,
github.com/weppos/publicsuffix-go) to compute the registrable domain (eTLD+1) in
Runner.GetBaseDomain and fall back to joining the last two labels only if the
parser returns an error or nil rule, and fix Runner.IsWildcardAuto by changing
the loop bound from i <= len(subdomainTokens) to i < len(subdomainTokens) so
subdomainTokens[i:] never becomes empty (avoid producing a newhost with a
leading dot).


// IsWildcardAuto checks if a host is wildcard using auto detection
func (r *Runner) IsWildcardAuto(host string) bool {
baseDomain := r.GetBaseDomain(host)

// First check if this domain has wildcards
if !r.IsWildcardDomain(baseDomain) {
return false
}

// Domain has wildcards, now check if this specific host is a wildcard
orig := make(map[string]struct{})
wildcards := make(map[string]struct{})

in, err := r.dnsx.QueryOne(host)
if err != nil || in == nil {
return false
}
for _, A := range in.A {
orig[A] = struct{}{}
}

// Get wildcard IPs by querying random subdomains at each level
subdomainPart := strings.TrimSuffix(host, "."+baseDomain)
subdomainTokens := strings.Split(subdomainPart, ".")

var hosts []string
hosts = append(hosts, baseDomain)

if len(subdomainTokens) > 0 && subdomainTokens[0] != "" {
for i := 1; i <= len(subdomainTokens); i++ {
newhost := strings.Join(subdomainTokens[i:], ".") + "." + baseDomain
if newhost != "" {
hosts = append(hosts, newhost)
}
}
}
Comment on lines +145 to +155
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 | 🔴 Critical

Off-by-one in loop produces a host with a leading dot.

The loop uses i <= len(subdomainTokens) (Line 134), but when i == len(subdomainTokens), subdomainTokens[i:] is an empty slice. strings.Join([], ".") + "." + baseDomain produces ".example.com", which passes the newhost != "" check on Line 136 and results in a DNS query like <random>..example.com (double dot — invalid).

The existing IsWildcard (Line 33) correctly uses i < len(subdomainTokens). This should match.

🐛 Proposed fix
-		for i := 1; i <= len(subdomainTokens); i++ {
+		for i := 1; i < len(subdomainTokens); i++ {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
var hosts []string
hosts = append(hosts, baseDomain)
if len(subdomainTokens) > 0 && subdomainTokens[0] != "" {
for i := 1; i <= len(subdomainTokens); i++ {
newhost := strings.Join(subdomainTokens[i:], ".") + "." + baseDomain
if newhost != "" {
hosts = append(hosts, newhost)
}
}
}
var hosts []string
hosts = append(hosts, baseDomain)
if len(subdomainTokens) > 0 && subdomainTokens[0] != "" {
for i := 1; i < len(subdomainTokens); i++ {
newhost := strings.Join(subdomainTokens[i:], ".") + "." + baseDomain
if newhost != "" {
hosts = append(hosts, newhost)
}
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/runner/wildcard.go` around lines 130 - 140, The loop building hosts
in wildcard.go has an off-by-one: change the loop condition in the block that
iterates subdomainTokens (currently using i <= len(subdomainTokens)) to stop
before the slice becomes empty (use i < len(subdomainTokens>) so that
strings.Join(subdomainTokens[i:], ".") never returns an empty string); update
the loop that constructs newhost (the one referencing subdomainTokens,
baseDomain, and hosts) to match the logic used by IsWildcard so it doesn't
append a host like ".example.com".


// Iterate over all the hosts generated for rand.
for _, h := range hosts {
r.wildcardscachemutex.Lock()
listip, ok := r.wildcardscache[h]
r.wildcardscachemutex.Unlock()
if !ok {
in, err := r.dnsx.QueryOne(xid.New().String() + "." + h)
if err != nil || in == nil {
continue
}
listip = in.A
r.wildcardscachemutex.Lock()
r.wildcardscache[h] = in.A
r.wildcardscachemutex.Unlock()
}

// Get all the records and add them to the wildcard map
for _, A := range listip {
if _, ok := wildcards[A]; !ok {
wildcards[A] = struct{}{}
}
}
}

// check if original ip are among wildcards
for a := range orig {
if _, ok := wildcards[a]; ok {
return true
}
}

return false
}