Windows Security: Detecting malicious scheduled tasks

Detecting malicious scheduled tasks on Windows is mostly about knowing what "normal" looks like in your environment and recognising the small set of shapes attackers favour. Scheduled tasks are first-class persistence — they survive reboots, can run as SYSTEM, and blend in with the dozens of legitimate maintenance tasks Windows ships with. This post is the working detection logic we run on systems we administer, framed as defence against MITRE ATT&CK technique T1053.005 (Scheduled Task).

Key Takeaways

  • Scheduled tasks are MITRE ATT&CK technique T1053.005; they persist across reboots and can run as SYSTEM without dropping a service.
  • The high-signal red flags are: tasks running PowerShell or scripting hosts with encoded arguments, paths under %TEMP%/%APPDATA%, generic or random names, and tasks outside \Microsoft\ running as SYSTEM.
  • Get-ScheduledTask plus Export-ScheduledTask exposes the XML definition, which is where the real detection signal lives.
  • Event IDs 4698 (task created), 4702 (task updated), and 4699 (task deleted) in the Security log give you the audit trail when Object Access auditing is enabled.
  • For continuous monitoring, push these signals through Microsoft Defender for Endpoint or a SIEM. The PowerShell triage below is for authorised investigation on systems you own.

Environment

  • Windows 10/11 and Windows Server 2019/2022.
  • Windows PowerShell 5.1 or PowerShell 7.4.
  • Local administrator rights — tasks owned by other users return partial properties otherwise.
  • Advanced audit policy with Audit Other Object Access Events enabled so 4698/4699/4702 are actually generated.

The Problem

Scheduled tasks were originally a quality-of-life feature for system maintenance. Modern Windows ships with hundreds of them under \Microsoft\Windows\, which makes a single malicious task hard to spot in the GUI. Attackers exploit that noise floor: a task named "GoogleUpdateCheck" running PowerShell with an encoded argument from a folder under C:\Users\Public visually disappears in a 600-row Task Scheduler view.

The detection problem is therefore one of filtering, not enumeration. The list of suspicious shapes is small and stable. Once you query for those shapes specifically, you typically end up with a handful of rows on a clean machine and one or two clearly abnormal rows on a compromised one.

The Solution

Step 1 — Enumerate every task and project the useful properties

The default Get-ScheduledTask output omits the bits you need. Pull the action, principal, and trigger explicitly:

$tasks = Get-ScheduledTask | ForEach-Object {
    $info    = $_ | Get-ScheduledTaskInfo
    $action  = $_.Actions  | Select-Object -First 1
    $trigger = $_.Triggers | Select-Object -First 1

    [PSCustomObject]@{
        TaskName    = $_.TaskName
        TaskPath    = $_.TaskPath
        State       = $_.State
        Author      = $_.Author
        RunAs       = $_.Principal.UserId
        LogonType   = $_.Principal.LogonType
        Execute     = $action.Execute
        Arguments   = $action.Arguments
        WorkingDir  = $action.WorkingDirectory
        TriggerType = $trigger.GetType().Name
        LastRunTime = $info.LastRunTime
        NextRunTime = $info.NextRunTime
        LastResult  = $info.LastTaskResult
    }
}

$tasks | Out-GridView

Out-GridView gives you a sortable, filterable view in seconds. Filter on RunAs = SYSTEM and TaskPath not starting with \Microsoft\ and you have already eliminated 99% of the noise.

Step 2 — Apply the standard suspicious-shape filter

The same set of signals catches most off-the-shelf and several bespoke implants:

$badArg = '(-enc|-encodedcommand|frombase64string|downloadstring|downloadfile|-w hidden|-windowstyle hidden|iex |invoke-expression)'
$badDir = '(temp|appdata|programdata|users\\public|recycle|perflogs)'
$badExec = '(powershell|pwsh|cmd|wscript|cscript|mshta|rundll32|regsvr32|certutil|bitsadmin)'

$tasks | Where-Object {
    ($_.TaskPath -notmatch '^\\Microsoft\\' -and $_.RunAs -in 'SYSTEM','LOCALSYSTEM','S-1-5-18') -or
    ($_.Arguments -match $badArg) -or
    ($_.Execute   -match $badDir) -or
    ($_.Execute   -match $badExec -and $_.TaskPath -notmatch '^\\Microsoft\\')
} | Format-Table TaskName, TaskPath, RunAs, Execute, Arguments -AutoSize

None of these matches is automatically malicious. Microsoft itself ships SYSTEM-context PowerShell tasks. The filter narrows the field; the human decides.

Step 3 — Pull the full task XML for anything interesting

The XML representation is the most complete view and is what gets shipped if you export a task from one host to another. It exposes triggers, principals, and conditions that the cmdlets do not surface:

[xml]$xml = Export-ScheduledTask -TaskName 'GoogleUpdateCheck' -TaskPath '\Microsoft\Windows\Custom\'

$xml.Task.RegistrationInfo.Author
$xml.Task.RegistrationInfo.Date
$xml.Task.Actions.Exec.Command
$xml.Task.Actions.Exec.Arguments
$xml.Task.Principals.Principal.UserId
$xml.Task.Triggers.LogonTrigger

The RegistrationInfo.Date stamp is particularly useful — it gives you the task creation time even on hosts where the 4698 audit event has rolled out of the Security log.

Step 4 — Cross-reference with audit events

When Object Access auditing is on, every task creation generates a 4698 in the Security log. The event includes the user who created the task and the full XML:

Get-WinEvent -FilterHashtable @{
    LogName   = 'Security'
    Id        = 4698, 4702
    StartTime = (Get-Date).AddDays(-30)
} -ErrorAction SilentlyContinue |
    Select-Object TimeCreated,
                  @{Name='Action';Expression={ if ($_.Id -eq 4698) {'Created'} else {'Updated'} }},
                  @{Name='User';  Expression={ $_.Properties[1].Value }},
                  @{Name='Task';  Expression={ $_.Properties[5].Value }}

Tasks created by users other than SYSTEM, TrustedInstaller, or your admin accounts are worth a closer look. Tasks created seconds after a known suspicious logon are worth a much closer look.

Step 5 — Verify the binary signature behind the task

A task that runs powershell.exe is fine; a task that runs an unsigned binary from C:\Users\Public is not. Resolve the action path and run an Authenticode check:

$tasks |
    Where-Object Execute |
    ForEach-Object {
        $path = [Environment]::ExpandEnvironmentVariables($_.Execute)
        if (Test-Path $path) {
            $sig = Get-AuthenticodeSignature $path
            [PSCustomObject]@{
                TaskName  = $_.TaskName
                TaskPath  = $_.TaskPath
                ExePath   = $path
                Signer    = $sig.SignerCertificate.Subject
                SigStatus = $sig.Status
            }
        }
    } |
    Where-Object SigStatus -ne 'Valid'

Unsigned binaries called from scheduled tasks are uncommon enough on a well-managed Windows server to deserve attention every time.

Step 6 — Run the tasks under managed identities you control

Defensively, the right side of this problem is making sure your own scheduled tasks run under Group Managed Service Accounts rather than plain domain users. gMSA passwords rotate automatically, are 240 characters, and never appear in scripts or runbooks. This narrows what an attacker can impersonate when they pop a host:

$action    = New-ScheduledTaskAction -Execute 'PowerShell.exe' `
                -Argument '-File C:\Scripts\Maintenance.ps1'
$trigger   = New-ScheduledTaskTrigger -Daily -At 3am
$principal = New-ScheduledTaskPrincipal -UserID 'CORP\gMSA_Maintenance$' -LogonType Password

Register-ScheduledTask -TaskName 'Maintenance' `
                       -Action    $action `
                       -Trigger   $trigger `
                       -Principal $principal

The Windows services hardening guide linked below covers the gMSA setup end to end.

Frequently Asked Questions

How are scheduled tasks different from services for persistence?

Services run continuously and are visible in services.msc. Scheduled tasks fire on triggers (time, logon, event) and live in the Task Scheduler. Attackers favour tasks because they blend into the hundreds of legitimate Microsoft tasks and can run as SYSTEM without registering a service.

What is the difference between Event IDs 4698, 4699, and 4702?

4698 is a task created, 4699 is a task deleted, 4702 is a task updated. All three require Object Access auditing to be enabled. The event payload contains the full XML, including the new action and trigger.

Why does my query miss tasks I can see in the GUI?

Most common cause: tasks under a subfolder you did not recurse into. Get-ScheduledTask already enumerates recursively by default, but if you filtered on TaskPath you may have excluded them. Second most common: lack of elevation — tasks in \Microsoft\Windows\ may return without their action set when read unelevated.

