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 "Turn on PowerShell Script Block Logging" policy set to Enabled — the supported way to switch 4104 on fleet-wide.
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.
A real 4104 "Creating Scriptblock text" event — the body shows the decoded command, not the Base64 that was passed on the command line.
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 Event ID 4104 logging?
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 Event ID 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.
How do I generate an Event ID 4104 log entry for testing?
With script block logging enabled, run any PowerShell command in a new session and a 4104 is written to Microsoft-Windows-PowerShell/Operational. To produce a high-signal test case, run an encoded command — powershell -EncodedCommand <Base64> — and confirm the resulting 4104 contains the decoded source. That verifies both that logging is on and that it deobfuscates. The setting only applies to sessions started after it was enabled.
Where do I find Event ID 4104 in Event Viewer?
In Event Viewer, browse to Applications and Services Logs → Microsoft → Windows → PowerShell → Operational. The 4104 entries are the "Creating Scriptblock text" events. The same channel name, Microsoft-Windows-PowerShell/Operational, is what you target with Get-WinEvent or a Windows Event Forwarding subscription. In Splunk, the same events arrive as EventCode=4104 from that channel.
Does Event ID 4104 record the parent process?
Not in the message body — the event text carries only the script block, its ScriptBlockId, and the file path if it came from a script on disk. The event's System metadata does include the process ID of the PowerShell host, and joining that PID against process-creation telemetry (Security 4688 or Sysmon Event ID 1) on the same host gives you the parent process and full command line. Alternatively, 4103 module logging includes a Host Application field naming the executable directly.
What are Event IDs 4105 and 4106?
4105 and 4106 record the start and completion of each script block invocation, as opposed to 4104, which records its creation. They are verbose-level events, off by default, and fire so often that collecting them fleet-wide is rarely worth the volume. For detection, 4104 plus 4103 covers what matters; 4105/4106 occasionally help in forensics when you need to prove a logged block actually executed.
How do I reassemble 4104 message blocks split across events?
Large script blocks are chunked across multiple 4104 events, each carrying MessageNumber and MessageTotal fields and a shared ScriptBlockId. Group by the script block ID, order by MessageNumber, and concatenate the message text to recover the full script. Blocks with MessageTotal of 1 need no reassembly:
# Reassemble multi-part script blocks logged in the last hour
Get-WinEvent -FilterHashtable @{
LogName = 'Microsoft-Windows-PowerShell/Operational'
Id = 4104
StartTime = (Get-Date).AddHours(-1)
} -ErrorAction SilentlyContinue |
Group-Object { $_.Properties[3].Value } | # ScriptBlockId
ForEach-Object {
$full = ($_.Group |
Sort-Object { [int]$_.Properties[0].Value } | # MessageNumber
ForEach-Object { $_.Properties[2].Value }) -join ''
[pscustomobject]@{ ScriptBlockId = $_.Name; Script = $full }
}
How do I cut down 4104 volume without losing detections?
Filter on LevelDisplayName = 'Warning' to keep only the events PowerShell already flagged as suspicious, raise the channel size so rotation does not drop events mid-incident, and allowlist known-good automation by signing certificate or script path. Forwarding only Warning-level 4104s to the SIEM while keeping the full channel on the host is a common compromise between coverage and storage cost.
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.
Editorial note: posts on this blog are drafted with AI assistance and then reviewed, edited, and tested against a real environment before publishing. Commands, output, and screenshots come from systems I actually ran the work on.
0 comments:
Post a Comment