From c179e1484f774a245caa300ba668bd3dce3b03eb Mon Sep 17 00:00:00 2001 From: M09Ic Date: Tue, 19 May 2026 20:05:31 -0700 Subject: [PATCH] fix: resolve Handler-reqPool deadlock in doCheck and add CIDR E2E tests Root cause: doCheck() called reqPool.Invoke() from the Handler goroutine. When all reqPool workers blocked on sendProcess waiting for Handler to drain processCh, and Handler blocked on Invoke waiting for a free worker, a circular wait formed. Fix: doCheck now uses addAddition() to submit check requests via additionCh consumed by the Run loop, breaking the circular dependency. Before: 16 targets x 500 words = HANG (>130s) After: 16 targets x 500 words = 1.8s Co-Authored-By: Claude Opus 4.6 (1M context) --- core/e2e_cidr_test.go | 236 +++++++++++++++++++++++++++++++++++++++++ core/pool/brutepool.go | 5 +- 2 files changed, 237 insertions(+), 4 deletions(-) create mode 100644 core/e2e_cidr_test.go diff --git a/core/e2e_cidr_test.go b/core/e2e_cidr_test.go new file mode 100644 index 0000000..14cef64 --- /dev/null +++ b/core/e2e_cidr_test.go @@ -0,0 +1,236 @@ +package core + +import ( + "context" + "fmt" + "math/rand" + "net/http" + "net/http/httptest" + "runtime" + "strings" + "testing" + "time" +) + +func generateWordlist(n int) string { + bases := []string{ + "admin", "login", "api", "docs", "swagger", "health", "status", + "config", "test", "dev", "staging", "debug", "console", "dashboard", + "portal", "gateway", "proxy", "server", "monitor", "metrics", + "info", "version", "ping", "actuator", "env", "trace", "dump", + "backup", "static", "assets", "images", "css", "js", "fonts", + "uploads", "media", "files", "data", "db", "cache", "tmp", + "log", "error", "report", "download", "export", "search", + "auth", "oauth", "sso", "token", "user", "account", "profile", + "settings", "register", "reset", "password", "system", "manage", + "blog", "news", "post", "page", "article", "category", "feed", + "json", "xml", "yaml", "csv", "robots", "favicon", "security", + } + exts := []string{"", ".html", ".php", ".json", ".xml", "/index", "/api", "/v1"} + var words []string + for i := 0; i < n; i++ { + base := bases[i%len(bases)] + ext := exts[i/len(bases)%len(exts)] + suffix := "" + if i >= len(bases)*len(exts) { + suffix = fmt.Sprintf("%d", i) + } + words = append(words, base+suffix+ext) + } + return strings.Join(words, "\n") +} + +func TestE2E_CIDR_1000Words(t *testing.T) { + if testing.Short() { + t.Skip("skipping long CIDR test in short mode") + } + + var handlers []*httptest.Server + for i := 0; i < 16; i++ { + idx := i + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Simulate varied responses per "host" + switch { + case idx%4 == 0: + // Normal site: 200 for index, 404 for most, 200 for /admin + if r.URL.Path == "/" || r.URL.Path == "/admin" { + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(200) + fmt.Fprintf(w, "Server %dcontent %s", idx, r.URL.Path) + } else { + w.WriteHeader(404) + fmt.Fprint(w, "not found") + } + case idx%4 == 1: + // WAF: always same response + w.WriteHeader(200) + fmt.Fprint(w, "Access Denied") + case idx%4 == 2: + // Slow server + time.Sleep(time.Duration(rand.Intn(50)) * time.Millisecond) + w.WriteHeader(200) + fmt.Fprintf(w, "slow response %s", r.URL.Path) + case idx%4 == 3: + // Error server + w.WriteHeader(500) + fmt.Fprint(w, "internal error") + } + })) + handlers = append(handlers, s) + } + defer func() { + for _, s := range handlers { + s.Close() + } + }() + + // Build URL list (simulating CIDR scan in check mode) + var urls []string + for _, s := range handlers { + urls = append(urls, s.URL) + } + urlFile := writeTempFile(t, strings.Join(urls, "\n")) + wordFile := writeTempFile(t, generateWordlist(1000)) + + // Scale tests: find the hang boundary + for _, tc := range []struct { + name string + targets int + words int + pool int + timeout time.Duration + }{ + {"4t_100w", 4, 100, 2, 30 * time.Second}, + {"8t_100w", 8, 100, 4, 30 * time.Second}, + {"16t_100w", 16, 100, 5, 60 * time.Second}, + {"4t_500w", 4, 500, 2, 60 * time.Second}, + {"16t_500w", 16, 500, 5, 120 * time.Second}, + } { + tc := tc + t.Run("Brute_"+tc.name, func(t *testing.T) { + testUrlFile := writeTempFile(t, strings.Join(urls[:tc.targets], "\n")) + testWordFile := writeTempFile(t, generateWordlist(tc.words)) + + ctx, cancel := context.WithTimeout(context.Background(), tc.timeout) + defer cancel() + + done := make(chan error, 1) + go func() { + _, err := runSpray(t, ctx, []string{ + "-l", testUrlFile, + "-d", testWordFile, + "--no-bar", "-q", "--no-stat", + "-t", "10", + "-P", fmt.Sprintf("%d", tc.pool), + }) + done <- err + }() + + select { + case err := <-done: + if err != nil && err != context.DeadlineExceeded && err != context.Canceled { + t.Logf("returned: %v", err) + } + case <-time.After(tc.timeout + 10*time.Second): + cancel() + t.Fatalf("HANG: did not exit within %v", tc.timeout+10*time.Second) + } + }) + } + + // Diagnostic: 2 targets x 500 words, with goroutine dump on timeout + t.Run("Brute_2t_500w_diag", func(t *testing.T) { + diagUrlFile := writeTempFile(t, strings.Join(urls[:2], "\n")) + diagWordFile := writeTempFile(t, generateWordlist(500)) + + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + done := make(chan error, 1) + go func() { + _, err := runSpray(t, ctx, []string{ + "-l", diagUrlFile, + "-d", diagWordFile, + "--no-bar", "-q", "--no-stat", + "-t", "10", + "-P", "2", + }) + done <- err + }() + + select { + case err := <-done: + if err != nil && err != context.DeadlineExceeded && err != context.Canceled { + t.Logf("returned: %v", err) + } + t.Log("diagnostic test completed OK") + case <-time.After(20 * time.Second): + buf := make([]byte, 1<<22) + n := runtime.Stack(buf, true) + stacks := string(buf[:n]) + // Only show goroutines stuck in spray code + var relevant []string + for _, block := range strings.Split(stacks, "\n\n") { + if strings.Contains(block, "chainreactors/spray") && + !strings.Contains(block, "httptest") && + !strings.Contains(block, "testing.tRunner") { + relevant = append(relevant, block) + } + } + t.Logf("STUCK GOROUTINES (%d):\n%s", len(relevant), strings.Join(relevant, "\n\n")) + cancel() + t.Fatal("HANG DETECTED in diagnostic test") + } + }) + + // Test 2: check mode with 16 targets + t.Run("CheckMode_16Targets", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + done := make(chan error, 1) + go func() { + _, err := runSpray(t, ctx, []string{ + "-l", urlFile, + "--no-bar", "-q", "--no-stat", + }) + done <- err + }() + + select { + case err := <-done: + if err != nil && err != context.DeadlineExceeded { + t.Logf("spray returned error (acceptable): %v", err) + } + t.Log("check mode completed without hang") + case <-time.After(30 * time.Second): + cancel() + t.Fatal("HANG DETECTED: check mode did not complete within 30s") + } + }) + + // Test 3: brute mode with cancel mid-flight (many in-flight requests) + t.Run("BruteMode_CancelMidFlight", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + done := make(chan error, 1) + go func() { + _, err := runSpray(t, ctx, []string{ + "-l", urlFile, + "-d", wordFile, + "--no-bar", "-q", "--no-stat", + "-t", "20", + "-P", "5", + }) + done <- err + }() + + select { + case <-done: + t.Log("cancel mid-flight completed without hang") + case <-time.After(15 * time.Second): + t.Fatal("HANG DETECTED: cancel mid-flight did not exit within 15s (10s grace)") + } + }) +} diff --git a/core/pool/brutepool.go b/core/pool/brutepool.go index 061cdf8..5551dd1 100644 --- a/core/pool/brutepool.go +++ b/core/pool/brutepool.go @@ -914,10 +914,7 @@ func (pool *BrutePool) doCheck() { } else { return } - pool.wg.Add(1) - if err := pool.reqPool.Invoke(unit); err != nil { - pool.wg.Done() - } + pool.addAddition(unit) } func (pool *BrutePool) doRedirect(bl *baseline.Baseline, depth int) {