DEV Community

Ayi NEDJIMI
Ayi NEDJIMI

Posted on

Building a Subdomain Enumeration Tool with Go

Your application's attack surface is rarely just your main domain. Subdomains host staging environments, internal APIs, forgotten microservices, and old admin panels — and attackers know this. A basic port scan won't surface what lives at dev.yourcompany.com or api-v1.yourcompany.com. Subdomain enumeration is the first step of any serious reconnaissance, and building your own tool gives you full control over wordlists, concurrency, and output formats.

In this article, we'll build a concurrent subdomain enumerator in Go from scratch — no heavy dependencies, no external frameworks. You'll get a working binary that resolves subdomains from a wordlist and reports the live ones.

The approach: DNS brute-forcing

There are two main techniques for subdomain enumeration:

  1. Passive enumeration — querying certificate transparency logs (crt.sh), DNS history APIs, or search engines. No direct interaction with the target.
  2. Active enumeration (brute-force) — resolving subdomains from a wordlist against the target's DNS.

We'll focus on active enumeration, which is the most comprehensive for finding internal or unlisted subdomains. Make sure you have written authorization before running this against any domain you don't own.

Project structure

subdomain-enum/
├── main.go
├── resolver.go
└── wordlists/
    └── subdomains-top1000.txt
Enter fullscreen mode Exit fullscreen mode

Two Go files: one for CLI logic, one for the resolution engine. The wordlist is a plain text file with one prefix per line (www, api, dev, staging, etc.).

The resolver

The core of the tool is a worker pool that tries to resolve each subdomain concurrently. Go's goroutines and channels make this straightforward.

// resolver.go
package main

import (
    "context"
    "fmt"
    "net"
    "sync"
    "time"
)

type Result struct {
    Subdomain string
    IPs       []string
    Found     bool
}

func Resolve(ctx context.Context, subdomain string, timeout time.Duration) Result {
    r := &net.Resolver{
        PreferGo: true,
        Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
            d := net.Dialer{Timeout: timeout}
            return d.DialContext(ctx, "udp", "8.8.8.8:53")
        },
    }

    ctx, cancel := context.WithTimeout(ctx, timeout)
    defer cancel()

    addrs, err := r.LookupHost(ctx, subdomain)
    if err != nil {
        return Result{Subdomain: subdomain, Found: false}
    }
    return Result{Subdomain: subdomain, IPs: addrs, Found: true}
}

func RunWorkerPool(
    ctx context.Context,
    targets <-chan string,
    results chan<- Result,
    workers int,
    timeout time.Duration,
) {
    var wg sync.WaitGroup
    for i := 0; i < workers; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for sub := range targets {
                select {
                case <-ctx.Done():
                    return
                default:
                    results <- Resolve(ctx, sub, timeout)
                }
            }
        }()
    }
    wg.Wait()
    close(results)
}
Enter fullscreen mode Exit fullscreen mode

A few things worth noting:

  • We use a custom net.Resolver that routes DNS queries to 8.8.8.8 directly, bypassing local resolver caching issues.
  • The timeout per lookup is configurable — 2 seconds is a good default for most networks.
  • RunWorkerPool closes results once all workers finish, which lets the consumer range over it cleanly without extra synchronization.

The main CLI

// main.go
package main

import (
    "bufio"
    "context"
    "flag"
    "fmt"
    "os"
    "strings"
    "time"
)