Can attackers create tasks without admin rights?

Yes. A standard user can create tasks in their own profile (\Users\<name> path under task scheduler) that run on logon under their own credentials. SYSTEM-context tasks require local admin. For T1053.005 attribution, look at the principal's logon type and SID.

What is a sensible baseline to compare against?

Export the current task list from a clean reference image and store the result as the known-good baseline. Compare every audited host against it weekly. Any task present on the live host but missing from the baseline is by definition an addition, regardless of whether the task itself looks malicious.

Conclusion

Scheduled tasks are not exotic. Detection is mostly disciplined filtering: query for the four or five shapes that matter, cross-reference creation events from the Security log, verify the signature on the action binary, and compare against a clean baseline. The trick is to do it on a schedule, not just during incident response. By the time an attacker is creating tasks for persistence, the rest of the chain is usually already in motion.

Related Posts

Authoritative reference: MITRE ATT&CK T1053.005 — Scheduled Task.

Windows Security: Best practices for securing Windows services

Securing Windows services is one of those topics where everyone agrees on the goal and almost nobody implements the same set of controls. Services are still the single most abused persistence and privilege-escalation surface on Windows, mapped by MITRE ATT&CK as T1543.003 (Windows Service). The recommendations below are the baseline we apply to every server we build before it sees production traffic — service accounts, executable security, dependencies, isolation, and monitoring.

Key Takeaways

  • Avoid running services as LocalSystem or domain users. Prefer Group Managed Service Accounts (gMSAs) — automatic 240-character passwords, central management, no plaintext credentials.
  • Unquoted service paths containing spaces are a classic privilege escalation vector. Audit and fix them as part of the baseline.
  • Service executables should be signed, live under Program Files or System32, and have ACLs that exclude regular users from write access.
  • Tighten the service's security descriptor with sc.exe sdset to restrict who can start, stop, change, or query it.
  • Monitor service install/change events (4697, 7045) and pair with a SIEM. The PowerShell snippets here are for authorised hardening and audit work.

Environment

  • Windows Server 2019/2022 and Windows 10/11 endpoints.
  • Windows PowerShell 5.1 or PowerShell 7.4.
  • Active Directory domain for the gMSA section (KDS root key must already exist).
  • Local administrator rights on each target.

The Problem

Services are attractive to attackers for the same reasons they are useful to administrators: they run automatically, they typically run as SYSTEM, and they survive reboots without a separate persistence mechanism. The default service configuration on most third-party applications is also poor — plain domain users with weak passwords, executables in C:\App\ with broad write ACLs, paths containing spaces and no quotes, security descriptors permitting any local admin to swap the image path at runtime.

None of this is fixable in one click. The hardening below is a checklist applied at server build time and re-verified in the regular audit pass.

The Solution

Step 1 — Audit the current state

Before changing anything, enumerate what is on the box. Three columns matter most: which account each service runs as, whether the path is quoted, and what the binary looks like:

Get-CimInstance Win32_Service |
    Select-Object Name, DisplayName, State, StartMode, StartName, PathName |
    Sort-Object StartName, Name |
    Export-Csv .\service_inventory.csv -NoTypeInformation -Encoding UTF8BOM

Rows where StartName is a domain user and the password is fifteen years old are the first to address. Rows where PathName contains a space but no surrounding quotes are the second.

Step 2 — Replace plain service accounts with gMSAs

Group Managed Service Accounts give you a domain identity with a password that AD manages and rotates automatically. For multi-server services (web farms, scheduled task fleets, application services on a cluster), they replace every reason to use a plain domain user.

# One-time, per forest
Add-KdsRootKey -EffectiveTime ((Get-Date).AddHours(-10))

# Per service or service family
New-ADGroup -Name 'gMSA_WebApp_Servers' -GroupScope Global
Add-ADGroupMember -Identity 'gMSA_WebApp_Servers' -Members 'Web01$','Web02$','Web03$'

