Files
fscan/plugins/web/webtitle.go
T
ZacharyZcR 71b92d4408 feat: v2.1.0 核心重构与功能增强
## 架构重构
- 全局变量消除,迁移至 Config/State 对象
- SMB 插件融合(smb/smb2/smbghost/smbinfo)
- 服务探测重构,实现 Nmap 风格 fallback 机制
- 输出系统重构,TXT 实时刷盘 + 双写机制
- i18n 框架升级至 go-i18n

## 性能优化
- 正则表达式预编译
- 内存优化 map[string]struct{}
- 并发指纹匹配
- SOCKS5 连接复用
- 滑动窗口调度 + 自适应线程池

## 新功能
- Web 管理界面
- 多格式 POC 适配(xray/afrog)
- 增强指纹库(3139条)
- Favicon hash 指纹识别
- 插件选择性编译(Build Tags)
- fscan-lab 靶场环境
- 默认端口扩展(62→133)

## 构建系统
- 添加 no_local tag 支持排除本地插件
- 多版本构建:fscan/fscan-nolocal/fscan-web
- CI 添加 snapshot 模式支持仅测试构建

## Bug 修复
- 修复 120+ 个问题,包括 RDP panic、批量扫描漏报、
  JSON 输出格式、Redis 检测、Context 超时等

## 测试增强
- 单元测试覆盖率 74-100%
- 并发安全测试
- 集成测试(Web/端口/服务/SSH/ICMP)
2026-01-11 20:16:23 +08:00

