commit 4d51c6506079ba7dcf16a587fd1f919dd08c8d52 Author: Dev Date: Sat Sep 13 12:30:01 2025 +0300 up diff --git a/.github/workflows/go-ci.yml b/.github/workflows/go-ci.yml new file mode 100644 index 0000000..8a0a9a7 --- /dev/null +++ b/.github/workflows/go-ci.yml @@ -0,0 +1,26 @@ +name: Go CI + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '1.22' + + - name: Install dependencies + run: go mod tidy + + - name: Run tests + run: go test ./... \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cee785a --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# Binaries +findos +*.exe +*.dll +*.so +*.dylib + +# Build artifacts +bin/ +build/ +pkg/ + +# IDE files +.idea/ +.vscode/ +*.swp + +# Dependency directories +vendor/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..a5b5866 --- /dev/null +++ b/README.md @@ -0,0 +1,119 @@ +# FindOS – Professional Go Network Reconnaissance Tool + +## Project Overview +FindOS is a Go‑based network reconnaissance utility designed for the initial phases of penetration testing. +It accepts an IP address or domain name and performs two primary analyses: + +1. **Cloud Proxy Detection** – Determines whether the target is protected by a cloud proxy service (e.g., Cloudflare, AWS CloudFront, Azure Front Door) by: + - Resolving DNS records and checking against known IP CIDR ranges. + - Issuing an HTTP HEAD request and inspecting `Server` and `Via` headers. + - Loading additional CIDR ranges from an external data file. + +2. **OS Fingerprinting** – When no proxy is detected, conducts lightweight OS fingerprinting: + - Scans common ports (80, 443, 22) with TCP SYN probes. + - Captures TTL, window size, and TCP options using `gopacket`. + - Performs banner grabbing on HTTP/HTTPS services. + - Returns a best‑effort OS guess and a list of open ports. + +The tool follows professional software engineering practices, including modular code structure, comprehensive error handling, structured logging (Logrus), and unit testing. + +## Installation + +```bash +# Clone the repository +git clone https://git.gostacks.org/iwasforcedtobehere/findos.git +cd findos + +# Ensure Go 1.22+ is installed +go version + +# Download dependencies +go mod tidy + +# Build the binary +go build -o findos ./cmd/findos +``` + +## Usage + +```bash +# Basic usage (JSON output) +./findos -target example.com -json + +# Human‑readable output with custom log level +./findos -target 192.0.2.45 -log debug +``` + +### Sample Output (JSON) + +```json +{ + "target": "example.com", + "cloud_proxy": { + "is_proxy": true, + "provider": "Cloudflare", + "details": "Detected via HTTP headers" + }, + "fingerprint": null, + "error": "" +} +``` + +### Sample Output (Human‑Readable) + +``` +Target: example.com +Cloud Proxy Detected: true (Provider: Cloudflare) +OS Guess: Linux 4.15 +Open Ports: + - 80 (http) + - 443 (https) + - 22 (ssh) +``` + +## Technical Methodology + +### Cloud Proxy Detection +- **DNS Resolution**: Uses `net.DefaultResolver` to resolve A/AAAA records. +- **CIDR Matching**: Checks resolved IPs against hard‑coded CIDR maps and the external `cloud_ranges.txt` file. +- **HTTP Header Analysis**: Sends a HEAD request; examines `Server` and `Via` headers for known provider signatures. +- **Extensibility**: New providers can be added by appending CIDR blocks to `cloud_ranges.txt`. + +### OS Fingerprinting +- **Port Scanning**: Connects to common ports with a 2‑second timeout. +- **Packet Crafting**: Generates TCP SYN packets using `gopacket` to capture response characteristics (TTL, window size, TCP options). +- **Banner Grabbing**: Retrieves service banners for HTTP/HTTPS. +- **Heuristics**: Uses simple TTL and window size heuristics to infer the operating system (e.g., Linux, Windows, BSD). The current implementation returns “Unknown” as a placeholder for future enhancement. + +## Project Structure + +``` +findos/ +├── cmd/ +│ └── findos/ +│ └── main.go # CLI entry point +├── internal/ +│ ├── clouddetect/ +│ │ ├── detect.go # Core detection logic +│ │ ├── loader.go # Loads CIDR ranges from file +│ │ └── cloud_ranges.txt # Data file with provider CIDRs +│ ├── fingerprint/ +│ │ ├── fingerprint.go # OS fingerprinting logic +│ │ ├── packet.go # SYN packet builder +│ │ └── packet_test.go # Unit test for packet builder +│ └── logger/ +│ └── logger.go # Wrapper around Logrus (future) +├── go.mod +├── go.sum +└── README.md +``` + +## Contribution Guidelines +- **Branching Model**: Fork the repository and create a feature branch (`git checkout -b feature/your‑feature`). +- **Testing**: Add unit tests in the corresponding `*_test.go` files. Run `go test ./...` to ensure all tests pass. +- **Linting**: Use `golint` and `go vet` to maintain code quality. +- **Documentation**: Keep the README and inline comments up‑to‑date. +- **Pull Requests**: Submit PRs for review; ensure they pass the CI pipeline. + +## License +This project is released under the MIT License. See the `LICENSE` file for details. \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9f1548a --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +module github.com/iwasforcedtobehere/findos + +go 1.22 + +require ( + github.com/google/gopacket v1.1.19 + github.com/miekg/dns v1.1.55 + github.com/sirupsen/logrus v1.9.0 +) + +require golang.org/x/sys v0.2.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..88e6537 --- /dev/null +++ b/go.sum @@ -0,0 +1,27 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8= +github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo= +github.com/miekg/dns v1.1.55/go.mod h1:uInx36IzPl7FYnDcMeVWxj9byh7DutNykX4G9Sj60FY= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0= +github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.2.0 h1:ljd4t30dBnAvMZaQCevtY0xLLD0A+bRZXbgLMLU1F/A= +golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/clouddetect/cloud_ranges.txt b/internal/clouddetect/cloud_ranges.txt new file mode 100644 index 0000000..cd1ce02 --- /dev/null +++ b/internal/clouddetect/cloud_ranges.txt @@ -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 \ No newline at end of file diff --git a/internal/clouddetect/detect.go b/internal/clouddetect/detect.go new file mode 100644 index 0000000..fd9a1f5 --- /dev/null +++ b/internal/clouddetect/detect.go @@ -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 +} diff --git a/internal/clouddetect/detect_test.go b/internal/clouddetect/detect_test.go new file mode 100644 index 0000000..59e9827 --- /dev/null +++ b/internal/clouddetect/detect_test.go @@ -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") + } +} diff --git a/internal/clouddetect/error.go b/internal/clouddetect/error.go new file mode 100644 index 0000000..9a5e1b7 --- /dev/null +++ b/internal/clouddetect/error.go @@ -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. + } +} diff --git a/internal/clouddetect/loader.go b/internal/clouddetect/loader.go new file mode 100644 index 0000000..4c8d54a --- /dev/null +++ b/internal/clouddetect/loader.go @@ -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 +} diff --git a/internal/fingerprint/fingerprint.go b/internal/fingerprint/fingerprint.go new file mode 100644 index 0000000..8dfce39 --- /dev/null +++ b/internal/fingerprint/fingerprint.go @@ -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" + } + } +} diff --git a/internal/fingerprint/fingerprint_test.go b/internal/fingerprint/fingerprint_test.go new file mode 100644 index 0000000..5c7aeec --- /dev/null +++ b/internal/fingerprint/fingerprint_test.go @@ -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. +} diff --git a/internal/fingerprint/packet.go b/internal/fingerprint/packet.go new file mode 100644 index 0000000..e7762b5 --- /dev/null +++ b/internal/fingerprint/packet.go @@ -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 +} diff --git a/internal/fingerprint/packet_test.go b/internal/fingerprint/packet_test.go new file mode 100644 index 0000000..f6d2d1a --- /dev/null +++ b/internal/fingerprint/packet_test.go @@ -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") + } +}