Event ID 4104 comes from the Microsoft-Windows-PowerShell provider and lands in the Microsoft-Windows-PowerShell/Operational channel. It fires every time the PowerShell engine parses a script block: a function definition, a loop, a one-liner typed at the prompt, or a block built dynamically at runtime through Invoke-Expression or Add-Type.
Two independent mechanisms can put a 4104 in the log, and mixing them up is the single most common mistake analysts make when scoping a hunt.
The first mechanism is deliberate: Script Block Logging turned on through Group Policy under Administrative Templates, Windows Components, Windows PowerShell, Turn on PowerShell Script Block Logging, or through the equivalent registry value EnableScriptBlockLogging under HKLM\Software\Policies\Microsoft\Windows\PowerShell\ScriptBlockLogging.
With this enabled, every script block PowerShell processes gets logged, at Verbose level, whether the session is interactive, remote, or a scheduled task. This is the setting that gives full-fidelity coverage, and it is also the setting most environments still leave off because of the log volume it generates on build servers and DevOps hosts that run PowerShell constantly.
The second mechanism runs regardless of that policy. PowerShell carries a built-in list of suspicious constructs, patterns tied to encoding, reflection, and known bypass techniques, and any script block that matches gets logged automatically at Warning level even on a host where Script Block Logging was never enabled. This is why an analyst can sometimes find a 4104 for a single malicious one-liner on a machine that otherwise has none of this logging turned on.
It is a safety net, not a substitute for the GPO: it only catches patterns Microsoft already knows about, and it says nothing about the benign 4104 traffic the analyst would otherwise use as a baseline. Filtering on LevelDisplayName equal to Warning is a legitimate, cheap first pass across a fleet where full logging is not deployed everywhere.
The event's payload carries the fields that make it useful. ScriptBlockText is the actual code, post-parse, which is the important part: PowerShell logs what the interpreter is about to run, not what the operator typed. ScriptBlockId is a GUID that ties related events together.
Path records the originating script file when the block came from a .ps1 on disk, and is empty for ad hoc or in-memory blocks, which is itself a signal worth tracking. MessageNumber and MessageTotal exist because the event payload has a size limit; PowerShell splits a large script block into multiple 4104 events sharing one ScriptBlockId, with MessageNumber marking each fragment's position and MessageTotal recording how many fragments to expect.
An analyst reconstructing a long or heavily obfuscated script has to group by ScriptBlockId, sort by MessageNumber, and concatenate ScriptBlockText across every fragment before reading it as one artifact. Skipping this step is how analysts miss the back half of a payload that only reveals its intent in the last few hundred characters.
In a typical SOC, 4104 shows up constantly from legitimate sources: PSReadLine tab completion, module autoloading, Exchange and Active Directory management scripts, configuration management tools like DSC, and vendor agents that shell out to PowerShell for routine tasks.
It also shows up from attackers at almost every stage past initial access: a downloader pulling a second-stage payload with IEX and a WebClient object, a reconnaissance script enumerating AD group membership, a credential-dumping module reflected into memory, or a ransomware precursor disabling shadow copies.
The neighboring events matter for context. Event 4103 (module logging) records the pipeline execution details, parameter binding and cmdlet invocation, of specific modules that have been opted into logging, giving a shallower but broader view than 4104's full text capture.
Event 4688 (process creation) shows that powershell.exe or pwsh.exe launched at all, with the command line as originally typed, before any decoding, which is why a heavily obfuscated -EncodedCommand argument in 4688 and its plaintext, decoded equivalent in 4104 are two views of the same execution, useful for confirming that what launched matches what actually ran.