New-ADServiceAccount -Name 'gMSA_WebApp' `
    -DNSHostName 'gMSA_WebApp.corp.example.com' `
    -PrincipalsAllowedToRetrieveManagedPassword 'gMSA_WebApp_Servers' `
    -ServicePrincipalNames 'HTTP/webapp.corp.example.com'

# On each target server
Invoke-Command -ComputerName Web01,Web02,Web03 -ScriptBlock {
    Install-ADServiceAccount -Identity 'gMSA_WebApp'
    Test-ADServiceAccount    -Identity 'gMSA_WebApp'
}

Bind the gMSA to a service with sc.exe config using the username form DOMAIN\gMSA_WebApp$ and an empty password. The trailing $ is required.

Step 3 — Fix unquoted paths with embedded spaces

Unquoted service paths are CWE-428 and one of the longest-running Windows privilege escalation patterns. A path like C:\Program Files\Vendor App\service.exe is interpreted starting from C:\Program.exe, then C:\Program Files\Vendor.exe, and so on. A writable executable in any of those intermediate paths runs as the service identity:

Get-CimInstance Win32_Service |
    Where-Object {
        $_.PathName -and
        $_.PathName -notmatch '^"' -and
        ($_.PathName -split ' ')[0] -match '\s'
    } |
    Select-Object Name, PathName

Fix each by editing the registry value HKLM:\SYSTEM\CurrentControlSet\Services\<ServiceName>\ImagePath to wrap the executable portion in double quotes. Restart the service to validate.

Step 4 — Lock down the executable and its directory

Even a correctly-configured service is compromised if the binary it loads is writable by everyone. Audit ACLs and signatures together:

Get-CimInstance Win32_Service | ForEach-Object {
    $exe = ($_.PathName -split '"')[1]
    if (-not $exe) { $exe = ($_.PathName -split ' ')[0] }
    if (-not (Test-Path $exe)) { return }

    $acl  = Get-Acl $exe
    $sig  = Get-AuthenticodeSignature $exe
    $bad  = $acl.Access |
        Where-Object {
            $_.IdentityReference -match '(Everyone|Users|Authenticated Users)' -and
            $_.FileSystemRights -match '(Write|Modify|FullControl)'
        }

    if ($bad -or $sig.Status -ne 'Valid') {
        [PSCustomObject]@{
            Service    = $_.Name
            Path       = $exe
            SigStatus  = $sig.Status
            BadAccess  = ($bad | ForEach-Object { "$($_.IdentityReference)=$($_.FileSystemRights)" }) -join '; '
        }
    }
}

Anything that comes back deserves remediation: tighten the ACL with icacls or move the binary to a directory inherited from Program Files, and verify the signing chain.

Step 5 — Tighten the service security descriptor

By default, any local administrator can stop, start, or modify a service. For services with elevated impact (security agents, backup software, hypervisor management), restrict that with a custom security descriptor:

# Allow: SYSTEM full, Builtin Admins full, Authenticated Users query only
$sddl = 'D:(A;;CCLCSWRPWPDTLOCRRC;;;SY)(A;;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;BA)(A;;CCLCSWLOCRRC;;;AU)'

sc.exe sdset 'YourService' $sddl
sc.exe sdshow 'YourService'

The SDDL syntax is dense but readable once you have the pattern. Reference the Microsoft documentation when designing the DACL for a specific service; never copy a permissive SDDL from a forum without reading it.

Step 6 — Map dependencies

A service is only as secure as the services it depends on. Mapping the dependency tree once per service tells you what the impact is when a dependency is restarted or compromised:

function Get-ServiceDependencyTree {
    param([string]$Name, [int]$Depth = 0, [int]$Max = 6)
    $svc = Get-Service -Name $Name -ErrorAction SilentlyContinue
    if (-not $svc -or $Depth -gt $Max) { return }

    '{0}{1} ({2}, {3})' -f ('  ' * $Depth), $svc.Name, $svc.Status, $svc.StartType
    foreach ($dep in $svc.ServicesDependedOn) {
        Get-ServiceDependencyTree -Name $dep.Name -Depth ($Depth + 1) -Max $Max
    }
}

Get-ServiceDependencyTree -Name 'YourService'

Step 7 — Monitor for service changes

Service creation and modification raise specific Windows events. Hook them with a SIEM rule or, on a single host, a scheduled query:

  • 4697 — A service was installed in the system (Security log). Requires audit-policy "Audit Security System Extension" enabled.
  • 7045 — A new service was installed (System log). Always on, useful in environments without the advanced audit policy.
  • 7040 — Service start type changed. Worth watching for the DisabledAutomatic transition.
Get-WinEvent -FilterHashtable @{
    LogName = 'System'; Id = 7045; StartTime = (Get-Date).AddDays(-30)
} -ErrorAction SilentlyContinue |
    Select-Object TimeCreated,
                  @{Name='Service'; Expression={ $_.Properties[0].Value }},
                  @{Name='Image';   Expression={ $_.Properties[1].Value }},
                  @{Name='Type';    Expression={ $_.Properties[2].Value }},
                  @{Name='Account'; Expression={ $_.Properties[4].Value }}

Frequently Asked Questions

When should I use a gMSA versus a regular service account?

Use a gMSA for anything running on more than one machine, anything running in a cluster, and anything where the password would otherwise sit in a script or runbook. Use a single-machine MSA for legacy single-server applications. Reserve plain domain users for cases where the application explicitly does not support managed accounts — that list should shrink every year.

Is LocalSystem ever the right choice?

For services that genuinely need SYSTEM privileges (kernel drivers, device management, backup agents that touch every file), yes. For application services that need a database connection and a file share, no — drop them to a least-privileged account.

How do I find unquoted service paths across an entire fleet?

Wrap the Step 3 snippet in Invoke-Command -ComputerName $list. The result is a per-host list of vulnerable services suitable for a remediation ticket. The Remote Management guide linked below covers the wrapper pattern.

What is the difference between Event 4697 and Event 7045?

7045 lives in the System log and is generated by the Service Control Manager whenever a service is installed. 4697 lives in the Security log and is part of the advanced audit policy. Use 4697 in environments where the audit policy is configured; rely on 7045 elsewhere.

Can I block the creation of new services entirely?

Not natively — service creation is a privileged operation, but local administrators can always do it. The mitigations are AppLocker/WDAC code-integrity policy preventing unsigned binaries from running, plus monitoring of 7045 events. For high-assurance servers, lock down local admin membership tightly and stream the audit events to a SIEM.

Conclusion

Service hardening is not glamorous and nobody notices when it is right. The combination of managed service accounts, signed executables, quoted paths, tight security descriptors, and 7045 monitoring covers most of the real-world attack surface. Apply it as part of server build, re-verify it in audit, and you remove a large class of opportunistic compromise without needing to fight an EDR alert every week.

Related Posts

Authoritative references: Group Managed Service Accounts on Microsoft Learn and MITRE ATT&CK T1543.003.

Windows Security: Registry Forensics - Where Attackers Hide

Windows registry forensics is one of the highest-yield areas of endpoint investigation, and the reason is structural: the registry is where Windows reads "what should I run when…" answers, so attackers who want persistence have to write there. This post walks the seven registry locations we check first on a suspected-compromised host, framed as defence against the MITRE ATT&CK persistence techniques behind them. It is meant for authorised triage on systems you administer — for continuous monitoring, use Sysmon, Microsoft Defender for Endpoint, or a SIEM agent.

Key Takeaways

  • Seven registry surfaces account for the vast majority of registry-based persistence: Run/RunOnce keys, services, COM hijacking, shell extensions, Winlogon/AppInit load points, file association handlers, and LSA providers.
  • The reason attackers reach for the registry is structural — Windows itself reads these keys to decide what to launch. Anything that breaks that pact also breaks Windows, which is why the surface is unlikely to shrink.
  • Each surface maps to a MITRE ATT&CK sub-technique (T1547 Boot or Logon Autostart Execution, T1546.015 COM Hijacking, T1546.009 AppCert DLLs, etc.).
  • Baseline a clean image. Compare every audited host against it. The new keys are the suspicious ones; the existing list of Microsoft entries is large and irrelevant.
  • For production monitoring, use Sysmon's RegistryEvent rules and forward them to a SIEM. The PowerShell snippets below are for authorised investigation, not for monitoring fleets.

Environment

  • Windows 10/11 and Windows Server 2019/2022.
  • Windows PowerShell 5.1 or PowerShell 7.4.
  • Local administrator rights — most of HKLM is unreadable otherwise.
  • Sysmon recommended for continuous detection (separate from this triage).

The Problem

Triage time on a suspect host is finite. Most of the registry is irrelevant; a few keys are heavily abused. Looking everywhere wastes hours. Looking only at the Run keys misses three-quarters of the modern persistence surface. The right answer is a short checklist of locations that account for most real-world cases, plus a sense of what each one is for so you can tell a legitimate entry from a planted one.

The Solution

1 — Run and RunOnce keys (MITRE T1547.001)

The classics, and still common. Five locations are worth checking; only the first four are widely known:

$runKeys = @(
    'HKLM:\Software\Microsoft\Windows\CurrentVersion\Run',
    'HKLM:\Software\Microsoft\Windows\CurrentVersion\RunOnce',
    'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run',
    'HKCU:\Software\Microsoft\Windows\CurrentVersion\RunOnce',
    'HKLM:\Software\Microsoft\Windows\CurrentVersion\RunOnceEx',
    'HKLM:\Software\Wow6432Node\Microsoft\Windows\CurrentVersion\Run',
    'HKCU:\Software\Microsoft\Windows NT\CurrentVersion\Windows\Load',
    'HKCU:\Software\Microsoft\Windows NT\CurrentVersion\Windows\Run'
)

$runKeys | ForEach-Object {
    $key = $_
    Get-ItemProperty $_ -ErrorAction SilentlyContinue |
        Select-Object @{Name='Key';Expression={$key}}, * -ExcludeProperty PS*
}

The HKCU variants do not require elevation to write, which makes them attractive to malware running as a standard user. RunOnce entries delete themselves after execution, which makes them ideal for staging payloads — short-lived, harder to catch.

2 — Services (MITRE T1543.003)

Services run as SYSTEM by default and look indistinguishable from legitimate Windows components in the GUI. The interesting key per service is ImagePath (the binary) and ObjectName (the account). Everything below HKLM:\SYSTEM\CurrentControlSet\Services is a candidate:

Get-ChildItem 'HKLM:\SYSTEM\CurrentControlSet\Services' |
    ForEach-Object {
        $p = Get-ItemProperty $_.PsPath -ErrorAction SilentlyContinue
        if ($p.ImagePath -and $p.ImagePath -match 'temp|appdata|users\\public|programdata') {
            [PSCustomObject]@{
                Service     = $_.PsChildName
                ImagePath   = $p.ImagePath
                ObjectName  = $p.ObjectName
                Start       = $p.Start
                Type        = $p.Type
            }
        }
    }

A service binary living in %TEMP% or %PROGRAMDATA% on a production server is essentially always wrong. Cross-reference any hit against the signed-binary check from the process investigation guide.

3 — COM hijacking (MITRE T1546.015)

COM hijacking abuses the lookup order between HKCU and HKLM. A class registered in HKCU shadows the same CLSID in HKLM when the user-mode component does the lookup — and Windows itself does a lot of COM lookups. The HKCU CLSID hive should normally be sparse:

Get-ChildItem 'HKCU:\Software\Classes\CLSID' -ErrorAction SilentlyContinue |
    ForEach-Object {
        $inproc = Get-ItemProperty (Join-Path $_.PsPath 'InprocServer32') -ErrorAction SilentlyContinue
        if ($inproc) {
            [PSCustomObject]@{
                CLSID      = $_.PsChildName
                Dll        = $inproc.'(default)'
                Threading  = $inproc.ThreadingModel
            }
        }
    }

On a clean workstation this query usually returns very few rows, often zero. Anything pointing at a DLL outside C:\Program Files or the Windows directory is worth investigating.

4 — Winlogon and AppInit_DLLs (MITRE T1547.004, T1546.010)

Three values matter here. Shell and Userinit under Winlogon determine what runs at logon; AppInit_DLLs under Windows NT is injected into every process that loads user32.dll:

Get-ItemProperty 'HKLM:\Software\Microsoft\Windows NT\CurrentVersion\Winlogon' |
    Select-Object Shell, Userinit

Get-ItemProperty 'HKLM:\Software\Microsoft\Windows NT\CurrentVersion\Windows' |
    Select-Object AppInit_DLLs, LoadAppInit_DLLs, RequireSignedAppInit_DLLs

Shell should be explorer.exe and Userinit should be C:\Windows\system32\userinit.exe,. Anything else is either a deliberate kiosk configuration or an attack. AppInit_DLLs should be empty on modern Windows; it is a legacy mechanism still enabled on some systems.

5 — Shell extensions (MITRE T1546.001)

Shell extensions load whenever Explorer runs. The interesting paths are the Approved key (which lists the registered handlers) and the per-class context-menu handlers:

Get-ItemProperty 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Shell Extensions\Approved' |
    Select-Object * -ExcludeProperty PS*

Get-ChildItem 'Registry::HKEY_CLASSES_ROOT\*\shellex\ContextMenuHandlers' -ErrorAction SilentlyContinue |
    Where-Object PsChildName -notmatch '^(Open With|Sharing|Compatibility)$' |
    Select-Object PsPath, PsChildName

Most shell extensions are signed Microsoft or vendor components. An unsigned DLL appearing here on a managed host is worth a closer look.

6 — File association handlers (MITRE T1546.001)

Modifying the handler for .exe, .dll, .lnk, or .bat at the HKCU level redirects execution of those file types for the affected user. The HKLM defaults should match the documented values; HKCU overrides should be examined:

'.exe','.dll','.cmd','.bat','.ps1','.lnk' | ForEach-Object {
    $ext = $_
    $hklm = (Get-ItemProperty "HKLM:\Software\Classes\$ext" -ErrorAction SilentlyContinue)."(default)"
    $hkcu = (Get-ItemProperty "HKCU:\Software\Classes\$ext" -ErrorAction SilentlyContinue)."(default)"
    if ($hkcu -and $hkcu -ne $hklm) {
        [PSCustomObject]@{
            Extension   = $ext
            HKLMHandler = $hklm
            HKCUHandler = $hkcu
        }
    }
}

An HKCU override that differs from HKLM for a system extension is a strong signal. Carbanak and several other groups have used this technique to silently intercept Office file openings.

7 — LSA providers (MITRE T1547.002)

LSA Security Support Provider entries handle authentication. A malicious SSP loaded into LSASS can intercept credential material. The relevant values live under the LSA key:

Get-ItemProperty 'HKLM:\System\CurrentControlSet\Control\Lsa' |
    Select-Object 'Security Packages','Authentication Packages','Notification Packages'

Get-ItemProperty 'HKLM:\System\CurrentControlSet\Control\Lsa\OSConfig' -ErrorAction SilentlyContinue |
    Select-Object * -ExcludeProperty PS*

Expected packages are kerberos, msv1_0, schannel, wdigest, tspkg, pku2u, and cloudAP. Anything else, particularly an unrecognised name, is significant. APT29 has used LSA provider abuse as part of their credential theft toolkit.

Pulling it together — a single triage pass

Wrapping the seven checks above in a single function gives you a one-command first pass. Pipe the result to Export-Csv for sharing or to a SIEM ingestion path. Always treat the output as input to a human decision, not as automatic verdicts:

function Invoke-RegistryPersistenceSweep {
    $now = Get-Date
    $hits = New-Object System.Collections.Generic.List[object]

    foreach ($key in $runKeys) {
        Get-ItemProperty $key -ErrorAction SilentlyContinue |
            Select-Object * -ExcludeProperty PS* |
            ForEach-Object {
                $_.PSObject.Properties |
                    Where-Object Name -notmatch '^PS' |
                    ForEach-Object { $hits.Add([PSCustomObject]@{
                        Time      = $now
                        Source    = "Run/$key"
                        Name      = $_.Name
                        Value     = $_.Value
                    })}
            }
    }
    # … repeat for the remaining six surfaces …
    return $hits
}

Frequently Asked Questions

Is the registry the only place to look for persistence?

No. Scheduled tasks, WMI event subscriptions, services, startup folders, and Group Policy preferences all coexist with the registry as persistence anchors. The registry covers a large fraction but not all of them. Pair this guide with the scheduled-task detection post linked below.

Can I monitor the registry continuously without polling?

Yes — Sysmon with RegistryEvent rules emits an event for every create/modify on the keys you specify. Defender for Endpoint includes registry telemetry natively. Both are dramatically better than periodic PowerShell sweeps for monitoring at scale.

Why HKCU as well as HKLM?

HKCU does not require admin rights to modify. Malware running as a standard user lands there first, and several COM and file-association hijacks specifically depend on the HKCU-over-HKLM precedence in user-mode lookups.

What about the BootExecute and Image File Execution Options keys?

Both are also legitimate persistence surfaces — T1037.002 and T1546.012 respectively. They are less common in commodity malware but worth adding to the checklist for higher-confidence investigations. We omitted them from the seven above to keep the first pass short.

How do I tell legitimate from malicious in these locations?

Two heuristics carry most of the weight: signed Microsoft or vendor binaries living in well-known directories are almost always legitimate; unsigned binaries in user-writable directories are almost always not. Beyond that, compare against a clean-image baseline. The diff is the answer.

Conclusion

Registry-based persistence is not exotic; it is the predictable result of Windows reading "what should I run when X happens" from a small set of well-documented keys. Triage is about knowing which of those keys actually matter, what the expected values are, and where to look first. Seven surfaces, one baseline, a routine sweep — that is most of what you need for the first pass. Anything beyond it is for the SIEM and the EDR.

Related Posts

Authoritative reference: MITRE ATT&CK Persistence tactic (TA0003).

Essential Windows Event IDs for Security Monitoring: The Complete Guide

Every defender eventually needs a working knowledge of Windows Event IDs for security monitoring. There are thousands of them, but a much smaller core actually drives detection. This post is the reference we hand to new analysts on the team: the events that matter, what each is for, the audit policy needed to produce them, and PowerShell queries to pull them on demand. The list is curated for defenders, not exhaustive — use it as a working baseline and extend per environment.

Key Takeaways

  • The high-value detection IDs cluster into five groups: authentication, account management, Active Directory changes, process and PowerShell execution, and policy or audit changes.
  • None of them appear without the right audit policy. Configure Advanced Audit Policy via Group Policy before assuming the events are missing.
  • Default log sizes are too small for security monitoring on Domain Controllers. Size Security to 4 GB and PowerShell/Operational to 1 GB at minimum.
  • Use Get-WinEvent -FilterHashtable for ad-hoc queries; centralise via Windows Event Forwarding or a SIEM for retention and correlation.
  • Pair every event with the MITRE ATT&CK technique it helps detect. Coverage gaps are easier to spot when alerts are technique-tagged.

Environment

  • Windows 10/11 endpoints and Windows Server 2019/2022 (Domain Controllers and member servers).
  • Active Directory domain with Advanced Audit Policy applied via Group Policy.
  • PowerShell 5.1 or PowerShell 7.4 for queries.
  • Optional: Sysmon for richer process and network telemetry, plus a SIEM (Sentinel, Splunk, Elastic, QRadar) for retention and correlation.

The Problem

Event Viewer is fine for a single host but useless for a fleet. The two recurring problems are missing events (audit policy not configured, log full and overwriting) and overwhelmed analysts (every event has equal weight in the GUI, even though 4624 dwarfs every other event ID in volume). The fix is the same in both cases: know which event IDs actually carry signal, configure audit policy and log size to capture them, and standardise the queries you reach for first.

The Solution

Step 1 — Configure audit policy and log size

Without Advanced Audit Policy, half the events in this list never fire. Apply via GPO under Computer Configuration → Windows Settings → Security Settings → Advanced Audit Policy Configuration. The minimum categories to enable with both Success and Failure:

  • Account Logon
  • Account Management
  • Detailed Tracking
  • DS Access
  • Logon/Logoff
  • Object Access
  • Policy Change
  • Privilege Use
  • System
# Verify what is configured locally
auditpol /get /category:*

# Size critical logs
wevtutil sl Security                                    /ms:4294967296
wevtutil sl Application                                 /ms:1073741824
wevtutil sl System                                      /ms:1073741824
wevtutil sl 'Microsoft-Windows-PowerShell/Operational'  /ms:1073741824

Roll the same configuration out via Group Policy preferences or your provisioning tool so every server inherits the same baseline.

Step 2 — Authentication events (Logon/Logoff)

The six most useful authentication event IDs:

  • 4624 — Successful logon. LogonType matters: 2 (interactive), 3 (network), 10 (RemoteInteractive/RDP).
  • 4625 — Failed logon. Status and SubStatus codes explain why.
  • 4634 — Logoff (matched to a 4624 logon ID).
  • 4647 — User-initiated logoff.
  • 4648 — Explicit credential use (RunAs, scheduled task with stored credentials, lateral movement primitive).
  • 4672 — Special privileges assigned at logon (administrator session indicator).
# Failed logons in the last hour, grouped by user and source IP
Get-WinEvent -FilterHashtable @{
    LogName = 'Security'; Id = 4625; StartTime = (Get-Date).AddHours(-1)
} -ErrorAction SilentlyContinue |
    Select-Object TimeCreated,
                  @{Name='User';     Expression={ $_.Properties[5].Value }},
                  @{Name='SourceIP'; Expression={ $_.Properties[19].Value }},
                  @{Name='Status';   Expression={ '{0:X}' -f $_.Properties[7].Value }} |
    Group-Object User, SourceIP |
    Where-Object Count -gt 10 |
    Sort-Object Count -Descending

Step 3 — Account management events

Anything that changes a user, group, or computer object in AD generates one of these:

  • 4720 — User account created.
  • 4722 — User account enabled.
  • 4723 — User attempted to change their password.
  • 4724 — Administrator reset a user's password.
  • 4726 — User account deleted.
  • 4728 / 4732 / 4756 — Member added to global, local, or universal group.
  • 4738 — User account changed.
  • 4741 / 4742 / 4743 — Computer account created, changed, deleted.

A 4732 adding a non-admin into the local Administrators group is a high-fidelity signal. Same for any 4728 against Domain Admins, Enterprise Admins, or Schema Admins.

Step 4 — Active Directory object changes

The 51xx range covers directory-service changes. These are noisy by default and need to be filtered to specific object classes:

  • 4662 — Operation performed on an AD object (the DCSync indicator).
  • 5136 — Directory service object modified.
  • 5137 — Directory service object created.
  • 5138 — Directory service object undeleted.
  • 5139 — Directory service object moved.
  • 5141 — Directory service object deleted.
$sensitive = 'CN=Domain Admins','CN=Enterprise Admins','CN=Schema Admins'

Get-WinEvent -FilterHashtable @{
    LogName = 'Security'; Id = 5136, 5137, 5141; StartTime = (Get-Date).AddDays(-7)
} -ErrorAction SilentlyContinue |
    Where-Object {
        $dn = $_.Properties[5].Value
        $sensitive | ForEach-Object { if ($dn -like "*$_*") { return $true } }
    } |
    Select-Object TimeCreated, Id,
                  @{Name='Object'; Expression={ $_.Properties[5].Value }},
                  @{Name='Actor';  Expression={ $_.Properties[1].Value }}

Step 5 — Process and PowerShell execution

These are the foundation for execution-style detections:

  • 4688 — Process creation. Enable Audit Process Creation and the Include command line setting under Detailed Tracking.
  • 4689 — Process termination.
  • 4103 — PowerShell module logging (parameters and module calls).
  • 4104PowerShell script block logging (deobfuscated script source). This is the gold standard for catching encoded payloads.
  • 4105 / 4106 — PowerShell pipeline execution started/stopped.
# Enable script block logging via registry (also configurable in GPO)
$path = 'HKLM:\Software\Policies\Microsoft\Windows\PowerShell\ScriptBlockLogging'
New-Item -Path $path -Force | Out-Null
Set-ItemProperty -Path $path -Name EnableScriptBlockLogging -Value 1

# Hunt 4104 events containing classic loader strings
Get-WinEvent -FilterHashtable @{
    LogName = 'Microsoft-Windows-PowerShell/Operational'; Id = 4104; StartTime = (Get-Date).AddDays(-1)
} -ErrorAction SilentlyContinue |
    Where-Object { $_.Message -match '(-enc|frombase64string|downloadstring|invoke-expression)' } |
    Select-Object TimeCreated,
                  @{Name='User';   Expression={ $_.UserId }},
                  @{Name='Block';  Expression={ ($_.Message -split "`n")[0..2] -join ' / ' }}

