Scheduled task persistence exposes three detection surfaces. Creation is the earliest and richest, the command line carries everything you need to score the task before it executes.
Red Canary's Threat Detection Report has ranked scheduled tasks the single most prevalent persistence technique across all incidents, year after year. Not registry keys, not services, not WMI. Scheduled tasks. The reason is boring and durable: schtasks.exe ships on every Windows host, task creation looks like ordinary administration, and a task survives reboots, credential resets, and the analyst who cleaned up "the malware" without checking what re-spawns it at logon.
That last point is why this technique matters more than its simplicity suggests. You isolate the host, kill the malicious process, reset the account, and close the incident. Fifteen minutes later the task fires on schedule, re-downloads the payload, and the attacker is back. The task was the persistence; the process you killed was just the current instance of it. If your detection fires on the running payload and not on the task that launches it, you are cleaning up symptoms.
This post builds the detection that catches the task itself, at the moment it is created, with the same logic expressed for Sentinel (KQL), Splunk (SPL), and any SIEM (Sigma). The goal is a rule you can deploy this week and a scoring model that keeps it from drowning your admins' legitimate tasks in false positives.
Why creation is the surface that matters
A scheduled task has three moments where it touches your telemetry: when it is created, when it is modified, and when it fires. Most teams instrument only the third, because that is where the malicious behavior lives, the payload running, the network connection opening. The problem is timing. By the time the task executes, the attacker's persistence is already established and the first C2 check-in has already happened. You are detecting the intrusion after it has succeeded at the thing the task existed to do.
Creation is the better surface for one concrete reason: the command line carries the whole story. schtasks /create names the task, points it at a payload, sets a frequency, and specifies the account it runs as, all in a single process event you can see the instant it runs. That is four independent signals in one record, before the task has fired even once. Compare that to the execution surface, where you see a child process spawn but have to work backwards to prove a task launched it rather than a user. At creation, the intent is written into the command line in plain text.
Modification (Event ID 4702) is the surface almost nobody instruments, and attackers know it. Instead of creating a new task that a creation rule would catch, they take an existing legitimate task, one already on your baseline, already trusted, and repoint its action at a new payload. The task name never changes. The creating account never changes. Nothing a creation-based rule watches for moves. The only signal is that the task's action now points somewhere it did not yesterday, and that shows up in 4702, not 4698. It is a lower-volume event and a lower-volume rule, but it is the gap that lets a patient attacker persist through a detection program built entirely on creation. Instrument it as a separate rule, keyed on any task whose updated action resolves to a user-writable path, and you close the hole. Creation is where you start; modification is where you finish.
The scoring model: two indicators, not one
The naive rule, alert on any schtasks /create, is useless. Your IT team creates scheduled tasks constantly: maintenance scripts, update checks, backup jobs. A rule that fires on all of them trains your analysts to close the alert without reading it, which is worse than no rule.
The fix is scoring. Four independent indicators, each worth one point, and an alert threshold of two:
- Suspicious payload path. The task executes a binary from
\Users\,\Temp\,\AppData\,\Downloads\,\ProgramData\, or\Public\. Legitimate tasks run from Program Files and System32. - Suspicious command. The action invokes
powershell,cmd.exe /c,mshta,wscript,cscript,certutil, orrundll32, the living-off-the-land interpreters attackers use to avoid dropping a distinctly-named binary. - High frequency. The schedule fires every minute or hourly. Attacker C2 check-ins are frequent; legitimate maintenance tasks usually run daily or weekly.
- Non-SYSTEM creator. The account creating the task is a regular user, not SYSTEM or a service account.
Why a threshold of two and not one? Because any single indicator has a legitimate explanation. A non-admin creating a task is normal when IT pushes a maintenance script. A task running from ProgramData is normal when a vendor tool installs there. But a non-SYSTEM account creating a high-frequency task pointed at PowerShell in ProgramData, that combination has no legitimate story. Two independent indicators pointing the same direction is the line where investigation is warranted. A score of three or four is near-certain compromise.
The detection: KQL for Sentinel and Defender XDR
This queries DeviceProcessEvents, the Defender for Endpoint table available through the Defender XDR connector. It extracts the task name, action, and frequency from the command line, scores the four indicators, and alerts at two or above.
// Scheduled task creation: score four indicators, alert at >= 2
// Hypothesis: a task created by a non-SYSTEM account, running a binary
// from a non-standard path at high frequency, is attacker persistence.
let suspiciousPaths = dynamic(["\\Users\\", "\\Temp\\", "\\AppData\\",
"\\Downloads\\", "\\ProgramData\\", "\\Public\\"]);
let suspiciousCommands = dynamic(["powershell", "pwsh", "cmd.exe /c",
"mshta", "wscript", "cscript", "certutil", "rundll32"]);
DeviceProcessEvents
| where TimeGenerated > ago(1d)
| where FileName =~ "schtasks.exe"
| where ProcessCommandLine has "/create" or ProcessCommandLine has "-create"
| extend TaskAction = extract("(?i)/tr\\s+[\"']?([^\"']+)[\"']?", 1, ProcessCommandLine),
TaskName = extract("(?i)/tn\\s+[\"']?([^\"']+)[\"']?", 1, ProcessCommandLine),
TaskFreq = extract("(?i)/sc\\s+(\\w+)", 1, ProcessCommandLine)
| extend SuspPath = TaskAction has_any (suspiciousPaths),
SuspCmd = TaskAction has_any (suspiciousCommands),
HighFreq = TaskFreq in~ ("minute", "hourly"),
NonSystem = AccountName !in~ ("system", "network service", "local service")
| extend Score = toint(SuspPath) + toint(SuspCmd) + toint(HighFreq) + toint(NonSystem)
| where Score >= 2
| project TimeGenerated, DeviceName, AccountName, TaskName, TaskAction, TaskFreq, Score, ProcessCommandLineIf you run Windows Security Events into Sentinel rather than MDE, the same creation event is Event ID 4698 (a scheduled task was created) in the SecurityEvent table. It carries less structure than the MDE command line, the task definition arrives as embedded XML you have to parse, but the creating account and task name are there. Keep SecurityEvent at the analytics tier if you rely on it; the basic ingestion tier cannot be queried by scheduled analytics rules.
The same logic in SPL for Splunk
Splunk teams pulling Sysmon Event ID 1 (process creation) or Windows 4688 into the endpoint data model express the identical scoring. This assumes Sysmon process-creation events normalized to CIM:
index=endpoint (process_name="schtasks.exe" OR Image="*\\schtasks.exe")
(process="*/create*" OR process="*-create*")
| rex field=process "(?i)/tr\s+[\"']?(?<task_action>[^\"']+)"
| rex field=process "(?i)/tn\s+[\"']?(?<task_name>[^\"']+)"
| rex field=process "(?i)/sc\s+(?<task_freq>\w+)"
| eval susp_path=if(match(task_action, "(?i)\\\\(Users|Temp|AppData|Downloads|ProgramData|Public)\\\\"), 1, 0)
| eval susp_cmd=if(match(task_action, "(?i)(powershell|pwsh|cmd\.exe /c|mshta|wscript|cscript|certutil|rundll32)"), 1, 0)
| eval high_freq=if(match(task_freq, "(?i)(minute|hourly)"), 1, 0)
| eval non_system=if(match(user, "(?i)(system|network service|local service)"), 0, 1)
| eval score=susp_path + susp_cmd + high_freq + non_system
| where score >= 2
| table _time, host, user, task_name, task_action, task_freq, score, processThe rex extractions mirror the KQL extract calls exactly. The data source you have determines how much of this works. Sysmon Event ID 1 gives you the full command line, so all four indicators extract cleanly. Windows 4688 process creation gives you the command line only if you have enabled the "Include command line in process creation events" audit policy, without it, 4688 records the process name but not its arguments, and the whole scoring model collapses because every indicator lives in the arguments. Check that policy before you rely on 4688; it is off by default on most builds.
If your Splunk environment ingests the Windows Security log's task events rather than process creation, key on EventCode=4698 and parse the task definition from the event's XML Data fields instead of the command line. The task name, the action path, and the trigger all live in that XML, so the same four indicators are recoverable, they just require field extraction against the event XML rather than a command-line regex. The scoring logic does not change; only where you read the inputs from does.
Sigma for portability
If you maintain detections as Sigma and compile to whichever backend you run, this rule expresses the creation signal at its simplest. Scoring logic like the above does not translate cleanly to Sigma's condition syntax, so the portable version flags the highest-signal combination, a schtasks /create writing to a user-writable path, and leaves the multi-indicator scoring to the platform-native versions above.
title: Suspicious Scheduled Task Creation From User-Writable Path
id: 8f3e2a11-6c4d-4e9a-b2f1-scheduledtaskcreate
status: experimental
description: Detects schtasks.exe creating a task whose action runs from a user-writable directory, a common persistence pattern (T1053.005).
references:
- https://attack.mitre.org/techniques/T1053/005/
logsource:
category: process_creation
product: windows
detection:
selection_img:
Image|endswith: '\schtasks.exe'
selection_create:
CommandLine|contains:
- '/create'
- '-create'
selection_path:
CommandLine|contains:
- '\Users\'
- '\Temp\'
- '\AppData\'
- '\ProgramData\'
- '\Public\'
condition: selection_img and selection_create and selection_path
falsepositives:
- Software installers that register tasks pointing at ProgramData
- Admin scripts staged in user directories
level: medium
tags:
- attack.persistence
- attack.t1053.005Tune the falsepositives out with an allowlist of your known-good task names and installer paths, do not lower the level. A medium-severity rule with a tight allowlist beats a low-severity rule nobody reviews.
How attackers hide, and why baselining beats blocklists
The naming is the tell, and also the trap. Attackers name tasks to blend into the Task Scheduler tree: nesting under \Microsoft\Windows\ paths that do not exist in a clean install, using fabricated-but-plausible names like SystemHealthMonitor, TelemetryCollection, or WindowsDefenderUpdate, or mimicking your own org's naming convention so the task reads as IT-managed. An analyst scanning the task list sees the fake \Microsoft\Windows\NetTrace\NetLogon alongside real Microsoft tasks and moves on.
You cannot win this by recognizing bad names, that requires knowing every legitimate Windows task, and there are hundreds. The counter is inversion: maintain a known-good baseline of tasks per host or per image, and flag anything not on it, regardless of what it is called. That turns "detect the suspicious" (impossible to enumerate) into "flag the unknown" (a finite comparison). The scoring rule above catches the creation; the baseline catches the task that was created before you deployed the rule, or modified in place to dodge it.
What to do this week
- Run the KQL query against your last 30 days with
ago(30d)instead ofago(1d). Every result at score 2+ is a task worth explaining. You will find your own admins' tasks, allowlist them by name, and whatever is left is your starting hunt. - Deploy the creation rule as a scheduled analytics rule at 15-minute cadence, mapping Host to
DeviceNameand Account toAccountNameso Sentinel correlates it with any other alert on the same host or account into one incident. - Add the modification rule you are almost certainly missing. Alert on Event ID 4702 (scheduled task updated) for any task whose action now points at a user-writable path. This is the surface attackers use to dodge creation-based detection, and most teams have zero coverage of it.
- Baseline your critical servers. Export
Get-ScheduledTaskfrom your domain controllers and key application servers now, while they are known-good. Any task that appears later and is not on that list is a hunt lead, no scoring required. - Check what re-spawns after cleanup. On your next endpoint incident, before you close it, enumerate scheduled tasks and services created or modified in the incident window. The persistence that survives your cleanup is the reason the same host reappears in your queue next month.
Catching the running payload tells you the attacker is inside. Catching the scheduled task tells you how they plan to stay, and lets you remove the thing that brings them back.