From a1ff55ef55d124aad4ba120113434ed15756562a Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Wed, 26 Aug 2026 04:19:58 +0800 Subject: [PATCH] fix: resolve recent service scan regressions --- core/service_cache_test.go | 4 +-- core/web_scanner.go | 3 +- core/web_scanner_test.go | 4 +-- libs/grdp/protocol/tpkt/tpkt.go | 7 ++++ libs/grdp/protocol/tpkt/tpkt_test.go | 13 ++++++++ main_cli.go | 16 ++++++--- plugins/services/ssh.go | 29 ++++++++++------ plugins/services/ssh_test.go | 38 +++++++++++++++++++++ plugins/services/telnet.go | 5 ++- plugins/services/vnc.go | 9 +++++ plugins/services/vnc_test.go | 49 ++++++++++++++++++++++++++++ 11 files changed, 153 insertions(+), 24 deletions(-) create mode 100644 libs/grdp/protocol/tpkt/tpkt_test.go diff --git a/core/service_cache_test.go b/core/service_cache_test.go index 745ee74..c85916c 100644 --- a/core/service_cache_test.go +++ b/core/service_cache_test.go @@ -88,8 +88,8 @@ func TestCacheServiceInfo_BasicCRUD(t *testing.T) { func TestWebServiceFiltering(t *testing.T) { clearServiceCache() - webNames := []string{"http", "https", "ssl", "tls", "nginx", "apache", "iis", "tomcat"} - nonWebNames := []string{"ssh", "mysql", "postgresql", "redis", "mongodb", "ftp", "smtp", "telnet", "vnc", "rdp"} + webNames := []string{"http", "https", "nginx", "apache", "iis", "tomcat"} + nonWebNames := []string{"ssl", "tls", "ssh", "mysql", "postgresql", "redis", "mongodb", "ftp", "smtp", "telnet", "vnc", "rdp"} for _, name := range webNames { clearServiceCache() diff --git a/core/web_scanner.go b/core/web_scanner.go index b73811e..549a8f7 100644 --- a/core/web_scanner.go +++ b/core/web_scanner.go @@ -222,7 +222,7 @@ var ( "telnet", "ftp", "smtp", "pop3", "imap", "ldap", "snmp", "vnc", "rdp", "smb", } webKeywords = []string{ - "http", "https", "ssl", "tls", "nginx", "apache", "iis", "tomcat", + "http", "https", "nginx", "apache", "iis", "tomcat", "jetty", "nodejs", "php", "asp", "jsp", } bannerKeywords = []string{"server:", "http/", "content-type:"} @@ -514,4 +514,3 @@ func hasMalformedURLPort(host string) bool { } return strings.Contains(host, ":") } - diff --git a/core/web_scanner_test.go b/core/web_scanner_test.go index ec07d66..a633b12 100644 --- a/core/web_scanner_test.go +++ b/core/web_scanner_test.go @@ -230,11 +230,11 @@ func TestIsWebServiceByFingerprint(t *testing.T) { expected: true, }, { - name: "SSL/TLS服务", + name: "通用TLS服务不是Web", serviceInfo: &ServiceInfo{ Name: "ssl", }, - expected: true, + expected: false, }, { name: "包含非Web关键字-postgresql", diff --git a/libs/grdp/protocol/tpkt/tpkt.go b/libs/grdp/protocol/tpkt/tpkt.go index 51d53b8..a045714 100644 --- a/libs/grdp/protocol/tpkt/tpkt.go +++ b/libs/grdp/protocol/tpkt/tpkt.go @@ -477,6 +477,13 @@ func (t *TPKT) recvFastPath(s []byte, err error) { return } + // NLA-only authentication can receive a Fast-Path packet before the PDU + // layer installs a listener. Treat it as an ignorable early packet instead + // of dereferencing a nil interface and crashing the whole scan. + if t.fastPathListener == nil { + return + } + t.fastPathListener.RecvFastPath(t.secFlag, s) core.StartReadBytes(2, t.Conn, t.recvHeader) } diff --git a/libs/grdp/protocol/tpkt/tpkt_test.go b/libs/grdp/protocol/tpkt/tpkt_test.go new file mode 100644 index 0000000..f48f2e7 --- /dev/null +++ b/libs/grdp/protocol/tpkt/tpkt_test.go @@ -0,0 +1,13 @@ +package tpkt + +import ( + "testing" + + "github.com/shadow1ng/fscan/libs/grdp/glog" +) + +func TestRecvFastPathWithoutListenerDoesNotPanic(t *testing.T) { + glog.SetLevel(glog.NONE) + tpkt := &TPKT{} + tpkt.recvFastPath([]byte{0x00}, nil) +} diff --git a/main_cli.go b/main_cli.go index 06bd370..2c5802d 100644 --- a/main_cli.go +++ b/main_cli.go @@ -20,6 +20,10 @@ import ( ) func main() { + os.Exit(run()) +} + +func run() int { // 启动 pprof(仅调试版本) debug.Start() defer debug.Stop() @@ -28,23 +32,23 @@ func main() { var info common.HostInfo if err := common.Flag(&info); err != nil { if err == common.ErrShowHelp { - os.Exit(0) // 显示帮助是正常退出 + return 0 // 显示帮助是正常退出 } common.LogError(i18n.Tr("param_error", err)) - os.Exit(1) + return 1 } // 检查参数互斥性 if err := common.ValidateExclusiveParams(&info); err != nil { common.LogError(i18n.Tr("error_generic", err)) - os.Exit(1) + return 1 } // 统一初始化:解析 → 配置 → 输出 result, err := common.Initialize(&info) if err != nil { common.LogError(i18n.Tr("init_failed", err)) - os.Exit(1) + return 1 } // 设置信号处理,确保 Ctrl+C 时能正确保存结果 @@ -62,6 +66,8 @@ func main() { // 执行扫描 if _, err := core.RunScan(context.Background(), *result.Info, result.Session); err != nil { common.LogError(i18n.Tr("error_generic", err)) - os.Exit(1) + return 1 } + + return 0 } diff --git a/plugins/services/ssh.go b/plugins/services/ssh.go index ca01af7..74abbc1 100644 --- a/plugins/services/ssh.go +++ b/plugins/services/ssh.go @@ -3,6 +3,7 @@ package services import ( + "bufio" "context" "fmt" "io" @@ -308,19 +309,27 @@ func (p *SSHPlugin) identifyService(ctx context.Context, info *common.HostInfo, func (p *SSHPlugin) readSSHBanner(conn net.Conn, config *common.Config) string { _ = conn.SetReadDeadline(time.Now().Add(config.ModuleTimeout())) - banner := make([]byte, 256) - n, err := conn.Read(banner) - if err != nil || n < 4 { - return "" - } + // RFC 4253 permits servers to send informational lines before the SSH + // identification string. Read bounded lines until the protocol banner is + // found instead of requiring SSH- at the first byte of the first read. + reader := bufio.NewReaderSize(conn, 256) + for range 50 { + line, err := reader.ReadString('\n') + if len(line) > 255 { + return "" + } - bannerStr := strings.TrimSpace(string(banner[:n])) + banner := strings.TrimSpace(line) + if strings.HasPrefix(banner, "SSH-") { + if matched := sshBannerRegex.FindStringSubmatch(banner); len(matched) >= 3 { + return fmt.Sprintf("SSH %s (%s)", matched[1], matched[2]) + } + return i18n.Tr("ssh_service_banner", banner) + } - if strings.HasPrefix(bannerStr, "SSH-") { - if matched := sshBannerRegex.FindStringSubmatch(bannerStr); len(matched) >= 3 { - return fmt.Sprintf("SSH %s (%s)", matched[1], matched[2]) + if err != nil { + return "" } - return i18n.Tr("ssh_service_banner", bannerStr) } return "" diff --git a/plugins/services/ssh_test.go b/plugins/services/ssh_test.go index a5208a4..e3a27af 100644 --- a/plugins/services/ssh_test.go +++ b/plugins/services/ssh_test.go @@ -4,9 +4,47 @@ package services import ( "errors" + "net" "testing" + "time" + + "github.com/shadow1ng/fscan/common" ) +func TestReadSSHBannerAllowsPreBannerLines(t *testing.T) { + client, server := net.Pipe() + defer client.Close() + defer server.Close() + + go func() { + _, _ = server.Write([]byte("Authorized access only\r\nSSH-2.0-OpenSSH_9.6\r\n")) + }() + + cfg := common.NewConfig() + cfg.Timeout = time.Second + got := NewSSHPlugin().readSSHBanner(client, cfg) + if got != "SSH 2.0 (OpenSSH_9.6)" { + t.Fatalf("readSSHBanner() = %q", got) + } +} + +func TestReadSSHBannerRejectsNonSSHService(t *testing.T) { + client, server := net.Pipe() + defer client.Close() + defer server.Close() + + go func() { + _, _ = server.Write([]byte("HTTP/1.1 200 OK\r\n")) + _ = server.Close() + }() + + cfg := common.NewConfig() + cfg.Timeout = time.Second + if got := NewSSHPlugin().readSSHBanner(client, cfg); got != "" { + t.Fatalf("readSSHBanner() = %q, want empty", got) + } +} + func TestClassifySSHErrorType(t *testing.T) { tests := []struct { name string diff --git a/plugins/services/telnet.go b/plugins/services/telnet.go index eeefd88..ca9d539 100644 --- a/plugins/services/telnet.go +++ b/plugins/services/telnet.go @@ -61,12 +61,11 @@ func (p *TelnetPlugin) Scan(ctx context.Context, info *common.HostInfo, session // 检测未授权访问 if result := p.testUnauthAccess(ctx, info, session); result != nil && result.Success { - session.LogVuln(i18n.Tr("telnet_service", target, result.Banner)) - // 验证命令执行能力 if ok, osType, evidence := p.verifyCommandExecution(ctx, info, "", "", session); ok { + session.LogVuln(i18n.Tr("telnet_service", target, result.Banner)) session.LogVuln(i18n.Tr("telnet_unauth_rce", target, osType, evidence)) + return result } - return result } // 生成密码字典 diff --git a/plugins/services/vnc.go b/plugins/services/vnc.go index 9d6aec3..06c5e9a 100644 --- a/plugins/services/vnc.go +++ b/plugins/services/vnc.go @@ -34,6 +34,15 @@ func (p *VNCPlugin) Scan(ctx context.Context, info *common.HostInfo, session *co return result } + // -nobr 仅保留未授权访问检测,不继续尝试密码。 + if config.DisableBrute { + return &ScanResult{ + Type: plugins.ResultTypeService, + Success: true, + Service: "vnc", + } + } + // 生成密码列表 var credentials []Credential if config.Credentials.Passwords != nil { diff --git a/plugins/services/vnc_test.go b/plugins/services/vnc_test.go index b686d13..5a28c85 100644 --- a/plugins/services/vnc_test.go +++ b/plugins/services/vnc_test.go @@ -3,10 +3,59 @@ package services import ( + "context" "errors" + "net" + "strconv" + "sync/atomic" "testing" + "time" + + "github.com/shadow1ng/fscan/common" ) +func TestVNCDisableBruteOnlyChecksUnauthenticatedAccess(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + + var connections atomic.Int32 + go func() { + for { + conn, err := listener.Accept() + if err != nil { + return + } + connections.Add(1) + _ = conn.Close() + } + }() + + host, portText, err := net.SplitHostPort(listener.Addr().String()) + if err != nil { + t.Fatal(err) + } + port, err := strconv.Atoi(portText) + if err != nil { + t.Fatal(err) + } + + cfg := common.NewConfig() + cfg.DisableBrute = true + session := common.NewScanSession(cfg, common.NewState(), &common.FlagVars{}) + result := NewVNCPlugin().Scan(context.Background(), &common.HostInfo{Host: host, Port: port}, session) + if result == nil || !result.Success || result.Service != "vnc" { + t.Fatalf("Scan() = %#v, want identified VNC service", result) + } + + time.Sleep(20 * time.Millisecond) + if got := connections.Load(); got != 1 { + t.Fatalf("connections = %d, want one unauthenticated-access check and no password attempts", got) + } +} + func TestClassifyVNCErrorType(t *testing.T) { tests := []struct { name string