Step 6 — Object access (files, registry, scheduled tasks)

Object Access auditing is what makes the 46xx range fire. Once enabled, you get visibility into file, registry, and task changes:

  • 4657 — Registry value modified.
  • 4663 — Object accessed.
  • 4698 — Scheduled task created.
  • 4699 — Scheduled task deleted.
  • 4702 — Scheduled task updated.
  • 5140 / 5145 — Network share accessed; detailed share access.
  • 1102 — Audit log was cleared. Generates regardless of policy. Treat as a high-fidelity alert.

Step 7 — Policy and audit changes

Watch for an attacker turning off the very auditing you are relying on:

  • 4713 — Kerberos policy changed.
  • 4717 / 4718 — System security access granted/removed.
  • 4719 — System audit policy changed.
  • 4739 — Domain policy changed.
  • 4907 — Auditing settings on object were changed.
  • 4912 — Per-user audit policy changed.

Step 8 — Application control (AppLocker / WDAC)

If you run AppLocker or Windows Defender Application Control, the corresponding deny/allow events are highest-value detections:

  • 3001 — AppLocker policy applied.
  • 8002 — Executable/DLL was allowed by policy.
  • 8003 — Executable/DLL was denied by policy.
  • 8004 — Script/MSI was allowed by policy.
  • 8005 — Script/MSI was denied by policy.

