first commit

This commit is contained in:
Ryan Smith
2024-10-16 11:48:13 -07:00
commit c7bb4b7b28
13 changed files with 1334 additions and 0 deletions

37
.gitignore vendored Normal file
View File

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

21
LICENSE Normal file
View File

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

120
cmd/cti-honeypot/main.go Normal file
View File

@@ -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()
}

7
go.mod Normal file
View File

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

6
go.sum Normal file
View File

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

136
internal/config/config.go Normal file
View File

@@ -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:
// <server type="http"><enabled>true</enabled></server>
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
}

View File

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

View File

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

View File

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

View File

@@ -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, " ")
}

View File

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

View File

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

View File

@@ -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)
}()
}
}