mirror of
https://github.com/shadow1ng/fscan.git
synced 2026-09-22 03:10:42 +08:00
fix: harden scan edge cases
This commit is contained in:
+33
-19
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(空排除列表) 应该返回原列表")
|
||||
|
||||
+19
-14
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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"))
|
||||
|
||||
|
||||
+14
-7
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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{}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user