mirror of
https://github.com/r-smith/deceptifeed.git
synced 2025-10-23 08:22:21 +00:00
first commit
This commit is contained in:
37
.gitignore
vendored
Normal file
37
.gitignore
vendored
Normal 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
21
LICENSE
Normal 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
120
cmd/cti-honeypot/main.go
Normal 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
7
go.mod
Normal 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
6
go.sum
Normal 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
136
internal/config/config.go
Normal 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
|
||||||
|
}
|
48
internal/config/helpers.go
Normal file
48
internal/config/helpers.go
Normal 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
|
||||||
|
}
|
277
internal/httpserver/httpserver.go
Normal file
277
internal/httpserver/httpserver.go
Normal 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
|
||||||
|
}
|
187
internal/sshserver/sshserver.go
Normal file
187
internal/sshserver/sshserver.go
Normal 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
|
||||||
|
}
|
150
internal/tcpserver/tcpserver.go
Normal file
150
internal/tcpserver/tcpserver.go
Normal 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, " ")
|
||||||
|
}
|
173
internal/threatfeed/database.go
Normal file
173
internal/threatfeed/database.go
Normal 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)
|
||||||
|
}
|
87
internal/threatfeed/threatfeed.go
Normal file
87
internal/threatfeed/threatfeed.go
Normal 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)
|
||||||
|
}
|
85
internal/udpserver/udpserver.go
Normal file
85
internal/udpserver/udpserver.go
Normal 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)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
Reference in New Issue
Block a user