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
35 changes: 33 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,8 @@ 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)
-sw, -strict-wildcard perform strict wildcard check on all found subdomains
-wildcard-retry int number of dns retries for wildcard detection (used with -sw) (default 5)
```

## Running dnsx
Expand Down Expand Up @@ -405,6 +407,35 @@ A special feature of `dnsx` is its ability to handle **multi-level DNS based wil
dnsx -l subdomain_list.txt -wd airbnb.com -o output.txt
```

### Strict wildcard filtering

The `-sw` flag provides automatic wildcard detection and filtering without requiring you to specify which domains are wildcards. It works by querying random subdomains for each parent domain — if a random subdomain resolves, the parent is a wildcard root and all its subdomains are filtered from the output.

```console
$ dnsx -l domains.txt -sw

[INF] Detecting wildcard root subdomains
[INF] Found 8 wildcard roots:
[INF] *.netlify.com
[INF] *.dev.projectdiscovery.io
[INF] *.netlify.app
[INF] *.ngrok.io
[INF] *.wordpress.com
[INF] *.vercel.app
[INF] *.github.io
[INF] *.herokuapp.com
cloud.projectdiscovery.io
docs.projectdiscovery.io
www.example.com
[INF] Found 3 non-wildcard domains (11 wildcard subdomains filtered)
```

The wildcard retry count can be configured with `-wildcard-retry` (default 5):

```console
dnsx -l subdomain_list.txt -sw -wildcard-retry 10
```

---------

