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
478 lines
13 KiB
Go
478 lines
13 KiB
Go
package core
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"net/url"
|
||
"os"
|
||
"os/signal"
|
||
"sync"
|
||
"syscall"
|
||
"time"
|
||
|
||
"github.com/shadow1ng/fscan/common"
|
||
"github.com/shadow1ng/fscan/common/i18n"
|
||
"github.com/shadow1ng/fscan/common/output"
|
||
"github.com/shadow1ng/fscan/plugins"
|
||
"github.com/shadow1ng/fscan/webscan/lib"
|
||
)
|
||
|
||
// ScanReport summarizes one scan execution.
|
||
type ScanReport struct {
|
||
Duration time.Duration
|
||
TasksTotal int64
|
||
TasksCompleted int64
|
||
Packets int64
|
||
TCPPackets int64
|
||
TCPSuccessPackets int64
|
||
TCPFailedPackets int64
|
||
UDPPackets int64
|
||
HTTPPackets int64
|
||
ResourceExhausted int64
|
||
}
|
||
|
||
// ScanStrategy 定义扫描策略接口
|
||
type ScanStrategy interface {
|
||
Execute(ctx context.Context, session *common.ScanSession, info common.HostInfo, ch chan struct{}, wg *sync.WaitGroup)
|
||
GetPlugins(config *common.Config) ([]string, bool)
|
||
IsPluginApplicableByName(pluginName string, targetHost string, targetPort int, isCustomMode bool, config *common.Config) bool
|
||
}
|
||
|
||
// ScanMode 扫描模式类型
|
||
type ScanMode int
|
||
|
||
const (
|
||
ScanModeService ScanMode = iota // 默认:服务扫描
|
||
ScanModeAlive // 仅存活检测
|
||
ScanModeLocal // 本地插件
|
||
ScanModeWeb // Web扫描
|
||
)
|
||
|
||
// strategyInfo 策略信息
|
||
type strategyInfo struct {
|
||
factory func() ScanStrategy
|
||
logKey string
|
||
}
|
||
|
||
var strategyRegistry = map[ScanMode]strategyInfo{
|
||
ScanModeAlive: {func() ScanStrategy { return NewAliveScanStrategy() }, "scan_mode_alive_selected"},
|
||
ScanModeLocal: {func() ScanStrategy { return NewLocalScanStrategy() }, "scan_mode_local_selected"},
|
||
ScanModeWeb: {func() ScanStrategy { return NewWebScanStrategy() }, "scan_mode_web_selected"},
|
||
ScanModeService: {func() ScanStrategy { return NewServiceScanStrategy() }, "scan_mode_service_selected"},
|
||
}
|
||
|
||
// determineScanMode 根据配置和状态确定扫描模式
|
||
func determineScanMode(config *common.Config, state *common.State) ScanMode {
|
||
switch {
|
||
case config.AliveOnly || config.Mode == "icmp":
|
||
return ScanModeAlive
|
||
case config.LocalMode:
|
||
return ScanModeLocal
|
||
case common.IsLocalMode != nil && common.IsLocalMode(config.Mode):
|
||
config.LocalMode = true
|
||
config.LocalPlugin = config.Mode
|
||
return ScanModeLocal
|
||
case len(state.GetURLs()) > 0:
|
||
return ScanModeWeb
|
||
default:
|
||
return ScanModeService
|
||
}
|
||
}
|
||
|
||
// selectStrategy 根据扫描模式选择策略
|
||
func selectStrategy(config *common.Config, state *common.State, info common.HostInfo) ScanStrategy {
|
||
mode := determineScanMode(config, state)
|
||
|
||
if info, ok := strategyRegistry[mode]; ok {
|
||
return info.factory()
|
||
}
|
||
|
||
// 后备:默认服务扫描(理论上不会执行到这里)
|
||
return NewServiceScanStrategy()
|
||
}
|
||
|
||
// RunScan 执行整体扫描流程
|
||
func RunScan(ctx context.Context, info common.HostInfo, session *common.ScanSession) (ScanReport, error) {
|
||
start := time.Now()
|
||
ctx, cancel := context.WithCancel(ctx)
|
||
defer cancel()
|
||
|
||
config := session.Config
|
||
state := session.State
|
||
|
||
// 初始化HTTP客户端(静默,无需日志)
|
||
if err := lib.Inithttp(config); err != nil {
|
||
session.LogError(i18n.Tr("http_client_init_failed", err))
|
||
return buildScanReport(state, start), fmt.Errorf("initialize http client: %w", err)
|
||
}
|
||
|
||
// 选择策略
|
||
strategy := selectStrategy(config, state, info)
|
||
|
||
// 并发控制初始化
|
||
ch := make(chan struct{}, config.ThreadNum)
|
||
wg := sync.WaitGroup{}
|
||
|
||
// 执行策略
|
||
strategy.Execute(ctx, session, info, ch, &wg)
|
||
|
||
// 等待所有扫描完成
|
||
wg.Wait()
|
||
|
||
// 检查是否有活跃的连接需要维持
|
||
if state.IsReverseShellActive() || state.IsSocks5ProxyActive() || state.IsForwardShellActive() {
|
||
if state.IsReverseShellActive() {
|
||
session.LogInfo(i18n.GetText("active_reverse_shell"))
|
||
}
|
||
if state.IsSocks5ProxyActive() {
|
||
session.LogInfo(i18n.GetText("active_socks5_proxy"))
|
||
}
|
||
if state.IsForwardShellActive() {
|
||
session.LogInfo(i18n.GetText("active_forward_shell"))
|
||
}
|
||
session.LogInfo(i18n.GetText("press_ctrl_c_exit"))
|
||
|
||
// 优雅等待信号或 context 取消(Web Stop)
|
||
sigChan := make(chan os.Signal, 1)
|
||
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
|
||
select {
|
||
case <-sigChan:
|
||
session.LogInfo(i18n.GetText("received_exit_signal"))
|
||
case <-ctx.Done():
|
||
}
|
||
cancel()
|
||
time.Sleep(500 * time.Millisecond)
|
||
}
|
||
|
||
// 完成扫描
|
||
finishScan(session)
|
||
if err := ctx.Err(); err != nil {
|
||
return buildScanReport(state, start), err
|
||
}
|
||
return buildScanReport(state, start), nil
|
||
}
|
||
|
||
func buildScanReport(state *common.State, start time.Time) ScanReport {
|
||
return ScanReport{
|
||
Duration: time.Since(start),
|
||
TasksTotal: state.GetEnd(),
|
||
TasksCompleted: state.GetNum(),
|
||
Packets: state.GetPacketCount(),
|
||
TCPPackets: state.GetTCPPacketCount(),
|
||
TCPSuccessPackets: state.GetTCPSuccessPacketCount(),
|
||
TCPFailedPackets: state.GetTCPFailedPacketCount(),
|
||
UDPPackets: state.GetUDPPacketCount(),
|
||
HTTPPackets: state.GetHTTPPacketCount(),
|
||
ResourceExhausted: state.GetResourceExhaustedCount(),
|
||
}
|
||
}
|
||
|
||
// finishScan 完成扫描并输出结果
|
||
func finishScan(session *common.ScanSession) {
|
||
config := session.Config
|
||
state := session.State
|
||
|
||
// 确保进度条正确完成
|
||
if common.IsProgressActive() {
|
||
common.FinishProgressBar()
|
||
}
|
||
|
||
// 输出扫描完成信息
|
||
session.LogInfo(i18n.Tr("scan_task_complete", time.Since(state.GetStartTime()).Round(time.Millisecond), state.GetNum()))
|
||
|
||
// 输出性能统计 JSON(如果启用)
|
||
if config.Output.PerfStats {
|
||
fmt.Printf("\n[PERF_STATS_JSON]%s[/PERF_STATS_JSON]\n", state.GetPerfStatsJSON())
|
||
}
|
||
}
|
||
|
||
// ExecuteScanTasks 任务执行通用框架
|
||
func ExecuteScanTasks(ctx context.Context, session *common.ScanSession, targets []common.HostInfo, strategy ScanStrategy, ch chan struct{}, wg *sync.WaitGroup) {
|
||
config := session.Config
|
||
|
||
// 获取要执行的插件
|
||
pluginsToRun, isCustomMode := strategy.GetPlugins(config)
|
||
|
||
// 预计算任务数量用于进度条
|
||
taskCount := countApplicableTasks(targets, pluginsToRun, isCustomMode, strategy, config)
|
||
|
||
// 初始化进度条
|
||
if taskCount > 0 && config.Output.ShowProgress {
|
||
description := i18n.GetText("progress_scanning_description")
|
||
common.InitProgressBar(int64(taskCount), description)
|
||
}
|
||
|
||
// 流式执行任务,避免预构建大量任务对象
|
||
for _, target := range targets {
|
||
// 检查取消
|
||
select {
|
||
case <-ctx.Done():
|
||
return
|
||
default:
|
||
}
|
||
|
||
if session.PauseGate != nil {
|
||
if err := session.PauseGate(ctx); err != nil {
|
||
return
|
||
}
|
||
}
|
||
|
||
targetPort := target.Port
|
||
|
||
for _, pluginName := range pluginsToRun {
|
||
// 使用Exists检查避免不必要的插件实例创建
|
||
if !plugins.Exists(pluginName) {
|
||
continue
|
||
}
|
||
|
||
// 检查插件是否适用于当前目标
|
||
if strategy.IsPluginApplicableByName(pluginName, target.Host, targetPort, isCustomMode, config) {
|
||
executeScanTask(ctx, session, pluginName, target, ch, wg)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// countApplicableTasks 计算适用的任务数量
|
||
func countApplicableTasks(targets []common.HostInfo, pluginsToRun []string, isCustomMode bool, strategy ScanStrategy, config *common.Config) int {
|
||
count := 0
|
||
for _, target := range targets {
|
||
targetPort := target.Port
|
||
|
||
for _, pluginName := range pluginsToRun {
|
||
// 使用Exists检查避免不必要的插件实例创建
|
||
if plugins.Exists(pluginName) &&
|
||
strategy.IsPluginApplicableByName(pluginName, target.Host, targetPort, isCustomMode, config) {
|
||
count++
|
||
}
|
||
}
|
||
}
|
||
return count
|
||
}
|
||
|
||
// longRunningPlugins 长驻插件,不加入 scan WaitGroup,通过 ctx 取消退出
|
||
var longRunningPlugins = map[string]bool{
|
||
"forwardshell": true,
|
||
"socks5proxy": true,
|
||
"reverseshell": true,
|
||
}
|
||
|
||
// executeScanTask 执行单个扫描任务
|
||
func executeScanTask(ctx context.Context, session *common.ScanSession, pluginName string, target common.HostInfo, ch chan struct{}, wg *sync.WaitGroup) {
|
||
state := session.State
|
||
|
||
// 检查取消
|
||
select {
|
||
case <-ctx.Done():
|
||
return
|
||
default:
|
||
}
|
||
|
||
if session.PauseGate != nil {
|
||
if err := session.PauseGate(ctx); err != nil {
|
||
return
|
||
}
|
||
}
|
||
|
||
// 长驻插件不进 WaitGroup,通过 ctx 管理生命周期
|
||
if longRunningPlugins[pluginName] {
|
||
ready := make(chan struct{}, 1)
|
||
go func() {
|
||
plugin := plugins.Get(pluginName)
|
||
if plugin != nil {
|
||
go func() {
|
||
time.Sleep(500 * time.Millisecond)
|
||
ready <- struct{}{}
|
||
}()
|
||
plugin.Scan(ctx, &target, session)
|
||
} else {
|
||
ready <- struct{}{}
|
||
}
|
||
}()
|
||
<-ready
|
||
return
|
||
}
|
||
|
||
wg.Add(1)
|
||
|
||
// 获取并发槽位,支持取消
|
||
select {
|
||
case ch <- struct{}{}:
|
||
case <-ctx.Done():
|
||
wg.Done()
|
||
return
|
||
}
|
||
|
||
go func() {
|
||
// 开始监控插件任务
|
||
monitor := common.GetConcurrencyMonitor()
|
||
monitor.StartPluginTask()
|
||
|
||
defer func() {
|
||
// 捕获并记录任何可能的panic
|
||
if r := recover(); r != nil {
|
||
session.LogError(i18n.Tr("plugin_panic", pluginName, target.Host, target.Port, r))
|
||
}
|
||
|
||
// 更新统计和进度(任务真正完成时才更新)
|
||
state.IncrementNum()
|
||
common.UpdateProgressBar(1)
|
||
|
||
// 完成任务,释放资源
|
||
monitor.FinishPluginTask()
|
||
wg.Done()
|
||
<-ch // 释放并发槽位
|
||
}()
|
||
|
||
plugin := plugins.Get(pluginName)
|
||
if plugin != nil {
|
||
result := plugin.Scan(ctx, &target, session)
|
||
if result != nil {
|
||
if result.Success {
|
||
// 保存成功的扫描结果到文件
|
||
savePluginResult(session, &target, pluginName, result)
|
||
} else if result.Type == plugins.ResultTypeCredential {
|
||
// 凭据测试完成但未发现弱密码,在error级别输出提示
|
||
session.LogError(i18n.Tr("brute_no_weak_pass", target.Host, target.Port, pluginName))
|
||
} else if result.Error != nil {
|
||
// 其他类型的错误
|
||
session.LogError(i18n.Tr("plugin_scan_error", target.Host, target.Port, result.Error))
|
||
}
|
||
}
|
||
}
|
||
}()
|
||
}
|
||
|
||
// resultSerializer 结果序列化信息
|
||
type resultSerializer struct {
|
||
outputType output.ResultType
|
||
getStatus func(*plugins.Result, *common.HostInfo) string
|
||
fillDetail func(*plugins.Result, *common.HostInfo, map[string]interface{})
|
||
}
|
||
|
||
var resultSerializers = map[plugins.ResultType]resultSerializer{
|
||
plugins.ResultTypeCredential: {
|
||
outputType: output.TypeVuln,
|
||
getStatus: func(r *plugins.Result, _ *common.HostInfo) string {
|
||
return fmt.Sprintf("weak_credential: %s:%s", r.Username, r.Password)
|
||
},
|
||
fillDetail: func(r *plugins.Result, _ *common.HostInfo, d map[string]interface{}) {
|
||
d["service"] = r.Service
|
||
d["username"] = r.Username
|
||
d["password"] = r.Password
|
||
d["type"] = "weak_credential"
|
||
},
|
||
},
|
||
plugins.ResultTypeService: {
|
||
outputType: output.TypeService,
|
||
getStatus: func(r *plugins.Result, _ *common.HostInfo) string {
|
||
if r.Banner != "" {
|
||
return r.Banner
|
||
}
|
||
return r.Service
|
||
},
|
||
fillDetail: func(r *plugins.Result, _ *common.HostInfo, d map[string]interface{}) {
|
||
if r.Banner != "" {
|
||
d["banner"] = r.Banner
|
||
}
|
||
if r.Service != "" {
|
||
d["service"] = r.Service
|
||
}
|
||
},
|
||
},
|
||
plugins.ResultTypeVuln: {
|
||
outputType: output.TypeVuln,
|
||
getStatus: func(r *plugins.Result, _ *common.HostInfo) string {
|
||
// 优先使用VulInfo,为空则回退到Banner
|
||
if r.VulInfo != "" {
|
||
return r.VulInfo
|
||
}
|
||
return r.Banner
|
||
},
|
||
fillDetail: func(r *plugins.Result, _ *common.HostInfo, d map[string]interface{}) {
|
||
// 优先使用VulInfo,为空则回退到Banner
|
||
vuln := r.VulInfo
|
||
if vuln == "" {
|
||
vuln = r.Banner
|
||
}
|
||
d["vulnerability"] = vuln
|
||
d["service"] = r.Service
|
||
},
|
||
},
|
||
plugins.ResultTypeWeb: {
|
||
outputType: output.TypeService,
|
||
getStatus: func(_ *plugins.Result, _ *common.HostInfo) string { return "web" },
|
||
fillDetail: func(r *plugins.Result, info *common.HostInfo, d map[string]interface{}) {
|
||
d["is_web"] = true
|
||
d["port"] = info.Port
|
||
if r.Output == "" {
|
||
return
|
||
}
|
||
d["url"] = r.Output
|
||
if parsed, err := url.Parse(r.Output); err == nil && (parsed.Scheme == "http" || parsed.Scheme == "https") {
|
||
d["protocol"] = parsed.Scheme
|
||
}
|
||
},
|
||
},
|
||
}
|
||
|
||
var defaultSerializer = resultSerializer{
|
||
outputType: output.TypeService,
|
||
getStatus: func(r *plugins.Result, _ *common.HostInfo) string {
|
||
if r.Banner != "" {
|
||
return r.Banner
|
||
}
|
||
if r.Service != "" {
|
||
return r.Service
|
||
}
|
||
return "detected"
|
||
},
|
||
fillDetail: func(_ *plugins.Result, _ *common.HostInfo, _ map[string]interface{}) {},
|
||
}
|
||
|
||
// savePluginResult 保存插件扫描结果
|
||
func savePluginResult(session *common.ScanSession, info *common.HostInfo, pluginName string, result *plugins.Result) {
|
||
if result == nil || !result.Success || result.Skipped {
|
||
return
|
||
}
|
||
|
||
// 获取序列化器
|
||
serializer, ok := resultSerializers[result.Type]
|
||
if !ok {
|
||
serializer = defaultSerializer
|
||
}
|
||
|
||
// 构建详情
|
||
details := map[string]interface{}{"plugin": pluginName}
|
||
serializer.fillDetail(result, info, details)
|
||
|
||
// 添加通用字段
|
||
addCommonDetails(result, details)
|
||
|
||
// 保存结果
|
||
target := info.Target()
|
||
_ = session.SaveResult(&output.ScanResult{
|
||
Time: time.Now(),
|
||
Type: serializer.outputType,
|
||
Target: target,
|
||
Status: serializer.getStatus(result, info),
|
||
Details: details,
|
||
})
|
||
}
|
||
|
||
// addCommonDetails 添加通用详情字段
|
||
func addCommonDetails(result *plugins.Result, details map[string]interface{}) {
|
||
if len(result.Fingerprints) > 0 {
|
||
details["fingerprints"] = result.Fingerprints
|
||
}
|
||
if result.Title != "" {
|
||
details["title"] = result.Title
|
||
}
|
||
if result.Status != 0 {
|
||
details["status"] = result.Status
|
||
}
|
||
if result.Server != "" {
|
||
details["server"] = result.Server
|
||
}
|
||
}
|