Scheduled Task Persistence Detection with KQL and SPL (T1053.005)
Scheduled tasks are a love letter from attackers to themselves — survive a reboot, run as SYSTEM, hide in plain sight. They are the single most-abused persistence mechanism in Windows intrusion telemetry, and they show up in almost every ransomware, nation-state, and commodity malware incident the industry publishes. If your SOC isn’t writing scheduled task detection queries, you are blind to the technique adversaries reach for first after initial access.
This guide walks through production-tested KQL and SPL queries for detecting T1053.005 Scheduled Task abuse, plus coverage for the broader T1053 Scheduled Task/Job family — at.exe, cron, hidden tasks, and the 4698/4700/4702 Windows audit events.
1. Why scheduled tasks dominate persistence techniques
Scheduled tasks are the ideal adversary primitive for five reasons, each of which explains why they appear in MITRE ATT&CK under three separate tactics (Execution, Persistence, Privilege Escalation):
- Reboot survival. A task registered with an
ONSTARTorONLOGONtrigger re-executes every time the system comes back up. No separate persistence mechanism required. - SYSTEM execution. Unlike Run keys or startup folders, tasks can be configured with
/ru SYSTEM, giving the attacker the highest-privilege execution context on the host. - Trusted parentage. When a task fires, the child process is spawned by
svchost.exeortaskhostw.exe— both legitimate Windows processes. Detection rules that only look for “suspicious parents” miss this completely. - Multiple creation paths.
schtasks.exe, the Task Scheduler GUI,Register-ScheduledTask, WMI (Win32_ScheduledJob), direct registry manipulation, and XML import all produce scheduled tasks. If your detection only watchesschtasks.exe, attackers simply use one of the other five paths. - Hiding in plain sight. Windows ships with hundreds of legitimate scheduled tasks. Attacker tasks masquerading as
MicrosoftEdgeUpdate,WindowsDefender, orGoogleUpdateslip past casual review.
The T1053.005 detection in the df00tech library addresses all five paths with a multi-branch detection strategy. Let’s walk through the most important pieces.
2. Detecting schtasks.exe /create from unusual parents (T1053.005)
The highest-fidelity signal for malicious scheduled task creation is schtasks.exe spawned by a process that has no business invoking it — Office applications, browsers, script interpreters, or LOLBins. Legitimate task creation originates from msiexec.exe, ccmexec.exe (SCCM), admin PowerShell sessions, or direct cmd.exe invocations by interactive administrators. When winword.exe spawns schtasks.exe, that’s a phishing payload establishing persistence.
The full T1053.005 KQL detection uses three branches. Branch 1 handles command-line analysis with a multi-factor suspicion score:
let SuspiciousTaskPatterns = dynamic([
"cmd.exe", "powershell.exe", "pwsh.exe", "wscript.exe", "cscript.exe",
"mshta.exe", "rundll32.exe", "regsvr32.exe", "certutil.exe",
"bitsadmin.exe", "msbuild.exe", "wmic.exe", "msiexec.exe"
]);
let SuspiciousLocations = dynamic([
"\\AppData\\", "\\Temp\\", "\\ProgramData\\", "\\Public\\",
"\\Users\\Default\\", "%temp%", "%appdata%", "%public%"
]);
let SuspiciousSchtasksArgs = dynamic([
"/sc onlogon", "/sc onstart", "/sc onstartup", "/ru system",
"/ru \"system\"", "http://", "https://", "\\\\\\\\"
]);
// Branch 1: schtasks.exe process creation with suspicious patterns
let SchtasksExecution = DeviceProcessEvents
| where Timestamp > ago(24h)
| where FileName =~ "schtasks.exe"
| where ProcessCommandLine has_any ("/create", "/change")
| extend HasSuspiciousLoc = ProcessCommandLine has_any (SuspiciousLocations)
| extend HasSuspiciousBin = ProcessCommandLine has_any (SuspiciousTaskPatterns)
| extend RunAsSystem = ProcessCommandLine has_any ("/ru system", "/ru \"SYSTEM\"", "/ru \"NT AUTHORITY\\SYSTEM\"")
| extend RemoteTask = ProcessCommandLine has "/s "
| extend OnLogonTrigger = ProcessCommandLine has_any ("/sc onlogon", "/sc onstartup", "/sc onstart")
| extend HighFreqTrigger = ProcessCommandLine has_any ("/sc minute", "/sc hourly")
| extend SuspicionScore = toint(HasSuspiciousLoc) + toint(HasSuspiciousBin) + toint(RunAsSystem) + toint(RemoteTask) + toint(OnLogonTrigger) + toint(HighFreqTrigger)
| where SuspicionScore > 0
| extend DetectionSource = "schtasks_process"
| project Timestamp, DeviceName, AccountName, FileName, ProcessCommandLine,
InitiatingProcessFileName, InitiatingProcessCommandLine,
HasSuspiciousLoc, HasSuspiciousBin, RunAsSystem, RemoteTask,
OnLogonTrigger, HighFreqTrigger, SuspicionScore, DetectionSource;
What this catches: Any schtasks /create or /change command that hits at least one suspicious indicator — a payload in AppData or Temp, a scripting engine as the task action, /ru SYSTEM privilege escalation, a remote /s flag for lateral movement, or an ONLOGON/ONSTART persistence trigger. The SuspicionScore lets analysts prioritize — a task with four indicators is almost certainly malicious, a task with one may be a legitimate installer.
XML task payloads are the tricky part. When attackers use schtasks /xml <file> to import a pre-built task, the malicious arguments live inside the XML file, not the schtasks command line. That’s why Branch 2 of the detection correlates schtasks.exe with suspicious parent processes regardless of the command-line content, and why Security Event 4698 — which captures the full task XML — is your safety net.
3. Detecting at.exe and cron abuse (T1053.002)
The at utility is the forgotten cousin of schtasks. Microsoft deprecated at.exe in favor of schtasks.exe starting with Windows 8, which means any use of at.exe on a modern Windows host is inherently suspicious. Threat Group-3390, APT18, BRONZE BUTLER, and Impacket’s atexec.py all rely on at.exe for lateral movement, precisely because defenders stopped watching it.
The T1053.002 detection covers three sub-patterns: direct at.exe with suspicious arguments, at.exe spawned by script interpreters, and WMI Win32_ScheduledJob abuse:
// Detect suspicious use of the 'at' scheduler utility on Windows and WMI-based job scheduling
let AtSuspiciousArgs = dynamic([
"cmd.exe", "powershell", "wscript", "cscript", "mshta", "rundll32", "regsvr32",
"certutil", "bitsadmin", "net use", "net user", "whoami", "mimikatz",
".exe", ".bat", ".vbs", ".ps1", ".hta"
]);
union
(
// Windows: at.exe process creation via DeviceProcessEvents
DeviceProcessEvents
| where Timestamp > ago(24h)
| where FileName =~ "at.exe"
| where ProcessCommandLine has_any (AtSuspiciousArgs)
or ProcessCommandLine matches regex @"\d{1,2}:\d{2}\s+(AM|PM|/every|/next)"
or ProcessCommandLine has "/interactive"
| extend Source = "at.exe direct execution"
| project Timestamp, DeviceName, AccountName, FileName, ProcessCommandLine,
InitiatingProcessFileName, InitiatingProcessCommandLine,
InitiatingProcessAccountName, FolderPath, Source
),
(
// Windows: at.exe spawned by unusual parents (lateral movement pattern)
DeviceProcessEvents
| where Timestamp > ago(24h)
| where FileName =~ "at.exe"
| where InitiatingProcessFileName in~ ("cmd.exe", "powershell.exe", "wscript.exe", "cscript.exe",
"mshta.exe", "python.exe", "python3.exe", "perl.exe")
| extend Source = "at.exe spawned by scripting engine"
| project Timestamp, DeviceName, AccountName, FileName, ProcessCommandLine,
InitiatingProcessFileName, InitiatingProcessCommandLine,
InitiatingProcessAccountName, FolderPath, Source
),
(
// WMI Win32_ScheduledJob creation detected via DeviceProcessEvents (wmic or powershell invoking Win32_ScheduledJob)
DeviceProcessEvents
| where Timestamp > ago(24h)
| where (FileName =~ "wmic.exe" and ProcessCommandLine has "ScheduledJob")
or (FileName in~ ("powershell.exe", "pwsh.exe") and ProcessCommandLine has "Win32_ScheduledJob")
| extend Source = "WMI Win32_ScheduledJob"
| project Timestamp, DeviceName, AccountName, FileName, ProcessCommandLine,
InitiatingProcessFileName, InitiatingProcessCommandLine,
InitiatingProcessAccountName, FolderPath, Source
)
| sort by Timestamp desc
What this catches: at.exe invoking any LOLBin, script, or scheduling syntax (HH:MM AM/PM, /every, /next); at.exe launched by a Python or Perl interpreter (a classic Impacket atexec fingerprint); and WMI-based scheduling via wmic ScheduledJob or PowerShell’s Win32_ScheduledJob — a favorite evasion technique that never touches schtasks.exe or at.exe.
Pair this with credential telemetry. Remote at.exe execution requires local Administrator privileges on the target. Correlate with Security Event 4624 Logon Type 3 (network logon) or 4648 (logon with explicit credentials) in the ten minutes preceding the task creation — this is the fingerprint of CrackMapExec or Impacket running harvested credentials against the host.
Cron abuse on Linux is conceptually identical — modification of /var/spool/cron/crontabs/<user> or drops into /etc/cron.d/ by non-root users or by root at unusual times. For the cron side of the house, see the T1053.003 detection. macOS defenders should also watch T1053.001 launchd for LaunchAgent and LaunchDaemon plist persistence.
4. Hunting for modified / hidden tasks
The most insidious scheduled task technique isn’t creation — it’s hiding. The Tarrask malware, attributed to HAFNIUM, demonstrated a technique that has since been widely copied: delete the SD (Security Descriptor) registry value under HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Schedule\TaskCache\Tree\<TaskName>. Without the SD value, the task is invisible to schtasks /query, the Task Scheduler GUI, and most EDR enumeration APIs — but it still executes.
This hunting query from the T1053.005 detection catches that exact pattern with near-zero false positives:
// Hunt for tasks missing the SD (Security Descriptor) value — hidden task indicator
DeviceRegistryEvents
| where Timestamp > ago(7d)
| where RegistryKey has "\\Schedule\\TaskCache\\Tree\\"
| where ActionType == "RegistryValueDeleted"
| where RegistryValueName == "SD"
| project Timestamp, DeviceName, InitiatingProcessAccountName,
InitiatingProcessFileName, InitiatingProcessCommandLine,
RegistryKey, RegistryValueName
| sort by Timestamp desc
What this catches: Any deletion of the SD value under TaskCache\Tree. There is no legitimate reason for this value to be removed — Windows itself never deletes it, and no mainstream administrative tool touches it. Every hit is either a red team exercise or an active intrusion. Response should be immediate: hunt for the task payload, identify the initiating process, and assume SYSTEM-level compromise (the technique requires SYSTEM privileges to succeed).
For the SPL equivalent and additional hunts for task-modification events and high-frequency task execution patterns — both of which catch beaconing implants hidden inside legitimate-looking tasks — see the full T1053.005 detection page.
5. The 4698/4700/4702 event log approach
Process-creation telemetry is the preferred signal because it’s fast and high-fidelity, but it has blind spots. If an attacker uses WMI, COM, or direct registry manipulation to create a task, there may be no schtasks.exe process to catch. That’s where the Windows Security audit events come in:
- 4698 — Scheduled Task Created. Contains the full task XML, including action, trigger, and principal.
- 4699 — Scheduled Task Deleted. Often fires after a malicious task executes its payload and cleans up.
- 4700 — Scheduled Task Enabled.
- 4701 — Scheduled Task Disabled.
- 4702 — Scheduled Task Updated.
These events only fire if you explicitly enable Audit Other Object Access Events under Advanced Audit Policy Configuration. Many environments leave it disabled by default, which is one of the most common SOC blind spots. Turn it on.
Once audit data is flowing, the T1053 parent detection correlates process creation with audit events using a combined SPL query:
// T1053 — Scheduled Task/Job: Multi-source SPL detection
(
(index=wineventlog sourcetype="XmlWinEventLog:Microsoft-Windows-Sysmon/Operational" EventCode=1
(Image="*\\schtasks.exe" OR Image="*\\at.exe"))
OR
(index=wineventlog sourcetype="WinEventLog:Security" EventCode=4698)
)
| eval source_branch=case(
sourcetype="XmlWinEventLog:Microsoft-Windows-Sysmon/Operational", "schtasks_sysmon",
sourcetype="WinEventLog:Security" AND EventCode=4698, "task_created_4698",
true(), "unknown"
)
| eval RawText=coalesce(CommandLine, Message, "")
| eval RawTextLower=lower(RawText)
| eval RunAsSystem=if(
match(RawTextLower, "(/ru\s+system|/ru\s+\"nt authority|<userid>s-1-5-18</userid>|userid.*system)"),
1, 0
)
| eval SuspiciousPath=if(
match(RawTextLower, "(appdata|\\\\temp\\\\|\\\\tmp\\\\|\\\\public\\\\|c:\\\\programdata\\\\|users\\\\public)"),
1, 0
)
| eval RemoteTask=if(
match(RawTextLower, "/s\s+[a-z0-9\-_\.]+"),
1, 0
)
| eval ScriptExecution=if(
match(RawTextLower, "(powershell|wscript\.exe|cscript\.exe|mshta\.exe|regsvr32\.exe|rundll32\.exe|cmd\.exe|certutil\.exe)"),
1, 0
)
| eval EncodedPayload=if(
match(RawTextLower, "(-encodedcommand|-enc\s|-e\s|-ec\s|frombase64string)"),
1, 0
)
| eval IsTaskCreationEvent=if(EventCode=4698, 1, 0)
| eval SuspicionScore=RunAsSystem + SuspiciousPath + RemoteTask + ScriptExecution + EncodedPayload + IsTaskCreationEvent
| where SuspicionScore > 0
| eval TaskName=coalesce(
mvindex(split(RawText, "Task Name:"), 1),
""
)
| table
_time, host, User, source_branch,
Image, CommandLine, ParentImage, ParentCommandLine,
EventCode, TaskName,
RunAsSystem, SuspiciousPath, RemoteTask, ScriptExecution, EncodedPayload, IsTaskCreationEvent,
SuspicionScore
| sort - _time
What this catches: The same multi-factor suspicion model as the KQL detection, applied uniformly across both Sysmon process creation and Security Event 4698 task creation. The source_branch field tells the analyst which path fired — a 4698 event with RunAsSystem=1 and ScriptExecution=1 but no corresponding process event means the task was created via WMI, COM, or XML import, a much more sophisticated evasion pattern than schtasks.exe /create.
SYSTEM impersonation is the critical signal. A task running as S-1-5-18 (SYSTEM) that was created by an unprivileged account indicates the attacker has already achieved privilege escalation. Correlate task creation events with Security Event 4672 (Special Logon) to identify the moment a local admin session began — if the same account’s 4672 event doesn’t match a change ticket or documented maintenance window, you have a compromised privileged account.
Scheduled tasks aren’t the only autostart persistence mechanism. Registry Run keys are the other half of the Windows persistence tree — see the T1547.001 detection for comparable coverage of Run and RunOnce abuse.
Putting it together
Effective scheduled task detection requires watching all five creation paths simultaneously: schtasks.exe, at.exe, WMI, direct registry writes, and 4698 audit events. Missing any one of them leaves attackers a clean evasion route. Our detection library treats this as a multi-branch union problem rather than a single-query problem — that’s why the T1053.005 detection ships three KQL branches and the T1053 parent detection ships a two-branch SPL.
A few operational principles that pay for themselves:
- Audit policy first. Enable “Audit Other Object Access Events” on every Windows host before you write a single detection. Without it, 4698 is invisible.
- Allowlist by parent + action, not by task name. Malware impersonates
MicrosoftEdgeUpdateall the time;ccmexec.exeas a parent is much harder to fake. - Watch the SD value. Deletion of
TaskCache\Tree\<Task>\SDis a hunting query that should run daily — it’s the single highest-fidelity scheduled task indicator and costs almost nothing to maintain. - Correlate creation with execution. Task creation without subsequent execution is just noise. Task creation followed by a child process making outbound connections is an incident.
Browse the full df00tech detection library — 700+ KQL and SPL queries mapped to MITRE ATT&CK, free forever.