/* ·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· : : : █▀ █ █▀▀ · Blazing-fast pentesting suite : : ▄█ █ █▀ · BSD 3-Clause License : : : : (c) 2022-2026 vmfunc, xyzeva, : : lunchcat alumni & contributors : : : ·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· */ package scan import ( "context" "fmt" "io" "net" "net/http" "os" "strings" "time" "github.com/charmbracelet/log" "github.com/vmfunc/sif/internal/httpx" "github.com/vmfunc/sif/internal/logger" "github.com/vmfunc/sif/internal/output" "github.com/vmfunc/sif/internal/pool" "github.com/vmfunc/sif/internal/styles" ) // SubdomainTakeoverResult represents the outcome of a subdomain takeover vulnerability check. // It includes the subdomain tested, whether it's vulnerable, and the potentially vulnerable service. type SubdomainTakeoverResult struct { Subdomain string `json:"subdomain"` Vulnerable bool `json:"vulnerable"` Service string `json:"service,omitempty"` // Confidence is "confirmed" when the cname chain resolves to the same // provider as the matched signature, or "potential" when only the body // fingerprint matched and the cname could not be checked against it. Confidence string `json:"confidence,omitempty"` } // takeoverProviders maps a takeoverable third-party's cname apex to its service // name. a "no such host" on a subdomain only counts as a dangling-cname takeover // when the cname points at one of these and the target is unclaimed - a cname // to anything else (or to the host itself) is a normal record, not a finding. var takeoverProviders = map[string]string{ "github.io": "GitHub Pages", "herokuapp.com": "Heroku", "herokudns.com": "Heroku", "myshopify.com": "Shopify", "wordpress.com": "WordPress", "s3.amazonaws.com": "Amazon S3", "ghost.io": "Ghost", "pantheonsite.io": "Pantheon", "zendesk.com": "Zendesk", "surge.sh": "Surge", "bitbucket.io": "Bitbucket", "fastly.net": "Fastly", "helpscoutdocs.com": "Helpscout", "cargocollective.com": "Cargo", "uservoice.com": "Uservoice", "webflow.io": "Webflow", "readthedocs.io": "ReadTheDocs", "azurewebsites.net": "Azure", "cloudapp.net": "Azure", "trafficmanager.net": "Azure", "blob.core.windows.net": "Azure", "netlify.app": "Netlify", "netlify.com": "Netlify", } // SubdomainTakeover checks dnsResults for dangling subdomains pointing at // unclaimed third-party services. func SubdomainTakeover(url string, dnsResults []string, timeout time.Duration, threads int, logdir string) ([]SubdomainTakeoverResult, error) { output.ScanStart("Subdomain Takeover Vulnerability Check") sanitizedURL := stripScheme(url) if logdir != "" { if err := logger.WriteHeader(sanitizedURL, logdir, "Subdomain Takeover Vulnerability Check"); err != nil { log.Errorf("Error creating log file: %v", err) return nil, err } } subdomainlog := log.NewWithOptions(os.Stderr, log.Options{ Prefix: "Subdomain Takeover", }) client := httpx.Client(timeout) // buffered to the full candidate count so a send never blocks: Each only // returns once every worker is done, and the channel is drained afterwards. resultsChan := make(chan SubdomainTakeoverResult, len(dnsResults)) pool.Each(dnsResults, threads, func(subdomain string) { vulnerable, service, confidence := checkSubdomainTakeover(subdomain, client) result := SubdomainTakeoverResult{ Subdomain: subdomain, Vulnerable: vulnerable, Service: service, Confidence: confidence, } resultsChan <- result if vulnerable { subdomainlog.Warnf("Potential subdomain takeover: %s (%s)", styles.Highlight.Render(subdomain), service) if logdir != "" { logger.Write(sanitizedURL, logdir, fmt.Sprintf("Potential subdomain takeover: %s (%s)\n", subdomain, service)) } } else { subdomainlog.Infof("Subdomain not vulnerable: %s", subdomain) } }) close(resultsChan) var results []SubdomainTakeoverResult for result := range resultsChan { results = append(results, result) } return results, nil } // lookupCNAME resolves a subdomain's cname. it is a var so tests can stub out // dns resolution instead of depending on a live resolver. var lookupCNAME = func(ctx context.Context, host string) (string, error) { return net.DefaultResolver.LookupCNAME(ctx, host) } // serviceCorrelatable reports whether service has at least one cname apex in // takeoverProviders, i.e. whether a cname lookup can ever confirm or contradict // a body match for it. many body signatures (tumblr, intercom, and other // custom-domain providers) have no apex entry, so a cname can neither back nor // disprove them and must not be used to drop the finding. func serviceCorrelatable(service string) bool { for _, s := range takeoverProviders { if s == service { return true } } return false } // danglingProvider reports whether cname points off-host at a known // takeoverable provider. a self-referential cname (LookupCNAME echoing an A // record back as the host) is rejected, since that's a live host, not a // dangling pointer. func danglingProvider(subdomain, cname string) (string, bool) { // LookupCNAME returns a fqdn with a trailing dot; strip it so suffix and // self-reference checks compare like-for-like. target := strings.ToLower(strings.TrimSuffix(cname, ".")) host := strings.ToLower(strings.TrimSuffix(subdomain, ".")) if target == "" || target == host { return "", false } for apex, service := range takeoverProviders { if target == apex || strings.HasSuffix(target, "."+apex) { return service, true } } return "", false } func checkSubdomainTakeover(subdomain string, client *http.Client) (bool, string, string) { req, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, "http://"+subdomain, http.NoBody) if err != nil { return false, "", "" } resp, err := client.Do(req) if err != nil { // a dead host only matters if its cname still points at an unclaimed // third-party service. LookupCNAME echoes the host back for plain A // records, so "any cname" is not a signal - the cname must resolve to a // known takeoverable provider and not be the host itself. if strings.Contains(err.Error(), "no such host") { cname, lookupErr := lookupCNAME(context.TODO(), subdomain) if lookupErr == nil { if service, ok := danglingProvider(subdomain, cname); ok { return true, service + " (Dangling CNAME)", "confirmed" } } } return false, "", "" } defer resp.Body.Close() body, err := io.ReadAll(io.LimitReader(resp.Body, 5*1024*1024)) if err != nil { return false, "", "" } bodyString := string(body) // Check for common takeover signatures in the response signatures := map[string]string{ "GitHub Pages": "There isn't a GitHub Pages site here.", "Heroku": "No such app", "Shopify": "Sorry, this shop is currently unavailable.", "Tumblr": "There's nothing here.", "WordPress": "Do you want to register *.wordpress.com?", "Amazon S3": "The specified bucket does not exist", "Bitbucket": "Repository not found", "Ghost": "Failed to resolve DNS path for this host", "Pantheon": "404 - Unknown site", "Helpjuice": "We could not find what you're looking for.", "Helpscout": "No settings were found for this company:", "Cargo": "If you're moving your domain away from Cargo you must make this configuration through your registrar's DNS control panel.", "Surge": "project not found", "Intercom": "This page is reserved for artistic dogs.", "Webflow": "The page you are looking for doesn't exist or has been moved.", "Wishpond": "https://www.wishpond.com/404?campaign=true", "Aftership": "Oops.

The page you're looking for doesn't exist.", "Aha": "There is no portal here ... sending you back to Aha!", "Brightcove": "

", "Bigcartel": "

Oops! We couldn’t find that page.

", "Compaignmonitor": "Double check the URL or