HackBlue

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.

11 min read · practice only with authorization

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.

Snippet
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.

Snippet
$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-List

What 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

  1. Input

    An encoded or concatenated command is started.

  2. Compile

    PowerShell decodes and builds the script block.

  3. Log

    4104 records the block text as compiled.

  4. 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?