func main() {
    domain := flag.String("domain", "", "Target domain (e.g. example.com)")
    wordlist := flag.String("wordlist", "wordlists/subdomains-top1000.txt", "Path to wordlist")
    workers := flag.Int("workers", 50, "Number of concurrent workers")
    timeoutSec := flag.Int("timeout", 2, "DNS timeout in seconds per lookup")
    outputFile := flag.String("output", "", "Write results to file (optional)")
    flag.Parse()

    if *domain == "" {
        fmt.Fprintln(os.Stderr, "error: -domain is required")
        os.Exit(1)
    }

    f, err := os.Open(*wordlist)
    if err != nil {
        fmt.Fprintf(os.Stderr, "error opening wordlist: %v\n", err)
        os.Exit(1)
    }
    defer f.Close()

    var out *os.File
    if *outputFile != "" {
        out, err = os.Create(*outputFile)
        if err != nil {
            fmt.Fprintf(os.Stderr, "error creating output file: %v\n", err)
            os.Exit(1)
        }
        defer out.Close()
    }

    targets := make(chan string, *workers*2)
    results := make(chan Result, *workers*2)
    timeout := time.Duration(*timeoutSec) * time.Second
    ctx := context.Background()

    // Feed wordlist into targets channel
    go func() {
        scanner := bufio.NewScanner(f)
        for scanner.Scan() {
            word := strings.TrimSpace(scanner.Text())
            if word == "" || strings.HasPrefix(word, "#") {
                continue
            }
            targets <- fmt.Sprintf("%s.%s", word, *domain)
        }
        close(targets)
    }()

    // Start worker pool in background
    go RunWorkerPool(ctx, targets, results, *workers, timeout)

    // Consume and print results
    found := 0
    for r := range results {
        if !r.Found {
            continue
        }
        found++
        line := fmt.Sprintf("[+] %s -> %s", r.Subdomain, strings.Join(r.IPs, ", "))
        fmt.Println(line)
        if out != nil {
            fmt.Fprintln(out, line)
        }
    }

    fmt.Printf("\n[*] Done. %d subdomains found.\n", found)
}
Enter fullscreen mode Exit fullscreen mode

Build and run:

go build -o subenum .
./subenum -domain example.com -workers 100 -timeout 2 -output results.txt
Enter fullscreen mode Exit fullscreen mode

Handling edge cases

A few things that trip up naive implementations:

Wildcard DNS. Some domains resolve every subdomain to a catch-all IP (*.example.com → 1.2.3.4). If you see the same IP across hundreds of results, the domain is using wildcard records and your output is mostly noise. Add a probe step before launching the full sweep:

func DetectWildcard(ctx context.Context, domain string, timeout time.Duration) (string, bool) {
    probe := fmt.Sprintf("zz-wildcard-probe-99999.%s", domain)
    r := Resolve(ctx, probe, timeout)
    if r.Found && len(r.IPs) > 0 {
        return r.IPs[0], true
    }
    return "", false
}
Enter fullscreen mode Exit fullscreen mode

Call this in main before starting the worker pool, then filter out any result whose IP matches the wildcard IP.

Rate limiting by the upstream resolver. With 100 workers hitting 8.8.8.8 in rapid succession, you may start getting SERVFAIL responses. Rotate between public resolvers (8.8.8.8, 1.1.1.1, 9.9.9.9) by modifying the Dial closure to pick one per-goroutine using the worker index.

False negatives from tight timeouts. Under network congestion, a 2-second timeout may silently drop valid subdomains. Consider a second pass — pipe the Found: false results back into a smaller pool with a 5-second timeout. Most valid misses resolve correctly on retry.

Where this fits in an external assessment

Subdomain enumeration is typically the second step after passive recon (crt.sh, Shodan, VirusTotal passive DNS). Its output feeds directly into:

  • Port scanning — run masscan against the discovered IP list
  • HTTP fingerprinting — check what services respond on 80/443 with httpx
  • Vulnerability correlation — match subdomains against CVE databases and outdated service banners

For a structured approach to organizing and prioritizing these findings, the security hardening checklists at AYI NEDJIMI Consultants cover the full external attack surface review workflow, including how to document what subdomain enumeration surfaces.

The takeaway

Building your own DNS enumeration tool in Go pays off precisely because you control everything: which resolvers to query, how many goroutines to spin up, what wildcard detection logic to apply, and what output format downstream tooling expects. The pattern here — a buffered channel feeding a fixed worker pool, closed by the producer — scales from 10 to 500 workers without touching the architecture.

The full source is about 130 lines. Start there, then layer in wildcard detection, multi-resolver rotation, and HTTP probing as your use cases evolve.


I run AYI NEDJIMI Consultants, a cybersecurity consulting firm. We publish free security hardening checklists — PDF and Excel.

Top comments (0)