Step 9 — Map events to MITRE ATT&CK and common attack patterns

A few patterns are worth pinning to a cheat sheet:

  • Kerberoasting (T1558.003) — bursts of 4769 with RC4-HMAC ticket encryption from a single user against multiple SPNs.
  • DCSync (T1003.006) — 4662 with GUID {1131f6aa-9c07-11d1-f79f-00c04fc2dcd2} ("DS-Replication-Get-Changes-All") from a non-DC principal.
  • Living-off-the-land (T1218) — 4688 with command-line for certutil -urlcache, bitsadmin /transfer, mshta, regsvr32 /s /i.
  • Privilege escalation (T1078) — 4672 immediately followed by 7045 (new service) or 4698 (new scheduled task) for the same user.
  • Audit tampering (T1562.002) — 1102 (audit cleared) or 4719 (audit policy changed) outside of approved change windows.

Frequently Asked Questions

Why are some of these event IDs not firing on my domain controllers?

Almost always because the matching Advanced Audit Policy subcategory is disabled. Verify with auditpol /get /category:* on the DC itself, then push the policy via GPO. Local audit settings can be overridden by GPO at the next refresh, so changing them with auditpol alone is temporary.

What is the relationship between Event ID 4624 LogonType values?

LogonType is in the event payload and tells you how the logon happened. The values that matter for detection are 2 (interactive console), 3 (network — most common), 4 (batch — scheduled task), 5 (service), 7 (unlock), 8 (network plaintext), 10 (RemoteInteractive — RDP), and 11 (cached credential).

Why does my Security log fill up so fast on a DC?

4624 and 4634 dominate volume on a busy DC, often thousands per minute. The fix is either a larger log (4 GB minimum) with rotation, central forwarding so the local log only buffers a short window, or both. Trying to retain a week of DC security events locally rarely works.

Are PowerShell 4104 events still useful if attackers use obfuscation?

Yes. Script block logging captures the deobfuscated text after PowerShell's parser has resolved encoded commands, so the malicious content is visible in plaintext even when the operator passed -EncodedCommand. Obfuscation that survives the parser (string concatenation, variable renaming) still leaves recognisable behavioural primitives like DownloadString.

What is Event ID 1102 and why is it special?

1102 fires whenever the Security log is cleared — by definition, after every other event in the log has been erased. It is one of the very few events that fires regardless of audit policy and survives clearing. Alert on it immediately and unconditionally.

Conclusion

The core list of Windows event IDs for security monitoring is shorter than the documentation suggests — five categories, maybe forty IDs, cover the vast majority of detection use cases. Configure the audit policy that produces them, size the logs to hold a useful window, and standardise the queries you reach for first. Pair the events with MITRE ATT&CK techniques and you have the foundation a SIEM correlation layer needs to do real work.

Related Posts

Authoritative reference: Advanced Security Audit Policy Settings on Microsoft Learn.

Python Quick Guide: Building a Simple Port Scanner

Building a Python port scanner is a useful exercise for any defender who wants to understand what attackers' first reconnaissance actually looks like — and, more usefully, what an internal asset-discovery sweep across your own network looks like from a tool's perspective. This post walks through a small but real scanner intended for authorised scanning of systems you own or administer. For production discovery, use Nmap; this implementation is here to teach you what Nmap does underneath.

Key Takeaways

  • A working TCP-connect scanner is ~100 lines of Python using socket and concurrent.futures; you do not need root or raw packets.
  • Threading is essential — scanning 1024 ports sequentially with a one-second timeout takes 17 minutes; with 50 workers it takes ~25 seconds.
  • Connect scans complete the three-way handshake, so the destination logs the connection. This is intentional for authorised scanning; do not run this against systems you do not own.
  • For production network discovery, asset inventory, or pentesting, use Nmap. It is more accurate, faster, and battle-tested.
  • Treat the scanner as a learning step toward understanding firewall, IDS, and EDR behaviour, not as a long-term replacement tool.

