diff --git a/internal/c2/listener_tcp.go b/internal/c2/listener_tcp.go index e3effc92..f5fb5693 100644 --- a/internal/c2/listener_tcp.go +++ b/internal/c2/listener_tcp.go @@ -20,10 +20,9 @@ import ( ) // TCPReverseListener 监听 TCP 端口,等待目标机反弹连接。 -// 经典模式:纯交互式 raw shell,与 nc / bash -i >& /dev/tcp 兼容。 -// 二进制 Beacon:连接后先发送魔数 CSB1,随后使用与 HTTP Beacon 相同的 AES-GCM JSON 语义(成帧见 tcp_beacon_server.go)。 -// 每个新连接自动生成一个 implant_uuid(基于远端地址 + 启动时间 hash),登记为 c2_session; -// 任务派发:使用同步 exec 模式 —— 收到 task 时直接 send 命令字节并读取输出(带结束标记)。 +// 默认仅接受加密 TCP Beacon:连接后先发送魔数 CSB1,再经 AES-GCM 解密且校验 ImplantToken 后才登记会话。 +// 可选经典模式(config.allow_legacy_shell=true):纯交互式 raw shell,与 nc / bash -i >& /dev/tcp 兼容,无鉴权,仅建议内网实验。 +// 任务派发(经典模式):同步 exec —— 收到 task 时直接 send 命令字节并读取输出(带结束标记)。 type TCPReverseListener struct { rec *database.C2Listener cfg *ListenerConfig @@ -122,12 +121,14 @@ func (l *TCPReverseListener) acceptLoop() { } } -// handleConn 一个连接=一个会话:先识别二进制 TCP Beacon(魔数 CSB1),否则走经典交互式 shell。 +// handleConn 先识别加密 TCP Beacon(魔数 CSB1 + AES-GCM + Token);未通过则按配置拒绝或走经典 shell。 func (l *TCPReverseListener) handleConn(conn net.Conn) { br := bufio.NewReader(conn) - _ = conn.SetReadDeadline(time.Now().Add(20 * time.Second)) - prefix, err := br.Peek(4) - if err == nil && len(prefix) == 4 && string(prefix) == tcpBeaconMagic { + remote := conn.RemoteAddr().String() + + _ = conn.SetReadDeadline(time.Now().Add(tcpBeaconPeekTimeout)) + prefix, peekErr := br.Peek(4) + if peekErr == nil && len(prefix) == 4 && string(prefix) == tcpBeaconMagic { if _, err := br.Discard(4); err != nil { _ = conn.Close() return @@ -136,14 +137,22 @@ func (l *TCPReverseListener) handleConn(conn net.Conn) { l.handleTCPBeaconSession(conn, br) return } + + if !l.cfg.AllowLegacyShell { + l.logger.Debug("tcp_reverse 拒绝未加密连接", zap.String("remote", remote)) + _ = conn.Close() + return + } + _ = conn.SetReadDeadline(time.Time{}) l.handleShellConn(conn, br) } -// handleShellConn 经典裸 TCP 反弹 shell(与 nc/bash /dev/tcp 兼容)。 +// handleShellConn 经典裸 TCP 反弹 shell(与 nc/bash /dev/tcp 兼容);需监听器显式开启 allow_legacy_shell。 func (l *TCPReverseListener) handleShellConn(conn net.Conn, br *bufio.Reader) { remote := conn.RemoteAddr().String() host, _, _ := net.SplitHostPort(remote) + // 用 listener+remote_ip 生成稳定 implant_uuid,使同一来源的重连复用同一会话 uuidSeed := fmt.Sprintf("%s|%s", l.rec.ID, host) hash := sha256.Sum256([]byte(uuidSeed)) diff --git a/internal/c2/manager.go b/internal/c2/manager.go index de2764d8..d477e46e 100644 --- a/internal/c2/manager.go +++ b/internal/c2/manager.go @@ -381,8 +381,10 @@ func (m *Manager) IngestCheckIn(listenerID string, req ImplantCheckInRequest) (* Metadata: req.Metadata, } if existing != nil { - // 保留原 ID/FirstSeenAt/Note,避免被覆盖 + // 保留原 ID/FirstSeenAt/Note 与操作员设置的 sleep/jitter,避免被 beacon 心跳上报覆盖 session.FirstSeenAt = existing.FirstSeenAt + session.SleepSeconds = existing.SleepSeconds + session.JitterPercent = existing.JitterPercent if session.Note == "" { session.Note = existing.Note } @@ -413,6 +415,44 @@ func (m *Manager) IngestCheckIn(listenerID string, req ImplantCheckInRequest) (* return session, nil } +// SetSessionSleep 更新会话期望的心跳间隔,并向植入体下发 sleep 任务以尽快生效。 +func (m *Manager) SetSessionSleep(sessionID string, sleepSeconds, jitterPercent int) (*database.C2Task, error) { + if strings.TrimSpace(sessionID) == "" { + return nil, ErrInvalidInput + } + if sleepSeconds < 1 { + sleepSeconds = 1 + } + if jitterPercent < 0 { + jitterPercent = 0 + } + if jitterPercent > 100 { + jitterPercent = 100 + } + if err := m.db.SetC2SessionSleep(sessionID, sleepSeconds, jitterPercent); err != nil { + return nil, err + } + task, err := m.EnqueueTask(EnqueueTaskInput{ + SessionID: sessionID, + TaskType: TaskTypeSleep, + Payload: map[string]interface{}{ + "seconds": sleepSeconds, + "jitter": jitterPercent, + }, + Source: "manual", + }) + if err != nil { + m.logger.Warn("sleep 任务入队失败", zap.Error(err), zap.String("session_id", sessionID)) + } + m.publishEvent("info", "session", sessionID, "", + fmt.Sprintf("Sleep 已更新: %ds (抖动 %d%%)", sleepSeconds, jitterPercent), + map[string]interface{}{ + "sleep_seconds": sleepSeconds, + "jitter_percent": jitterPercent, + }) + return task, nil +} + // MarkSessionDead 心跳超时检测器调用:标记会话为 dead func (m *Manager) MarkSessionDead(sessionID string) error { if err := m.db.SetC2SessionStatus(sessionID, string(SessionDead)); err != nil { diff --git a/internal/c2/manager_sleep_test.go b/internal/c2/manager_sleep_test.go new file mode 100644 index 00000000..3dae2caf --- /dev/null +++ b/internal/c2/manager_sleep_test.go @@ -0,0 +1,118 @@ +package c2 + +import ( + "path/filepath" + "testing" + + "cyberstrike-ai/internal/database" + + "go.uber.org/zap" +) + +func TestIngestCheckIn_PreservesOperatorSleepOnHeartbeat(t *testing.T) { + tmp := t.TempDir() + db, err := database.NewDB(filepath.Join(tmp, "c2.sqlite"), zap.NewNop()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + + mgr := NewManager(db, zap.NewNop(), tmp) + ln, err := mgr.CreateListener(CreateListenerInput{ + Name: "t", + Type: string(ListenerTypeHTTPBeacon), + BindHost: "127.0.0.1", + BindPort: 18080, + }) + if err != nil { + t.Fatal(err) + } + + first, err := mgr.IngestCheckIn(ln.ID, ImplantCheckInRequest{ + ImplantUUID: "implant-uuid-1", + Hostname: "host1", + Username: "user", + OS: "darwin", + Arch: "amd64", + SleepSeconds: 5, + JitterPercent: 0, + }) + if err != nil { + t.Fatal(err) + } + + if err := db.SetC2SessionSleep(first.ID, 30, 20); err != nil { + t.Fatal(err) + } + + second, err := mgr.IngestCheckIn(ln.ID, ImplantCheckInRequest{ + ImplantUUID: "implant-uuid-1", + Hostname: "host1", + Username: "user", + OS: "darwin", + Arch: "amd64", + SleepSeconds: 5, + JitterPercent: 0, + }) + if err != nil { + t.Fatal(err) + } + if second.SleepSeconds != 30 || second.JitterPercent != 20 { + t.Fatalf("expected sleep=30 jitter=20, got sleep=%d jitter=%d", second.SleepSeconds, second.JitterPercent) + } + + stored, err := db.GetC2Session(first.ID) + if err != nil || stored == nil { + t.Fatal(err) + } + if stored.SleepSeconds != 30 || stored.JitterPercent != 20 { + t.Fatalf("db: expected sleep=30 jitter=20, got sleep=%d jitter=%d", stored.SleepSeconds, stored.JitterPercent) + } +} + +func TestSetSessionSleep_UpdatesDBAndEnqueuesTask(t *testing.T) { + tmp := t.TempDir() + db, err := database.NewDB(filepath.Join(tmp, "c2.sqlite"), zap.NewNop()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + + mgr := NewManager(db, zap.NewNop(), tmp) + ln, err := mgr.CreateListener(CreateListenerInput{ + Name: "t2", + Type: string(ListenerTypeHTTPBeacon), + BindHost: "127.0.0.1", + BindPort: 18081, + }) + if err != nil { + t.Fatal(err) + } + sess, err := mgr.IngestCheckIn(ln.ID, ImplantCheckInRequest{ + ImplantUUID: "implant-uuid-2", + Hostname: "host2", + Username: "user", + OS: "linux", + Arch: "amd64", + SleepSeconds: 5, + }) + if err != nil { + t.Fatal(err) + } + + task, err := mgr.SetSessionSleep(sess.ID, 15, 10) + if err != nil { + t.Fatal(err) + } + if task == nil || task.TaskType != string(TaskTypeSleep) { + t.Fatalf("expected sleep task, got %#v", task) + } + + stored, err := db.GetC2Session(sess.ID) + if err != nil || stored == nil { + t.Fatal(err) + } + if stored.SleepSeconds != 15 || stored.JitterPercent != 10 { + t.Fatalf("expected sleep=15 jitter=10, got sleep=%d jitter=%d", stored.SleepSeconds, stored.JitterPercent) + } +} diff --git a/internal/c2/payload_oneliner.go b/internal/c2/payload_oneliner.go index 0945b95a..794eb126 100644 --- a/internal/c2/payload_oneliner.go +++ b/internal/c2/payload_oneliner.go @@ -1,9 +1,12 @@ package c2 import ( + "encoding/json" "fmt" "net/url" "strings" + + "cyberstrike-ai/internal/database" ) // OnelinerKind 单行 payload 的语言/形式 @@ -79,6 +82,23 @@ type OnelinerInput struct { ImplantToken string // HTTP Beacon 鉴权 token } +// ValidateOnelinerForListener 校验 oneliner 与监听器配置是否匹配(如 tcp_reverse 默认要求加密 Beacon)。 +func ValidateOnelinerForListener(listener *database.C2Listener, kind OnelinerKind) error { + if listener == nil { + return fmt.Errorf("listener is nil") + } + if ListenerType(listener.Type) == ListenerTypeTCPReverse && tcpOnelinerKinds[kind] { + cfg := &ListenerConfig{} + if strings.TrimSpace(listener.ConfigJSON) != "" { + _ = json.Unmarshal([]byte(listener.ConfigJSON), cfg) + } + if !cfg.AllowLegacyShell { + return fmt.Errorf("监听器未开启 allow_legacy_shell:tcp_reverse 默认仅接受 CSB1 加密 Beacon(AES-GCM + Token);请用 build 生成 beacon,或显式开启 allow_legacy_shell(公网不推荐)") + } + } + return nil +} + // GenerateOneliner 生成单行 payload。 // 设计要点: // - 不依赖目标机预装的可执行(除该 oneliner 关键的 bash/python/perl 等); diff --git a/internal/c2/tcp_beacon_server.go b/internal/c2/tcp_beacon_server.go index 63803b32..4eeda05e 100644 --- a/internal/c2/tcp_beacon_server.go +++ b/internal/c2/tcp_beacon_server.go @@ -23,6 +23,9 @@ import ( // tcpBeaconMagic 二进制 Beacon 在反向 TCP 连接建立后首先发送的 4 字节,用于与经典 shell 反弹区分。 const tcpBeaconMagic = "CSB1" +// tcpBeaconPeekTimeout 等待 CSB1 魔数的探测窗口;合法 Beacon 连接后立即发送魔数。 +const tcpBeaconPeekTimeout = 2 * time.Second + // tcpBeaconMaxFrame 单帧密文(base64 字符串)最大字节数,防止 OOM。 const tcpBeaconMaxFrame = 64 << 20 diff --git a/internal/c2/types.go b/internal/c2/types.go index 488b524a..a6fb4acf 100644 --- a/internal/c2/types.go +++ b/internal/c2/types.go @@ -141,6 +141,8 @@ type ListenerConfig struct { MaxConcurrentTasks int `json:"max_concurrent_tasks,omitempty"` // CallbackHost 植入端/Payload 使用的回连主机名(可选);与 bind_host 分离,便于 NAT/ECS 等场景 CallbackHost string `json:"callback_host,omitempty"` + // AllowLegacyShell 为 true 时 tcp_reverse 允许未加密的经典 bash/nc 反弹 shell 登记会话(默认 false,公网部署强烈不建议开启) + AllowLegacyShell bool `json:"allow_legacy_shell,omitempty"` } // ApplyDefaults 对未填字段填默认值;调用方负责持久化时序列化新值