/* ·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· : : : █▀ █ █▀▀ · Blazing-fast pentesting suite : : ▄█ █ █▀ · BSD 3-Clause License : : : : (c) 2022-2026 vmfunc, xyzeva, : : lunchcat alumni & contributors : : : ·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· */ package scan import ( "context" "fmt" "io" "net/http" "net/url" "regexp" "strings" "time" "github.com/vmfunc/sif/internal/fingerprint" "github.com/vmfunc/sif/internal/httpx" "github.com/vmfunc/sif/internal/logger" "github.com/vmfunc/sif/internal/output" ) // FaviconResult is the computed shodan-style favicon hash plus the pivot query // and any matched tech. type FaviconResult struct { FaviconURL string `json:"favicon_url"` // where the icon was fetched Hash int32 `json:"hash"` // shodan mmh3 hash (signed int32) Tech string `json:"tech"` // matched technology, empty when unknown ShodanQ string `json:"shodan_query"` } // faviconBodyReadCap bounds the icon read. real favicons are tens of kilobytes; // a megabyte ceiling covers oversized ones without letting a hostile endpoint // stream forever. const faviconBodyReadCap = 1 << 20 // faviconLinkRegex pulls the href off a tag so we can // fall back to a declared icon when /favicon.ico is absent. var faviconLinkRegex = regexp.MustCompile(`(?i)]+rel=["'][^"']*icon[^"']*["'][^>]*>`) // faviconHrefRegex extracts the href attribute value from a matched link tag. var faviconHrefRegex = regexp.MustCompile(`(?i)href=["']([^"']+)["']`) // Favicon fetches the target's favicon, computes the shodan mmh3 hash and matches // it against the bundled fingerprint map. func Favicon(targetURL string, timeout time.Duration, logdir string) (*FaviconResult, error) { log := output.Module("FAVICON") log.Start() sanitizedURL := stripScheme(targetURL) if logdir != "" { if err := logger.WriteHeader(sanitizedURL, logdir, "Favicon hash fingerprint"); err != nil { log.Error("error creating log file: %v", err) return nil, fmt.Errorf("create favicon log: %w", err) } } client := httpx.Client(timeout) base := strings.TrimRight(targetURL, "/") iconURL, data, err := fetchFavicon(client, base) if err != nil { log.Info("no favicon found: %v", err) log.Complete(0, "found") return nil, nil //nolint:nilnil // a missing favicon is not an error } hash := fingerprint.FaviconHash(data) tech, _ := fingerprint.LookupFaviconTech(hash) result := &FaviconResult{ FaviconURL: iconURL, Hash: hash, Tech: tech, ShodanQ: fmt.Sprintf("http.favicon.hash:%d", hash), } if result.Tech != "" { log.Warn("favicon hash %d matches %s", hash, output.Highlight.Render(result.Tech)) } else { log.Info("favicon hash %d (no fingerprint match)", hash) } log.Info("shodan pivot: %s", output.Highlight.Render(result.ShodanQ)) if logdir != "" { _ = logger.Write(sanitizedURL, logdir, fmt.Sprintf("Favicon %s hash=%d tech=%q query=%s\n", iconURL, hash, result.Tech, result.ShodanQ)) } log.Complete(1, "hashed") return result, nil } // fetchFavicon tries /favicon.ico first, then the declared in the // homepage html. it returns the url it pulled the bytes from so the report shows // exactly which icon was hashed. func fetchFavicon(client *http.Client, base string) (string, []byte, error) { // the well-known icon always lives at the origin root, so resolve it there // rather than appending to a target that may carry a path. iconURL := resolveFaviconURL(base, "/favicon.ico") if data, err := getFaviconBytes(client, iconURL); err == nil { return iconURL, data, nil } // no /favicon.ico; parse the homepage for a declared icon link. href, err := declaredFaviconHref(client, base) if err != nil { return "", nil, err } iconURL = resolveFaviconURL(base, href) data, err := getFaviconBytes(client, iconURL) if err != nil { return "", nil, err } return iconURL, data, nil } // getFaviconBytes GETs an icon url and returns the body, erroring on a non-200 or // an empty body so a soft-404 html page isn't hashed as if it were an icon. func getFaviconBytes(client *http.Client, iconURL string) ([]byte, error) { req, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, iconURL, http.NoBody) if err != nil { return nil, fmt.Errorf("build favicon request: %w", err) } resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("fetch favicon: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("favicon status %d", resp.StatusCode) } data, err := io.ReadAll(io.LimitReader(resp.Body, faviconBodyReadCap)) if err != nil { return nil, fmt.Errorf("read favicon: %w", err) } if len(data) == 0 { return nil, fmt.Errorf("empty favicon body") } return data, nil } // declaredFaviconHref fetches the homepage and extracts the href of the first // tag. func declaredFaviconHref(client *http.Client, base string) (string, error) { req, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, base, http.NoBody) if err != nil { return "", fmt.Errorf("build homepage request: %w", err) } resp, err := client.Do(req) if err != nil { return "", fmt.Errorf("fetch homepage: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(io.LimitReader(resp.Body, faviconBodyReadCap)) if err != nil { return "", fmt.Errorf("read homepage: %w", err) } link := faviconLinkRegex.Find(body) if link == nil { return "", fmt.Errorf("no favicon link in homepage") } href := faviconHrefRegex.FindSubmatch(link) if href == nil { return "", fmt.Errorf("favicon link has no href") } return string(href[1]), nil } // resolveFaviconURL turns a possibly-relative href into an absolute url against // the target base, browser-style (root-relative anchors at the origin, not the // target's path). unparsable input falls back to the raw href. func resolveFaviconURL(base, href string) string { baseURL, err := url.Parse(base) if err != nil { return href } ref, err := url.Parse(href) if err != nil { return href } return baseURL.ResolveReference(ref).String() } // ResultType identifies favicon findings for the result registry. func (r *FaviconResult) ResultType() string { return "favicon" } var _ ScanResult = (*FaviconResult)(nil)