From 1698080f9bcf64a0196d7f4efc1dfd67c6b3a29d Mon Sep 17 00:00:00 2001 From: Georigy Sitnikov Date: Tue, 23 Dec 2025 13:30:07 +0100 Subject: [PATCH 01/10] - Deterministic per host - Realistic versions - Avoids bot fingerprinting --- main.go | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/main.go b/main.go index 08b1851..4ccb777 100644 --- a/main.go +++ b/main.go @@ -10,8 +10,8 @@ import ( "math/rand" "net" "os" - "time" "strconv" + "time" "github.com/sirupsen/logrus" "golang.org/x/crypto/ssh" @@ -189,16 +189,19 @@ func getKey(host string) (*rsa.PrivateKey, error) { } var serverVersions = []string{ - "SSH-2.0-libssh-0.6.1", + "SSH-2.0-OpenSSH_7.4", + "SSH-2.0-OpenSSH_7.9p1 Debian-10", + "SSH-2.0-OpenSSH_8.2p1 Ubuntu-4ubuntu0.5", + "SSH-2.0-OpenSSH_8.4", + "SSH-2.0-dropbear_2019.78", } func getServerVersion(host string) string { - randomSeed := HashToInt64([]byte(host), []byte(sshd_key_key)) - if randomSeed < 0 { - randomSeed = -randomSeed + seed := HashToInt64([]byte(host), []byte(sshd_key_key)) + if seed < 0 { + seed = -seed } - n := int(randomSeed) % len(serverVersions) - return serverVersions[n] + return serverVersions[int(seed)%len(serverVersions)] } func makeSSHConfig(host string) ssh.ServerConfig { From 9a952bcb99db584819d0176f57c8700933214764 Mon Sep 17 00:00:00 2001 From: Georigy Sitnikov Date: Tue, 23 Dec 2025 13:31:30 +0100 Subject: [PATCH 02/10] - Allow multiple auth failures. 3 is too low, real systems allow more. - Add Variable delay between failures - Increase key to `3072` bits, `1024` was way too outdated - Readme update with a new variables and add some comments to them --- README.md | 15 ++++++--- main.go | 92 ++++++++++++++++++++++++++++++++++++++++--------------- 2 files changed, 77 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 359a286..79a10b1 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +# SSH Auth Logger + A low/zero interaction ssh authentication logging honeypot ## Interesting features @@ -61,20 +63,23 @@ docker run -t -i --rm -p 2222:2222 justinazoff/ssh-auth-logger Docker compose example: -```shell +```yaml services: ssh-auth-logger: image: justinazoff/ssh-auth-logger:latest container_name: ssh-auth-logger environment: - - SSHD_RATE=120 # bits per second, emulate very slow connection - - SSHD_BIND=:2222 # Port and interface to listen - - TZ=Europe/Berlin # You can set Time Zone to see logs with your local time + # Following are default values + - SSHD_RATE=120 # bits per second, emulate very slow connection + - SSHD_BIND=:22 # Port and interface to listen + - SSHD_KEY_KEY="Take me to your leader" # It's a secret key that is used to generate a deterministic hash value for a given host IP address + - SSHD_MAX_AUTH_TRIES=6 # The minimum number of authentication attempts allowed + - TZ=Europe/Berlin # You can set Time Zone to see logs with your local time volumes: # Mount log file if needed - /var/docker/ssh-auth-logger/log:/var/log ports: - - 2222:2222 # SSH Auth Logger + - 2222:22 # SSH Auth Logger restart: unless-stopped deploy: resources: diff --git a/main.go b/main.go index 4ccb777..196d653 100644 --- a/main.go +++ b/main.go @@ -31,6 +31,7 @@ var ( sshd_bind string sshd_key_key string rate int + maxAuthTries int ) // rateLimitedConn is a wrapper around net.Conn that limits the bandwidth. @@ -42,6 +43,10 @@ type rateLimitedConn struct { lastUpdate time.Time } +type authState struct { + attempts int +} + // newRateLimitedConn returns a new rateLimitedConn. func newRateLimitedConn(conn net.Conn, rate int) *rateLimitedConn { return &rateLimitedConn{ @@ -142,11 +147,17 @@ func logParameters(conn ssh.ConnMetadata) logrus.Fields { } } +func authDelay(base, jitter time.Duration) { + d := base + time.Duration(rand.Int63n(int64(jitter))) + time.Sleep(d) +} + func authenticatePassword(conn ssh.ConnMetadata, password []byte) (*ssh.Permissions, error) { - fields := logrus.Fields{ - "password": string(password), - } - logger.WithFields(logParameters(conn)).WithFields(fields).Info("Request with password") + // Realistic delay (200–900ms) + authDelay(200*time.Millisecond, 700*time.Millisecond) + logger.WithFields(logParameters(conn)). + WithField("password", string(password)). + Info("Request with password") return nil, errAuthenticationFailed } @@ -175,17 +186,16 @@ func getHost(addr string) string { return host } -func getKey(host string) (*rsa.PrivateKey, error) { - logrus.WithFields(logrus.Fields{"addr": host}).Debug("Generating host key") - +func getKey(host string) (ssh.Signer, error) { randomSeed := HashToInt64([]byte(host), []byte(sshd_key_key)) randomSource := rand.New(rand.NewSource(randomSeed)) - key, err := rsa.GenerateKey(randomSource, 1024) + key, err := rsa.GenerateKey(randomSource, 3072) if err != nil { - return key, err + return nil, err } - return key, err + + return ssh.NewSignerFromKey(key) } var serverVersions = []string{ @@ -205,22 +215,49 @@ func getServerVersion(host string) string { } func makeSSHConfig(host string) ssh.ServerConfig { + state := &authState{} + config := ssh.ServerConfig{ - PasswordCallback: authenticatePassword, - PublicKeyCallback: authenticateKey, - ServerVersion: getServerVersion(host), - MaxAuthTries: 3, - } + PasswordCallback: func(conn ssh.ConnMetadata, password []byte) (*ssh.Permissions, error) { + state.attempts++ - privateKey, err := getKey(host) - if err != nil { - logrus.Panic(err) + base := time.Duration(200*state.attempts) * time.Millisecond + jitter := time.Duration(rand.Intn(400)) * time.Millisecond + time.Sleep(base + jitter) + + logger.WithFields(logParameters(conn)). + WithField("password", string(password)). + Info("Request with password") + + return nil, errAuthenticationFailed + }, + + PublicKeyCallback: func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { + state.attempts++ + + base := time.Duration(200*state.attempts) * time.Millisecond + jitter := time.Duration(rand.Intn(400)) * time.Millisecond + time.Sleep(base + jitter) + + logger.WithFields(logParameters(conn)). + WithFields(logrus.Fields{ + "keytype": key.Type(), + "fingerprint": ssh.FingerprintSHA256(key), + }).Info("Request with key") + + return nil, errAuthenticationFailed + }, + + ServerVersion: getServerVersion(host), + MaxAuthTries: maxAuthTries + rand.Intn(5), } - hostPrivateKeySigner, err := ssh.NewSignerFromKey(privateKey) + + signer, err := getKey(host) if err != nil { logrus.Panic(err) } - config.AddHostKey(hostPrivateKeySigner) + config.AddHostKey(signer) + return config } @@ -250,14 +287,19 @@ func init() { var err error rate, err = strconv.Atoi(rateStr) if err != nil { - logrus.Fatal("Invalid RATE environment variable") + logrus.Fatal("Invalid SSHD_RATE environment variable") + } + maxAuthTriesStr := getEnvWithDefault("SSHD_MAX_AUTH_TRIES", "6") // default amount of tries is 6-10. + maxAuthTries, err = strconv.Atoi(maxAuthTriesStr) + if err != nil { + logrus.Fatal("Invalid SSHD_MAX_AUTH_TRIES environment variable") } - // Show Configuration on Startup logrus.WithFields(logrus.Fields{ - "SSHD_BIND": sshd_bind, - "SSHD_KEY_KEY": sshd_key_key, - "SSHD_RATE": rate, + "SSHD_BIND": sshd_bind, + "SSHD_KEY_KEY": sshd_key_key, + "SSHD_RATE": rate, + "SSHD_MAX_AUTH_TRIES": maxAuthTries, }).Info("Starting SSH Auth Logger") } From 5c6ca07f877b9642e61b0aa34cd1215c448381d7 Mon Sep 17 00:00:00 2001 From: Georigy Sitnikov Date: Tue, 23 Dec 2025 13:32:56 +0100 Subject: [PATCH 03/10] - Add `ed25519` and `rsa` support - Config update with `SSHD_HOSTKEY_TYPE` and `SSHD_RSA_BITS` - Faking Session channel - Readme example update: - Add isolated network example - Update health check with log file test to aviod empty log after system log rotation - Add new variables with comments - Unused code cleanup --- README.md | 16 ++++++-- main.go | 108 +++++++++++++++++++++++++++++++++++++++--------------- 2 files changed, 90 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 79a10b1..b2a2d1f 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,3 @@ -# SSH Auth Logger - A low/zero interaction ssh authentication logging honeypot ## Interesting features @@ -64,6 +62,10 @@ docker run -t -i --rm -p 2222:2222 justinazoff/ssh-auth-logger Docker compose example: ```yaml +networks: + isolated_net: + driver: bridge + services: ssh-auth-logger: image: justinazoff/ssh-auth-logger:latest @@ -74,12 +76,17 @@ services: - SSHD_BIND=:22 # Port and interface to listen - SSHD_KEY_KEY="Take me to your leader" # It's a secret key that is used to generate a deterministic hash value for a given host IP address - SSHD_MAX_AUTH_TRIES=6 # The minimum number of authentication attempts allowed + - SSHD_HOSTKEY_TYPE=rsa # Key type, could be 'rsa' or 'ed25519' + - SSHD_RSA_BITS=3072 # If you use 'rsa' you can also set RSA key size, 2048, 3072, 4096 (very rare) - TZ=Europe/Berlin # You can set Time Zone to see logs with your local time volumes: # Mount log file if needed - /var/docker/ssh-auth-logger/log:/var/log ports: - - 2222:22 # SSH Auth Logger + - 2222:2222 # SSH Auth Logger + networks: + # Use isolated docker network, so that other containers will be not reachable from it + - isolated_net restart: unless-stopped deploy: resources: @@ -87,7 +94,8 @@ services: cpus: '0.50' memory: 100M healthcheck: - test: wget -v localhost$SSHD_BIND --no-verbose --tries=1 --spider || exit 1 + # Will test if port is still open AND log file was not + test: wget -v localhost$SSHD_BIND --no-verbose --tries=1 --spider && test -s /var/log/ssh-auth-logger.log || exit 1 interval: 5m00s timeout: 5s retries: 2 diff --git a/main.go b/main.go index 196d653..91c491b 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,7 @@ package main import ( + "crypto/ed25519" "crypto/hmac" "crypto/rsa" "crypto/sha256" @@ -32,6 +33,8 @@ var ( sshd_key_key string rate int maxAuthTries int + hostKeyType string // "rsa" or "ed25519" + rsaBits int // only used if hostKeyType == "rsa" ) // rateLimitedConn is a wrapper around net.Conn that limits the bandwidth. @@ -152,24 +155,6 @@ func authDelay(base, jitter time.Duration) { time.Sleep(d) } -func authenticatePassword(conn ssh.ConnMetadata, password []byte) (*ssh.Permissions, error) { - // Realistic delay (200–900ms) - authDelay(200*time.Millisecond, 700*time.Millisecond) - logger.WithFields(logParameters(conn)). - WithField("password", string(password)). - Info("Request with password") - return nil, errAuthenticationFailed -} - -func authenticateKey(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { - fields := logrus.Fields{ - "keytype": key.Type(), - "fingerprint": ssh.FingerprintSHA256(key), - } - logger.WithFields(logParameters(conn)).WithFields(fields).Info("Request with key") - return nil, errAuthenticationFailed -} - func HashToInt64(message, key []byte) int64 { mac := hmac.New(sha256.New, key) mac.Write(message) @@ -186,16 +171,29 @@ func getHost(addr string) string { return host } -func getKey(host string) (ssh.Signer, error) { - randomSeed := HashToInt64([]byte(host), []byte(sshd_key_key)) - randomSource := rand.New(rand.NewSource(randomSeed)) +func getHostKeySigner(host string) (ssh.Signer, error) { + seed := HashToInt64([]byte(host), []byte(sshd_key_key)) + rng := rand.New(rand.NewSource(seed)) - key, err := rsa.GenerateKey(randomSource, 3072) - if err != nil { - return nil, err - } + switch hostKeyType { - return ssh.NewSignerFromKey(key) + case "ed25519": + _, priv, err := ed25519.GenerateKey(rng) + if err != nil { + return nil, err + } + return ssh.NewSignerFromKey(priv) + + case "rsa": + key, err := rsa.GenerateKey(rng, rsaBits) + if err != nil { + return nil, err + } + return ssh.NewSignerFromKey(key) + + default: + return nil, errors.New("unsupported SSHD_HOSTKEY_TYPE") + } } var serverVersions = []string{ @@ -252,7 +250,7 @@ func makeSSHConfig(host string) ssh.ServerConfig { MaxAuthTries: maxAuthTries + rand.Intn(5), } - signer, err := getKey(host) + signer, err := getHostKeySigner(host) if err != nil { logrus.Panic(err) } @@ -261,10 +259,52 @@ func makeSSHConfig(host string) ssh.ServerConfig { return config } +// Accept Session Channels but Never Grant Shell func handleConnection(conn net.Conn, config *ssh.ServerConfig) { - _, _, _, err := ssh.NewServerConn(conn, config) - if err == nil { - logrus.Panic("Successful login? why!?") + serverConn, chans, reqs, err := ssh.NewServerConn(conn, config) + if err != nil { + return + } + logger.WithFields(logParameters(serverConn)).Info("SSH connection established") + + go ssh.DiscardRequests(reqs) + + for ch := range chans { + if ch.ChannelType() != "session" { + ch.Reject(ssh.UnknownChannelType, "unsupported") + continue + } + + channel, requests, err := ch.Accept() + if err != nil { + continue + } + + go func(in <-chan *ssh.Request) { + for req := range in { + switch req.Type { + + case "pty-req": + req.Reply(true, nil) + + case "env": + req.Reply(true, nil) + + case "exec": + logger.WithField("cmd", string(req.Payload)).Info("Exec attempt") + req.Reply(false, nil) + + case "shell": + // Fake success, no shell + req.Reply(true, nil) + time.Sleep(2 * time.Second) + channel.Close() + + default: + req.Reply(false, nil) + } + } + }(requests) } } @@ -294,12 +334,20 @@ func init() { if err != nil { logrus.Fatal("Invalid SSHD_MAX_AUTH_TRIES environment variable") } + hostKeyType = getEnvWithDefault("SSHD_HOSTKEY_TYPE", "rsa") + rsaBitsStr := getEnvWithDefault("SSHD_RSA_BITS", "3072") + rsaBits, err = strconv.Atoi(rsaBitsStr) + if err != nil || rsaBits < 2048 { + logrus.Fatal("Invalid SSHD_RSA_BITS (must be >= 2048)") + } // Show Configuration on Startup logrus.WithFields(logrus.Fields{ "SSHD_BIND": sshd_bind, "SSHD_KEY_KEY": sshd_key_key, "SSHD_RATE": rate, "SSHD_MAX_AUTH_TRIES": maxAuthTries, + "SSHD_HOSTKEY_TYPE": hostKeyType, + "SSHD_RSA_BITS": rsaBitsStr, }).Info("Starting SSH Auth Logger") } From 0814ab89801411ec2cd44f68f4dc68bf2d1a41c1 Mon Sep 17 00:00:00 2001 From: Georigy Sitnikov Date: Tue, 23 Dec 2025 13:34:27 +0100 Subject: [PATCH 04/10] Do not cache `ssh.ServerConfig` to aviod Auth delays increase across different IPs Add random seed based on start time --- main.go | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/main.go b/main.go index 91c491b..2e37735 100644 --- a/main.go +++ b/main.go @@ -340,6 +340,8 @@ func init() { if err != nil || rsaBits < 2048 { logrus.Fatal("Invalid SSHD_RSA_BITS (must be >= 2048)") } + // seed for non-deterministic uses to avoid identical timing patterns across restarts + rand.Seed(time.Now().UnixNano()) // Show Configuration on Startup logrus.WithFields(logrus.Fields{ "SSHD_BIND": sshd_bind, @@ -352,7 +354,6 @@ func init() { } func main() { - sshConfigMap := make(map[string]ssh.ServerConfig) socket, err := net.Listen("tcp", sshd_bind) if err != nil { panic(err) @@ -362,16 +363,14 @@ func main() { if err != nil { log.Panic(err) } + logger.WithFields(connLogParameters(conn)).Info("Connection") limitedConn := newRateLimitedConn(conn, rate) - host := getHost(conn.LocalAddr().String()) - config, existed := sshConfigMap[host] - if !existed { - config = makeSSHConfig(host) - sshConfigMap[host] = config - } + + config := makeSSHConfig(host) // NEW CONFIG PER CONNECTION go handleConnection(limitedConn, &config) } + } \ No newline at end of file From f90bf84e5a904f8c75e902032ffa9f4fe5dd1952 Mon Sep 17 00:00:00 2001 From: Georigy Sitnikov Date: Tue, 23 Dec 2025 13:34:54 +0100 Subject: [PATCH 05/10] Add simple login banner --- main.go | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/main.go b/main.go index 2e37735..597b7b9 100644 --- a/main.go +++ b/main.go @@ -204,6 +204,14 @@ var serverVersions = []string{ "SSH-2.0-dropbear_2019.78", } +var loginBanners = []string{ + "Ubuntu 20.04.6 LTS\n\nUnauthorized access prohibited.\n", + "Debian GNU/Linux 11\n\nAuthorized users only.\n", + "CentOS Linux 7 (Core)\n\nAll connections are monitored.\n", + "Warning: This system is for authorized use only.\n", + "Welcome to OpenSSH Server\n\nUnauthorized access is prohibited.\n", +} + func getServerVersion(host string) string { seed := HashToInt64([]byte(host), []byte(sshd_key_key)) if seed < 0 { @@ -212,10 +220,25 @@ func getServerVersion(host string) string { return serverVersions[int(seed)%len(serverVersions)] } +// Select Banner per host, same host --> same banner +func getLoginBanner(host string) string { + seed := HashToInt64([]byte("banner:"+host), []byte(sshd_key_key)) + if seed < 0 { + seed = -seed + } + return loginBanners[int(seed)%len(loginBanners)] +} + func makeSSHConfig(host string) ssh.ServerConfig { state := &authState{} config := ssh.ServerConfig{ + BannerCallback: func(conn ssh.ConnMetadata) string { + // Small delay makes it feel real + time.Sleep(time.Duration(100+rand.Intn(200)) * time.Millisecond) + return getLoginBanner(host) + }, + PasswordCallback: func(conn ssh.ConnMetadata, password []byte) (*ssh.Permissions, error) { state.attempts++ From 445f61e8c349b1d39741c42ec026e3c66c20761b Mon Sep 17 00:00:00 2001 From: Georigy Sitnikov Date: Tue, 23 Dec 2025 14:12:42 +0100 Subject: [PATCH 06/10] - Major change add profiles to match banner to server version - Add `SSHD_PROFILE_SCOPE` for per host, or per ip profiles - Remove initial code to emulate succeed login - bad idea :( --- README.md | 15 +++-- main.go | 186 ++++++++++++++++++++++++++---------------------------- 2 files changed, 98 insertions(+), 103 deletions(-) diff --git a/README.md b/README.md index b2a2d1f..ea8e165 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ docker run -t -i --rm -p 2222:2222 justinazoff/ssh-auth-logger Docker compose example: ```yaml +# Create isolated network networks: isolated_net: driver: bridge @@ -72,12 +73,12 @@ services: container_name: ssh-auth-logger environment: # Following are default values - - SSHD_RATE=120 # bits per second, emulate very slow connection - - SSHD_BIND=:22 # Port and interface to listen - - SSHD_KEY_KEY="Take me to your leader" # It's a secret key that is used to generate a deterministic hash value for a given host IP address - - SSHD_MAX_AUTH_TRIES=6 # The minimum number of authentication attempts allowed - - SSHD_HOSTKEY_TYPE=rsa # Key type, could be 'rsa' or 'ed25519' - - SSHD_RSA_BITS=3072 # If you use 'rsa' you can also set RSA key size, 2048, 3072, 4096 (very rare) +# - SSHD_RATE=120 # bits per second, emulate very slow connection +# - SSHD_BIND=:2222 # Port and interface to listen +# - SSHD_KEY_KEY="Take me to your leader" # It's a secret key that is used to generate a deterministic hash value for a given host IP address +# - SSHD_MAX_AUTH_TRIES=6 # The minimum number of authentication attempts allowed +# - SSHD_RSA_BITS=3072 # If you use 'rsa' you can also set RSA key size, 2048, 3072, 4096 (very rare) +# - SSHD_PROFILE_SCOPE=remote_ip # Can be 'remote_ip' (each remote IP gets its own profile, simulating per-attacker behavior.), or anything else for 'host' (the same local host always gets the same profile). - TZ=Europe/Berlin # You can set Time Zone to see logs with your local time volumes: # Mount log file if needed @@ -94,7 +95,7 @@ services: cpus: '0.50' memory: 100M healthcheck: - # Will test if port is still open AND log file was not + # Will test if port is still open AND log file was not vanished by host machine log rotate test: wget -v localhost$SSHD_BIND --no-verbose --tries=1 --spider && test -s /var/log/ssh-auth-logger.log || exit 1 interval: 5m00s timeout: 5s diff --git a/main.go b/main.go index 597b7b9..97033a1 100644 --- a/main.go +++ b/main.go @@ -33,8 +33,8 @@ var ( sshd_key_key string rate int maxAuthTries int - hostKeyType string // "rsa" or "ed25519" rsaBits int // only used if hostKeyType == "rsa" + profileScope string // "host" or "remote_ip" ) // rateLimitedConn is a wrapper around net.Conn that limits the bandwidth. @@ -46,10 +46,19 @@ type rateLimitedConn struct { lastUpdate time.Time } +// Currently state is not shared between connections +// multiple attackers can "reset” delays by opening new connections type authState struct { attempts int } +// Create profile to match banner and Server Version +type serverProfile struct { + ServerVersion string + LoginBanner string + HostKeyType string // "rsa" or "ed25519" +} + // newRateLimitedConn returns a new rateLimitedConn. func newRateLimitedConn(conn net.Conn, rate int) *rateLimitedConn { return &rateLimitedConn{ @@ -150,11 +159,6 @@ func logParameters(conn ssh.ConnMetadata) logrus.Fields { } } -func authDelay(base, jitter time.Duration) { - d := base + time.Duration(rand.Int63n(int64(jitter))) - time.Sleep(d) -} - func HashToInt64(message, key []byte) int64 { mac := hmac.New(sha256.New, key) mac.Write(message) @@ -171,12 +175,12 @@ func getHost(addr string) string { return host } -func getHostKeySigner(host string) (ssh.Signer, error) { - seed := HashToInt64([]byte(host), []byte(sshd_key_key)) +func getHostKeySigner(host, keyType string) (ssh.Signer, error) { + seed := HashToInt64([]byte(host+":"+keyType), []byte(sshd_key_key)) + // Fine for honeypot — no security issue. Do not use for real keys. rng := rand.New(rand.NewSource(seed)) - switch hostKeyType { - + switch keyType { case "ed25519": _, priv, err := ed25519.GenerateKey(rng) if err != nil { @@ -192,51 +196,74 @@ func getHostKeySigner(host string) (ssh.Signer, error) { return ssh.NewSignerFromKey(key) default: - return nil, errors.New("unsupported SSHD_HOSTKEY_TYPE") + return nil, errors.New("unsupported host key type") } } -var serverVersions = []string{ - "SSH-2.0-OpenSSH_7.4", - "SSH-2.0-OpenSSH_7.9p1 Debian-10", - "SSH-2.0-OpenSSH_8.2p1 Ubuntu-4ubuntu0.5", - "SSH-2.0-OpenSSH_8.4", - "SSH-2.0-dropbear_2019.78", +var serverProfiles = []serverProfile{ + { + ServerVersion: "SSH-2.0-OpenSSH_7.4", + LoginBanner: "CentOS Linux 7 (Core)\n\nAll connections are monitored.\n", + HostKeyType: "rsa", + }, + { + ServerVersion: "SSH-2.0-OpenSSH_7.9p1 Debian-10", + LoginBanner: "Debian GNU/Linux 10\n\nAuthorized users only.\n", + HostKeyType: "rsa", + }, + { + ServerVersion: "SSH-2.0-OpenSSH_8.2p1 Ubuntu-4ubuntu0.5", + LoginBanner: "Ubuntu 20.04.6 LTS\n\nUnauthorized access prohibited.\n", + HostKeyType: "ed25519", + }, + { + ServerVersion: "SSH-2.0-OpenSSH_8.4", + LoginBanner: "Debian GNU/Linux 11\n\nAuthorized users only.\n", + HostKeyType: "ed25519", + }, + { + ServerVersion: "SSH-2.0-dropbear_2019.78", + LoginBanner: "Welcome to Dropbear SSH Server\n\nUnauthorized access is prohibited.\n", + HostKeyType: "rsa", + }, } -var loginBanners = []string{ - "Ubuntu 20.04.6 LTS\n\nUnauthorized access prohibited.\n", - "Debian GNU/Linux 11\n\nAuthorized users only.\n", - "CentOS Linux 7 (Core)\n\nAll connections are monitored.\n", - "Warning: This system is for authorized use only.\n", - "Welcome to OpenSSH Server\n\nUnauthorized access is prohibited.\n", -} - -func getServerVersion(host string) string { - seed := HashToInt64([]byte(host), []byte(sshd_key_key)) +func getServerProfile(host string) serverProfile { + seed := HashToInt64([]byte("profile:"+host), []byte(sshd_key_key)) if seed < 0 { seed = -seed } - return serverVersions[int(seed)%len(serverVersions)] + return serverProfiles[int(seed)%len(serverProfiles)] } -// Select Banner per host, same host --> same banner -func getLoginBanner(host string) string { - seed := HashToInt64([]byte("banner:"+host), []byte(sshd_key_key)) - if seed < 0 { - seed = -seed +func makeSSHConfig(conn net.Conn) ssh.ServerConfig { + state := &authState{} + // per‑local host profile +// profile := getServerProfile(host) + // per‑IP profile +// profile := getServerProfile(conn.RemoteAddr().String()) + // Determine the key for profile lookup + var profileKey string + if profileScope == "remote_ip" { + profileKey = conn.RemoteAddr().String() + } else { // default "host" + profileKey = getHost(conn.LocalAddr().String()) } - return loginBanners[int(seed)%len(loginBanners)] -} -func makeSSHConfig(host string) ssh.ServerConfig { - state := &authState{} + profile := getServerProfile(profileKey) + // Generate primary host key signer + signer, err := getHostKeySigner(profileKey, profile.HostKeyType) + if err != nil { + logrus.Panic(err) + } + + // Capture the actual host key type + actualHostKeyType := signer.PublicKey().Type() config := ssh.ServerConfig{ BannerCallback: func(conn ssh.ConnMetadata) string { - // Small delay makes it feel real time.Sleep(time.Duration(100+rand.Intn(200)) * time.Millisecond) - return getLoginBanner(host) + return profile.LoginBanner }, PasswordCallback: func(conn ssh.ConnMetadata, password []byte) (*ssh.Permissions, error) { @@ -247,8 +274,10 @@ func makeSSHConfig(host string) ssh.ServerConfig { time.Sleep(base + jitter) logger.WithFields(logParameters(conn)). - WithField("password", string(password)). - Info("Request with password") + WithFields(logrus.Fields{ + "password": password, + "server_key_type": actualHostKeyType, + }).Info("Request with password") return nil, errAuthenticationFailed }, @@ -262,73 +291,37 @@ func makeSSHConfig(host string) ssh.ServerConfig { logger.WithFields(logParameters(conn)). WithFields(logrus.Fields{ - "keytype": key.Type(), + "keytype": key.Type(), "fingerprint": ssh.FingerprintSHA256(key), + "server_key_type": actualHostKeyType, }).Info("Request with key") return nil, errAuthenticationFailed }, - ServerVersion: getServerVersion(host), - MaxAuthTries: maxAuthTries + rand.Intn(5), + ServerVersion: profile.ServerVersion, + MaxAuthTries: maxAuthTries + rand.Intn(5), } - signer, err := getHostKeySigner(host) - if err != nil { - logrus.Panic(err) - } config.AddHostKey(signer) + // Compatibility: add RSA fallback if primary is ED25519 + if profile.HostKeyType == "ed25519" { + if rsaSigner, err := getHostKeySigner(profileKey, "rsa"); err == nil { + config.AddHostKey(rsaSigner) + } + } + return config } -// Accept Session Channels but Never Grant Shell func handleConnection(conn net.Conn, config *ssh.ServerConfig) { - serverConn, chans, reqs, err := ssh.NewServerConn(conn, config) + _, _, _, err := ssh.NewServerConn(conn, config) if err != nil { + // Auth failed or client closed connection — expected behavior return } - logger.WithFields(logParameters(serverConn)).Info("SSH connection established") - - go ssh.DiscardRequests(reqs) - - for ch := range chans { - if ch.ChannelType() != "session" { - ch.Reject(ssh.UnknownChannelType, "unsupported") - continue - } - - channel, requests, err := ch.Accept() - if err != nil { - continue - } - - go func(in <-chan *ssh.Request) { - for req := range in { - switch req.Type { - - case "pty-req": - req.Reply(true, nil) - - case "env": - req.Reply(true, nil) - - case "exec": - logger.WithField("cmd", string(req.Payload)).Info("Exec attempt") - req.Reply(false, nil) - - case "shell": - // Fake success, no shell - req.Reply(true, nil) - time.Sleep(2 * time.Second) - channel.Close() - - default: - req.Reply(false, nil) - } - } - }(requests) - } + // This should never happen because auth never succeeds } //getEnvWithDefault returns the environment value for key @@ -357,13 +350,14 @@ func init() { if err != nil { logrus.Fatal("Invalid SSHD_MAX_AUTH_TRIES environment variable") } - hostKeyType = getEnvWithDefault("SSHD_HOSTKEY_TYPE", "rsa") rsaBitsStr := getEnvWithDefault("SSHD_RSA_BITS", "3072") rsaBits, err = strconv.Atoi(rsaBitsStr) if err != nil || rsaBits < 2048 { logrus.Fatal("Invalid SSHD_RSA_BITS (must be >= 2048)") } - // seed for non-deterministic uses to avoid identical timing patterns across restarts + profileScope = getEnvWithDefault("SSHD_PROFILE_SCOPE", "host") + // Seed for non-deterministic uses to avoid identical timing patterns across restarts + // Fine for delays and banner selection — no security issue. rand.Seed(time.Now().UnixNano()) // Show Configuration on Startup logrus.WithFields(logrus.Fields{ @@ -371,8 +365,8 @@ func init() { "SSHD_KEY_KEY": sshd_key_key, "SSHD_RATE": rate, "SSHD_MAX_AUTH_TRIES": maxAuthTries, - "SSHD_HOSTKEY_TYPE": hostKeyType, "SSHD_RSA_BITS": rsaBitsStr, + "SSHD_PROFILE_SCOPE": profileScope, }).Info("Starting SSH Auth Logger") } @@ -390,9 +384,9 @@ func main() { logger.WithFields(connLogParameters(conn)).Info("Connection") limitedConn := newRateLimitedConn(conn, rate) - host := getHost(conn.LocalAddr().String()) + //host := getHost(conn.LocalAddr().String()) - config := makeSSHConfig(host) // NEW CONFIG PER CONNECTION + config := makeSSHConfig(conn) // NEW CONFIG PER CONNECTION go handleConnection(limitedConn, &config) } From ec7fb323aa649619c6a3ceddfddb4299cedf675e Mon Sep 17 00:00:00 2001 From: Georigy Sitnikov Date: Tue, 23 Dec 2025 14:17:14 +0100 Subject: [PATCH 07/10] - Readme refresh --- README.md | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index ea8e165..0572b70 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,16 @@ +# SSH Auth Logger + A low/zero interaction ssh authentication logging honeypot ## Interesting features ### Structured logging -ssh-auth-logger logs all authentication attempts as json making it easy to -consume in other tools. No more ugly [openssh log parsing -vulnerabilities](http://dcid.me/texts/attacking-log-analysis-tools.html). +ssh-auth-logger logs all authentication attempts as json making it easy to consume in other tools. No more ugly [openssh log parsing vulnerabilities](http://dcid.me/texts/attacking-log-analysis-tools.html). ### "Random" host keys -ssh-auth-logger uses HMAC to hash the destination IP address and a key in order to -generate a consistently "random" key for every responding IP address. This -means you can run ssh-auth-logger on a /16 and every ip address will appear -with a different host key. TODO: add random sshd version reporting as well. +ssh-auth-logger uses HMAC to hash the destination IP address and a key in order to generate a consistently "random" key for every responding IP address. This means you can run ssh-auth-logger on a /16 and every ip address will appear with a different host key. Random sshd version reporting as well. ## Example log entry @@ -30,7 +27,8 @@ This is normally logged on one line "msg": "Request with password", "password": "P@ssword1", "product": "ssh-auth-logger", - "server_version": "SSH-2.0-OpenSSH_5.3", + "server_version": "SSH-2.0-dropbear_2019.78", + "server_key_type":"ssh-rsa", "spt": "38624", "src": "192.168.1.4", "time": "2017-11-17T19:16:37-05:00" From e676ec4bb2ff2f61f3279d18ece26d4c01fa6a56 Mon Sep 17 00:00:00 2001 From: Georigy Sitnikov Date: Tue, 23 Dec 2025 14:27:24 +0100 Subject: [PATCH 08/10] Add back initial code part --- main.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/main.go b/main.go index 97033a1..fb3bcf4 100644 --- a/main.go +++ b/main.go @@ -317,11 +317,14 @@ func makeSSHConfig(conn net.Conn) ssh.ServerConfig { func handleConnection(conn net.Conn, config *ssh.ServerConfig) { _, _, _, err := ssh.NewServerConn(conn, config) + if err == nil { + // This should never happen because auth never succeeds + logrus.Panic("Successful login? why!?") + } if err != nil { // Auth failed or client closed connection — expected behavior return } - // This should never happen because auth never succeeds } //getEnvWithDefault returns the environment value for key From f2a49be447c0443b0999035729188c806b8f77d3 Mon Sep 17 00:00:00 2001 From: Georigy Sitnikov Date: Sun, 4 Jan 2026 14:26:18 +0100 Subject: [PATCH 09/10] Make SSH Login Banner configurable, defalut --> do not send any banner. --- README.md | 1 + main.go | 19 +++++++++++++------ 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 0572b70..228174b 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,7 @@ services: # - SSHD_MAX_AUTH_TRIES=6 # The minimum number of authentication attempts allowed # - SSHD_RSA_BITS=3072 # If you use 'rsa' you can also set RSA key size, 2048, 3072, 4096 (very rare) # - SSHD_PROFILE_SCOPE=remote_ip # Can be 'remote_ip' (each remote IP gets its own profile, simulating per-attacker behavior.), or anything else for 'host' (the same local host always gets the same profile). +# - SSHD_SEND_BANNER=false # Send SSH Login Banner before Password prompt - TZ=Europe/Berlin # You can set Time Zone to see logs with your local time volumes: # Mount log file if needed diff --git a/main.go b/main.go index fb3bcf4..fd826b8 100644 --- a/main.go +++ b/main.go @@ -35,6 +35,7 @@ var ( maxAuthTries int rsaBits int // only used if hostKeyType == "rsa" profileScope string // "host" or "remote_ip" + sendBanner bool ) // rateLimitedConn is a wrapper around net.Conn that limits the bandwidth. @@ -261,11 +262,6 @@ func makeSSHConfig(conn net.Conn) ssh.ServerConfig { actualHostKeyType := signer.PublicKey().Type() config := ssh.ServerConfig{ - BannerCallback: func(conn ssh.ConnMetadata) string { - time.Sleep(time.Duration(100+rand.Intn(200)) * time.Millisecond) - return profile.LoginBanner - }, - PasswordCallback: func(conn ssh.ConnMetadata, password []byte) (*ssh.Permissions, error) { state.attempts++ @@ -303,6 +299,14 @@ func makeSSHConfig(conn net.Conn) ssh.ServerConfig { MaxAuthTries: maxAuthTries + rand.Intn(5), } + // 🔐 Banner only if enabled + if sendBanner { + config.BannerCallback = func(conn ssh.ConnMetadata) string { + time.Sleep(time.Duration(100+rand.Intn(200)) * time.Millisecond) + return profile.LoginBanner + } + } + config.AddHostKey(signer) // Compatibility: add RSA fallback if primary is ED25519 @@ -362,6 +366,9 @@ func init() { // Seed for non-deterministic uses to avoid identical timing patterns across restarts // Fine for delays and banner selection — no security issue. rand.Seed(time.Now().UnixNano()) + // Banner sending option + sendBannerStr := getEnvWithDefault("SSHD_SEND_BANNER", "false") + sendBanner = sendBannerStr == "1" || sendBannerStr == "true" || sendBannerStr == "yes" // Show Configuration on Startup logrus.WithFields(logrus.Fields{ "SSHD_BIND": sshd_bind, @@ -370,6 +377,7 @@ func init() { "SSHD_MAX_AUTH_TRIES": maxAuthTries, "SSHD_RSA_BITS": rsaBitsStr, "SSHD_PROFILE_SCOPE": profileScope, + "SSHD_SEND_BANNER": sendBanner, }).Info("Starting SSH Auth Logger") } @@ -387,7 +395,6 @@ func main() { logger.WithFields(connLogParameters(conn)).Info("Connection") limitedConn := newRateLimitedConn(conn, rate) - //host := getHost(conn.LocalAddr().String()) config := makeSSHConfig(conn) // NEW CONFIG PER CONNECTION go handleConnection(limitedConn, &config) From 11f5b46498672a2b8602801076eab3ee987f0763 Mon Sep 17 00:00:00 2001 From: Georigy Sitnikov Date: Sun, 4 Jan 2026 14:37:37 +0100 Subject: [PATCH 10/10] Make passwords logging configurable (clear text vs BASE64) and set default to clear text as it initially was --- README.md | 1 + main.go | 26 ++++++++++++++++++-------- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 228174b..38dee87 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,7 @@ services: # - SSHD_RSA_BITS=3072 # If you use 'rsa' you can also set RSA key size, 2048, 3072, 4096 (very rare) # - SSHD_PROFILE_SCOPE=remote_ip # Can be 'remote_ip' (each remote IP gets its own profile, simulating per-attacker behavior.), or anything else for 'host' (the same local host always gets the same profile). # - SSHD_SEND_BANNER=false # Send SSH Login Banner before Password prompt +# - SSHD_LOG_CLEAR_PASSWORD=true # Log Passwords as clear text or Base64 coded - TZ=Europe/Berlin # You can set Time Zone to see logs with your local time volumes: # Mount log file if needed diff --git a/main.go b/main.go index fd826b8..6f68c1a 100644 --- a/main.go +++ b/main.go @@ -36,6 +36,7 @@ var ( rsaBits int // only used if hostKeyType == "rsa" profileScope string // "host" or "remote_ip" sendBanner bool + logClearPassword bool ) // rateLimitedConn is a wrapper around net.Conn that limits the bandwidth. @@ -269,9 +270,14 @@ func makeSSHConfig(conn net.Conn) ssh.ServerConfig { jitter := time.Duration(rand.Intn(400)) * time.Millisecond time.Sleep(base + jitter) + var loggedPassword any = password + if logClearPassword { + loggedPassword = string(password) + } + logger.WithFields(logParameters(conn)). WithFields(logrus.Fields{ - "password": password, + "password": loggedPassword, "server_key_type": actualHostKeyType, }).Info("Request with password") @@ -369,15 +375,19 @@ func init() { // Banner sending option sendBannerStr := getEnvWithDefault("SSHD_SEND_BANNER", "false") sendBanner = sendBannerStr == "1" || sendBannerStr == "true" || sendBannerStr == "yes" + logClearPasswordStr := getEnvWithDefault("SSHD_LOG_CLEAR_PASSWORD", "true") + logClearPassword = logClearPasswordStr == "1" || logClearPasswordStr == "true" || logClearPasswordStr == "yes" + // Show Configuration on Startup logrus.WithFields(logrus.Fields{ - "SSHD_BIND": sshd_bind, - "SSHD_KEY_KEY": sshd_key_key, - "SSHD_RATE": rate, - "SSHD_MAX_AUTH_TRIES": maxAuthTries, - "SSHD_RSA_BITS": rsaBitsStr, - "SSHD_PROFILE_SCOPE": profileScope, - "SSHD_SEND_BANNER": sendBanner, + "SSHD_BIND": sshd_bind, + "SSHD_KEY_KEY": sshd_key_key, + "SSHD_RATE": rate, + "SSHD_MAX_AUTH_TRIES": maxAuthTries, + "SSHD_RSA_BITS": rsaBitsStr, + "SSHD_PROFILE_SCOPE": profileScope, + "SSHD_SEND_BANNER": sendBanner, + "SSHD_LOG_CLEAR_PASSWORD": logClearPassword, }).Info("Starting SSH Auth Logger") }