mirror of
https://github.com/lunchcat/sif.git
synced 2026-07-28 14:37:01 -07:00
* fix(logger): create log dirs and flatten url log filenames CreateFile/Write kept the '/' from a target's url path when building the log filename, so a target like http://host:port/ tried to open <dir>/host:port/.log whose parent directory never existed. os.OpenFile failed and scanTarget aborted the whole target before any scanning ran. fold every '/' and '\\' run in the sanitized url into a single '_' via a shared logPath helper so CreateFile and Write always resolve to the same flat file, and mkdir the parent defensively in getWriter as a second line of defense. * fix(scan): route module status lines through the locking sink subdomaintakeover, cloudstorage, and the next.js framework detector printed their status/error lines with a bare fmt.Println straight to os.Stdout, bypassing the output package's sink. under -concurrency>1 only the sink is lock-wrapped, so these lines could interleave or tear against lines other targets write concurrently. swap them for output.ScanStart / output.Error, matching every other scanner in the package. * fix(store): make snapshot filenames injective and writes atomic sanitize() folds every separator run (and a literal '_') to one '_', so distinct targets like https://a.com/x and https://a.com//x, or host:8443/path and host_8443_path, sanitized to the identical string and shared one snapshot file - the second target's Save silently clobbered the first's baseline. Save's os.WriteFile also wasn't atomic, so two targets racing on a collided path (or -concurrency>1 in general) could interleave partial writes. pathFor now appends 16 hex chars of the target's sha256 to the sanitized name, so distinct targets never collide, and Save writes through a temp file in the same dir followed by os.Rename so a reader always sees a complete snapshot or the previous one, never a partial write. this changes the on-disk snapshot filename scheme: existing users' -diff baselines under the old sanitize()-only names won't be found and the next run re-baselines from scratch (every finding reports as newly added once). noting this as a deliberate tradeoff for a local hardening pass rather than silently orphaning them. * fix(store): check the deferred temp-file cleanup error golangci-lint (errcheck) flagged the bare defer os.Remove(tmpPath) in writeFileAtomic; wrap it so the discard is explicit.
267 lines
11 KiB
Go
267 lines
11 KiB
Go
/*
|
|
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
|
: :
|
|
: █▀ █ █▀▀ · 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.</h2><p class=\"text-muted text-tight\">The page you're looking for doesn't exist.",
|
|
"Aha": "There is no portal here ... sending you back to Aha!",
|
|
"Brightcove": "<p class=\"bc-gallery-error-code\">Error Code: 404</p>",
|
|
"Bigcartel": "<h1>Oops! We couldn’t find that page.</h1>",
|
|
"Compaignmonitor": "Double check the URL or <a href=\"mailto:help@createsend.com",
|
|
"Proposify": "If you need immediate assistance, please contact <a href=\"mailto:support@proposify.biz",
|
|
"Simplebooklet": "We can't find this <a href=\"https://simplebooklet.com",
|
|
"Getresponse": "With GetResponse Landing Pages, lead generation has never been easier",
|
|
"Vend": "Looks like you've traveled too far into cyberspace.",
|
|
"Jetbrains": "is not a registered InCloud YouTrack.",
|
|
"Azure": "404 Web Site not found.",
|
|
}
|
|
|
|
for service, signature := range signatures {
|
|
if !strings.Contains(bodyString, signature) {
|
|
continue
|
|
}
|
|
|
|
// a body fingerprint alone only proves the page renders a provider's
|
|
// unclaimed-domain copy - it says nothing about whether this host is
|
|
// actually delegated to that provider. a real github/s3/etc 404 page can
|
|
// show up on infrastructure with no dangling cname at all (a claimed page
|
|
// that happens to echo the same wording, or an unrelated host quoting
|
|
// it), so confirm the cname resolves to the SAME provider before calling
|
|
// it a takeover. a cname that resolves to something else (or to the host
|
|
// itself) contradicts the signature and must not be flagged.
|
|
cname, lookupErr := lookupCNAME(context.TODO(), subdomain)
|
|
if lookupErr == nil {
|
|
if cnameService, ok := danglingProvider(subdomain, cname); ok && cnameService == service {
|
|
return true, service, "confirmed"
|
|
}
|
|
// a resolving cname only contradicts the body match for providers we
|
|
// can actually correlate (an apex in takeoverProviders). rejecting
|
|
// those is the fp fix. for a provider with no apex to check against
|
|
// (tumblr and other custom-domain services), the cname proves
|
|
// nothing either way, so keep the body-only signal as low-confidence
|
|
// instead of silently dropping a real takeover.
|
|
if serviceCorrelatable(service) {
|
|
continue
|
|
}
|
|
return true, service, "potential"
|
|
}
|
|
|
|
// cname lookup unavailable (resolver error, no records to compare):
|
|
// keep the body-only signal but mark it low-confidence rather than
|
|
// treating an unverified match as a confirmed takeover.
|
|
return true, service, "potential"
|
|
}
|
|
|
|
return false, "", ""
|
|
}
|