From c7bb4b7b282e7469139e10899fd805abcea3979d Mon Sep 17 00:00:00 2001 From: Ryan Smith Date: Wed, 16 Oct 2024 11:48:13 -0700 Subject: [PATCH] first commit --- .gitignore | 37 ++++ LICENSE | 21 +++ cmd/cti-honeypot/main.go | 120 +++++++++++++ go.mod | 7 + go.sum | 6 + internal/config/config.go | 136 +++++++++++++++ internal/config/helpers.go | 48 ++++++ internal/httpserver/httpserver.go | 277 ++++++++++++++++++++++++++++++ internal/sshserver/sshserver.go | 187 ++++++++++++++++++++ internal/tcpserver/tcpserver.go | 150 ++++++++++++++++ internal/threatfeed/database.go | 173 +++++++++++++++++++ internal/threatfeed/threatfeed.go | 87 ++++++++++ internal/udpserver/udpserver.go | 85 +++++++++ 13 files changed, 1334 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 cmd/cti-honeypot/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/config/config.go create mode 100644 internal/config/helpers.go create mode 100644 internal/httpserver/httpserver.go create mode 100644 internal/sshserver/sshserver.go create mode 100644 internal/tcpserver/tcpserver.go create mode 100644 internal/threatfeed/database.go create mode 100644 internal/threatfeed/threatfeed.go create mode 100644 internal/udpserver/udpserver.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3549f03 --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work +go.work.sum + +# env file +.env + +# Ignore linux binary, but allow folder of same name. +cti-honeypot +!cti-honeypot/ + +# Ignore user configuration and log files used by cti-honeypot. +cti-honeypot.xml +cti-honeypot-log.txt +cti-honeypot-feed.json +cti-honeypot-https.crt +cti-honeypot-https.key +cti-honeypot-ssh.key \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ae27491 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Ryan Smith + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/cmd/cti-honeypot/main.go b/cmd/cti-honeypot/main.go new file mode 100644 index 0000000..8cdbda7 --- /dev/null +++ b/cmd/cti-honeypot/main.go @@ -0,0 +1,120 @@ +package main + +import ( + "flag" + "log" + "log/slog" + "os" + "sync" + + "github.com/r-smith/cti-honeypot/internal/config" + "github.com/r-smith/cti-honeypot/internal/httpserver" + "github.com/r-smith/cti-honeypot/internal/sshserver" + "github.com/r-smith/cti-honeypot/internal/tcpserver" + "github.com/r-smith/cti-honeypot/internal/threatfeed" + "github.com/r-smith/cti-honeypot/internal/udpserver" +) + +func main() { + // Initialize config structs for parsing command-line flags. + cfg := config.Config{} + http := config.Server{Type: config.HTTP} + https := config.Server{Type: config.HTTPS} + ssh := config.Server{Type: config.SSH} + + // Parse command line flags. + configFile := flag.String("config", "", "Path to optional XML configuration file") + flag.BoolVar(&http.Enabled, "enable-http", config.DefaultEnableHTTP, "Enable HTTP server") + flag.BoolVar(&https.Enabled, "enable-https", config.DefaultEnableHTTPS, "Enable HTTPS server") + flag.BoolVar(&ssh.Enabled, "enable-ssh", config.DefaultEnableSSH, "Enable SSH server") + flag.BoolVar(&cfg.ThreatFeed.Enabled, "enable-threatfeed", config.DefaultEnableThreatFeed, "Enable threat feed server") + flag.StringVar(&cfg.LogPath, "log", config.DefaultLogPath, "Path to log file") + flag.StringVar(&cfg.ThreatFeed.DatabasePath, "threat-database", config.DefaultThreatDatabasePath, "Path to threat feed database file") + flag.UintVar(&cfg.ThreatFeed.ExpiryHours, "threat-expiry-hours", config.DefaultThreatExpiryHours, "Remove inactive IPs from threat feed after specified hours") + flag.BoolVar(&cfg.ThreatFeed.IsPrivateIncluded, "threat-include-private", config.DefaultThreatIncludePrivate, "Include private IPs in threat feed (default false)") + flag.StringVar(&http.HtmlPath, "html", config.DefaultHtmlPath, "Path to optional HTML file to serve") + flag.StringVar(&http.Port, "port-http", config.DefaultPortHTTP, "Port number to listen on for HTTP server") + flag.StringVar(&https.Port, "port-https", config.DefaultPortHTTPS, "Port number to listen on for HTTPS server") + flag.StringVar(&ssh.Port, "port-ssh", config.DefaultPortSSH, "Port number to listen on for SSH server") + flag.StringVar(&cfg.ThreatFeed.Port, "port-threatfeed", config.DefaultPortThreatFeed, "Port number to listen on for threat feed server") + flag.StringVar(&https.CertPath, "https-cert", config.DefaultCertPathHTTPS, "Path to optional TLS public certificate") + flag.StringVar(&https.KeyPath, "https-key", config.DefaultKeyPathHTTPS, "Path to optional TLS private key") + flag.StringVar(&ssh.KeyPath, "ssh-key", config.DefaultKeyPathSSH, "Path to optional SSH private key") + flag.Parse() + + // If the '-config' flag is provided, the specified configuration file is + // loaded. When a config file is used, all other command-line flags are + // ignored. The 'cfg' variable will contain all settings parsed from the + // configuration file. + if *configFile != "" { + cfgFromFile, err := config.Load(*configFile) + if err != nil { + log.Fatalln("Failed to load config:", err) + } + cfg = *cfgFromFile + } else { + https.HtmlPath = http.HtmlPath + cfg.Servers = append(cfg.Servers, http, https, ssh) + } + + // Initialize a structured logger to record events in a text file. This + // logger captures all interactions with the honeypot servers. + f, err := os.OpenFile(cfg.LogPath, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644) + if err != nil { + log.Fatalln("Failed to open log file:", err) + } + defer f.Close() + cfg.Logger = slog.New(slog.NewJSONHandler(f, &slog.HandlerOptions{ + ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr { + // Remove 'message' and 'log level' fields from output. + if a.Key == slog.MessageKey || a.Key == slog.LevelKey { + return slog.Attr{} + } + return a + }, + })) + + // Initialize a WaitGroup, as each server operates in its own goroutine. + // The WaitGroup counter is set to the number of configured honeypot + // servers, plus one additional count for the threat feed server. + var wg sync.WaitGroup + wg.Add(len(cfg.Servers) + 1) + + // Start the threat feed. + go func() { + defer wg.Done() + + if !cfg.ThreatFeed.Enabled { + return + } + + threatfeed.StartThreatFeed(&cfg.ThreatFeed) + }() + + // Start the honeypot servers. + for _, server := range cfg.Servers { + go func() { + defer wg.Done() + + if !server.Enabled || len(server.Port) == 0 { + return + } + + switch server.Type { + case config.HTTP: + httpserver.StartHTTP(&cfg, &server) + case config.HTTPS: + httpserver.StartHTTPS(&cfg, &server) + case config.SSH: + sshserver.StartSSH(&cfg, &server) + case config.TCP: + tcpserver.StartTCP(&cfg, &server) + case config.UDP: + udpserver.StartUDP(&cfg, &server) + } + }() + } + + // Wait for all servers to end. + wg.Wait() +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d455872 --- /dev/null +++ b/go.mod @@ -0,0 +1,7 @@ +module github.com/r-smith/cti-honeypot + +go 1.23 + +require golang.org/x/crypto v0.28.0 + +require golang.org/x/sys v0.26.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..8063669 --- /dev/null +++ b/go.sum @@ -0,0 +1,6 @@ +golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= +golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= +golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= +golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24= +golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..40f265e --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,136 @@ +package config + +import ( + "encoding/xml" + "fmt" + "io" + "log/slog" + "os" +) + +// This block of constants defines the application default settings. +const ( + DefaultEnableHTTP = true + DefaultEnableHTTPS = true + DefaultEnableSSH = true + DefaultEnableThreatFeed = true + DefaultPortHTTP = "8080" + DefaultPortHTTPS = "8443" + DefaultPortSSH = "2022" + DefaultPortThreatFeed = "8081" + DefaultThreatExpiryHours = 168 + DefaultThreatDatabasePath = "cti-honeypot-feed.json" + DefaultThreatIncludePrivate = false + DefaultLogPath = "cti-honeypot-log.txt" + DefaultHtmlPath = "" + DefaultCertPathHTTPS = "cti-honeypot-https.crt" + DefaultKeyPathHTTPS = "cti-honeypot-https.key" + DefaultKeyPathSSH = "cti-honeypot-ssh.key" + DefaultBannerSSH = "SSH-2.0-OpenSSH_9.3 FreeBSD-20230316" // SSH banner for FreeBSD 13.2 +) + +// ServerType represents the different types of honeypot servers that can be +// deployed. Each type has its own specific handlers and behavior. +type ServerType int + +const ( + HTTP ServerType = iota + HTTPS + SSH + TCP + UDP +) + +// String returns a string represenation of ServerType. +func (t ServerType) String() string { + return [...]string{"http", "https", "ssh", "tcp", "udp"}[t] +} + +// UnmarshalXMLAttr unmarshals the XML 'type' attribute from 'server' elements +// into a ServerType. +// +// Example XML snippet: +// true +func (t *ServerType) UnmarshalXMLAttr(attr xml.Attr) error { + switch attr.Value { + case "http": + *t = HTTP + case "https": + *t = HTTPS + case "ssh": + *t = SSH + case "tcp": + *t = TCP + case "udp": + *t = UDP + default: + return fmt.Errorf("invalid server type: %s", attr.Value) + } + return nil +} + +// Config holds the configuration settings for the application. It contains the +// logger, settings for managing a threat feed, and the collection of honeypot +// servers that are configured to run. +type Config struct { + LogPath string `xml:"logPath"` + Servers []Server `xml:"honeypotServers>server"` + ThreatFeed ThreatFeed `xml:"threatFeed"` + Logger *slog.Logger +} + +// Server represents a honeypot server with its relevant settings. +type Server struct { + Type ServerType `xml:"type,attr"` + Enabled bool `xml:"enabled"` + Port string `xml:"port"` + CertPath string `xml:"certPath"` + KeyPath string `xml:"keyPath"` + HtmlPath string `xml:"htmlPath"` + Banner string `xml:"banner"` + Prompts []Prompt `xml:"prompt"` +} + +// Prompt represents a text prompt that can be displayed to connecting clients +// when using the TCP-type honeypot server. Each prompt waits for input and +// logs the response. A Server can include multiple prompts which are displayed +// one at a time. The optional Log field gives a description when logging the +// response. +type Prompt struct { + Text string `xml:",chardata"` + Log string `xml:"log,attr"` +} + +// ThreatFeed represents an optional HTTP server that serves a list of IP +// addresses observed interacting with your honeypot servers. This server +// outputs data in a format compatible with most enterprise firewalls, which +// can be configured to automatically block communication with IP addresses +// appearing in the threat feed. +type ThreatFeed struct { + Enabled bool `xml:"enabled"` + Port string `xml:"port"` + DatabasePath string `xml:"databasePath"` + ExpiryHours uint `xml:"threatExpiryHours"` + IsPrivateIncluded bool `xml:"isPrivateIncluded"` +} + +// Load reads an optional XML configuration file and unmarshals its contents +// into a Config struct. Any errors encountered opening or decoding the file +// are returned. When decoding is successful, the populated Config struct is +// returned. +func Load(filename string) (*Config, error) { + file, err := os.Open(filename) + if err != nil { + return nil, err + } + defer file.Close() + + var config Config + xmlBytes, _ := io.ReadAll(file) + err = xml.Unmarshal(xmlBytes, &config) + if err != nil { + return nil, fmt.Errorf("failed to decode XML file: %w", err) + } + + return &config, nil +} diff --git a/internal/config/helpers.go b/internal/config/helpers.go new file mode 100644 index 0000000..f215551 --- /dev/null +++ b/internal/config/helpers.go @@ -0,0 +1,48 @@ +package config + +import ( + "net" + "os" +) + +// GetHostname returns the system's hostname, defaulting to "localhost" if it +// cannot be determined. +func GetHostname() string { + hostname, err := os.Hostname() + if err != nil { + return "localhost" + } + return hostname +} + +// GetHostIP returns the local IP address of the system, defaulting to +// "127.0.0.1" if it cannot be determined. If there is more than one active IP +// address on the system, only the first found is returned. +func GetHostIP() string { + const failedLookup = "127.0.0.1" + + interfaces, err := net.Interfaces() + if err != nil { + return failedLookup + } + + for _, i := range interfaces { + if i.Flags&net.FlagUp == 0 { + continue + } + + addrs, err := i.Addrs() + if err != nil { + return failedLookup + } + + for _, addr := range addrs { + if ip, ok := addr.(*net.IPNet); ok && !ip.IP.IsLoopback() { + if ip.IP.To4() != nil { + return ip.IP.String() + } + } + } + } + return failedLookup +} diff --git a/internal/httpserver/httpserver.go b/internal/httpserver/httpserver.go new file mode 100644 index 0000000..52dc10a --- /dev/null +++ b/internal/httpserver/httpserver.go @@ -0,0 +1,277 @@ +package httpserver + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "encoding/pem" + "fmt" + "log/slog" + "math/big" + "net" + "net/http" + "os" + "strings" + "time" + + "github.com/r-smith/cti-honeypot/internal/config" + "github.com/r-smith/cti-honeypot/internal/threatfeed" +) + +// StartHTTP initializes and starts an HTTP honeypot server. This is a fully +// functional HTTP server designed to log all incoming requests for analysis. +func StartHTTP(cfg *config.Config, srv *config.Server) { + // Setup handler. + mux := http.NewServeMux() + mux.HandleFunc("/", handleConnection(cfg, srv)) + + // Start the HTTP server. + fmt.Printf("Starting HTTP server on port: %s\n", srv.Port) + if err := http.ListenAndServe(":"+srv.Port, mux); err != nil { + fmt.Fprintln(os.Stderr, "The HTTP server has terminated:", err) + } +} + +// StartHTTPS initializes and starts an HTTPS honeypot server. This is a fully +// functional HTTPS server designed to log all incoming requests for analysis. +func StartHTTPS(cfg *config.Config, srv *config.Server) { + // Setup handler and initialize HTTPS config. + mux := http.NewServeMux() + mux.HandleFunc("/", handleConnection(cfg, srv)) + server := &http.Server{ + Addr: ":" + srv.Port, + Handler: mux, + } + + // If the cert and key aren't found, generate a self-signed certificate. + if _, err := os.Stat(srv.CertPath); os.IsNotExist(err) { + if _, err := os.Stat(srv.KeyPath); os.IsNotExist(err) { + // Generate a self-signed certificate. + cert, err := generateSelfSignedCert(srv.CertPath, srv.KeyPath) + if err != nil { + fmt.Fprintln(os.Stderr, "Failed to generate HTTPS certificate:", err) + return + } + + // Add cert to server config. + server.TLSConfig = &tls.Config{Certificates: []tls.Certificate{cert}} + } + } + + // Start the HTTPS server. + fmt.Printf("Starting HTTPS server on port: %s\n", srv.Port) + if err := server.ListenAndServeTLS(srv.CertPath, srv.KeyPath); err != nil { + fmt.Fprintln(os.Stderr, "The HTTPS server has terminated:", err) + } +} + +// handleConnection is the handler for incoming HTTP and HTTPS client requests. +// It logs the details of each request and generates responses based on the +// requested URL. When the root or index.html is requested, it serves either an +// HTML file specified in the configuration or a default page prompting for +// basic HTTP authentication. Requests for any other URLs will return a 404 +// error to the client. +func handleConnection(cfg *config.Config, srv *config.Server) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Log details of the incoming HTTP request. + dst_ip, dst_port := getLocalAddr(r) + src_ip, src_port, _ := net.SplitHostPort(r.RemoteAddr) + username, password := decodeBasicAuthCredentials(r.Header.Get("Authorization")) + cfg.Logger.LogAttrs(context.Background(), slog.LevelInfo, "", + slog.String("event_type", "http"), + slog.String("source_ip", src_ip), + slog.String("source_port", src_port), + slog.String("sensor_ip", dst_ip), + slog.String("sensor_port", dst_port), + slog.String("sensor_name", config.GetHostname()), + slog.Group("event_details", + slog.String("method", r.Method), + slog.String("path", r.URL.Path), + slog.String("query", r.URL.RawQuery), + slog.String("user_agent", r.UserAgent()), + slog.String("protocol", r.Proto), + slog.String("host", r.Host), + slog.String("basic_auth_username", username), + slog.String("basic_auth_password", password), + slog.Any("request_headers", flattenHeaders(r.Header)), + ), + ) + + // Print a simplified version of the request to the console. + fmt.Printf("[HTTP] %s %s %s %s\n", src_ip, r.Method, r.URL.Path, r.URL.RawQuery) + + // Update the threat feed with the source IP address from the request. + threatfeed.UpdateIoC(src_ip) + + // Prepare the HTTP response for the client. If a banner string is + // provided in the configuration, set it as the "Sever" header in the + // HTTP response. This allows the honeypot server to mimic the + // appearance of common HTTP servers, such as IIS, Nginx, or Apache. + if len(srv.Banner) > 0 { + w.Header().Set("Server", srv.Banner) + } + + // Serve the web content to the client based on the requested URL. If + // the root or /index.html is requested, serve the specified content. + // For any other requests, return a '404 Not Found' response. + if r.URL.Path == "/" || r.URL.Path == "/index.html" { + // The request is for the root or /index.html. + if len(srv.HtmlPath) > 0 { + // Serve the custom HTML file specified in the configuration. + http.ServeFile(w, r, srv.HtmlPath) + } else { + // Serve the default page that prompts the client for basic + // authentication. + w.Header().Set("WWW-Authenticate", "Basic") + http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) + } + } else { + // The request is outside the root or /index.html. Respond with a + // 404 error. + http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) + } + } +} + +// flattenHeaders converts HTTP headers from an http.Request from the format of +// map[string][]string to map[string]string. This results in a cleaner format +// for logging, where each headers values are represented as a single string +// instead of a slice. When a header contains multiple values, they are +// combined into a single string, separated by commas. +func flattenHeaders(headers map[string][]string) map[string]string { + newHeaders := make(map[string]string, len(headers)) + for header, values := range headers { + if len(values) == 1 { + newHeaders[header] = values[0] + } else { + newHeaders[header] = "[" + strings.Join(values, ", ") + "]" + } + } + // Delete the User-Agent header, as it is managed separately. + delete(newHeaders, "User-Agent") + return newHeaders +} + +// decodeBasicAuthCredentials takes an HTTP "Authorization" header string, +// decodes it, and extracts the username and password. The Basic Authentication +// header follows the format 'username:password' and is encoded in base64. +// After decoding, the username and password is returned. +func decodeBasicAuthCredentials(header string) (username string, password string) { + if !strings.HasPrefix(header, "Basic ") { + return "", "" + } + + encodedCredentials := strings.TrimPrefix(header, "Basic ") + decoded, err := base64.StdEncoding.DecodeString(encodedCredentials) + if err != nil { + return "", "" + } + + parts := strings.SplitN(string(decoded), ":", 2) + if len(parts) != 2 { + return "", "" + } + + return parts[0], parts[1] +} + +// getLocalAddr retrieves the local IP address and port from the given HTTP +// request. If the local address is not found, it returns empty strings. +func getLocalAddr(r *http.Request) (ip string, port string) { + localAddr, ok := r.Context().Value(http.LocalAddrContextKey).(net.Addr) + if !ok { + return "", "" + } else { + ip, port, _ = net.SplitHostPort(localAddr.String()) + } + return ip, port +} + +// generateSelfSignedCert creates a self-signed TLS certificate and private key +// and returns the resulting tls.Certificate. If file paths are provided, the +// certificate and key are also saved to disk. +func generateSelfSignedCert(certPath string, keyPath string) (tls.Certificate, error) { + // Generate 2048-bit RSA private key. + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return tls.Certificate{}, fmt.Errorf("failed to generate private key: %w", err) + } + + // Set the certificate validity period to 10 years. + notBefore := time.Now() + notAfter := notBefore.AddDate(10, 0, 0) + + // Generate a random serial number for the certificate. + serialNumber := make([]byte, 16) + _, err = rand.Read(serialNumber) + if err != nil { + return tls.Certificate{}, fmt.Errorf("failed to generate certificate serial number: %w", err) + } + + // Set up the template for creating the certificate. + template := x509.Certificate{ + SerialNumber: new(big.Int).SetBytes(serialNumber), + Subject: pkix.Name{CommonName: "localhost"}, + NotBefore: notBefore, + NotAfter: notAfter, + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + BasicConstraintsValid: true, + } + + // Use the template to create a self-signed X.509 certificate. + derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey) + if err != nil { + return tls.Certificate{}, fmt.Errorf("failed to create certificate: %w", err) + } + + certPEM := &pem.Block{Type: "CERTIFICATE", Bytes: derBytes} + keyPEM := &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privateKey)} + + // Save the certificate and key to disk. + if len(certPath) > 0 && len(keyPath) > 0 { + _ = writeCertAndKey(certPEM, keyPEM, certPath, keyPath) + // If saving fails, ignore the errors and use the in-memory + // certificate. + } + + // Parse the public certificate and private key bytes into a tls.Certificate. + cert, err := tls.X509KeyPair(pem.EncodeToMemory(certPEM), pem.EncodeToMemory(keyPEM)) + if err != nil { + return tls.Certificate{}, fmt.Errorf("failed to load certificate and private key: %w", err) + } + + // Return the tls.Certificate. + return cert, nil +} + +// writeCertAndKey saves the public certificate and private key in PEM format +// to the specified file paths. +func writeCertAndKey(cert *pem.Block, key *pem.Block, certPath string, keyPath string) error { + // Save the certificate file to disk. + certFile, err := os.Create(certPath) + if err != nil { + return err + } + defer certFile.Close() + + if err := pem.Encode(certFile, cert); err != nil { + return err + } + + // Save the private key file to disk. + keyFile, err := os.Create(keyPath) + if err != nil { + return err + } + defer keyFile.Close() + + if err := pem.Encode(keyFile, key); err != nil { + return err + } + + return nil +} diff --git a/internal/sshserver/sshserver.go b/internal/sshserver/sshserver.go new file mode 100644 index 0000000..0dadbca --- /dev/null +++ b/internal/sshserver/sshserver.go @@ -0,0 +1,187 @@ +package sshserver + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "fmt" + "log/slog" + "net" + "os" + + "github.com/r-smith/cti-honeypot/internal/config" + "github.com/r-smith/cti-honeypot/internal/threatfeed" + "golang.org/x/crypto/ssh" +) + +// StartSSH serves as a wrapper to initialize and start an SSH honeypot server. +// The SSH server is designed to log the usernames and passwords submitted in +// authentication requests. It is not possible for clients to log in to the +// honeypot server, as authentication is the only function handled by the +// server. Clients receive authentication failure responses for every login +// attempt. This function calls the underlying startSSH function to perform the +// actual server startup. +func StartSSH(cfg *config.Config, srv *config.Server) { + fmt.Printf("Starting SSH server on port: %s\n", srv.Port) + if err := startSSH(cfg, srv); err != nil { + fmt.Fprintln(os.Stderr, "The SSH server has terminated:", err) + } +} + +// startSSH starts the SSH honeypot server. It handles the server's main loop, +// authentication callback, and logging. +func startSSH(cfg *config.Config, srv *config.Server) error { + // Create a new SSH server configuration. + sshConfig := &ssh.ServerConfig{} + + // Load or generate a private key and add it to the SSH configuration. + privateKey, err := loadOrGeneratePrivateKey(srv.KeyPath) + if err != nil { + return err + } + sshConfig.AddHostKey(privateKey) + + // If a banner string is provided in the configuration, use it as the SSH + // server version string advertised to connecting clients. This allows + // the honeypot server to mimic the appearance of other common SSH servers, + // such as OpenSSH on Debian, Ubuntu, FreeBSD, or Raspberry Pi. + if len(srv.Banner) > 0 { + sshConfig.ServerVersion = srv.Banner + } else { + sshConfig.ServerVersion = config.DefaultBannerSSH + } + + // Define the password callback function for the SSH server. + sshConfig.PasswordCallback = func(conn ssh.ConnMetadata, password []byte) (*ssh.Permissions, error) { + // Log the the username and password submitted by the client. + dst_ip, dst_port, _ := net.SplitHostPort(conn.LocalAddr().String()) + src_ip, src_port, _ := net.SplitHostPort(conn.RemoteAddr().String()) + cfg.Logger.LogAttrs(context.Background(), slog.LevelInfo, "", + slog.String("event_type", "ssh"), + slog.String("source_ip", src_ip), + slog.String("source_port", src_port), + slog.String("sensor_ip", dst_ip), + slog.String("sensor_port", dst_port), + slog.String("sensor_name", config.GetHostname()), + slog.Group("event_details", + slog.String("username", conn.User()), + slog.String("password", string(password)), + slog.String("ssh_client", string(conn.ClientVersion())), + ), + ) + + // Print a simplified version of the request to the console. + fmt.Printf("[SSH] %s Username: %s Password: %s\n", src_ip, conn.User(), string(password)) + + // Update the threat feed with the source IP address from the request. + threatfeed.UpdateIoC(src_ip) + + // Return an invalid username or password error to the client. + return nil, fmt.Errorf("invalid username or password") + } + + // Start the SSH server. + listener, err := net.Listen("tcp", ":"+srv.Port) + if err != nil { + return fmt.Errorf("failed to listen on port '%s': %w", srv.Port, err) + } + defer listener.Close() + + // Listen for and accept incoming connections. + for { + conn, err := listener.Accept() + if err != nil { + continue + } + + go handleConnection(conn, sshConfig) + } +} + +// handleConnection manages incoming SSH client connections. It performs the +// handshake and establishes communication channels. +func handleConnection(conn net.Conn, config *ssh.ServerConfig) { + defer conn.Close() + + // Perform handshake on incoming connection. + sshConn, chans, reqs, err := ssh.NewServerConn(conn, config) + if err != nil { + return + } + defer sshConn.Close() + + // Handle SSH requests and channels. + go ssh.DiscardRequests(reqs) + go handleChannels(chans) +} + +// handleChannels processes SSH channels for the connected client. +func handleChannels(chans <-chan ssh.NewChannel) { + for newChannel := range chans { + newChannel.Reject(ssh.UnknownChannelType, "unknown channel type") + continue + } +} + +// loadOrGeneratePrivateKey attempts to load a private key from the specified +// path. If the key does not exist, it generates a new private key, saves it to +// the specified path, and returns the generated key. +func loadOrGeneratePrivateKey(path string) (ssh.Signer, error) { + if _, err := os.Stat(path); err == nil { + // Load the specified file and return the parsed private key. + privateKey, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read private key from '%s': %w", path, err) + } + signer, err := ssh.ParsePrivateKey(privateKey) + if err != nil { + return nil, fmt.Errorf("failed to parse private key '%s': %w", path, err) + } + return signer, nil + } else if os.IsNotExist(err) { + // Generate and return a new private key. + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return nil, fmt.Errorf("failed to generate RSA private key: %w", err) + } + + // Save the private key to disk. + if len(path) > 0 { + _ = writePrivateKey(path, privateKey) + // If saving fails, ignore the errors and use the in-memory private + // key. + } + + // Convert the key to ssh.Signer. + signer, err := ssh.NewSignerFromKey(privateKey) + if err != nil { + return nil, fmt.Errorf("failed to convert RSA key to SSH signer: %w", err) + } + return signer, nil + } else { + return nil, err + } +} + +// writePrivateKey saves a private key in PEM format to the specified file +// path. +func writePrivateKey(path string, privateKey *rsa.PrivateKey) error { + privBytes := x509.MarshalPKCS1PrivateKey(privateKey) + privPem := &pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: privBytes, + } + + file, err := os.Create(path) + if err != nil { + return err + } + defer file.Close() + + if err := pem.Encode(file, privPem); err != nil { + return err + } + return nil +} diff --git a/internal/tcpserver/tcpserver.go b/internal/tcpserver/tcpserver.go new file mode 100644 index 0000000..a7f3f2f --- /dev/null +++ b/internal/tcpserver/tcpserver.go @@ -0,0 +1,150 @@ +package tcpserver + +import ( + "bufio" + "context" + "fmt" + "log/slog" + "net" + "os" + "sort" + "strings" + "time" + + "github.com/r-smith/cti-honeypot/internal/config" + "github.com/r-smith/cti-honeypot/internal/threatfeed" +) + +// serverTimeout defines the duration after which connected clients are +// automatically disconnected, set to 30 seconds. +const serverTimeout = 30 * time.Second + +// StartTCP serves as a wrapper to initialize and start a generic TCP honeypot +// server. It presents custom prompts to connected clients and logs their +// responses. This function calls the underlying startTCP function to +// perform the actual server startup. +func StartTCP(cfg *config.Config, srv *config.Server) { + fmt.Printf("Starting TCP server on port: %s\n", srv.Port) + if err := startTCP(cfg, srv); err != nil { + fmt.Fprintln(os.Stderr, "The TCP server has terminated:", err) + } +} + +// startTCP starts the TCP honeypot server. It handles the server's main loop. +func startTCP(cfg *config.Config, srv *config.Server) error { + // Start the TCP server. + listener, err := net.Listen("tcp", ":"+srv.Port) + if err != nil { + return fmt.Errorf("failed to listen on port '%s': %w", srv.Port, err) + } + defer listener.Close() + + // Listen for and accept incoming connections. + for { + conn, err := listener.Accept() + if err != nil { + continue + } + + go handleConnection(conn, cfg, srv) + } +} + +// handleConnection is invoked when a client connects to the TCP honeypot +// server. It presents custom prompts to the client, records and logs their +// responses, and then disconnects the client. This function manages the entire +// client interaction. +func handleConnection(conn net.Conn, cfg *config.Config, srv *config.Server) { + defer conn.Close() + conn.SetDeadline(time.Now().Add(serverTimeout)) + + // Print an optional banner. Replace any occurrences of the newline escape + // sequence "\\n" with "\r\n" (carriage return, line feed), used by + // protocols such as Telnet and SMTP. + if len(srv.Banner) > 0 { + conn.Write([]byte(strings.ReplaceAll(srv.Banner, "\\n", "\r\n"))) + } + + // Present the prompts from the server configuration to the connected + // client and record their responses. + scanner := bufio.NewScanner(conn) + answers := make(map[string]string) + for i, prompt := range srv.Prompts { + conn.Write([]byte(strings.ReplaceAll(prompt.Text, "\\n", "\r\n"))) + scanner.Scan() + var key string + // Each prompt includes an optional Log field that serves as the key + // for logging. If Log is set to "none", the prompt is displayed, but + // the response will not be logged. If Log is omitted, the default key + // "answer00" is used, where "00" is the index plus one. + if prompt.Log == "none" { + // Skip logging for this entry. + continue + } else if len(prompt.Log) > 0 { + key = prompt.Log + } else { + key = fmt.Sprintf("answer%02d", i+1) + } + answers[key] = scanner.Text() + } + + // If no prompts are provided in the configuration, wait for the client to + // send data then record the received input. + if len(srv.Prompts) == 0 { + scanner.Scan() + answers["data"] = scanner.Text() + } + + // Check if the client sent any data. If not, exit without logging. + didProvideData := false + for _, v := range answers { + if len(v) > 0 { + didProvideData = true + break + } + } + if !didProvideData { + return + } + + // Log the connection along with all responses received from the client. + dst_ip, dst_port, _ := net.SplitHostPort(conn.LocalAddr().String()) + src_ip, src_port, _ := net.SplitHostPort(conn.RemoteAddr().String()) + cfg.Logger.LogAttrs(context.Background(), slog.LevelInfo, "", + slog.String("event_type", "tcp"), + slog.String("source_ip", src_ip), + slog.String("source_port", src_port), + slog.String("sensor_ip", dst_ip), + slog.String("sensor_port", dst_port), + slog.String("sensor_name", config.GetHostname()), + slog.Any("event_details", answers), + ) + + // Print a simplified version of the interaction to the console. + fmt.Printf("[TCP] %s %v\n", src_ip, answersToString(answers)) + + // Update the threat feed with the source IP address from the interaction. + threatfeed.UpdateIoC(src_ip) +} + +// answersToString converts a map of responses from custom prompts into a +// single string formatted as "key:value key:value ...". Each key-value pair +// represents a prompt and its corresponding response. +func answersToString(answers map[string]string) string { + var keys, simpleAnswers []string + + // Collect and sort the keys. + for key := range answers { + keys = append(keys, key) + } + sort.Strings(keys) + + // For each key-value pair, convert to the string "key:value". + for _, key := range keys { + simpleAnswers = append(simpleAnswers, fmt.Sprintf("%s:%s", key, answers[key])) + } + + // Combine all the answers into a single string. For example, the result + // would be formatted as: "key01:value key02:value key03:value". + return strings.Join(simpleAnswers, " ") +} diff --git a/internal/threatfeed/database.go b/internal/threatfeed/database.go new file mode 100644 index 0000000..e11256a --- /dev/null +++ b/internal/threatfeed/database.go @@ -0,0 +1,173 @@ +package threatfeed + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net" + "os" + "sync" + "time" + + "github.com/r-smith/cti-honeypot/internal/config" +) + +var ( + // iocMap stores the Indicator of Compromise (IoC) entries which makes up + // the threat feed database. It is initially populated by loadIoC if an + // existing JSON database file is provided. The map is subsequently updated + // by UpdateIoC whenever a client interacts with a honeypot server. This + // map is accessed and served by the threat feed HTTP server. + iocMap = make(map[string]*IoC) + + // isPrivateIncluded indicates whether private IP addresses are included in + // the threat feed database. It is set once by loadIoC according to the + // threat feed configuration. + isPrivateIncluded bool + + // jsonFile holds the path to the JSON file used to save IoC data to disk. + // It is set once by loadIoC according to the threat feed configuration. + // This file ensures the threat feed database persists across server + // restarts. + jsonFile string + + // expiryHours specifies the duration after which an IoC entry is + // considered expired based on its last seen date. It is set once by + // loadIoC according to the threat feed configuration. + expiryHours uint + + // mutex is to ensure thread-safe access to iocMap. + mutex sync.Mutex +) + +// IoC represents an Indicator of Compromise (IoC) entry in the threat feed +// database. The database is formatted as JSON, where each IP address serves as +// a key. Each IP entry includes the date the IP was added and the date it was +// last seen. +// +// Example database: +// +// { +// "127.0.14.54": +// { +// "added": "2024-10-13T17:35:04.8199165-00:00", +// "last_seen": "2024-10-16T08:07:17.6370403-00:00" +// }, +// "127.19.201.8": +// { +// "added": "2024-10-16T04:27:58.301360933-00:00", +// "last_seen": "2024-10-16T05:57:37.646377358-00:00" +// } +// } +type IoC struct { + Added time.Time `json:"added"` + LastSeen time.Time `json:"last_seen"` +} + +// loadIoC reads IoC data from an existing JSON database. If found, it +// populates iocMap. This function is called once during the initialization of +// the threat feed server. +func loadIoC(threatFeed *config.ThreatFeed) error { + jsonFile = threatFeed.DatabasePath + expiryHours = threatFeed.ExpiryHours + isPrivateIncluded = threatFeed.IsPrivateIncluded + + file, err := os.Open(jsonFile) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err + } + defer file.Close() + + jsonBytes, err := io.ReadAll(file) + if err != nil { + return err + } + if len(jsonBytes) == 0 { + return nil + } + + return json.Unmarshal(jsonBytes, &iocMap) +} + +// UpdateIoC updates the IoC map. This function is called by honeypot servers +// each time a client interacts with the honeypot. The modified IoC map is then +// saved back to the JSON database. +func UpdateIoC(ip string) { + mutex.Lock() + defer mutex.Unlock() + + // Check if the given IP string is a private address. The threat feed may + // be configured to include or exclude private IPs. + netIP := net.ParseIP(ip) + if netIP == nil { + return + } + if !isPrivateIncluded && netIP.IsPrivate() { + return + } + + now := time.Now() + if ioc, exists := iocMap[ip]; exists { + // Update existing entry. + ioc.LastSeen = now + } else { + // Create a new entry. + iocMap[ip] = &IoC{ + Added: now, + LastSeen: now, + } + } + + // Remove expired entries from iocMap. + removeExpired() + + // Write the updated map back to the JSON file. + if err := saveIoC(); err != nil { + fmt.Fprintln(os.Stderr, "Error saving Threat Feed database:", err) + } +} + +// removeExpired checks the IoC map for entries that have expired based on +// their last seen date and the configured expiry hours. It deletes any expired +// entries from the map. This function should be called exclusively by +// UpdateIoC, which manages the mutex lock. +func removeExpired() { + // If expiryHours is set to 0, entries never expire and will remain + // indefinitely. + if expiryHours <= 0 { + return + } + + var iocToRemove []string + expirtyTime := time.Now().Add(-time.Hour * time.Duration(expiryHours)) + + for key, value := range iocMap { + if value.LastSeen.Before(expirtyTime) { + iocToRemove = append(iocToRemove, key) + } + } + + for _, key := range iocToRemove { + delete(iocMap, key) + } +} + +// saveIoC writes the current IoC map to the JSON file. This function is called +// after modifications to the IoC map. The file ensures the threat feed +// database persists across server restarts. This function should be called +// exclusively by UpdateIoC, which manages the mutex lock. +func saveIoC() error { + file, err := os.Create(jsonFile) + if err != nil { + return err + } + defer file.Close() + + encoder := json.NewEncoder(file) + encoder.SetIndent("", " ") + return encoder.Encode(iocMap) +} diff --git a/internal/threatfeed/threatfeed.go b/internal/threatfeed/threatfeed.go new file mode 100644 index 0000000..86afec1 --- /dev/null +++ b/internal/threatfeed/threatfeed.go @@ -0,0 +1,87 @@ +package threatfeed + +import ( + "bytes" + "fmt" + "net" + "net/http" + "os" + "sort" + "time" + + "github.com/r-smith/cti-honeypot/internal/config" +) + +// StartThreatFeed initializes and starts the threat feed server. The server +// provides a list of IP addresses observed interacting with the honeypot +// servers. The data is served in a format compatible with most enterprise +// firewalls. +func StartThreatFeed(threatFeed *config.ThreatFeed) { + // Check for and open an existing threat feed JSON database, if available. + err := loadIoC(threatFeed) + if err != nil { + fmt.Fprintln(os.Stderr, "The Threat Feed server has terminated: Failed to open Threat Feed database:", err) + return + } + + // Setup handlers. + mux := http.NewServeMux() + mux.HandleFunc("/", handleConnection) + mux.HandleFunc("/empty/", serveEmpty) + + // Start the threat feed HTTP server. + fmt.Printf("Starting Threat Feed server on port: %s\n", threatFeed.Port) + if err := http.ListenAndServe(":"+threatFeed.Port, mux); err != nil { + fmt.Fprintln(os.Stderr, "The Threat Feed server has terminated:", err) + } +} + +// handleConnection processes incoming HTTP requests for the threat feed +// server. It serves the sorted list of IP addresses observed interacting with +// the honeypot servers. +func handleConnection(w http.ResponseWriter, r *http.Request) { + mutex.Lock() + defer mutex.Unlock() + + // Calculate expiry time. + now := time.Now() + expiryTime := now.Add(-time.Hour * time.Duration(expiryHours)) + + // If the IP is not expired, convert it to a string for sorting. + var netIPs []net.IP + for ip, ioc := range iocMap { + if ioc.LastSeen.After(expiryTime) { + netIPs = append(netIPs, net.ParseIP(ip)) + } + } + + // Sort the IP addresses. + sort.Slice(netIPs, func(i, j int) bool { + return bytes.Compare(netIPs[i], netIPs[j]) < 0 + }) + + // Serve the sorted list of IP addresses. + w.Header().Set("Content-Type", "text/plain") + for _, ip := range netIPs { + if ip == nil { + // Skip IP addresses that failed parsing. + continue + } + _, err := w.Write([]byte(ip.String() + "\n")) + if err != nil { + http.Error(w, "Falled to write response", http.StatusInternalServerError) + return + } + } +} + +// serveEmpty handles HTTP requests to /empty/. It returns an empty body with +// status code 200. This endpoint is useful for clearing the threat feed in +// firewalls, as many firewalls retain the last ingested feed. Firewalls can be +// configured to point to this endpoint, effectively clearing all previous +// threat feed data. +func serveEmpty(w http.ResponseWriter, r *http.Request) { + // Serve an empty body with status code 200. + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) +} diff --git a/internal/udpserver/udpserver.go b/internal/udpserver/udpserver.go new file mode 100644 index 0000000..1d243f2 --- /dev/null +++ b/internal/udpserver/udpserver.go @@ -0,0 +1,85 @@ +package udpserver + +import ( + "context" + "fmt" + "log/slog" + "net" + "os" + "strconv" + "strings" + + "github.com/r-smith/cti-honeypot/internal/config" + "github.com/r-smith/cti-honeypot/internal/threatfeed" +) + +// StartUDP serves as a wrapper to initialize and start a generic UDP honeypot +// server. It listens on the specified port, logging any received data without +// responding back to the client. Since UDP is connectionless, clients are +// unaware of the server's existence and that it is actively listening and +// recording data sent to the port. This function calls the underlying startUDP +// function to perform the actual server startup. +func StartUDP(cfg *config.Config, srv *config.Server) { + fmt.Printf("Starting UDP server on port: %s\n", srv.Port) + if err := startUDP(cfg, srv); err != nil { + fmt.Fprintln(os.Stderr, "The UDP server has terminated:", err) + } +} + +// startUDP starts the UDP honeypot server. It handles the server's main loop +// and logging. +func startUDP(cfg *config.Config, srv *config.Server) error { + // Convert the specified port number to an integer. + port, err := strconv.Atoi(srv.Port) + if err != nil { + return fmt.Errorf("invalid port '%s': %w", srv.Port, err) + } + + // Start the UDP server. + conn, err := net.ListenUDP("udp", &net.UDPAddr{Port: port}) + if err != nil { + return fmt.Errorf("failure to listen on port '%s': %w", srv.Port, err) + } + defer conn.Close() + + // Listen for and accept incoming data, with a maximum size of 1024 bytes. + buffer := make([]byte, 1024) + for { + n, remoteAddr, err := conn.ReadFrom(buffer) + if err != nil { + continue + } + + go func() { + // The UDP server has received incoming data from a client. Log the + // interaction and the received data. Note: Go's listenUDP does not + // capture the local IP address that received the UDP packet. To + // assist with logging, call config.GetHostIP(), which returns the + // first active local IP address found on the system. On systems + // with multiple IP addresses, this may not correspond to the IP + // address that received the UDP data. However, this limitation is + // acceptable as the primary goal is to log the source IP and + // received data. + _, dst_port, _ := net.SplitHostPort(conn.LocalAddr().String()) + src_ip, src_port, _ := net.SplitHostPort(remoteAddr.String()) + cfg.Logger.LogAttrs(context.Background(), slog.LevelInfo, "", + slog.String("event_type", "udp"), + slog.String("source_ip", src_ip), + slog.String("source_port", src_port), + slog.String("sensor_ip", config.GetHostIP()), + slog.String("sensor_port", dst_port), + slog.String("sensor_name", config.GetHostname()), + slog.Group("event_details", + slog.String("data", string(buffer[:n])), + ), + ) + + // Print a simplified version of the interaction to the console. + fmt.Printf("[UDP] %s Data: %s\n", src_ip, strings.TrimSpace(string(buffer[:n]))) + + // Update the threat feed with the source IP address from the + // interaction. + threatfeed.UpdateIoC(src_ip) + }() + } +}