Initial commit
This commit is contained in:
11
honeypot/actions.go
Normal file
11
honeypot/actions.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package honeypot
|
||||
|
||||
type ActionType int
|
||||
|
||||
const (
|
||||
ActionTypeLogPassword ActionType = iota
|
||||
ActionTypeLogPasswordSlow
|
||||
ActionTypeLogCommandAndExit
|
||||
ActionTypeSendGarbage
|
||||
)
|
||||
const ActionTypeDefault ActionType = ActionTypeLogPassword
|
64
honeypot/conn.go
Normal file
64
honeypot/conn.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package honeypot
|
||||
|
||||
import (
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/fujiwara/shapeio"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type throttledConn struct {
|
||||
ID uuid.UUID
|
||||
conn net.Conn
|
||||
speed float64
|
||||
CloseCallback func(c *throttledConn)
|
||||
}
|
||||
|
||||
func newThrottledConn(conn net.Conn) *throttledConn {
|
||||
id := uuid.Must(uuid.NewRandom())
|
||||
return &throttledConn{ID: id, conn: conn, speed: 1024 * 10}
|
||||
}
|
||||
|
||||
func (sc *throttledConn) SetSpeed(bytesPerSec float64) {
|
||||
sc.speed = bytesPerSec
|
||||
}
|
||||
|
||||
func (sc *throttledConn) Read(b []byte) (n int, err error) {
|
||||
slowReader := shapeio.NewReader(sc.conn)
|
||||
slowReader.SetRateLimit(sc.speed)
|
||||
return slowReader.Read(b)
|
||||
}
|
||||
|
||||
func (sc *throttledConn) Write(b []byte) (n int, err error) {
|
||||
slowWriter := shapeio.NewWriter(sc.conn)
|
||||
slowWriter.SetRateLimit(sc.speed)
|
||||
return slowWriter.Write(b)
|
||||
}
|
||||
|
||||
func (sc *throttledConn) Close() error {
|
||||
if sc.CloseCallback != nil {
|
||||
sc.CloseCallback(sc)
|
||||
}
|
||||
return sc.conn.Close()
|
||||
}
|
||||
|
||||
func (sc *throttledConn) LocalAddr() net.Addr {
|
||||
return sc.conn.LocalAddr()
|
||||
}
|
||||
|
||||
func (sc *throttledConn) RemoteAddr() net.Addr {
|
||||
return sc.conn.RemoteAddr()
|
||||
}
|
||||
|
||||
func (sc *throttledConn) SetDeadline(t time.Time) error {
|
||||
return sc.conn.SetDeadline(t)
|
||||
}
|
||||
|
||||
func (sc *throttledConn) SetReadDeadline(t time.Time) error {
|
||||
return sc.conn.SetReadDeadline(t)
|
||||
}
|
||||
|
||||
func (sc *throttledConn) SetWriteDeadline(t time.Time) error {
|
||||
return sc.conn.SetWriteDeadline(t)
|
||||
}
|
24
honeypot/geolocate.go
Normal file
24
honeypot/geolocate.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package honeypot
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"net"
|
||||
|
||||
"github.com/oschwald/geoip2-golang"
|
||||
)
|
||||
|
||||
//go:embed Geoacumen-Country.mmdb
|
||||
var mmdb []byte
|
||||
|
||||
func LookupCountry(ip net.IP) string {
|
||||
db, err := geoip2.FromBytes(mmdb)
|
||||
if err != nil {
|
||||
return "??"
|
||||
}
|
||||
|
||||
country, err := db.Country(ip)
|
||||
if err != nil {
|
||||
return "??"
|
||||
}
|
||||
return country.Country.IsoCode
|
||||
}
|
106
honeypot/server.go
Normal file
106
honeypot/server.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package honeypot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.uio.no/torjus/apiary/config"
|
||||
|
||||
"github.com/gliderlabs/ssh"
|
||||
"github.com/google/uuid"
|
||||
"github.uio.no/torjus/apiary/honeypot/store"
|
||||
"github.uio.no/torjus/apiary/models"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type HoneypotServer struct {
|
||||
attemptStore store.LoginAttemptStore
|
||||
attemptsCallbacks []func(l models.LoginAttempt)
|
||||
|
||||
sshServer *ssh.Server
|
||||
|
||||
Logger *zap.SugaredLogger
|
||||
}
|
||||
|
||||
func NewHoneypotServer(cfg config.HoneypotConfig, store store.LoginAttemptStore) (*HoneypotServer, error) {
|
||||
var hs HoneypotServer
|
||||
hs.attemptStore = store
|
||||
hs.Logger = zap.NewNop().Sugar()
|
||||
|
||||
hs.sshServer = &ssh.Server{
|
||||
Addr: cfg.ListenAddr,
|
||||
PasswordHandler: hs.passwordHandler,
|
||||
ConnCallback: hs.connCallback,
|
||||
Handler: handler,
|
||||
}
|
||||
|
||||
return &hs, nil
|
||||
}
|
||||
|
||||
func (hs *HoneypotServer) ListenAndServe() error {
|
||||
return hs.sshServer.ListenAndServe()
|
||||
}
|
||||
|
||||
func (hs *HoneypotServer) Shutdown(ctx context.Context) error {
|
||||
return hs.sshServer.Shutdown(ctx)
|
||||
}
|
||||
|
||||
func (hs *HoneypotServer) AddLoginCallback(c func(l models.LoginAttempt)) {
|
||||
hs.attemptsCallbacks = append(hs.attemptsCallbacks, c)
|
||||
}
|
||||
|
||||
func (hs *HoneypotServer) passwordHandler(ctx ssh.Context, password string) bool {
|
||||
sessUUID, ok := ctx.Value("uuid").(uuid.UUID)
|
||||
if !ok {
|
||||
hs.Logger.Warn("Unable to get session UUID")
|
||||
return false
|
||||
}
|
||||
|
||||
la := models.LoginAttempt{
|
||||
Date: time.Now(),
|
||||
RemoteIP: ipFromAddr(ctx.RemoteAddr().String()),
|
||||
Username: ctx.User(),
|
||||
Password: password,
|
||||
SSHClientVersion: ctx.ClientVersion(),
|
||||
ConnectionUUID: sessUUID,
|
||||
}
|
||||
country := LookupCountry(la.RemoteIP)
|
||||
la.Country = country
|
||||
hs.Logger.Infow("Login attempt",
|
||||
"remote_ip", la.RemoteIP.String(),
|
||||
"username", la.Username,
|
||||
"password", la.Password)
|
||||
|
||||
if err := hs.attemptStore.AddAttempt(&la); err != nil {
|
||||
hs.Logger.Warnf("Error adding attempt to store")
|
||||
}
|
||||
|
||||
for _, cFunc := range hs.attemptsCallbacks {
|
||||
cFunc(la)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *HoneypotServer) connCallback(ctx ssh.Context, conn net.Conn) net.Conn {
|
||||
throttledConn := newThrottledConn(conn)
|
||||
ctx.SetValue("uuid", throttledConn.ID)
|
||||
throttledConn.SetSpeed(1024)
|
||||
return throttledConn
|
||||
}
|
||||
|
||||
func handler(session ssh.Session) {
|
||||
_, _ = io.WriteString(session, "[root@hostname ~]#")
|
||||
session.Exit(1)
|
||||
}
|
||||
|
||||
func ipFromAddr(addr string) net.IP {
|
||||
host, _, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return net.ParseIP(host)
|
||||
}
|
127
honeypot/store/memory.go
Normal file
127
honeypot/store/memory.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"github.uio.no/torjus/apiary/models"
|
||||
)
|
||||
|
||||
type MemoryStore struct {
|
||||
lock sync.RWMutex
|
||||
attempts []models.LoginAttempt
|
||||
currentID int
|
||||
}
|
||||
|
||||
type StatItem struct {
|
||||
Key string
|
||||
Count int
|
||||
}
|
||||
|
||||
type StatItems []StatItem
|
||||
|
||||
func (ms *MemoryStore) AddAttempt(l *models.LoginAttempt) error {
|
||||
ms.lock.Lock()
|
||||
defer ms.lock.Unlock()
|
||||
l.ID = ms.currentID + 1
|
||||
ms.currentID = ms.currentID + 1
|
||||
|
||||
ms.attempts = append(ms.attempts, *l)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ms *MemoryStore) All() ([]models.LoginAttempt, error) {
|
||||
return ms.attempts, nil
|
||||
}
|
||||
|
||||
func (ms *MemoryStore) Stats(statType LoginStats, limit int) ([]StatsResult, error) {
|
||||
counts := make(map[string]int)
|
||||
|
||||
if statType == LoginStatsTotals {
|
||||
return ms.statTotals()
|
||||
}
|
||||
|
||||
ms.lock.RLock()
|
||||
defer ms.lock.RUnlock()
|
||||
|
||||
for _, a := range ms.attempts {
|
||||
switch statType {
|
||||
case LoginStatsPasswords:
|
||||
counts[a.Password]++
|
||||
case LoginStatsCountry:
|
||||
counts[a.Country]++
|
||||
case LoginStatsIP:
|
||||
counts[a.RemoteIP.String()]++
|
||||
case LoginStatsUsername:
|
||||
counts[a.Username]++
|
||||
default:
|
||||
return nil, fmt.Errorf("Invalid stat type")
|
||||
}
|
||||
}
|
||||
|
||||
if limit < 1 {
|
||||
return toResults(counts), nil
|
||||
}
|
||||
if limit >= len(counts) {
|
||||
return toResults(counts), nil
|
||||
}
|
||||
|
||||
var si StatItems
|
||||
for key := range counts {
|
||||
si = append(si, StatItem{Key: key, Count: counts[key]})
|
||||
}
|
||||
sort.Sort(si)
|
||||
|
||||
output := make(map[string]int)
|
||||
for i := len(si) - 1; i > len(si)-limit-1; i-- {
|
||||
output[si[i].Key] = si[i].Count
|
||||
}
|
||||
return toResults(output), nil
|
||||
}
|
||||
|
||||
func (ss StatItems) Len() int {
|
||||
return len(ss)
|
||||
}
|
||||
func (ss StatItems) Less(i, j int) bool {
|
||||
return ss[i].Count < ss[j].Count
|
||||
}
|
||||
func (ss StatItems) Swap(i, j int) {
|
||||
ss[i], ss[j] = ss[j], ss[i]
|
||||
}
|
||||
|
||||
func (ms *MemoryStore) statTotals() ([]StatsResult, error) {
|
||||
passwords := make(map[string]int)
|
||||
usernames := make(map[string]int)
|
||||
ips := make(map[string]int)
|
||||
countries := make(map[string]int)
|
||||
|
||||
ms.lock.RLock()
|
||||
defer ms.lock.RUnlock()
|
||||
|
||||
for _, val := range ms.attempts {
|
||||
passwords[val.Password] += 1
|
||||
usernames[val.Username] += 1
|
||||
ips[val.RemoteIP.String()] += 1
|
||||
countries[val.Country] += 1
|
||||
}
|
||||
|
||||
stats := []StatsResult{
|
||||
{Name: "UniquePasswords", Count: len(passwords)},
|
||||
{Name: "UniqueUsernames", Count: len(usernames)},
|
||||
{Name: "UniqueIPs", Count: len(ips)},
|
||||
{Name: "UniqueCountries", Count: len(countries)},
|
||||
{Name: "TotalLoginAttempts", Count: len(ms.attempts)},
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func toResults(m map[string]int) []StatsResult {
|
||||
var results []StatsResult
|
||||
|
||||
for key, value := range m {
|
||||
results = append(results, StatsResult{key, value})
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
119
honeypot/store/memory_test.go
Normal file
119
honeypot/store/memory_test.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package store
|
||||
|
||||
/*
|
||||
func TestStatItems(t *testing.T) {
|
||||
var tc = []struct {
|
||||
Input StatItems
|
||||
ExpectedOutput StatItems
|
||||
}{
|
||||
{
|
||||
Input: StatItems{
|
||||
{Key: "a", Count: 5},
|
||||
{Key: "b", Count: 100},
|
||||
{Key: "c", Count: 99},
|
||||
{Key: "d", Count: 98},
|
||||
{Key: "f", Count: 18},
|
||||
},
|
||||
ExpectedOutput: StatItems{
|
||||
{Key: "a", Count: 5},
|
||||
{Key: "f", Count: 18},
|
||||
{Key: "d", Count: 98},
|
||||
{Key: "c", Count: 99},
|
||||
{Key: "b", Count: 100},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range tc {
|
||||
sort.Sort(testCase.Input)
|
||||
|
||||
for i := range testCase.Input {
|
||||
if testCase.Input[i] != testCase.ExpectedOutput[i] {
|
||||
t.Fatalf("Not sorted correctly")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStats(t *testing.T) {
|
||||
var exampleAttempts = []models.LoginAttempt{
|
||||
{Username: "root", Password: "root", Country: "NO"},
|
||||
{Username: "root", Password: "root", Country: "US"},
|
||||
{Username: "user", Password: "passWord", Country: "US"},
|
||||
{Username: "ibm", Password: "ibm", Country: "US"},
|
||||
{Username: "ubnt", Password: "ubnt", Country: "GB"},
|
||||
{Username: "ubnt", Password: "ubnt", Country: "FI"},
|
||||
{Username: "root", Password: "root", Country: "CH"},
|
||||
{Username: "ubnt", Password: "12345", Country: "DE"},
|
||||
{Username: "oracle", Password: "oracle", Country: "FI"},
|
||||
}
|
||||
var tc = []struct {
|
||||
Attempts []models.LoginAttempt
|
||||
StatType LoginStats
|
||||
Limit int
|
||||
ExpectedOutput map[string]int
|
||||
}{
|
||||
{
|
||||
Attempts: exampleAttempts,
|
||||
StatType: LoginStatsPasswords,
|
||||
Limit: 2,
|
||||
ExpectedOutput: map[string]int{
|
||||
"root": 3,
|
||||
"ubnt": 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
Attempts: exampleAttempts,
|
||||
StatType: LoginStatsPasswords,
|
||||
Limit: 1,
|
||||
ExpectedOutput: map[string]int{
|
||||
"root": 3,
|
||||
},
|
||||
},
|
||||
{
|
||||
Attempts: exampleAttempts,
|
||||
StatType: LoginStatsCountry,
|
||||
Limit: 2,
|
||||
ExpectedOutput: map[string]int{
|
||||
"US": 3,
|
||||
"FI": 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
Attempts: exampleAttempts,
|
||||
StatType: LoginStatsCountry,
|
||||
Limit: 0,
|
||||
ExpectedOutput: map[string]int{
|
||||
"US": 3,
|
||||
"FI": 2,
|
||||
"NO": 1,
|
||||
"GB": 1,
|
||||
"CH": 1,
|
||||
"DE": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range tc {
|
||||
ms := MemoryStore{}
|
||||
for _, a := range c.Attempts {
|
||||
if err := ms.AddAttempt(a); err != nil {
|
||||
t.Fatalf("Unable to add attempt: %s", err)
|
||||
}
|
||||
}
|
||||
stats, err := ms.Stats(c.StatType, c.Limit)
|
||||
if err != nil {
|
||||
t.Fatalf("Error getting stats: %s", err)
|
||||
}
|
||||
if len(stats) != len(c.ExpectedOutput) {
|
||||
t.Fatalf("Stats have wrong length")
|
||||
}
|
||||
for key := range stats {
|
||||
if c.ExpectedOutput[key] != stats[key] {
|
||||
t.Fatalf("Stats does not match expected output")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
*/
|
131
honeypot/store/postgres.go
Normal file
131
honeypot/store/postgres.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
_ "github.com/jackc/pgx/v4/stdlib"
|
||||
"github.uio.no/torjus/apiary/models"
|
||||
)
|
||||
|
||||
const DBSchema = `
|
||||
CREATE TABLE IF NOT EXISTS login_attempts(
|
||||
id serial PRIMARY KEY,
|
||||
date timestamptz,
|
||||
remote_ip inet,
|
||||
username text,
|
||||
password text,
|
||||
client_version text,
|
||||
connection_uuid uuid,
|
||||
country varchar(2)
|
||||
);
|
||||
`
|
||||
|
||||
type PostgresStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewPostgresStore(dsn string) (*PostgresStore, error) {
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rs := &PostgresStore{
|
||||
db: db,
|
||||
}
|
||||
return rs, nil
|
||||
}
|
||||
|
||||
func (s *PostgresStore) InitDB() error {
|
||||
_, err := s.db.Exec(DBSchema)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PostgresStore) AddAttempt(l *models.LoginAttempt) error {
|
||||
stmt := `INSERT INTO
|
||||
login_attempts(date, remote_ip, username, password, client_version, connection_uuid, country)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id;`
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var id int
|
||||
if err := tx.QueryRow(stmt, l.Date, l.RemoteIP.String(), l.Username, l.Password, l.SSHClientVersion, l.ConnectionUUID, l.Country).Scan(&id); err != nil {
|
||||
return err
|
||||
}
|
||||
l.ID = id
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PostgresStore) All() ([]models.LoginAttempt, error) {
|
||||
stmt := `SELECT date, remote_ip, username, password, client_version, connection_uuid, country FROM login_attempts`
|
||||
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows, err := tx.Query(stmt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var attempts []models.LoginAttempt
|
||||
for rows.Next() {
|
||||
var a models.LoginAttempt
|
||||
if err := rows.Scan(&a.Date, &a.RemoteIP, &a.Username, &a.Password, &a.SSHClientVersion, &a.SSHClientVersion, &a.Country); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
attempts = append(attempts, a)
|
||||
}
|
||||
|
||||
return attempts, nil
|
||||
}
|
||||
|
||||
func (s *PostgresStore) Stats(statType LoginStats, limit int) ([]StatsResult, error) {
|
||||
var stmt string
|
||||
|
||||
if statType == LoginStatsTotals {
|
||||
return s.statsTotal(limit)
|
||||
}
|
||||
|
||||
switch statType {
|
||||
case LoginStatsCountry:
|
||||
stmt = `select country, count(country) from login_attempts order by count desc`
|
||||
case LoginStatsIP:
|
||||
stmt = `select remote_ip, count(remote_ip) from login_attempts order by count desc`
|
||||
case LoginStatsPasswords:
|
||||
stmt = `select password, count(password) from login_attempts order by count desc`
|
||||
case LoginStatsUsername:
|
||||
stmt = `select username, count(username) from login_attempts order by count desc`
|
||||
}
|
||||
|
||||
if limit > 0 {
|
||||
stmt = fmt.Sprintf("%s limit %d", stmt, limit)
|
||||
}
|
||||
rows, err := s.db.Query(stmt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var results []StatsResult
|
||||
for rows.Next() {
|
||||
var r StatsResult
|
||||
|
||||
if err := rows.Scan(&r.Name, &r.Count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results = append(results, r)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (s *PostgresStore) statsTotal(limit int) ([]StatsResult, error) {
|
||||
return nil, nil
|
||||
}
|
25
honeypot/store/store.go
Normal file
25
honeypot/store/store.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package store
|
||||
|
||||
import "github.uio.no/torjus/apiary/models"
|
||||
|
||||
type LoginStats string
|
||||
|
||||
const (
|
||||
LoginStatsUndefined LoginStats = ""
|
||||
LoginStatsPasswords LoginStats = "password"
|
||||
LoginStatsCountry LoginStats = "country"
|
||||
LoginStatsIP LoginStats = "ips"
|
||||
LoginStatsUsername LoginStats = "username"
|
||||
LoginStatsTotals LoginStats = "totals"
|
||||
)
|
||||
|
||||
type StatsResult struct {
|
||||
Name string `json:"name"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
type LoginAttemptStore interface {
|
||||
AddAttempt(l *models.LoginAttempt) error
|
||||
All() ([]models.LoginAttempt, error)
|
||||
Stats(statType LoginStats, limit int) ([]StatsResult, error)
|
||||
}
|
Reference in New Issue
Block a user