Detecting malicious scheduled tasks on Windows is mostly about knowing what "normal" looks like in your environment and recognising the small set of shapes attackers favour. Scheduled tasks are first-class persistence — they survive reboots, can run as SYSTEM, and blend in with the dozens of legitimate maintenance tasks Windows ships with. This post is the working detection logic we run on systems we administer, framed as defence against MITRE ATT&CK technique T1053.005 (Scheduled Task).
Key Takeaways
- Scheduled tasks are MITRE ATT&CK technique T1053.005; they persist across reboots and can run as SYSTEM without dropping a service.
- The high-signal red flags are: tasks running PowerShell or scripting hosts with encoded arguments, paths under
%TEMP%/%APPDATA%, generic or random names, and tasks outside\Microsoft\running as SYSTEM. Get-ScheduledTaskplusExport-ScheduledTaskexposes the XML definition, which is where the real detection signal lives.- Event IDs 4698 (task created), 4702 (task updated), and 4699 (task deleted) in the Security log give you the audit trail when Object Access auditing is enabled.
- For continuous monitoring, push these signals through Microsoft Defender for Endpoint or a SIEM. The PowerShell triage below is for authorised investigation on systems you own.
Environment
- Windows 10/11 and Windows Server 2019/2022.
- Windows PowerShell 5.1 or PowerShell 7.4.
- Local administrator rights — tasks owned by other users return partial properties otherwise.
- Advanced audit policy with Audit Other Object Access Events enabled so 4698/4699/4702 are actually generated.
The Problem
Scheduled tasks were originally a quality-of-life feature for system maintenance. Modern Windows ships with hundreds of them under \Microsoft\Windows\, which makes a single malicious task hard to spot in the GUI. Attackers exploit that noise floor: a task named "GoogleUpdateCheck" running PowerShell with an encoded argument from a folder under C:\Users\Public visually disappears in a 600-row Task Scheduler view.
The detection problem is therefore one of filtering, not enumeration. The list of suspicious shapes is small and stable. Once you query for those shapes specifically, you typically end up with a handful of rows on a clean machine and one or two clearly abnormal rows on a compromised one.
The Solution
Step 1 — Enumerate every task and project the useful properties
The default Get-ScheduledTask output omits the bits you need. Pull the action, principal, and trigger explicitly:
$tasks = Get-ScheduledTask | ForEach-Object {
$info = $_ | Get-ScheduledTaskInfo
$action = $_.Actions | Select-Object -First 1
$trigger = $_.Triggers | Select-Object -First 1
[PSCustomObject]@{
TaskName = $_.TaskName
TaskPath = $_.TaskPath
State = $_.State
Author = $_.Author
RunAs = $_.Principal.UserId
LogonType = $_.Principal.LogonType
Execute = $action.Execute
Arguments = $action.Arguments
WorkingDir = $action.WorkingDirectory
TriggerType = $trigger.GetType().Name
LastRunTime = $info.LastRunTime
NextRunTime = $info.NextRunTime
LastResult = $info.LastTaskResult
}
}
$tasks | Out-GridView
Out-GridView gives you a sortable, filterable view in seconds. Filter on RunAs = SYSTEM and TaskPath not starting with \Microsoft\ and you have already eliminated 99% of the noise.
Step 2 — Apply the standard suspicious-shape filter
The same set of signals catches most off-the-shelf and several bespoke implants:
$badArg = '(-enc|-encodedcommand|frombase64string|downloadstring|downloadfile|-w hidden|-windowstyle hidden|iex |invoke-expression)'
$badDir = '(temp|appdata|programdata|users\\public|recycle|perflogs)'
$badExec = '(powershell|pwsh|cmd|wscript|cscript|mshta|rundll32|regsvr32|certutil|bitsadmin)'
$tasks | Where-Object {
($_.TaskPath -notmatch '^\\Microsoft\\' -and $_.RunAs -in 'SYSTEM','LOCALSYSTEM','S-1-5-18') -or
($_.Arguments -match $badArg) -or
($_.Execute -match $badDir) -or
($_.Execute -match $badExec -and $_.TaskPath -notmatch '^\\Microsoft\\')
} | Format-Table TaskName, TaskPath, RunAs, Execute, Arguments -AutoSize
None of these matches is automatically malicious. Microsoft itself ships SYSTEM-context PowerShell tasks. The filter narrows the field; the human decides.
Step 3 — Pull the full task XML for anything interesting
The XML representation is the most complete view and is what gets shipped if you export a task from one host to another. It exposes triggers, principals, and conditions that the cmdlets do not surface:
[xml]$xml = Export-ScheduledTask -TaskName 'GoogleUpdateCheck' -TaskPath '\Microsoft\Windows\Custom\'
$xml.Task.RegistrationInfo.Author
$xml.Task.RegistrationInfo.Date
$xml.Task.Actions.Exec.Command
$xml.Task.Actions.Exec.Arguments
$xml.Task.Principals.Principal.UserId
$xml.Task.Triggers.LogonTrigger
The RegistrationInfo.Date stamp is particularly useful — it gives you the task creation time even on hosts where the 4698 audit event has rolled out of the Security log.
Step 4 — Cross-reference with audit events
When Object Access auditing is on, every task creation generates a 4698 in the Security log. The event includes the user who created the task and the full XML:
Get-WinEvent -FilterHashtable @{
LogName = 'Security'
Id = 4698, 4702
StartTime = (Get-Date).AddDays(-30)
} -ErrorAction SilentlyContinue |
Select-Object TimeCreated,
@{Name='Action';Expression={ if ($_.Id -eq 4698) {'Created'} else {'Updated'} }},
@{Name='User'; Expression={ $_.Properties[1].Value }},
@{Name='Task'; Expression={ $_.Properties[5].Value }}
Tasks created by users other than SYSTEM, TrustedInstaller, or your admin accounts are worth a closer look. Tasks created seconds after a known suspicious logon are worth a much closer look.
Step 5 — Verify the binary signature behind the task
A task that runs powershell.exe is fine; a task that runs an unsigned binary from C:\Users\Public is not. Resolve the action path and run an Authenticode check:
$tasks |
Where-Object Execute |
ForEach-Object {
$path = [Environment]::ExpandEnvironmentVariables($_.Execute)
if (Test-Path $path) {
$sig = Get-AuthenticodeSignature $path
[PSCustomObject]@{
TaskName = $_.TaskName
TaskPath = $_.TaskPath
ExePath = $path
Signer = $sig.SignerCertificate.Subject
SigStatus = $sig.Status
}
}
} |
Where-Object SigStatus -ne 'Valid'
Unsigned binaries called from scheduled tasks are uncommon enough on a well-managed Windows server to deserve attention every time.
Step 6 — Run the tasks under managed identities you control
Defensively, the right side of this problem is making sure your own scheduled tasks run under Group Managed Service Accounts rather than plain domain users. gMSA passwords rotate automatically, are 240 characters, and never appear in scripts or runbooks. This narrows what an attacker can impersonate when they pop a host:
$action = New-ScheduledTaskAction -Execute 'PowerShell.exe' `
-Argument '-File C:\Scripts\Maintenance.ps1'
$trigger = New-ScheduledTaskTrigger -Daily -At 3am
$principal = New-ScheduledTaskPrincipal -UserID 'CORP\gMSA_Maintenance$' -LogonType Password
Register-ScheduledTask -TaskName 'Maintenance' `
-Action $action `
-Trigger $trigger `
-Principal $principal
The Windows services hardening guide linked below covers the gMSA setup end to end.
Frequently Asked Questions
How are scheduled tasks different from services for persistence?
Services run continuously and are visible in services.msc. Scheduled tasks fire on triggers (time, logon, event) and live in the Task Scheduler. Attackers favour tasks because they blend into the hundreds of legitimate Microsoft tasks and can run as SYSTEM without registering a service.
What is the difference between Event IDs 4698, 4699, and 4702?
4698 is a task created, 4699 is a task deleted, 4702 is a task updated. All three require Object Access auditing to be enabled. The event payload contains the full XML, including the new action and trigger.
Why does my query miss tasks I can see in the GUI?
Most common cause: tasks under a subfolder you did not recurse into. Get-ScheduledTask already enumerates recursively by default, but if you filtered on TaskPath you may have excluded them. Second most common: lack of elevation — tasks in \Microsoft\Windows\ may return without their action set when read unelevated.
Can attackers create tasks without admin rights?
Yes. A standard user can create tasks in their own profile (\Users\<name> path under task scheduler) that run on logon under their own credentials. SYSTEM-context tasks require local admin. For T1053.005 attribution, look at the principal's logon type and SID.
What is a sensible baseline to compare against?
Export the current task list from a clean reference image and store the result as the known-good baseline. Compare every audited host against it weekly. Any task present on the live host but missing from the baseline is by definition an addition, regardless of whether the task itself looks malicious.
Conclusion
Scheduled tasks are not exotic. Detection is mostly disciplined filtering: query for the four or five shapes that matter, cross-reference creation events from the Security log, verify the signature on the action binary, and compare against a clean baseline. The trick is to do it on a schedule, not just during incident response. By the time an attacker is creating tasks for persistence, the rest of the chain is usually already in motion.
Related Posts
- Windows Security: Best Practices for Securing Windows Services — the same hardening logic applied to the related persistence surface.
- Windows Security: Registry Forensics – Where Attackers Hide — registry persistence covered alongside scheduled tasks.
- Essential Windows Event IDs for Security Monitoring — the wider event-ID context for 4698/4699/4702.
Authoritative reference: MITRE ATT&CK T1053.005 — Scheduled Task.