fix: close expired offline commands and serialize TCP dispatch

This commit is contained in:
Ed1s0nZ committed 2026-10-06 03:41:37 +08:00
1 parent 338dcdbc17
commit e081858df3
5 files changed
+160 -7

No files matched your search

+4
View File
@@ -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 包测试通过。
+1 -1
View File
@@ -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 | — | 无权限用户仍能看到“新建角色”“添加成员”等管理操作按钮;页面同时提示“权限不足”。应在前端按权 | 已补权限入口标记,按钮可见性验证通过 |
+24
View File
@@ -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
+48 -6
View File
@@ -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 判断会话是否超时
+83
View File
@@ -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)
}
}