Files
fscan/core/web_scanner.go

517 lines
15 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package core
import (
"context"
"crypto/tls"
"fmt"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"github.com/shadow1ng/fscan/common"
"github.com/shadow1ng/fscan/common/i18n"
gmtls "github.com/tjfoc/gmsm/gmtls"
)
// WebPortDetector 简化的Web检测器 - 保持API兼容
type WebPortDetector struct{}
// GetWebPortDetector 获取检测器实例 - 保持API兼容,删除单例模式
func GetWebPortDetector() *WebPortDetector {
return &WebPortDetector{}
}
// DetectHTTPScheme 智能检测HTTP/HTTPS协议
// 策略:TLS握手优先(快速且准确),失败后尝试GM TLS,最后HTTP
// 返回: "https", "https-gm", "http", 或 "" (都不是Web服务)
func DetectHTTPScheme(host string, port int, config *common.Config, session *common.ScanSession) string {
return DetectHTTPSchemeContext(context.Background(), host, port, config, session)
}
func DetectHTTPSchemeContext(ctx context.Context, host string, port int, config *common.Config, session *common.ScanSession) string {
// 优化:先快速检测 TCP 连通性
if !isPortReachable(ctx, host, port, config, session) {
return ""
}
timeout := config.Network.WebTimeout
addr := net.JoinHostPort(host, strconv.Itoa(port))
// 第一步:尝试标准TLS握手(优先检测HTTPS)
tlsDialer := &net.Dialer{Timeout: timeout}
tlsConn, err := tls.DialWithDialer(
tlsDialer,
"tcp", addr,
&tls.Config{
InsecureSkipVerify: true,
MinVersion: tls.VersionTLS10,
},
)
if err == nil {
_ = tlsConn.Close()
return "https"
}
// 第二步:仅在标准 TLS 握手级别失败(cipher/protocol 不兼容)时尝试国密
// 连接级别失败(timeout/refused/非 TLS 端口)不需要尝试
if maybeGMTLS(err) {
gmConn, gmErr := gmtls.DialWithDialer(
tlsDialer,
"tcp", addr,
&gmtls.Config{
GMSupport: gmtls.NewGMSupport(),
InsecureSkipVerify: true,
},
)
if gmErr == nil {
_ = gmConn.Close()
return "https-gm"
}
}
// TLS和GM TLS都失败,尝试HTTP
client := createHTTPClient(config, session)
// 使用HEAD请求(更轻量)
httpURL := fmt.Sprintf("http://%s", addr)
req, err := http.NewRequestWithContext(ctx, "HEAD", httpURL, nil)
if err != nil {
return ""
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
req.Header.Set("Accept", "*/*")
resp, err := session.HTTPDo(client, req)
if err == nil {
_ = resp.Body.Close()
return "http"
}
// HTTP也失败,记录并返回空
return ""
}
// createHTTPClient 创建统一的HTTP客户端 - 支持HTTP/HTTPS和代理
func createHTTPClient(config *common.Config, session *common.ScanSession) *http.Client {
timeout := config.Network.WebTimeout
// 创建基础Transport,配置连接和 TLS 超时
transport := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
DisableKeepAlives: true,
// 设置连接超时,避免长时间等待无响应的服务器
DialContext: (&net.Dialer{
Timeout: timeout,
}).DialContext,
// TLS 握手超时
TLSHandshakeTimeout: timeout,
}
// 配置代理设置
networkConfig := config.Network
if networkConfig.HTTPProxy != "" {
// 使用HTTP代理
httpProxy := networkConfig.HTTPProxy
if !strings.Contains(httpProxy, "://") {
httpProxy = "http://" + httpProxy
}
if proxyURL, err := url.Parse(httpProxy); err == nil && proxyURL.Host != "" {
transport.Proxy = http.ProxyURL(proxyURL)
} else {
session.LogError(i18n.Tr("http_proxy_config_error", err))
}
} else if networkConfig.Socks5Proxy != "" {
// 使用SOCKS5代理 - 需要特殊处理
if _, err := url.Parse(networkConfig.Socks5Proxy); err == nil {
// SOCKS5代理需要使用代理管理器
// 这里先记录警告,建议使用HTTP代理进行Web检测
session.LogError(i18n.GetText("socks5_not_supported_web"))
}
}
return &http.Client{
Timeout: timeout,
Transport: transport,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse // 不跟随重定向
},
}
}
// DetectHTTPServiceOnly HTTP协议检测 - 保持API兼容,简化实现
func (w *WebPortDetector) DetectHTTPServiceOnly(host string, port int, config *common.Config, session *common.ScanSession) bool {
return w.DetectHTTPServiceOnlyContext(context.Background(), host, port, config, session)
}
func (w *WebPortDetector) DetectHTTPServiceOnlyContext(ctx context.Context, host string, port int, config *common.Config, session *common.ScanSession) bool {
// 优化:先快速检测 TCP 连通性,避免在不可达端口上浪费双倍超时时间
// 对于不存在的端口,这可以将检测时间从 2×timeout 减少到 1×timeout
if !isPortReachable(ctx, host, port, config, session) {
return false
}
client := createHTTPClient(config, session)
// 尝试HTTP
if w.tryHTTP(ctx, client, session, host, port, "http") {
return true
}
// 尝试HTTPS
if w.tryHTTP(ctx, client, session, host, port, "https") {
return true
}
return false
}
// isPortReachable 快速检测端口是否可达(TCP 连接测试)
// 用于在 HTTP/HTTPS 检测前过滤不可达端口,避免双重超时
func isPortReachable(ctx context.Context, host string, port int, config *common.Config, session *common.ScanSession) bool {
timeout := config.Network.WebTimeout
addr := net.JoinHostPort(host, strconv.Itoa(port))
conn, err := session.DialTCP(ctx, "tcp", addr, timeout)
if err != nil {
return false
}
_ = conn.Close()
return true
}
// tryHTTP 尝试HTTP请求 - 简化的核心逻辑
func (w *WebPortDetector) tryHTTP(ctx context.Context, client *http.Client, session *common.ScanSession, host string, port int, protocol string) bool {
// 构造URL
targetURL := (&url.URL{Scheme: protocol, Host: net.JoinHostPort(host, strconv.Itoa(port))}).String()
// 发送HEAD请求
req, err := http.NewRequestWithContext(ctx, "HEAD", targetURL, nil)
if err != nil {
return false
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
req.Header.Set("Accept", "*/*")
resp, err := session.HTTPDo(client, req)
if err != nil {
return false
}
defer func() { _ = resp.Body.Close() }()
// 简单有效的判断:有HTTP状态码就是Web服务
return resp.StatusCode > 0 && resp.StatusCode < 600
}
// ===============================
// 基于服务指纹的Web服务识别
// ===============================
// globalState 全局 State 兼容指针(向后兼容不接受 State 的旧调用方)
// 新代码应通过 State 方法访问服务缓存
var globalState *common.State
// IsWebServiceByFingerprint 基于服务指纹判断Web服务 - 保持API兼容
// 服务识别规则 - 编译期常量,避免运行时分配
var (
nonWebKeywords = []string{
"oracle", "mysql", "postgresql", "redis", "mongodb", "ssh",
"telnet", "ftp", "smtp", "pop3", "imap", "ldap", "snmp", "vnc", "rdp", "smb",
}
webKeywords = []string{
"http", "https", "nginx", "apache", "iis", "tomcat",
"jetty", "nodejs", "php", "asp", "jsp",
}
bannerKeywords = []string{"server:", "http/", "content-type:"}
)
// IsWebServiceByFingerprint 通过指纹判断是否为Web服务
func IsWebServiceByFingerprint(serviceInfo *ServiceInfo) bool {
if serviceInfo == nil || serviceInfo.Name == "" {
return false
}
serviceName := strings.ToLower(serviceInfo.Name)
// 非Web服务优先检查(短路)
for _, keyword := range nonWebKeywords {
if strings.Contains(serviceName, keyword) {
return false
}
}
// Web服务名检查
for _, keyword := range webKeywords {
if strings.Contains(serviceName, keyword) {
return true
}
}
// Banner特征检查
if serviceInfo.Banner != "" {
banner := strings.ToLower(serviceInfo.Banner)
for _, keyword := range bannerKeywords {
if strings.Contains(banner, keyword) {
return true
}
}
}
return false
}
// isDefinitelyNonWeb 判断服务是否明确不是 Web 服务
// 只检查 nonWebKeywords,不在里面 = 不确定 = 值得做 HTTP 探测
func isDefinitelyNonWeb(serviceInfo *ServiceInfo) bool {
if serviceInfo == nil || serviceInfo.Name == "" {
return false
}
serviceName := strings.ToLower(serviceInfo.Name)
for _, keyword := range nonWebKeywords {
if strings.Contains(serviceName, keyword) {
return true
}
}
return false
}
// SetGlobalState 设置全局 StateRunScan 入口调用,兼容旧代码路径)
func SetGlobalState(state *common.State) {
globalState = state
}
func resolveState(state *common.State) *common.State {
if state != nil {
return state
}
return globalState
}
// CacheServiceInfoWithState 缓存服务信息到指定 State
func CacheServiceInfoWithState(state *common.State, host string, port int, serviceInfo *ServiceInfo) {
s := resolveState(state)
if s == nil {
return
}
key := net.JoinHostPort(host, strconv.Itoa(port))
s.CacheService(key, serviceInfo)
}
// CacheServiceInfo 兼容旧调用(使用全局 State)
func CacheServiceInfo(host string, port int, serviceInfo *ServiceInfo) {
CacheServiceInfoWithState(nil, host, port, serviceInfo)
}
// MarkAsWebService 标记 Web 服务
func MarkAsWebService(host string, port int, serviceInfo *ServiceInfo) {
CacheServiceInfo(host, port, serviceInfo)
}
// GetCachedServiceInfoWithState 从指定 State 获取缓存的服务信息
func GetCachedServiceInfoWithState(state *common.State, host string, port int) (*ServiceInfo, bool) {
s := resolveState(state)
if s == nil {
return nil, false
}
key := net.JoinHostPort(host, strconv.Itoa(port))
val, ok := s.GetCachedService(key)
if !ok {
return nil, false
}
info, ok := val.(*ServiceInfo)
return info, ok
}
// GetCachedServiceInfo 兼容旧调用
func GetCachedServiceInfo(host string, port int) (*ServiceInfo, bool) {
return GetCachedServiceInfoWithState(nil, host, port)
}
// GetWebServiceInfo 获取 Web 服务信息
func GetWebServiceInfo(host string, port int) (*ServiceInfo, bool) {
return GetWebServiceInfoWithState(nil, host, port)
}
// GetWebServiceInfoWithState 从指定 State 获取 Web 服务信息
func GetWebServiceInfoWithState(state *common.State, host string, port int) (*ServiceInfo, bool) {
info, exists := GetCachedServiceInfoWithState(state, host, port)
if !exists || !IsWebServiceByFingerprint(info) {
return nil, false
}
return info, true
}
// IsMarkedWebService 检查是否为 Web 服务(使用全局 State)
func IsMarkedWebService(host string, port int) bool {
_, exists := GetWebServiceInfo(host, port)
return exists
}
// IsMarkedWebServiceWithState 检查是否为 Web 服务(指定 State)
func IsMarkedWebServiceWithState(state *common.State, host string, port int) bool {
_, exists := GetWebServiceInfoWithState(state, host, port)
return exists
}
// ===============================
// Web扫描策略
// ===============================
// WebScanStrategy Web扫描策略
type WebScanStrategy struct {
*BaseScanStrategy
}
// NewWebScanStrategy 创建新的Web扫描策略
func NewWebScanStrategy() *WebScanStrategy {
return &WebScanStrategy{
BaseScanStrategy: NewBaseScanStrategy(i18n.GetText("scan_strategy_web_name"), FilterWeb),
}
}
// Name 返回策略名称
func (s *WebScanStrategy) Name() string {
return i18n.GetText("scan_strategy_web_name")
}
// Description 返回策略描述
func (s *WebScanStrategy) Description() string {
return i18n.GetText("scan_strategy_web_desc")
}
// Execute 执行Web扫描策略
func (s *WebScanStrategy) Execute(ctx context.Context, session *common.ScanSession, info common.HostInfo, ch chan struct{}, wg *sync.WaitGroup) {
// 输出扫描开始信息
s.LogScanStart(session)
// 验证插件配置
if err := s.ValidateConfiguration(); err != nil {
session.LogError(err.Error())
return
}
// 准备URL目标
targets := s.prepareTargets(info, session.State, session)
// 输出插件信息
s.LogPluginInfo(session.Config, session)
// 执行扫描任务
ExecuteScanTasks(ctx, session, targets, s, ch, wg)
}
// PrepareTargets 准备URL目标列表
func (s *WebScanStrategy) PrepareTargets(baseInfo common.HostInfo, state *common.State) []common.HostInfo {
return s.prepareTargets(baseInfo, state, nil)
}
func (s *WebScanStrategy) prepareTargets(baseInfo common.HostInfo, state *common.State, session *common.ScanSession) []common.HostInfo {
var targetInfos []common.HostInfo
// 首先从State获取URL目标
urls := state.GetURLs()
for _, urlStr := range urls {
urlInfo := s.createTargetFromURLWithSession(baseInfo, urlStr, session)
if urlInfo != nil {
targetInfos = append(targetInfos, *urlInfo)
}
}
// 如果URLs为空但baseInfo.Url有值,使用baseInfo.URL
if len(targetInfos) == 0 && baseInfo.URL != "" {
urlInfo := s.createTargetFromURLWithSession(baseInfo, baseInfo.URL, session)
if urlInfo != nil {
targetInfos = append(targetInfos, *urlInfo)
}
}
return targetInfos
}
// createTargetFromURL 从URL创建目标信息
func (s *WebScanStrategy) createTargetFromURL(baseInfo common.HostInfo, urlStr string) *common.HostInfo {
return s.createTargetFromURLWithSession(baseInfo, urlStr, nil)
}
func (s *WebScanStrategy) createTargetFromURLWithSession(baseInfo common.HostInfo, urlStr string, session *common.ScanSession) *common.HostInfo {
// 确保URL包含协议头
if !strings.HasPrefix(urlStr, "http://") && !strings.HasPrefix(urlStr, "https://") {
urlStr = "http://" + urlStr
}
// 解析URL获取Host和Port信息
parsedURL, err := url.Parse(urlStr)
if err != nil {
if session != nil {
session.LogError(i18n.Tr("url_parse_failed", urlStr, err))
}
return nil
}
urlInfo := baseInfo
urlInfo.URL = urlStr
urlInfo.Host = parsedURL.Hostname()
if urlInfo.Host == "" {
if session != nil {
session.LogError(i18n.Tr("url_parse_failed", urlStr, "empty host"))
}
return nil
}
// 设置端口
portStr := parsedURL.Port()
if portStr == "" {
if hasMalformedURLPort(parsedURL.Host) {
if session != nil {
session.LogError(i18n.Tr("host_port_invalid", urlInfo.Host, ""))
}
return nil
}
// 根据协议设置默认端口
if parsedURL.Scheme == "https" {
urlInfo.Port = 443
} else {
urlInfo.Port = 80
}
} else {
port, err := strconv.Atoi(portStr)
if err != nil || port < 1 || port > 65535 {
if session != nil {
session.LogError(i18n.Tr("host_port_invalid", urlInfo.Host, portStr))
}
return nil
}
urlInfo.Port = port
}
// 标记为Web服务,确保Web插件能识别此目标
MarkAsWebService(urlInfo.Host, urlInfo.Port, &ServiceInfo{Name: "http"})
return &urlInfo
}
// maybeGMTLS 判断标准 TLS 握手错误是否可能是国密服务端
// 只有 cipher/protocol 层面的不兼容才值得尝试国密回退
// 连接超时、拒绝、非 TLS 端口等连接级错误直接跳过
func maybeGMTLS(err error) bool {
if err == nil {
return false
}
s := err.Error()
return strings.Contains(s, "handshake failure") ||
strings.Contains(s, "protocol version") ||
strings.Contains(s, "no mutual") ||
strings.Contains(s, "cipher suite")
}
func hasMalformedURLPort(host string) bool {
if strings.HasPrefix(host, "[") {
end := strings.LastIndexByte(host, ']')
return end >= 0 && len(host) > end+1 && host[end+1] == ':'
}
return strings.Contains(host, ":")
}