This guide assumes you have script-block logging and module logging enabled (covered in the logging post) and that you have read access to the
Microsoft-Windows-PowerShell/Operationallog on the hosts you want to hunt across.
Everything in this post is defensive. The goal is to find malicious activity in your own logs, not to write or distribute offensive tooling.
The worst time to learn what a Powershell payload looks like is during an incident. This post walks through the patterns real attackers use, why they use them, and how to surface them with a few hundred lines of detection code.
The Hunter's Dataset
Every detection in this post runs against the same source: event ID 4104 script-block logs.
function Get-ScriptBlockEvent
{
[CmdletBinding()]
param(
[int]$LastHours = 24,
[string]$ComputerName = $env:COMPUTERNAME
)
$start = (Get-Date).AddHours(-$LastHours)
Get-WinEvent -ComputerName $ComputerName -FilterHashtable @{
LogName = 'Microsoft-Windows-PowerShell/Operational'
Id = 4104
StartTime = $start
} | ForEach-Object {
[pscustomobject]@{
Time = $_.TimeCreated
Host = $_.MachineName
User = $_.UserId
ScriptId = $_.Properties[3].Value # ScriptBlockId
Path = $_.Properties[4].Value # Path (often empty)
Script = $_.Properties[2].Value # The actual script content
Length = ($_.Properties[2].Value).Length
}
}
}
Every detection function below takes this stream as input. Cache an hour or two of events into a variable when iterating on rules re-querying EventLog repeatedly is slow.
$events = Get-ScriptBlockEvent -LastHours 6
Pattern 1: Encoded Commands
powershell.exe -EncodedCommand <base64> is the classic LOLBin pattern. Almost no legitimate operator uses it interactively. A reliable signal:
function Find-EncodedCommand
{
param([Parameter(ValueFromPipeline)] $Events)
process
{
$Events | Where-Object {
$_.Script -match '(?i)-(e|en|enc|enco|encod|encode|encoded|encodedc|encodedco|encodedcom|encodedcomm|encodedcomma|encodedcomman|encodedcommand)\b' -or
$_.Script -match '(?i)\bFromBase64String\b'
}
}
}
$events | Find-EncodedCommand | Format-Table Time, Host, Length, @{ N='Snippet'; E={ $_.Script.Substring(0, [Math]::Min(120, $_.Script.Length)) } }
The verbose regex catches all the abbreviated forms (
-e,-enc,-encodedc) that Powershell will accept. Real-world malware uses these constantly to evade naive-EncodedCommandmatches.
Then auto-decode the matches:
function Expand-EncodedCommand
{
param([string]$Script)
if ($Script -match '-(?:e|en|enc|enco|encod|encode|encoded|encodedc|encodedco|encodedcom|encodedcomm|encodedcomma|encodedcomman|encodedcommand)\s+([A-Za-z0-9+/=]{20,})')
{
try
{
[Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($matches[1]))
} catch { $null }
}
}
$events | Find-EncodedCommand | ForEach-Object {
[pscustomobject]@{
Time = $_.Time
Host = $_.Host
Decoded = Expand-EncodedCommand $_.Script
}
} | Where-Object Decoded
Pattern 2: Download-and-Execute
The most-stolen attacker pattern in the world. Variations of IEX (New-Object Net.WebClient).DownloadString('http://...').
function Find-DownloadCradle
{
param([Parameter(ValueFromPipeline)] $Events)
process
{
$patterns = @(
'Net\.WebClient',
'Invoke-WebRequest',
'Invoke-RestMethod',
'Start-BitsTransfer',
'DownloadString',
'DownloadFile',
'DownloadData',
'WebRequest::Create'
)
$iex = '\b(IEX|Invoke-Expression|&\s*\(|\.Invoke\(\s*\))\b'
$Events | Where-Object {
$script = $_.Script
($patterns | Where-Object { $script -match $_ }) -and ($script -match $iex)
}
}
}
The combination of "download something" + "execute it as code" is what makes this high-signal. Either alone is normal; both together, in the same script block, almost never is.
Pattern 3: Suspicious Obfuscation
Real-world obfuscators (Invoke-Obfuscation, GootLoader, etc.) leave statistical fingerprints. A few cheap heuristics:
function Get-ObfuscationScore
{
[CmdletBinding()]
[OutputType([int])]
param([string]$Script)
$score = 0
# 1. Backtick density (escape character spam)
$backticks = ([regex]::Matches($Script, '`')).Count
if ($Script.Length -gt 0 -and ($backticks / $Script.Length) -gt 0.02) { $score += 2 }
# 2. Char concatenation: 'IE'+'X'
if ($Script -match "(['""]\w['""]\s*\+\s*['""]\w['""]\s*\+){3,}") { $score += 3 }
# 3. Char codes: [char]65 + [char]66 + ...
if (([regex]::Matches($Script, '\[char\]\d+')).Count -gt 5) { $score += 3 }
# 4. Heavy use of -join, -split, -replace
$joins = ([regex]::Matches($Script, '-join|-split|-replace')).Count
if ($joins -gt 5) { $score += 2 }
# 5. Format-string ordering: ('{2}{0}{1}' -f ...)
if ($Script -match "['""]\{[0-9]+\}\{[0-9]+\}\{[0-9]+\}.*?['""].*-f") { $score += 3 }
# 6. Reflection invocation
if ($Script -match '\[Reflection\.Assembly\]::Load') { $score += 4 }
# 7. Variable name entropy (shannon entropy of variable names > 4 is suspicious)
$vars = [regex]::Matches($Script, '\$[A-Za-z0-9_]{6,}') | Select-Object -ExpandProperty Value -Unique
foreach ($v in $vars)
{
$chars = $v.ToCharArray() | Group-Object
$h = 0
foreach ($g in $chars)
{
$p = $g.Count / $v.Length
$h -= $p * [Math]::Log($p, 2)
}
if ($h -gt 4) { $score += 1; break }
}
return $score
}
$events |
Select-Object Time, Host, @{ N='Score'; E={ Get-ObfuscationScore $_.Script } }, Script |
Where-Object Score -ge 5 |
Sort-Object Score -Descending
A score is a prioritization tool, not a verdict. A 7 is worth looking at; a 12 is almost always interesting; both still need a human eye before you escalate.
Pattern 4: AMSI Bypass Attempts
A small, distinct family of strings that no legitimate script ever contains:
function Find-AmsiBypass
{
param([Parameter(ValueFromPipeline)] $Events)
process
{
$patterns = @(
'amsiInitFailed',
'AmsiScanBuffer',
'System\.Management\.Automation\.AmsiUtils',
'\[Ref\]\.Assembly\.GetType\(\s*[''""]System\.Management\.Automation\.Ams',
'amsi\.dll'
)
$Events | Where-Object {
$s = $_.Script
($patterns | Where-Object { $s -match $_ }).Count -gt 0
}
}
}
Hits here are nearly always real. Treat this detection as P1.
Pattern 5: Suspicious Cmdlet Combinations
Nothing here is malicious in isolation. Together, in the same script block, in the same minute? Almost always interesting.
function Find-LivingOffTheLand
{
param([Parameter(ValueFromPipeline)] $Events)
process
{
$high = @(
'Mimikatz', 'Invoke-Mimikatz', 'Get-PassHashes',
'New-Object Net\.Sockets\.TCPClient',
'Add-Type.*-MemberDefinition.*VirtualAlloc',
'kernel32\.dll.*VirtualAllocEx',
'CreateRemoteThread',
'Set-MpPreference.*-Disable',
'Add-MpPreference.*-ExclusionPath',
'sekurlsa::',
'lsadump::',
'Out-Minidump'
)
$Events | Where-Object {
$s = $_.Script
($high | Where-Object { $s -match $_ }).Count -gt 0
}
}
}
Set-MpPreference -DisableRealtimeMonitoring is the canonical "I'm about to do something bad" canary.
Pattern 6: Persistence Indicators
Where attackers hide so they survive a reboot:
function Find-Persistence
{
param([Parameter(ValueFromPipeline)] $Events)
process
{
$Events | Where-Object {
$_.Script -match 'Register-ScheduledTask|New-ScheduledTaskAction' -or
$_.Script -match 'New-Service\b|Set-Service.*-StartupType\s+Automatic' -or
$_.Script -match 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run' -or
$_.Script -match '\\Microsoft\\Windows\\Start Menu\\Programs\\Startup' -or
$_.Script -match 'WMI.*__EventFilter|__FilterToConsumerBinding' -or
$_.Script -match 'Add-MpPreference.*-ExclusionPath'
}
}
}
Pair this with Get-ScheduledTask, Get-CimInstance Win32_Service, and a baseline of "what did this host look like yesterday" to convert "someone wrote a Run key" into "someone wrote this specific Run key that wasn't there yesterday".
Wiring It All Together
function Invoke-PSHunt
{
[CmdletBinding()]
param([int]$LastHours = 6, [string[]]$ComputerName = @($env:COMPUTERNAME))
foreach ($cn in $ComputerName)
{
$events = Get-ScriptBlockEvent -LastHours $LastHours -ComputerName $cn
$events | Find-EncodedCommand | Add-Member -NotePropertyName Detection -NotePropertyValue 'EncodedCommand' -PassThru
$events | Find-DownloadCradle | Add-Member -NotePropertyName Detection -NotePropertyValue 'DownloadCradle' -PassThru
$events | Find-AmsiBypass | Add-Member -NotePropertyName Detection -NotePropertyValue 'AMSIBypass' -PassThru
$events | Find-LivingOffTheLand | Add-Member -NotePropertyName Detection -NotePropertyValue 'LOLBin' -PassThru
$events | Find-Persistence | Add-Member -NotePropertyName Detection -NotePropertyValue 'Persistence' -PassThru
$events |
Where-Object { (Get-ObfuscationScore $_.Script) -ge 7 } |
Add-Member -NotePropertyName Detection -NotePropertyValue 'Obfuscation' -PassThru
}
}
Invoke-PSHunt -LastHours 24 |
Sort-Object Time |
Format-Table Time, Host, Detection, @{ N='Snippet'; E={ $_.Script.Substring(0, [Math]::Min(150, $_.Script.Length)) } }
Schedule this as a daily task and email the output to your security team. Even if 95% of hits are false positives, the 5% that aren't will more than pay for the noise.
Tuning the Noise
Three things will save you:
- Allow-list paths, not content. Operators run weird-looking but legitimate code from
C:\OpsScripts\constantly. Suppress detections whosePathstarts with a known-good directory. - Allow-list signed scripts. Pull the signing cert (
Get-AuthenticodeSignature) on any.ps1referenced inPath. Code signed by your internal CA is almost certainly not the threat. - Suppress your own monitoring. This script will detect itself if it isn't excluded. Tag your hunting code with a unique marker comment and skip events containing it.
Where to Take This Next
- Push hits into Zabbix as items with severity-mapped values, using the process logging post's pattern. A spike in detections becomes a trigger.
- Cross-reference with Sysmon event IDs
1(process create) and3(network connection). The Powershell event tells you what; Sysmon tells you who started it and who it talked to. - Build a baseline, then alert on delta. "This host ran an encoded command for the first time ever in 90 days" is a vastly stronger signal than "this host ran an encoded command".
What to Do Next
Detection isn't about catching the cleverest attack; it's about making the boring ones impossible to miss. A few hundred lines of pattern matching against script-block logs will catch the vast majority of real-world PowerShell-based intrusions, because the vast majority of real-world PowerShell-based intrusions reuse the same six or seven patterns above.
Three concrete moves to operationalise this:
- Run the pattern-matching function from the post against the last 30 days of
4104events on a representative server. The first run sets a baseline; the false positives you see (legitimate scripts usingIEXor base64) tell you what to whitelist next. - Pick the two highest-fidelity patterns (typically the AMSI bypass and the long base64 payload) and turn them into Sigma rules that fire as alerts in your SIEM. The other patterns become weekly digest reports.
- Build a runbook for each alert. "Alert fired on host X" should always link to "what to check first": pivot to forensics, look at process tree, check parent process. An alert without a runbook gets ignored on the second false positive.
The forensics post is the next step: once a hit lands, how do you reconstruct the session, build a timeline, and hand a defensible case to legal in under an hour.