Environment

  • Python 3.10+ (uses concurrent.futures.ThreadPoolExecutor and type hints).
  • Windows, macOS, or Linux — sockets are stdlib.
  • Network connectivity to the target you have explicit authorisation to scan.
  • No root or admin rights needed for TCP-connect scanning. UDP and SYN scanning would; we are deliberately staying in user space.

The Problem

Network ports are the entry points for everything that runs on a host. Knowing which are open, which service answers, and how it identifies itself is the first question of both attack and defence. Nmap answers that question better than any homemade script, but rolling a simple version forces you to confront the actual mechanics: socket states, timeouts, banner grabbing, false negatives caused by firewalls, and why scan rate matters. Five percent of the value of writing one is the script. Ninety-five percent is internalising what the script is doing.

The Solution

Step 1 — Scaffold the scanner

Start with a class that captures the target, port range, and worker count. concurrent.futures replaces the manual queue and thread plumbing — fewer lines, fewer bugs:

import socket
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass
from typing import Iterable

@dataclass
class PortResult:
    port: int
    open: bool
    service: str = ""
    banner: str = ""


class PortScanner:
    def __init__(self, target: str, ports: Iterable[int], workers: int = 50, timeout: float = 1.0):
        self.target  = target
        self.ports   = list(ports)
        self.workers = workers
        self.timeout = timeout

    def _probe(self, port: int) -> PortResult:
        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
            s.settimeout(self.timeout)
            if s.connect_ex((self.target, port)) != 0:
                return PortResult(port=port, open=False)
            service = self._service_name(port)
            banner  = self._banner(s)
            return PortResult(port=port, open=True, service=service, banner=banner)

    def scan(self) -> list[PortResult]:
        with ThreadPoolExecutor(max_workers=self.workers) as pool:
            futures = [pool.submit(self._probe, p) for p in self.ports]
            results = [f.result() for f in as_completed(futures)]
        return sorted([r for r in results if r.open], key=lambda r: r.port)

connect_ex returns 0 on success and the OS error number on failure, which is exactly what we need. connect would raise on failure, forcing exception-handling overhead in the hot path.

Step 2 — Resolve service names

The operating system already knows what service usually runs on a given port. socket.getservbyport consults /etc/services on Unix or %SystemRoot%\system32\drivers\etc\services on Windows:

    @staticmethod
    def _service_name(port: int) -> str:
        try:
            return socket.getservbyport(port, 'tcp')
        except OSError:
            return 'unknown'

This is a hint, not ground truth. The service running on port 22 might not be SSH, and the service running on 8080 might be anything. Banner grabbing in the next step is more reliable when it works.

Step 3 — Grab a banner where the protocol lets you

Some protocols volunteer a banner on connect (SSH, SMTP, FTP). Others wait for a request (HTTP). A short read with a tight timeout covers both cases without blocking on silent ports:

    def _banner(self, sock: socket.socket) -> str:
        try:
            sock.settimeout(0.5)
            data = sock.recv(256)
            return data.decode(errors='replace').strip()
        except OSError:
            return ''

An empty banner does not mean the port is closed — it means the service did not chatter on connect. For HTTP, sending a single HEAD / HTTP/1.0\r\n\r\n before reading recovers the response line.

Step 4 — Add a small CLI

argparse keeps the entry point thin and self-documenting:

import argparse
import time

def main():
    parser = argparse.ArgumentParser(description='Authorised TCP-connect port scanner')
    parser.add_argument('target', help='Host or IP you have permission to scan')
    parser.add_argument('-s', '--start',   type=int,   default=1)
    parser.add_argument('-e', '--end',     type=int,   default=1024)
    parser.add_argument('-w', '--workers', type=int,   default=50)
    parser.add_argument('-t', '--timeout', type=float, default=1.0)
    args = parser.parse_args()

    scanner = PortScanner(args.target, range(args.start, args.end + 1), args.workers, args.timeout)
    print(f'Scanning {args.target} ports {args.start}-{args.end} ({args.workers} workers)')

    start   = time.time()
    results = scanner.scan()
    elapsed = time.time() - start

    print(f'\nFound {len(results)} open ports in {elapsed:.2f}s\n')
    print(f'{"PORT":<8}{"SERVICE":<12}BANNER')
    for r in results:
        print(f'{r.port:<8}{r.service:<12}{r.banner[:80]}')


if __name__ == '__main__':
    main()

Running it: python scanner.py 192.0.2.10 -s 1 -e 65535 -w 200 -t 0.5. Pulling the worker count up and the timeout down is the easiest way to trade accuracy for speed.

Step 5 — Understand what you cannot see

TCP-connect scanning has limits worth knowing before you trust the output:

  • Filtered ports look closed. A stateful firewall that drops packets silently is indistinguishable from a closed port at the socket layer. Nmap's -sS SYN scan can sometimes distinguish them; connect_ex cannot.
  • UDP requires a different probe. UDP has no handshake, so absence of a reply is ambiguous. Application-specific probes (DNS query to port 53, SNMP getRequest to 161) are how Nmap handles it.
  • Rate limiting hides services. Many environments rate-limit new TCP connections. Push too many workers, lose results to silent drops.
  • You are logged. A connect scan completes a three-way handshake. Firewalls, IDS, EDR, and the host itself all record the event. Treat this as expected behaviour for authorised testing.

Step 6 — Compare against Nmap on the same target

For any non-trivial use, run Nmap on the same target and compare. The equivalent commands:

# Same shape as our scanner
nmap -p1-1024 -sT --max-rate 200 target

# Add service detection
nmap -p1-1024 -sT -sV target

# SYN scan, faster and more accurate (requires root)
sudo nmap -p- -sS -T4 target

Nmap's NSE engine, OS fingerprinting, and version detection are decades of work. Our scanner exists to demystify the mechanics; Nmap exists to actually do the job in production.

Frequently Asked Questions

Is it legal to run this scanner?

On systems you own or have written permission to test — yes. Against arbitrary public targets — almost certainly not, depending on jurisdiction. Most jurisdictions treat unauthorised port scanning as at minimum a terms-of-service violation and often an offence under computer-misuse law. Always have explicit written authorisation before scanning anything you do not own.

Why use threads rather than asyncio?

Threads work cleanly with the blocking socket API and need very little code. asyncio is the right tool when you scale past low-thousands of concurrent probes, but the added complexity is not justified for a 1024-port sweep.

Why are my scan results different from Nmap's?

Most often: a stateful firewall is dropping packets silently and our connect scan reads that as "closed", while Nmap's SYN scan reads it as "filtered". Less often: rate limiting is silently dropping fast probes. Reduce --workers and increase --timeout and re-test.

Can I extend this to do UDP?

Yes, but the simple "connect and see what comes back" approach does not work because UDP is stateless. You need protocol-specific probes (a DNS query for port 53, an NTP request for port 123, etc.). Nmap's nmap-payloads file is the reference set; replicating it is more work than the rest of the scanner combined.

How do I tell whether a port banner is genuine or an opaque proxy?

You cannot, from a single probe. The banner is whatever bytes the service decides to send. Cross-check with protocol-specific probes — for HTTP, request / and look at the headers; for SSH, the version exchange is well-defined; for SMTP, send EHLO and inspect the response.

Conclusion

Writing your own port scanner is one of those rare exercises where the act of writing it is the entire point. The script itself does not do anything Nmap cannot do better — but once you have written it, you understand exactly why TCP-connect scanning leaves traces, why UDP scanning is hard, why rate limits matter, and what a "filtered" port actually means. After that, treat Nmap as the production tool and your scanner as a teaching artefact. Both have their place; do not confuse the roles.

Related Posts

Authoritative reference for the real tool: Nmap Reference Guide (man page).

PowerShell Quick Guide: Creating Your First Security Audit Script

A first PowerShell security audit script is the single most valuable thing a new Windows admin can build. Not the polished, branded compliance scanner — the messy two-hundred-line file you keep refining for a year that collects exactly the signals your environment cares about. This guide walks through the structure we use for that script, in a shape you can paste into a new project and grow from.

Key Takeaways

  • A useful audit script is modular: one function per signal area (system info, security settings, accounts, services, network), called from one top-level loop.
  • The collection format should be a list of PSCustomObject, which trivially exports to CSV, JSON, or HTML at the end.
  • The script is for systems you administer. For production-scale monitoring, integrate the same signals into Microsoft Defender for Endpoint or a SIEM rather than scheduling a script everywhere.
  • Run elevated. Most of the interesting properties (Security log, process command lines, signature paths) require local administrator rights.
  • Output to a timestamped file so you can diff today's audit against last week's and see what changed.

