Tools · Windows security and Event Logs · 15 / 27
PowerShell script block logging: enable Event ID 4104 and read it
Turn on script block logging, find 4104 events in the PowerShell Operational log, reassemble long scripts, and see decoded commands that obfuscation tried to hide.
Why script block logging matters
PowerShell compiles code into script blocks before running it. With script block logging enabled, PowerShell 5 and later records each block’s text in Microsoft-Windows-PowerShell/Operational as Event 4104. Because logging happens after PowerShell has decoded the input, an -EncodedCommand or a string built at runtime appears as the code that actually ran.
Even with the policy off, PowerShell logs blocks it considers suspicious at Warning level. Turning the policy on records everything, which is what investigators need.
Enable it
Set Group Policy Administrative Templates, Windows Components, Windows PowerShell, “Turn on PowerShell Script Block Logging”. The registry equivalent is EnableScriptBlockLogging = 1 under HKLM\SOFTWARE\Policies\Microsoft\Windows\PowerShell\ScriptBlockLogging. Avoid the invocation start and stop option unless you need it, as it adds a lot of volume.
Raise the PowerShell Operational log size, because scripts are verbose. 512 MB or more is common on servers.
New-Item -Path 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\PowerShell\ScriptBlockLogging' -Force | Out-Null Set-ItemProperty -Path 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\PowerShell\ScriptBlockLogging' -Name EnableScriptBlockLogging -Value 1 wevtutil sl Microsoft-Windows-PowerShell/Operational /ms:536870912
Lab: watch obfuscation disappear
Run a harmless command in an encoded form and another built by string concatenation, such as invoking Get-Date through a variable assembled from pieces. Query 4104 and read ScriptBlockText. The log shows the decoded command and the pieces as they were evaluated.
Long scripts are split across several 4104 events with the same ScriptBlockId and a MessageNumber and MessageTotal. Group by ScriptBlockId and sort by MessageNumber to rebuild the full script.
$e=[Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes('Get-Date; "lab-4104"'))
powershell -NoProfile -EncodedCommand $e
& ('Get-' + 'Process') -Id $PID | Out-Null
Get-WinEvent -FilterHashtable @{LogName='Microsoft-Windows-PowerShell/Operational'; Id=4104; StartTime=(Get-Date).AddMinutes(-10)} |
% { [pscustomobject]@{Time=$_.TimeCreated; Id=$_.Properties[3].Value; Part="$($_.Properties[0].Value)/$($_.Properties[1].Value)"; Text=$_.Properties[2].Value} } |
Where-Object Text -match 'lab-4104|Get-Process' | Format-ListWhat to hunt for
Downloads into memory with Net.WebClient or Invoke-WebRequest followed by Invoke-Expression, references to Mimikatz-style function names, AMSI bypass attempts that touch AmsiUtils, reflection loading assemblies from byte arrays, and disabling Defender with Set-MpPreference.
Script blocks can contain secrets that administrators typed. Restrict who can read the PowerShell log and consider Protected Event Logging, which encrypts the content with a certificate.
Diagram
From obfuscated input to readable log
- Input
An encoded or concatenated command is started.
- Compile
PowerShell decodes and builds the script block.
- Log
4104 records the block text as compiled.
- Rebuild
Group by ScriptBlockId to reassemble long scripts.
Check yourself
1. Which log holds Event 4104?
2. An attacker runs powershell -EncodedCommand. What does 4104 show?
3. How do you rebuild a script split across many 4104 events?