diff --git a/docs/bug-sheet-review.md b/docs/bug-sheet-review.md index 19041c2a..ee1ea032 100644 --- a/docs/bug-sheet-review.md +++ b/docs/bug-sheet-review.md @@ -86,3 +86,9 @@ - Excel 第 50 行:编辑保存使用 document 点击委托,但 c2-modal 内层 inline stopPropagation 阻止按钮到达委托。移除内层阻断;背景关闭本来已检查 event.target===this,不会因内部点击关闭。验证实际委托分发到真实 saveListener 后发送 PUT 并在成功后关闭。 - Excel 第 23 行:名称、类型、端口增加必填标识;创建/编辑均提示并聚焦具体错误字段,端口仅允许 1–65535 整数,不再接受 parseInt 的小数或尾随文本。网络/响应解析失败显示错误并保留表单。 - 编辑 API 在修改字段前检查空名称和端口范围;数据库回归验证非法请求不部分保存、合法编辑正常落库。5 项前端测试和针对性后端测试通过。 + +## 追加:回连地址生成校验 + +- Excel 第 52 行:截图中的 `bad host/abc` 原先会直接拼入 URL。单行生成与 Beacon 构建均在生成前校验 IP/DNS 主机名,拒绝协议、路径、端口、空格、非法数字 IP 与命令片段;不执行 DNS 查询,也不改变地址解析优先级。 +- URL 以监听器协议生成,HTTPS 使用 https;IPv6 使用 net.JoinHostPort 补齐方括号,普通域名及 IPv4 保持原格式。 +- 主机校验覆盖全部单行类型;真实 API 验证非法输入返回 400、合法域名与 IPv6 正常生成,测试仅使用 test-only-token。 diff --git a/docs/bug-sheet-status.md b/docs/bug-sheet-status.md index c10cab52..37f6060e 100644 --- a/docs/bug-sheet-status.md +++ b/docs/bug-sheet-status.md @@ -54,7 +54,7 @@ | 49 | — | C2 会话终端执行没有换行结尾的命令时,系统内部用于获取当前目录的 pwd 输出会与用户命令输出 | 已修复,Unix Shell 回归通过 | | 50 | — | 监听器编辑后点击保存没有反应 | 已修复弹窗事件冒泡阻断;实际委托保存和失败提示回归通过 | | 51 | — | 创建 Malleable Profile 时 UA、响应头和 Jitter 配置被静默丢弃 | 已修复并有针对性验证 | -| 52 | — | C2 载荷生成未校验回连地址,非法地址仍能生成 Oneliner | 待核对 | +| 52 | — | C2 载荷生成未校验回连地址,非法地址仍能生成 Oneliner | 已修复:生成前校验主机格式;非法输入拒绝,HTTPS/IPv6 API 回归通过 | | 53 | — | 知识库索引构建失败原因不怎么太清晰 | 待核对 | | 54 | — | Agent 未挂载知识检索工具 | 待核对 | | 55 | — | - 选择其专用测试会话,打开「终端」。 | 已修复,Unix Shell 回归通过 | diff --git a/internal/c2/beacon_host.go b/internal/c2/beacon_host.go index 9899c6a6..920e9a00 100644 --- a/internal/c2/beacon_host.go +++ b/internal/c2/beacon_host.go @@ -1,6 +1,8 @@ package c2 import ( + "fmt" + "net" "strings" "cyberstrike-ai/internal/database" @@ -37,3 +39,34 @@ func ResolveBeaconDialHost(listener *database.C2Listener, explicitOverride strin } return host } + +// ValidateBeaconDialHost accepts a host only, never a URL, port or command fragment. +func ValidateBeaconDialHost(host string) error { + if host == "" || len(host) > 253 { + return fmt.Errorf("callback host must be an IP address or hostname without a scheme, path or port") + } + if net.ParseIP(host) != nil { + return nil + } + name := strings.TrimSuffix(host, ".") + numeric := true + for _, c := range name { + if c != '.' && (c < '0' || c > '9') { + numeric = false + } + } + if numeric { + return fmt.Errorf("callback host contains an invalid IP address") + } + for _, label := range strings.Split(name, ".") { + if len(label) == 0 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' { + return fmt.Errorf("callback hostname has an invalid label") + } + for _, c := range label { + if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-') { + return fmt.Errorf("callback host must be an IP address or hostname without a scheme, path or port") + } + } + } + return nil +} diff --git a/internal/c2/beacon_host_validation_test.go b/internal/c2/beacon_host_validation_test.go new file mode 100644 index 00000000..4da7707c --- /dev/null +++ b/internal/c2/beacon_host_validation_test.go @@ -0,0 +1,22 @@ +package c2 + +import "testing" + +func TestCallbackHostValidation(t *testing.T) { + for _, host := range []string{"127.0.0.1", "::1", "2001:db8::1", "localhost", "example.com", "host.docker.internal", "example.com."} { + if err := ValidateBeaconDialHost(host); err != nil { + t.Errorf("valid %q: %v", host, err) + } + } + for _, host := range []string{"", "bad host/abc", "https://example.com", "example.com:443", "999.1.1.1", "-bad.example", "bad_.example", "a..example", "x';echo test", "$(echo test)", "example.\ncom"} { + if err := ValidateBeaconDialHost(host); err == nil { + t.Errorf("invalid %q accepted", host) + } + for _, kind := range AllOnelinerKinds() { + result, err := GenerateOneliner(OnelinerInput{Kind: kind, Host: host, Port: 8080, HTTPBaseURL: "http://example.com:8080", ImplantToken: "test-only-token"}) + if err == nil || result != "" { + t.Errorf("%s generated output for invalid host %q", kind, host) + } + } + } +} diff --git a/internal/c2/payload_builder.go b/internal/c2/payload_builder.go index bfef21da..c6f46718 100644 --- a/internal/c2/payload_builder.go +++ b/internal/c2/payload_builder.go @@ -90,6 +90,12 @@ func (b *PayloadBuilder) BuildBeacon(in PayloadBuilderInput) (*BuildResult, erro goarch = "amd64" } + // 模板参数:请求 Host > 监听器 callback_host > bind 推导(见 ResolveBeaconDialHost) + host := ResolveBeaconDialHost(listener, in.Host, b.logger, listener.ID) + if err := ValidateBeaconDialHost(host); err != nil { + return nil, err + } + // 读取模板 tmplPath := filepath.Join(b.tmplDir, "beacon.go.tmpl") tmplData, err := os.ReadFile(tmplPath) @@ -97,12 +103,9 @@ func (b *PayloadBuilder) BuildBeacon(in PayloadBuilderInput) (*BuildResult, erro return nil, fmt.Errorf("read template: %w", err) } - // 模板参数:请求 Host > 监听器 callback_host > bind 推导(见 ResolveBeaconDialHost) - host := ResolveBeaconDialHost(listener, in.Host, b.logger, listener.ID) - serverURL := fmt.Sprintf("%s://%s:%d", + serverURL := fmt.Sprintf("%s://%s", listenerTypeToScheme(listener.Type), - host, - listener.BindPort, + net.JoinHostPort(host, strconv.Itoa(listener.BindPort)), ) transport := "http" diff --git a/internal/c2/payload_oneliner.go b/internal/c2/payload_oneliner.go index 794eb126..0ca97c99 100644 --- a/internal/c2/payload_oneliner.go +++ b/internal/c2/payload_oneliner.go @@ -106,8 +106,8 @@ func ValidateOnelinerForListener(listener *database.C2Listener, kind OnelinerKin // - 同时返回执行示例,便于 AI 在对话里直接展示给操作员。 func GenerateOneliner(in OnelinerInput) (string, error) { host := strings.TrimSpace(in.Host) - if host == "" { - return "", fmt.Errorf("host is required") + if err := ValidateBeaconDialHost(host); err != nil { + return "", err } switch in.Kind { case OnelinerBash: diff --git a/internal/handler/c2.go b/internal/handler/c2.go index 77c54c4a..c20f3343 100644 --- a/internal/handler/c2.go +++ b/internal/handler/c2.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "io" + "net" "net/http" "os" "path/filepath" @@ -748,11 +749,15 @@ func (h *C2Handler) PayloadOneliner(c *gin.Context) { return } + scheme := "http" + if listener.Type == "https_beacon" { + scheme = "https" + } input := c2.OnelinerInput{ Kind: kind, Host: host, Port: listener.BindPort, - HTTPBaseURL: fmt.Sprintf("http://%s:%d", host, listener.BindPort), + HTTPBaseURL: fmt.Sprintf("%s://%s", scheme, net.JoinHostPort(host, strconv.Itoa(listener.BindPort))), ImplantToken: listener.ImplantToken, } diff --git a/internal/handler/c2_listener_validation_test.go b/internal/handler/c2_listener_validation_test.go index 50fab25c..392f2c6b 100644 --- a/internal/handler/c2_listener_validation_test.go +++ b/internal/handler/c2_listener_validation_test.go @@ -11,6 +11,7 @@ import ( "cyberstrike-ai/internal/c2" "cyberstrike-ai/internal/database" + "cyberstrike-ai/internal/security" "github.com/gin-gonic/gin" "go.uber.org/zap" ) @@ -53,3 +54,43 @@ func TestC2ListenerEditRejectsInvalidFieldsWithoutSaving(t *testing.T) { } } } + +func TestC2OnelinerRejectsInvalidCallbackAndUsesListenerScheme(t *testing.T) { + db, err := database.NewDB(filepath.Join(t.TempDir(), "callback.db"), zap.NewNop()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + listener := &database.C2Listener{ID: "https", Name: "test", Type: "https_beacon", BindHost: "127.0.0.1", BindPort: 8443, ImplantToken: "test-only-token", CreatedAt: time.Now()} + if err := db.CreateC2Listener(listener); err != nil { + t.Fatal(err) + } + h := NewC2Handler(c2.NewManager(db, zap.NewNop(), t.TempDir()), zap.NewNop()) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set(security.ContextSessionKey, security.Session{UserID: "test-owner", Scope: database.RBACScopeAll}) + }) + r.POST("/oneliner", h.PayloadOneliner) + for _, host := range []string{"bad host/abc", "https://example.com", "example.com:8443", "example.com", "::1"} { + req := httptest.NewRequest(http.MethodPost, "/oneliner", bytes.NewBufferString(fmt.Sprintf(`{"listener_id":"https","kind":"curl_beacon","host":%q}`, host))) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + valid := host == "example.com" || host == "::1" + if !valid && w.Code != 400 { + t.Fatalf("invalid callback status: %d", w.Code) + } + if valid { + if w.Code != 200 { + t.Fatalf("valid callback status: %d", w.Code) + } + expected := "https://example.com:8443" + if host == "::1" { + expected = "https://[::1]:8443" + } + if !bytes.Contains(w.Body.Bytes(), []byte(expected)) { + t.Fatalf("scheme or IPv6 formatting lost") + } + } + } +}