Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions internal/fingerprint/favicon.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,29 @@ func FaviconHash(data []byte) int32 {
return int32(murmur3.Sum32(encoded)) //nolint:gosec // shodan stores the signed reinterpretation on purpose
}

// faviconTech maps a known shodan favicon hash to the tech that ships it.
// these are stable default icons for panels/frameworks/c2; a hit is a strong
// fingerprint. kept small on purpose - high-signal defaults, not an exhaustive db.
var faviconTech = map[int32]string{
116323821: "Apache Tomcat",
81586312: "Spring Boot (default whitelabel)",
-235701012: "Jenkins",
-1255347784: "GitLab",
1278322581: "Grafana",
743365239: "Kibana",
-1462443472: "phpMyAdmin",
999357577: "Cobalt Strike (default beacon)",
-1521704893: "Metasploit",
-1893514588: "Gitea",
}

// LookupFaviconTech returns the tech that ships the given shodan favicon hash and
// whether the hash is known.
func LookupFaviconTech(hash int32) (string, bool) {
tech, ok := faviconTech[hash]
return tech, ok
}

// encodeFaviconBase64 mirrors python's base64.encodebytes: standard base64 with
// a newline inserted every 76 output characters and a trailing newline. this is
// the exact byte stream shodan feeds to mmh3, so it must match byte-for-byte.
Expand Down
35 changes: 35 additions & 0 deletions internal/fingerprint/favicon_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,41 @@ func TestFaviconHashGolden(t *testing.T) {
}
}

// TestLookupFaviconTech proves the SSOT table is the single place all ten
// facts are resolved: every entry round-trips, an unknown hash misses, and the
// count is pinned so an accidental addition/removal is caught.
func TestLookupFaviconTech(t *testing.T) {
if len(faviconTech) != 10 {
t.Fatalf("faviconTech has %d entries, want 10", len(faviconTech))
}
for hash, want := range faviconTech {
got, ok := LookupFaviconTech(hash)
if !ok {
t.Errorf("LookupFaviconTech(%d) ok = false, want true", hash)
}
if got != want {
t.Errorf("LookupFaviconTech(%d) = %q, want %q", hash, got, want)
}
}

if tech, ok := LookupFaviconTech(0); ok {
t.Errorf("LookupFaviconTech(0) = (%q, true), want (\"\", false)", tech)
}

tests := []struct {
hash int32
want string
}{
{hash: -1255347784, want: "GitLab"},
{hash: 116323821, want: "Apache Tomcat"},
}
for _, tt := range tests {
if got, ok := LookupFaviconTech(tt.hash); !ok || got != tt.want {
t.Errorf("LookupFaviconTech(%d) = (%q, %v), want (%q, true)", tt.hash, got, ok, tt.want)
}
}
}

// TestFaviconBase64Chunking pins the encode step against python's
// base64.encodebytes: a 60-byte input encodes to 80 base64 chars, so it must
// wrap into two newline-terminated lines.
Expand Down
6 changes: 5 additions & 1 deletion internal/modules/favicon.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,11 @@ func faviconEvidence(matchers []Matcher, body string) (string, bool) {
if !favicon {
return "", false
}
return fmt.Sprintf("favicon mmh3=%d", fingerprint.FaviconHash([]byte(body))), true
hash := fingerprint.FaviconHash([]byte(body))
if tech, ok := fingerprint.LookupFaviconTech(hash); ok {
return fmt.Sprintf("favicon mmh3=%d tech=%s", hash, tech), true
}
return fmt.Sprintf("favicon mmh3=%d", hash), true
}

// validateMatchers fails favicon matchers that would silently never fire (no
Expand Down
70 changes: 70 additions & 0 deletions internal/modules/favicon_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"math"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"

Expand Down Expand Up @@ -116,6 +117,75 @@ func TestFaviconEvidence(t *testing.T) {
}
}

// TestFaviconEvidenceNamesCanonicalTech proves faviconEvidence consults the same
// SSOT table as fingerprint.LookupFaviconTech rather than a private copy: the
// evidence line must always match the format built directly from the shared
// hash + lookup functions, whether or not the fixture hash is canonical.
func TestFaviconEvidenceNamesCanonicalTech(t *testing.T) {
body := string(faviconFixture)
hash := fingerprint.FaviconHash(faviconFixture)
want := fmt.Sprintf("favicon mmh3=%d", hash)
if tech, ok := fingerprint.LookupFaviconTech(hash); ok {
want = fmt.Sprintf("favicon mmh3=%d tech=%s", hash, tech)
}

got, ok := faviconEvidence([]Matcher{{Type: "favicon"}}, body)
if !ok {
t.Fatal("faviconEvidence ok = false, want true")
}
if got != want {
t.Errorf("evidence = %q, want %q", got, want)
}
}