338 lines
9.1 KiB
Go
Raw 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.
//go:build plugin_webtitle || !plugin_selective
package web
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"unicode/utf8"
"github.com/shadow1ng/fscan/common"
"github.com/shadow1ng/fscan/core"
"github.com/shadow1ng/fscan/plugins"
WebScan "github.com/shadow1ng/fscan/webscan"
"github.com/shadow1ng/fscan/webscan/fingerprint"
"github.com/shadow1ng/fscan/webscan/lib"
)
// 预编译正则表达式
var (
titleRegex = regexp.MustCompile(`(?i)<title[^>]*>([^<]+)</title>`)
whitespaceRegex = regexp.MustCompile(`\s+`)
)
// WebTitlePlugin Web标题获取插件
type WebTitlePlugin struct {
plugins.BasePlugin
}
// NewWebTitlePlugin 创建WebTitle插件
func NewWebTitlePlugin() *WebTitlePlugin {
return &WebTitlePlugin{
BasePlugin: plugins.NewBasePlugin("webtitle"),
}
}
// Scan 执行WebTitle扫描
func (p *WebTitlePlugin) Scan(ctx context.Context, info *common.HostInfo, config *common.Config, state *common.State) *WebScanResult {
target := info.Target()
title, status, server, fingerprints, err := p.getWebTitle(ctx, info, config)
if err != nil {
return &WebScanResult{
Success: false,
Error: err,
}
}
msg := fmt.Sprintf("WebTitle %s", target)
if title != "" {
msg += fmt.Sprintf(" [%s]", title)
}
if status != 0 {
msg += fmt.Sprintf(" %d", status)
}
if server != "" {
msg += fmt.Sprintf(" %s", server)
}
if len(fingerprints) > 0 {
msg += fmt.Sprintf(" %v", fingerprints)
}
common.LogSuccess(msg)
return &WebScanResult{
Type: plugins.ResultTypeWeb,
Success: true,
Title: title,
Status: status,
Server: server,
Fingerprints: fingerprints,
}
}
func (p *WebTitlePlugin) getWebTitle(ctx context.Context, info *common.HostInfo, config *common.Config) (string, int, string, []string, error) {
// 智能协议检测
protocol := p.detectProtocol(info, config)
baseURL := fmt.Sprintf("%s://%s:%d", protocol, info.Host, info.Port)
req, err := http.NewRequestWithContext(ctx, "GET", baseURL, nil)
if err != nil {
return "", 0, "", nil, err
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
// 先使用不跟随重定向的Client获取原始响应
resp, err := lib.ClientNoRedirect.Do(req)
if err != nil {
return "", 0, "", nil, err
}
body, err := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if len(body) <= 0 && err != nil {
return "", resp.StatusCode, resp.Header.Get("Server"), nil, err
}
// 收集用于指纹识别的响应数据
var checkDataList []WebScan.CheckDatas
checkDataList = append(checkDataList, WebScan.CheckDatas{
Body: body,
Headers: p.formatHeaders(resp.Header),
Favicon: p.fetchFaviconHash(baseURL),
})
title := p.extractTitle(string(body))
statusCode := resp.StatusCode
server := resp.Header.Get("Server")
// 如果是3xx重定向,跟随重定向获取最终页面的指纹
if statusCode >= 300 && statusCode < 400 {
location := resp.Header.Get("Location")
if location != "" {
// 解析重定向URL
redirectURL := p.resolveRedirectURL(baseURL, location)
if redirectURL != "" {
// 发送跟随重定向的请求
reqRedirect, err := http.NewRequestWithContext(ctx, "GET", redirectURL, nil)
if err == nil {
reqRedirect.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
respRedirect, err := lib.Client.Do(reqRedirect)
if err == nil {
bodyRedirect, _ := io.ReadAll(respRedirect.Body)
_ = respRedirect.Body.Close()
if len(bodyRedirect) > 0 {
// 添加跳转后页面的指纹数据
checkDataList = append(checkDataList, WebScan.CheckDatas{
Body: bodyRedirect,
Headers: p.formatHeaders(respRedirect.Header),
Favicon: p.fetchFaviconHash(redirectURL),
})
// 如果原始页面没有标题,使用跳转后页面的标题
if title == "" {
title = p.extractTitle(string(bodyRedirect))
}
}
}
}
}
}
}
// 执行指纹识别(合并原始响应和跳转后响应的指纹)
fingerprints := p.identifyFingerprintsMulti(info, baseURL, checkDataList, config)
return title, statusCode, server, fingerprints, nil
}
// resolveRedirectURL 解析重定向URL,处理相对路径
func (p *WebTitlePlugin) resolveRedirectURL(baseURL, location string) string {
// 如果是绝对URL,直接返回
if strings.HasPrefix(location, "http://") || strings.HasPrefix(location, "https://") {
return location
}
// 解析基础URL
base, err := url.Parse(baseURL)
if err != nil {
return ""
}
// 解析相对路径
ref, err := url.Parse(location)
if err != nil {
return ""
}
// 合并URL
return base.ResolveReference(ref).String()
}
// identifyFingerprintsMulti 识别多个响应的指纹并合并
func (p *WebTitlePlugin) identifyFingerprintsMulti(info *common.HostInfo, baseURL string, checkDataList []WebScan.CheckDatas, config *common.Config) []string {
// 调用指纹识别
fingerprints := WebScan.InfoCheck(baseURL, &checkDataList)
// 存入缓存
if len(fingerprints) > 0 {
core.SetFingerprints(info.Host, info.Port, fingerprints)
}
// 非全量模式下,基于指纹触发POC扫描
if !config.POC.Full && !config.POC.Disabled {
p.triggerPocScan(info, fingerprints, config)
}
return fingerprints
}
// triggerPocScan 基于指纹触发POC扫描
func (p *WebTitlePlugin) triggerPocScan(info *common.HostInfo, fingerprints []string, config *common.Config) {
target := info.Target()
// 无指纹,跳过
if len(fingerprints) == 0 {
common.LogDebug(fmt.Sprintf("WebTitle %s 无匹配指纹,跳过POC扫描", target))
return
}
// 检测CDN/WAF
if cdnName := matchCDNorWAF(fingerprints); cdnName != "" {
common.LogDebug(fmt.Sprintf("WebTitle %s 检测到%s,跳过POC扫描", target, cdnName))
return
}
// 基于指纹执行POC扫描
common.LogDebug(fmt.Sprintf("WebTitle %s 触发指纹POC扫描: %v", target, fingerprints))
info.Info = fingerprints
WebScan.WebScan(info, config)
}
// formatHeaders 将 HTTP Header 格式化为字符串
func (p *WebTitlePlugin) formatHeaders(headers http.Header) string {
var builder strings.Builder
for name, values := range headers {
for _, value := range values {
builder.WriteString(fmt.Sprintf("%s: %s\n", name, value))
}
}
return builder.String()
}
// detectProtocol 智能检测HTTP/HTTPS协议(基于服务识别和主动探测)
func (p *WebTitlePlugin) detectProtocol(info *common.HostInfo, config *common.Config) string {
host := info.Host
port := info.Port
serviceInfo, exists := core.GetWebServiceInfo(host, port)
if exists {
// 第一优先级:检查已缓存的协议检测结果
if protocol, ok := serviceInfo.Extras["protocol"]; ok {
return protocol
}
// 第二优先级:基于服务名称特征判断
serviceName := strings.ToLower(serviceInfo.Name)
var protocol string
if common.ContainsAny(serviceName, "https", "ssl", "tls") {
protocol = "https"
} else if strings.Contains(serviceName, "http") {
protocol = "http"
}
if protocol != "" {
// 缓存协议信息到Extras(避免重复判断)
if serviceInfo.Extras == nil {
serviceInfo.Extras = make(map[string]string)
}
serviceInfo.Extras["protocol"] = protocol
return protocol
}
}
// 第三优先级:主动协议检测(TLS握手)
detected := core.DetectHTTPScheme(host, port, config)
if detected != "" {
// 缓存检测结果(避免重复检测)
if exists {
if serviceInfo.Extras == nil {
serviceInfo.Extras = make(map[string]string)
}
serviceInfo.Extras["protocol"] = detected
}
return detected
}
// 第四优先级:默认HTTPfallback
return "http"
}
func (p *WebTitlePlugin) extractTitle(html string) string {
matches := titleRegex.FindStringSubmatch(html)
if len(matches) > 1 {
title := strings.TrimSpace(matches[1])
title = whitespaceRegex.ReplaceAllString(title, " ")
if len(title) > 100 {
title = title[:100] + "..."
}
if utf8.ValidString(title) {
return title
}
}
return ""
}
// fetchFaviconHash 下载 favicon.ico 并计算 hash
func (p *WebTitlePlugin) fetchFaviconHash(baseURL string) fingerprint.FaviconHashes {
// 构造 favicon URL
u, err := url.Parse(baseURL)
if err != nil {
return fingerprint.FaviconHashes{}
}
faviconURL := fmt.Sprintf("%s://%s/favicon.ico", u.Scheme, u.Host)
// 请求 favicon
req, err := http.NewRequest("GET", faviconURL, nil)
if err != nil {
return fingerprint.FaviconHashes{}
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
resp, err := lib.Client.Do(req)
if err != nil {
return fingerprint.FaviconHashes{}
}
defer func() { _ = resp.Body.Close() }()
// 只处理成功响应
if resp.StatusCode != http.StatusOK {
return fingerprint.FaviconHashes{}
}
// 读取 favicon 数据(限制大小防止恶意文件)
data, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) // 最大 1MB
if err != nil || len(data) == 0 {
return fingerprint.FaviconHashes{}
}
return fingerprint.CalculateFaviconHashes(data)
}
func init() {
RegisterWebPlugin("webtitle", func() WebPlugin {
return NewWebTitlePlugin()
})
}