Detecting malicious PowerShell with script block logging
Turn PowerShell logs into detections: rebuild 4104 script blocks, use AMSI and engine events, spot obfuscation and unmanaged PowerShell, tune out admin noise.
The PowerShell security pillar turns on the logging. This guide is about what to do with it: where the evidence lands, how to rebuild long scripts from fragments, which patterns separate an attacker from an administrator, and how to tune detections so your analysts still read them next month.
The telemetry you have to work with
| Source | Log | Event | What it gives you |
|---|---|---|---|
| Script block logging | Microsoft-Windows-PowerShell/Operational | 4104 | De-obfuscated script text, script block ID, path |
| Module logging | Microsoft-Windows-PowerShell/Operational | 4103 | Pipeline execution with parameter values |
| Engine lifecycle | Windows PowerShell (classic log) | 400, 403, 600 | Engine start/stop, HostApplication, EngineVersion |
| Process creation | Security | 4688 | Command line and parent (with command-line auditing on) |
| Sysmon | Microsoft-Windows-Sysmon/Operational | 1, 7 | Process tree with hashes; DLL loads |
| AMSI via Defender | Microsoft-Windows-Windows Defender/Operational | 1116, 1117 | Malicious script content detected and acted on |
PowerShell 7 writes to its own PowerShellCore/Operational log once its event manifest is registered, and uses its own policy settings. If PowerShell 7 is installed, collect both logs or you have a blind spot with a different executable name (pwsh.exe).
All of these are only useful off the host. Forward them with Windows Event Forwarding or your agent, and raise the Operational log size so a busy server doesn't roll over before collection.
Step 1: reassemble split script blocks
Large scripts are split across several 4104 events. Each fragment carries the same ScriptBlock ID and a "part n of m" counter. Analysts reading single events see broken code, and detection rules miss patterns that straddle a boundary. Rebuild them first:
$events = Get-WinEvent -FilterHashtable @{
LogName = 'Microsoft-Windows-PowerShell/Operational'; Id = 4104
} -MaxEvents 5000
$events | Group-Object { $_.Properties[3].Value } | ForEach-Object {
$parts = $_.Group | Sort-Object { [int]$_.Properties[0].Value }
[pscustomobject]@{
ScriptBlockId = $_.Name
Time = ($parts | Select-Object -First 1).TimeCreated
Path = $parts[0].Properties[4].Value
Text = ($parts | ForEach-Object { $_.Properties[2].Value }) -join ''
}
}In a SIEM, do the same with a group-by on the script block ID before matching. An empty Path means the code came from an interactive prompt, -Command, -EncodedCommand or memory rather than a file on disk. That alone is a useful pivot.
Step 2: start with what PowerShell already flags
Even without a policy, PowerShell 5.1 logs script blocks containing known-suspicious terms as warning-level 4104 events. With script block logging enabled, those are still marked as warnings among the verbose ones. They make a low-noise first alert:
Get-WinEvent -FilterHashtable @{
LogName = 'Microsoft-Windows-PowerShell/Operational'; Id = 4104; Level = 3
} -MaxEvents 200 | Select-Object TimeCreated, MachineName, @{n='Snippet';e={ $_.Properties[2].Value.Substring(0, [Math]::Min(200, $_.Properties[2].Value.Length)) }}Review every warning-level event for a few weeks. You'll learn which admin scripts trip them and can exclude those by path.
Step 3: hunt for the patterns attackers need
Attack tooling has to do a small number of things, and each leaves words in the script text. Group indicators by what the attacker is trying to achieve:
| Goal | Indicators in 4104 / command lines |
|---|---|
| Download and run | DownloadString, DownloadData, Net.WebClient, Invoke-WebRequest / iwr piped to Invoke-Expression / iex, Start-BitsTransfer to temp paths |
| Hide the payload | FromBase64String, -EncodedCommand and its prefixes (-e, -ec, -enc), IO.Compression.DeflateStream, GzipStream, -bxor |
| Obfuscate strings | Heavy backtick use (`i`e`x), [char] arrays joined with -join, string reversal, format-operator reordering ('{1}{0}' -f), random casing |
| Run in memory | [Reflection.Assembly]::Load, Add-Type with DllImport, VirtualAlloc, GetProcAddress, GetDelegateForFunctionPointer |
| Blind defences | AmsiUtils, amsiInitFailed, cachedGroupPolicySettings, Set-MpPreference with -Disable* switches, Add-MpPreference -ExclusionPath |
| Stay hidden | -WindowStyle Hidden / -w hidden, -NoProfile, -NonInteractive, -ExecutionPolicy Bypass combined with the above |
| Harvest credentials | References to lsass, MiniDumpWriteDump, Get-Credential loops, SecureString conversions from files |
Two things make these rules workable:
- Combine indicators.
-NoProfilealone is everywhere.-NoProfileplus a hidden window plus a download cradle is not. - Normalise first. Lowercase the text and strip backticks before matching, and match command-line flags by prefix.
powershell.exeaccepts/as well as-, and partial parameter names.
The open Sigma rule repository has well-maintained PowerShell rules you can translate to your SIEM rather than writing each from scratch.
Step 4: watch the engine, not just the scripts
Some of the best signals are in the classic Windows PowerShell log and in process telemetry rather than in script text:
- Downgrade attempts. Event 400 with
EngineVersion=2.0means someone started the PowerShell 2.0 engine, which has no script block logging and no AMSI. Alert on every occurrence, and remove the engine as described in the pillar. - Unusual hosts. Event 400 and 600 include
HostApplication. PowerShell running inside something other thanpowershell.exe,pwsh.exeor the ISE is suspicious. - Unmanaged PowerShell. Tools can load the PowerShell engine into any .NET process. Sysmon event 7 for
System.Management.Automation.dll(or its native image) loaded by an unexpected process catches this. Use your Sysmon configuration to include that one DLL rather than logging every image load. - Suspicious parents. 4688 or Sysmon 1 where
powershell.exeis the child ofwinword.exe,excel.exe,outlook.exe,mshta.exe,wmiprvse.exe,w3wp.exeorsqlservr.exe. Office-spawned PowerShell is also blocked outright by attack surface reduction rules.
Step 5: use AMSI detections as high-fidelity alerts
The Antimalware Scan Interface hands script content to Defender at run time, after de-obfuscation. When Defender blocks a script, the user sees "This script contains malicious content and has been blocked by your antivirus software", and Defender logs 1116 (detected) and 1117 (action taken). The detection's resource refers to AMSI and the host process rather than a file on disk.
Treat any AMSI detection on a server as an incident, not an antivirus nuisance. Enable the ASR rule Block execution of potentially obfuscated scripts in audit mode first to see what it would catch.
Step 6: detect attempts to switch off the lights
Attackers who know about logging try to disable it:
- Registry changes under
HKLM\SOFTWARE\Policies\Microsoft\Windows\PowerShell(Sysmon 13, or a SACL with 4657). - Log clearing: 1102 (Security) and 104 (System, for any other log). See the security event ID reference for the rest of the tampering signals.
- A host whose 4104 volume drops to zero while it still reports other events. Build a "silent host" check per log, not just per machine.
- Defender changes: 5001 (real-time protection disabled) and 5007 (configuration changed, including new exclusions).
Step 7: triage a hit
When a rule fires, answer four questions before deciding it's benign:
- Who and where? Account, host and logon session. Pivot on the session to find the 4624 event: an interactive admin at a console is a different story from a network logon arriving from a workstation.
- What launched it? Walk the parent chain in 4688 or Sysmon 1. A scheduled task, a service, an Office document and a remote WinRM session (
wsmprovhost.exe) each point to different entry points. - What did it do next? Look at the reassembled script, then at network connections (Sysmon 3 and 22), file writes (Sysmon 11) and child processes in the minutes that follow.
- Is it known? Search the script block hash or distinctive strings across the fleet. The same script on hundreds of machines is usually an agent; the same script on three servers overnight is usually an intrusion.
Record the answer in the rule's tuning notes, so the next analyst doesn't start from scratch.
What breaks
Detection engineering for PowerShell mostly breaks your analysts' patience:
- Management agents look like malware. Configuration Manager, the Intune Management Extension, monitoring agents and EDR response tooling routinely run PowerShell with
-ExecutionPolicy Bypass,-NoProfileand encoded commands. Exclude them by full parent path and signer, never by process name alone. - Volume. Full script block logging on a busy management server produces a lot of 4104 events, and so does every DSC or monitoring run. Size the log accordingly and filter at the SIEM, not by turning logging off.
- Secrets in logs. Script text includes whatever admins type, including passwords. Restrict read access to the PowerShell logs in your SIEM, or use Protected Event Logging.
Verify it
Test each layer on a lab machine before trusting it in production:
# 1. Script block logging: a harmless, easy-to-find marker
powershell.exe -NoProfile -Command "Write-Output 'sbl-canary-2026'"
# 2. AMSI: Microsoft's documented test string, detected but harmless
'AMSI Test Sample: 7e72c3ce-861b-4339-8740-0ac1484c1386'
# 3. Engine downgrade detection, where the v2 engine still exists in the lab
powershell.exe -Version 2 -Command "exit"Then confirm each event reached the SIEM and fired the expected rule: 4104 containing the canary string, 1116 from Defender, 400 with EngineVersion=2.0. If one of them is missing, fix the pipeline before writing more rules. Constrained Language Mode reduces what attackers can do; detection tells you they tried. More on both on the PowerShell security topic page.
Checklist
- 4103, 4104, 400/403/600 and PowerShellCore/Operational forwarded in near real time
- Script blocks reassembled by ScriptBlock ID before rule matching
- Alert on warning-level 4104 events, tuned by script path
- Combined-indicator rules for download, obfuscation, in-memory execution and defence evasion
- Event 400 with EngineVersion 2.0 alerted
- Sysmon rule for System.Management.Automation.dll loaded by unexpected processes
- Office, web server and WMI parents of PowerShell alerted
- AMSI detections (1116/1117) on servers treated as incidents
- Silent-host and log-tampering detections in place
- Management agents excluded by full path and signer only
Frequently asked questions
Does script block logging defeat obfuscation?
Largely. PowerShell logs each script block as it is compiled, so layers of encoding, compression and string tricks are logged again once they are unwrapped into new script blocks. The outer, obfuscated layer is still logged too, which is itself a strong signal. Attackers who avoid PowerShell's engine entirely, or disable logging first, are the gap you cover with other telemetry.
Should I alert on every -EncodedCommand?
Not blindly. Management agents, installers and monitoring tools use encoded commands legitimately, so a raw alert floods the queue. Alert on encoded commands whose parent process is unusual (Office, browsers, web servers, WMI), or combine the encoding with other indicators such as downloads, hidden windows or reflection. Baseline known agents by full path and exclude them precisely.
What if an attacker disables script block logging?
They need administrative rights and leave traces: registry changes under the PowerShell policy key, a sudden drop in 4104 volume from a host, cleared logs (1102 or 104), or in-memory tampering patterns that are themselves logged before they take effect. Forward events centrally in near real time so the evidence leaves the host before it can be removed.
Detecting malicious PowerShell with script block logging