mirror of
https://github.com/shadow1ng/fscan.git
synced 2026-09-21 19:00:42 +08:00
SDK enhancements for endpoint agent embedding: - ScanWithController for pause/resume and live stats - OnProgress callback for periodic progress reporting - TaskID injection into every scan result - ScanController with goroutine-safe pause/resume/stats - Multi-target stats aggregation (race-free) UDP plugin infrastructure: - PluginTypeUDP registry with dedicated dispatch path - DialUDP on ScanSession with rate limiting and packet counting - UDP plugins bypass TCP port scan, probe targets directly - FilterService excludes UDP plugins from TCP port matching SNMP plugin (first UDP plugin): - SNMPv2c GetRequest probe for sysDescr detection - Community string brute force (public/private/community/etc) - Pure stdlib implementation (encoding/asn1) - Registered as safe default plugin on port 161/UDP Tests: 95.7% SDK coverage, race-free, 50+ new test cases
427 lines
13 KiB
Go
427 lines
13 KiB
Go
package core
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/shadow1ng/fscan/common"
|
|
"github.com/shadow1ng/fscan/common/i18n"
|
|
"github.com/shadow1ng/fscan/common/parsers"
|
|
"github.com/shadow1ng/fscan/plugins"
|
|
)
|
|
|
|
// ServiceScanStrategy 服务扫描策略
|
|
type ServiceScanStrategy struct {
|
|
*BaseScanStrategy
|
|
}
|
|
|
|
// NewServiceScanStrategy 创建新的服务扫描策略
|
|
func NewServiceScanStrategy() *ServiceScanStrategy {
|
|
return &ServiceScanStrategy{
|
|
BaseScanStrategy: NewBaseScanStrategy("服务扫描", FilterService),
|
|
}
|
|
}
|
|
|
|
// LogPluginInfo 重写以提供基于端口的插件过滤
|
|
func (s *ServiceScanStrategy) LogPluginInfo(config *common.Config, session *common.ScanSession) {
|
|
// 需要从命令行参数获取端口信息来进行过滤
|
|
// 如果没有指定端口,使用默认端口进行过滤显示
|
|
ports := config.Target.Ports
|
|
if ports == "" || ports == "all" {
|
|
// 默认端口扫描:显示所有插件
|
|
s.BaseScanStrategy.LogPluginInfo(config, session)
|
|
} else {
|
|
// 指定端口扫描:只显示匹配的插件
|
|
s.showPluginsForSpecifiedPorts(config, session)
|
|
}
|
|
}
|
|
|
|
// showPluginsForSpecifiedPorts 显示指定端口的匹配插件
|
|
func (s *ServiceScanStrategy) showPluginsForSpecifiedPorts(config *common.Config, session *common.ScanSession) {
|
|
allPlugins, isCustomMode := s.GetPlugins(config)
|
|
|
|
// 解析端口
|
|
ports := s.parsePortList(config.Target.Ports)
|
|
if len(ports) == 0 {
|
|
s.BaseScanStrategy.LogPluginInfo(config, session)
|
|
return
|
|
}
|
|
|
|
// 收集所有匹配的插件(去重)
|
|
pluginSet := make(map[string]struct{}, len(allPlugins))
|
|
for _, port := range ports {
|
|
for _, pluginName := range allPlugins {
|
|
if s.pluginExists(pluginName) {
|
|
if s.isPluginApplicableToPort(pluginName, port) && s.isPluginPassesFilterType(pluginName, isCustomMode, config) {
|
|
pluginSet[pluginName] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 转换为列表
|
|
var applicablePlugins []string
|
|
for pluginName := range pluginSet {
|
|
applicablePlugins = append(applicablePlugins, pluginName)
|
|
}
|
|
|
|
// 输出结果
|
|
if len(applicablePlugins) > 0 {
|
|
pluginStr := formatPluginList(applicablePlugins)
|
|
if isCustomMode {
|
|
session.LogInfo(i18n.Tr("service_plugin_custom", pluginStr))
|
|
} else {
|
|
session.LogInfo(i18n.Tr("service_plugin_info", pluginStr))
|
|
}
|
|
} else {
|
|
session.LogInfo(i18n.GetText("service_plugin_none"))
|
|
}
|
|
}
|
|
|
|
// parsePortList 解析端口列表
|
|
func (s *ServiceScanStrategy) parsePortList(portStr string) []int {
|
|
if portStr == "" || portStr == "all" {
|
|
return []int{}
|
|
}
|
|
|
|
ports := []int{} // 初始化为空切片而非nil
|
|
parts := strings.Split(portStr, ",")
|
|
for _, part := range parts {
|
|
part = strings.TrimSpace(part)
|
|
if port, err := strconv.Atoi(part); err == nil {
|
|
// 验证端口范围 1-65535(与 scanner.go 的 parsePort 保持一致)
|
|
if port >= 1 && port <= 65535 {
|
|
ports = append(ports, port)
|
|
} else {
|
|
common.LogError(i18n.Tr("port_out_of_range", port))
|
|
}
|
|
}
|
|
}
|
|
return ports
|
|
}
|
|
|
|
// Name 返回策略名称
|
|
func (s *ServiceScanStrategy) Name() string {
|
|
return i18n.GetText("scan_strategy_service_name")
|
|
}
|
|
|
|
// Description 返回策略描述
|
|
func (s *ServiceScanStrategy) Description() string {
|
|
return i18n.GetText("scan_strategy_service_desc")
|
|
}
|
|
|
|
// Execute 执行服务扫描策略
|
|
func (s *ServiceScanStrategy) Execute(ctx context.Context, session *common.ScanSession, info common.HostInfo, ch chan struct{}, wg *sync.WaitGroup) {
|
|
config := session.Config
|
|
|
|
// 验证扫描目标(需要同时检查 -h 和 -hf 参数)
|
|
if info.Host == "" && session.Params.HostsFile == "" {
|
|
session.LogError(i18n.GetText("parse_error_target_empty"))
|
|
return
|
|
}
|
|
|
|
// 输出扫描开始信息
|
|
s.LogScanStart(session)
|
|
|
|
// 验证插件配置
|
|
if err := s.ValidateConfiguration(); err != nil {
|
|
session.LogError(err.Error())
|
|
return
|
|
}
|
|
|
|
// 输出插件信息(重写以提供端口过滤)
|
|
s.LogPluginInfo(config, session)
|
|
|
|
// 执行主机扫描流程
|
|
s.performHostScan(ctx, session, info, ch, wg)
|
|
}
|
|
|
|
// performHostScan 执行主机扫描的完整流程
|
|
// pipeline 模式:端口扫描和插件执行并行,扫到开放端口立即开始跑插件
|
|
func (s *ServiceScanStrategy) performHostScan(ctx context.Context, session *common.ScanSession, info common.HostInfo, ch chan struct{}, wg *sync.WaitGroup) {
|
|
config := session.Config
|
|
state := session.State
|
|
|
|
// 解析目标主机
|
|
hosts, err := parsers.ParseIP(info.Host, session.Params.HostsFile, session.Params.ExcludeHosts)
|
|
if err != nil {
|
|
session.LogError(fmt.Sprintf("%s: %v", i18n.GetText("parse_target_failed"), err))
|
|
return
|
|
}
|
|
|
|
// 主机存活检测
|
|
if s.shouldPerformLivenessCheck(hosts, config) {
|
|
hosts = CheckLive(ctx, hosts, false, session)
|
|
session.LogInfo(i18n.Tr("alive_hosts_count_info", len(hosts)))
|
|
}
|
|
|
|
if len(hosts) == 0 && len(state.GetHostPorts()) == 0 {
|
|
return
|
|
}
|
|
|
|
// UDP 插件并行分发:直接对存活主机发协议探测包,不走端口扫描
|
|
if len(hosts) > 0 {
|
|
s.dispatchUDPPlugins(ctx, session, hosts, info, config, ch, wg)
|
|
}
|
|
|
|
// 流式 channel:端口扫描发现开放端口后立即通知插件执行
|
|
stream := make(chan string, 64)
|
|
|
|
// 启动端口扫描 goroutine
|
|
go func() {
|
|
if len(hosts) > 0 {
|
|
EnhancedPortScan(ctx, hosts, config.Target.Ports, int64(config.Timeout.Seconds()), session, stream)
|
|
} else {
|
|
close(stream)
|
|
}
|
|
}()
|
|
|
|
// pipeline 消费:边收开放端口边执行插件
|
|
pluginsToRun, isCustomMode := s.GetPlugins(config)
|
|
cancelled := false
|
|
for addr := range stream {
|
|
if cancelled {
|
|
continue // ctx 已取消,排空 stream 防止写端阻塞
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
cancelled = true
|
|
continue
|
|
default:
|
|
}
|
|
|
|
infos := s.convertToTargetInfos([]string{addr}, info)
|
|
for _, target := range infos {
|
|
for _, pluginName := range pluginsToRun {
|
|
if s.IsPluginApplicableByName(pluginName, target.Host, target.Port, isCustomMode, config) {
|
|
executeScanTask(ctx, session, pluginName, target, ch, wg)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 合并预设的 host:port
|
|
hostPorts := state.GetHostPorts()
|
|
if len(hostPorts) > 0 {
|
|
merged := mergeHostPorts(nil, hostPorts)
|
|
targets := s.convertToTargetInfos(merged, info)
|
|
for _, target := range targets {
|
|
for _, pluginName := range pluginsToRun {
|
|
if s.IsPluginApplicableByName(pluginName, target.Host, target.Port, isCustomMode, config) {
|
|
executeScanTask(ctx, session, pluginName, target, ch, wg)
|
|
}
|
|
}
|
|
}
|
|
state.ClearHostPorts()
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
allPlugins, isCustomMode := s.GetPlugins(config)
|
|
|
|
var udpPlugins []string
|
|
for _, name := range allPlugins {
|
|
if plugins.IsUDP(name) {
|
|
if isCustomMode || plugins.IsSafe(name) {
|
|
udpPlugins = append(udpPlugins, name)
|
|
}
|
|
}
|
|
}
|
|
if len(udpPlugins) == 0 {
|
|
return
|
|
}
|
|
|
|
for _, host := range hosts {
|
|
for _, pluginName := range udpPlugins {
|
|
for _, port := range plugins.GetPluginPorts(pluginName) {
|
|
target := baseInfo
|
|
target.Host = host
|
|
target.Port = port
|
|
executeScanTask(ctx, session, pluginName, target, ch, wg)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// PrepareTargets 准备目标信息
|
|
func (s *ServiceScanStrategy) PrepareTargets(info common.HostInfo, session *common.ScanSession) []common.HostInfo {
|
|
// 发现目标主机和端口
|
|
targetInfos, err := s.discoverTargets(context.Background(), info.Host, info, session)
|
|
if err != nil {
|
|
session.LogError(err.Error())
|
|
return nil
|
|
}
|
|
return targetInfos
|
|
}
|
|
|
|
// LogVulnerabilityPluginInfo 输出服务扫描插件信息
|
|
func (s *ServiceScanStrategy) LogVulnerabilityPluginInfo(targets []common.HostInfo, config *common.Config) {
|
|
allPlugins, isCustomMode := s.GetPlugins(config)
|
|
|
|
// 获取实际会被使用的插件列表
|
|
servicePluginSet := make(map[string]struct{}, len(allPlugins))
|
|
|
|
for _, pluginName := range allPlugins {
|
|
// 使用统一插件系统检查插件存在性
|
|
if !s.pluginExists(pluginName) {
|
|
continue
|
|
}
|
|
|
|
// 检查插件是否通过过滤器类型检查
|
|
if !s.isPluginPassesFilterType(pluginName, isCustomMode, config) {
|
|
continue
|
|
}
|
|
|
|
// 检查插件是否适用于任意一个目标
|
|
for _, target := range targets {
|
|
if target.Port == 0 {
|
|
continue
|
|
}
|
|
|
|
// 使用 host:port 信息检查插件适用性(Web插件需要host信息)
|
|
if s.isPluginApplicableToPortWithHost(pluginName, target.Host, target.Port) {
|
|
servicePluginSet[pluginName] = struct{}{}
|
|
break // 只要适用于一个目标就添加
|
|
}
|
|
}
|
|
}
|
|
|
|
// 转换为切片
|
|
var servicePlugins []string
|
|
for pluginName := range servicePluginSet {
|
|
servicePlugins = append(servicePlugins, pluginName)
|
|
}
|
|
|
|
// 输出插件信息
|
|
if len(servicePlugins) > 0 {
|
|
common.LogInfo(i18n.Tr("service_plugin_info", strings.Join(servicePlugins, ", ")))
|
|
} else {
|
|
common.LogInfo(i18n.GetText("scan_no_service_plugins"))
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// 端口发现功能(从 PortDiscoveryService 合并)
|
|
// =============================================================================
|
|
|
|
// discoverTargets 发现目标主机和端口
|
|
func (s *ServiceScanStrategy) discoverTargets(ctx context.Context, hostInput string, baseInfo common.HostInfo, session *common.ScanSession) ([]common.HostInfo, error) {
|
|
config := session.Config
|
|
state := session.State
|
|
// 标准流程:解析目标主机
|
|
hosts, err := parsers.ParseIP(hostInput, session.Params.HostsFile, session.Params.ExcludeHosts)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s: %w", i18n.GetText("parse_target_failed"), err)
|
|
}
|
|
|
|
var targetInfos []common.HostInfo
|
|
|
|
// 主机存活性检测和端口扫描
|
|
if len(hosts) > 0 || len(state.GetHostPorts()) > 0 {
|
|
// 主机存活检测
|
|
if s.shouldPerformLivenessCheck(hosts, config) {
|
|
hosts = CheckLive(ctx, hosts, false, session)
|
|
session.LogInfo(i18n.Tr("alive_hosts_count_info", len(hosts)))
|
|
}
|
|
|
|
// 端口扫描
|
|
alivePorts := s.discoverAlivePorts(ctx, hosts, session)
|
|
if len(alivePorts) > 0 {
|
|
targetInfos = s.convertToTargetInfos(alivePorts, baseInfo)
|
|
}
|
|
}
|
|
|
|
return targetInfos, nil
|
|
}
|
|
|
|
// shouldPerformLivenessCheck 判断是否需要执行存活性检测
|
|
func (s *ServiceScanStrategy) shouldPerformLivenessCheck(hosts []string, config *common.Config) bool {
|
|
return !config.DisablePing && len(hosts) > 1
|
|
}
|
|
|
|
// discoverAlivePorts 发现存活的端口
|
|
// 执行正常端口扫描后,合并预设的 host:port(来自项目缓存或 CLI),确保不遗漏
|
|
func (s *ServiceScanStrategy) discoverAlivePorts(ctx context.Context, hosts []string, session *common.ScanSession) []string {
|
|
config := session.Config
|
|
state := session.State
|
|
var alivePorts []string
|
|
|
|
// 正常端口扫描
|
|
if len(hosts) > 0 {
|
|
alivePorts = EnhancedPortScan(ctx, hosts, config.Target.Ports, int64(config.Timeout.Seconds()), session, nil)
|
|
}
|
|
|
|
// 合并预设的 host:port(项目缓存 / CLI 注入)
|
|
hostPorts := state.GetHostPorts()
|
|
if len(hostPorts) > 0 {
|
|
alivePorts = mergeHostPorts(alivePorts, hostPorts)
|
|
session.LogInfo(i18n.Tr("alive_ports_count", len(alivePorts)))
|
|
state.ClearHostPorts()
|
|
}
|
|
|
|
return alivePorts
|
|
}
|
|
|
|
// mergeHostPorts 合并两个 host:port 列表并去重
|
|
func mergeHostPorts(a, b []string) []string {
|
|
seen := make(map[string]struct{}, len(a)+len(b))
|
|
for _, s := range a {
|
|
seen[s] = struct{}{}
|
|
}
|
|
for _, s := range b {
|
|
seen[s] = struct{}{}
|
|
}
|
|
result := make([]string, 0, len(seen))
|
|
for s := range seen {
|
|
result = append(result, s)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// convertToTargetInfos 将端口列表转换为目标信息
|
|
func (s *ServiceScanStrategy) convertToTargetInfos(ports []string, baseInfo common.HostInfo) []common.HostInfo {
|
|
var infos []common.HostInfo
|
|
|
|
for _, targetIP := range ports {
|
|
hostParts := strings.Split(targetIP, ":")
|
|
if len(hostParts) != 2 {
|
|
common.LogError(i18n.Tr("invalid_target_format", targetIP))
|
|
continue
|
|
}
|
|
|
|
// 去除空格并过滤空值
|
|
host := strings.TrimSpace(hostParts[0])
|
|
portStr := strings.TrimSpace(hostParts[1])
|
|
if host == "" || portStr == "" {
|
|
common.LogError(i18n.Tr("invalid_target_format", targetIP))
|
|
continue
|
|
}
|
|
|
|
// 验证端口范围(与scanner.go中parsePort保持一致)
|
|
port, err := strconv.Atoi(portStr)
|
|
if err != nil {
|
|
common.LogError(i18n.Tr("host_port_invalid", host, portStr))
|
|
continue
|
|
}
|
|
if port < 1 || port > 65535 {
|
|
common.LogError(i18n.Tr("host_port_out_of_range", host, port))
|
|
continue
|
|
}
|
|
|
|
info := baseInfo
|
|
info.Host = host
|
|
info.Port = port
|
|
// 深拷贝Info避免多个target共享slice底层数组
|
|
if len(baseInfo.Info) > 0 {
|
|
info.Info = append([]string(nil), baseInfo.Info...)
|
|
}
|
|
infos = append(infos, info)
|
|
}
|
|
|
|
return infos
|
|
}
|