up
Some checks failed
Go CI / test (push) Has been cancelled

This commit is contained in:
Dev
2025-09-13 12:30:01 +03:00
commit 4d51c65060
14 changed files with 1227 additions and 0 deletions

26
.github/workflows/go-ci.yml vendored Normal file
View File

@@ -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 ./...

19
.gitignore vendored Normal file
View File

@@ -0,0 +1,19 @@
# Binaries
findos
*.exe
*.dll
*.so
*.dylib
# Build artifacts
bin/
build/
pkg/
# IDE files
.idea/
.vscode/
*.swp
# Dependency directories
vendor/

119
README.md Normal file
View File

@@ -0,0 +1,119 @@
# FindOS Professional Go Network Reconnaissance Tool
## Project Overview
FindOS is a Gobased 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 besteffort 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
# Humanreadable 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 (HumanReadable)
```
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 hardcoded 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 2second 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/yourfeature`).
- **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 uptodate.
- **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.

11
go.mod Normal file
View File

@@ -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

27
go.sum Normal file
View File

@@ -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=

View 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

View 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 hardcoded.
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
}

View 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 Cloudflareprotected 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")
}
}

View 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.
}
}

View 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
}

View 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"
}
}
}

View 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.
}

View 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
}

View 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")
}
}