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
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ CONFIGURATIONS:
-r, -resolver string list of resolvers to use (file or comma separated)
-wt, -wildcard-threshold int wildcard filter threshold (default 5)
-wd, -wildcard-domain string domain name for wildcard filtering (other flags will be ignored - only json output is supported)
-aw, -auto-wildcard automatically detect wildcard DNS per root domain and filter matching results (supports A, AAAA, CNAME; uses 3 probes per domain)
```

## Running dnsx
Expand Down Expand Up @@ -457,6 +458,38 @@ func main() {
}
```

### Auto wildcard filtering

The `-aw` (`--auto-wildcard`) flag automatically detects wildcard DNS per root domain and filters matching results. Unlike `-wd`, you don't need to know which domains are wildcards in advance — it works transparently across mixed-domain input lists.

**How it works:**
1. For each unique root domain (eTLD+1), three random subdomains are probed (e.g. `<rand1>.example.com`, `<rand2>.example.com`, `<rand3>.example.com`)
2. If any probe resolves, the domain is marked as a wildcard and its A/AAAA/CNAME fingerprint is cached
3. During resolution, any result matching the wildcard fingerprint is silently filtered
4. Results that resolve to *different* IPs (real overrides) pass through

```console
$ dnsx -l subdomains.txt -aw
[INF] [auto-wildcard] Detected wildcard domain: *.dev.example.com
[INF] [auto-wildcard] Detected wildcard domain: *.app.example.net
api.example.com
www.example.com
```

**Improvements over `-wd`:**
- No need to specify domains manually
- Uses 3 probes per domain (reduces false negatives from intermittent DNS)
- Detects CNAME wildcards in addition to A/AAAA wildcards
- Thread-safe cache — works with concurrent resolution
- Handles multi-level TLDs correctly (e.g. `co.uk`, `com.au`)
- Compatible with `-re`, `-ro`, `-j` output flags

```console
dnsx -l subdomain_list.txt -aw -re
```

> Note: `-aw` and `-wd` cannot be used together. Use `-aw` when scanning multiple domains; use `-wd` for single-domain targeted filtering.

# 📋 Notes

- As default, `dnsx` checks for **A** record.
Expand Down
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 wildcard DNS per root domain and filter matching results (supports A, AAAA, CNAME; uses 3 probes per domain)"),
flagSet.StringVar(&options.Proxy, "proxy", "", "proxy to use (eg socks5://127.0.0.1:8080)"),
)

Expand Down Expand Up @@ -307,10 +309,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(-aw) and wildcard-domain(-wd) cannot be used together")
}
}

