Files
fscan/pkg/fscan/scanner.go
T
ZacharyZcR f43e3e3d0a feat: add JDWP plugin for Java debug port detection
Detects exposed JDWP (Java Debug Wire Protocol) services on ports
5005/8000/8787/5050. Performs protocol handshake and reports as VULN
since an exposed debug port allows remote code execution.
2026-05-18 23:22:15 +08:00

646 lines
17 KiB
Go

package fscan
import (
"context"
"fmt"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/shadow1ng/fscan/common"
commonconfig "github.com/shadow1ng/fscan/common/config"
"github.com/shadow1ng/fscan/common/i18n"
"github.com/shadow1ng/fscan/common/output"
"github.com/shadow1ng/fscan/core"
"github.com/shadow1ng/fscan/plugins"
_ "github.com/shadow1ng/fscan/plugins/local"
_ "github.com/shadow1ng/fscan/plugins/services"
_ "github.com/shadow1ng/fscan/plugins/web"
)
var defaultSafePlugins = []string{
"activemq",
"cassandra",
"elasticsearch",
"ftp",
"kafka",
"ldap",
"memcached",
"mongodb",
"mssql",
"mysql",
"neo4j",
"netbios",
"oracle",
"postgresql",
"rabbitmq",
"rdp",
"redis",
"jdwp",
"rsync",
"smb",
"smtp",
"snmp",
"ssh",
"telnet",
"vnc",
"webtitle",
}
// Scanner runs fscan from another Go process.
type Scanner struct {
config Config
}
// NewScanner creates an embedded scanner.
func NewScanner(config Config) *Scanner {
return &Scanner{config: config}
}
// DefaultSafePlugins returns the plugin set used by the SDK when Config.Plugins
// is empty. The returned slice can be modified by callers.
func DefaultSafePlugins() []string {
return append([]string(nil), defaultSafePlugins...)
}
// ListPlugins returns metadata for all registered plugins, sorted by name.
func ListPlugins() []PluginInfo {
names := plugins.All()
sort.Strings(names)
items := make([]PluginInfo, 0, len(names))
for _, name := range names {
if info, ok := GetPlugin(name); ok {
items = append(items, info)
}
}
return items
}
// GetPlugin returns metadata for a registered plugin.
func GetPlugin(name string) (PluginInfo, bool) {
name = strings.TrimSpace(name)
if name == "" || !plugins.Exists(name) {
return PluginInfo{}, false
}
return PluginInfo{
Name: name,
Types: pluginTypes(name),
Capabilities: PluginCapabilities(name),
Ports: pluginPorts(name),
Safe: IsSafePlugin(name),
Default: isDefaultSafePlugin(name),
}, true
}
// ValidateConfig checks whether a config and target set can be used for an
// embedded scan. If no targets are passed, Config.Targets is validated.
func ValidateConfig(config Config, targets ...Target) error {
if len(targets) == 0 {
targets = config.Targets
}
return validateConfig(config, targets)
}
// IsSafePlugin reports whether a plugin may be used while AllowUnsafePlugins is
// false. Unknown plugin names are not safe.
func IsSafePlugin(name string) bool {
name = strings.TrimSpace(name)
if name == "" || !plugins.Exists(name) {
return false
}
return plugins.IsSafe(name) && !hasPluginCapability(name, PluginCapabilityPOC, PluginCapabilityLocalEffect)
}
// PluginCapabilities returns the SDK-facing behavior classes for a plugin.
func PluginCapabilities(name string) []string {
name = strings.TrimSpace(name)
if name == "" || !plugins.Exists(name) {
return nil
}
return pluginCapabilities(name)
}
type scanOpts struct {
controller *ScanController
}
// Scan runs the scanner for the provided targets and returns structured
// findings. If no targets are provided, Config.Targets is used.
func (s *Scanner) Scan(ctx context.Context, targets ...Target) ([]Result, error) {
report, err := s.ScanReport(ctx, targets...)
return report.Results, err
}
// ScanReport runs the scanner and returns results with summary and runtime stats.
func (s *Scanner) ScanReport(ctx context.Context, targets ...Target) (ScanReport, error) {
return s.collectReport(ctx, scanOpts{}, targets...)
}
// ScanEach runs the scanner and calls handle serially for each structured
// result without retaining all results in memory. If handle returns an error,
// the scan context is canceled and that error is returned.
func (s *Scanner) ScanEach(ctx context.Context, handle ResultHandler, targets ...Target) error {
_, err := s.scanEach(ctx, scanOpts{}, handle, targets...)
return err
}
// ScanWithController starts a scan and returns a controller for pause/resume
// and live stats. The scan runs in a background goroutine; read the returned
// channels to get the report and error when the scan completes.
func (s *Scanner) ScanWithController(ctx context.Context, targets ...Target) (*ScanController, <-chan ScanReport, <-chan error) {
ctrl := newScanController()
reportCh := make(chan ScanReport, 1)
errCh := make(chan error, 1)
go func() {
report, err := s.collectReport(ctx, scanOpts{controller: ctrl}, targets...)
reportCh <- report
errCh <- err
}()
return ctrl, reportCh, errCh
}
func (s *Scanner) collectReport(ctx context.Context, opts scanOpts, targets ...Target) (ScanReport, error) {
var (
mu sync.Mutex
results []Result
)
stats, err := s.scanEach(ctx, opts, func(result Result) error {
mu.Lock()
results = append(results, result)
mu.Unlock()
return nil
}, targets...)
results = snapshotResults(&mu, results)
return ScanReport{
Results: results,
Summary: SummarizeResults(results),
Stats: stats,
}, err
}
func (s *Scanner) scanEach(ctx context.Context, opts scanOpts, handle ResultHandler, targets ...Target) (ScanStats, error) {
if handle == nil {
return ScanStats{}, fmt.Errorf("fscan: result handler is required")
}
if ctx == nil {
ctx = context.Background()
}
if len(targets) == 0 {
targets = s.config.Targets
}
if err := validateConfig(s.config, targets); err != nil {
return ScanStats{}, err
}
ctrl := opts.controller
if ctrl == nil && s.config.OnProgress != nil {
ctrl = newScanController()
}
ctx, cancel := context.WithCancel(ctx)
defer cancel()
restoreLogger := common.PushSilentLogger()
defer restoreLogger()
if ctrl != nil && s.config.OnProgress != nil {
progressCtx, progressCancel := context.WithCancel(ctx)
defer progressCancel()
onProgress := s.config.OnProgress
go func() {
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ticker.C:
onProgress(ctrl.progress())
case <-progressCtx.Done():
return
}
}
}()
}
var (
errMu sync.Mutex
handleMu sync.Mutex
handlerErr error
)
var stats ScanStats
for _, target := range targets {
if err := ctx.Err(); err != nil {
if stored := getHandlerError(&errMu, &handlerErr); stored != nil {
return stats, stored
}
return stats, err
}
sink := func(raw *output.ScanResult) error {
if result, ok := convertOutputResult(raw); ok {
s.injectTaskID(&result)
handleMu.Lock()
if err := handle(result); err != nil {
handleMu.Unlock()
setHandlerError(&errMu, &handlerErr, err)
cancel()
return err
}
if s.config.OnResult != nil {
s.config.OnResult(result)
}
handleMu.Unlock()
}
return nil
}
report, err := s.scanOne(ctx, target, sink, opts)
stats.add(coreStatsToSDK(report))
if err != nil {
if stored := getHandlerError(&errMu, &handlerErr); stored != nil {
return stats, stored
}
return stats, err
}
if stored := getHandlerError(&errMu, &handlerErr); stored != nil {
return stats, stored
}
}
if stored := getHandlerError(&errMu, &handlerErr); stored != nil {
return stats, stored
}
return stats, ctx.Err()
}
func (s *Scanner) injectTaskID(result *Result) {
if s.config.TaskID == "" {
return
}
if result.Details == nil {
result.Details = make(map[string]interface{})
}
result.Details["task_id"] = s.config.TaskID
}
func (s *Scanner) scanOne(ctx context.Context, target Target, sink common.ResultSink, opts scanOpts) (core.ScanReport, error) {
fv := buildFlagVars(s.config, target)
info := common.HostInfo{Host: strings.TrimSpace(target.Host), URL: strings.TrimSpace(target.URL)}
if strings.TrimSpace(s.config.Language) != "" {
previousLanguage := i18n.GetLanguage()
i18n.SetLanguage(fv.Language)
defer i18n.SetLanguage(previousLanguage)
}
cfg, state, err := common.BuildConfig(fv, &info)
if err != nil {
return core.ScanReport{}, err
}
if len(s.config.UserPassPairs) > 0 {
cfg.Credentials.UserPassPairs = make([]commonconfig.CredentialPair, 0, len(s.config.UserPassPairs))
for _, pair := range s.config.UserPassPairs {
cfg.Credentials.UserPassPairs = append(cfg.Credentials.UserPassPairs, commonconfig.CredentialPair{
Username: pair.Username,
Password: pair.Password,
})
}
}
cfg.Output.DisableSave = true
cfg.Output.Silent = true
cfg.Output.DisableProgress = true
cfg.Output.ShowProgress = false
session := common.NewScanSession(cfg, state, fv)
session.ResultSink = sink
if opts.controller != nil {
session.PauseGate = opts.controller.pauseGate
opts.controller.addState(state)
}
return core.RunScan(ctx, info, session)
}
func validateConfig(config Config, targets []Target) error {
if len(targets) == 0 {
return fmt.Errorf("fscan: at least one target is required")
}
for _, name := range normalizePlugins(config.Plugins) {
if !plugins.Exists(name) {
return fmt.Errorf("fscan: plugin %q not found", name)
}
if !config.AllowUnsafePlugins && !IsSafePlugin(name) {
return fmt.Errorf("fscan: plugin %q is not enabled for embedded safe mode", name)
}
}
for _, target := range targets {
if strings.TrimSpace(target.Host) == "" && strings.TrimSpace(target.URL) == "" {
return fmt.Errorf("fscan: target host or URL is required")
}
if strings.TrimSpace(target.Host) != "" && strings.TrimSpace(target.URL) != "" {
return fmt.Errorf("fscan: target cannot set both Host and URL")
}
for _, port := range target.Ports {
if port < 1 || port > 65535 {
return fmt.Errorf("fscan: invalid port %d", port)
}
}
}
for _, port := range config.Ports {
if port < 1 || port > 65535 {
return fmt.Errorf("fscan: invalid port %d", port)
}
}
return nil
}
func buildFlagVars(config Config, target Target) *common.FlagVars {
timeout := secondsOrDefault(config.Timeout, common.DefaultTimeout)
webTimeout := secondsOrDefault(config.WebTimeout, 5)
threadNum := config.Threads
if threadNum <= 0 {
threadNum = common.DefaultThreadNum
}
moduleThreads := config.ModuleThreads
if moduleThreads <= 0 {
moduleThreads = 20
}
maxRetries := config.MaxRetries
if maxRetries <= 0 {
maxRetries = 3
}
maxRedirects := config.MaxRedirects
if maxRedirects <= 0 {
maxRedirects = 10
}
pocConcurrency := config.POCConcurrency
if pocConcurrency <= 0 {
pocConcurrency = 20
}
icmpRate := config.ICMPRate
if icmpRate <= 0 {
icmpRate = 0.1
}
language := config.Language
if language == "" {
language = common.DefaultLanguage
}
ports := config.Ports
if len(target.Ports) > 0 {
ports = target.Ports
}
return &common.FlagVars{
Host: strings.TrimSpace(target.Host),
Ports: formatPorts(ports),
ScanMode: formatPlugins(config),
ThreadNum: threadNum,
ModuleThreadNum: moduleThreads,
TimeoutSec: timeout,
GlobalTimeout: 180,
DisablePing: config.DisablePing,
DisableTcpProbe: config.DisableTCPProbe,
AliveOnly: false,
DisableBrute: config.DisableBrute,
MaxRetries: maxRetries,
Username: strings.Join(config.Usernames, ","),
Password: strings.Join(config.Passwords, ","),
Domain: config.Domain,
SSHKeyPath: config.SSHKeyPath,
TargetURL: strings.TrimSpace(target.URL),
WebTimeout: webTimeout,
MaxRedirects: maxRedirects,
HTTPProxy: config.HTTPProxy,
Socks5Proxy: config.Socks5Proxy,
Iface: config.Interface,
PocPath: config.POCPath,
PocName: config.POCName,
PocFull: config.POCFull,
PocNum: pocConcurrency,
DisablePocScan: config.DisablePOCScan,
PacketRateLimit: config.PacketRateLimit,
MaxPacketCount: config.MaxPacketCount,
ICMPRate: icmpRate,
Outputfile: "result.txt",
OutputFormat: "txt",
DisableSave: true,
Silent: true,
NoColor: true,
LogLevel: common.LogLevelError,
DisableProgress: true,
Language: language,
ForwardShellPort: 4444,
KeyloggerOutputFile: "keylog.txt",
}
}
func formatPlugins(config Config) string {
parts := normalizePlugins(config.Plugins)
if len(parts) == 0 {
if config.AllowUnsafePlugins {
return "all"
}
parts = defaultSafePlugins
}
return strings.Join(parts, ",")
}
func formatPorts(ports []int) string {
if len(ports) == 0 {
return commonconfig.MainPorts
}
ports = append([]int(nil), ports...)
sort.Ints(ports)
parts := make([]string, 0, len(ports))
for _, port := range ports {
parts = append(parts, strconv.Itoa(port))
}
return strings.Join(parts, ",")
}
func normalizePlugins(pluginNames []string) []string {
parts := make([]string, 0, len(pluginNames))
for _, plugin := range pluginNames {
plugin = strings.TrimSpace(plugin)
if plugin != "" {
parts = append(parts, plugin)
}
}
return parts
}
func pluginTypes(name string) []string {
types := make([]string, 0, 4)
for _, pluginType := range []string{PluginTypeService, PluginTypeWeb, PluginTypeLocal, PluginTypeUDP} {
if plugins.HasType(name, pluginType) {
types = append(types, pluginType)
}
}
return types
}
func pluginPorts(name string) []int {
ports := plugins.GetPluginPorts(name)
ports = append([]int(nil), ports...)
sort.Ints(ports)
return ports
}
func pluginCapabilities(name string) []string {
capSet := map[string]struct{}{}
add := func(capability string) {
capSet[capability] = struct{}{}
}
if plugins.HasType(name, PluginTypeService) || plugins.HasType(name, PluginTypeWeb) || plugins.HasType(name, PluginTypeUDP) {
add(PluginCapabilityDetect)
}
if serviceAuthPlugins[name] {
add(PluginCapabilityAuthCheck)
add(PluginCapabilityBrute)
}
if activePOCPlugins[name] || strings.Contains(name, "poc") {
add(PluginCapabilityPOC)
}
if plugins.HasType(name, PluginTypeLocal) {
add(PluginCapabilityLocalEffect)
}
capabilities := make([]string, 0, len(capSet))
for capability := range capSet {
capabilities = append(capabilities, capability)
}
sort.Strings(capabilities)
return capabilities
}
func hasPluginCapability(name string, capabilities ...string) bool {
pluginCaps := pluginCapabilities(name)
for _, want := range capabilities {
for _, got := range pluginCaps {
if got == want {
return true
}
}
}
return false
}
var serviceAuthPlugins = map[string]bool{
"activemq": true,
"cassandra": true,
"elasticsearch": true,
"ftp": true,
"kafka": true,
"ldap": true,
"memcached": true,
"mongodb": true,
"mssql": true,
"mysql": true,
"neo4j": true,
"oracle": true,
"postgresql": true,
"rabbitmq": true,
"redis": true,
"rsync": true,
"smb": true,
"smtp": true,
"snmp": true,
"ssh": true,
"telnet": true,
"vnc": true,
}
var activePOCPlugins = map[string]bool{
"ms17010": true,
"webpoc": true,
}
func isDefaultSafePlugin(name string) bool {
for _, plugin := range defaultSafePlugins {
if plugin == name {
return true
}
}
return false
}
func secondsOrDefault(value time.Duration, fallback int) int64 {
if value <= 0 {
return int64(fallback)
}
seconds := int64(value.Round(time.Second) / time.Second)
if seconds < 1 {
return 1
}
return seconds
}
func convertOutputResult(raw *output.ScanResult) (Result, bool) {
if raw == nil {
return Result{}, false
}
result := Result{
Time: raw.Time,
Type: string(raw.Type),
Target: raw.Target,
Status: raw.Status,
Details: raw.Details,
}
return result, result.Target != "" || result.Status != ""
}
func coreStatsToSDK(report core.ScanReport) ScanStats {
return ScanStats{
Duration: report.Duration,
TasksTotal: report.TasksTotal,
TasksCompleted: report.TasksCompleted,
Packets: report.Packets,
TCPPackets: report.TCPPackets,
TCPSuccessPackets: report.TCPSuccessPackets,
TCPFailedPackets: report.TCPFailedPackets,
UDPPackets: report.UDPPackets,
HTTPPackets: report.HTTPPackets,
ResourceExhausted: report.ResourceExhausted,
}
}
func (s *ScanStats) add(other ScanStats) {
s.Duration += other.Duration
s.TasksTotal += other.TasksTotal
s.TasksCompleted += other.TasksCompleted
s.Packets += other.Packets
s.TCPPackets += other.TCPPackets
s.TCPSuccessPackets += other.TCPSuccessPackets
s.TCPFailedPackets += other.TCPFailedPackets
s.UDPPackets += other.UDPPackets
s.HTTPPackets += other.HTTPPackets
s.ResourceExhausted += other.ResourceExhausted
}
func snapshotResults(mu *sync.Mutex, results []Result) []Result {
mu.Lock()
defer mu.Unlock()
return append([]Result(nil), results...)
}
func setHandlerError(mu *sync.Mutex, target *error, err error) {
mu.Lock()
defer mu.Unlock()
if *target == nil {
*target = err
}
}
func getHandlerError(mu *sync.Mutex, err *error) error {
mu.Lock()
defer mu.Unlock()
return *err
}