### Dnsx as a library
Expand Down Expand Up @@ -462,8 +493,8 @@ func main() {
- As default, `dnsx` checks for **A** record.
- As default `dnsx` uses Google, Cloudflare, Quad9 [resolver](https://github.com/projectdiscovery/dnsx/blob/43af78839e237ea8cbafe571df1ab0d6cbe7f445/libs/dnsx/dnsx.go#L31).
- Custom resolver list can be loaded using the `r` flag.
- Domain name (`wd`) input is mandatory for wildcard elimination.
- DNS record flag can not be used when using wildcard filtering.
- Domain name (`wd`) input is required for wildcard filtering with `-wd`. The `-sw` flag provides automatic wildcard detection without needing `-wd`.
- DNS record flags cannot be used when using wildcard filtering (`-wd` or `-sw`).
- DNS resolution (`l`) and DNS brute-forcing (`w`) can't be used together.
- VPN operators tend to filter high DNS/UDP traffic, therefore the tool might experience packets loss (eg. [Mullvad VPN](https://github.com/projectdiscovery/dnsx/issues/221)). Check [this potential solution](./MULLVAD.md).

Expand Down
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
StrictWildcard bool
WildcardRetry 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.StrictWildcard, "strict-wildcard", "sw", false, "perform strict wildcard check on all found subdomains"),
flagSet.IntVar(&options.WildcardRetry, "wildcard-retry", 5, "number of dns retries for wildcard detection (used with -sw)"),
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.StrictWildcard {
gologger.Fatal().Msgf("strict wildcard not supported in stream mode")
}
if options.ShowStatistics {
gologger.Fatal().Msgf("stats not supported in stream mode")
}
}

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

func argumentHasStdin(arg string) bool {
Expand Down
94 changes: 90 additions & 4 deletions internal/runner/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import (
type Runner struct {
options *Options
dnsx *dnsx.DNSX
wildcardDnsx *dnsx.DNSX
wgoutputworker *sync.WaitGroup
wgresolveworkers *sync.WaitGroup
wgwildcardworker *sync.WaitGroup
Expand Down Expand Up @@ -115,9 +116,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.StrictWildcard {
if !options.A {
options.A = true
questionTypes = append(questionTypes, dns.TypeA)
}
}
dnsxOptions.QuestionTypes = questionTypes
dnsxOptions.QueryAll = options.QueryAll
Expand Down Expand Up @@ -149,9 +152,21 @@ func New(options *Options) (*Runner, error) {
options.NoColor = true
}

// create a DNS client for wildcard testing with dedicated retry count
var wildcardDnsX *dnsx.DNSX
if options.StrictWildcard {
wOpts := dnsxOptions
wOpts.MaxRetries = options.WildcardRetry
wildcardDnsX, err = dnsx.New(wOpts)
if err != nil {
return nil, err
}
}

r := Runner{
options: options,
dnsx: dnsX,
wildcardDnsx: wildcardDnsX,
wgoutputworker: &sync.WaitGroup{},
wgresolveworkers: &sync.WaitGroup{},
wgwildcardworker: &sync.WaitGroup{},
Expand Down Expand Up @@ -548,9 +563,63 @@ func (r *Runner) run() error {
gologger.Print().Msgf("%d wildcard subdomains removed\n", numRemovedSubdomains)
}

if r.options.StrictWildcard {
gologger.Info().Msgf("Detecting wildcard root subdomains")

// collect all resolved hostnames from the HybridMap (only those with A records)
var allHosts []string
r.hm.Scan(func(k, v []byte) error {
if v == nil {
return nil
}
var dnsData retryabledns.DNSData
if err := json.Unmarshal(v, &dnsData); err != nil {
return nil
}
if len(dnsData.A) == 0 {
return nil
}
allHosts = append(allHosts, string(k))
return nil
})

// detect wildcard roots
wildcardRoots := r.detectWildcardRoots(allHosts)

if len(wildcardRoots) > 0 {
gologger.Info().Msgf("Found %d wildcard root%s:", len(wildcardRoots), plural(len(wildcardRoots)))
for root := range wildcardRoots {
gologger.Info().Msgf(" *.%s", root)
}
} else {
gologger.Info().Msgf("Found 0 wildcard roots")
}

// restart output worker and filter results
r.startOutputWorker()
numFiltered := 0
for _, host := range allHosts {
if isSubdomainOfWildcard(host, wildcardRoots) {
numFiltered++
} else {
_ = r.lookupAndOutput(host)
}
}
close(r.outputchan)
r.wgoutputworker.Wait()
gologger.Info().Msgf("Found %d non-wildcard domains (%d wildcard subdomains filtered)", len(allHosts)-numFiltered, numFiltered)
}

return nil
}

func plural(n int) string {
if n != 1 {
return "s"
}
return ""
}

func (r *Runner) lookupAndOutput(host string) error {
if r.options.JSON {
if data, ok := r.hm.Get(host); ok {
Expand All @@ -568,6 +637,23 @@ func (r *Runner) lookupAndOutput(host string) error {
}
}

if r.options.Response || r.options.ResponseOnly {
if data, ok := r.hm.Get(host); ok {
var dnsData retryabledns.DNSData
if err := json.Unmarshal(data, &dnsData); err != nil {
return err
}
for _, a := range dnsData.A {
if r.options.ResponseOnly {
r.outputchan <- a
} else {
r.outputchan <- fmt.Sprintf("%s [%s] [%s]", host, r.aurora.Magenta("A"), r.aurora.Green(a))
}
}
return nil
}
}

r.outputchan <- host
return nil
}
Expand Down Expand Up @@ -731,7 +817,7 @@ func (r *Runner) worker() {
}
}
// if wildcard filtering just store the data
if r.options.WildcardDomain != "" {
if r.options.WildcardDomain != "" || r.options.StrictWildcard {
if err := r.storeDNSData(dnsData.DNSData); err != nil {
gologger.Debug().Msgf("Failed to store DNS data for %s: %v\n", domain, err)
}
Expand Down
88 changes: 88 additions & 0 deletions internal/runner/wildcard.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package runner

import (
"strings"
"sync"

"github.com/rs/xid"
)
Expand Down Expand Up @@ -69,3 +70,90 @@ func (r *Runner) IsWildcard(host string) bool {

return false
}

// isStrictWildcard checks if a domain is a wildcard root.
// Unlike IsWildcard (which compares IPs and requires -wd to specify the domain),
// this just checks if a random subdomain resolves at all — works for load-balanced wildcards.
func (r *Runner) isStrictWildcard(domain string) bool {
randomHost := xid.New().String() + "." + domain
resp, err := r.wildcardDnsx.QueryOne(randomHost)
if err != nil || resp == nil {
return false
}
return len(resp.A) > 0
}

// detectWildcardRoots finds all wildcard roots from a list of resolved hosts.
// Tests candidates top-down (shallowest first) so that finding *.example.com
// skips testing *.sub.example.com.
func (r *Runner) detectWildcardRoots(hosts []string) map[string]struct{} {
allCandidates := make(map[string]struct{})
for _, host := range hosts {
parts := strings.Split(host, ".")
if len(parts) < 3 {
continue
}
for i := 1; i < len(parts)-1; i++ {
candidate := strings.Join(parts[i:], ".")
allCandidates[candidate] = struct{}{}
}
}

byDepth := make(map[int][]string)
maxDepth := 0
for candidate := range allCandidates {
depth := strings.Count(candidate, ".") + 1
byDepth[depth] = append(byDepth[depth], candidate)
if depth > maxDepth {
maxDepth = depth
}
}

roots := make(map[string]struct{})

for depth := 2; depth <= maxDepth; depth++ {
candidates := byDepth[depth]
if len(candidates) == 0 {
continue
}

var toTest []string
for _, c := range candidates {
if !isSubdomainOfWildcard(c, roots) {
toTest = append(toTest, c)
}
}
if len(toTest) == 0 {
continue
}

var wg sync.WaitGroup
sem := make(chan struct{}, r.options.Threads)
var mu sync.Mutex
for _, domain := range toTest {
wg.Add(1)
sem <- struct{}{}
go func(d string) {
defer wg.Done()
defer func() { <-sem }()
if r.isStrictWildcard(d) {
mu.Lock()
roots[d] = struct{}{}
mu.Unlock()
}
}(domain)
}
wg.Wait()
}

return roots
}

func isSubdomainOfWildcard(host string, roots map[string]struct{}) bool {
for root := range roots {
if host != root && strings.HasSuffix(host, "."+root) {
return true
}
}
return false
}
Loading