From b2e91d9fc09c3f42f70c820cb7e2da6f0fdc5d1b Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Wed, 13 May 2026 18:37:33 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20POC=20Cookie/?= =?UTF-8?q?=E5=8F=98=E9=87=8F=E6=8F=90=E5=8F=96=E7=9A=84=E4=B8=A4=E4=B8=AA?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - poc_adapter: xray/afrog 的 output.search 转换为 Search 字段,多步POC变量传递不再丢失 - poc_executor: Set-Cookie 提取优化不再要求捕获组名含 cookie,sessid/token等命名均生效 --- webscan/lib/poc_adapter.go | 10 +++ webscan/lib/poc_adapter_test.go | 149 +++++++++++++++++++++++++++++++ webscan/lib/poc_executor.go | 4 +- webscan/lib/poc_executor_test.go | 97 ++++++++++++++++++++ 4 files changed, 258 insertions(+), 2 deletions(-) diff --git a/webscan/lib/poc_adapter.go b/webscan/lib/poc_adapter.go index 3eb12a0..3bd813a 100644 --- a/webscan/lib/poc_adapter.go +++ b/webscan/lib/poc_adapter.go @@ -399,6 +399,11 @@ func (x *XrayPocAdapter) ToFscanPoc() (*Poc, error) { Expression: rule.Expression, } + // 转换 output 字段为 Search — 多步POC中从响应提取变量供后续步骤使用 + if searchVal, ok := rule.Output["search"]; ok { + fscanRule.Search = fmt.Sprintf("%v", searchVal) + } + // 如果expression为空,默认检查200状态码 if fscanRule.Expression == "" { fscanRule.Expression = "response.status == 200" @@ -500,6 +505,11 @@ func (a *AfrogPocAdapter) ToFscanPoc() (*Poc, error) { Expression: rule.Expression, } + // 转换 output 字段为 Search — 多步POC中从响应提取变量供后续步骤使用 + if searchVal, ok := rule.Output["search"]; ok { + fscanRule.Search = fmt.Sprintf("%v", searchVal) + } + // 如果expression为空,默认检查200状态码 if fscanRule.Expression == "" { fscanRule.Expression = "response.status == 200" diff --git a/webscan/lib/poc_adapter_test.go b/webscan/lib/poc_adapter_test.go index 3fb9367..2864e8d 100644 --- a/webscan/lib/poc_adapter_test.go +++ b/webscan/lib/poc_adapter_test.go @@ -1,6 +1,7 @@ package lib import ( + "strings" "testing" ) @@ -383,3 +384,151 @@ unknown: format }) } } + +// TestXrayOutputToSearch 测试 xray output 字段到 Search 的转换 +func TestXrayOutputToSearch(t *testing.T) { + // 多步POC: r0 提取 cookie,r1 使用 {{cookie}} + yamlData := ` +name: poc-yaml-test-cookie-extract +transport: http +rules: + r0: + request: + method: POST + path: /login + headers: + Content-Type: text/xml + body: userID=admin + follow_redirects: false + expression: response.status == 200 + output: + search: "Set-Cookie:(?P.*)" + r1: + request: + method: GET + path: /admin/dashboard + headers: + Cookie: "{{cookie}}" + expression: response.status == 200 +detail: + author: test +` + + adapter, err := loadXrayPoc([]byte(yamlData)) + if err != nil { + t.Fatalf("loadXrayPoc() error = %v", err) + } + + poc, err := adapter.ToFscanPoc() + if err != nil { + t.Fatalf("ToFscanPoc() error = %v", err) + } + + if len(poc.Rules) != 2 { + t.Fatalf("len(Poc.Rules) = %d, want 2", len(poc.Rules)) + } + + // r0 应该有 Search 字段(从 output.search 转换) + if poc.Rules[0].Search == "" { + t.Error("Rules[0].Search should not be empty — output.search was not converted") + } + if !strings.Contains(poc.Rules[0].Search, "cookie") { + t.Errorf("Rules[0].Search = %q, should contain 'cookie'", poc.Rules[0].Search) + } + + // r1 不应该有 Search(没有 output 字段) + if poc.Rules[1].Search != "" { + t.Errorf("Rules[1].Search = %q, should be empty", poc.Rules[1].Search) + } + + // r1 的 Headers 应保留 {{cookie}} 占位符 + if poc.Rules[1].Headers["Cookie"] != `{{cookie}}` { + t.Errorf("Rules[1].Headers[Cookie] = %q, want %q", poc.Rules[1].Headers["Cookie"], `{{cookie}}`) + } +} + +// TestXrayNoOutput 测试 xray 没有 output 字段时 Search 为空(回归) +func TestXrayNoOutput(t *testing.T) { + yamlData := ` +name: poc-yaml-test-simple +transport: http +rules: + r0: + request: + method: GET + path: /api/test + expression: response.status == 200 +detail: + author: test +` + + adapter, err := loadXrayPoc([]byte(yamlData)) + if err != nil { + t.Fatalf("loadXrayPoc() error = %v", err) + } + + poc, err := adapter.ToFscanPoc() + if err != nil { + t.Fatalf("ToFscanPoc() error = %v", err) + } + + if len(poc.Rules) != 1 { + t.Fatalf("len(Poc.Rules) = %d, want 1", len(poc.Rules)) + } + + if poc.Rules[0].Search != "" { + t.Errorf("Rules[0].Search = %q, should be empty when no output field", poc.Rules[0].Search) + } +} + +// TestAfrogOutputToSearch 测试 afrog output 字段到 Search 的转换 +func TestAfrogOutputToSearch(t *testing.T) { + yamlData := ` +id: test-afrog-cookie +info: + name: 测试Cookie提取 + author: test + severity: high +rules: + r0: + request: + method: POST + path: /login + headers: + Content-Type: application/x-www-form-urlencoded + body: username=admin&password=123456 + expression: response.status == 200 && response.body.bcontains(b"success") + output: + search: "Set-Cookie:(?P.*)" + r1: + request: + method: GET + path: /panel + headers: + Cookie: "{{sessid}}" + expression: response.status == 200 && response.body.bcontains(b"admin") +` + + adapter, err := loadAfrogPoc([]byte(yamlData)) + if err != nil { + t.Fatalf("loadAfrogPoc() error = %v", err) + } + + poc, err := adapter.ToFscanPoc() + if err != nil { + t.Fatalf("ToFscanPoc() error = %v", err) + } + + if len(poc.Rules) != 2 { + t.Fatalf("len(Poc.Rules) = %d, want 2", len(poc.Rules)) + } + + if poc.Rules[0].Search == "" { + t.Error("Rules[0].Search should not be empty — output.search was not converted") + } + + // r1 的占位符应对应捕获组名 sessid + if poc.Rules[1].Headers["Cookie"] != `{{sessid}}` { + t.Errorf("Rules[1].Headers[Cookie] = %q, want %q", poc.Rules[1].Headers["Cookie"], `{{sessid}}`) + } +} diff --git a/webscan/lib/poc_executor.go b/webscan/lib/poc_executor.go index 5aab360..0f2a1b9 100644 --- a/webscan/lib/poc_executor.go +++ b/webscan/lib/poc_executor.go @@ -348,8 +348,8 @@ func doSearch(re string, body string) map[string]string { paramsMap := make(map[string]string) for i, name := range names { if i > 0 && i <= len(result) { - // 特殊处理Cookie头 - if strings.HasPrefix(re, "Set-Cookie:") && strings.Contains(name, "cookie") { + // 特殊处理Set-Cookie头:剥离Path/Expires等属性,仅保留key=value + if strings.HasPrefix(re, "Set-Cookie:") { paramsMap[name] = optimizeCookies(result[i]) } else { paramsMap[name] = result[i] diff --git a/webscan/lib/poc_executor_test.go b/webscan/lib/poc_executor_test.go index 5972877..3627ec5 100644 --- a/webscan/lib/poc_executor_test.go +++ b/webscan/lib/poc_executor_test.go @@ -1,6 +1,7 @@ package lib import ( + "strings" "testing" ) @@ -101,6 +102,102 @@ func TestGetRuleHash(t *testing.T) { } } +// TestDoSearchSetCookieOptimization 测试 Set-Cookie 提取和清理 +func TestDoSearchSetCookieOptimization(t *testing.T) { + responseHeaders := "HTTP/1.1 200 OK\r\n" + cases := []struct { + name string + regex string + body string + wantContain string // 期望结果包含的内容 + wantNotContain string // 期望结果不包含的内容 + }{ + { + name: "捕获组名为cookie时清理属性", + regex: `Set-Cookie:(?P.*)`, + body: responseHeaders + "Set-Cookie: sessionid=abc123; Path=/; HttpOnly\r\n\r\n", + wantContain: "sessionid=abc123", + wantNotContain: "Path", + }, + { + name: "捕获组名为sessid时也清理属性", + regex: `Set-Cookie:(?P.*)`, + body: responseHeaders + "Set-Cookie: JSESSIONID=xyz789; Path=/app; Secure; HttpOnly\r\n\r\n{}", + wantContain: "JSESSIONID=xyz789", + wantNotContain: "Secure", + }, + { + name: "捕获组名为token时也清理属性", + regex: `Set-Cookie:(?P.*)`, + body: responseHeaders + "Set-Cookie: csrf_token=tok123; Max-Age=3600; SameSite=Strict\r\n\r\nOK", + wantContain: "csrf_token=tok123", + wantNotContain: "Max-Age", + }, + { + name: "非Set-Cookie的正则不触发清理", + regex: `X-Custom:(?P.*)`, + body: responseHeaders + "X-Custom: some-value; extra=stuff\r\n\r\ndone", + wantContain: "some-value; extra=stuff", + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + result := doSearch(c.regex, c.body) + if result == nil { + t.Fatal("doSearch() returned nil") + } + for _, v := range result { + if c.wantContain != "" && !strings.Contains(v, c.wantContain) { + t.Errorf("result should contain %q, got %q", c.wantContain, v) + } + if c.wantNotContain != "" && strings.Contains(v, c.wantNotContain) { + t.Errorf("result should NOT contain %q, got %q", c.wantNotContain, v) + } + } + }) + } +} + +// TestOptimizeCookies 测试 Cookie 清理函数 +func TestOptimizeCookies(t *testing.T) { + cases := []struct { + name string + raw string + want string + }{ + { + name: "标准Set-Cookie带多个属性", + raw: "sessionid=abc123; Path=/; HttpOnly; Secure", + want: "sessionid=abc123", + }, + { + name: "多个cookie键值对", + raw: "token=xyz; user=admin; Path=/app; Expires=Wed, 21 Oct 2025 07:28:00 GMT", + want: "token=xyz; user=admin", + }, + { + name: "无属性的干净cookie", + raw: "sid=simple", + want: "sid=simple", + }, + { + name: "空字符串", + raw: "", + want: "", + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := optimizeCookies(c.raw) + if got != c.want { + t.Errorf("optimizeCookies(%q) = %q, want %q", c.raw, got, c.want) + } + }) + } +} + // TestApplyParametersToRule 测试参数替换逻辑 func TestApplyParametersToRule(t *testing.T) { tests := []struct {