Hardening

PowerShell Logging and Constrained Language Mode Setup

22 September 2026 9 min read

PowerShell is still the most common post-exploitation shell on Windows because it is already installed, digitally signed by Microsoft, and generates traffic that looks identical to routine administration. Turning on its logging providers and enforcing Constrained Language Mode (CLM) removes a large part of that cover, but only when the settings are configured correctly, sized to survive an actual incident, and checked for drift after every image update and domain join. This walkthrough covers what each logging source captures, how to size and retain it, how CLM is actually enforced, the downgrade and bypass paths that undo it, what breaks for administrators, and how to keep the configuration applied as the fleet grows.

Module, script block and transcription logging: what each one actually captures

Windows PowerShell exposes three independent logging providers, and teams frequently enable only one and assume they have full coverage. They do not overlap as much as the names suggest.

Module logging (Event ID 4103)

Module logging is controlled under Computer Configuration > Administrative Templates > Windows Components > Windows PowerShell > Turn on Module Logging, or in the registry at HKLM:SOFTWAREPoliciesMicrosoftWindowsPowerShellModuleLogging with EnableModuleLogging = 1 and ModuleNames = *. It records pipeline execution details, including variable values passed through cmdlets in loaded modules, to Event ID 4103 in the Microsoft-Windows-PowerShell/Operational log. It is useful for reconstructing what a cmdlet was actually given as input, but it does not reliably capture full script text, especially when commands are built dynamically.

Script block logging (Event ID 4104)

This provider matters most for detection. Enable it under Turn on PowerShell Script Block Logging, or set HKLM:SOFTWAREPoliciesMicrosoftWindowsPowerShellScriptBlockLogging with EnableScriptBlockLogging = 1. It logs the deobfuscated content of every script block PowerShell parses, so base64 payloads, string concatenation tricks, and character-code obfuscation all show up in plain text at the point the engine executes them. Content that trips AMSI’s suspicious-content heuristics is written at the Warning level instead of Verbose, which is the most useful triage filter: query Event ID 4104 at level 3 first. EnableScriptBlockInvocationLogging adds start and stop events (4105/4106) that help correlate a block’s runtime against other telemetry.

Transcription

Transcription, set under Turn on PowerShell Transcription with an OutputDirectory value, writes a plain-text record of session input and output. It is readable without parsing event XML, which makes it fast for manual review, but it captures interactive sessions more reliably than background jobs, and a local, user-writable output path lets an attacker delete or edit the transcript that would have recorded their own command. Point it at a network share where the computer account has write-only access and users cannot read or modify prior transcripts.

Provider Event ID / location Captures Main blind spot
Module logging 4103, Operational log Pipeline execution detail, variable state Weak on dynamically built commands
Script block logging 4104 (+4105/4106), Operational log Deobfuscated script content Nothing if the v2 engine is used instead
Transcription Text files in OutputDirectory Full session input/output, human-readable Local files can be tampered with; weaker for non-interactive jobs

Where the logs go, how big they get, and retention settings that survive a real incident

The Microsoft-Windows-PowerShell/Operational log ships with a modest default maximum size, and a single large or obfuscated script block can produce a multi-kilobyte 4104 event. On a jump box or a server running scheduled automation, that log can wrap and overwrite itself within hours, so an analyst called in a day after an intrusion often finds nothing. Resize it explicitly:

  • wevtutil sl Microsoft-Windows-PowerShell/Operational /ms:1073741824 for a 1 GB cap, higher still on domain controllers and RDP jump servers.
  • Set retention to not overwrite events, paired with off-box forwarding rather than relying on local disk as the system of record.
  • Forward the Operational log via Windows Event Forwarding subscriptions, or a shipper matching the SIEM’s ingestion format, so retention is decoupled from the endpoint’s own disk.

Transcription output directories need their own housekeeping too. Left unmanaged, a busy admin workstation accumulates thousands of small text files with no expiry. Schedule a cleanup job that ages files out after a defined window, for example 90 days, once they are confirmed to have reached the central log store.

Constrained Language Mode: how it is enforced, and why it is only meaningful with app control

Constrained Language Mode restricts what a session can do at the language level: no arbitrary .NET type instantiation beyond an approved list, no Add-Type, no COM object creation, and restricted script block invocation and dot-sourcing. Check the active mode with $ExecutionContext.SessionState.LanguageMode, which returns ConstrainedLanguage when active.

Setting the __PSLockdownPolicy environment variable directly forces this mode, but it is not a security boundary on its own. Any account able to modify environment variables or registry values can remove it, so a manually set lockdown stops accidental misuse, not a deliberate attacker.

Real enforcement comes from Windows Defender Application Control (WDAC) or AppLocker with User Mode Code Integrity enabled. Once a code integrity policy is active, PowerShell automatically drops any script or session not covered by an allow rule into Constrained Language Mode, while scripts that satisfy the policy, typically because they are signed by a trusted publisher, run in Full Language. CLM without an application control policy behind it is mostly cosmetic: it constrains honest users and does nothing to a session an attacker can relaunch under different conditions.

Downgrade and bypass tricks (v2 engine, alternative hosts) and how to close them

Two bypass categories account for most real-world CLM and logging evasion.

The Windows PowerShell 2.0 engine

