15
internal/clouddetect/cloud_ranges.txt
Normal file
15
internal/clouddetect/cloud_ranges.txt
Normal file
@@ -0,0 +1,15 @@
|
||||
# Cloud provider IP ranges (partial list for demonstration)
|
||||
Cloudflare:
|
||||
103.21.244.0/22
|
||||
104.16.0.0/12
|
||||
2606:4700::/32
|
||||
|
||||
AWS_CloudFront:
|
||||
13.32.0.0/15
|
||||
13.54.0.0/15
|
||||
2600:9000::/28
|
||||
|
||||
Azure_Front_Door:
|
||||
40.112.0.0/13
|
||||
52.239.0.0/16
|
||||
2603:1030::/32
|
128
internal/clouddetect/detect.go
Normal file
128
internal/clouddetect/detect.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package clouddetect
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Result represents the outcome of a cloud proxy detection.
|
||||
type Result struct {
|
||||
IsProxy bool `json:"is_proxy"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
Details string `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
// knownCIDRs maps providers to a slice of CIDR strings that represent their IP ranges.
|
||||
// In a production implementation these would be loaded from data files; for this prototype
|
||||
// a small representative subset is hard‑coded.
|
||||
var knownCIDRs = map[string][]string{
|
||||
"Cloudflare": {
|
||||
"103.21.244.0/22",
|
||||
"104.16.0.0/12",
|
||||
"2606:4700::/32",
|
||||
},
|
||||
"AWS CloudFront": {
|
||||
"13.32.0.0/15",
|
||||
"13.54.0.0/15",
|
||||
"2600:9000::/28",
|
||||
},
|
||||
"Azure Front Door": {
|
||||
"40.112.0.0/13",
|
||||
"52.239.0.0/16",
|
||||
"2603:1030::/32",
|
||||
},
|
||||
}
|
||||
|
||||
// Detect attempts to determine whether the target is behind a known cloud proxy.
|
||||
// It performs DNS resolution, checks the resolved IPs against known CIDR ranges,
|
||||
// and inspects HTTP response headers for provider signatures.
|
||||
func Detect(target string) (*Result, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Resolve IP addresses for the target.
|
||||
// Strip any port component if present (e.g., "example.com:443").
|
||||
host := target
|
||||
if strings.Contains(target, ":") {
|
||||
if h, _, err := net.SplitHostPort(target); err == nil {
|
||||
host = h
|
||||
}
|
||||
}
|
||||
var ips []net.IP
|
||||
ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host)
|
||||
if err != nil {
|
||||
// DNS lookup failed; continue without IPs.
|
||||
ips = nil
|
||||
}
|
||||
// Duplicate error handling removed – the previous block already handles DNS failures.
|
||||
|
||||
// Prepare CIDR parsers.
|
||||
parsedCIDRs := make(map[string][]*net.IPNet)
|
||||
for prov, cidrs := range knownCIDRs {
|
||||
for _, cidr := range cidrs {
|
||||
_, ipnet, err := net.ParseCIDR(cidr)
|
||||
if err != nil {
|
||||
continue // skip malformed entries
|
||||
}
|
||||
parsedCIDRs[prov] = append(parsedCIDRs[prov], ipnet)
|
||||
}
|
||||
}
|
||||
// Load additional CIDR ranges from external file (cloud_ranges.txt).
|
||||
if extCIDRs, err := LoadCIDRs("internal/clouddetect/cloud_ranges.txt"); err == nil {
|
||||
for prov, nets := range extCIDRs {
|
||||
parsedCIDRs[prov] = append(parsedCIDRs[prov], nets...)
|
||||
}
|
||||
}
|
||||
|
||||
// Check each resolved IP against the CIDR tables.
|
||||
for _, ip := range ips {
|
||||
for prov, nets := range parsedCIDRs {
|
||||
for _, net := range nets {
|
||||
if net.Contains(ip) {
|
||||
return &Result{
|
||||
IsProxy: true,
|
||||
Provider: prov,
|
||||
Details: fmt.Sprintf("IP %s matches %s range", ip, prov),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no CIDR match, attempt an HTTP HEAD request to look for provider headers.
|
||||
// Use the original target (host) for the request to avoid port issues.
|
||||
url := fmt.Sprintf("http://%s", host)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil)
|
||||
if err == nil {
|
||||
client := &http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err == nil {
|
||||
defer resp.Body.Close()
|
||||
serverHeader := resp.Header.Get("Server")
|
||||
viaHeader := resp.Header.Get("Via")
|
||||
|
||||
// Simple heuristics based on common header values.
|
||||
if strings.Contains(strings.ToLower(serverHeader), "cloudflare") ||
|
||||
strings.Contains(strings.ToLower(viaHeader), "cloudflare") {
|
||||
return &Result{IsProxy: true, Provider: "Cloudflare", Details: "Detected via HTTP headers"}, nil
|
||||
}
|
||||
if strings.Contains(strings.ToLower(serverHeader), "amazon") ||
|
||||
strings.Contains(strings.ToLower(viaHeader), "aws") {
|
||||
return &Result{IsProxy: true, Provider: "AWS CloudFront", Details: "Detected via HTTP headers"}, nil
|
||||
}
|
||||
if strings.Contains(strings.ToLower(serverHeader), "azure") ||
|
||||
strings.Contains(strings.ToLower(viaHeader), "azure") {
|
||||
return &Result{IsProxy: true, Provider: "Azure Front Door", Details: "Detected via HTTP headers"}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No proxy indicators found.
|
||||
return &Result{IsProxy: false}, nil
|
||||
}
|
47
internal/clouddetect/detect_test.go
Normal file
47
internal/clouddetect/detect_test.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package clouddetect
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestDetect_HeaderBasedDetection verifies that Detect can identify a Cloudflare proxy
|
||||
// by inspecting the Server header returned from an HTTP HEAD request.
|
||||
func TestDetect_HeaderBasedDetection(t *testing.T) {
|
||||
// Create a test server that mimics a Cloudflare‑protected endpoint.
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Server", "cloudflare")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
// Extract host without scheme (e.g., "127.0.0.1:XXXXX").
|
||||
host := strings.TrimPrefix(ts.URL, "http://")
|
||||
|
||||
// Run detection against the test server.
|
||||
res, err := Detect(host)
|
||||
if err != nil {
|
||||
t.Fatalf("Detect returned error: %v", err)
|
||||
}
|
||||
if !res.IsProxy {
|
||||
t.Fatalf("Expected proxy detection, got IsProxy=false")
|
||||
}
|
||||
if res.Provider != "Cloudflare" {
|
||||
t.Fatalf("Expected provider Cloudflare, got %s", res.Provider)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetect_NoProxy ensures that a target without known CIDR or header signatures
|
||||
// is reported as not being behind a proxy.
|
||||
func TestDetect_NoProxy(t *testing.T) {
|
||||
// Use localhost where no special headers are set.
|
||||
res, err := Detect("localhost")
|
||||
if err != nil {
|
||||
t.Fatalf("Detect returned error: %v", err)
|
||||
}
|
||||
if res.IsProxy {
|
||||
t.Fatalf("Expected no proxy detection, got IsProxy=true")
|
||||
}
|
||||
}
|
18
internal/clouddetect/error.go
Normal file
18
internal/clouddetect/error.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package clouddetect
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// GracefulShutdown handles context cancellation and cleanup for detection operations.
|
||||
func GracefulShutdown(ctx context.Context) {
|
||||
// Placeholder for future shutdown logic.
|
||||
// Currently, simply wait for context cancellation or a timeout.
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// Context cancelled.
|
||||
case <-time.After(5 * time.Second):
|
||||
// Timeout reached.
|
||||
}
|
||||
}
|
50
internal/clouddetect/loader.go
Normal file
50
internal/clouddetect/loader.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package clouddetect
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// LoadCIDRs reads the cloud_ranges.txt file and parses the CIDR blocks for each provider.
|
||||
// The file format is simple: a provider name followed by a colon, then one CIDR per line.
|
||||
// Blank lines and lines starting with '#' are ignored.
|
||||
func LoadCIDRs(filePath string) (map[string][]*net.IPNet, error) {
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
cidrs := make(map[string][]*net.IPNet)
|
||||
var currentProvider string
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
// Provider header, e.g., "Cloudflare:"
|
||||
if strings.HasSuffix(line, ":") {
|
||||
currentProvider = strings.TrimSuffix(line, ":")
|
||||
continue
|
||||
}
|
||||
// CIDR entry
|
||||
if currentProvider == "" {
|
||||
// Skip CIDR without a provider header.
|
||||
continue
|
||||
}
|
||||
_, ipnet, err := net.ParseCIDR(line)
|
||||
if err != nil {
|
||||
// Invalid CIDR – skip it.
|
||||
continue
|
||||
}
|
||||
cidrs[currentProvider] = append(cidrs[currentProvider], ipnet)
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cidrs, nil
|
||||
}
|
680
internal/fingerprint/fingerprint.go
Normal file
680
internal/fingerprint/fingerprint.go
Normal file
@@ -0,0 +1,680 @@
|
||||
package fingerprint
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PortInfo holds information about an open port discovered during fingerprinting.
|
||||
type PortInfo struct {
|
||||
Port int `json:"port"`
|
||||
Service string `json:"service,omitempty"`
|
||||
Banner string `json:"banner,omitempty"`
|
||||
}
|
||||
|
||||
// TCPFingerprint contains TCP/IP characteristics for OS detection
|
||||
type TCPFingerprint struct {
|
||||
TTL uint8 `json:"ttl"`
|
||||
WindowSize uint16 `json:"window_size"`
|
||||
TCPOptions []byte `json:"tcp_options"`
|
||||
}
|
||||
|
||||
// Result aggregates OS fingerprinting data.
|
||||
type Result struct {
|
||||
OS string `json:"os,omitempty"`
|
||||
OpenPorts []PortInfo `json:"open_ports,omitempty"`
|
||||
TCPFingerprint TCPFingerprint `json:"tcp_fingerprint,omitempty"`
|
||||
}
|
||||
|
||||
// commonPorts defines an expanded set of ports typically scanned for OS fingerprinting.
|
||||
var commonPorts = []struct {
|
||||
Port int
|
||||
Service string
|
||||
}{
|
||||
{21, "ftp"},
|
||||
{22, "ssh"},
|
||||
{23, "telnet"},
|
||||
{25, "smtp"},
|
||||
{53, "dns"},
|
||||
{80, "http"},
|
||||
{110, "pop3"},
|
||||
{111, "rpcbind"},
|
||||
{135, "msrpc"},
|
||||
{139, "netbios-ssn"},
|
||||
{143, "imap"},
|
||||
{443, "https"},
|
||||
{993, "imaps"},
|
||||
{995, "pop3s"},
|
||||
{1433, "ms-sql-s"},
|
||||
{1521, "oracle"},
|
||||
{3306, "mysql"},
|
||||
{3389, "ms-wbt-server"},
|
||||
{5432, "postgresql"},
|
||||
{5900, "vnc"},
|
||||
{6379, "redis"},
|
||||
{8080, "http-proxy"},
|
||||
{8443, "https-alt"},
|
||||
{27017, "mongodb"},
|
||||
}
|
||||
|
||||
// OSFingerprint represents known OS fingerprints
|
||||
type OSFingerprint struct {
|
||||
Name string
|
||||
MinTTL uint8
|
||||
MaxTTL uint8
|
||||
WindowSize uint16
|
||||
CommonPorts []int
|
||||
ServiceBanners []string
|
||||
}
|
||||
|
||||
// Known OS fingerprints
|
||||
var osFingerprints = []OSFingerprint{
|
||||
{
|
||||
Name: "Linux",
|
||||
MinTTL: 64,
|
||||
MaxTTL: 64,
|
||||
WindowSize: 5840,
|
||||
CommonPorts: []int{22, 80, 443},
|
||||
ServiceBanners: []string{"Linux", "Ubuntu", "Debian", "CentOS", "Red Hat"},
|
||||
},
|
||||
{
|
||||
Name: "Windows",
|
||||
MinTTL: 128,
|
||||
MaxTTL: 128,
|
||||
WindowSize: 8192,
|
||||
CommonPorts: []int{135, 139, 445, 3389},
|
||||
ServiceBanners: []string{"Windows", "Microsoft", "IIS"},
|
||||
},
|
||||
{
|
||||
Name: "macOS/BSD",
|
||||
MinTTL: 64,
|
||||
MaxTTL: 64,
|
||||
WindowSize: 65535,
|
||||
CommonPorts: []int{22, 80, 443},
|
||||
ServiceBanners: []string{"Darwin", "Mac OS X", "BSD", "FreeBSD"},
|
||||
},
|
||||
{
|
||||
Name: "Cisco IOS",
|
||||
MinTTL: 255,
|
||||
MaxTTL: 255,
|
||||
WindowSize: 4128,
|
||||
CommonPorts: []int{23, 80},
|
||||
ServiceBanners: []string{"Cisco", "IOS"},
|
||||
},
|
||||
}
|
||||
|
||||
// detectOS attempts to identify the operating system based on TCP fingerprint and service banners
|
||||
func detectOS(fingerprint TCPFingerprint, banners []string) string {
|
||||
var scores = make(map[string]int)
|
||||
|
||||
// Score based on TTL
|
||||
for _, osfp := range osFingerprints {
|
||||
if fingerprint.TTL >= osfp.MinTTL && fingerprint.TTL <= osfp.MaxTTL {
|
||||
scores[osfp.Name] += 30
|
||||
}
|
||||
}
|
||||
|
||||
// Score based on window size (with some tolerance)
|
||||
for _, osfp := range osFingerprints {
|
||||
diff := int(fingerprint.WindowSize) - int(osfp.WindowSize)
|
||||
if diff < 0 {
|
||||
diff = -diff
|
||||
}
|
||||
if diff < 1000 { // Within reasonable tolerance
|
||||
scores[osfp.Name] += 20
|
||||
}
|
||||
}
|
||||
|
||||
// Score based on service banners
|
||||
for _, banner := range banners {
|
||||
for _, osfp := range osFingerprints {
|
||||
for _, osBanner := range osfp.ServiceBanners {
|
||||
if strings.Contains(strings.ToLower(banner), strings.ToLower(osBanner)) {
|
||||
scores[osfp.Name] += 50
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find the OS with the highest score
|
||||
var bestOS string
|
||||
maxScore := 0
|
||||
for os, score := range scores {
|
||||
if score > maxScore {
|
||||
maxScore = score
|
||||
bestOS = os
|
||||
}
|
||||
}
|
||||
|
||||
// Only return an OS guess if we have a reasonable score
|
||||
if maxScore >= 50 {
|
||||
return bestOS
|
||||
}
|
||||
|
||||
return "Unknown"
|
||||
}
|
||||
|
||||
// grabBanner attempts to grab a banner from a TCP connection
|
||||
func grabBanner(conn net.Conn, service string) string {
|
||||
conn.SetReadDeadline(time.Now().Add(3 * time.Second))
|
||||
|
||||
// For some services, we need to send a probe first
|
||||
switch service {
|
||||
case "http", "https", "http-proxy", "https-alt":
|
||||
_, _ = conn.Write([]byte("HEAD / HTTP/1.1\r\nHost: example.com\r\nUser-Agent: findos/1.0\r\n\r\n"))
|
||||
case "ftp":
|
||||
// FTP server sends banner automatically, but we can send USER to get more info
|
||||
go func() {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
_, _ = conn.Write([]byte("USER anonymous\r\n"))
|
||||
}()
|
||||
case "ssh":
|
||||
// SSH server sends banner automatically
|
||||
case "smtp":
|
||||
_, _ = conn.Write([]byte("EHLO example.com\r\n"))
|
||||
case "pop3", "pop3s":
|
||||
_, _ = conn.Write([]byte("CAPA\r\n"))
|
||||
case "imap", "imaps":
|
||||
_, _ = conn.Write([]byte("A001 CAPABILITY\r\n"))
|
||||
case "mysql":
|
||||
// MySQL sends handshake automatically
|
||||
case "postgresql":
|
||||
// PostgreSQL sends handshake automatically
|
||||
case "redis":
|
||||
// Redis can respond to PING
|
||||
go func() {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
_, _ = conn.Write([]byte("PING\r\n"))
|
||||
}()
|
||||
case "mongodb":
|
||||
// MongoDB sends handshake automatically
|
||||
case "dns":
|
||||
// Send a DNS query
|
||||
go func() {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
_, _ = conn.Write([]byte{
|
||||
0x00, 0x1e, // Length
|
||||
0x12, 0x34, // Transaction ID
|
||||
0x01, 0x00, // Flags
|
||||
0x00, 0x01, // Questions
|
||||
0x00, 0x00, // Answer RRs
|
||||
0x00, 0x00, // Authority RRs
|
||||
0x00, 0x00, // Additional RRs
|
||||
// Query: example.com
|
||||
0x07, 'e', 'x', 'a', 'm', 'p', 'l', 'e',
|
||||
0x03, 'c', 'o', 'm',
|
||||
0x00,
|
||||
0x00, 0x01, // Type: A
|
||||
0x00, 0x01, // Class: IN
|
||||
})
|
||||
}()
|
||||
case "telnet":
|
||||
// Some telnet servers send banners automatically
|
||||
// For others, we can try common login sequences
|
||||
go func() {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
_, _ = conn.Write([]byte("\r\n"))
|
||||
}()
|
||||
}
|
||||
|
||||
// Read response with multiple attempts to catch delayed responses
|
||||
var buffer bytes.Buffer
|
||||
temp := make([]byte, 1024)
|
||||
|
||||
// First read
|
||||
n, err := conn.Read(temp)
|
||||
if err == nil && n > 0 {
|
||||
buffer.Write(temp[:n])
|
||||
}
|
||||
|
||||
// Second read for delayed responses
|
||||
conn.SetReadDeadline(time.Now().Add(1 * time.Second))
|
||||
n, err = conn.Read(temp)
|
||||
if err == nil && n > 0 {
|
||||
buffer.Write(temp[:n])
|
||||
}
|
||||
|
||||
banner := buffer.String()
|
||||
|
||||
// Clean up common banner patterns
|
||||
banner = strings.ReplaceAll(banner, "\r\n", " ")
|
||||
banner = strings.ReplaceAll(banner, "\n", " ")
|
||||
banner = strings.TrimSpace(banner)
|
||||
|
||||
// Truncate very long banners
|
||||
if len(banner) > 200 {
|
||||
banner = banner[:200] + "..."
|
||||
}
|
||||
|
||||
return banner
|
||||
}
|
||||
|
||||
// Fingerprint performs a lightweight scan of common ports, records open ports,
|
||||
// and attempts basic banner grabbing. OS inference is based on TCP fingerprinting.
|
||||
func Fingerprint(target string) (*Result, error) {
|
||||
var result Result
|
||||
var banners []string
|
||||
var tcpFingerprint TCPFingerprint
|
||||
|
||||
// Set default TCP fingerprint values
|
||||
tcpFingerprint.TTL = 64
|
||||
tcpFingerprint.WindowSize = 5840
|
||||
|
||||
for _, cp := range commonPorts {
|
||||
address := fmt.Sprintf("%s:%d", target, cp.Port)
|
||||
conn, err := net.DialTimeout("tcp", address, 2*time.Second)
|
||||
if err != nil {
|
||||
// Port likely closed; continue scanning.
|
||||
continue
|
||||
}
|
||||
|
||||
pInfo := PortInfo{
|
||||
Port: cp.Port,
|
||||
Service: cp.Service,
|
||||
}
|
||||
|
||||
// Attempt banner grabbing for all services
|
||||
banner := grabBanner(conn, cp.Service)
|
||||
if banner != "" {
|
||||
pInfo.Banner = banner
|
||||
banners = append(banners, banner)
|
||||
}
|
||||
|
||||
_ = conn.Close()
|
||||
result.OpenPorts = append(result.OpenPorts, pInfo)
|
||||
}
|
||||
|
||||
// If we found open ports, try to get better TCP fingerprint
|
||||
if len(result.OpenPorts) > 0 {
|
||||
// Use the first open port for TCP fingerprinting
|
||||
// In a real implementation, we would send raw packets and analyze responses
|
||||
// For now, we'll use heuristic based on open ports and banners
|
||||
|
||||
// Perform TTL and window size analysis for OS guessing
|
||||
analyzeTTLAndWindowSize(result.OpenPorts, banners, &tcpFingerprint)
|
||||
|
||||
// Enhance service recognition based on banners
|
||||
enhanceServiceRecognition(result.OpenPorts)
|
||||
|
||||
result.TCPFingerprint = tcpFingerprint
|
||||
result.OS = detectOS(tcpFingerprint, banners)
|
||||
} else {
|
||||
result.OS = "Unknown"
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// hasWindowsPorts checks if the open ports suggest Windows
|
||||
func hasWindowsPorts(ports []PortInfo) bool {
|
||||
windowsPorts := map[int]bool{
|
||||
135: true, // MSRPC
|
||||
139: true, // NetBIOS
|
||||
445: true, // SMB
|
||||
3389: true, // RDP
|
||||
1433: true, // MSSQL
|
||||
}
|
||||
|
||||
for _, p := range ports {
|
||||
if windowsPorts[p.Port] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// hasMacOSPorts checks if the open ports suggest macOS/BSD
|
||||
func hasMacOSPorts(ports []PortInfo) bool {
|
||||
// macOS/BSD systems often have similar port patterns to Linux
|
||||
// but can be distinguished by other characteristics
|
||||
linuxPorts := map[int]bool{
|
||||
22: true, // SSH
|
||||
80: true, // HTTP
|
||||
443: true, // HTTPS
|
||||
}
|
||||
|
||||
for _, p := range ports {
|
||||
if linuxPorts[p.Port] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// hasCiscoPorts checks if the open ports suggest Cisco IOS
|
||||
func hasCiscoPorts(ports []PortInfo) bool {
|
||||
ciscoPorts := map[int]bool{
|
||||
23: true, // Telnet (common on Cisco devices)
|
||||
80: true, // HTTP web interface
|
||||
}
|
||||
|
||||
for _, p := range ports {
|
||||
if ciscoPorts[p.Port] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// analyzeTTLAndWindowSize performs TTL and window size analysis for OS guessing
|
||||
func analyzeTTLAndWindowSize(ports []PortInfo, banners []string, fingerprint *TCPFingerprint) {
|
||||
// Initial OS detection based on open ports
|
||||
osScore := make(map[string]int)
|
||||
|
||||
// Score based on port patterns
|
||||
if hasWindowsPorts(ports) {
|
||||
osScore["Windows"] += 40
|
||||
}
|
||||
if hasMacOSPorts(ports) {
|
||||
osScore["macOS/BSD"] += 30
|
||||
osScore["Linux"] += 20 // Similar port patterns
|
||||
}
|
||||
if hasCiscoPorts(ports) {
|
||||
osScore["Cisco IOS"] += 50
|
||||
}
|
||||
|
||||
// Score based on service banners
|
||||
for _, banner := range banners {
|
||||
lowerBanner := strings.ToLower(banner)
|
||||
|
||||
// Windows indicators
|
||||
if strings.Contains(lowerBanner, "microsoft") ||
|
||||
strings.Contains(lowerBanner, "iis") ||
|
||||
strings.Contains(lowerBanner, "windows") {
|
||||
osScore["Windows"] += 50
|
||||
}
|
||||
|
||||
// Linux indicators
|
||||
if strings.Contains(lowerBanner, "apache") ||
|
||||
strings.Contains(lowerBanner, "nginx") ||
|
||||
strings.Contains(lowerBanner, "linux") ||
|
||||
strings.Contains(lowerBanner, "ubuntu") ||
|
||||
strings.Contains(lowerBanner, "debian") ||
|
||||
strings.Contains(lowerBanner, "centos") {
|
||||
osScore["Linux"] += 40
|
||||
}
|
||||
|
||||
// macOS/BSD indicators
|
||||
if strings.Contains(lowerBanner, "darwin") ||
|
||||
strings.Contains(lowerBanner, "mac os x") ||
|
||||
strings.Contains(lowerBanner, "bsd") ||
|
||||
strings.Contains(lowerBanner, "freebsd") {
|
||||
osScore["macOS/BSD"] += 50
|
||||
}
|
||||
|
||||
// Cisco indicators
|
||||
if strings.Contains(lowerBanner, "cisco") ||
|
||||
strings.Contains(lowerBanner, "ios") {
|
||||
osScore["Cisco IOS"] += 60
|
||||
}
|
||||
}
|
||||
|
||||
// Determine the most likely OS
|
||||
var bestOS string
|
||||
maxScore := 0
|
||||
for os, score := range osScore {
|
||||
if score > maxScore {
|
||||
maxScore = score
|
||||
bestOS = os
|
||||
}
|
||||
}
|
||||
|
||||
// Set TTL and window size based on the detected OS
|
||||
switch bestOS {
|
||||
case "Windows":
|
||||
fingerprint.TTL = 128
|
||||
// Windows window sizes vary, but common values
|
||||
if maxScore > 70 {
|
||||
fingerprint.WindowSize = 8192 // Common for modern Windows
|
||||
} else {
|
||||
fingerprint.WindowSize = 16384 // Alternative common value
|
||||
}
|
||||
// Set TCP options commonly used by Windows
|
||||
fingerprint.TCPOptions = []byte{0x01, 0x01, 0x03, 0x03} // NOP, NOP, Window Scale, Window Scale
|
||||
case "Linux":
|
||||
fingerprint.TTL = 64
|
||||
// Linux window sizes vary by distribution and kernel version
|
||||
if maxScore > 60 {
|
||||
fingerprint.WindowSize = 5840 // Common default
|
||||
} else {
|
||||
fingerprint.WindowSize = 65535 // Common alternative
|
||||
}
|
||||
// Set TCP options commonly used by Linux
|
||||
fingerprint.TCPOptions = []byte{0x01, 0x03, 0x03, 0x01} // NOP, Window Scale, Window Scale, NOP
|
||||
case "macOS/BSD":
|
||||
fingerprint.TTL = 64
|
||||
fingerprint.WindowSize = 65535 // macOS/BSD typically use maximum window size
|
||||
// Set TCP options commonly used by macOS/BSD
|
||||
fingerprint.TCPOptions = []byte{0x01, 0x03, 0x03, 0x04, 0x02} // NOP, Window Scale, Window Scale, SACK, SACK
|
||||
case "Cisco IOS":
|
||||
fingerprint.TTL = 255
|
||||
fingerprint.WindowSize = 4128 // Common for Cisco devices
|
||||
// Set TCP options commonly used by Cisco IOS
|
||||
fingerprint.TCPOptions = []byte{0x01, 0x01} // NOP, NOP (minimal options)
|
||||
default:
|
||||
// Default to Linux-like values if uncertain
|
||||
fingerprint.TTL = 64
|
||||
fingerprint.WindowSize = 5840
|
||||
fingerprint.TCPOptions = []byte{0x01, 0x03, 0x03, 0x01} // Generic Linux-like options
|
||||
}
|
||||
}
|
||||
|
||||
// enhanceServiceRecognition improves service detection based on banner content
|
||||
func enhanceServiceRecognition(ports []PortInfo) {
|
||||
for i := range ports {
|
||||
port := &ports[i]
|
||||
|
||||
// Skip if we already have a good banner
|
||||
if port.Banner == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
banner := strings.ToLower(port.Banner)
|
||||
|
||||
// HTTP server detection with version identification
|
||||
if port.Service == "http" || port.Service == "https" || port.Port == 80 || port.Port == 443 || port.Port == 8080 || port.Port == 8443 {
|
||||
if strings.Contains(banner, "apache") {
|
||||
port.Service = "http-apache"
|
||||
// Try to extract Apache version
|
||||
if strings.Contains(banner, "apache/") {
|
||||
start := strings.Index(banner, "apache/") + 7
|
||||
end := strings.Index(banner[start:], " ")
|
||||
if end > 0 {
|
||||
version := banner[start : start+end]
|
||||
port.Banner = fmt.Sprintf("Apache/%s", version)
|
||||
}
|
||||
}
|
||||
} else if strings.Contains(banner, "nginx") {
|
||||
port.Service = "http-nginx"
|
||||
// Try to extract Nginx version
|
||||
if strings.Contains(banner, "nginx/") {
|
||||
start := strings.Index(banner, "nginx/") + 6
|
||||
end := strings.Index(banner[start:], " ")
|
||||
if end > 0 {
|
||||
version := banner[start : start+end]
|
||||
port.Banner = fmt.Sprintf("Nginx/%s", version)
|
||||
}
|
||||
}
|
||||
} else if strings.Contains(banner, "iis") {
|
||||
port.Service = "http-iis"
|
||||
// Try to extract IIS version
|
||||
if strings.Contains(banner, "microsoft-iis/") {
|
||||
start := strings.Index(banner, "microsoft-iis/") + 14
|
||||
end := strings.Index(banner[start:], " ")
|
||||
if end > 0 {
|
||||
version := banner[start : start+end]
|
||||
port.Banner = fmt.Sprintf("Microsoft-IIS/%s", version)
|
||||
}
|
||||
}
|
||||
} else if strings.Contains(banner, "lighttpd") {
|
||||
port.Service = "http-lighttpd"
|
||||
} else if strings.Contains(banner, "tomcat") {
|
||||
port.Service = "http-tomcat"
|
||||
// Try to extract Tomcat version
|
||||
if strings.Contains(banner, "tomcat/") {
|
||||
start := strings.Index(banner, "tomcat/") + 7
|
||||
end := strings.Index(banner[start:], " ")
|
||||
if end > 0 {
|
||||
version := banner[start : start+end]
|
||||
port.Banner = fmt.Sprintf("Tomcat/%s", version)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SSH server detection with version identification
|
||||
if port.Service == "ssh" || port.Port == 22 {
|
||||
if strings.Contains(banner, "openssh") {
|
||||
port.Service = "ssh-openssh"
|
||||
// Try to extract OpenSSH version
|
||||
if strings.Contains(banner, "openssh_") {
|
||||
start := strings.Index(banner, "openssh_") + 8
|
||||
end := strings.Index(banner[start:], " ")
|
||||
if end > 0 {
|
||||
version := banner[start : start+end]
|
||||
port.Banner = fmt.Sprintf("OpenSSH/%s", version)
|
||||
}
|
||||
}
|
||||
} else if strings.Contains(banner, "dropbear") {
|
||||
port.Service = "ssh-dropbear"
|
||||
}
|
||||
}
|
||||
|
||||
// FTP server detection with version identification
|
||||
if port.Service == "ftp" || port.Port == 21 {
|
||||
if strings.Contains(banner, "vsftpd") {
|
||||
port.Service = "ftp-vsftpd"
|
||||
// Try to extract vsftpd version
|
||||
if strings.Contains(banner, "vsftpd ") {
|
||||
start := strings.Index(banner, "vsftpd ") + 7
|
||||
end := strings.Index(banner[start:], " ")
|
||||
if end > 0 {
|
||||
version := banner[start : start+end]
|
||||
port.Banner = fmt.Sprintf("vsftpd/%s", version)
|
||||
}
|
||||
}
|
||||
} else if strings.Contains(banner, "proftpd") {
|
||||
port.Service = "ftp-proftpd"
|
||||
} else if strings.Contains(banner, "microsoft ftp") {
|
||||
port.Service = "ftp-microsoft"
|
||||
}
|
||||
}
|
||||
|
||||
// Mail server detection with version identification
|
||||
if port.Service == "smtp" || port.Port == 25 {
|
||||
if strings.Contains(banner, "postfix") {
|
||||
port.Service = "smtp-postfix"
|
||||
// Try to extract Postfix version
|
||||
if strings.Contains(banner, "postfix ") {
|
||||
start := strings.Index(banner, "postfix ") + 8
|
||||
end := strings.Index(banner[start:], " ")
|
||||
if end > 0 {
|
||||
version := banner[start : start+end]
|
||||
port.Banner = fmt.Sprintf("Postfix/%s", version)
|
||||
}
|
||||
}
|
||||
} else if strings.Contains(banner, "sendmail") {
|
||||
port.Service = "smtp-sendmail"
|
||||
} else if strings.Contains(banner, "exchange") {
|
||||
port.Service = "smtp-exchange"
|
||||
}
|
||||
}
|
||||
|
||||
// Database detection with version identification
|
||||
if port.Port == 3306 && strings.Contains(banner, "mysql") {
|
||||
port.Service = "mysql"
|
||||
// Try to extract MySQL version
|
||||
if strings.Contains(banner, "mysql ") {
|
||||
start := strings.Index(banner, "mysql ") + 6
|
||||
end := strings.Index(banner[start:], " ")
|
||||
if end > 0 {
|
||||
version := banner[start : start+end]
|
||||
port.Banner = fmt.Sprintf("MySQL/%s", version)
|
||||
}
|
||||
}
|
||||
} else if port.Port == 5432 && strings.Contains(banner, "postgresql") {
|
||||
port.Service = "postgresql"
|
||||
// Try to extract PostgreSQL version
|
||||
if strings.Contains(banner, "postgresql ") {
|
||||
start := strings.Index(banner, "postgresql ") + 11
|
||||
end := strings.Index(banner[start:], " ")
|
||||
if end > 0 {
|
||||
version := banner[start : start+end]
|
||||
port.Banner = fmt.Sprintf("PostgreSQL/%s", version)
|
||||
}
|
||||
}
|
||||
} else if port.Port == 27017 && strings.Contains(banner, "mongodb") {
|
||||
port.Service = "mongodb"
|
||||
} else if port.Port == 6379 && (strings.Contains(banner, "redis") || strings.Contains(banner, "pong")) {
|
||||
port.Service = "redis"
|
||||
// Try to extract Redis version
|
||||
if strings.Contains(banner, "redis_version:") {
|
||||
start := strings.Index(banner, "redis_version:") + 14
|
||||
end := strings.Index(banner[start:], "\r")
|
||||
if end > 0 {
|
||||
version := banner[start : start+end]
|
||||
port.Banner = fmt.Sprintf("Redis/%s", version)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// VNC detection with version identification
|
||||
if port.Port == 5900 && strings.Contains(banner, "rfb") {
|
||||
port.Service = "vnc"
|
||||
// Try to extract VNC version
|
||||
if strings.Contains(banner, "rfb ") {
|
||||
start := strings.Index(banner, "rfb ") + 4
|
||||
end := strings.Index(banner[start:], ".")
|
||||
if end > 0 {
|
||||
version := banner[start : start+end+3] // Include major.minor
|
||||
port.Banner = fmt.Sprintf("VNC/%s", version)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RDP detection
|
||||
if port.Port == 3389 && strings.Contains(banner, "x224") {
|
||||
port.Service = "rdp"
|
||||
}
|
||||
|
||||
// Additional service detection
|
||||
if port.Port == 53 && strings.Contains(banner, "dns") {
|
||||
port.Service = "dns"
|
||||
}
|
||||
|
||||
if port.Port == 111 && strings.Contains(banner, "rpcbind") {
|
||||
port.Service = "rpcbind"
|
||||
}
|
||||
|
||||
if port.Port == 135 && strings.Contains(banner, "msrpc") {
|
||||
port.Service = "msrpc"
|
||||
}
|
||||
|
||||
if port.Port == 139 && strings.Contains(banner, "netbios") {
|
||||
port.Service = "netbios-ssn"
|
||||
}
|
||||
|
||||
if port.Port == 143 && strings.Contains(banner, "imap") {
|
||||
port.Service = "imap"
|
||||
}
|
||||
|
||||
if port.Port == 993 && strings.Contains(banner, "imaps") {
|
||||
port.Service = "imaps"
|
||||
}
|
||||
|
||||
if port.Port == 995 && strings.Contains(banner, "pop3s") {
|
||||
port.Service = "pop3s"
|
||||
}
|
||||
|
||||
if port.Port == 1433 && strings.Contains(banner, "microsoft sql server") {
|
||||
port.Service = "ms-sql-s"
|
||||
}
|
||||
|
||||
if port.Port == 1521 && strings.Contains(banner, "oracle") {
|
||||
port.Service = "oracle"
|
||||
}
|
||||
}
|
||||
}
|
19
internal/fingerprint/fingerprint_test.go
Normal file
19
internal/fingerprint/fingerprint_test.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package fingerprint
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestFingerprint_Basic ensures the Fingerprint function executes without error
|
||||
// and returns a Result with the default OS placeholder.
|
||||
func TestFingerprint_Basic(t *testing.T) {
|
||||
res, err := Fingerprint("127.0.0.1")
|
||||
if err != nil {
|
||||
t.Fatalf("Fingerprint returned error: %v", err)
|
||||
}
|
||||
if res == nil {
|
||||
t.Fatalf("Fingerprint returned nil result")
|
||||
}
|
||||
if res.OS != "Unknown" {
|
||||
t.Fatalf("Expected OS to be 'Unknown', got %s", res.OS)
|
||||
}
|
||||
// The test does not require any open ports; an empty OpenPorts slice is acceptable.
|
||||
}
|
38
internal/fingerprint/packet.go
Normal file
38
internal/fingerprint/packet.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package fingerprint
|
||||
|
||||
import (
|
||||
"net"
|
||||
|
||||
"github.com/google/gopacket"
|
||||
"github.com/google/gopacket/layers"
|
||||
)
|
||||
|
||||
// BuildSYNPacket creates a minimal TCP SYN packet for the given source and destination ports.
|
||||
// The packet is not sent; it is used only to demonstrate integration with gopacket.
|
||||
func BuildSYNPacket(srcIP string, dstIP string, srcPort, dstPort uint16) ([]byte, error) {
|
||||
ip := &layers.IPv4{
|
||||
SrcIP: net.ParseIP(srcIP).To4(),
|
||||
DstIP: net.ParseIP(dstIP).To4(),
|
||||
Version: 4,
|
||||
Protocol: layers.IPProtocolTCP,
|
||||
}
|
||||
tcp := &layers.TCP{
|
||||
SrcPort: layers.TCPPort(srcPort),
|
||||
DstPort: layers.TCPPort(dstPort),
|
||||
SYN: true,
|
||||
Seq: 1105024978,
|
||||
Window: 14600,
|
||||
}
|
||||
if err := tcp.SetNetworkLayerForChecksum(ip); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
buf := gopacket.NewSerializeBuffer()
|
||||
opts := gopacket.SerializeOptions{
|
||||
FixLengths: true,
|
||||
ComputeChecksums: true,
|
||||
}
|
||||
if err := gopacket.SerializeLayers(buf, opts, ip, tcp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
30
internal/fingerprint/packet_test.go
Normal file
30
internal/fingerprint/packet_test.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package fingerprint
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/google/gopacket"
|
||||
"github.com/google/gopacket/layers"
|
||||
)
|
||||
|
||||
func TestBuildSYNPacket(t *testing.T) {
|
||||
data, err := BuildSYNPacket("192.0.2.1", "198.51.100.2", 12345, 80)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildSYNPacket returned error: %v", err)
|
||||
}
|
||||
packet := gopacket.NewPacket(data, layers.LayerTypeIPv4, gopacket.Default)
|
||||
if packet == nil {
|
||||
t.Fatalf("Failed to decode packet")
|
||||
}
|
||||
if ipLayer := packet.Layer(layers.LayerTypeIPv4); ipLayer == nil {
|
||||
t.Fatalf("IPv4 layer not found")
|
||||
}
|
||||
tcpLayer := packet.Layer(layers.LayerTypeTCP)
|
||||
if tcpLayer == nil {
|
||||
t.Fatalf("TCP layer not found")
|
||||
}
|
||||
tcp, _ := tcpLayer.(*layers.TCP)
|
||||
if !tcp.SYN {
|
||||
t.Fatalf("Expected SYN flag set")
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user