Files
CyberStrikeAI/internal/c2/http_session_auth_test.go
T

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")
}
}