If the legacy engine feature is still installed, powershell.exe -version 2 launches a session that predates script block logging and AMSI entirely, and it does not go through the same language-mode enforcement path as the current engine. Remove it outright:

  • Disable-WindowsOptionalFeature -Online -FeatureName MicrosoftWindowsPowerShellV2Root -NoRestart
  • Disable-WindowsOptionalFeature -Online -FeatureName MicrosoftWindowsPowerShellV2 -NoRestart
  • Confirm with Get-WindowsOptionalFeature -Online -FeatureName MicrosoftWindowsPowerShellV2*, and drop .NET Framework 3.5 too if nothing still depends on it.

Alternative hosts

PowerShell’s engine is a managed assembly, System.Management.Automation, and anything capable of hosting a .NET runspace can run PowerShell code without going through powershell.exe at all. If a WDAC policy only evaluates the trust of the PowerShell binary and not the broader set of processes able to load that assembly, this path sidesteps both logging and language-mode restrictions. Closing it needs a deny-by-default WDAC base policy governing binaries and managed code broadly, not one scoped to powershell.exe, plus current HVCI and managed installer rules as new admin tooling is added. Obfuscation itself is not a durable logging bypass: base64 and string-concatenation tricks still land in Event ID 4104 once the engine deobfuscates them, so the real bypass value is in avoiding the engine’s normal execution path altogether.

Admin scripts and automation that break under CLM, plus the signing workflow that fixes them

Constrained Language Mode is not free. Enforced fleet-wide, it breaks predictable things: modules that ship compiled C# helper types and load them with Add-Type, scripts that instantiate COM objects (Excel automation is a classic case), DSC configurations, and Active Directory or Exchange management scripts calling .NET methods outside the approved core type list. The failure usually surfaces as a method invocation error rather than a clear policy message, so checking the language mode should be an early step in any troubleshooting runbook after a CLM rollout.

The fix is not to loosen the policy globally, it is to sign the scripts:

  1. Issue a code-signing certificate from an internal CA dedicated to script signing, not a general-purpose server certificate.
  2. Set-AuthenticodeSignature -FilePath .script.ps1 -Certificate $cert -TimestampServer http://timestamp.digicert.com
  3. Add a WDAC signer rule for that certificate’s issuing chain, so files it signs run in Full Language while everything unsigned stays in Constrained Language.

For a large legacy toolset that would otherwise need broad signing exceptions, Just Enough Administration (JEA) endpoints are usually a better fit: routine tasks run under a virtual account with an explicit role capability file, independent of the interactive session’s language mode, keeping the exception surface small and auditable.

Keeping the settings applied when new endpoints and servers join the domain

Logging and CLM configuration that only exists as a GPO applied after the fact leaves a window on every new endpoint between imaging and the first policy refresh. Bake both the logging registry values and the WDAC policy into the golden image or provisioning process so they are present before first login, not dependent on a background gpupdate cycle days later.

Link the GPO containing Turn on Module Logging, Turn on PowerShell Script Block Logging, and Turn on PowerShell Transcription at an OU level that actually covers servers and workstations, and verify a lower-precedence OU is not silently overriding it with block inheritance. Confirm rollout on a sample of newly joined machines with gpresult /h report.html rather than assuming the console’s link status reflects what clients received.

WDAC policies deploy through a separate channel, whether that is an Intune Application Control profile, a GPO pushing the .cip file into the CodeIntegrity folder, or SCCM, and a version change needs an explicit refresh, typically CiTool --refresh, or a reboot depending on policy type. A WDAC policy that silently fails to apply is worse than one never deployed, because the endpoint fails open into unrestricted Full Language while a dashboard may still report it as covered. This is the kind of drift that attack surface management tooling is meant to catch continuously instead of during an annual audit, and it starts with knowing which hosts even expose an interactive PowerShell path, which is usually a question for asset inventory data rather than the logging configuration itself. Platforms that pull this telemetry into a broader risk picture, such as SITEY, can treat a missing or truncated PowerShell operational log as a finding tied to the host’s asset record, rather than something only a SOC analyst notices during a live investigation.

These controls map onto specific framework requirements, mainly logging of privileged and administrative activity under ISO 27001 Annex A.8.15 and PCI DSS logging requirements, so they are worth tracking alongside other technical controls in compliance mapping rather than in a separate hardening spreadsheet that drifts out of sync with the rest of the audit evidence. Tools that continuously re-check configuration state, such as SITEY, flag the moment a WDAC policy fails to apply or a script block logging value reverts after an image update, instead of waiting for the next quarterly review. Confirming a GPO value is present is not the same as confirming the fix worked, since a policy can apply to a machine without the PowerShell engine picking it up until the next session starts. A platform such as SITEY handles this at retest by checking that Event ID 4104 volume actually increased on the host after the change, which is closer to what the attack surface management module is trying to establish about real exposure than a one-time settings check would be.

None of this replaces detection content built on the logs themselves. But without the engine downgrade path closed, retention sized for more than a few hours on noisy hosts, and CLM backed by actual code integrity enforcement, the configuration is closer to a checkbox than a control.

About SITEY

SITEY is an autonomous vulnerability management platform. It discovers, validates, prioritizes, remediates and re-tests vulnerabilities through an eight-phase automated pipeline, unifying output from 17 integrated scanners. SITEY is self-hosted: it runs in your own infrastructure and your findings are stored there. Outbound connections are limited to licence activation and the optional services you enable, such as an AI provider, CVE enrichment and patch catalogues. Pricing is 599 USD per month or 5,999 USD for a perpetual lifetime license. See pricing or how the platform works.

SITEY closes the loop, not just the report.Discover, validate, fix and verify in your own infrastructure.

See pricing