464 lines
9.8 KiB
Go
464 lines
9.8 KiB
Go
/*
|
|
©AngelaMos | 2026
|
|
server.go
|
|
|
|
SSH honeypot service accepting all authentication attempts
|
|
|
|
Emulates an OpenSSH server with accept-all password and public key
|
|
callbacks. After authentication, provides an interactive shell with
|
|
a fake filesystem and command execution. Logs every connection,
|
|
credential attempt, and session to the event bus. Uses the SSH
|
|
server version banner from config to avoid honeypot fingerprinting.
|
|
*/
|
|
|
|
package sshd
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/rs/zerolog"
|
|
"golang.org/x/crypto/ssh"
|
|
|
|
"github.com/CarterPerez-dev/hive/internal/config"
|
|
"github.com/CarterPerez-dev/hive/internal/event"
|
|
"github.com/CarterPerez-dev/hive/internal/ratelimit"
|
|
"github.com/CarterPerez-dev/hive/internal/session"
|
|
"github.com/CarterPerez-dev/hive/pkg/types"
|
|
)
|
|
|
|
type SSHService struct {
|
|
cfg *config.Config
|
|
bus *event.Bus
|
|
logger zerolog.Logger
|
|
tracker *session.Tracker
|
|
limiter *ratelimit.IPLimiter
|
|
hostkey ssh.Signer
|
|
}
|
|
|
|
func New(
|
|
cfg *config.Config,
|
|
bus *event.Bus,
|
|
logger *zerolog.Logger,
|
|
tracker *session.Tracker,
|
|
limiter *ratelimit.IPLimiter,
|
|
hostkey ssh.Signer,
|
|
) *SSHService {
|
|
return &SSHService{
|
|
cfg: cfg,
|
|
bus: bus,
|
|
logger: logger.With().Str("service", "ssh").Logger(),
|
|
tracker: tracker,
|
|
limiter: limiter,
|
|
hostkey: hostkey,
|
|
}
|
|
}
|
|
|
|
func (s *SSHService) Name() string { return "ssh" }
|
|
|
|
func (s *SSHService) Start(ctx context.Context) error {
|
|
addr := s.cfg.Addr(s.cfg.SSH.Port)
|
|
|
|
listener, err := net.Listen("tcp", addr)
|
|
if err != nil {
|
|
return fmt.Errorf("ssh listen %s: %w", addr, err)
|
|
}
|
|
|
|
s.logger.Info().
|
|
Str("addr", addr).
|
|
Msg("ssh honeypot listening")
|
|
|
|
go func() {
|
|
<-ctx.Done()
|
|
_ = listener.Close()
|
|
}()
|
|
|
|
for ctx.Err() == nil {
|
|
conn, err := listener.Accept()
|
|
if err != nil {
|
|
s.logger.Debug().Err(err).Msg("accept failed")
|
|
continue
|
|
}
|
|
|
|
go s.handleConnection(ctx, conn)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *SSHService) handleConnection(
|
|
ctx context.Context, conn net.Conn,
|
|
) {
|
|
defer func() { _ = conn.Close() }()
|
|
|
|
srcIP, srcPort := types.RemoteAddr(conn)
|
|
|
|
if !s.limiter.Allow(srcIP) {
|
|
return
|
|
}
|
|
|
|
sess := s.tracker.Start(
|
|
s.cfg.Sensor.ID, types.ServiceSSH,
|
|
srcIP, srcPort, s.cfg.SSH.Port,
|
|
)
|
|
defer s.tracker.End(sess.ID)
|
|
|
|
s.publishConnect(sess, srcIP, srcPort)
|
|
|
|
var lastUsername string
|
|
|
|
sshCfg := &ssh.ServerConfig{
|
|
ServerVersion: s.cfg.SSH.Banner,
|
|
PasswordCallback: func(
|
|
c ssh.ConnMetadata, pass []byte,
|
|
) (*ssh.Permissions, error) {
|
|
lastUsername = c.User()
|
|
s.publishAuth(
|
|
sess.ID, srcIP, c.User(),
|
|
string(pass), "password",
|
|
string(c.ClientVersion()),
|
|
)
|
|
return &ssh.Permissions{}, nil
|
|
},
|
|
PublicKeyCallback: func(
|
|
c ssh.ConnMetadata, key ssh.PublicKey,
|
|
) (*ssh.Permissions, error) {
|
|
lastUsername = c.User()
|
|
s.publishAuth(
|
|
sess.ID, srcIP, c.User(),
|
|
"", "publickey",
|
|
string(c.ClientVersion()),
|
|
)
|
|
return &ssh.Permissions{}, nil
|
|
},
|
|
}
|
|
|
|
sshCfg.AddHostKey(s.hostkey)
|
|
|
|
sshConn, chans, reqs, err := ssh.NewServerConn(
|
|
conn, sshCfg,
|
|
)
|
|
if err != nil {
|
|
s.publishDisconnect(sess, srcIP, srcPort)
|
|
return
|
|
}
|
|
defer func() { _ = sshConn.Close() }()
|
|
|
|
s.tracker.SetLogin(
|
|
sess.ID, true, lastUsername,
|
|
string(sshConn.ClientVersion()),
|
|
)
|
|
|
|
go ssh.DiscardRequests(reqs)
|
|
|
|
for newChan := range chans {
|
|
switch newChan.ChannelType() {
|
|
case "session":
|
|
go s.handleSession(
|
|
ctx, newChan, sess, srcIP, lastUsername,
|
|
)
|
|
case "direct-tcpip":
|
|
s.logLateralMovement(sess, srcIP, newChan)
|
|
_ = newChan.Reject(
|
|
ssh.Prohibited, "not allowed",
|
|
)
|
|
default:
|
|
_ = newChan.Reject(
|
|
ssh.UnknownChannelType, "unknown",
|
|
)
|
|
}
|
|
}
|
|
|
|
s.publishDisconnect(sess, srcIP, srcPort)
|
|
}
|
|
|
|
type sessionState struct {
|
|
ch ssh.Channel
|
|
sess *types.Session
|
|
srcIP string
|
|
username string
|
|
cols int
|
|
rows int
|
|
recorder *session.Recorder
|
|
}
|
|
|
|
func (s *SSHService) handleSession(
|
|
ctx context.Context,
|
|
newChan ssh.NewChannel,
|
|
sess *types.Session,
|
|
srcIP string,
|
|
username string,
|
|
) {
|
|
ch, reqs, err := newChan.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer func() { _ = ch.Close() }()
|
|
|
|
st := &sessionState{
|
|
ch: ch,
|
|
sess: sess,
|
|
srcIP: srcIP,
|
|
username: username,
|
|
cols: 80,
|
|
rows: 24,
|
|
}
|
|
|
|
st.recorder = session.NewRecorder(
|
|
sess.ID, srcIP, s.cfg.Sensor.ID,
|
|
st.cols, st.rows,
|
|
)
|
|
|
|
go s.dispatchRequests(reqs, st)
|
|
|
|
<-ctx.Done()
|
|
}
|
|
|
|
func (s *SSHService) dispatchRequests(
|
|
reqs <-chan *ssh.Request, st *sessionState,
|
|
) {
|
|
for req := range reqs {
|
|
if s.handleSessionRequest(req, st) {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *SSHService) handleSessionRequest(
|
|
req *ssh.Request, st *sessionState,
|
|
) bool {
|
|
switch req.Type {
|
|
case "pty-req":
|
|
if len(req.Payload) > 4 {
|
|
st.cols, st.rows = parsePTYRequest(
|
|
req.Payload,
|
|
)
|
|
st.recorder.Resize(st.cols, st.rows)
|
|
}
|
|
|
|
case "shell":
|
|
replyOK(req)
|
|
RunShell(
|
|
st.ch, st.sess.ID, s.cfg.Sensor.ID,
|
|
st.srcIP, st.username,
|
|
s.cfg.SSH.Hostname,
|
|
s.bus, st.recorder, st.cols, st.rows,
|
|
)
|
|
s.saveRecording(st)
|
|
_ = st.ch.Close()
|
|
return true
|
|
|
|
case "exec":
|
|
replyOK(req)
|
|
cmd := parseExecPayload(req.Payload)
|
|
s.handleExec(
|
|
st.ch, st.sess, st.srcIP,
|
|
st.username, cmd, st.recorder,
|
|
)
|
|
_ = st.ch.Close()
|
|
return true
|
|
|
|
case "window-change":
|
|
if len(req.Payload) >= 8 {
|
|
st.cols, st.rows = parseWindowChange(
|
|
req.Payload,
|
|
)
|
|
st.recorder.Resize(st.cols, st.rows)
|
|
}
|
|
}
|
|
|
|
replyOK(req)
|
|
return false
|
|
}
|
|
|
|
func replyOK(req *ssh.Request) {
|
|
if req.WantReply {
|
|
_ = req.Reply(true, nil)
|
|
}
|
|
}
|
|
|
|
func (s *SSHService) saveRecording(st *sessionState) {
|
|
if _, err := st.recorder.Save(s.cfg.Log.ReplayDir); err != nil {
|
|
s.logger.Error().Err(err).
|
|
Str("session_id", st.sess.ID).
|
|
Msg("failed to save session recording")
|
|
}
|
|
}
|
|
|
|
func (s *SSHService) handleExec(
|
|
ch ssh.Channel,
|
|
sess *types.Session,
|
|
srcIP string,
|
|
username string,
|
|
cmd string,
|
|
recorder *session.Recorder,
|
|
) {
|
|
recorder.WriteInput([]byte(cmd + "\n"))
|
|
|
|
publishCommand(
|
|
s.bus, sess.ID, s.cfg.Sensor.ID, srcIP,
|
|
cmd, s.cfg.SSH.Hostname,
|
|
)
|
|
|
|
fs := NewFakeFS(s.cfg.SSH.Hostname)
|
|
cmdCtx := &CommandContext{
|
|
FS: fs,
|
|
Hostname: s.cfg.SSH.Hostname,
|
|
Username: username,
|
|
CWD: "/root",
|
|
}
|
|
|
|
output := DispatchCommand(cmd, cmdCtx)
|
|
if output != "" {
|
|
_, _ = ch.Write([]byte(output))
|
|
recorder.WriteOutput([]byte(output))
|
|
}
|
|
|
|
_, _ = ch.SendRequest(
|
|
"exit-status", false, []byte{0, 0, 0, 0},
|
|
)
|
|
}
|
|
|
|
func (s *SSHService) logLateralMovement(
|
|
sess *types.Session,
|
|
srcIP string,
|
|
newChan ssh.NewChannel,
|
|
) {
|
|
serviceData, _ := json.Marshal(map[string]string{
|
|
"channel_type": newChan.ChannelType(),
|
|
"extra_data": string(newChan.ExtraData()),
|
|
})
|
|
|
|
s.bus.Publish(config.TopicCommand, &types.Event{
|
|
ID: uuid.Must(uuid.NewV7()).String(),
|
|
SessionID: sess.ID,
|
|
SensorID: s.cfg.Sensor.ID,
|
|
Timestamp: time.Now().UTC(),
|
|
ServiceType: types.ServiceSSH,
|
|
EventType: types.EventExploit,
|
|
SourceIP: srcIP,
|
|
Protocol: types.ProtocolTCP,
|
|
SchemaVersion: config.SchemaVersion,
|
|
Tags: []string{"lateral-movement", "mitre:T1021.004"},
|
|
ServiceData: serviceData,
|
|
})
|
|
}
|
|
|
|
func (s *SSHService) publishConnect(
|
|
sess *types.Session, srcIP string, srcPort int,
|
|
) {
|
|
s.bus.Publish(config.TopicConnect, &types.Event{
|
|
ID: uuid.Must(uuid.NewV7()).String(),
|
|
SessionID: sess.ID,
|
|
SensorID: s.cfg.Sensor.ID,
|
|
Timestamp: time.Now().UTC(),
|
|
ServiceType: types.ServiceSSH,
|
|
EventType: types.EventConnect,
|
|
SourceIP: srcIP,
|
|
SourcePort: srcPort,
|
|
DestPort: s.cfg.SSH.Port,
|
|
Protocol: types.ProtocolTCP,
|
|
SchemaVersion: config.SchemaVersion,
|
|
})
|
|
}
|
|
|
|
func (s *SSHService) publishDisconnect(
|
|
sess *types.Session, srcIP string, srcPort int,
|
|
) {
|
|
s.bus.Publish(config.TopicDisconnect, &types.Event{
|
|
ID: uuid.Must(uuid.NewV7()).String(),
|
|
SessionID: sess.ID,
|
|
SensorID: s.cfg.Sensor.ID,
|
|
Timestamp: time.Now().UTC(),
|
|
ServiceType: types.ServiceSSH,
|
|
EventType: types.EventDisconnect,
|
|
SourceIP: srcIP,
|
|
SourcePort: srcPort,
|
|
DestPort: s.cfg.SSH.Port,
|
|
Protocol: types.ProtocolTCP,
|
|
SchemaVersion: config.SchemaVersion,
|
|
})
|
|
}
|
|
|
|
func (s *SSHService) publishAuth(
|
|
sessionID string,
|
|
srcIP string,
|
|
username string,
|
|
password string,
|
|
method string,
|
|
clientVersion string,
|
|
) {
|
|
serviceData, _ := json.Marshal(map[string]string{
|
|
"username": username,
|
|
"password": password,
|
|
"auth_method": method,
|
|
"client_version": clientVersion,
|
|
})
|
|
|
|
s.bus.Publish(config.TopicAuth, &types.Event{
|
|
ID: uuid.Must(uuid.NewV7()).String(),
|
|
SessionID: sessionID,
|
|
SensorID: s.cfg.Sensor.ID,
|
|
Timestamp: time.Now().UTC(),
|
|
ServiceType: types.ServiceSSH,
|
|
EventType: types.EventLoginSuccess,
|
|
SourceIP: srcIP,
|
|
Protocol: types.ProtocolTCP,
|
|
SchemaVersion: config.SchemaVersion,
|
|
ServiceData: serviceData,
|
|
})
|
|
}
|
|
|
|
func parsePTYRequest(payload []byte) (int, int) {
|
|
if len(payload) < 8 {
|
|
return 80, 24
|
|
}
|
|
|
|
termLen := int(payload[3]) | int(payload[2])<<8 |
|
|
int(payload[1])<<16 | int(payload[0])<<24
|
|
offset := 4 + termLen
|
|
|
|
if len(payload) < offset+8 {
|
|
return 80, 24
|
|
}
|
|
|
|
cols := int(payload[offset+3]) | int(payload[offset+2])<<8 |
|
|
int(payload[offset+1])<<16 | int(payload[offset])<<24
|
|
rows := int(payload[offset+7]) | int(payload[offset+6])<<8 |
|
|
int(payload[offset+5])<<16 | int(payload[offset+4])<<24
|
|
|
|
return cols, rows
|
|
}
|
|
|
|
func parseWindowChange(payload []byte) (int, int) {
|
|
if len(payload) < 8 {
|
|
return 80, 24
|
|
}
|
|
|
|
cols := int(payload[3]) | int(payload[2])<<8 |
|
|
int(payload[1])<<16 | int(payload[0])<<24
|
|
rows := int(payload[7]) | int(payload[6])<<8 |
|
|
int(payload[5])<<16 | int(payload[4])<<24
|
|
|
|
return cols, rows
|
|
}
|
|
|
|
func parseExecPayload(payload []byte) string {
|
|
if len(payload) < 4 {
|
|
return ""
|
|
}
|
|
|
|
cmdLen := int(payload[3]) | int(payload[2])<<8 |
|
|
int(payload[1])<<16 | int(payload[0])<<24
|
|
|
|
if len(payload) < 4+cmdLen {
|
|
return ""
|
|
}
|
|
|
|
return string(payload[4 : 4+cmdLen])
|
|
}
|