mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-10-19 14:07:36 +02:00
183 lines
6.8 KiB
Go
183 lines
6.8 KiB
Go
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")
|
|
}
|
|
}
|