diff --git a/pkg/snclient/check_tasksched_windows.go b/pkg/snclient/check_tasksched_windows.go index 65aae067..6029d68d 100644 --- a/pkg/snclient/check_tasksched_windows.go +++ b/pkg/snclient/check_tasksched_windows.go @@ -11,7 +11,6 @@ import ( "strings" "syscall" "time" - "unicode" "github.com/goccy/go-json" ) @@ -30,17 +29,18 @@ func (l *CheckTasksched) addTasks(ctx context.Context, snc *Agent, check *CheckD } } + titleRuneBlacklist := []rune{'\\', '/', ':', '*', '?', '"', '<', '>', '|'} if l.TaskTitle != CheckTaskschedDefaultTaskTitle { - if strings.ContainsFunc(l.TaskTitle, func(r rune) bool { return !unicode.IsLetter(r) }) { - return fmt.Errorf("custom specified title should be all letters, but it isnt: %s", l.TaskTitle) + if strings.ContainsFunc(l.TaskTitle, func(r rune) bool { return slices.Contains(titleRuneBlacklist, r) }) { + return fmt.Errorf("custom specified title: '%s' contains one of the blacklisted runes: '%s' ", l.TaskTitle, string(titleRuneBlacklist)) } } + // allow backslashes when specifying folders, to specify nested paths + folderRuneBlacklist := []rune{'/', ':', '*', '?', '"', '<', '>', '|'} if l.Folder != CheckTaskschedDefaultFolder { - // NTFS characters are generally allowed, expect quotes - allowedRunes := []rune{' ', '-', '\\', '_', '(', ')', '[', ']', '.', ','} - if strings.ContainsFunc(l.Folder, func(r rune) bool { return !unicode.IsLetter(r) && !slices.Contains(allowedRunes, r) }) { - return fmt.Errorf("custom specified folder should be all letters or allowed runes: '%s', but it isnt: %s", string(allowedRunes), l.Folder) + if strings.ContainsFunc(l.Folder, func(r rune) bool { return slices.Contains(folderRuneBlacklist, r) }) { + return fmt.Errorf("custom specified folder: '%s' contains one of the blacklisted runes: '%s' ", l.Folder, string(folderRuneBlacklist)) } } @@ -88,6 +88,7 @@ func (l *CheckTasksched) addTasks(ctx context.Context, snc *Agent, check *CheckD if err != nil { return fmt.Errorf("could not unmarshal scheduled tasks: %s", err.Error()) } + log.Debugf("found %d scheduled task(s)", len(taskList)) for index := range taskList { task := taskList[index] diff --git a/pkg/snclient/embed/scripts/windows/scheduled_tasks.ps1 b/pkg/snclient/embed/scripts/windows/scheduled_tasks.ps1 index ee9f5549..59d4ee16 100644 --- a/pkg/snclient/embed/scripts/windows/scheduled_tasks.ps1 +++ b/pkg/snclient/embed/scripts/windows/scheduled_tasks.ps1 @@ -1,9 +1,12 @@ -# list scheduled tasks in json format +# list scheduled tasks in json format +# this version uses the Schedule.Service COM API +# it avoids importing the ScheduledTasks module, which can be extremely slow +# on machines with EDR/antivirus solutions that scan modules via AMSI # usage: .\scheduled_tasks.ps1 [-title ] [-folder ] [-recursive ] # Parse named arguments (for standalone invocation). -# When called via snclient, variables are injected at the top of the script instead, -# so $args will be empty and this loop does nothing. +# When called via snclient, parameters are defined at the top of the script +# the parameters will be parsed without looking at $args if ($args) { for ($i = 0; $i -lt $args.Count; $i++) { if ($args[$i] -eq '-title' -and $i + 1 -lt $args.Count) { @@ -24,125 +27,113 @@ if ($args) { } } -# Apply defaults when variables are not defined (neither by snclient injection nor by args) +# Apply defaults when variables are not defined (neither by snclient parameter injection nor by args) if (!$title) { $title = '*' } if (!$folder) { $folder = '\' } -if (!$recursive) { $recursive = 'false' } +if (!$recursive) { $recursive = 'true' } # ensure output is utf8 $OutputEncoding = [Console]::OutputEncoding = [Text.UTF8Encoding]::UTF8 -$params = @{} -if ($title -ne '*') { - $params.TaskName = $title -} -if ($recursive -eq 'true') { - $params.TaskPath = $folder + '*' -} else { - $params.TaskPath = $folder -} +# Print powershell version +[Console]::Error.WriteLine(('Powershell version table: ' + ($PSVersionTable | ConvertTo-Json -Compress))) + +$sw = [System.Diagnostics.Stopwatch]::StartNew() +$scheduler = New-Object -ComObject Schedule.Service +$scheduler.Connect() +$sw.Stop() +[Console]::Error.WriteLine(('COM Schedule.Service connect took {0:F2} ms' -f $sw.Elapsed.TotalMilliseconds)) +$sw = [System.Diagnostics.Stopwatch]::StartNew() +$tasks = [System.Collections.Generic.List[object]]::new() try { - $tasks = Get-ScheduledTask @params -ErrorAction Stop + $targetFolder = $scheduler.GetFolder($folder) + $folderQueue = [System.Collections.Queue]::new() + $folderQueue.Enqueue($targetFolder) + while ($folderQueue.Count -gt 0) { + $currentFolder = $folderQueue.Dequeue() + # TASK_ENUM_HIDDEN = 1, include hidden tasks + # Call GetTasks() using TASK_ENUM_HIDDEN + foreach ($t in $currentFolder.GetTasks(1)) { + $tasks.Add($t) + } + if ($recursive -eq 'true') { + foreach ($sub in $currentFolder.GetFolders(0)) { + $folderQueue.Enqueue($sub) + } + } + } } catch { - $tasks = @() + $tasks = [System.Collections.Generic.List[object]]::new() } +$sw.Stop() +[Console]::Error.WriteLine(('Task enumeration took {0:F2} ms' -f $sw.Elapsed.TotalMilliseconds)) -$results = @() +if ($title -ne '*') { + $filtered = [System.Collections.Generic.List[object]]::new() + foreach ($t in $tasks) { + if ($t.Name -eq $title) { + $filtered.Add($t) + } + } + $tasks = $filtered +} + +$sw = [System.Diagnostics.Stopwatch]::StartNew() +$results = [System.Collections.Generic.List[object]]::new() foreach ($task in $tasks) { - $taskInfo = Get-ScheduledTaskInfo -TaskName $task.TaskName -TaskPath $task.TaskPath + $def = $task.Definition + $taskPath = $task.Path.Substring(0, $task.Path.Length - $task.Name.Length) - # Get-ScheduledTask returns a nested object - # Subobjects are not fully serialized and sent, only some of their fields are specifically selected + $actions = [System.Collections.Generic.List[object]]::new() + foreach ($action in $def.Actions) { + # COM IAction.Type: 0 = TASK_ACTION_EXEC (the only type with Path/Arguments/WorkingDirectory) + if ($action.Type -eq 0) { + $actions.Add( + [PSCustomObject]@{ + Arguments = [string]$action.Arguments + Execute = [string]$action.Path + Id = [string]$action.Id + PSComputerName = '' + WorkingDirectory = [string]$action.WorkingDirectory + } + ) + } + } - # Get-ScheduledTask -TaskName "XYZ" | Select-Object -ExpandProperty Actions | Get-Member -MemberType Property - # This one should be exported, as a complete object. It is an array, and only the last ones execute, parameters and working directory are picked - $actions = @($task.Actions | ForEach-Object { + $results.Add( [PSCustomObject]@{ - Arguments = $_.Arguments - Execute = $_.Execute - Id = $_.Id - PSComputerName = $_.PSComputerName - WorkingDirectory = $_.WorkingDirectory + TaskName = $task.Name + TaskPath = $taskPath + State = [int]$task.State + Description = [string]$def.RegistrationInfo.Description + PSComputerName = '' + URI = $task.Path + Version = [string]$def.RegistrationInfo.Version + LastRunTime = $task.LastRunTime + LastTaskResult = [BitConverter]::ToUInt32([BitConverter]::GetBytes([int32]$task.LastTaskResult), 0) + NextRunTime = $task.NextRunTime + NumberOfMissedRuns = [int64]$task.NumberOfMissedRuns + UserId = [string]$def.Principal.UserId + Enabled = [bool]$task.Enabled + Priority = [int64]$def.Settings.Priority + Hidden = [bool]$def.Settings.Hidden + ExecutionTimeLimit = [string]$def.Settings.ExecutionTimeLimit + Actions = @($actions) } - }) - - # Get-ScheduledTask -TaskName "XYZ" | Select-Object -ExpandProperty Triggers | Get-Member -MemberType Property - # $triggers = @($task.Triggers | ForEach-Object { - # [PSCustomObject]@{ - # DaysInterval = $_.DaysInterval - # Enabled = $_.Enabled - # EndBoundary = $_.EndBoundary - # ExecutionTimeLimit = $_.ExecutionTimeLimit - # Id = $_.Id - # RandomDelay = $_.RandomDelay - # Repetition = $_.Repetition - # StartBoundary = $_.StartBoundary - # } - # }) - - # Get-ScheduledTask -TaskName "XYZ" | Select-Object -ExpandProperty Settings | Get-Member -MemberType Property - # $settings = [PSCustomObject]@{ - # AllowDemandStart = $task.Settings.AllowDemandStart - # AllowHardTerminate = $task.Settings.AllowHardTerminate - # DeleteExpiredTaskAfter = $task.Settings.DeleteExpiredTaskAfter - # DisallowStartIfOnBatteries = $task.Settings.DisallowStartIfOnBatteries - # DisallowStartOnRemoteAppSession = $task.Settings.DisallowStartOnRemoteAppSession - # Enabled = $task.Settings.Enabled - # ExecutionTimeLimit = $task.Settings.ExecutionTimeLimit - # Hidden = $task.Settings.Hidden - # IdleSettings = $task.Settings.IdleSettings - # MaintenanceSettings = $task.Settings.MaintenanceSettings - # NetworkSettings = $task.Settings.NetworkSettings - # Priority = $task.Settings.Priority - # PSComputerName = $task.Settings.PSComputerName - # RestartCount = $task.Settings.RestartCount - # RestartInterval = $task.Settings.RestartInterval - # RunOnlyIfIdle = $task.Settings.RunOnlyIfIdle - # RunOnlyIfNetworkAvailable = $task.Settings.RunOnlyIfNetworkAvailable - # StartWhenAvailable = $task.Settings.StartWhenAvailable - # StopIfGoingOnBatteries = $task.Settings.StopIfGoingOnBatteries - # UseUnifiedSchedulingEngine = $task.Settings.UseUnifiedSchedulingEngine - # Volatile = $task.Settings.Volatile - # WakeToRun = $task.Settings.WakeToRun - # } - - # Get-ScheduledTask -TaskName "XYZ" | Select-Object -ExpandProperty Principal | Get-Member -MemberType Property - # $principal = [PSCustomObject]@{ - # DisplayName = $task.Principal.DisplayName - # Id = $task.Principal.Id - # GroupId = $task.Principal.GroupId - # PSComputerName = $task.Principal.PSComputerName - # RequiredPrivilege = $task.Principal.RequiredPrivilege - # UserId = $task.Principal.UserId - # } - - # Combine task properties with task info properties - # Get-ScheduledTask -TaskName "XYZ" | Get-Member -MemberType Property - # Get-ScheduledTaskInfo -TaskName "XYZ" | Get-Member -MemberType Property - $results += [PSCustomObject]@{ - TaskName = $task.TaskName - TaskPath = $task.TaskPath - State = $task.State - Description = $task.Description - PSComputerName = $task.PSComputerName - URI = $task.URI - Version = $task.Version - LastRunTime = $taskInfo.LastRunTime - LastTaskResult = $taskInfo.LastTaskResult - NextRunTime = $taskInfo.NextRunTime - NumberOfMissedRuns = $taskInfo.NumberOfMissedRuns - UserId = $task.Principal.UserId - Enabled = $task.Settings.Enabled - Priority = $task.Settings.Priority - Hidden = $task.Settings.Hidden - ExecutionTimeLimit = $task.Settings.ExecutionTimeLimit - Actions = $actions - } + ) } +$sw.Stop() +[Console]::Error.WriteLine(('Populating results list took {0:F2} ms' -f $sw.Elapsed.TotalMilliseconds)) +[Console]::Error.WriteLine(('Results list has {0} elements' -f $results.Count)) +$sw = [System.Diagnostics.Stopwatch]::StartNew() if ($results.Count -gt 0) { ConvertTo-Json -InputObject $results -Depth 4 } else { '[]' } +$sw.Stop() +[Console]::Error.WriteLine(('Converting to JSON took {0:F2} ms' -f $sw.Elapsed.TotalMilliseconds)) + +exit 0 \ No newline at end of file diff --git a/pkg/snclient/snclient_windows.go b/pkg/snclient/snclient_windows.go index f43b1456..3b6840e9 100644 --- a/pkg/snclient/snclient_windows.go +++ b/pkg/snclient/snclient_windows.go @@ -445,8 +445,9 @@ func powerShellCmd(ctx context.Context, command string, parameters ...PowerShell cmdLine := fmt.Sprintf(`%s -Command "& { %s %s }" %s `, POWERSHELL, parameterDefinitionsCmdline, command, parameterSpecificationsCmdline) cmd.SysProcAttr = &syscall.SysProcAttr{ - HideWindow: true, - CmdLine: cmdLine, + HideWindow: true, + CmdLine: cmdLine, + CreationFlags: windows.CREATE_NO_WINDOW, } return cmd, nil