Task Scheduler is one of the oldest privilege-escalation tricks in the Windows playbook: a SYSTEM-owned task that launches a binary from a folder a normal user can write to. This tutorial builds a WIN32OLE auditor that walks every task on the box and flags exactly that — plus the unquoted-path hijack that’s been in Windows hardening checklists for over a decade.
Step through the build below:
Two Task Scheduler misconfigurations show up over and over in real-world privilege escalation writeups. First: an action path with a space in it, launched unquoted — C:\Program Files\Vendor App\run.exe without surrounding quotes lets Windows try C:\Program.exe first, and anyone who can write to C:\ owns the task. Second: a task running as SYSTEM that executes a script sitting in a folder a low-privileged user can write to — edit the script, wait for the schedule to fire, and you’re SYSTEM too.
Neither of these is visible from the Task Scheduler GUI without checking every task by hand. This script walks the whole tree via the Schedule.Service COM API and flags both patterns automatically, plus a third: hidden tasks requesting the highest available privilege level — a common stealth-persistence combination worth a second look even when nothing else fires.
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# scheduled_task_audit.rb
#
# Audits Windows Task Scheduler tasks for privilege-escalation risks by
# walking the Task Scheduler COM API (Schedule.Service) via WIN32OLE. Task
# Scheduler is a favorite persistence/privilege-escalation mechanism for
# attackers because a task that runs as SYSTEM but launches a binary from a
# directory a low-privileged user can write to is a straight line from
# "local user" to "SYSTEM" -- and because unquoted paths with spaces let an
# attacker plant an evil executable earlier in the path.
#
# Checks performed on every task:
# 1. Unquoted action path containing spaces (classic unquoted-path hijack:
# "C:\Program Files\Vendor App\run.exe" launched unquoted lets an
# attacker place C:\Program.exe or C:\Program Files\Vendor.exe).
# 2. Action executable located under a directory that is commonly
# user-writable (Temp, Users profile paths, ProgramData root, Public).
# 3. Task runs as SYSTEM (or with "Highest" run level) while its action
# executable lives outside the trusted C:\Windows or
# C:\Program Files trees.
# 4. Task is both hidden and elevated -- a common stealth-persistence
# pattern worth a second look even when nothing else is wrong.
#
# Because Schedule.Service is a Windows-only COM object, this script cannot
# run on Linux/macOS at all. The repository's test suite instead exercises
# `evaluate_task`, the pure risk-scoring function, against realistic fixture
# hashes shaped exactly like what `TaskFetcher#fetch_tasks` builds from the
# live COM objects -- see scheduled_task_audit_test.rb. That test harness
# runs anywhere Ruby runs, including this Linux sandbox; the WIN32OLE
# integration itself was verified by code review against the documented
# Schedule.Service object model (Microsoft's ITaskService/IRegisteredTask/
# IPrincipal/IExecAction interfaces -- see the References section of the
# README) since a live Windows host was not available in this environment.
#
# Usage (on Windows, ideally an elevated prompt so hidden/system tasks are
# visible):
# ruby scheduled_task_audit.rb
# ruby scheduled_task_audit.rb --folder "\\Microsoft\\Windows\\UpdateOrchestrator"
# ruby scheduled_task_audit.rb --json
#
# Exit codes (cron/CI/Task Scheduler friendly):
# 0 - no findings
# 1 - WARN-level findings only
# 2 - CRIT-level findings present
require 'optparse'
require 'json'
require 'time'
Finding = Struct.new(:severity, :task, :check, :detail) do
def to_h
{ severity: severity.to_s, task: task, check: check, detail: detail }
end
end
SEVERITY_RANK = { info: 0, warn: 1, crit: 2 }.freeze
# Directories that are commonly writable by non-administrator users on a
# default Windows install. Not exhaustive -- a real hardened audit should
# also check the actual ACL via icacls/Get-Acl, but this catches the
# overwhelming majority of real-world misconfigurations cheaply.
USER_WRITABLE_HINTS = [
%r{\\Users\\[^\\]+\\}i,
%r{\\Temp\\}i,
%r{\\AppData\\}i,
%r{^[A-Z]:\\ProgramData\\}i,
%r{\\Public\\}i,
%r{\\Downloads\\}i
].freeze
TRUSTED_ROOTS = [
%r{^[A-Z]:\\Windows\\}i,
%r{^[A-Z]:\\Program Files\\}i,
%r{^[A-Z]:\\Program Files \(x86\)\\}i
].freeze
# ---------------------------------------------------------------------------
# Pure risk-scoring logic -- no WIN32OLE dependency, fully unit-testable.
#
# task is a Hash shaped like:
# {
# name: String, path: String, enabled: Boolean, hidden: Boolean,
# run_as: String, run_level: "Highest" | "LUA",
# actions: [{ execute: String, arguments: String }]
# }
# ---------------------------------------------------------------------------
def evaluate_task(task)
findings = []
name = task[:path] || task[:name]
task[:actions].each do |action|
exe = action[:execute].to_s
next if exe.empty?
if unquoted_with_space?(exe)
findings << Finding.new(:crit, name, 'unquoted-action-path',
"Action executable '#{exe}' contains a space and is not " \
'quoted. An attacker able to write to an earlier path ' \
'segment can hijack execution.')
end
if USER_WRITABLE_HINTS.any? { |re| exe =~ re }
findings << Finding.new(:crit, name, 'writable-action-directory',
"Action executable '#{exe}' lives under a directory that " \
'is commonly writable by non-admin users.')
end
runs_privileged = task[:run_as].to_s.casecmp('SYSTEM').zero? || task[:run_level].to_s == 'Highest'
if runs_privileged && !TRUSTED_ROOTS.any? { |re| exe =~ re }
findings << Finding.new(:warn, name, 'privileged-task-untrusted-path',
"Task runs as #{task[:run_as]} (run level: #{task[:run_level]}) " \
"but its action executable '#{exe}' is outside the trusted " \
'Windows/Program Files trees.')
end
end
if task[:hidden] && task[:run_level].to_s == 'Highest'
findings << Finding.new(:warn, name, 'hidden-and-elevated',
'Task is marked hidden and requests the highest available run ' \
'level -- a common stealth-persistence combination worth a ' \
'manual look even if nothing else fires.')
end
findings
end
def unquoted_with_space?(exe)
return false unless exe.include?(' ')
return false if exe.start_with?('"') && exe.rstrip.end_with?('"')
# A bare executable path with no arguments and no spaces before the .exe
# extension is fine; flag only when the space appears before we've hit the
# executable extension, i.e. the path itself (not just its arguments) has
# unquoted whitespace.
path_part = exe[/\A[^"]*?\.(exe|bat|cmd|ps1|vbs)\b/i]
return false unless path_part
path_part.include?(' ')
end
# ---------------------------------------------------------------------------
# WIN32OLE integration -- only loaded/used on Windows.
# ---------------------------------------------------------------------------
class TaskFetcher
RUN_LEVEL_MAP = { 0 => 'LUA', 1 => 'Highest' }.freeze
def initialize(root_folder: '\\')
require 'win32ole'
@service = WIN32OLE.new('Schedule.Service')
@service.Connect
@root_folder = root_folder
end
def fetch_tasks
tasks = []
walk_folder(@service.GetFolder(@root_folder), tasks)
tasks
end
private
def walk_folder(folder, tasks)
folder.GetTasks(1).each do |task|
tasks << build_task_hash(folder, task)
end
folder.GetFolders(0).each { |sub| walk_folder(sub, tasks) }
end
def build_task_hash(folder, task)
definition = task.Definition
principal = definition.Principal
actions = definition.Actions.each.map do |a|
{ execute: a.Path.to_s, arguments: a.Arguments.to_s }
end
{
name: task.Name,
path: task.Path,
enabled: task.Enabled,
hidden: definition.Settings.Hidden,
run_as: principal.UserId.to_s,
run_level: RUN_LEVEL_MAP.fetch(principal.RunLevel, 'Unknown'),
actions: actions
}
end
end
def parse_options(argv)
opts = { folder: '\\', json: false }
parser = OptionParser.new do |o|
o.banner = 'Usage: ruby scheduled_task_audit.rb [options]'
o.on('--folder PATH', 'Task Scheduler folder to start from (default: \\ = root, recurses)') { |v| opts[:folder] = v }
o.on('--json', 'Emit machine-readable JSON instead of text') { opts[:json] = true }
o.on('-h', '--help', 'Show this help') do
puts o
exit 0
end
end
parser.parse!(argv)
opts
end
def print_text_report(findings, task_count)
puts "scheduled_task_audit: scanned #{task_count} task(s), #{findings.size} finding(s)"
puts '-' * 72
if findings.empty?
puts 'No issues found.'
return
end
%i[crit warn info].each do |sev|
group = findings.select { |f| f.severity == sev }
next if group.empty?
puts "\n[#{sev.to_s.upcase}] (#{group.size})"
group.each do |f|
puts " - #{f.task}: #{f.check}"
puts " #{f.detail}"
end
end
end
if __FILE__ == $PROGRAM_NAME
options = parse_options(ARGV)
unless RUBY_PLATFORM =~ /mingw|mswin|windows/i
warn 'scheduled_task_audit.rb requires Windows (Schedule.Service via WIN32OLE is ' \
'not available on this platform). See scheduled_task_audit_test.rb for the ' \
'platform-independent logic test.'
exit 3
end
tasks = TaskFetcher.new(root_folder: options[:folder]).fetch_tasks
findings = tasks.flat_map { |t| evaluate_task(t) }
if options[:json]
puts JSON.pretty_generate(
scanned_at: Time.now.utc.iso8601,
task_count: tasks.size,
finding_count: findings.size,
findings: findings.map(&:to_h)
)
else
print_text_report(findings, tasks.size)
end
worst = findings.map { |f| SEVERITY_RANK[f.severity] }.max || -1
exit(worst >= SEVERITY_RANK[:crit] ? 2 : worst >= SEVERITY_RANK[:warn] ? 1 : 0)
end
The script is split cleanly into a WIN32OLE-dependent half and a pure-Ruby half, and that split is the whole design. TaskFetcher talks to Schedule.Service, recursively walking folders with GetFolders(0)/GetTasks(1) and flattening each task’s Definition.Principal and Definition.Actions into a plain Hash via build_task_hash. evaluate_task(hash) then runs the four risk checks against that Hash with zero WIN32OLE calls anywhere in it.
That split matters because Schedule.Service is a Windows-only COM object — it cannot be exercised on Linux or macOS at all, at any layer. Because evaluate_task takes a plain Hash instead of a live COM object, it can be unit-tested anywhere Ruby runs by feeding it fixture hashes shaped exactly like what build_task_hash produces. That is exactly what scheduled_task_audit_test.rb does — eight scenarios, eleven assertions, zero WIN32OLE, fully green in this Linux sandbox.
The unquoted-path check itself is narrower than “contains a space”: it only flags a space that appears before the executable extension (.exe/.bat/.cmd/.ps1/.vbs), so a fully-quoted path or a bare path with space-separated arguments after it doesn’t false-positive.
scheduled_task_audit_test.rb -- exercising evaluate_task() against WIN32OLE-shaped fixtures ============================================================================== [1] Healthy task: trusted path, quoted, not hidden PASS no findings for a clean SYSTEM task in C:\Windows\System32 [2] Unquoted path with a space -- classic hijack vector PASS flags unquoted-action-path PASS severity is crit [3] Same path, properly quoted -- should NOT flag unquoted-action-path PASS does not flag a properly quoted path [4] SYSTEM task launching a script from a user-writable Temp directory PASS flags writable-action-directory PASS also flags privileged-task-untrusted-path (SYSTEM + outside trusted roots) [5] Non-privileged task in an untrusted path -- should NOT trigger the privileged check PASS does not flag privileged-task-untrusted-path for a non-privileged user task PASS still flags writable-action-directory (it is under \Users\) [6] Hidden + highest run level -- stealth persistence pattern PASS flags hidden-and-elevated [7] Hidden but LUA (not elevated) -- should NOT flag hidden-and-elevated PASS does not flag hidden-and-elevated when run level is LUA [8] Multiple actions on one task -- each action evaluated independently PASS flags exactly one writable-action-directory (second action only) ============================================================================== 11 assertions, 0 failures
Full script + stub test harness + README on GitHub: ruby-devops-toolkit/scheduled-task-audit
Prerequisites
- Windows only —
Schedule.Serviceis a Windows COM object; running this on Linux/macOS prints a clear error and exits 3. - Ruby with the
win32olestandard library (bundled with RubyInstaller for Windows). - An elevated (Administrator) prompt, to see hidden and SYSTEM-owned tasks.
Full Script (for reference)
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# scheduled_task_audit.rb
#
# Audits Windows Task Scheduler tasks for privilege-escalation risks by
# walking the Task Scheduler COM API (Schedule.Service) via WIN32OLE. Task
# Scheduler is a favorite persistence/privilege-escalation mechanism for
# attackers because a task that runs as SYSTEM but launches a binary from a
# directory a low-privileged user can write to is a straight line from
# "local user" to "SYSTEM" -- and because unquoted paths with spaces let an
# attacker plant an evil executable earlier in the path.
#
# Checks performed on every task:
# 1. Unquoted action path containing spaces (classic unquoted-path hijack:
# "C:\Program Files\Vendor App\run.exe" launched unquoted lets an
# attacker place C:\Program.exe or C:\Program Files\Vendor.exe).
# 2. Action executable located under a directory that is commonly
# user-writable (Temp, Users profile paths, ProgramData root, Public).
# 3. Task runs as SYSTEM (or with "Highest" run level) while its action
# executable lives outside the trusted C:\Windows or
# C:\Program Files trees.
# 4. Task is both hidden and elevated -- a common stealth-persistence
# pattern worth a second look even when nothing else is wrong.
#
# Because Schedule.Service is a Windows-only COM object, this script cannot
# run on Linux/macOS at all. The repository's test suite instead exercises
# `evaluate_task`, the pure risk-scoring function, against realistic fixture
# hashes shaped exactly like what `TaskFetcher#fetch_tasks` builds from the
# live COM objects -- see scheduled_task_audit_test.rb. That test harness
# runs anywhere Ruby runs, including this Linux sandbox; the WIN32OLE
# integration itself was verified by code review against the documented
# Schedule.Service object model (Microsoft's ITaskService/IRegisteredTask/
# IPrincipal/IExecAction interfaces -- see the References section of the
# README) since a live Windows host was not available in this environment.
#
# Usage (on Windows, ideally an elevated prompt so hidden/system tasks are
# visible):
# ruby scheduled_task_audit.rb
# ruby scheduled_task_audit.rb --folder "\\Microsoft\\Windows\\UpdateOrchestrator"
# ruby scheduled_task_audit.rb --json
#
# Exit codes (cron/CI/Task Scheduler friendly):
# 0 - no findings
# 1 - WARN-level findings only
# 2 - CRIT-level findings present
require 'optparse'
require 'json'
require 'time'
Finding = Struct.new(:severity, :task, :check, :detail) do
def to_h
{ severity: severity.to_s, task: task, check: check, detail: detail }
end
end
SEVERITY_RANK = { info: 0, warn: 1, crit: 2 }.freeze
# Directories that are commonly writable by non-administrator users on a
# default Windows install. Not exhaustive -- a real hardened audit should
# also check the actual ACL via icacls/Get-Acl, but this catches the
# overwhelming majority of real-world misconfigurations cheaply.
USER_WRITABLE_HINTS = [
%r{\\Users\\[^\\]+\\}i,
%r{\\Temp\\}i,
%r{\\AppData\\}i,
%r{^[A-Z]:\\ProgramData\\}i,
%r{\\Public\\}i,
%r{\\Downloads\\}i
].freeze
TRUSTED_ROOTS = [
%r{^[A-Z]:\\Windows\\}i,
%r{^[A-Z]:\\Program Files\\}i,
%r{^[A-Z]:\\Program Files \(x86\)\\}i
].freeze
# ---------------------------------------------------------------------------
# Pure risk-scoring logic -- no WIN32OLE dependency, fully unit-testable.
#
# task is a Hash shaped like:
# {
# name: String, path: String, enabled: Boolean, hidden: Boolean,
# run_as: String, run_level: "Highest" | "LUA",
# actions: [{ execute: String, arguments: String }]
# }
# ---------------------------------------------------------------------------
def evaluate_task(task)
findings = []
name = task[:path] || task[:name]
task[:actions].each do |action|
exe = action[:execute].to_s
next if exe.empty?
if unquoted_with_space?(exe)
findings << Finding.new(:crit, name, 'unquoted-action-path',
"Action executable '#{exe}' contains a space and is not " \
'quoted. An attacker able to write to an earlier path ' \
'segment can hijack execution.')
end
if USER_WRITABLE_HINTS.any? { |re| exe =~ re }
findings << Finding.new(:crit, name, 'writable-action-directory',
"Action executable '#{exe}' lives under a directory that " \
'is commonly writable by non-admin users.')
end
runs_privileged = task[:run_as].to_s.casecmp('SYSTEM').zero? || task[:run_level].to_s == 'Highest'
if runs_privileged && !TRUSTED_ROOTS.any? { |re| exe =~ re }
findings << Finding.new(:warn, name, 'privileged-task-untrusted-path',
"Task runs as #{task[:run_as]} (run level: #{task[:run_level]}) " \
"but its action executable '#{exe}' is outside the trusted " \
'Windows/Program Files trees.')
end
end
if task[:hidden] && task[:run_level].to_s == 'Highest'
findings << Finding.new(:warn, name, 'hidden-and-elevated',
'Task is marked hidden and requests the highest available run ' \
'level -- a common stealth-persistence combination worth a ' \
'manual look even if nothing else fires.')
end
findings
end
def unquoted_with_space?(exe)
return false unless exe.include?(' ')
return false if exe.start_with?('"') && exe.rstrip.end_with?('"')
# A bare executable path with no arguments and no spaces before the .exe
# extension is fine; flag only when the space appears before we've hit the
# executable extension, i.e. the path itself (not just its arguments) has
# unquoted whitespace.
path_part = exe[/\A[^"]*?\.(exe|bat|cmd|ps1|vbs)\b/i]
return false unless path_part
path_part.include?(' ')
end
# ---------------------------------------------------------------------------
# WIN32OLE integration -- only loaded/used on Windows.
# ---------------------------------------------------------------------------
class TaskFetcher
RUN_LEVEL_MAP = { 0 => 'LUA', 1 => 'Highest' }.freeze
def initialize(root_folder: '\\')
require 'win32ole'
@service = WIN32OLE.new('Schedule.Service')
@service.Connect
@root_folder = root_folder
end
def fetch_tasks
tasks = []
walk_folder(@service.GetFolder(@root_folder), tasks)
tasks
end
private
def walk_folder(folder, tasks)
folder.GetTasks(1).each do |task|
tasks << build_task_hash(folder, task)
end
folder.GetFolders(0).each { |sub| walk_folder(sub, tasks) }
end
def build_task_hash(folder, task)
definition = task.Definition
principal = definition.Principal
actions = definition.Actions.each.map do |a|
{ execute: a.Path.to_s, arguments: a.Arguments.to_s }
end
{
name: task.Name,
path: task.Path,
enabled: task.Enabled,
hidden: definition.Settings.Hidden,
run_as: principal.UserId.to_s,
run_level: RUN_LEVEL_MAP.fetch(principal.RunLevel, 'Unknown'),
actions: actions
}
end
end
def parse_options(argv)
opts = { folder: '\\', json: false }
parser = OptionParser.new do |o|
o.banner = 'Usage: ruby scheduled_task_audit.rb [options]'
o.on('--folder PATH', 'Task Scheduler folder to start from (default: \\ = root, recurses)') { |v| opts[:folder] = v }
o.on('--json', 'Emit machine-readable JSON instead of text') { opts[:json] = true }
o.on('-h', '--help', 'Show this help') do
puts o
exit 0
end
end
parser.parse!(argv)
opts
end
def print_text_report(findings, task_count)
puts "scheduled_task_audit: scanned #{task_count} task(s), #{findings.size} finding(s)"
puts '-' * 72
if findings.empty?
puts 'No issues found.'
return
end
%i[crit warn info].each do |sev|
group = findings.select { |f| f.severity == sev }
next if group.empty?
puts "\n[#{sev.to_s.upcase}] (#{group.size})"
group.each do |f|
puts " - #{f.task}: #{f.check}"
puts " #{f.detail}"
end
end
end
if __FILE__ == $PROGRAM_NAME
options = parse_options(ARGV)
unless RUBY_PLATFORM =~ /mingw|mswin|windows/i
warn 'scheduled_task_audit.rb requires Windows (Schedule.Service via WIN32OLE is ' \
'not available on this platform). See scheduled_task_audit_test.rb for the ' \
'platform-independent logic test.'
exit 3
end
tasks = TaskFetcher.new(root_folder: options[:folder]).fetch_tasks
findings = tasks.flat_map { |t| evaluate_task(t) }
if options[:json]
puts JSON.pretty_generate(
scanned_at: Time.now.utc.iso8601,
task_count: tasks.size,
finding_count: findings.size,
findings: findings.map(&:to_h)
)
else
print_text_report(findings, tasks.size)
end
worst = findings.map { |f| SEVERITY_RANK[f.severity] }.max || -1
exit(worst >= SEVERITY_RANK[:crit] ? 2 : worst >= SEVERITY_RANK[:warn] ? 1 : 0)
end
The Stub Test Harness (scheduled_task_audit_test.rb)
Since Schedule.Service cannot run on this Linux sandbox, this is what actually got executed to verify the logic — see the output tab above for the full passing run.
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# scheduled_task_audit_test.rb
#
# Platform-independent stub test harness for scheduled_task_audit.rb.
#
# Schedule.Service is a Windows-only COM object, so `TaskFetcher` (which
# talks to WIN32OLE) cannot be exercised on Linux/macOS. Instead this test
# feeds `evaluate_task` -- the pure risk-scoring function -- realistic fixture
# hashes shaped exactly like what `TaskFetcher#build_task_hash` produces from
# a live ITaskService/IRegisteredTask/IPrincipal/IExecAction COM tree. This
# mirrors how service_audit.rb and registry_drift.rb in this same repo are
# tested: the WIN32OLE plumbing is verified by code review against
# Microsoft's documented object model, and the decision logic is verified
# with fixtures. Run with: ruby scheduled_task_audit_test.rb
require_relative 'scheduled_task_audit'
$failures = 0
$assertions = 0
def assert(description, condition)
$assertions += 1
if condition
puts " PASS #{description}"
else
$failures += 1
puts " FAIL #{description}"
end
end
def fixture(overrides = {})
{
name: 'CleanupTask',
path: '\\Custom\\CleanupTask',
enabled: true,
hidden: false,
run_as: 'SYSTEM',
run_level: 'Highest',
actions: [{ execute: 'C:\\Windows\\System32\\cleanup.exe', arguments: '' }]
}.merge(overrides)
end
puts 'scheduled_task_audit_test.rb -- exercising evaluate_task() against WIN32OLE-shaped fixtures'
puts '=' * 78
puts "\n[1] Healthy task: trusted path, quoted, not hidden"
findings = evaluate_task(fixture)
assert('no findings for a clean SYSTEM task in C:\\Windows\\System32', findings.empty?)
puts "\n[2] Unquoted path with a space -- classic hijack vector"
task = fixture(actions: [{ execute: 'C:\\Program Files\\Vendor App\\run.exe', arguments: '' }])
findings = evaluate_task(task)
assert('flags unquoted-action-path', findings.any? { |f| f.check == 'unquoted-action-path' })
assert('severity is crit', findings.find { |f| f.check == 'unquoted-action-path' }.severity == :crit)
puts "\n[3] Same path, properly quoted -- should NOT flag unquoted-action-path"
task = fixture(actions: [{ execute: '"C:\\Program Files\\Vendor App\\run.exe"', arguments: '' }])
findings = evaluate_task(task)
assert('does not flag a properly quoted path', findings.none? { |f| f.check == 'unquoted-action-path' })
puts "\n[4] SYSTEM task launching a script from a user-writable Temp directory"
task = fixture(actions: [{ execute: 'C:\\Users\\svc-deploy\\AppData\\Local\\Temp\\run.bat', arguments: '' }])
findings = evaluate_task(task)
assert('flags writable-action-directory', findings.any? { |f| f.check == 'writable-action-directory' })
assert('also flags privileged-task-untrusted-path (SYSTEM + outside trusted roots)',
findings.any? { |f| f.check == 'privileged-task-untrusted-path' })
puts "\n[5] Non-privileged task in an untrusted path -- should NOT trigger the privileged check"
task = fixture(run_as: 'DOMAIN\\alice', run_level: 'LUA',
actions: [{ execute: 'C:\\Users\\alice\\tools\\backup.exe', arguments: '' }])
findings = evaluate_task(task)
assert('does not flag privileged-task-untrusted-path for a non-privileged user task',
findings.none? { |f| f.check == 'privileged-task-untrusted-path' })
assert('still flags writable-action-directory (it is under \\Users\\)',
findings.any? { |f| f.check == 'writable-action-directory' })
puts "\n[6] Hidden + highest run level -- stealth persistence pattern"
task = fixture(hidden: true, run_level: 'Highest')
findings = evaluate_task(task)
assert('flags hidden-and-elevated', findings.any? { |f| f.check == 'hidden-and-elevated' })
puts "\n[7] Hidden but LUA (not elevated) -- should NOT flag hidden-and-elevated"
task = fixture(hidden: true, run_level: 'LUA', run_as: 'DOMAIN\\alice')
findings = evaluate_task(task)
assert('does not flag hidden-and-elevated when run level is LUA',
findings.none? { |f| f.check == 'hidden-and-elevated' })
puts "\n[8] Multiple actions on one task -- each action evaluated independently"
task = fixture(actions: [
{ execute: 'C:\\Windows\\System32\\cleanup.exe', arguments: '' },
{ execute: 'C:\\ProgramData\\legacyapp\\worker.exe', arguments: '' }
])
findings = evaluate_task(task)
assert('flags exactly one writable-action-directory (second action only)',
findings.count { |f| f.check == 'writable-action-directory' } == 1)
puts "\n#{'=' * 78}"
puts "#{$assertions} assertions, #{$failures} failures"
exit($failures.zero? ? 0 : 1)
Step-by-Step Walkthrough
Two files, two very different jobs:
- scheduled_task_audit.rb —
TaskFetcherconnects to
Schedule.Service, walks every folder recursively, and for each task builds a Hash with
name,path,enabled,hidden,run_as,
run_level, and anactionsarray.evaluate_taskthen checks,
per action: is the path unquoted with a space in it (CRIT), does it live under a commonly
user-writable directory likeTemp/AppData/ProgramData(CRIT),
does a SYSTEM/highest-privilege task point outside the trustedC:\Windows/
C:\Program Filestrees (WARN), and is the task both hidden and elevated (WARN). - scheduled_task_audit_test.rb — feeds
evaluate_taskeight
realistic fixture hashes: a clean SYSTEM task, an unquoted-path hijack, the same path properly quoted
(must NOT flag), a SYSTEM task in a writable Temp directory, a non-privileged user task in an
untrusted path (must NOT trigger the privileged-only check), a hidden+elevated task, a
hidden-but-not-elevated task (must NOT flag), and a two-action task where only one action is risky
(each action evaluated independently). This mirrors how this repository’s other WIN32OLE-backed
scripts (service-audit,registry-drift) are tested: the COM plumbing is
verified by code review against Microsoft’s documented object model, and the decision logic is
unit-tested with fixtures on any platform.
Example Output
Troubleshooting
- “Access is denied” connecting to Schedule.Service — run from an elevated
prompt; several folders (especially under\Microsoft\Windows\) are only enumerable as
Administrator. - Hidden tasks don’t show up —
GetTasks(1)passes the
TASK_ENUM_HIDDENflag so hidden tasks are requested; if you still don’t see them, confirm
you’re elevated, since Task Scheduler additionally restricts some enumeration to admins regardless of
that flag. - False positive on writable-action-directory — the directory-name heuristic
is intentionally broad. A hardened environment might have locked-down ACLs on
ProgramDatasuch that it isn’t actually user-writable; treat a hit as “go verify the ACL,”
not an automatic confirmed finding — this trades some false positives for not missing real
ones. LoadError: cannot load such file -- win32ole— you’re not on
Windows, or on a minimal Ruby build without the bundledwin32olelibrary. It ships by
default with RubyInstaller for Windows.- Honesty about testing — this script’s WIN32OLE integration could not be
executed against a real Windows host in this environment. The risk-scoring logic
(evaluate_task) is fully unit-tested and passing (11/11 assertions); the COM plumbing
around it was verified by code review against Microsoft’s documented
ITaskService/IRegisteredTask/IPrincipal/IExecAction
interfaces rather than by a live run. Validate against a real task list before relying on it in
production.
Extending It
- Replace the directory-name heuristic in
USER_WRITABLE_HINTSwith a real ACL check via
icacls, shelled out withOpen3.capture2, for an actual
“is this writable by non-admins” answer instead of a name-based guess. - Inspect
Actionsarguments (not just the executable path) for additional
unquoted-path risk when the action is a script interpreter (cmd.exe,
powershell.exe) and the real payload lives inarguments. - Add a check for network-facing triggers (event-log or registration triggers) combined with an
elevated run level — a broader remote attack surface than time-based triggers alone. - Feed findings into a JSON baseline file, matching this repo’s
registry-drift/
pattern, so a “known good” task inventory can be diffed over time instead of re-evaluated from
static rules alone.