mirror of
https://github.com/lunchcat/sif.git
synced 2026-06-12 11:01:24 -07:00
feat(dnslist): async dns resolution with wildcard filtering
dnslist previously http-probed every wordlist candidate through the blocking os resolver, so a big list meant a request per dead name and a wildcard zone flooded results. resolve each candidate first via a new internal/dnsx (retryabledns over a bundled 1.1.1.1/8.8.8.8/9.9.9.9 pool, promoted to a direct dep), fingerprint the apex with random nonexistent labels to detect catch-all zones, and http-probe only the names that actually resolve and aren't wildcard. add -resolvers to override the pool. resolverFn is a package-level seam so the dnsx tests stay hermetic; the dnslist newDNSResolver seam keeps the integration test network-free.
This commit is contained in:
@@ -11,6 +11,7 @@ require (
|
||||
github.com/likexian/whois v1.15.7
|
||||
github.com/projectdiscovery/goflags v0.1.74
|
||||
github.com/projectdiscovery/nuclei/v3 v3.8.0
|
||||
github.com/projectdiscovery/retryabledns v1.0.114
|
||||
github.com/projectdiscovery/utils v0.10.1
|
||||
github.com/rocketlaunchr/google-search v1.1.6
|
||||
golang.org/x/net v0.53.0
|
||||
@@ -288,7 +289,6 @@ require (
|
||||
github.com/projectdiscovery/ratelimit v0.0.85 // indirect
|
||||
github.com/projectdiscovery/rawhttp v0.1.90 // indirect
|
||||
github.com/projectdiscovery/rdap v0.9.1-0.20221108103045-9865884d1917 // indirect
|
||||
github.com/projectdiscovery/retryabledns v1.0.114 // indirect
|
||||
github.com/projectdiscovery/retryablehttp-go v1.3.8 // indirect
|
||||
github.com/projectdiscovery/sarif v0.0.1 // indirect
|
||||
github.com/projectdiscovery/tlsx v1.2.2 // indirect
|
||||
|
||||
@@ -30,6 +30,7 @@ type Settings struct {
|
||||
DirWordlist string // -w dirlist: custom wordlist (file path or url)
|
||||
DirExtensions string // -e dirlist: extensions appended to each word
|
||||
Dnslist string
|
||||
Resolvers string // -resolvers dnslist: comma list overriding the bundled pool
|
||||
Debug bool
|
||||
LogDir string
|
||||
NoScan bool
|
||||
@@ -120,6 +121,7 @@ func Parse() *Settings {
|
||||
flagSet.StringVar(&settings.DirWordlist, "w", "", "Dirlist: custom wordlist (local file path or url; overrides -dirlist size)"),
|
||||
flagSet.StringVar(&settings.DirExtensions, "e", "", "Dirlist: extensions appended to each word (comma list, e.g. php,bak,env)"),
|
||||
flagSet.EnumVar(&settings.Dnslist, "dnslist", Nil, "DNS fuzzing scan size (small/medium/large)", listSizes),
|
||||
flagSet.StringVar(&settings.Resolvers, "resolvers", "", "Dnslist: DNS resolvers to use (comma list, e.g. 1.1.1.1,8.8.8.8; overrides the bundled pool)"),
|
||||
flagSet.EnumVar(&settings.Ports, "ports", Nil, "Port scanning scope (common/full)", portScopes),
|
||||
flagSet.BoolVar(&settings.Dorking, "dork", false, "Enable Google dorking"),
|
||||
flagSet.BoolVar(&settings.Git, "git", false, "Enable git repository scanning"),
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
/*
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
: :
|
||||
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
|
||||
: ▄█ █ █▀ · BSD 3-Clause License :
|
||||
: :
|
||||
: (c) 2022-2026 vmfunc, xyzeva, :
|
||||
: lunchcat alumni & contributors :
|
||||
: :
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
*/
|
||||
|
||||
// Package dnsx resolves subdomain candidates against a bundled resolver pool
|
||||
// before anything is probed over http, so the slow/inaccurate path of HTTP-ing
|
||||
// every wordlist entry through the OS resolver is gone. it also fingerprints
|
||||
// wildcard zones (a zone that answers every random label) so a catch-all
|
||||
// nameserver can't flood the caller with phantom subdomains.
|
||||
package dnsx
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
retryabledns "github.com/projectdiscovery/retryabledns"
|
||||
)
|
||||
|
||||
// bundled default resolver pool. anycast cloudflare/google/quad9 - fast, public,
|
||||
// and unlikely to rate-limit a recon sweep. -resolvers overrides this set.
|
||||
const (
|
||||
resolverCloudflare = "1.1.1.1:53"
|
||||
resolverGoogle = "8.8.8.8:53"
|
||||
resolverQuad9 = "9.9.9.9:53"
|
||||
)
|
||||
|
||||
// defaultResolvers is the bundled pool used when the caller passes none.
|
||||
var defaultResolvers = []string{resolverCloudflare, resolverGoogle, resolverQuad9}
|
||||
|
||||
const (
|
||||
// defaultRetries is how many times retryabledns rotates through the pool on a
|
||||
// timeout before giving up on a name. low enough to stay fast on a big list.
|
||||
defaultRetries = 3
|
||||
|
||||
// wildcardProbes is how many random nonexistent labels we resolve to
|
||||
// fingerprint a wildcard zone. more samples make a rotating catch-all (one
|
||||
// that hands back a different ip per query) harder to miss, but each is a
|
||||
// real lookup so this stays small.
|
||||
wildcardProbes = 3
|
||||
|
||||
// randomLabelLen is the length of each random wildcard-probe label. long
|
||||
// enough that a collision with a real host is astronomically unlikely.
|
||||
randomLabelLen = 16
|
||||
)
|
||||
|
||||
// randomLabelAlphabet is the lowercase-alnum set wildcard probe labels draw
|
||||
// from; a valid dns label so the query isn't rejected before it leaves.
|
||||
const randomLabelAlphabet = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
|
||||
// defaultDNSPort is appended to any resolver entry given without an explicit
|
||||
// port, so "1.1.1.1" and "1.1.1.1:53" both work on the cli.
|
||||
const defaultDNSPort = "53"
|
||||
|
||||
// ParseResolvers splits a comma list of resolvers into a normalized slice,
|
||||
// appending the default port to bare ips/hosts. an empty or blank input returns
|
||||
// nil so the caller falls back to the bundled pool.
|
||||
func ParseResolvers(raw string) []string {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(raw, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for i := 0; i < len(parts); i++ {
|
||||
entry := strings.TrimSpace(parts[i])
|
||||
if entry == "" {
|
||||
continue
|
||||
}
|
||||
// a bare ip/host gets the default port; an entry already carrying ":port"
|
||||
// (or a bracketed ipv6 literal) is left as-is.
|
||||
if !strings.Contains(entry, ":") {
|
||||
entry += ":" + defaultDNSPort
|
||||
}
|
||||
out = append(out, entry)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// resolution is the resolved address set for one host. empty Addrs means the
|
||||
// name did not resolve (nxdomain / no records).
|
||||
type resolution struct {
|
||||
Addrs []string
|
||||
}
|
||||
|
||||
// resolved reports whether the name returned any address.
|
||||
func (r resolution) resolved() bool {
|
||||
return len(r.Addrs) > 0
|
||||
}
|
||||
|
||||
// resolverFn is the test seam: every lookup the package makes goes through this
|
||||
// var, so a fake can answer without touching the network. real runs point it at
|
||||
// a retryabledns-backed client via NewResolver.
|
||||
var resolverFn func(host string) (resolution, error)
|
||||
|
||||
// Resolver resolves candidates against a pool and filters wildcard answers. it
|
||||
// is built once per scan and shared across the worker goroutines; the
|
||||
// underlying retryabledns client is safe for concurrent use.
|
||||
type Resolver struct {
|
||||
// wildcardSigs holds the address sets a wildcard zone answers random labels
|
||||
// with. nil/empty means the zone is not wildcard. a candidate whose answer is
|
||||
// covered by one of these is a catch-all hit, not a real host.
|
||||
wildcardSigs []map[string]struct{}
|
||||
}
|
||||
|
||||
// NewResolver wires resolverFn to a retryabledns client over the given pool
|
||||
// (bundled default when resolvers is empty) and returns a Resolver. it does not
|
||||
// fingerprint anything yet - call FingerprintWildcard with the apex first.
|
||||
func NewResolver(resolvers []string) (*Resolver, error) {
|
||||
pool := resolvers
|
||||
if len(pool) == 0 {
|
||||
pool = defaultResolvers
|
||||
}
|
||||
|
||||
client, err := retryabledns.New(pool, defaultRetries)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dnsx: build resolver over %v: %w", pool, err)
|
||||
}
|
||||
|
||||
// only install the real client when a test hasn't already injected a fake;
|
||||
// the seam wins so hermetic tests never reach this client.
|
||||
if resolverFn == nil {
|
||||
resolverFn = func(host string) (resolution, error) {
|
||||
data, err := client.Resolve(host)
|
||||
if err != nil {
|
||||
return resolution{}, fmt.Errorf("dnsx: resolve %q: %w", host, err)
|
||||
}
|
||||
return resolution{Addrs: mergeAddrs(data)}, nil
|
||||
}
|
||||
}
|
||||
|
||||
return &Resolver{}, nil
|
||||
}
|
||||
|
||||
// FingerprintWildcard resolves wildcardProbes random labels under apex. any that
|
||||
// answer mean the zone is a catch-all, so their address sets are recorded as
|
||||
// signatures to filter real candidates against later. a clean zone leaves the
|
||||
// signature list empty and nothing gets filtered.
|
||||
func (r *Resolver) FingerprintWildcard(apex string) error {
|
||||
apex = strings.TrimSuffix(apex, ".")
|
||||
for i := 0; i < wildcardProbes; i++ {
|
||||
label, err := randomLabel(randomLabelLen)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dnsx: wildcard probe label: %w", err)
|
||||
}
|
||||
|
||||
res, err := resolverFn(label + "." + apex)
|
||||
if err != nil {
|
||||
// a probe failure (timeout / nxdomain surfaced as error) just means this
|
||||
// sample says "not wildcard"; don't abort the whole fingerprint on it.
|
||||
continue
|
||||
}
|
||||
if res.resolved() {
|
||||
r.wildcardSigs = append(r.wildcardSigs, toSet(res.Addrs))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Resolve looks up host and reports whether it is a real, non-wildcard hit. a
|
||||
// name that doesn't resolve, or whose answer matches a recorded wildcard
|
||||
// signature, returns false so the caller skips probing it.
|
||||
func (r *Resolver) Resolve(host string) (bool, error) {
|
||||
res, err := resolverFn(host)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("dnsx: resolve %q: %w", host, err)
|
||||
}
|
||||
if !res.resolved() {
|
||||
return false, nil
|
||||
}
|
||||
if r.isWildcard(res.Addrs) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// isWildcard reports whether addrs is covered by any recorded wildcard
|
||||
// signature. a candidate whose every address appears in a wildcard answer is a
|
||||
// catch-all hit; a host with even one address outside the signature is a real,
|
||||
// distinct record and survives.
|
||||
func (r *Resolver) isWildcard(addrs []string) bool {
|
||||
if len(r.wildcardSigs) == 0 {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(r.wildcardSigs); i++ {
|
||||
if subset(addrs, r.wildcardSigs[i]) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// mergeAddrs flattens the A and AAAA answers into one sorted, deduped slice so
|
||||
// two equal answers compare equal regardless of record ordering.
|
||||
func mergeAddrs(data *retryabledns.DNSData) []string {
|
||||
if data == nil {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]struct{}, len(data.A)+len(data.AAAA))
|
||||
for i := 0; i < len(data.A); i++ {
|
||||
seen[data.A[i]] = struct{}{}
|
||||
}
|
||||
for i := 0; i < len(data.AAAA); i++ {
|
||||
seen[data.AAAA[i]] = struct{}{}
|
||||
}
|
||||
|
||||
addrs := make([]string, 0, len(seen))
|
||||
for addr := range seen {
|
||||
addrs = append(addrs, addr)
|
||||
}
|
||||
sort.Strings(addrs)
|
||||
|
||||
return addrs
|
||||
}
|
||||
|
||||
// toSet turns addrs into a lookup set for subset checks.
|
||||
func toSet(addrs []string) map[string]struct{} {
|
||||
set := make(map[string]struct{}, len(addrs))
|
||||
for i := 0; i < len(addrs); i++ {
|
||||
set[addrs[i]] = struct{}{}
|
||||
}
|
||||
|
||||
return set
|
||||
}
|
||||
|
||||
// subset reports whether every addr is present in sig (and addrs is non-empty);
|
||||
// an empty addrs can't be a wildcard match.
|
||||
func subset(addrs []string, sig map[string]struct{}) bool {
|
||||
if len(addrs) == 0 {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(addrs); i++ {
|
||||
if _, ok := sig[addrs[i]]; !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// randomLabel returns a cryptographically-random lowercase-alnum dns label of
|
||||
// length n. crypto/rand (not math/rand) so a target can't predict the probe
|
||||
// labels and special-case them to defeat wildcard detection.
|
||||
func randomLabel(n int) (string, error) {
|
||||
var b strings.Builder
|
||||
b.Grow(n)
|
||||
alphabetLen := big.NewInt(int64(len(randomLabelAlphabet)))
|
||||
for i := 0; i < n; i++ {
|
||||
idx, err := rand.Int(rand.Reader, alphabetLen)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("dnsx: random index: %w", err)
|
||||
}
|
||||
b.WriteByte(randomLabelAlphabet[idx.Int64()])
|
||||
}
|
||||
|
||||
return b.String(), nil
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
/*
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
: :
|
||||
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
|
||||
: ▄█ █ █▀ · BSD 3-Clause License :
|
||||
: :
|
||||
: (c) 2022-2026 vmfunc, xyzeva, :
|
||||
: lunchcat alumni & contributors :
|
||||
: :
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
*/
|
||||
|
||||
package dnsx
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// withFakeResolver swaps resolverFn for fn for the duration of one test, then
|
||||
// restores it - the seam that keeps every case below network-free.
|
||||
func withFakeResolver(t *testing.T, fn func(host string) (resolution, error)) {
|
||||
t.Helper()
|
||||
orig := resolverFn
|
||||
resolverFn = fn
|
||||
t.Cleanup(func() { resolverFn = orig })
|
||||
}
|
||||
|
||||
// newFingerprinted builds a Resolver and runs the wildcard fingerprint against
|
||||
// apex using the already-injected fake; fatal on error.
|
||||
func newFingerprinted(t *testing.T, apex string) *Resolver {
|
||||
t.Helper()
|
||||
r := &Resolver{}
|
||||
if err := r.FingerprintWildcard(apex); err != nil {
|
||||
t.Fatalf("FingerprintWildcard: %v", err)
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
const testApex = "example.com"
|
||||
|
||||
// a host that resolves to a real address, in a clean (non-wildcard) zone, is a
|
||||
// genuine hit.
|
||||
func TestResolve_FoundInCleanZone(t *testing.T) {
|
||||
withFakeResolver(t, func(host string) (resolution, error) {
|
||||
// nothing answers a random wildcard probe -> clean zone.
|
||||
if strings.HasSuffix(host, "."+testApex) && host != "www."+testApex {
|
||||
return resolution{}, nil
|
||||
}
|
||||
if host == "www."+testApex {
|
||||
return resolution{Addrs: []string{"93.184.216.34"}}, nil
|
||||
}
|
||||
return resolution{}, nil
|
||||
})
|
||||
|
||||
r := newFingerprinted(t, testApex)
|
||||
if len(r.wildcardSigs) != 0 {
|
||||
t.Fatalf("clean zone should record no wildcard signatures, got %d", len(r.wildcardSigs))
|
||||
}
|
||||
|
||||
ok, err := r.Resolve("www." + testApex)
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Error("a resolving host in a clean zone should be a hit")
|
||||
}
|
||||
}
|
||||
|
||||
// nxdomain (no addresses) is not a hit, so the caller skips probing it.
|
||||
func TestResolve_NxdomainSkipped(t *testing.T) {
|
||||
withFakeResolver(t, func(string) (resolution, error) {
|
||||
// every name, probes included, returns no records.
|
||||
return resolution{}, nil
|
||||
})
|
||||
|
||||
r := newFingerprinted(t, testApex)
|
||||
|
||||
ok, err := r.Resolve("ghost." + testApex)
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve: %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Error("an nxdomain host must not count as found")
|
||||
}
|
||||
}
|
||||
|
||||
// a wildcard zone answers the random probe labels, so a candidate that resolves
|
||||
// to the same catch-all address is filtered out.
|
||||
func TestResolve_WildcardFiltered(t *testing.T) {
|
||||
const catchAll = "10.0.0.1"
|
||||
withFakeResolver(t, func(string) (resolution, error) {
|
||||
// the zone answers everything - probes and candidates alike - with one ip.
|
||||
return resolution{Addrs: []string{catchAll}}, nil
|
||||
})
|
||||
|
||||
r := newFingerprinted(t, testApex)
|
||||
if len(r.wildcardSigs) == 0 {
|
||||
t.Fatal("wildcard zone should record at least one signature")
|
||||
}
|
||||
|
||||
ok, err := r.Resolve("anything." + testApex)
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve: %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Error("a candidate matching the wildcard answer must be filtered")
|
||||
}
|
||||
}
|
||||
|
||||
// a real host in a wildcard zone that resolves to a distinct address (not the
|
||||
// catch-all) still survives the filter - one address outside the signature is
|
||||
// enough to be a genuine record.
|
||||
func TestResolve_DistinctHostSurvivesWildcard(t *testing.T) {
|
||||
const catchAll = "10.0.0.1"
|
||||
const realHost = "api." + testApex
|
||||
withFakeResolver(t, func(host string) (resolution, error) {
|
||||
if host == realHost {
|
||||
return resolution{Addrs: []string{"203.0.113.7"}}, nil
|
||||
}
|
||||
// everything else (probes + other candidates) hits the catch-all.
|
||||
return resolution{Addrs: []string{catchAll}}, nil
|
||||
})
|
||||
|
||||
r := newFingerprinted(t, testApex)
|
||||
if len(r.wildcardSigs) == 0 {
|
||||
t.Fatal("wildcard zone should record at least one signature")
|
||||
}
|
||||
|
||||
ok, err := r.Resolve(realHost)
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Error("a host resolving to a distinct address should survive the wildcard filter")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseResolvers(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want []string
|
||||
}{
|
||||
{"empty falls back to bundled", "", nil},
|
||||
{"blank falls back to bundled", " ", nil},
|
||||
{"bare ips get default port", "1.1.1.1,8.8.8.8", []string{"1.1.1.1:53", "8.8.8.8:53"}},
|
||||
{"explicit port preserved", "9.9.9.9:5353", []string{"9.9.9.9:5353"}},
|
||||
{"whitespace and empties trimmed", " 1.1.1.1 , ,8.8.8.8 ", []string{"1.1.1.1:53", "8.8.8.8:53"}},
|
||||
{"mixed bare and ported", "1.1.1.1,9.9.9.9:5353", []string{"1.1.1.1:53", "9.9.9.9:5353"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := ParseResolvers(tt.in); !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("ParseResolvers(%q) = %v, want %v", tt.in, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewResolver_DefaultsToBundledPool(t *testing.T) {
|
||||
// keep the seam already installed so New doesn't replace it with a real
|
||||
// client; we only assert the constructor accepts an empty override.
|
||||
withFakeResolver(t, func(string) (resolution, error) { return resolution{}, nil })
|
||||
|
||||
r, err := NewResolver(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewResolver(nil): %v", err)
|
||||
}
|
||||
if r == nil {
|
||||
t.Fatal("NewResolver returned nil resolver")
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"time"
|
||||
|
||||
charmlog "github.com/charmbracelet/log"
|
||||
"github.com/dropalldatabases/sif/internal/dnsx"
|
||||
"github.com/dropalldatabases/sif/internal/httpx"
|
||||
"github.com/dropalldatabases/sif/internal/logger"
|
||||
"github.com/dropalldatabases/sif/internal/output"
|
||||
@@ -33,6 +34,27 @@ var dnsURL = "https://raw.githubusercontent.com/dropalldatabases/sif-runtime/mai
|
||||
// local server instead of resolving real DNS. nil keeps http.DefaultTransport.
|
||||
var dnsTransport http.RoundTripper
|
||||
|
||||
// hostResolver is the small slice of dnsx the dnslist worker needs: resolve a
|
||||
// candidate and report whether it's a real, non-wildcard hit.
|
||||
type hostResolver interface {
|
||||
Resolve(host string) (bool, error)
|
||||
}
|
||||
|
||||
// newDNSResolver builds the resolver for one run; it's a var so integration
|
||||
// tests inject a fake that answers without touching real dns. the apex is
|
||||
// fingerprinted for wildcards before any candidate is checked.
|
||||
var newDNSResolver = func(apex string, resolvers []string) (hostResolver, error) {
|
||||
r, err := dnsx.NewResolver(resolvers)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dns resolver: %w", err)
|
||||
}
|
||||
if err := r.FingerprintWildcard(apex); err != nil {
|
||||
return nil, fmt.Errorf("wildcard fingerprint: %w", err)
|
||||
}
|
||||
|
||||
return r, nil
|
||||
}
|
||||
|
||||
const (
|
||||
dnsSmallFile = "subdomains-100.txt"
|
||||
dnsMediumFile = "subdomains-1000.txt"
|
||||
@@ -56,8 +78,11 @@ func meaningfulStatus(code int) bool {
|
||||
return code >= http.StatusOK && code < http.StatusMultipleChoices
|
||||
}
|
||||
|
||||
// Dnslist performs DNS subdomain enumeration on the target domain.
|
||||
func Dnslist(size string, url string, timeout time.Duration, threads int, logdir string) ([]string, error) {
|
||||
// Dnslist performs DNS subdomain enumeration on the target domain. each
|
||||
// candidate is resolved first; only names that actually resolve (and aren't a
|
||||
// wildcard catch-all) are http-probed, so a big wordlist no longer means a
|
||||
// http request per dead name.
|
||||
func Dnslist(size string, url string, timeout time.Duration, threads int, logdir string, resolvers []string) ([]string, error) {
|
||||
log := output.Module("DNS")
|
||||
log.Start()
|
||||
|
||||
@@ -92,6 +117,15 @@ func Dnslist(size string, url string, timeout time.Duration, threads int, logdir
|
||||
|
||||
sanitizedURL := stripScheme(url)
|
||||
|
||||
// resolve against dns first, fingerprinting the apex for wildcards so a
|
||||
// catch-all zone can't flood the probe step. build it once and share across
|
||||
// the workers - the underlying client is concurrency-safe.
|
||||
resolver, err := newDNSResolver(sanitizedURL, resolvers)
|
||||
if err != nil {
|
||||
log.Error("Error building DNS resolver: %s", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if logdir != "" {
|
||||
if err := logger.WriteHeader(sanitizedURL, logdir, size+" subdomain fuzzing"); err != nil {
|
||||
log.Error("Error creating log file: %v", err)
|
||||
@@ -132,10 +166,23 @@ func Dnslist(size string, url string, timeout time.Duration, threads int, logdir
|
||||
|
||||
charmlog.Debugf("Looking up: %s", domain)
|
||||
|
||||
host := domain + "." + sanitizedURL
|
||||
|
||||
// dns gate: skip the http probe entirely for names that don't
|
||||
// resolve or that a wildcard zone answers. this is the whole point -
|
||||
// no request per dead candidate.
|
||||
ok, err := resolver.Resolve(host)
|
||||
if err != nil {
|
||||
charmlog.Debugf("resolve %s: %s", host, err)
|
||||
continue
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// probe http first, then https - but a subdomain is recorded at
|
||||
// most once. firing both schemes and appending on each is what
|
||||
// double-counted every host on the old path.
|
||||
host := domain + "." + sanitizedURL
|
||||
foundURL, scheme := probeSubdomain(client, host)
|
||||
if foundURL == "" {
|
||||
continue
|
||||
|
||||
@@ -424,7 +424,15 @@ func TestIntegrationDnslist(t *testing.T) {
|
||||
}
|
||||
defer func() { dnsTransport = origTr }()
|
||||
|
||||
found, err := Dnslist("small", "http://example.com", 5*time.Second, 2, "")
|
||||
// inject a fake resolver so the run never touches real dns: every candidate
|
||||
// resolves, nothing is wildcard, so all wordlist names reach the probe step.
|
||||
origResolver := newDNSResolver
|
||||
newDNSResolver = func(_ string, _ []string) (hostResolver, error) {
|
||||
return resolveAllStub{}, nil
|
||||
}
|
||||
defer func() { newDNSResolver = origResolver }()
|
||||
|
||||
found, err := Dnslist("small", "http://example.com", 5*time.Second, 2, "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Dnslist: %v", err)
|
||||
}
|
||||
@@ -435,6 +443,12 @@ func TestIntegrationDnslist(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// resolveAllStub answers every host as a real, non-wildcard hit so the dns gate
|
||||
// is a pass-through and the probe step gets the full wordlist.
|
||||
type resolveAllStub struct{}
|
||||
|
||||
func (resolveAllStub) Resolve(string) (bool, error) { return true, nil }
|
||||
|
||||
func contains(s []string, v string) bool {
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == v {
|
||||
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/dropalldatabases/sif/internal/config"
|
||||
"github.com/dropalldatabases/sif/internal/dnsx"
|
||||
"github.com/dropalldatabases/sif/internal/finding"
|
||||
"github.com/dropalldatabases/sif/internal/httpx"
|
||||
"github.com/dropalldatabases/sif/internal/logger"
|
||||
@@ -270,7 +271,7 @@ func (app *App) Run() error {
|
||||
var dnsResults []string
|
||||
|
||||
if app.settings.Dnslist != "none" {
|
||||
result, err := scan.Dnslist(app.settings.Dnslist, url, app.settings.Timeout, app.settings.Threads, app.settings.LogDir)
|
||||
result, err := scan.Dnslist(app.settings.Dnslist, url, app.settings.Timeout, app.settings.Threads, app.settings.LogDir, dnsx.ParseResolvers(app.settings.Resolvers))
|
||||
if err != nil {
|
||||
log.Errorf("Error while running dns scan: %s", err)
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user