Windows Security: Detecting malicious scheduled tasks

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-ScheduledTask plus Export-ScheduledTask exposes 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

Authoritative reference: MITRE ATT&CK T1053.005 — Scheduled Task.

Editorial note: posts on this blog are drafted with AI assistance and then reviewed, edited, and tested against a real environment before publishing. Commands, output, and screenshots come from systems I actually ran the work on.

PowerShell Security Monitoring Threat Hunting Windows
SecurityScriptographer author

About the author

SecurityScriptographer is written and maintained by one person — a defender who builds and tests the detections, scripts, and Microsoft 365 workflows here before publishing them. More about me · @twi_nox

0 comments:

Post a Comment