diff --git a/README.md b/README.md index 359a286..38dee87 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" @@ -61,20 +59,35 @@ docker run -t -i --rm -p 2222:2222 justinazoff/ssh-auth-logger Docker compose example: -```shell +```yaml +# Create isolated network +networks: + isolated_net: + driver: bridge + 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=: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). +# - 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 - /var/docker/ssh-auth-logger/log:/var/log ports: - 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: @@ -82,7 +95,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 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 retries: 2 diff --git a/main.go b/main.go index 08b1851..6f68c1a 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,7 @@ package main import ( + "crypto/ed25519" "crypto/hmac" "crypto/rsa" "crypto/sha256" @@ -10,8 +11,8 @@ import ( "math/rand" "net" "os" - "time" "strconv" + "time" "github.com/sirupsen/logrus" "golang.org/x/crypto/ssh" @@ -31,6 +32,11 @@ var ( sshd_bind string sshd_key_key string rate int + maxAuthTries int + 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. @@ -42,6 +48,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{ @@ -142,23 +161,6 @@ func logParameters(conn ssh.ConnMetadata) logrus.Fields { } } -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") - 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) @@ -175,57 +177,164 @@ 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 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)) - randomSeed := HashToInt64([]byte(host), []byte(sshd_key_key)) - randomSource := rand.New(rand.NewSource(randomSeed)) + switch keyType { + case "ed25519": + _, priv, err := ed25519.GenerateKey(rng) + if err != nil { + return nil, err + } + return ssh.NewSignerFromKey(priv) - key, err := rsa.GenerateKey(randomSource, 1024) - if err != nil { - return key, err + case "rsa": + key, err := rsa.GenerateKey(rng, rsaBits) + if err != nil { + return nil, err + } + return ssh.NewSignerFromKey(key) + + default: + return nil, errors.New("unsupported host key type") } - return key, err } -var serverVersions = []string{ - "SSH-2.0-libssh-0.6.1", +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", + }, } -func getServerVersion(host string) string { - randomSeed := HashToInt64([]byte(host), []byte(sshd_key_key)) - if randomSeed < 0 { - randomSeed = -randomSeed +func getServerProfile(host string) serverProfile { + seed := HashToInt64([]byte("profile:"+host), []byte(sshd_key_key)) + if seed < 0 { + seed = -seed } - n := int(randomSeed) % len(serverVersions) - return serverVersions[n] + return serverProfiles[int(seed)%len(serverProfiles)] } -func makeSSHConfig(host string) ssh.ServerConfig { - config := ssh.ServerConfig{ - PasswordCallback: authenticatePassword, - PublicKeyCallback: authenticateKey, - ServerVersion: getServerVersion(host), - MaxAuthTries: 3, +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()) } - privateKey, err := getKey(host) + profile := getServerProfile(profileKey) + // Generate primary host key signer + signer, err := getHostKeySigner(profileKey, profile.HostKeyType) if err != nil { logrus.Panic(err) } - hostPrivateKeySigner, err := ssh.NewSignerFromKey(privateKey) - if err != nil { - logrus.Panic(err) + + // Capture the actual host key type + actualHostKeyType := signer.PublicKey().Type() + + config := ssh.ServerConfig{ + PasswordCallback: func(conn ssh.ConnMetadata, password []byte) (*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) + + var loggedPassword any = password + if logClearPassword { + loggedPassword = string(password) + } + + logger.WithFields(logParameters(conn)). + WithFields(logrus.Fields{ + "password": loggedPassword, + "server_key_type": actualHostKeyType, + }).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), + "server_key_type": actualHostKeyType, + }).Info("Request with key") + + return nil, errAuthenticationFailed + }, + + ServerVersion: profile.ServerVersion, + 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 + if profile.HostKeyType == "ed25519" { + if rsaSigner, err := getHostKeySigner(profileKey, "rsa"); err == nil { + config.AddHostKey(rsaSigner) + } } - config.AddHostKey(hostPrivateKeySigner) + return config } 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 + } } //getEnvWithDefault returns the environment value for key @@ -247,19 +356,42 @@ 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") + } + 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)") } + 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()) + // 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_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") } func main() { - sshConfigMap := make(map[string]ssh.ServerConfig) socket, err := net.Listen("tcp", sshd_bind) if err != nil { panic(err) @@ -269,16 +401,13 @@ 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(conn) // NEW CONFIG PER CONNECTION go handleConnection(limitedConn, &config) } + } \ No newline at end of file