func argumentHasStdin(arg string) bool {
Expand Down
22 changes: 18 additions & 4 deletions internal/runner/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ type Runner struct {
wildcards *mapsutil.SyncLockMap[string, struct{}]
wildcardscache map[string][]string
wildcardscachemutex sync.Mutex
// autoWildcard fields for -aw flag
autoWildcardMu sync.RWMutex
autoWildcardCache map[string]*wildcardFingerprint
limiter *ratelimit.Limiter
hm *hybrid.HybridMap
stats clistats.StatisticsClient
Expand Down Expand Up @@ -115,9 +118,11 @@ func New(options *Options) (*Runner, error) {
}

// If no option is specified or wildcard filter has been requested use query type A
if len(questionTypes) == 0 || options.WildcardDomain != "" {
options.A = true
questionTypes = append(questionTypes, dns.TypeA)
if len(questionTypes) == 0 || options.WildcardDomain != "" || options.AutoWildcard {
if !options.A {
options.A = true
questionTypes = append(questionTypes, dns.TypeA)
}
}
dnsxOptions.QuestionTypes = questionTypes
dnsxOptions.QueryAll = options.QueryAll
Expand Down Expand Up @@ -159,6 +164,7 @@ func New(options *Options) (*Runner, error) {
wildcardworkerchan: make(chan string),
wildcards: mapsutil.NewSyncLockMap[string, struct{}](),
wildcardscache: make(map[string][]string),
autoWildcardCache: make(map[string]*wildcardFingerprint),
limiter: limiter,
hm: hm,
stats: stats,
Expand Down Expand Up @@ -738,6 +744,14 @@ func (r *Runner) worker() {
continue
}

// auto-wildcard: filter results that match wildcard fingerprint for their root domain
if r.options.AutoWildcard {
if r.isAutoWildcardMatch(domain, dnsData.A, dnsData.AAAA, dnsData.CNAME) {
gologger.Debug().Msgf("[auto-wildcard] Filtered wildcard result: %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 Expand Up @@ -939,7 +953,7 @@ func (r *Runner) wildcardWorker() {
if !more {
break
}
if r.IsWildcard(host) {
if r.IsWildcard(host, r.options.WildcardDomain) {
// mark this host as a wildcard subdomain
_ = r.wildcards.Set(host, struct{}{})
}
Expand Down
167 changes: 162 additions & 5 deletions internal/runner/wildcard.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
package runner

import (
"net"
"strings"

miekgdns "github.com/miekg/dns"
"github.com/projectdiscovery/gologger"
"github.com/rs/xid"
"golang.org/x/net/publicsuffix"
)

// IsWildcard checks if a host is wildcard
func (r *Runner) IsWildcard(host string) bool {
// numWildcardProbes is the number of random subdomain probes per domain.
// Using 3 probes significantly reduces false negatives caused by intermittent DNS.
const numWildcardProbes = 3

// IsWildcard checks if a host is a wildcard for the given root domain.
// It is used by the existing -wd (wildcard-domain) post-processing flow.
func (r *Runner) IsWildcard(host, wildcardDomain string) bool {
orig := make(map[string]struct{})
wildcards := make(map[string]struct{})

Expand All @@ -19,19 +28,19 @@ func (r *Runner) IsWildcard(host string) bool {
orig[A] = struct{}{}
}

subdomainPart := strings.TrimSuffix(host, "."+r.options.WildcardDomain)
subdomainPart := strings.TrimSuffix(host, "."+wildcardDomain)
subdomainTokens := strings.Split(subdomainPart, ".")

// Build an array by preallocating a slice of a length
// and create the wildcard generation prefix.
// We use a rand prefix at the beginning like %rand%.domain.tld
// A permutation is generated for each level of the subdomain.
var hosts []string
hosts = append(hosts, r.options.WildcardDomain)
hosts = append(hosts, wildcardDomain)

if len(subdomainTokens) > 0 {
for i := 1; i < len(subdomainTokens); i++ {
newhost := strings.Join(subdomainTokens[i:], ".") + "." + r.options.WildcardDomain
newhost := strings.Join(subdomainTokens[i:], ".") + "." + wildcardDomain
hosts = append(hosts, newhost)
}
}
Expand Down Expand Up @@ -69,3 +78,151 @@ func (r *Runner) IsWildcard(host string) bool {

return false
}

// extractRootDomain extracts the eTLD+1 (registered domain) from a hostname.
// Uses publicsuffix-go for accurate multi-level TLD handling (e.g. co.uk, com.au).
// Falls back to simple last-two-labels heuristic if parsing fails.
// Returns ("", false) for IP addresses, host:port, or unparseable inputs.
func extractRootDomain(host string) (string, bool) {
host = strings.TrimSuffix(strings.TrimSpace(host), ".")
if host == "" {
return "", false
}
// Reject host:port style inputs
if idx := strings.LastIndex(host, ":"); idx > strings.LastIndex(host, ".") {
return "", false
}
// Reject raw IP addresses (IPv4 and IPv6)
if net.ParseIP(host) != nil {
return "", false
}

dom, err := publicsuffix.EffectiveTLDPlusOne(host)
if err == nil && dom != "" {
return dom, true
}

// Fallback: use last two labels
parts := strings.Split(host, ".")
if len(parts) < 2 {
return "", false
}
return strings.Join(parts[len(parts)-2:], "."), true
}

// wildcardFingerprint holds the unique A, AAAA, and CNAME values observed
// when probing a wildcard domain with random subdomains.
type wildcardFingerprint struct {
a map[string]struct{}
aaaa map[string]struct{}
cname map[string]struct{}
}

// probeWildcardDomain performs numWildcardProbes random subdomain lookups for `domain`
// and returns a wildcardFingerprint if any probe resolves (indicating wildcard DNS).
// Returns nil if the domain does not have wildcard DNS.
// Uses both A and CNAME queries to detect all wildcard flavours.
func (r *Runner) probeWildcardDomain(domain string) *wildcardFingerprint {
fp := &wildcardFingerprint{
a: make(map[string]struct{}),
aaaa: make(map[string]struct{}),
cname: make(map[string]struct{}),
}

anyResolved := false
for i := 0; i < numWildcardProbes; i++ {
randHost := xid.New().String() + "." + domain

// A/AAAA probe (QueryOne uses the first configured question type — TypeA by default)
if inA, err := r.dnsx.QueryOne(randHost); err == nil && inA != nil {
for _, ip := range inA.A {
fp.a[ip] = struct{}{}
}
for _, ip := range inA.AAAA {
fp.aaaa[ip] = struct{}{}
}
for _, cn := range inA.CNAME {
fp.cname[cn] = struct{}{}
}
if len(inA.A) > 0 || len(inA.AAAA) > 0 || len(inA.CNAME) > 0 {
anyResolved = true
}
}

// Explicit CNAME probe to catch pure-CNAME wildcards (e.g. *.example.com → alias.cdn.net)
if inC, err := r.dnsx.QueryType(randHost, miekgdns.TypeCNAME); err == nil && inC != nil {
for _, cn := range inC.CNAME {
fp.cname[cn] = struct{}{}
}
if len(inC.CNAME) > 0 {
anyResolved = true
}
}
}

if !anyResolved {
return nil
}
return fp
}

// getAutoWildcardFingerprint returns the cached wildcard fingerprint for a root domain,
// probing lazily if not yet seen. Thread-safe via RWMutex.
// Returns (nil, false) when the domain is not a wildcard.
func (r *Runner) getAutoWildcardFingerprint(rootDomain string) (*wildcardFingerprint, bool) {
r.autoWildcardMu.RLock()
fp, seen := r.autoWildcardCache[rootDomain]
r.autoWildcardMu.RUnlock()
if seen {
return fp, fp != nil
}

// Not yet probed — acquire write lock and probe (double-check idiom)
r.autoWildcardMu.Lock()
if fp, seen = r.autoWildcardCache[rootDomain]; seen {
r.autoWildcardMu.Unlock()
return fp, fp != nil
}
fp = r.probeWildcardDomain(rootDomain)
r.autoWildcardCache[rootDomain] = fp
r.autoWildcardMu.Unlock()

if fp != nil {
gologger.Info().Msgf("[auto-wildcard] Detected wildcard domain: *.%s\n", rootDomain)
}

return fp, fp != nil
}

// isAutoWildcardMatch returns true if the resolved hostname's DNS records (A, AAAA, CNAME)
// match the wildcard fingerprint for its eTLD+1 root domain.
// When true, the record should be filtered from output.
func (r *Runner) isAutoWildcardMatch(host string, a, aaaa, cname []string) bool {
rootDomain, ok := extractRootDomain(host)
if !ok {
return false
}

fp, isWildcard := r.getAutoWildcardFingerprint(rootDomain)
if !isWildcard {
return false
}

for _, ip := range a {
if _, ok := fp.a[ip]; ok {
return true
}
}
for _, ip := range aaaa {
if _, ok := fp.aaaa[ip]; ok {
return true
}
}
for _, cn := range cname {
if _, ok := fp.cname[cn]; ok {
return true
}
}

return false
}
Loading