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:
- Passive enumeration — querying certificate transparency logs (crt.sh), DNS history APIs, or search engines. No direct interaction with the target.
- 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
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)
}
A few things worth noting:
- We use a custom
net.Resolverthat routes DNS queries to8.8.8.8directly, bypassing local resolver caching issues. - The
timeoutper lookup is configurable — 2 seconds is a good default for most networks. -
RunWorkerPoolclosesresultsonce all workers finish, which lets the consumerrangeover 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)
}
Build and run:
go build -o subenum .
./subenum -domain example.com -workers 100 -timeout 2 -output results.txt
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
}
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)