fix: harden scan edge cases

This commit is contained in:
ZacharyZcR
2026-06-01 03:32:13 +08:00
parent 8b558b4f12
commit 8ec96bfe6d
21 changed files with 534 additions and 67 deletions
+32 -18
View File
@@ -49,7 +49,10 @@ func BuildConfig(fv *FlagVars, info *HostInfo) (*Config, *State, error) {
func parseCredentials(fv *FlagVars, cfg *Config) error { func parseCredentials(fv *FlagVars, cfg *Config) error {
// 解析用户名 // 解析用户名
usernames := parseUsernames(fv) usernames, err := parseUsernames(fv)
if err != nil {
return err
}
if len(usernames) > 0 { if len(usernames) > 0 {
for serviceName := range cfg.Credentials.Userdict { for serviceName := range cfg.Credentials.Userdict {
cfg.Credentials.Userdict[serviceName] = usernames 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 { if len(passwords) > 0 {
cfg.Credentials.Passwords = passwords cfg.Credentials.Passwords = passwords
} }
@@ -84,7 +90,7 @@ func parseCredentials(fv *FlagVars, cfg *Config) error {
return nil return nil
} }
func parseUsernames(fv *FlagVars) []string { func parseUsernames(fv *FlagVars) ([]string, error) {
var usernames []string var usernames []string
// 命令行用户名 // 命令行用户名
@@ -102,7 +108,7 @@ func parseUsernames(fv *FlagVars) []string {
if lines, err := parsers.ReadLinesFromFile(fv.UsersFile); err == nil { if lines, err := parsers.ReadLinesFromFile(fv.UsersFile); err == nil {
usernames = append(usernames, lines...) usernames = append(usernames, lines...)
} else { } 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 var passwords []string
// 命令行密码 // 命令行密码
if fv.Password != "" { 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 { if lines, err := parsers.ReadLinesFromFile(fv.PasswordsFile); err == nil {
passwords = append(passwords, lines...) passwords = append(passwords, lines...)
} else { } 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)...) passwords = append(passwords, splitCredentialValues(fv.AddPasswords)...)
} }
return removeDuplicate(passwords) return removeDuplicate(passwords), nil
} }
func splitCredentialValues(input string) []string { func splitCredentialValues(input string) []string {
@@ -192,13 +198,16 @@ func parseHashes(fv *FlagVars) ([]string, [][]byte, error) {
// 命令行哈希 // 命令行哈希
if fv.HashValue != "" { if fv.HashValue != "" {
hash := strings.TrimSpace(fv.HashValue) hash := strings.TrimSpace(fv.HashValue)
if len(hash) == 32 { 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) hashValues = append(hashValues, hash)
if hashByte, err := hex.DecodeString(hash); err == nil {
hashBytes = append(hashBytes, hashByte) hashBytes = append(hashBytes, hashByte)
} }
}
}
// 从文件读取 // 从文件读取
if fv.HashFile != "" { if fv.HashFile != "" {
@@ -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 { if port, portErr := strconv.Atoi(portStr); portErr == nil && port >= 1 && port <= 65535 {
// 有效的 host:port 格式 // 有效的 host:port 格式
state.SetHostPorts([]string{info.Host}) state.SetHostPorts([]string{info.Host})
info.Host = ""
ports = "" // 清空端口,避免双重扫描 ports = "" // 清空端口,避免双重扫描
} }
} }
} }
// 解析 URL // 解析 URL
urls := parseURLs(fv) urls, err := parseURLs(fv)
if err != nil {
return err
}
if len(urls) > 0 { if len(urls) > 0 {
state.SetURLs(urls) state.SetURLs(urls)
if info.URL == "" && len(urls) == 1 { if info.URL == "" && len(urls) == 1 {
@@ -247,7 +260,7 @@ func parseTargets(fv *FlagVars, info *HostInfo, cfg *Config, state *State) error
return nil return nil
} }
func parseURLs(fv *FlagVars) []string { func parseURLs(fv *FlagVars) ([]string, error) {
var urls []string var urls []string
// 命令行 URL // 命令行 URL
@@ -267,11 +280,11 @@ func parseURLs(fv *FlagVars) []string {
urls = append(urls, normalizeURL(line)) urls = append(urls, normalizeURL(line))
} }
} else { } 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 { func normalizeURL(rawURL string) string {
@@ -279,7 +292,8 @@ func normalizeURL(rawURL string) string {
if rawURL == "" { if rawURL == "" {
return 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 "http://" + rawURL
} }
return rawURL return rawURL
+60 -4
View File
@@ -5,15 +5,71 @@ import (
"testing" "testing"
) )
func TestParsePasswordsSplitsCommaAndWhitespace(t *testing.T) { func TestParsePasswordsKeepsPrimaryPasswordLiteral(t *testing.T) {
fv := &FlagVars{ fv := &FlagVars{
Password: "root,admin", Password: "root admin",
AddPasswords: "pass1 pass2,pass3\tpass4", AddPasswords: "pass1 pass2,pass3\tpass4",
} }
got := parsePasswords(fv) got, err := parsePasswords(fv)
want := []string{"root", "admin", "pass1", "pass2", "pass3", "pass4"} if err != nil {
t.Fatalf("parsePasswords error = %v", err)
}
want := []string{"root admin", "pass1", "pass2", "pass3", "pass4"}
if !reflect.DeepEqual(got, want) { if !reflect.DeepEqual(got, want) {
t.Fatalf("parsePasswords() = %#v, want %#v", 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)
}
}
+9 -4
View File
@@ -39,8 +39,11 @@ func NewHostIterator(host string, filename string, nohosts ...string) (*HostIter
sources = append(sources, hostSources...) sources = append(sources, hostSources...)
matcher := newHostMatcher() matcher := newHostMatcher()
if len(nohosts) > 0 && strings.TrimSpace(nohosts[0]) != "" { for _, exclude := range nohosts {
if err := matcher.add(nohosts[0]); err != nil { if strings.TrimSpace(exclude) == "" {
continue
}
if err := matcher.add(exclude); err != nil {
closeHostSources(sources) closeHostSources(sources)
return nil, err return nil, err
} }
@@ -180,10 +183,12 @@ func newFileHostSource(filename string) (*fileHostSource, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &fileHostSource{ src := &fileHostSource{
file: file, file: file,
scanner: bufio.NewScanner(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) { func (s *fileHostSource) Next() (string, bool, error) {
+44
View File
@@ -2,7 +2,9 @@ package parsers
import ( import (
"context" "context"
"os"
"reflect" "reflect"
"strings"
"testing" "testing"
) )
@@ -59,3 +61,45 @@ func TestHostIteratorExcludeCIDR(t *testing.T) {
t.Fatalf("batch = %#v, want %#v", batch, want) 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)
}
}
+16 -2
View File
@@ -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 测试去重 // TestParseIP_Deduplicate 测试去重
func TestParseIP_Deduplicate(t *testing.T) { 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", "", "") 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 测试排除主机 // TestExcludeHosts 测试排除主机
func TestExcludeHosts(t *testing.T) { func TestExcludeHosts(t *testing.T) {
hosts := []string{"host1", "host2", "host3", "host4"} hosts := []string{"host1", "host2", "host3", "host4"}
exclude := []string{"host2", "host4"} exclude := newHostMatcher()
exclude.exact["host2"] = struct{}{}
exclude.exact["host4"] = struct{}{}
result := excludeFromList(hosts, exclude) result := excludeFromList(hosts, exclude)
expected := []string{"host1", "host3"} expected := []string{"host1", "host3"}
@@ -815,7 +829,7 @@ func TestExcludeHosts(t *testing.T) {
// TestExcludeHosts_EmptyExclude 测试空排除列表 // TestExcludeHosts_EmptyExclude 测试空排除列表
func TestExcludeHosts_EmptyExclude(t *testing.T) { func TestExcludeHosts_EmptyExclude(t *testing.T) {
hosts := []string{"host1", "host2"} hosts := []string{"host1", "host2"}
result := excludeFromList(hosts, []string{}) result := excludeFromList(hosts, nil)
if !reflect.DeepEqual(result, hosts) { if !reflect.DeepEqual(result, hosts) {
t.Errorf("excludeFromList(空排除列表) 应该返回原列表") t.Errorf("excludeFromList(空排除列表) 应该返回原列表")
+17 -12
View File
@@ -59,12 +59,21 @@ func ParseIP(host string, filename string, nohosts ...string) ([]string, error)
} }
// 处理排除主机 // 处理排除主机
if len(nohosts) > 0 && nohosts[0] != "" { if len(nohosts) > 0 {
excludeList, err := parseHostString(nohosts[0]) matcher := newHostMatcher()
if err != nil { 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) return nil, fmt.Errorf(i18n.GetText("parser_parse_exclude_failed")+": %w", err)
} }
hosts = excludeFromList(hosts, excludeList) }
if hasExclude {
hosts = excludeFromList(hosts, matcher)
}
} }
// 去重和排序 // 去重和排序
@@ -212,6 +221,7 @@ func ReadLinesFromFile(filename string) ([]string, error) {
var lines []string var lines []string
scanner := bufio.NewScanner(file) scanner := bufio.NewScanner(file)
scanner.Buffer(make([]byte, 64*1024), 4*1024*1024)
for scanner.Scan() { for scanner.Scan() {
line := strings.TrimSpace(scanner.Text()) line := strings.TrimSpace(scanner.Text())
if line != "" && !strings.HasPrefix(line, "#") { if line != "" && !strings.HasPrefix(line, "#") {
@@ -420,19 +430,14 @@ func incrementIP(ip net.IP) {
} }
// excludeFromList 从列表中排除指定项 // excludeFromList 从列表中排除指定项
func excludeFromList(hosts, excludeList []string) []string { func excludeFromList(hosts []string, matcher *hostMatcher) []string {
if len(excludeList) == 0 { if matcher == nil {
return hosts return hosts
} }
excludeMap := make(map[string]struct{}, len(excludeList))
for _, e := range excludeList {
excludeMap[e] = struct{}{}
}
result := make([]string, 0, len(hosts)) result := make([]string, 0, len(hosts))
for _, h := range hosts { for _, h := range hosts {
if _, found := excludeMap[h]; !found { if !matcher.match(h) {
result = append(result, h) result = append(result, h)
} }
} }
+6 -1
View File
@@ -67,7 +67,12 @@ func (s *AliveScanStrategy) Execute(ctx context.Context, session *common.ScanSes
// performAliveScan 执行存活探测 // performAliveScan 执行存活探测
func (s *AliveScanStrategy) performAliveScan(ctx context.Context, info common.HostInfo, session *common.ScanSession) { 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 { if err != nil {
session.LogError(i18n.Tr("parse_target_failed", err)) session.LogError(i18n.Tr("parse_target_failed", err))
return return
+28
View File
@@ -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
}
+29
View File
@@ -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)
}
}
+1 -1
View File
@@ -203,7 +203,7 @@ func EnhancedPortScan(ctx context.Context, hosts []string, ports string, timeout
// 初始化端口扫描进度条 // 初始化端口扫描进度条
if totalTasks > 0 && config.Output.ShowProgress { if totalTasks > 0 && config.Output.ShowProgress {
description := i18n.Tr("port_scan_progress_description", threadNum) 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")) session.LogDebug(i18n.GetText("port_scan_debug_progress_ready"))
+14 -7
View File
@@ -145,7 +145,12 @@ func (s *ServiceScanStrategy) performHostScan(ctx context.Context, session *comm
config := session.Config config := session.Config
state := session.State 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 { if err != nil {
session.LogError(fmt.Sprintf("%s: %v", i18n.GetText("parse_target_failed"), err)) session.LogError(fmt.Sprintf("%s: %v", i18n.GetText("parse_target_failed"), err))
return return
@@ -157,6 +162,7 @@ func (s *ServiceScanStrategy) performHostScan(ctx context.Context, session *comm
pluginsToRun, isCustomMode := s.GetPlugins(config) pluginsToRun, isCustomMode := s.GetPlugins(config)
totalAlive := 0 totalAlive := 0
sawHosts := false sawHosts := false
performedLiveness := false
for { for {
hosts, err := iter.NextBatch(ctx, targetHostBatchSize(config)) hosts, err := iter.NextBatch(ctx, targetHostBatchSize(config))
@@ -170,6 +176,7 @@ func (s *ServiceScanStrategy) performHostScan(ctx context.Context, session *comm
sawHosts = true sawHosts = true
if s.shouldPerformLivenessCheck(hosts, config) { if s.shouldPerformLivenessCheck(hosts, config) {
performedLiveness = true
hosts = CheckLive(ctx, hosts, false, session) hosts = CheckLive(ctx, hosts, false, session)
} }
totalAlive += len(hosts) 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) 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)) 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端口扫描链路 // 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) { 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) _, isCustomMode := s.GetPlugins(config)
@@ -341,7 +344,11 @@ func (s *ServiceScanStrategy) discoverTargets(ctx context.Context, hostInput str
config := session.Config config := session.Config
state := session.State 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 { if err != nil {
return nil, fmt.Errorf("%s: %w", i18n.GetText("parse_target_failed"), err) return nil, fmt.Errorf("%s: %w", i18n.GetText("parse_target_failed"), err)
} }
+3 -3
View File
@@ -39,7 +39,7 @@ type SocketIterator struct {
ports []int ports []int
hostIdx int hostIdx int
portIdx int portIdx int
total int total int64
mu sync.Mutex mu sync.Mutex
} }
@@ -51,7 +51,7 @@ func NewSocketIterator(hosts []string, ports []int, exclude map[int]struct{}) *S
return &SocketIterator{ return &SocketIterator{
hosts: hosts, hosts: hosts,
ports: sortedPorts, 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 返回总任务数(用于进度条) // Total 返回总任务数(用于进度条)
func (it *SocketIterator) Total() int { func (it *SocketIterator) Total() int64 {
return it.total return it.total
} }
+11
View File
@@ -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 验证端口优先级排序 // TestSocketIterator_PortPrioritySort 验证端口优先级排序
// 高价值端口(80, 443, 22等)应该排在前面 // 高价值端口(80, 443, 22等)应该排在前面
func TestSocketIterator_PortPrioritySort(t *testing.T) { func TestSocketIterator_PortPrioritySort(t *testing.T) {
+28 -2
View File
@@ -163,6 +163,21 @@ func (p *Neo4jPlugin) testUnauthorizedAccess(ctx context.Context, info *common.H
defer func() { _ = resp.Body.Close() }() defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == 200 { 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{ return &ScanResult{
Type: plugins.ResultTypeVuln, Type: plugins.ResultTypeVuln,
Success: true, Success: true,
@@ -206,11 +221,22 @@ func (p *Neo4jPlugin) identifyService(ctx context.Context, info *common.HostInfo
if serverHeader != "" && strings.Contains(strings.ToLower(serverHeader), "neo4j") { if serverHeader != "" && strings.Contains(strings.ToLower(serverHeader), "neo4j") {
banner = "Neo4j" banner = "Neo4j"
} else if resp.StatusCode == 200 || resp.StatusCode == 401 { } 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") { if strings.Contains(strings.ToLower(string(body)), "neo4j") {
banner = "Neo4j" banner = "Neo4j"
} else { } else {
banner = "Neo4j" return &ScanResult{
Success: false,
Service: "neo4j",
Error: fmt.Errorf("%s", i18n.Tr("service_not_identified", "Neo4j")),
}
} }
} else { } else {
return &ScanResult{ return &ScanResult{
+59
View File
@@ -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)
}
}
+8 -1
View File
@@ -285,7 +285,14 @@ func (p *RabbitMQPlugin) testManagementInterface(ctx context.Context, info *comm
defer func() { _ = resp.Body.Close() }() defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == 200 || resp.StatusCode == 401 { 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") { if strings.Contains(strings.ToLower(string(body)), "rabbitmq") {
banner := "RabbitMQ Management" banner := "RabbitMQ Management"
common.LogSuccess(i18n.Tr("rabbitmq_detected", target, banner)) common.LogSuccess(i18n.Tr("rabbitmq_detected", target, banner))
+20
View File
@@ -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)
}
}
+7 -8
View File
@@ -124,7 +124,7 @@ func (p *WebTitlePlugin) getWebTitle(ctx context.Context, info *common.HostInfo,
body, err := io.ReadAll(resp.Body) body, err := io.ReadAll(resp.Body)
_ = resp.Body.Close() _ = resp.Body.Close()
contentLen := len(body) contentLen := len(body)
if contentLen <= 0 && err != nil { if err != nil {
return "", resp.StatusCode, 0, resp.Header.Get("Server"), nil, displayURL, err 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{ checkDataList = append(checkDataList, WebScan.CheckDatas{
Body: body, Body: body,
Headers: p.formatHeaders(resp.Header), Headers: p.formatHeaders(resp.Header),
Favicon: p.fetchFaviconHash(baseURL), Favicon: p.fetchFaviconHash(ctx, baseURL),
}) })
title := p.extractTitle(string(body)) 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") reqRedirect.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
respRedirect, err := clientR.Do(reqRedirect) respRedirect, err := clientR.Do(reqRedirect)
if err == nil { if err == nil {
bodyRedirect, _ := io.ReadAll(respRedirect.Body) bodyRedirect, err := io.ReadAll(respRedirect.Body)
_ = respRedirect.Body.Close() _ = respRedirect.Body.Close()
if err == nil && len(bodyRedirect) > 0 {
if len(bodyRedirect) > 0 {
// 添加跳转后页面的指纹数据 // 添加跳转后页面的指纹数据
checkDataList = append(checkDataList, WebScan.CheckDatas{ checkDataList = append(checkDataList, WebScan.CheckDatas{
Body: bodyRedirect, Body: bodyRedirect,
Headers: p.formatHeaders(respRedirect.Header), 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 // 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 // 构造 favicon URL
u, err := url.Parse(baseURL) u, err := url.Parse(baseURL)
if err != nil { 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) faviconURL := fmt.Sprintf("%s://%s/favicon.ico", u.Scheme, u.Host)
// 请求 favicon // 请求 favicon
req, err := http.NewRequest("GET", faviconURL, nil) req, err := http.NewRequestWithContext(ctx, "GET", faviconURL, nil)
if err != nil { if err != nil {
return fingerprint.FaviconHashes{} return fingerprint.FaviconHashes{}
} }
+37
View File
@@ -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)
}
}
+17 -1
View File
@@ -422,8 +422,19 @@ func RandomStr(randSource *rand.Rand, letterBytes string, n int) string {
func DoRequest(req *http.Request, redirect bool) (*Response, error) { func DoRequest(req *http.Request, redirect bool) (*Response, error) {
// 处理请求头 // 处理请求头
if req.Body != nil && req.Body != http.NoBody { 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 // 设置 Content-Length
req.Header.Set("Content-Length", strconv.Itoa(int(req.ContentLength))) req.Header.Set("Content-Length", strconv.FormatInt(req.ContentLength, 10))
// 如果未指定 Content-Type,设置默认值 // 如果未指定 Content-Type,设置默认值
if req.Header.Get("Content-Type") == "" { if req.Header.Get("Content-Type") == "" {
@@ -451,6 +462,11 @@ func DoRequest(req *http.Request, redirect bool) (*Response, error) {
// 标准TLS连接失败时,尝试国密TLS客户端 // 标准TLS连接失败时,尝试国密TLS客户端
if err != nil && req.URL.Scheme == "https" { if err != nil && req.URL.Scheme == "https" {
if req.GetBody != nil {
if body, bodyErr := req.GetBody(); bodyErr == nil {
req.Body = body
}
}
if redirect { if redirect {
if oResp2, err2 := ClientGM.Do(req); err2 == nil { if oResp2, err2 := ClientGM.Do(req); err2 == nil {
oResp, err = oResp2, nil oResp, err = oResp2, nil
+85
View File
@@ -1,6 +1,7 @@
package lib package lib
import ( import (
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@@ -11,6 +12,12 @@ import (
"github.com/google/cel-go/common/types" "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 测试 - 编码解码函数 // 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) { func TestRandomStr(t *testing.T) {
tests := []struct { tests := []struct {
name string name string