diff --git a/PLAN.md b/PLAN.md index 6efb77a..cf61d3a 100644 --- a/PLAN.md +++ b/PLAN.md @@ -210,3 +210,6 @@ Many bots send a command directly via `ssh user@host ` (an SSH "exec" r - Store the command on the session record before closing the channel - Optionally return plausible fake output for common commands (e.g. `uname`, `id`, `cat /etc/passwd`) to encourage further interaction - Surface exec commands in the web UI (session detail view) + +#### 4.4.1 Fake Exec Output +Return plausible fake output for common exec commands (e.g. `uname`, `id`, `cat /etc/passwd`) to encourage bots to interact further. Implement after collecting data on what bots commonly try to run. diff --git a/README.md b/README.md index ad42b89..181a319 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,9 @@ Test with: ssh -o StrictHostKeyChecking=no -p 2222 root@localhost ``` +SSH exec commands (`ssh user@host `) are also captured and stored on the session record. + + ### NixOS Module Add the flake as an input and enable the service: diff --git a/cmd/oubliette/main.go b/cmd/oubliette/main.go index 5067820..dcd50d1 100644 --- a/cmd/oubliette/main.go +++ b/cmd/oubliette/main.go @@ -20,7 +20,7 @@ import ( "git.t-juice.club/torjus/oubliette/internal/web" ) -const Version = "0.10.0" +const Version = "0.11.0" func main() { if err := run(); err != nil { diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index 870a920..e5dd0b5 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -23,6 +23,7 @@ type Metrics struct { SessionsTotal *prometheus.CounterVec SessionsActive prometheus.Gauge SessionDuration prometheus.Histogram + ExecCommandsTotal prometheus.Counter BuildInfo *prometheus.GaugeVec } @@ -70,6 +71,10 @@ func New(version string) *Metrics { Help: "Session duration in seconds.", Buckets: []float64{1, 5, 10, 30, 60, 120, 300, 600, 1800, 3600}, }), + ExecCommandsTotal: prometheus.NewCounter(prometheus.CounterOpts{ + Name: "oubliette_exec_commands_total", + Help: "Total SSH exec commands received.", + }), BuildInfo: prometheus.NewGaugeVec(prometheus.GaugeOpts{ Name: "oubliette_build_info", Help: "Build information. Always 1.", @@ -88,6 +93,7 @@ func New(version string) *Metrics { m.SessionsTotal, m.SessionsActive, m.SessionDuration, + m.ExecCommandsTotal, m.BuildInfo, ) diff --git a/internal/server/server.go b/internal/server/server.go index a717071..c4dc38a 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -231,14 +231,24 @@ func (s *Server) handleSession(channel ssh.Channel, requests <-chan *ssh.Request s.notifier.Notify(context.Background(), notify.EventSessionStarted, sessionInfo) defer s.notifier.CleanupSession(sessionID) - // Handle session requests (pty-req, shell, etc.) + // Handle session requests (pty-req, shell, exec, etc.) + execCh := make(chan string, 1) go func() { + defer close(execCh) for req := range requests { switch req.Type { case "pty-req", "shell": if req.WantReply { req.Reply(true, nil) } + case "exec": + if req.WantReply { + req.Reply(true, nil) + } + var payload struct{ Command string } + if err := ssh.Unmarshal(req.Payload, &payload); err == nil { + execCh <- payload.Command + } default: if req.WantReply { req.Reply(false, nil) @@ -247,6 +257,29 @@ func (s *Server) handleSession(channel ssh.Channel, requests <-chan *ssh.Request } }() + // Check for exec request before proceeding to interactive shell. + select { + case cmd, ok := <-execCh: + if ok && cmd != "" { + s.logger.Info("exec command received", + "remote_addr", conn.RemoteAddr(), + "user", conn.User(), + "session_id", sessionID, + "command", cmd, + ) + if err := s.store.SetExecCommand(context.Background(), sessionID, cmd); err != nil { + s.logger.Error("failed to set exec command", "err", err, "session_id", sessionID) + } + s.metrics.ExecCommandsTotal.Inc() + // Send exit-status 0 and close channel. + exitPayload := make([]byte, 4) // uint32(0) + _, _ = channel.SendRequest("exit-status", false, exitPayload) + return + } + case <-time.After(500 * time.Millisecond): + // No exec request within timeout — proceed with interactive shell. + } + // Build session context. var shellCfg map[string]any if s.cfg.Shell.Shells != nil { diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 82c4f53..a6b45cd 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -252,6 +252,54 @@ func TestIntegrationSSHConnect(t *testing.T) { } }) + // Test exec command capture. + t.Run("exec_command", func(t *testing.T) { + clientCfg := &ssh.ClientConfig{ + User: "root", + Auth: []ssh.AuthMethod{ssh.Password("toor")}, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + Timeout: 5 * time.Second, + } + + client, err := ssh.Dial("tcp", addr, clientCfg) + if err != nil { + t.Fatalf("SSH dial: %v", err) + } + defer client.Close() + + session, err := client.NewSession() + if err != nil { + t.Fatalf("new session: %v", err) + } + defer session.Close() + + // Run a command via exec (no PTY, no shell). + if err := session.Run("uname -a"); err != nil { + // Run returns an error because the server closes the channel, + // but that's expected. + _ = err + } + + // Give the server a moment to store the command. + time.Sleep(200 * time.Millisecond) + + // Verify the exec command was captured. + sessions, err := store.GetRecentSessions(context.Background(), 50, false) + if err != nil { + t.Fatalf("GetRecentSessions: %v", err) + } + var foundExec bool + for _, s := range sessions { + if s.ExecCommand != nil && *s.ExecCommand == "uname -a" { + foundExec = true + break + } + } + if !foundExec { + t.Error("expected a session with exec_command='uname -a'") + } + }) + // Test threshold acceptance: after enough failed dials, a subsequent // dial with the same credentials should succeed via threshold or // remembered credential. diff --git a/internal/storage/memstore.go b/internal/storage/memstore.go index 9b2e0e8..90a8fe0 100644 --- a/internal/storage/memstore.go +++ b/internal/storage/memstore.go @@ -91,6 +91,16 @@ func (m *MemoryStore) UpdateHumanScore(_ context.Context, sessionID string, scor return nil } +func (m *MemoryStore) SetExecCommand(_ context.Context, sessionID string, command string) error { + m.mu.Lock() + defer m.mu.Unlock() + + if s, ok := m.Sessions[sessionID]; ok { + s.ExecCommand = &command + } + return nil +} + func (m *MemoryStore) AppendSessionLog(_ context.Context, sessionID, input, output string) error { m.mu.Lock() defer m.mu.Unlock() diff --git a/internal/storage/migrations/004_add_exec_command.sql b/internal/storage/migrations/004_add_exec_command.sql new file mode 100644 index 0000000..ea27c70 --- /dev/null +++ b/internal/storage/migrations/004_add_exec_command.sql @@ -0,0 +1 @@ +ALTER TABLE sessions ADD COLUMN exec_command TEXT; diff --git a/internal/storage/migrations_test.go b/internal/storage/migrations_test.go index 8c88730..2fe16db 100644 --- a/internal/storage/migrations_test.go +++ b/internal/storage/migrations_test.go @@ -25,8 +25,8 @@ func TestMigrateCreatesTablesAndVersion(t *testing.T) { if err := db.QueryRow(`SELECT version FROM schema_version`).Scan(&version); err != nil { t.Fatalf("query version: %v", err) } - if version != 3 { - t.Errorf("version = %d, want 3", version) + if version != 4 { + t.Errorf("version = %d, want 4", version) } // Verify tables exist by inserting into them. @@ -64,8 +64,8 @@ func TestMigrateIdempotent(t *testing.T) { if err := db.QueryRow(`SELECT version FROM schema_version`).Scan(&version); err != nil { t.Fatalf("query version: %v", err) } - if version != 3 { - t.Errorf("version = %d after double migrate, want 3", version) + if version != 4 { + t.Errorf("version = %d after double migrate, want 4", version) } } diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go index 976f0a8..93672ca 100644 --- a/internal/storage/sqlite.go +++ b/internal/storage/sqlite.go @@ -83,6 +83,16 @@ func (s *SQLiteStore) UpdateHumanScore(ctx context.Context, sessionID string, sc return nil } +func (s *SQLiteStore) SetExecCommand(ctx context.Context, sessionID string, command string) error { + _, err := s.db.ExecContext(ctx, ` + UPDATE sessions SET exec_command = ? WHERE id = ?`, + command, sessionID) + if err != nil { + return fmt.Errorf("setting exec command: %w", err) + } + return nil +} + func (s *SQLiteStore) AppendSessionLog(ctx context.Context, sessionID, input, output string) error { now := time.Now().UTC().Format(time.RFC3339) _, err := s.db.ExecContext(ctx, ` @@ -100,12 +110,13 @@ func (s *SQLiteStore) GetSession(ctx context.Context, sessionID string) (*Sessio var connectedAt string var disconnectedAt sql.NullString var humanScore sql.NullFloat64 + var execCommand sql.NullString err := s.db.QueryRowContext(ctx, ` - SELECT id, ip, country, username, shell_name, connected_at, disconnected_at, human_score + SELECT id, ip, country, username, shell_name, connected_at, disconnected_at, human_score, exec_command FROM sessions WHERE id = ?`, sessionID).Scan( &sess.ID, &sess.IP, &sess.Country, &sess.Username, &sess.ShellName, - &connectedAt, &disconnectedAt, &humanScore, + &connectedAt, &disconnectedAt, &humanScore, &execCommand, ) if err == sql.ErrNoRows { return nil, nil @@ -122,6 +133,9 @@ func (s *SQLiteStore) GetSession(ctx context.Context, sessionID string) (*Sessio if humanScore.Valid { sess.HumanScore = &humanScore.Float64 } + if execCommand.Valid { + sess.ExecCommand = &execCommand.String + } return &sess, nil } @@ -368,7 +382,7 @@ func (s *SQLiteStore) queryTopN(ctx context.Context, column string, limit int) ( } func (s *SQLiteStore) GetRecentSessions(ctx context.Context, limit int, activeOnly bool) ([]Session, error) { - query := `SELECT id, ip, country, username, shell_name, connected_at, disconnected_at, human_score FROM sessions` + query := `SELECT id, ip, country, username, shell_name, connected_at, disconnected_at, human_score, exec_command FROM sessions` if activeOnly { query += ` WHERE disconnected_at IS NULL` } @@ -386,7 +400,8 @@ func (s *SQLiteStore) GetRecentSessions(ctx context.Context, limit int, activeOn var connectedAt string var disconnectedAt sql.NullString var humanScore sql.NullFloat64 - if err := rows.Scan(&s.ID, &s.IP, &s.Country, &s.Username, &s.ShellName, &connectedAt, &disconnectedAt, &humanScore); err != nil { + var execCommand sql.NullString + if err := rows.Scan(&s.ID, &s.IP, &s.Country, &s.Username, &s.ShellName, &connectedAt, &disconnectedAt, &humanScore, &execCommand); err != nil { return nil, fmt.Errorf("scanning session: %w", err) } s.ConnectedAt, _ = time.Parse(time.RFC3339, connectedAt) @@ -397,6 +412,9 @@ func (s *SQLiteStore) GetRecentSessions(ctx context.Context, limit int, activeOn if humanScore.Valid { s.HumanScore = &humanScore.Float64 } + if execCommand.Valid { + s.ExecCommand = &execCommand.String + } sessions = append(sessions, s) } return sessions, rows.Err() diff --git a/internal/storage/store.go b/internal/storage/store.go index 66f7c5b..d0289b8 100644 --- a/internal/storage/store.go +++ b/internal/storage/store.go @@ -27,6 +27,7 @@ type Session struct { ConnectedAt time.Time DisconnectedAt *time.Time HumanScore *float64 + ExecCommand *string } // SessionLog represents a single log entry for a session. @@ -76,6 +77,9 @@ type Store interface { // UpdateHumanScore sets the human detection score for a session. UpdateHumanScore(ctx context.Context, sessionID string, score float64) error + // SetExecCommand sets the exec command for a session. + SetExecCommand(ctx context.Context, sessionID string, command string) error + // AppendSessionLog adds a log entry to a session. AppendSessionLog(ctx context.Context, sessionID, input, output string) error diff --git a/internal/storage/store_test.go b/internal/storage/store_test.go index b70fb21..285c0be 100644 --- a/internal/storage/store_test.go +++ b/internal/storage/store_test.go @@ -361,6 +361,69 @@ func TestCloseActiveSessions(t *testing.T) { }) } +func TestSetExecCommand(t *testing.T) { + testStores(t, func(t *testing.T, newStore storeFactory) { + t.Run("set and retrieve", func(t *testing.T) { + store := newStore(t) + ctx := context.Background() + + id, err := store.CreateSession(ctx, "10.0.0.1", "root", "bash", "") + if err != nil { + t.Fatalf("CreateSession: %v", err) + } + + // Initially nil. + s, err := store.GetSession(ctx, id) + if err != nil { + t.Fatalf("GetSession: %v", err) + } + if s.ExecCommand != nil { + t.Errorf("expected nil ExecCommand, got %q", *s.ExecCommand) + } + + // Set exec command. + if err := store.SetExecCommand(ctx, id, "uname -a"); err != nil { + t.Fatalf("SetExecCommand: %v", err) + } + + s, err = store.GetSession(ctx, id) + if err != nil { + t.Fatalf("GetSession: %v", err) + } + if s.ExecCommand == nil { + t.Fatal("expected non-nil ExecCommand") + } + if *s.ExecCommand != "uname -a" { + t.Errorf("ExecCommand = %q, want %q", *s.ExecCommand, "uname -a") + } + }) + + t.Run("appears in recent sessions", func(t *testing.T) { + store := newStore(t) + ctx := context.Background() + + id, err := store.CreateSession(ctx, "10.0.0.1", "root", "bash", "") + if err != nil { + t.Fatalf("CreateSession: %v", err) + } + if err := store.SetExecCommand(ctx, id, "id"); err != nil { + t.Fatalf("SetExecCommand: %v", err) + } + + sessions, err := store.GetRecentSessions(ctx, 10, false) + if err != nil { + t.Fatalf("GetRecentSessions: %v", err) + } + if len(sessions) != 1 { + t.Fatalf("len = %d, want 1", len(sessions)) + } + if sessions[0].ExecCommand == nil || *sessions[0].ExecCommand != "id" { + t.Errorf("ExecCommand = %v, want \"id\"", sessions[0].ExecCommand) + } + }) + }) +} + func TestGetRecentSessions(t *testing.T) { testStores(t, func(t *testing.T, newStore storeFactory) { t.Run("empty", func(t *testing.T) { diff --git a/internal/web/templates.go b/internal/web/templates.go index be8dc5d..6713b14 100644 --- a/internal/web/templates.go +++ b/internal/web/templates.go @@ -44,6 +44,12 @@ func templateFuncMap() template.FuncMap { } return fmt.Sprintf("%.0f%%", *f*100) }, + "derefString": func(s *string) string { + if s == nil { + return "" + } + return *s + }, } } diff --git a/internal/web/templates/session_detail.html b/internal/web/templates/session_detail.html index a44e18f..ee96db2 100644 --- a/internal/web/templates/session_detail.html +++ b/internal/web/templates/session_detail.html @@ -10,6 +10,7 @@ Country{{.Session.Country}} Username{{.Session.Username}} Shell{{.Session.ShellName}} + {{if .Session.ExecCommand}}Exec Command{{derefString .Session.ExecCommand}}{{end}} Score{{formatScore .Session.HumanScore}} Connected{{formatTime .Session.ConnectedAt}}