the shed // windows // scheduled tasks

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:

schtasks_audit.rb

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)
Get the code

Full script + README on GitHub: ruby-devops-toolkit/schtasks-audit

Flow diagram of the scheduled task audit

Query, parse, classify, report.
Background

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.

Setup

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 --input on a saved dump
  • Run from an elevated prompt to see tasks owned by other accounts
Full script

The complete code

schtasks_audit.rbruby
#!/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
Step by step

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.

Run it

Example output

$ 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)
When it breaks

Troubleshooting

  • schtasks query failed: you are not on Windows or the shell lacks schtasks; use --input with a dump.
  • Localised Windows: column headers are translated; run chcp 437 or 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 schtasks call 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.
Next steps

Extending the script

  • Resolve each executable’s ACL with icacls and 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.

Tha-Shed

Type a brief, get a finished film. Characters, voices and music included.

Start creating