Environment

  • Windows 10/11 or Windows Server 2019/2022.
  • Windows PowerShell 5.1 or PowerShell 7.4. The script below uses Get-CimInstance so it works on both.
  • Local administrator rights on every target machine.
  • Optional: an existing CSV-friendly central share for collected reports.

The Problem

Most "first audit script" examples run a single Get-* cmdlet, format the output, and call it a day. That gives you a snapshot, not an audit. A useful script does three things: collects a defined set of signals, structures the output so it can be compared over time, and stays small enough to read in one sitting. The structure below grew out of running the same audit on three hundred machines a week for over a year.

The Solution

Step 1 — Define a script signature and a single output path

Start with parameters that make the script schedule-friendly and a single timestamped output path so multiple runs do not collide:

[CmdletBinding()]
param(
    [string]$OutputDirectory = "$env:ProgramData\SecurityAudit",
    [ValidateSet('Csv','Json','Both')] [string]$Format = 'Both'
)

$ErrorActionPreference = 'Stop'
$timestamp  = Get-Date -Format 'yyyyMMdd_HHmmss'
$hostName   = $env:COMPUTERNAME
$reportPath = Join-Path $OutputDirectory "$hostName`_$timestamp"

New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null

[CmdletBinding()] turns the script into an advanced function and lights up -Verbose output for free. Worth it for almost zero effort.

Step 2 — One function per signal area

Splitting the collection into named functions keeps each one short and lets you reuse them in other scripts. Five areas cover most of what we care about:

function Get-SystemSignal {
    $os = Get-CimInstance Win32_OperatingSystem
    $cs = Get-CimInstance Win32_ComputerSystem
    [PSCustomObject]@{
        Hostname     = $env:COMPUTERNAME
        OS           = $os.Caption
        Build        = $os.Version
        Architecture = $os.OSArchitecture
        LastBoot     = $os.LastBootUpTime
        Domain       = $cs.Domain
        Manufacturer = $cs.Manufacturer
        Model        = $cs.Model
        MemoryGB     = [math]::Round($cs.TotalPhysicalMemory/1GB,1)
        UptimeDays   = [math]::Round(((Get-Date) - $os.LastBootUpTime).TotalDays,1)
    }
}

