diff --git a/docs/bug-sheet-review.md b/docs/bug-sheet-review.md index d1dd2dd1..b17df174 100644 --- a/docs/bug-sheet-review.md +++ b/docs/bug-sheet-review.md @@ -154,3 +154,7 @@ HTTP 上传端点此前只验证共享监听器凭据与任务 ID 路径,未 HTTP 原共享监听器凭据无法证明具体会话身份。现生成客户端为每个运行实例产生随机独立 X-Session-Token,数据库只保存 SHA-256 哈希;首次身份绑定不可覆盖,已有无绑定身份不得由仅知 UUID 的客户端认领。心跳、任务领取、结果回传和文件读写均检查独立凭据;文件下载通过任务 payload.file_id 关联真实所属会话。生成 Go 客户端所有 HTTP 请求同步携带会话凭据,curl 心跳客户端同步生成并携带凭据。双会话回归覆盖正常重连、任务领取、结果回传、文件读写及缺失/其他会话凭据拒绝,验证不修改其他任务或文件。身份测试覆盖哈希存储、旧身份禁止认领和跨监听器拒绝。C2/数据库测试通过,Go 客户端 Linux/Windows/macOS 编译通过,未执行生成客户端。 升级兼容:原 HTTP 客户端没有独立会话凭据,不能继续通过新校验,需要重新生成客户端;新实例使用新身份,不自动接管旧实例任务。自定义 HTTP 客户端需生成至少 32 字符随机凭据、首次注册携带并在同一实例后续请求保持一致。旧会话和任务记录保留。此要求是修复共享身份绕过所必需,未保留可绕过校验的旧协议入口。 + +### 离线命令收尾及串行派发(表格第 37 行) + +看门狗原仅标记会话离线,已发送命令无错误和完成时间。现对 dead 会话已发送 exec/shell 命令,超过客户端既有 timeout_seconds(默认 60 秒)加最小宽限期后写失败、完成时间及远端终止尚未确认说明;不推断远端进程已经停止。显式长超时、queued、成功/取消及非命令任务不受影响,迟到成功不能覆盖失败终态。TCP 串行客户端原一次领取多条并提前标记 sent,现同会话有 sent/running 任务时不再派发,一次领取一条,后续任务保持 queued。回归覆盖离线默认超时、300 秒显式期限、等待任务、正常终态、非命令任务、迟到成功与串行下一任务派发。C2 包测试通过。 diff --git a/docs/bug-sheet-status.md b/docs/bug-sheet-status.md index fd37eea4..9d9b4614 100644 --- a/docs/bug-sheet-status.md +++ b/docs/bug-sheet-status.md @@ -39,7 +39,7 @@ | 34 | 32 | 批量任务里只要指令不调用任何工具(例如「请只回复:OK」),子任务就必然失败并回「缺少执行证据」 | 已修复并有针对性验证 | | 35 | — | Markdown 智能体:同文件名静默覆盖、关键字段不校验、name 含路径字符被静默改写 | 已修复并有针对性验证 | | 36 | — | 任务结果不校验上报者归属 —— 任意植入体可伪造别的会话、甚至别的监听器的任务结果。 | 已修复持久连接及 HTTP 跨会话、跨监听器回传,HTTP 文件端点同步隔离;旧 HTTP 客户端须重新生成 | -| 37 | — | 100s 状态不变、无 error、无 completed_at,会话早已离线。 | 待核对 | +| 37 | — | 100s 状态不变、无 error、无 completed_at,会话早已离线。 | 已修复:离线会话超期命令记录失败、错误和完成时间;串行 TCP 后续任务保持排队 | | 38 | — | 在「工作流」里删掉一个正被角色引用的工作流 → 删除成功且没有任何提示;之后任何人用这个角色发起 | 已修复并有针对性验证 | | 39 | — | 同一 implant uuid 重复上线互踢,旧连接断开把会话判死 | 已修复并有针对性验证 | | 40 | — | 无权限用户仍能看到“新建角色”“添加成员”等管理操作按钮;页面同时提示“权限不足”。应在前端按权 | 已补权限入口标记,按钮可见性验证通过 | diff --git a/internal/c2/manager.go b/internal/c2/manager.go index c4bdd12a..585a4b68 100644 --- a/internal/c2/manager.go +++ b/internal/c2/manager.go @@ -650,6 +650,30 @@ func (m *Manager) CancelTask(taskID string) error { // PopTasksForBeacon beacon check_in 后调用:取该会话所有 queued+approved 的任务, // 内部已置为 sent;返回 TaskEnvelope,便于 listener 直接编码下发。 func (m *Manager) PopTasksForBeacon(sessionID string, limit int) ([]TaskEnvelope, error) { + session, err := m.db.GetC2Session(sessionID) + if err != nil { + return nil, err + } + if session != nil { + listener, err := m.db.GetC2Listener(session.ListenerID) + if err != nil { + return nil, err + } + if listener != nil && listener.Type == string(ListenerTypeTCPReverse) { + m.mu.Lock() + defer m.mu.Unlock() + for _, status := range []string{string(TaskSent), string(TaskRunning)} { + count, err := m.db.CountC2Tasks(database.ListC2TasksFilter{SessionID: sessionID, Status: status}) + if err != nil { + return nil, err + } + if count > 0 { + return []TaskEnvelope{}, nil + } + } + limit = 1 + } + } tasks, err := m.db.PopQueuedC2Tasks(sessionID, limit) if err != nil { return nil, err diff --git a/internal/c2/session_watchdog.go b/internal/c2/session_watchdog.go index 328f1f32..898874e7 100644 --- a/internal/c2/session_watchdog.go +++ b/internal/c2/session_watchdog.go @@ -18,12 +18,12 @@ import ( // - 全局最小宽限期 minGrace 避免 sleep 配置错误的会话被误判; // - 不读 implant_uuid,纯按 last_check_in 字段,与 listener 类型解耦。 type SessionWatchdog struct { - manager *Manager - logger *zap.Logger - interval time.Duration // 扫描周期,默认 15s - minGrace time.Duration // 最小宽限期,默认 30s - gracePct float64 // 心跳超时倍数,默认 3.0(即 3 倍 sleep 周期没心跳算掉线) - stopCh chan struct{} + manager *Manager + logger *zap.Logger + interval time.Duration // 扫描周期,默认 15s + minGrace time.Duration // 最小宽限期,默认 30s + gracePct float64 // 心跳超时倍数,默认 3.0(即 3 倍 sleep 周期没心跳算掉线) + stopCh chan struct{} } // NewSessionWatchdog 创建看门狗 @@ -79,6 +79,48 @@ func (w *SessionWatchdog) tick() { } } } + sessions, err := w.manager.DB().ListC2Sessions(database.ListC2SessionsFilter{Status: string(SessionDead)}) + if err != nil { + w.logger.Warn("离线任务查询失败", zap.Error(err)) + return + } + for _, session := range sessions { + w.expireOfflineCommands(session, now) + } +} + +func (w *SessionWatchdog) expireOfflineCommands(session *database.C2Session, now time.Time) { + tasks, err := w.manager.DB().ListC2Tasks(database.ListC2TasksFilter{SessionID: session.ID, Status: string(TaskSent)}) + if err != nil { + w.logger.Warn("离线命令查询失败", zap.Error(err)) + return + } + for _, task := range tasks { + if task.TaskType != string(TaskTypeExec) && task.TaskType != string(TaskTypeShell) { + continue + } + if task.SentAt == nil { + continue + } + seconds := 60.0 + if value, ok := task.Payload["timeout_seconds"].(float64); ok && value > 0 { + seconds = value + } + // Invalid huge deadlines remain unexpired rather than wrapping a duration. + if seconds > float64((1<<63-1)/int64(time.Second)) { + continue + } + if now.Sub(*task.SentAt) <= time.Duration(seconds)*time.Second+w.minGrace { + continue + } + status := string(TaskFailed) + errText := "会话已离线且任务结果超过执行期限;远端进程是否终止尚未确认" + if err := w.manager.DB().UpdateC2Task(task.ID, database.C2TaskUpdate{ExpectedStatus: &task.Status, Status: &status, Error: &errText, CompletedAt: &now}); err != nil { + w.logger.Warn("收尾离线命令失败", zap.Error(err)) + continue + } + w.manager.publishEvent("warn", "task", session.ID, task.ID, errText, nil) + } } // isStale 判断会话是否超时 diff --git a/internal/c2/session_watchdog_test.go b/internal/c2/session_watchdog_test.go new file mode 100644 index 00000000..4d4f4bd1 --- /dev/null +++ b/internal/c2/session_watchdog_test.go @@ -0,0 +1,83 @@ +package c2 + +import ( + "cyberstrike-ai/internal/database" + "strings" + "testing" + "time" +) + +func TestWatchdogClosesExpiredOfflineCommandsWithoutTouchingQueuedOrLongTasks(t *testing.T) { + manager, db := terminalTestManager(t) + now := time.Now() + sent := now.Add(-100 * time.Second) + if _, err := db.Exec(`UPDATE c2_sessions SET last_check_in=?,sleep_seconds=5 WHERE id=?`, now.Add(-time.Hour), "session"); err != nil { + t.Fatal(err) + } + for _, c := range []struct { + id, status, kind string + timeout float64 + }{{"expired", "sent", "exec", 0}, {"long", "sent", "exec", 300}, {"queued", "queued", "exec", 0}, {"done", "success", "exec", 0}, {"cancelled", "cancelled", "exec", 0}, {"other", "sent", "download", 0}} { + task := &database.C2Task{ID: c.id, SessionID: "session", TaskType: c.kind, Status: c.status, SentAt: &sent, CreatedAt: sent, Payload: map[string]interface{}{"timeout_seconds": c.timeout}} + if err := db.CreateC2Task(task); err != nil { + t.Fatal(err) + } + if err := db.UpdateC2Task(c.id, database.C2TaskUpdate{SentAt: &sent}); err != nil { + t.Fatal(err) + } + } + watchdog := NewSessionWatchdog(manager) + watchdog.tick() + session, _ := db.GetC2Session("session") + if session.Status != string(SessionDead) { + t.Fatal("stale session not closed") + } + expired, _ := db.GetC2Task("expired") + if expired.Status != "failed" || expired.CompletedAt == nil || !strings.Contains(expired.Error, "尚未确认") { + t.Fatalf("offline command left hanging: %#v", expired) + } + for id, want := range map[string]string{"long": "sent", "queued": "queued", "done": "success", "cancelled": "cancelled", "other": "sent"} { + task, _ := db.GetC2Task(id) + if task.Status != want { + t.Fatalf("%s status=%s want %s", id, task.Status, want) + } + } + if err := manager.IngestTaskResult(TaskResultReport{TaskID: "expired", Success: true, Output: "late"}); err != nil { + t.Fatal(err) + } + expired, _ = db.GetC2Task("expired") + if expired.Status != "failed" || expired.ResultText != "" { + t.Fatal("late success replaced offline terminal") + } +} + +func TestTCPSerialDispatchKeepsRemainingTasksQueuedUntilResult(t *testing.T) { + manager, db := terminalTestManager(t) + if _, err := db.Exec(`UPDATE c2_listeners SET type=? WHERE id=?`, string(ListenerTypeTCPReverse), "listener"); err != nil { + t.Fatal(err) + } + for i, id := range []string{"first", "second"} { + if err := db.CreateC2Task(&database.C2Task{ID: id, SessionID: "session", TaskType: "exec", Status: "queued", ApprovalStatus: "approved", CreatedAt: time.Now().Add(time.Duration(i) * time.Millisecond)}); err != nil { + t.Fatal(err) + } + } + first, err := manager.PopTasksForBeacon("session", 50) + if err != nil || len(first) != 1 { + t.Fatalf("first dispatch=%v err=%v", first, err) + } + again, err := manager.PopTasksForBeacon("session", 50) + if err != nil || len(again) != 0 { + t.Fatal("second task sent before first result") + } + queued, _ := db.GetC2Task("second") + if queued.Status != "queued" { + t.Fatal("serial sibling marked sent prematurely") + } + if err := manager.IngestTaskResult(TaskResultReport{TaskID: first[0].TaskID, Success: true}); err != nil { + t.Fatal(err) + } + next, err := manager.PopTasksForBeacon("session", 50) + if err != nil || len(next) != 1 || next[0].TaskID != "second" { + t.Fatalf("next dispatch=%v err=%v", next, err) + } +}