Most PowerShell-based attacks rely on the same trick: pass a Base64-encoded command, a string concatenation, or a script downloaded at runtime, and hope nothing reads what actually executed. PowerShell Script Block Logging 4104 defeats that assumption by logging the script source after the parser has resolved encoding, concatenation, and variable substitution. This post is the enable-tune-hunt walkthrough for getting 4104 useful in a real environment.
Key Takeaways
- Event ID 4104 in the
Microsoft-Windows-PowerShell/Operationalchannel logs every script block PowerShell compiles — including the deobfuscated content of Base64 and concatenated strings. - 4104 is off by default. Enable it via Group Policy or a one-line registry write; the corresponding registry key is the canonical reference.
- The default channel size overflows quickly on busy hosts. Raise
Microsoft-Windows-PowerShell/Operationalto 1 GB at minimum and forward to a central collector. - Hunt for the few high-signal patterns first:
-EncodedCommand,FromBase64String,DownloadString, and reflectiveAssembly.Loadcalls. They produce one or two hits per fleet per day and are almost always worth reading. - 4104 pairs with Constrained Language Mode and AMSI — the event sees the code; the other two stop or scan it. Run all three.
Environment
- Windows 10/11 and Windows Server 2019/2022 endpoints with PowerShell 5.1 or later (5.1 is the minimum that supports script block logging).
- Group Policy or Intune deployment channel to push the registry key fleet-wide.
- Windows Event Forwarding or a SIEM agent to ship the PowerShell Operational channel off-host. See our WEF setup post.
- PowerShell 5.1 or 7.4 on the collector for hunting queries.
The Problem
PowerShell is the default execution environment for both legitimate administration and a large share of post-exploitation tooling on Windows. Empire, PowerSploit, Covenant, and most commodity loaders all reach for PowerShell because it is signed, present on every host, and trusted by application control. The mitigations Microsoft added in PowerShell 5.0 — script block logging, AMSI, Constrained Language Mode, transcription — are what make PowerShell defensible. Of those, 4104 is the one that produces the analyst-readable artefact: the actual code that ran, after the parser unrolled everything.
The wrinkle is that 4104 is off out of the box, and even when it is on, the channel size is too small to be useful on a busy host. A single web shell that loops every five seconds will fill the default 15 MB log in an afternoon. Detection engineering against 4104 starts with the operational work of enabling it correctly, forwarding it, and writing queries that pick out the few patterns worth alerting on.
The Solution
Step 1 — Enable script block logging
The supported path is Group Policy: Computer Configuration → Administrative Templates → Windows Components → Windows PowerShell → Turn on PowerShell Script Block Logging → Enabled. The "Log script block invocation start / stop events" checkbox underneath generates additional 4105/4106 events for every block — useful for execution chaining, noisy for everything else.
The registry equivalent is one key, scriptable for non-domain endpoints:
# Enable on a single host
$path = 'HKLM:\Software\Policies\Microsoft\Windows\PowerShell\ScriptBlockLogging'
New-Item -Path $path -Force | Out-Null
Set-ItemProperty -Path $path -Name 'EnableScriptBlockLogging' -Value 1 -Type DWord
Microsoft's authoritative reference for the logging settings is in the about_Logging_Windows documentation. The setting takes effect on the next PowerShell session — running sessions are not retroactively instrumented.
Step 2 — Size the channel and add transcription
Before traffic builds up, raise the channel size on every endpoint:
wevtutil sl 'Microsoft-Windows-PowerShell/Operational' /ms:1073741824 # 1 GB
For high-value hosts (jump boxes, admin workstations, build servers), enable transcription as well — script block logging captures what was compiled, transcription captures the full input and output as a text file:
$tx = 'HKLM:\Software\Policies\Microsoft\Windows\PowerShell\Transcription'
New-Item -Path $tx -Force | Out-Null
Set-ItemProperty -Path $tx -Name 'EnableTranscripting' -Value 1 -Type DWord
Set-ItemProperty -Path $tx -Name 'EnableInvocationHeader' -Value 1 -Type DWord
Set-ItemProperty -Path $tx -Name 'OutputDirectory' -Value '\\fileserver\pstx$\%COMPUTERNAME%' -Type String
Point transcripts to a write-only network share that the host can append to but not read back. That stops a compromised endpoint from tampering with its own transcript history while keeping the data centralised.
Step 3 — Where the deobfuscated text actually lives
Large script blocks split across multiple 4104 events, with two fields used to stitch them back together: MessageNumber and MessageTotal. The deobfuscated source is in the event message body, not in a separate property. The crucial point is that PowerShell logs the script after its parser has processed the input — passing -EncodedCommand <Base64> produces a 4104 containing the decoded UTF-16 source, not the Base64 string the operator typed.
String concatenation that survives the parser (assembling a command from variables, building method names character by character) is still visible — the 4104 logs the resulting compiled block, which contains the concatenated literals as written. Obfuscation that defeats 4104 has to defeat compilation itself, which leaves less room than most operators realise.
Step 4 — Hunt encoded payloads and download cradles
The single highest-signal query against 4104 is a regex over the message body for known loader strings:
# Encoded-or-downloaded payloads in the last 24h
Get-WinEvent -FilterHashtable @{
LogName = 'ForwardedEvents'
ProviderName = 'Microsoft-Windows-PowerShell'
Id = 4104
StartTime = (Get-Date).AddDays(-1)
} -ErrorAction SilentlyContinue |
Where-Object {
$_.Message -match '(?i)(\-enc(odedcommand)?|frombase64string|downloadstring|downloadfile|invoke-webrequest\s+[^|]+\.(ps1|exe|dll))'
} |
Select-Object TimeCreated, MachineName,
@{Name='User'; Expression={ $_.UserId }},
@{Name='Snippet'; Expression={ ($_.Message -split "`n")[0..3] -join ' / ' }}
This will catch the obvious cases. A typical environment produces a handful of hits per day — almost all of them either legitimate admin automation (treat as a tuning opportunity, allowlist by signed cert or known path) or genuinely worth investigating.
Step 5 — Hunt reflective .NET loading
A more advanced loader pattern: load a .NET assembly from a byte array in memory, never touching disk. Cobalt Strike's PowerShell stager, plenty of public C2 frameworks, and a fair number of red-team tools use it:
Get-WinEvent -FilterHashtable @{
LogName = 'ForwardedEvents'
ProviderName = 'Microsoft-Windows-PowerShell'
Id = 4104
StartTime = (Get-Date).AddDays(-1)
} -ErrorAction SilentlyContinue |
Where-Object {
$_.Message -match '\[Reflection\.Assembly\]::Load\(' -or
$_.Message -match 'System\.Reflection\.AssemblyName' -or
$_.Message -match 'GetDelegateForFunctionPointer'
} |
Select-Object TimeCreated, MachineName, Id,
@{Name='Snippet'; Expression={ ($_.Message -split "`n")[0..5] -join ' / ' }}
Reflective loading has almost no legitimate use case in modern administration — virtually every hit is worth a closer look at the host. Pair with Sysmon event 10 (process access) against lsass.exe from the same host to chain credential-access tooling.
Step 6 — Watch for AMSI tampering
AMSI (Antimalware Scan Interface) is the kernel-mode bridge that lets antivirus inspect PowerShell script content at parse time. Operators routinely try to break AMSI by patching amsi.dll in memory or setting amsiInitFailed via reflection. The bypass code itself is a 4104 event:
Get-WinEvent -FilterHashtable @{
LogName = 'ForwardedEvents'
ProviderName = 'Microsoft-Windows-PowerShell'
Id = 4104
StartTime = (Get-Date).AddDays(-7)
} -ErrorAction SilentlyContinue |
Where-Object {
$_.Message -match '(?i)amsiInitFailed|amsi\.dll|AmsiUtils|AmsiScanBuffer'
} |
Select-Object TimeCreated, MachineName,
@{Name='Snippet'; Expression={ ($_.Message -split "`n")[0..5] -join ' / ' }}
Even when the bypass succeeds against AMSI, it cannot prevent the 4104 that logged the bypass code itself — the event is written by PowerShell's logging subsystem before AMSI is invoked. The implication is that 4104 catches the bypass attempt even on the operations where AMSI is the thing being bypassed.
Frequently Asked Questions
Does script block logging slow PowerShell down?
In benchmarks on modern hardware, the overhead is in the low single digits of percent for typical workloads. Heavy automation that emits very large script blocks (multi-megabyte modules) sees more measurable impact because the channel write is synchronous. For everything else — interactive admin, scheduled tasks, normal scripting — the cost is not user-visible.
Will obfuscation defeat 4104?
Not the common kinds. Base64 encoding, character substitution, and string concatenation are resolved by the parser before logging, so the 4104 contains the recovered source. Obfuscation that survives is the rarer kind that builds the eventual code through layered Invoke-Expression chains or runtime AST manipulation, and even then each layer produces its own 4104. The forensic question shifts from "what did this run" to "which 4104 has the final payload."
What is the relationship between 4103 and 4104?
4103 is module logging — parameters and module member calls. 4104 is script block logging — the actual code text. 4103 tells you that Invoke-WebRequest was called with a particular URL parameter; 4104 tells you the full surrounding script. Both are useful; 4104 is the higher-signal of the two for hunting.
Why are some 4104 events flagged as Warning level?
PowerShell promotes a 4104 to Warning when the parser detects content matching its built-in suspicious-strings list (encoded commands, known reflection patterns, AMSI bypass strings). Filtering on LevelDisplayName = 'Warning' is a cheap pre-filter that surfaces the highest-value events without any custom regex.
Should I also enable transcription on every endpoint?
Workstation-wide transcription generates a significant volume of small text files and a corresponding storage and retention cost. The pragmatic split is to enable transcription on jump boxes, admin workstations, and high-value servers — places where every interactive session is worth keeping — and rely on 4104 alone for the broader fleet. Transcription pays off most when an analyst needs to read the exact output an operator saw, which is rare outside incident response.
Conclusion
PowerShell Script Block Logging is one of the cheapest, most analyst-friendly Windows detection events available — and one of the most commonly left disabled. Enable 4104 via Group Policy, raise the channel size, forward it off-host, and run the four or five regex queries above against the centralised stream. The signal-to-noise ratio is high enough that the alerts produced are mostly worth reading.
Combined with audit policy, Windows Event Forwarding, and Sysmon, 4104 closes the last big visibility gap in the standard Windows detection stack. Attackers who rely on PowerShell are loud against it; the work is making sure the channel is on, sized, and shipped.
Related Posts
- Essential Windows Event IDs for Security Monitoring — 4104 in the broader event-ID context.
- Windows Event Forwarding Setup for Centralised Security Logs — shipping the PowerShell channel to a collector.
- PowerShell Quick Guide: Working with Event Logs Like a Pro — query patterns for the kind of hunting above.
0 comments:
Post a Comment