Scheduled tasks that run as SYSTEM from a user-writable folder are a classic privilege-escalation path. This Ruby script parses schtasks /query /v output and surfaces them in seconds.
Step through the build below:
On Windows, a task that runs as SYSTEM and launches C:\Users\bob\AppData\Local\Temp\backup.exe hands SYSTEM to anyone who can write that file. Unquoted executable paths with spaces create a similar hijack opportunity. Both are common in real fleets and invisible in the Task Scheduler GUI unless you click through hundreds of tasks.
schtasks /query /fo CSV /v dumps every task with its run-as account, command line and last result. Ruby’s csv library makes it easy to apply rules to that dump.
# schtasks_audit.rb – complete script
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# schtasks_audit.rb - Audit Windows Scheduled Tasks for privilege-escalation risks.
#
# Runs `schtasks /query /fo CSV /v` (or reads a saved CSV with --input) and flags:
# HIGH task runs as SYSTEM/Administrator but its executable lives in a
# user-writable location (C:\Users, \Temp, \AppData, C:\ProgramData)
# HIGH unquoted executable path containing spaces (classic path-hijack)
# MEDIUM task runs as SYSTEM and its last run failed
# LOW enabled task whose author is not Microsoft and runs as SYSTEM
# The parser and rules are pure Ruby and run on any OS; only the live query needs Windows.
require 'csv'
require 'optparse'
require 'json'
PRIVILEGED = /\A(NT AUTHORITY\\SYSTEM|SYSTEM|.*\\Administrator|Administrator|NT AUTHORITY\\LOCAL SERVICE|NT AUTHORITY\\NETWORK SERVICE)\z/i
WRITABLE = %r{(\\users\\|\\temp\\|\\tmp\\|\\appdata\\|\\programdata\\|\\downloads\\|\\public\\)}i
Finding = Struct.new(:severity, :task, :run_as, :command, :reason)
# Split `"C:\Program Files\x\a.exe" -arg` or `C:\tools\a.exe -arg` into [exe, args].
def split_command(cmd)
cmd = cmd.to_s.strip
return [cmd[1..cmd.index('"', 1) - 1], cmd[(cmd.index('"', 1) + 1)..].to_s.strip] if cmd.start_with?('"') && cmd.index('"', 1)
m = cmd.match(/\A(.+?\.(?:exe|bat|cmd|ps1|vbs|com))(?:\s+(.*))?\z/i)
m ? [m[1], m[2].to_s] : [cmd, '']
end
# `schtasks /v` repeats the header row before each task and emits several rows per
# multi-trigger task; drop repeated headers and de-duplicate by task name.
def load_tasks(io)
rows = CSV.parse(io, headers: true, skip_blanks: true)
seen = {}
rows.each do |r|
next if r['TaskName'] == 'TaskName' # repeated header
seen[r['TaskName']] ||= r.to_h
end
seen.values
end
def audit_task(t)
name = t['TaskName']
run_as = t['Run As User'].to_s
cmd = t['Task To Run'].to_s
state = t['Scheduled Task State'].to_s
return [] if state =~ /disabled/i || cmd.empty? || cmd =~ /COM handler/i # COM actions have no path to audit
exe, = split_command(cmd)
privileged = run_as =~ PRIVILEGED
out = []
quoted = cmd.start_with?('"')
out << Finding.new(:high, name, run_as, cmd, 'unquoted path with spaces: Windows tries shorter paths first (C:\\Program.exe)') if !quoted && exe.include?(' ') && privileged
out << Finding.new(:high, name, run_as, cmd, "privileged task runs binary from user-writable location") if privileged && exe =~ WRITABLE
out << Finding.new(:medium, name, run_as, cmd, "privileged task last result #{t['Last Result']} (non-zero)") if privileged && t['Last Result'].to_s !~ /\A(0|267009|267011|267014)?\z/ # 0x41301 running, 0x41303 not yet run
if privileged && t['Author'].to_s !~ /microsoft/i && name !~ %r{\\Microsoft\\}i && out.empty?
out << Finding.new(:low, name, run_as, cmd, "third-party task (author: #{t['Author']}) runs with high privilege - confirm it is expected")
end
out
end
if $PROGRAM_NAME == __FILE__
opts = { input: nil, json: false }
OptionParser.new do |op|
op.on('--input FILE', 'read a saved `schtasks /query /fo CSV /v` dump') { |v| opts[:input] = v }
op.on('--json') { opts[:json] = true }
end.parse!(ARGV)
raw = opts[:input] ? File.read(opts[:input], encoding: 'bom|utf-8') : `schtasks /query /fo CSV /v`
abort 'schtasks query failed (are you on Windows?)' if raw.to_s.strip.empty?
tasks = load_tasks(raw)
findings = tasks.flat_map { |t| audit_task(t) }
order = { high: 0, medium: 1, low: 2 }
findings.sort_by! { |f| [order[f.severity], f.task] }
if opts[:json]
puts JSON.pretty_generate(findings.map(&:to_h))
else
puts "Scanned #{tasks.size} scheduled tasks, #{findings.size} findings\n\n"
findings.each do |f|
puts "[#{f.severity.to_s.upcase.ljust(6)}] #{f.task}"
puts " run as : #{f.run_as}"
puts " command: #{f.command}"
puts " why : #{f.reason}"
end
end
exit(findings.any? { |f| f.severity == :high } ? 2 : 0)
end
**Querying the scheduler** Without --input the script runs schtasks /query /fo CSV /v. With --input it reads a saved dump, which also makes the logic testable anywhere.
**Cleaning the CSV** The verbose output repeats the header row before every task and emits one row per trigger. load_tasks drops repeated headers and de-duplicates by TaskName.
**Splitting the command line** split_command separates a quoted path or a path ending in a known extension (.exe, .bat, .ps1…) from its arguments, so rules can look at the executable alone.
**The rules** A task is privileged if it runs as SYSTEM, a service account or an Administrator. Privileged tasks are HIGH if the executable is in a user-writable location or unquoted with spaces, MEDIUM if the last result is non-zero, LOW if third-party and otherwise clean. Disabled tasks and COM-handler tasks are skipped.
**Exit codes for automation** The script exits 2 when any HIGH finding exists, so you can run it from a scheduled task or CI and alert on failure.
$ ruby schtasks_audit.rb –input sample_schtasks.csv
Scanned 6 scheduled tasks, 3 findings
[HIGH ] \Backup Nightly
run as : SYSTEM
command: C:\Users\bob\AppData\Local\Temp\backup.exe /full
why : privileged task runs binary from user-writable location
[HIGH ] \Vendor Updater
run as : SYSTEM
command: C:\Program Files\Acme Updater\update.exe --silent
why : unquoted path with spaces: Windows tries shorter paths first (C:\Program.exe)
[MEDIUM] \Disk Cleanup
run as : SYSTEM
command: "C:\Program Files\Cleanup\clean.exe" /quiet
why : privileged task last result 1 (non-zero)
Full script + README on GitHub: ruby-devops-toolkit/schtasks-audit
The problem
On Windows, a task that runs as SYSTEM and launches C:\Users\bob\AppData\Local\Temp\backup.exe hands SYSTEM to anyone who can write that file. Unquoted executable paths with spaces create a similar hijack opportunity. Both are common in real fleets and invisible in the Task Scheduler GUI unless you click through hundreds of tasks.
schtasks /query /fo CSV /v dumps every task with its run-as account, command line and last result. Ruby’s csv library makes it easy to apply rules to that dump.
Prerequisites
- Ruby 3.0+ (RubyInstaller on Windows); standard library only (
csv,json,optparse) - Windows for the live query; the parser also runs on Linux/macOS with
--inputon a saved dump - Run from an elevated prompt to see tasks owned by other accounts
The complete code
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# schtasks_audit.rb - Audit Windows Scheduled Tasks for privilege-escalation risks.
#
# Runs `schtasks /query /fo CSV /v` (or reads a saved CSV with --input) and flags:
# HIGH task runs as SYSTEM/Administrator but its executable lives in a
# user-writable location (C:\Users, \Temp, \AppData, C:\ProgramData)
# HIGH unquoted executable path containing spaces (classic path-hijack)
# MEDIUM task runs as SYSTEM and its last run failed
# LOW enabled task whose author is not Microsoft and runs as SYSTEM
# The parser and rules are pure Ruby and run on any OS; only the live query needs Windows.
require 'csv'
require 'optparse'
require 'json'
PRIVILEGED = /\A(NT AUTHORITY\\SYSTEM|SYSTEM|.*\\Administrator|Administrator|NT AUTHORITY\\LOCAL SERVICE|NT AUTHORITY\\NETWORK SERVICE)\z/i
WRITABLE = %r{(\\users\\|\\temp\\|\\tmp\\|\\appdata\\|\\programdata\\|\\downloads\\|\\public\\)}i
Finding = Struct.new(:severity, :task, :run_as, :command, :reason)
# Split `"C:\Program Files\x\a.exe" -arg` or `C:\tools\a.exe -arg` into [exe, args].
def split_command(cmd)
cmd = cmd.to_s.strip
return [cmd[1..cmd.index('"', 1) - 1], cmd[(cmd.index('"', 1) + 1)..].to_s.strip] if cmd.start_with?('"') && cmd.index('"', 1)
m = cmd.match(/\A(.+?\.(?:exe|bat|cmd|ps1|vbs|com))(?:\s+(.*))?\z/i)
m ? [m[1], m[2].to_s] : [cmd, '']
end
# `schtasks /v` repeats the header row before each task and emits several rows per
# multi-trigger task; drop repeated headers and de-duplicate by task name.
def load_tasks(io)
rows = CSV.parse(io, headers: true, skip_blanks: true)
seen = {}
rows.each do |r|
next if r['TaskName'] == 'TaskName' # repeated header
seen[r['TaskName']] ||= r.to_h
end
seen.values
end
def audit_task(t)
name = t['TaskName']
run_as = t['Run As User'].to_s
cmd = t['Task To Run'].to_s
state = t['Scheduled Task State'].to_s
return [] if state =~ /disabled/i || cmd.empty? || cmd =~ /COM handler/i # COM actions have no path to audit
exe, = split_command(cmd)
privileged = run_as =~ PRIVILEGED
out = []
quoted = cmd.start_with?('"')
out << Finding.new(:high, name, run_as, cmd, 'unquoted path with spaces: Windows tries shorter paths first (C:\\Program.exe)') if !quoted && exe.include?(' ') && privileged
out << Finding.new(:high, name, run_as, cmd, "privileged task runs binary from user-writable location") if privileged && exe =~ WRITABLE
out << Finding.new(:medium, name, run_as, cmd, "privileged task last result #{t['Last Result']} (non-zero)") if privileged && t['Last Result'].to_s !~ /\A(0|267009|267011|267014)?\z/ # 0x41301 running, 0x41303 not yet run
if privileged && t['Author'].to_s !~ /microsoft/i && name !~ %r{\\Microsoft\\}i && out.empty?
out << Finding.new(:low, name, run_as, cmd, "third-party task (author: #{t['Author']}) runs with high privilege - confirm it is expected")
end
out
end
if $PROGRAM_NAME == __FILE__
opts = { input: nil, json: false }
OptionParser.new do |op|
op.on('--input FILE', 'read a saved `schtasks /query /fo CSV /v` dump') { |v| opts[:input] = v }
op.on('--json') { opts[:json] = true }
end.parse!(ARGV)
raw = opts[:input] ? File.read(opts[:input], encoding: 'bom|utf-8') : `schtasks /query /fo CSV /v`
abort 'schtasks query failed (are you on Windows?)' if raw.to_s.strip.empty?
tasks = load_tasks(raw)
findings = tasks.flat_map { |t| audit_task(t) }
order = { high: 0, medium: 1, low: 2 }
findings.sort_by! { |f| [order[f.severity], f.task] }
if opts[:json]
puts JSON.pretty_generate(findings.map(&:to_h))
else
puts "Scanned #{tasks.size} scheduled tasks, #{findings.size} findings\n\n"
findings.each do |f|
puts "[#{f.severity.to_s.upcase.ljust(6)}] #{f.task}"
puts " run as : #{f.run_as}"
puts " command: #{f.command}"
puts " why : #{f.reason}"
end
end
exit(findings.any? { |f| f.severity == :high } ? 2 : 0)
end
How it works
Querying the scheduler
Without --input the script runs schtasks /query /fo CSV /v. With --input it reads a saved dump, which also makes the logic testable anywhere.
Cleaning the CSV
The verbose output repeats the header row before every task and emits one row per trigger. load_tasks drops repeated headers and de-duplicates by TaskName.
Splitting the command line
split_command separates a quoted path or a path ending in a known extension (.exe, .bat, .ps1…) from its arguments, so rules can look at the executable alone.
The rules
A task is privileged if it runs as SYSTEM, a service account or an Administrator. Privileged tasks are HIGH if the executable is in a user-writable location or unquoted with spaces, MEDIUM if the last result is non-zero, LOW if third-party and otherwise clean. Disabled tasks and COM-handler tasks are skipped.
Exit codes for automation
The script exits 2 when any HIGH finding exists, so you can run it from a scheduled task or CI and alert on failure.
Example output
Troubleshooting
schtasks query failed: you are not on Windows or the shell lacksschtasks; use--inputwith a dump.- Localised Windows: column headers are translated; run
chcp 437or export with an English locale. - Accented characters garbled: the script reads with
bom|utf-8; re-save the dump as UTF-8. - Honest caveat: the live
schtaskscall could not be executed in the Linux test sandbox. The parser and every rule were tested against a realistic sample CSV (included); the Windows-only call is a one-line backtick.
Extending the script
- Resolve each executable’s ACL with
icaclsand flag only paths that are actually writable by Users. - Query remote hosts with
schtasks /s HOST. - Diff results against yesterday’s JSON to spot newly created tasks.
- Send HIGH findings to a webhook.