function Get-SecuritySignal {
    [PSCustomObject]@{
        FirewallProfiles = (Get-NetFirewallProfile |
            ForEach-Object { "$($_.Name)=$($_.Enabled)" }) -join '; '
        Antivirus        = (Get-CimInstance -Namespace root\SecurityCenter2 -ClassName AntiVirusProduct -ErrorAction SilentlyContinue).DisplayName -join '; '
        UACEnabled       = ((Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System').EnableLUA -eq 1)
        SecureBoot       = (Confirm-SecureBootUEFI -ErrorAction SilentlyContinue)
        BitLockerC       = (Get-BitLockerVolume -MountPoint 'C:' -ErrorAction SilentlyContinue).ProtectionStatus
        PSVersion        = $PSVersionTable.PSVersion.ToString()
    }
}

function Get-AccountSignal {
    [PSCustomObject]@{
        EnabledLocalUsers = (Get-LocalUser |
            Where-Object Enabled).Name -join ', '
        LocalAdministrators = (Get-LocalGroupMember -Group Administrators |
            Select-Object -ExpandProperty Name) -join ', '
        AccountsNeverExpire = (Get-LocalUser |
            Where-Object { $_.Enabled -and $_.PasswordNeverExpires }).Name -join ', '
    }
}

function Get-ServiceSignal {
    $suspect = Get-CimInstance Win32_Service |
        Where-Object { $_.PathName -match 'temp|appdata|users\\public|programdata' }
    [PSCustomObject]@{
        SuspectPathServices = ($suspect | Select-Object -ExpandProperty Name) -join ', '
        AutoStoppedServices = (Get-Service |
            Where-Object { $_.StartType -eq 'Automatic' -and $_.Status -eq 'Stopped' } |
            Select-Object -ExpandProperty Name) -join ', '
    }
}

function Get-NetworkSignal {
    $public = Get-NetTCPConnection -State Established |
        Where-Object { $_.RemoteAddress -notmatch '^10\.|^172\.(1[6-9]|2[0-9]|3[01])\.|^192\.168\.|^127\.|^::1' }
    [PSCustomObject]@{
        EstablishedConnections   = (Get-NetTCPConnection -State Established | Measure-Object).Count
        ExternalConnectionCount  = ($public | Measure-Object).Count
        ListeningPorts           = ((Get-NetTCPConnection -State Listen).LocalPort | Sort-Object -Unique) -join ', '
    }
}

Each function returns a single object with the same shape every run. That stability is what makes day-over-day diffs trivial.

Step 3 — Run the collection and assemble one record

The top-level loop wraps each signal function in a try/catch so a single broken section does not lose the whole audit:

function Invoke-Signal {
    param([scriptblock]$Block, [string]$Name)
    try   { & $Block }
    catch {
        Write-Warning "$Name failed: $($_.Exception.Message)"
        [PSCustomObject]@{ Error = $_.Exception.Message }
    }
}

$record = [ordered]@{
    Timestamp = Get-Date
    Host      = $hostName
    System    = Invoke-Signal { Get-SystemSignal   } 'System'
    Security  = Invoke-Signal { Get-SecuritySignal } 'Security'
    Accounts  = Invoke-Signal { Get-AccountSignal  } 'Accounts'
    Services  = Invoke-Signal { Get-ServiceSignal  } 'Services'
    Network   = Invoke-Signal { Get-NetworkSignal  } 'Network'
}

The [ordered] hashtable keeps the section order stable, which matters once you start visually diffing reports.

Step 4 — Persist in the formats you actually consume

CSV is for analysts who want to filter in Excel; JSON is for downstream tooling. Write both unless you have a reason not to:

if ($Format -in 'Json','Both') {
    $record | ConvertTo-Json -Depth 6 |
        Set-Content -Path "$reportPath.json" -Encoding UTF8
}

if ($Format -in 'Csv','Both') {
    # Flatten the nested structure into a single row per signal area
    foreach ($section in 'System','Security','Accounts','Services','Network') {
        $row = $record[$section] |
            Select-Object @{Name='Section';Expression={$section}}, * -ExcludeProperty PS*
        $row | Export-Csv -Path "$reportPath.csv" -NoTypeInformation -Encoding UTF8BOM -Append
    }
}

UTF8BOM keeps Excel from mojibaking non-ASCII characters; see the CSV guide linked below for the long version.

Step 5 — Summarise on the console, write detail to disk

Interactive runs benefit from a short summary block. Keep the detail in the file:

Write-Host ""
Write-Host "Audit complete on $hostName at $(Get-Date)" -ForegroundColor Green
Write-Host "  OS:              $($record.System.OS)"
Write-Host "  Uptime (days):   $($record.System.UptimeDays)"
Write-Host "  Local admins:    $($record.Accounts.LocalAdministrators)"
Write-Host "  Ext. connections:$($record.Network.ExternalConnectionCount)"
Write-Host "  Report:          $reportPath"

Console output is just for the operator at the keyboard. The actual record on disk is what feeds the comparison.

Step 6 — Schedule the script and diff successive runs

Register the script as a scheduled task running daily under SYSTEM. Point the output path at a writable share or sync it to one. The day-over-day diff is where the value actually lives — new local admin, new auto-stopped service, new external listening port, new unsigned service binary are all signals worth chasing:

Compare-Object -ReferenceObject  (Get-Content .\HOST_yesterday.json | ConvertFrom-Json) `
               -DifferenceObject (Get-Content .\HOST_today.json     | ConvertFrom-Json) `
               -Property Accounts.LocalAdministrators

Frequently Asked Questions

Why not use a real compliance scanner like Microsoft Defender or CIS-CAT?

You should, in production. The point of a hand-written script is to teach you what the platform actually exposes, to capture environment-specific signals that off-the-shelf tools miss, and to have something runnable in five seconds during an incident on a box that does not have an agent installed yet.

Should I run this on a Domain Controller?

Yes, but extend the signal list. DCs have additional auditable surfaces — replication health, FSMO role placement, AD recycle bin state, KDS root key, gMSA password retrievers. The same five-function structure scales naturally.

How do I run this across a fleet?

Wrap the body in Invoke-Command -ComputerName $list -ScriptBlock { ... } or push the script via Group Policy and have each host write to a central share. The Remote Management guide linked below covers the patterns.

Why use PSCustomObject instead of a hashtable?

PSCustomObject renders predictably through Export-Csv and ConvertTo-Json, preserves property order in PowerShell 7, and behaves like a real object in the pipeline. Hashtables are fine for keyed lookups, not for tabular output.

What signals should I add next?

The four we add second are: scheduled tasks created in the last 14 days, Authenticode signature status on every service executable, registry persistence locations, and current Group Policy applied. Each is a few lines; together they more than double the value of the audit.

Conclusion

An audit script is not a one-off deliverable; it is a long-running record of what "normal" looks like on the systems you administer. Start with the five signal areas above, keep the structure stable, and add a function whenever a new question turns out to be one you ask twice. Within a quarter you will have something more useful for your specific environment than any out-of-the-box scanner.

Related Posts

Authoritative reference for the script structure: PowerShell Cmdlet Overview on Microsoft Learn.

PowerShell Quick Guide: Essential One-Liners Every Threat Hunter Should Know

PowerShell threat hunting one-liners are the equivalent of a paramedic's pocket card — not where you do the bulk of the work, but exactly what you reach for when something looks wrong and you need an answer right now. The collection below is the one we paste from memory during the first five minutes of looking at a possibly-compromised Windows host. Everything here is read-only and intended for authorised triage on systems you administer.

Key Takeaways

  • One-liners are for quick orientation, not continuous monitoring. For that, use a SIEM or EDR product such as Microsoft Defender for Endpoint.
  • The most useful triage signals are processes (parent-child + command line), services running from unusual paths, autorun registry entries, and outbound network connections.
  • Get-CimInstance beats Get-Process for triage because it exposes ParentProcessId, CommandLine, and creation timestamps.
  • -ErrorAction SilentlyContinue is essential when walking the filesystem or registry as a non-elevated user; without it, the output is buried in access-denied errors.
  • Pipe anything interesting to Export-Csv -Encoding UTF8BOM to share results with another analyst.

Environment

  • Windows 10/11 or Windows Server 2019/2022.
  • Windows PowerShell 5.1 or PowerShell 7.4 — every snippet works on both unless flagged.
  • Local administrator rights. Several queries silently truncate output without elevation.
  • Authorised access to the host. These commands are read-only but should still only run with permission.

The One-Liners

Top CPU consumers right now

Get-Process | Sort-Object CPU -Descending | Select-Object -First 10 ProcessName, Id, CPU, @{N='MB';E={[math]::Round($_.WorkingSet64/1MB,1)}}

Quick orientation. Anything sitting at 80–100% CPU that you do not recognise is your first lead — particularly common with coin miners and unpacked loaders.

Processes with their parent and command line

$p = Get-CimInstance Win32_Process; $p | Select-Object ProcessId,Name,CommandLine,@{N='Parent';E={($p|? ProcessId -eq $_.ParentProcessId).Name}} | Sort-Object Parent,Name

Living-off-the-land abuse hides in this view. Look for Office applications, outlook.exe, or browser processes spawning cmd.exe, powershell.exe, wscript.exe, mshta.exe, or regsvr32.exe.

PowerShell command lines with suspicious arguments

Get-CimInstance Win32_Process -Filter "Name='powershell.exe' OR Name='pwsh.exe'" | Where-Object { $_.CommandLine -match '(-enc|-encodedcommand|frombase64string|downloadstring|downloadfile|-w hidden|-windowstyle hidden)' } | Select-Object ProcessId,ParentProcessId,CommandLine

Catches the classic encoded-payload patterns. Expect false positives from internal deployment tooling; build a known-good list before alerting.

Established connections to non-RFC1918 destinations

Get-NetTCPConnection -State Established | Where-Object { $_.RemoteAddress -notmatch '^10\.|^172\.(1[6-9]|2[0-9]|3[01])\.|^192\.168\.|^127\.|^::1' } | Select-Object LocalPort,RemoteAddress,RemotePort,@{N='Process';E={(Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue).Name}}

Filters out RFC 1918 ranges and loopback. Remaining rows are processes talking out to the public internet — handy for spotting unusual C2-style traffic.

Services running from temporary or user-writable paths

Get-CimInstance Win32_Service | Where-Object { $_.PathName -match 'temp|appdata|programdata|users\\public' } | Select-Object Name,DisplayName,PathName,StartName,State

Legitimate services almost never live in user-writable paths. Anything matching here deserves a closer look at the binary signature.

Services set to auto-start but currently stopped

Get-Service | Where-Object { $_.StartType -eq 'Automatic' -and $_.Status -eq 'Stopped' } | Select-Object Name,DisplayName,Status,StartType

Sometimes attackers disable defensive services (Defender, Windows Update, BITS) without unsetting auto-start. Compare against a known-good baseline.

Autorun entries from the four classic Run keys

'HKLM:\Software\Microsoft\Windows\CurrentVersion\Run','HKLM:\Software\Microsoft\Windows\CurrentVersion\RunOnce','HKCU:\Software\Microsoft\Windows\CurrentVersion\Run','HKCU:\Software\Microsoft\Windows\CurrentVersion\RunOnce' | ForEach-Object { Get-ItemProperty $_ -ErrorAction SilentlyContinue | Select-Object PSPath,* -ExcludeProperty PS* } 

The simplest persistence anchor. For broader coverage use Sysinternals Autoruns; this one-liner catches the most common locations.

Recently created executables outside Program Files

Get-ChildItem -Path C:\ -Include *.exe,*.dll,*.ps1,*.bat,*.cmd -Recurse -ErrorAction SilentlyContinue | Where-Object { $_.CreationTime -gt (Get-Date).AddHours(-24) -and $_.FullName -notmatch 'Program Files|Windows\\(System32|SysWOW64|WinSxS|servicing|assembly)' } | Select-Object FullName,CreationTime,Length

Filesystem-level new-arrival check. Tune the time window and exclusion list to match what your environment legitimately drops.

Scheduled tasks created in the last seven days

Get-ScheduledTask | Get-ScheduledTaskInfo | Where-Object { $_.LastRunTime -gt (Get-Date).AddDays(-7) -or $_.NextRunTime -gt (Get-Date) } | Select-Object TaskName,LastRunTime,NextRunTime,LastTaskResult

Scheduled tasks are a top persistence mechanism. Cross-reference with the dedicated detection post linked below for deeper analysis.

Local admins (including nested group members)

Get-LocalGroupMember -Group 'Administrators' | Select-Object Name,PrincipalSource,ObjectClass

Anyone in Administrators who is not Administrator or your expected admin group is worth a question. Domain group nesting is resolved automatically.

Failed logons in the last hour, grouped by user and source IP

Get-WinEvent -FilterHashtable @{LogName='Security';Id=4625;StartTime=(Get-Date).AddHours(-1)} -ErrorAction SilentlyContinue | Select-Object @{N='User';E={$_.Properties[5].Value}},@{N='IP';E={$_.Properties[19].Value}} | Group-Object User,IP | Sort-Object Count -Descending | Select-Object Count,Name -First 20

The fastest way to confirm or rule out an active password attack. For a fleet-wide view, fan the same query out across all DCs with Invoke-Command.

Frequently Asked Questions

Are these one-liners safe to run on production systems?

All of them are read-only. The CPU impact is negligible on modern hardware. The only operational risk is the filesystem walk on C:\ — on a server with millions of files that can take minutes and produce a long list. Scope it tighter when in doubt.

Do I need to be a local administrator?

Some — yes. CommandLine on Win32_Process, the Security event log, and most registry hives under HKLM require elevation. Without it, the same one-liner silently returns fewer rows, which is misleading. Always run from an elevated shell.

Can I run these against remote machines?

Yes. Wrap any snippet in Invoke-Command -ComputerName $list -ScriptBlock { ... }. The returned objects are deserialised, so most downstream pipeline operators still work. See the remote management guide linked below.

Why not just use Defender for Endpoint or a SIEM?

You should — for ongoing monitoring. These one-liners are for the moment you do not have the tool, the agent has not yet been deployed, or you need to confirm something interactively before paging an analyst. They complement, not replace, proper telemetry.

How do I export the output for a colleague?

Append | Export-Csv -Path .\out.csv -NoTypeInformation -Encoding UTF8BOM to any of them. The CSV export guide linked below covers the encoding flags that keep Excel from mangling non-ASCII characters.

Conclusion

A useful one-liner is one you can type from memory at 2 a.m. when something is on fire. Pick the four or five from this list that map to the questions you most often ask, drop them in a profile module, and they become reflexes. They will not solve a real incident on their own — that is what your EDR, SIEM, and IR runbook are for — but they will get you to "is this actually a problem?" within minutes rather than hours.

Related Posts

Reference for the WMI/CIM provider that most of these snippets lean on: CIM Win32 Provider on Microsoft Learn.