From 8ec96bfe6d1a8aa44934d114b0f1e3cf8516ea34 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Mon, 1 Jun 2026 03:32:13 +0800 Subject: [PATCH] fix: harden scan edge cases --- common/config_builder.go | 52 ++++++++++------- common/config_builder_test.go | 64 +++++++++++++++++++-- common/parsers/host_iterator.go | 13 +++-- common/parsers/host_iterator_test.go | 44 ++++++++++++++ common/parsers/parse_test.go | 18 +++++- common/parsers/parsers.go | 33 ++++++----- core/alive_scanner.go | 7 ++- core/host_excludes.go | 28 +++++++++ core/host_excludes_test.go | 29 ++++++++++ core/port_scan.go | 2 +- core/service_scanner.go | 21 ++++--- core/socket_iterator.go | 6 +- core/socket_iterator_test.go | 11 ++++ plugins/services/neo4j.go | 30 +++++++++- plugins/services/neo4j_test.go | 59 +++++++++++++++++++ plugins/services/rabbitmq.go | 9 ++- plugins/services/rabbitmq_test.go | 20 +++++++ plugins/web/webtitle.go | 15 +++-- plugins/web/webtitle_test.go | 37 ++++++++++++ webscan/lib/Eval.go | 18 +++++- webscan/lib/eval_test.go | 85 ++++++++++++++++++++++++++++ 21 files changed, 534 insertions(+), 67 deletions(-) create mode 100644 core/host_excludes.go create mode 100644 core/host_excludes_test.go create mode 100644 plugins/services/neo4j_test.go create mode 100644 plugins/services/rabbitmq_test.go create mode 100644 plugins/web/webtitle_test.go diff --git a/common/config_builder.go b/common/config_builder.go index e14057a..d5b7210 100644 --- a/common/config_builder.go +++ b/common/config_builder.go @@ -49,7 +49,10 @@ func BuildConfig(fv *FlagVars, info *HostInfo) (*Config, *State, error) { func parseCredentials(fv *FlagVars, cfg *Config) error { // 解析用户名 - usernames := parseUsernames(fv) + usernames, err := parseUsernames(fv) + if err != nil { + return err + } if len(usernames) > 0 { for serviceName := range cfg.Credentials.Userdict { cfg.Credentials.Userdict[serviceName] = usernames @@ -57,7 +60,10 @@ func parseCredentials(fv *FlagVars, cfg *Config) error { } // 解析密码 - passwords := parsePasswords(fv) + passwords, err := parsePasswords(fv) + if err != nil { + return err + } if len(passwords) > 0 { cfg.Credentials.Passwords = passwords } @@ -84,7 +90,7 @@ func parseCredentials(fv *FlagVars, cfg *Config) error { return nil } -func parseUsernames(fv *FlagVars) []string { +func parseUsernames(fv *FlagVars) ([]string, error) { var usernames []string // 命令行用户名 @@ -102,7 +108,7 @@ func parseUsernames(fv *FlagVars) []string { if lines, err := parsers.ReadLinesFromFile(fv.UsersFile); err == nil { usernames = append(usernames, lines...) } else { - LogError(i18n.Tr("config_read_users_failed", fv.UsersFile, err)) + return nil, fmt.Errorf("%s", i18n.Tr("config_read_users_failed", fv.UsersFile, err)) } } @@ -116,15 +122,15 @@ func parseUsernames(fv *FlagVars) []string { } } - return removeDuplicate(usernames) + return removeDuplicate(usernames), nil } -func parsePasswords(fv *FlagVars) []string { +func parsePasswords(fv *FlagVars) ([]string, error) { var passwords []string // 命令行密码 if fv.Password != "" { - passwords = append(passwords, splitCredentialValues(fv.Password)...) + passwords = append(passwords, fv.Password) } // 从文件读取 @@ -132,7 +138,7 @@ func parsePasswords(fv *FlagVars) []string { if lines, err := parsers.ReadLinesFromFile(fv.PasswordsFile); err == nil { passwords = append(passwords, lines...) } else { - LogError(i18n.Tr("config_read_passwords_failed", fv.PasswordsFile, err)) + return nil, fmt.Errorf("%s", i18n.Tr("config_read_passwords_failed", fv.PasswordsFile, err)) } } @@ -141,7 +147,7 @@ func parsePasswords(fv *FlagVars) []string { passwords = append(passwords, splitCredentialValues(fv.AddPasswords)...) } - return removeDuplicate(passwords) + return removeDuplicate(passwords), nil } func splitCredentialValues(input string) []string { @@ -192,12 +198,15 @@ func parseHashes(fv *FlagVars) ([]string, [][]byte, error) { // 命令行哈希 if fv.HashValue != "" { hash := strings.TrimSpace(fv.HashValue) - if len(hash) == 32 { - hashValues = append(hashValues, hash) - if hashByte, err := hex.DecodeString(hash); err == nil { - hashBytes = append(hashBytes, hashByte) - } + if len(hash) != 32 { + return nil, nil, fmt.Errorf("invalid hash length: %s", hash) } + hashByte, err := hex.DecodeString(hash) + if err != nil { + return nil, nil, err + } + hashValues = append(hashValues, hash) + hashBytes = append(hashBytes, hashByte) } // 从文件读取 @@ -225,13 +234,17 @@ func parseTargets(fv *FlagVars, info *HostInfo, cfg *Config, state *State) error if port, portErr := strconv.Atoi(portStr); portErr == nil && port >= 1 && port <= 65535 { // 有效的 host:port 格式 state.SetHostPorts([]string{info.Host}) + info.Host = "" ports = "" // 清空端口,避免双重扫描 } } } // 解析 URL - urls := parseURLs(fv) + urls, err := parseURLs(fv) + if err != nil { + return err + } if len(urls) > 0 { state.SetURLs(urls) if info.URL == "" && len(urls) == 1 { @@ -247,7 +260,7 @@ func parseTargets(fv *FlagVars, info *HostInfo, cfg *Config, state *State) error return nil } -func parseURLs(fv *FlagVars) []string { +func parseURLs(fv *FlagVars) ([]string, error) { var urls []string // 命令行 URL @@ -267,11 +280,11 @@ func parseURLs(fv *FlagVars) []string { urls = append(urls, normalizeURL(line)) } } else { - LogError(i18n.Tr("config_read_urls_failed", fv.URLsFile, err)) + return nil, fmt.Errorf("%s", i18n.Tr("config_read_urls_failed", fv.URLsFile, err)) } } - return removeDuplicate(urls) + return removeDuplicate(urls), nil } func normalizeURL(rawURL string) string { @@ -279,7 +292,8 @@ func normalizeURL(rawURL string) string { if rawURL == "" { return rawURL } - if !strings.HasPrefix(rawURL, "http://") && !strings.HasPrefix(rawURL, "https://") { + lowerURL := strings.ToLower(rawURL) + if !strings.HasPrefix(lowerURL, "http://") && !strings.HasPrefix(lowerURL, "https://") { return "http://" + rawURL } return rawURL diff --git a/common/config_builder_test.go b/common/config_builder_test.go index 87a8523..376ac61 100644 --- a/common/config_builder_test.go +++ b/common/config_builder_test.go @@ -5,15 +5,71 @@ import ( "testing" ) -func TestParsePasswordsSplitsCommaAndWhitespace(t *testing.T) { +func TestParsePasswordsKeepsPrimaryPasswordLiteral(t *testing.T) { fv := &FlagVars{ - Password: "root,admin", + Password: "root admin", AddPasswords: "pass1 pass2,pass3\tpass4", } - got := parsePasswords(fv) - want := []string{"root", "admin", "pass1", "pass2", "pass3", "pass4"} + got, err := parsePasswords(fv) + if err != nil { + t.Fatalf("parsePasswords error = %v", err) + } + want := []string{"root admin", "pass1", "pass2", "pass3", "pass4"} if !reflect.DeepEqual(got, want) { t.Fatalf("parsePasswords() = %#v, want %#v", got, want) } } + +func TestBuildConfigReturnsUserFileError(t *testing.T) { + _, _, err := BuildConfig(&FlagVars{UsersFile: "missing-users-file.txt"}, &HostInfo{}) + if err == nil { + t.Fatal("BuildConfig should fail for missing users file") + } +} + +func TestBuildConfigReturnsPasswordFileError(t *testing.T) { + _, _, err := BuildConfig(&FlagVars{PasswordsFile: "missing-passwords-file.txt"}, &HostInfo{}) + if err == nil { + t.Fatal("BuildConfig should fail for missing passwords file") + } +} + +func TestBuildConfigReturnsURLFileError(t *testing.T) { + _, _, err := BuildConfig(&FlagVars{URLsFile: "missing-urls-file.txt"}, &HostInfo{}) + if err == nil { + t.Fatal("BuildConfig should fail for missing urls file") + } +} + +func TestBuildConfigRejectsInvalidHashValue(t *testing.T) { + _, _, err := BuildConfig(&FlagVars{HashValue: "not-md5"}, &HostInfo{}) + if err == nil { + t.Fatal("BuildConfig should fail for invalid hash value") + } +} + +func TestParseTargetsHostPortDoesNotLeaveSyntheticHost(t *testing.T) { + fv := &FlagVars{Ports: "22"} + info := &HostInfo{Host: "127.0.0.1:8080"} + cfg := BuildConfigFromFlags(fv) + state := NewState() + + if err := parseTargets(fv, info, cfg, state); err != nil { + t.Fatalf("parseTargets error = %v", err) + } + + if info.Host != "" { + t.Fatalf("info.Host = %q, want empty after host:port extraction", info.Host) + } + if got := state.GetHostPorts(); !reflect.DeepEqual(got, []string{"127.0.0.1:8080"}) { + t.Fatalf("hostPorts = %#v, want host:port target", got) + } +} + +func TestNormalizeURLKeepsUppercaseScheme(t *testing.T) { + got := normalizeURL("HTTPS://example.com") + if got != "HTTPS://example.com" { + t.Fatalf("normalizeURL() = %q", got) + } +} diff --git a/common/parsers/host_iterator.go b/common/parsers/host_iterator.go index a50a488..f937462 100644 --- a/common/parsers/host_iterator.go +++ b/common/parsers/host_iterator.go @@ -39,8 +39,11 @@ func NewHostIterator(host string, filename string, nohosts ...string) (*HostIter sources = append(sources, hostSources...) matcher := newHostMatcher() - if len(nohosts) > 0 && strings.TrimSpace(nohosts[0]) != "" { - if err := matcher.add(nohosts[0]); err != nil { + for _, exclude := range nohosts { + if strings.TrimSpace(exclude) == "" { + continue + } + if err := matcher.add(exclude); err != nil { closeHostSources(sources) return nil, err } @@ -180,10 +183,12 @@ func newFileHostSource(filename string) (*fileHostSource, error) { if err != nil { return nil, err } - return &fileHostSource{ + src := &fileHostSource{ file: file, scanner: bufio.NewScanner(file), - }, nil + } + src.scanner.Buffer(make([]byte, 64*1024), 4*1024*1024) + return src, nil } func (s *fileHostSource) Next() (string, bool, error) { diff --git a/common/parsers/host_iterator_test.go b/common/parsers/host_iterator_test.go index 5d7c9de..c2c0f9b 100644 --- a/common/parsers/host_iterator_test.go +++ b/common/parsers/host_iterator_test.go @@ -2,7 +2,9 @@ package parsers import ( "context" + "os" "reflect" + "strings" "testing" ) @@ -59,3 +61,45 @@ func TestHostIteratorExcludeCIDR(t *testing.T) { t.Fatalf("batch = %#v, want %#v", batch, want) } } + +func TestHostIteratorAcceptsMultipleExcludeSources(t *testing.T) { + iter, err := NewHostIterator("192.168.1.0/29", "", "192.168.1.2", "192.168.1.5") + if err != nil { + t.Fatalf("NewHostIterator error = %v", err) + } + defer iter.Close() + + batch, err := iter.NextBatch(context.Background(), 10) + if err != nil { + t.Fatalf("NextBatch error = %v", err) + } + + want := []string{"192.168.1.1", "192.168.1.3", "192.168.1.4", "192.168.1.6"} + if !reflect.DeepEqual(batch, want) { + t.Fatalf("batch = %#v, want %#v", batch, want) + } +} + +func TestHostIteratorReadsLongHostFileLine(t *testing.T) { + dir := t.TempDir() + path := dir + "/hosts.txt" + longPrefix := strings.Repeat("a", 70*1024) + host := longPrefix + ".example.com" + if err := os.WriteFile(path, []byte(host+"\n"), 0o600); err != nil { + t.Fatalf("WriteFile error = %v", err) + } + + iter, err := NewHostIterator("", path) + if err != nil { + t.Fatalf("NewHostIterator error = %v", err) + } + defer iter.Close() + + batch, err := iter.NextBatch(context.Background(), 1) + if err != nil { + t.Fatalf("NextBatch error = %v", err) + } + if !reflect.DeepEqual(batch, []string{host}) { + t.Fatalf("batch = %#v, want long host", batch) + } +} diff --git a/common/parsers/parse_test.go b/common/parsers/parse_test.go index 66fbb1c..03aacea 100644 --- a/common/parsers/parse_test.go +++ b/common/parsers/parse_test.go @@ -696,6 +696,18 @@ func TestParseIP_Exclude(t *testing.T) { } } +func TestParseIPMultipleExcludeSources(t *testing.T) { + result, err := ParseIP("192.168.1.1-192.168.1.4", "", "192.168.1.2", "192.168.1.4") + if err != nil { + t.Fatalf("ParseIP error = %v", err) + } + + expected := []string{"192.168.1.1", "192.168.1.3"} + if !reflect.DeepEqual(result, expected) { + t.Fatalf("ParseIP with multiple excludes = %v, want %v", result, expected) + } +} + // TestParseIP_Deduplicate 测试去重 func TestParseIP_Deduplicate(t *testing.T) { result, err := ParseIP("192.168.1.1,192.168.1.1,192.168.1.2,192.168.1.2", "", "") @@ -800,7 +812,9 @@ func TestParsePortRange(t *testing.T) { // TestExcludeHosts 测试排除主机 func TestExcludeHosts(t *testing.T) { hosts := []string{"host1", "host2", "host3", "host4"} - exclude := []string{"host2", "host4"} + exclude := newHostMatcher() + exclude.exact["host2"] = struct{}{} + exclude.exact["host4"] = struct{}{} result := excludeFromList(hosts, exclude) expected := []string{"host1", "host3"} @@ -815,7 +829,7 @@ func TestExcludeHosts(t *testing.T) { // TestExcludeHosts_EmptyExclude 测试空排除列表 func TestExcludeHosts_EmptyExclude(t *testing.T) { hosts := []string{"host1", "host2"} - result := excludeFromList(hosts, []string{}) + result := excludeFromList(hosts, nil) if !reflect.DeepEqual(result, hosts) { t.Errorf("excludeFromList(空排除列表) 应该返回原列表") diff --git a/common/parsers/parsers.go b/common/parsers/parsers.go index 1cb6268..dd7b8b8 100644 --- a/common/parsers/parsers.go +++ b/common/parsers/parsers.go @@ -53,18 +53,27 @@ func ParseIP(host string, filename string, nohosts ...string) ([]string, error) if host != "" { hostList, err := parseHostString(host) if err != nil { - return nil, fmt.Errorf(i18n.GetText("parser_parse_host_failed")+": %w", err) + return nil, fmt.Errorf(i18n.GetText("parser_parse_host_failed")+": %w", err) } hosts = append(hosts, hostList...) } // 处理排除主机 - if len(nohosts) > 0 && nohosts[0] != "" { - excludeList, err := parseHostString(nohosts[0]) - if err != nil { - return nil, fmt.Errorf(i18n.GetText("parser_parse_exclude_failed")+": %w", err) + if len(nohosts) > 0 { + matcher := newHostMatcher() + hasExclude := false + for _, exclude := range nohosts { + if strings.TrimSpace(exclude) == "" { + continue + } + hasExclude = true + if err := matcher.add(exclude); err != nil { + return nil, fmt.Errorf(i18n.GetText("parser_parse_exclude_failed")+": %w", err) + } + } + if hasExclude { + hosts = excludeFromList(hosts, matcher) } - hosts = excludeFromList(hosts, excludeList) } // 去重和排序 @@ -212,6 +221,7 @@ func ReadLinesFromFile(filename string) ([]string, error) { var lines []string scanner := bufio.NewScanner(file) + scanner.Buffer(make([]byte, 64*1024), 4*1024*1024) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line != "" && !strings.HasPrefix(line, "#") { @@ -420,19 +430,14 @@ func incrementIP(ip net.IP) { } // excludeFromList 从列表中排除指定项 -func excludeFromList(hosts, excludeList []string) []string { - if len(excludeList) == 0 { +func excludeFromList(hosts []string, matcher *hostMatcher) []string { + if matcher == nil { return hosts } - excludeMap := make(map[string]struct{}, len(excludeList)) - for _, e := range excludeList { - excludeMap[e] = struct{}{} - } - result := make([]string, 0, len(hosts)) for _, h := range hosts { - if _, found := excludeMap[h]; !found { + if !matcher.match(h) { result = append(result, h) } } diff --git a/core/alive_scanner.go b/core/alive_scanner.go index e421d25..1d2f250 100644 --- a/core/alive_scanner.go +++ b/core/alive_scanner.go @@ -67,7 +67,12 @@ func (s *AliveScanStrategy) Execute(ctx context.Context, session *common.ScanSes // performAliveScan 执行存活探测 func (s *AliveScanStrategy) performAliveScan(ctx context.Context, info common.HostInfo, session *common.ScanSession) { - iter, err := parsers.NewHostIterator(info.Host, session.Params.HostsFile, session.Params.ExcludeHosts) + excludes, err := loadHostExcludes(session.Params) + if err != nil { + session.LogError(i18n.Tr("parse_target_failed", err)) + return + } + iter, err := parsers.NewHostIterator(info.Host, session.Params.HostsFile, excludes...) if err != nil { session.LogError(i18n.Tr("parse_target_failed", err)) return diff --git a/core/host_excludes.go b/core/host_excludes.go new file mode 100644 index 0000000..a6c5bad --- /dev/null +++ b/core/host_excludes.go @@ -0,0 +1,28 @@ +package core + +import ( + "strings" + + "github.com/shadow1ng/fscan/common" + "github.com/shadow1ng/fscan/common/parsers" +) + +func loadHostExcludes(params *common.FlagVars) ([]string, error) { + if params == nil { + return nil, nil + } + + excludes := make([]string, 0, 1) + if strings.TrimSpace(params.ExcludeHosts) != "" { + excludes = append(excludes, params.ExcludeHosts) + } + if strings.TrimSpace(params.ExcludeHostsFile) == "" { + return excludes, nil + } + + lines, err := parsers.ReadLinesFromFile(params.ExcludeHostsFile) + if err != nil { + return nil, err + } + return append(excludes, lines...), nil +} diff --git a/core/host_excludes_test.go b/core/host_excludes_test.go new file mode 100644 index 0000000..1252f6c --- /dev/null +++ b/core/host_excludes_test.go @@ -0,0 +1,29 @@ +package core + +import ( + "os" + "reflect" + "testing" + + "github.com/shadow1ng/fscan/common" +) + +func TestLoadHostExcludesIncludesExcludeFile(t *testing.T) { + path := t.TempDir() + "/exclude.txt" + if err := os.WriteFile(path, []byte("192.168.1.2\n# comment\n192.168.1.3\n"), 0o600); err != nil { + t.Fatalf("WriteFile error = %v", err) + } + + got, err := loadHostExcludes(&common.FlagVars{ + ExcludeHosts: "192.168.1.1", + ExcludeHostsFile: path, + }) + if err != nil { + t.Fatalf("loadHostExcludes error = %v", err) + } + + want := []string{"192.168.1.1", "192.168.1.2", "192.168.1.3"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("loadHostExcludes = %#v, want %#v", got, want) + } +} diff --git a/core/port_scan.go b/core/port_scan.go index 64cd037..553da8b 100644 --- a/core/port_scan.go +++ b/core/port_scan.go @@ -203,7 +203,7 @@ func EnhancedPortScan(ctx context.Context, hosts []string, ports string, timeout // 初始化端口扫描进度条 if totalTasks > 0 && config.Output.ShowProgress { description := i18n.Tr("port_scan_progress_description", threadNum) - common.InitProgressBar(int64(totalTasks), description) + common.InitProgressBar(totalTasks, description) } session.LogDebug(i18n.GetText("port_scan_debug_progress_ready")) diff --git a/core/service_scanner.go b/core/service_scanner.go index 5aa3b28..cc87a54 100644 --- a/core/service_scanner.go +++ b/core/service_scanner.go @@ -145,7 +145,12 @@ func (s *ServiceScanStrategy) performHostScan(ctx context.Context, session *comm config := session.Config state := session.State - iter, err := parsers.NewHostIterator(info.Host, session.Params.HostsFile, session.Params.ExcludeHosts) + excludes, err := loadHostExcludes(session.Params) + if err != nil { + session.LogError(fmt.Sprintf("%s: %v", i18n.GetText("parse_target_failed"), err)) + return + } + iter, err := parsers.NewHostIterator(info.Host, session.Params.HostsFile, excludes...) if err != nil { session.LogError(fmt.Sprintf("%s: %v", i18n.GetText("parse_target_failed"), err)) return @@ -157,6 +162,7 @@ func (s *ServiceScanStrategy) performHostScan(ctx context.Context, session *comm pluginsToRun, isCustomMode := s.GetPlugins(config) totalAlive := 0 sawHosts := false + performedLiveness := false for { hosts, err := iter.NextBatch(ctx, targetHostBatchSize(config)) @@ -170,6 +176,7 @@ func (s *ServiceScanStrategy) performHostScan(ctx context.Context, session *comm sawHosts = true if s.shouldPerformLivenessCheck(hosts, config) { + performedLiveness = true hosts = CheckLive(ctx, hosts, false, session) } totalAlive += len(hosts) @@ -181,7 +188,7 @@ func (s *ServiceScanStrategy) performHostScan(ctx context.Context, session *comm s.scanHostBatch(ctx, session, hosts, info, pluginsToRun, isCustomMode, ch, wg) } - if sawHosts && s.shouldReportAliveCount(config) { + if sawHosts && performedLiveness { session.LogInfo(i18n.Tr("alive_hosts_count_info", totalAlive)) } @@ -234,10 +241,6 @@ func (s *ServiceScanStrategy) scanHostBatch(ctx context.Context, session *common } } -func (s *ServiceScanStrategy) shouldReportAliveCount(config *common.Config) bool { - return !config.DisablePing -} - // dispatchUDPPlugins 分发UDP协议插件,跳过TCP端口扫描链路 func (s *ServiceScanStrategy) dispatchUDPPlugins(ctx context.Context, session *common.ScanSession, hosts []string, baseInfo common.HostInfo, config *common.Config, ch chan struct{}, wg *sync.WaitGroup) { _, isCustomMode := s.GetPlugins(config) @@ -341,7 +344,11 @@ func (s *ServiceScanStrategy) discoverTargets(ctx context.Context, hostInput str config := session.Config state := session.State // 标准流程:解析目标主机 - hosts, err := parsers.ParseIP(hostInput, session.Params.HostsFile, session.Params.ExcludeHosts) + excludes, err := loadHostExcludes(session.Params) + if err != nil { + return nil, fmt.Errorf("%s: %w", i18n.GetText("parse_target_failed"), err) + } + hosts, err := parsers.ParseIP(hostInput, session.Params.HostsFile, excludes...) if err != nil { return nil, fmt.Errorf("%s: %w", i18n.GetText("parse_target_failed"), err) } diff --git a/core/socket_iterator.go b/core/socket_iterator.go index f5868b0..c8b1b6a 100644 --- a/core/socket_iterator.go +++ b/core/socket_iterator.go @@ -39,7 +39,7 @@ type SocketIterator struct { ports []int hostIdx int portIdx int - total int + total int64 mu sync.Mutex } @@ -51,7 +51,7 @@ func NewSocketIterator(hosts []string, ports []int, exclude map[int]struct{}) *S return &SocketIterator{ hosts: hosts, ports: sortedPorts, - total: len(hosts) * len(sortedPorts), + total: int64(len(hosts)) * int64(len(sortedPorts)), } } @@ -113,7 +113,7 @@ func (it *SocketIterator) Next() (string, int, bool) { } // Total 返回总任务数(用于进度条) -func (it *SocketIterator) Total() int { +func (it *SocketIterator) Total() int64 { return it.total } diff --git a/core/socket_iterator_test.go b/core/socket_iterator_test.go index 2954785..17357a5 100644 --- a/core/socket_iterator_test.go +++ b/core/socket_iterator_test.go @@ -182,6 +182,17 @@ func TestSocketIterator_EmptyInputs(t *testing.T) { }) } +func TestSocketIteratorTotalUsesInt64(t *testing.T) { + hosts := make([]string, 1<<20) + ports := make([]int, 4096) + it := NewSocketIterator(hosts, ports, nil) + + want := int64(len(hosts)) * int64(len(ports)) + if it.Total() != want { + t.Fatalf("Total() = %d, want %d", it.Total(), want) + } +} + // TestSocketIterator_PortPrioritySort 验证端口优先级排序 // 高价值端口(80, 443, 22等)应该排在前面 func TestSocketIterator_PortPrioritySort(t *testing.T) { diff --git a/plugins/services/neo4j.go b/plugins/services/neo4j.go index fcf7e64..c2cb47c 100644 --- a/plugins/services/neo4j.go +++ b/plugins/services/neo4j.go @@ -163,6 +163,21 @@ 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) + if err != nil { + return &ScanResult{ + Success: false, + Service: "neo4j", + Error: err, + } + } + if !strings.Contains(strings.ToLower(string(body)), "neo4j") { + return &ScanResult{ + Success: false, + Service: "neo4j", + Error: fmt.Errorf("%s", i18n.Tr("service_not_identified", "Neo4j")), + } + } return &ScanResult{ Type: plugins.ResultTypeVuln, Success: true, @@ -206,11 +221,22 @@ 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, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return &ScanResult{ + Success: false, + Service: "neo4j", + Error: err, + } + } if strings.Contains(strings.ToLower(string(body)), "neo4j") { banner = "Neo4j" } else { - banner = "Neo4j" + return &ScanResult{ + Success: false, + Service: "neo4j", + Error: fmt.Errorf("%s", i18n.Tr("service_not_identified", "Neo4j")), + } } } else { return &ScanResult{ diff --git a/plugins/services/neo4j_test.go b/plugins/services/neo4j_test.go new file mode 100644 index 0000000..0ad6a9f --- /dev/null +++ b/plugins/services/neo4j_test.go @@ -0,0 +1,59 @@ +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")) + })) + defer server.Close() + + result := NewNeo4jPlugin().identifyService(context.Background(), hostInfoFromServer(t, server), testSession()) + if result.Success { + t.Fatalf("identifyService reported generic HTTP as Neo4j: %#v", result) + } +} + +func TestNeo4jUnauthorizedRequiresNeo4jBody(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("ok")) + })) + defer server.Close() + + result := NewNeo4jPlugin().testUnauthorizedAccess(context.Background(), hostInfoFromServer(t, server), testSession()) + if result != nil && result.Success { + t.Fatalf("testUnauthorizedAccess reported generic 200 as Neo4j: %#v", result) + } +} diff --git a/plugins/services/rabbitmq.go b/plugins/services/rabbitmq.go index e75c77d..213a489 100644 --- a/plugins/services/rabbitmq.go +++ b/plugins/services/rabbitmq.go @@ -285,7 +285,14 @@ func (p *RabbitMQPlugin) testManagementInterface(ctx context.Context, info *comm defer func() { _ = resp.Body.Close() }() if resp.StatusCode == 200 || resp.StatusCode == 401 { - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return &ScanResult{ + Success: false, + Service: "rabbitmq", + Error: err, + } + } if strings.Contains(strings.ToLower(string(body)), "rabbitmq") { banner := "RabbitMQ Management" common.LogSuccess(i18n.Tr("rabbitmq_detected", target, banner)) diff --git a/plugins/services/rabbitmq_test.go b/plugins/services/rabbitmq_test.go new file mode 100644 index 0000000..5697929 --- /dev/null +++ b/plugins/services/rabbitmq_test.go @@ -0,0 +1,20 @@ +package services + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +func TestRabbitMQManagementRejectsGenericHTTP(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("plain http service")) + })) + defer server.Close() + + result := NewRabbitMQPlugin().testManagementInterface(context.Background(), hostInfoFromServer(t, server), testSession()) + if result.Success { + t.Fatalf("testManagementInterface reported generic HTTP as RabbitMQ: %#v", result) + } +} diff --git a/plugins/web/webtitle.go b/plugins/web/webtitle.go index 1d09286..c8776e5 100644 --- a/plugins/web/webtitle.go +++ b/plugins/web/webtitle.go @@ -124,7 +124,7 @@ func (p *WebTitlePlugin) getWebTitle(ctx context.Context, info *common.HostInfo, body, err := io.ReadAll(resp.Body) _ = resp.Body.Close() contentLen := len(body) - if contentLen <= 0 && err != nil { + if err != nil { return "", resp.StatusCode, 0, resp.Header.Get("Server"), nil, displayURL, err } @@ -133,7 +133,7 @@ func (p *WebTitlePlugin) getWebTitle(ctx context.Context, info *common.HostInfo, checkDataList = append(checkDataList, WebScan.CheckDatas{ Body: body, Headers: p.formatHeaders(resp.Header), - Favicon: p.fetchFaviconHash(baseURL), + Favicon: p.fetchFaviconHash(ctx, baseURL), }) title := p.extractTitle(string(body)) @@ -153,15 +153,14 @@ 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, _ := io.ReadAll(respRedirect.Body) + bodyRedirect, err := io.ReadAll(respRedirect.Body) _ = respRedirect.Body.Close() - - if len(bodyRedirect) > 0 { + if err == nil && len(bodyRedirect) > 0 { // 添加跳转后页面的指纹数据 checkDataList = append(checkDataList, WebScan.CheckDatas{ Body: bodyRedirect, Headers: p.formatHeaders(respRedirect.Header), - Favicon: p.fetchFaviconHash(redirectURL), + Favicon: p.fetchFaviconHash(ctx, redirectURL), }) // 如果原始页面没有标题,使用跳转后页面的标题 @@ -314,7 +313,7 @@ func (p *WebTitlePlugin) extractTitle(html string) string { } // fetchFaviconHash 下载 favicon.ico 并计算 hash -func (p *WebTitlePlugin) fetchFaviconHash(baseURL string) fingerprint.FaviconHashes { +func (p *WebTitlePlugin) fetchFaviconHash(ctx context.Context, baseURL string) fingerprint.FaviconHashes { // 构造 favicon URL u, err := url.Parse(baseURL) if err != nil { @@ -323,7 +322,7 @@ func (p *WebTitlePlugin) fetchFaviconHash(baseURL string) fingerprint.FaviconHas faviconURL := fmt.Sprintf("%s://%s/favicon.ico", u.Scheme, u.Host) // 请求 favicon - req, err := http.NewRequest("GET", faviconURL, nil) + req, err := http.NewRequestWithContext(ctx, "GET", faviconURL, nil) if err != nil { return fingerprint.FaviconHashes{} } diff --git a/plugins/web/webtitle_test.go b/plugins/web/webtitle_test.go new file mode 100644 index 0000000..cef33dd --- /dev/null +++ b/plugins/web/webtitle_test.go @@ -0,0 +1,37 @@ +package web + +import ( + "context" + "net/http" + "testing" + + "github.com/shadow1ng/fscan/webscan/lib" +) + +type faviconRoundTripper struct { + called bool +} + +func (rt *faviconRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + rt.called = true + <-req.Context().Done() + return nil, req.Context().Err() +} + +func TestFetchFaviconHashHonorsContext(t *testing.T) { + previous := lib.Client + rt := &faviconRoundTripper{} + lib.Client = &http.Client{Transport: rt} + defer func() { lib.Client = previous }() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + hashes := NewWebTitlePlugin().fetchFaviconHash(ctx, "http://example.com") + if !rt.called { + t.Fatal("favicon client was not called") + } + if len(hashes.MMH3) != 0 || len(hashes.MD5) != 0 { + t.Fatalf("fetchFaviconHash returned hashes for canceled context: %#v", hashes) + } +} diff --git a/webscan/lib/Eval.go b/webscan/lib/Eval.go index 217266e..a773586 100644 --- a/webscan/lib/Eval.go +++ b/webscan/lib/Eval.go @@ -422,8 +422,19 @@ func RandomStr(randSource *rand.Rand, letterBytes string, n int) string { func DoRequest(req *http.Request, redirect bool) (*Response, error) { // 处理请求头 if req.Body != nil && req.Body != http.NoBody { + body, err := io.ReadAll(req.Body) + if err != nil { + return nil, fmt.Errorf("%s: %w", i18n.GetText("webscan_request_body_read_failed"), err) + } + _ = req.Body.Close() + req.Body = io.NopCloser(bytes.NewReader(body)) + req.GetBody = func() (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader(body)), nil + } + req.ContentLength = int64(len(body)) + // 设置 Content-Length - req.Header.Set("Content-Length", strconv.Itoa(int(req.ContentLength))) + req.Header.Set("Content-Length", strconv.FormatInt(req.ContentLength, 10)) // 如果未指定 Content-Type,设置默认值 if req.Header.Get("Content-Type") == "" { @@ -451,6 +462,11 @@ func DoRequest(req *http.Request, redirect bool) (*Response, error) { // 标准TLS连接失败时,尝试国密TLS客户端 if err != nil && req.URL.Scheme == "https" { + if req.GetBody != nil { + if body, bodyErr := req.GetBody(); bodyErr == nil { + req.Body = body + } + } if redirect { if oResp2, err2 := ClientGM.Do(req); err2 == nil { oResp, err = oResp2, nil diff --git a/webscan/lib/eval_test.go b/webscan/lib/eval_test.go index cf6b848..d032d6d 100644 --- a/webscan/lib/eval_test.go +++ b/webscan/lib/eval_test.go @@ -1,6 +1,7 @@ package lib import ( + "errors" "fmt" "io" "net/http" @@ -11,6 +12,12 @@ import ( "github.com/google/cel-go/common/types" ) +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + // ============================================================================= // eval_encoding.go 测试 - 编码解码函数 // ============================================================================= @@ -1070,6 +1077,84 @@ func TestGetRespBody(t *testing.T) { } } +func TestDoRequestBuffersUnknownLengthBody(t *testing.T) { + previous := ClientNoRedirect + defer func() { ClientNoRedirect = previous }() + + var gotContentLength string + var gotBody string + ClientNoRedirect = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + gotContentLength = req.Header.Get("Content-Length") + body, err := io.ReadAll(req.Body) + if err != nil { + return nil, err + } + gotBody = string(body) + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("ok")), + Request: req, + }, nil + })} + + req, err := http.NewRequest(http.MethodPost, "http://example.com", io.NopCloser(strings.NewReader("abc"))) + if err != nil { + t.Fatalf("NewRequest error = %v", err) + } + req.ContentLength = -1 + + if _, err := DoRequest(req, false); err != nil { + t.Fatalf("DoRequest error = %v", err) + } + if gotContentLength != "3" { + t.Fatalf("Content-Length = %q, want 3", gotContentLength) + } + if gotBody != "abc" { + t.Fatalf("body = %q, want abc", gotBody) + } +} + +func TestDoRequestReplaysBodyForGMTLSFallback(t *testing.T) { + previousNR, previousGM := ClientNoRedirect, ClientNoRedirectGM + defer func() { + ClientNoRedirect = previousNR + ClientNoRedirectGM = previousGM + }() + + ClientNoRedirect = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + _, _ = io.ReadAll(req.Body) + return nil, errors.New("standard tls failed") + })} + + var gotBody string + ClientNoRedirectGM = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + body, err := io.ReadAll(req.Body) + if err != nil { + return nil, err + } + gotBody = string(body) + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("ok")), + Request: req, + }, nil + })} + + req, err := http.NewRequest(http.MethodPost, "https://example.com", strings.NewReader("payload")) + if err != nil { + t.Fatalf("NewRequest error = %v", err) + } + + if _, err := DoRequest(req, false); err != nil { + t.Fatalf("DoRequest error = %v", err) + } + if gotBody != "payload" { + t.Fatalf("fallback body = %q, want payload", gotBody) + } +} + func TestRandomStr(t *testing.T) { tests := []struct { name string