// favicon demo modules must reference a hash from fingerprint.LookupFaviconTech
// that names the service in their filename, so a demo cannot drift from the
// canonical hash->tech table.
func TestFaviconDemoModulesMatchCanonicalMap(t *testing.T) {
matches, err := filepath.Glob("../../modules/info/favicon-*.yaml")
if err != nil {
t.Fatal(err)
}
if len(matches) == 0 {
t.Skip("no favicon demo modules present")
}

for _, path := range matches {
t.Run(filepath.Base(path), func(t *testing.T) {
def, err := ParseYAMLModule(path)
if err != nil {
t.Fatalf("parse: %v", err)
}
if def.HTTP == nil {
t.Fatal("favicon demo is not an http module")
}

var hashes []int64
for _, m := range def.HTTP.Matchers {
if m.Type == "favicon" {
hashes = append(hashes, m.Hash...)
}
}
if len(hashes) == 0 {
t.Fatal("no favicon hash in module")
}

service := strings.TrimSuffix(strings.TrimPrefix(filepath.Base(path), "favicon-"), ".yaml")
for _, h := range hashes {
// hashes are range-checked at parse, so int32(h) is the canonical fold.
tech, ok := fingerprint.LookupFaviconTech(int32(h))
if !ok {
t.Errorf("hash %d is absent from the canonical table; demo references a hash the scanner does not know", h)
continue
}
if !strings.Contains(strings.ToLower(tech), service) {
t.Errorf("hash %d maps to %q, but the file names service %q", h, tech, service)
}
}
})
}
}

func TestValidateMatchers(t *testing.T) {
tests := []struct {
name string
Expand Down
19 changes: 2 additions & 17 deletions internal/scan/favicon.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,22 +48,6 @@ var faviconLinkRegex = regexp.MustCompile(`(?i)<link[^>]+rel=["'][^"']*icon[^"']
// faviconHrefRegex extracts the href attribute value from a matched link tag.
var faviconHrefRegex = regexp.MustCompile(`(?i)href=["']([^"']+)["']`)

// faviconHashes maps a known shodan favicon hash to the tech that ships it.
// these are stable default icons for panels/frameworks/c2; a hit is a strong
// fingerprint. kept small on purpose - high-signal defaults, not an exhaustive db.
var faviconHashes = map[int32]string{
116323821: "Apache Tomcat",
81586312: "Spring Boot (default whitelabel)",
-235701012: "Jenkins",
-1255347784: "GitLab",
1278322581: "Grafana",
743365239: "Kibana",
-1462443472: "phpMyAdmin",
999357577: "Cobalt Strike (default beacon)",
-1521704893: "Metasploit",
-1893514588: "Gitea",
}

// 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) {
Expand All @@ -90,10 +74,11 @@ func Favicon(targetURL string, timeout time.Duration, logdir string) (*FaviconRe
}

hash := fingerprint.FaviconHash(data)
tech, _ := fingerprint.LookupFaviconTech(hash)
result := &FaviconResult{
FaviconURL: iconURL,
Hash: hash,
Tech: faviconHashes[hash],
Tech: tech,
ShodanQ: fmt.Sprintf("http.favicon.hash:%d", hash),
}

Expand Down
68 changes: 0 additions & 68 deletions internal/scan/favicon_demo_sync_test.go

This file was deleted.

7 changes: 7 additions & 0 deletions internal/scan/favicon_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import (
"strings"
"testing"
"time"

"github.com/vmfunc/sif/internal/fingerprint"
)

// goldenFaviconBytes is a fixed payload long enough to span multiple base64
Expand Down Expand Up @@ -61,6 +63,11 @@ func TestFavicon_FetchAndHash(t *testing.T) {
if result.ShodanQ != wantQ {
t.Errorf("ShodanQ = %q, want %q", result.ShodanQ, wantQ)
}

wantTech, _ := fingerprint.LookupFaviconTech(fingerprint.FaviconHash(goldenFaviconBytes))
if result.Tech != wantTech {
t.Errorf("Tech = %q, want %q", result.Tech, wantTech)
}
}

// TestFavicon_LinkFallback covers the <link rel=icon> path when /favicon.ico is
Expand Down
Loading