We execute the following bootstrap script:
try { [Console]::InputEncoding = [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding } catch { $null = $_ }
if ($PSVersionTable.PSVersion -lt [Version]"3.0") {
'{"failed":true,"msg":"Ansible requires PowerShell v3.0 or newer"}'
exit 1
}
$exec_wrapper_str = $input | Out-String
$split_parts = $exec_wrapper_str.Split(@("`0`0`0`0"), 2, [StringSplitOptions]::RemoveEmptyEntries)
If (-not $split_parts.Length -eq 2) { throw "invalid payload" }
Set-Variable -Name json_raw -Value $split_parts[1]
& ([ScriptBlock]::Create($split_parts[0]))
For the ssh
and winrm
connection plugin this is done through powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Unrestricted -EncodedCommand $base64
whereas the psrp
connection plugin sends the script through the PowerShell pipeline API. When the process/script has started the connection plugin then sends data over stdin (ssh/winrm) or the input pipe (psrp) with the exec_wrapper.ps1 then a JSON string after \0\0\0\0
in a format similar to:
{
"module_entry": "module code as base64 string",
"powershell_modules": {
"ModuleUtilName": "module util code as base64 string",
},
"csharp_utils": {
"ModuleUtilName": "module util code as base64 string",
},
"csharp_utils_modules": ["List of csharp_utils required by the module only and not exec wrapper"],
"module_args": {
"key": "module arguments/options from task"
},
"actions": ["actions to perform - depends on become, async, etc"],
"environment": {
"env name": "env value"
}
}
This JSON string is parsed by the exec_wrapper.ps1
and uses the PowerShell .NET API to setup the module state before invoking it. Any of the C# utils provided are compiled using a custom Add-Type implementation. This uses the same APIs as the builtin Add-Type
cmdlet just with a few more options exposed.
What this means is Ansible builds up a payload to execute with the bootstrapping code and exec options as part of the input to the script. This allows us to invoke code without having to touch the disk or copy any codes/files beforehand to invoke the modules.
In terms of signing and validation we would need to validate the immediate stdin exec code (exec_wrapper.ps1
) and the subsequence manifest JSON entries:
module_entry
- this is the module code that is runpowershell_modules
- any PowerShell module util code loaded in the sessioncsharp_utils
- any C# code compiled and loaded in the sessionactions
- subsequence exec wrapper code (async, become, coverage, module runner, etc)
WDAC is integrated in PowerShell and is used to configure what code can and cannot be run. With WDAC you can apply a policy that restricts what PowerShell will allow to run. A script must be signed by a certificate that is trusted and explicitly added in the WDAC policy file.
PowerShell can run in either Full Language Mode (FLM
) or Constrained Language Mode (CLM
) with the latter being a severly locked down environment. With WDAC PowerShell will default to run in CLM and only use FLM when invoking a script or module cmdlet that has been signed by a publisher trusted in the WDAC policy. More information around CLM can be found in PowerShell Constrained Language Mode but some important details are:
- CLM can run very little code unless what it is calling has been signed and trusted
- The approved allowed types in CLM is limited and really not much use to us
- Some cmdlets like
Add-Type
are blocked because it can define arbitrary types
By default PowerShell will run in Constrained Lanaguage Mode (CLM
) which is highly restricted in what can be done. Essentially only the cmdlets provided by PowerShell and any modules that are signed and trusted in the WDAC policy is available. When a signed and trusted script/function is run it will run in FLM allowing it to work as it would without WDAC.
PowerShell can only run files that are allowed to run based on the WDAC policy. It uses the certificate on the authenticode signature to validate whether the script can run in the WDAC policy that is set. The script or module must be on the filesystem for PowerShell to validate the script and run it in FLM. You cannot dot source a signed script when in CLM but you can use &
or just call it by the path. For example here is how you can run a signed script when in CLM mode. This script just runs $ExecutionContext.SessionState.LanguageMode
to display the language mode it was run in.
# The interactive/default session we are running in will be CLM
$ExecutionContext.SessionState.LanguageMode
# ConstrainedLanguage
# This is not dot sourcing, this is how to invoke a script in the same pwd
.\signed.ps1
# FullLanguage
# This is the same as the above just with the explicit call operator &
& .\signed.ps1
# FullLanguage
# This is dot sourcing which will not work in CLM
. .\signed.ps1
# C:\Users\vagrant\dev\signed\signed.ps1 : Cannot dot-source this command because it was defined in a different language mode.
# To invoke this command without importing its contents, omit the '.' operator.
# At line:1 char:1
# + . .\signed.ps1
# + ~~~~~~~~~~~~~~
# + CategoryInfo : InvalidOperation: (:) [signed.ps1], NotSupportedException
# + FullyQualifiedErrorId : DotSourceNotSupported,signed.ps1
# It is possible to invoke the script in a sub process in FLM
pwsh -File signed.ps1
# FullLanguage
# Same for PowerShell 5.1
powershell -File signed.ps1
# FullLanguage
Note that PowerShell has an issue with -File
when the signed script has the [CmdletBinding()]param()
attribute at the start. While I think this is a bug the issue is closed and probably won't be fixed, or will only be fixed for new PowerShell versions which don't help us PowerShell/PowerShell#20508.
It is not possible to get the content of a signed script and invoke it in memory in FLM from CLM. You cannot create a ScriptBlock from a string normally as [ScriptBlock]
is not a core type. You also can't hack it in by defining it through the provider to do the string conversion for you as it will still run in CLM.
$script = Get-Content signed.ps1 -Raw
& ([ScriptBlock]::Create($script))
# Cannot invoke method. Method invocation is supported only on core types in this language mode.
# At line:1 char:1
# + & ([ScriptBlock]::Create($script))
# + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
# + CategoryInfo : InvalidOperation: (:) [], RuntimeException
# + FullyQualifiedErrorId : MethodInvocationNotSupportedInConstrainedLanguage
# While the script string contains the signature, it is not trusted as it isn't
# loaded from a file.
${function:Test-Function} = $script
Test-Function
# ConstrainedLanguage
Running a new Runspace inside FLM will also run in FLM. This is the same if the PowerShell class is set to run in the current runspace or even with a new runspace:
"Parent Mode: $($ExecutionContext.SessionState.LanguageMode)"
# Same as no mode set
$ps = [PowerShell]::Create([System.Management.Automation.RunspaceMode]::NewRunspace)
$ps.AddScript('"PowerShell with implicit Runspace: $($ExecutionContext.SessionState.LanguageMode)"').Invoke()
$ps = [PowerShell]::Create([System.Management.Automation.RunspaceMode]::CurrentRunspace)
$ps.AddScript('"PowerShell in current Runspace: $($ExecutionContext.SessionState.LanguageMode)"').Invoke()
$rs = [RunspaceFactory]::CreateRunspace()
$rs.Open()
$ps = [PowerShell]::Create()
$ps.Runspace = $rs
$ps.AddScript('"PowerShell with explicit Runspace: $($ExecutionContext.SessionState.LanguageMode)"').Invoke()
$rs.Dispose()
# Parent Mode: FullLanguage
# PowerShell with implicit Runspace: FullLanguage
# PowerShell in current Runspace: FullLanguage
# PowerShell with explicit Runspace: FullLanguage
PowerShell's validation is done through the Wldp*
set of Win32 APIs. PowerShell will use WldpGetLockdownPolicy to determine whether WDAC is enforced on the system. Here is an example of it being run through the Ctypes module.
$WLDP_HOST_INFORMATION_REVISION = 1
$WLDP_HOST_ID_POWERSHELL = 4
[Flags()] enum LockdownState {
WLDP_LOCKDOWN_UNDEFINED = 0
WLDP_LOCKDOWN_SECUREBOOT_FLAG = 1
WLDP_LOCKDOWN_DEBUGPOLICY_FLAG = 2
WLDP_LOCKDOWN_UMCIENFORCE_FLAG = 4
WLDP_LOCKDOWN_UMCIAUDIT_FLAG = 8
WLDP_LOCKDOWN_EXCLUSION_FLAG = 16
WLDP_LOCKDOWN_DEFINED_FLAG = 0x80000000
}
ctypes_struct WLDP_HOST_INFORMATION {
[int]$dwRevision;
[int]$dwHostId;
[MarshalAs('LPWStr')][string]$szSource
[Microsoft.Win32.SafeHandles.SafeFileHandle]$hSource
}
$wldp = New-CtypesLib wldp.dll
$hostInfo = [WLDP_HOST_INFORMATION]::new()
$hostInfo.dwRevision = $WLDP_HOST_INFORMATION_REVISION
$hostInfo.dwHostId = $WLDP_HOST_ID_POWERSHELL
$hostInfo.hSource = [Microsoft.Win32.SafeHandles.SafeFileHandle]::new([IntPtr]::Zero, $false)
$state = 0
$result = $wldp.WldpGetLockdownPolicy([ref]$hostInfo, [ref]$state, 0)
"Result 0x{0:X8}`nState 0x{1:X8} {2}" -f $result, $state, ([LockdownState]$state)
# Result 0x00000000
# State 0x80000005 WLDP_LOCKDOWN_SECUREBOOT_FLAG, WLDP_LOCKDOWN_UMCIENFORCE_FLAG, WLDP_LOCKDOWN_DEFINED_FLAG
The WLDP_LOCKDOWN_UMCIENFORCE_FLAG
flag (4) is the important flag here that tells us the system has enabled User-mode Code Integrity (UMCI
). When in this mode the default language mode will be ConstrainedLanguage
. The code as used by PowerShell 7 can be found in SystemPolicy.GetWldpPolicy where the system policy has path
and handle
as null
.
When PowerShell is in CLM and goes to invoke a script from the file system it will try two different APIs to validate whether it's allowed in the WDAC policy:
- WldpCanExecuteFile, Build 22621 or newer
- WldpGetLockdownPolicy
Build 22621 is Windows 11 22H2 and the first server release that has this API will be the upcoming Server 2025. If that API is not available then PowerShell falls back to WldpGetLockdownPolicy
. The API that PowerShell uses to call this is publicly exposed:
$fs = [System.IO.FileStream]::OpenRead("signed.ps1")
[System.Management.Automation.Security.SystemPolicy]::GetFilePolicyEnforcement("signed.ps1", $fs)
$fs.Dispose()
It looks like this is only present on PowerShell 5.1 in Server 2025 or newer. I'm unsure if it will ever be backported but since it's not there in box in our supported versions we cannot rely on it. To manually call the Wldp APIs you can use the following PowerShell code:
$WLDP_EXECUTION_EVALUATION_OPTION_NONE = 0
enum WLDP_EXECUTION_POLICY {
WLDP_CAN_EXECUTE_BLOCKED = 0
WLDP_CAN_EXECUTE_ALLOWED = 1
WLDP_CAN_EXECUTE_REQUIRE_SANDBOX = 2
}
$wldp = New-CtypesLib wldp.dll
$hostId = [Guid]'8E9AAA7C-198B-4879-AE41-A50D47AD6458'
$fs = [System.IO.File]::OpenRead("$pwd\signed.ps1")
try {
$policy = 0
$res = $wldp.WldpCanExecuteFile(
[ref]$hostId,
$WLDP_EXECUTION_EVALUATION_OPTION_NONE,
$fs.SafeFileHandle,
$wldp.MarshalAs("Test WldpCanExecuteFile", "LPWStr"),
[ref]$policy)
}
finally {
$fs.Dispose()
}
"Result 0x{0:X8}`nPolicy 0x{1:X8} {2}" -f $res, $policy, ([WLDP_EXECUTION_POLICY]$policy)
# When targeting a signed file
# Result 0x00000000
# Policy 0x00000001 WLDP_CAN_EXECUTE_ALLOWED
# When targeting an unsigned file
# Result 0x00000000
# Policy 0x00000002 WLDP_CAN_EXECUTE_REQUIRE_SANDBOX
And an example of WldpGetLockdownPolicy
:
$WLDP_HOST_INFORMATION_REVISION = 1
$WLDP_HOST_ID_POWERSHELL = 4
[Flags()] enum LockdownState {
WLDP_LOCKDOWN_UNDEFINED = 0
WLDP_LOCKDOWN_SECUREBOOT_FLAG = 1
WLDP_LOCKDOWN_DEBUGPOLICY_FLAG = 2
WLDP_LOCKDOWN_UMCIENFORCE_FLAG = 4
WLDP_LOCKDOWN_UMCIAUDIT_FLAG = 8
WLDP_LOCKDOWN_EXCLUSION_FLAG = 16
WLDP_LOCKDOWN_DEFINED_FLAG = 0x80000000
}
ctypes_struct WLDP_HOST_INFORMATION {
[int]$dwRevision;
[int]$dwHostId;
[MarshalAs('LPWStr')][string]$szSource
[Microsoft.Win32.SafeHandles.SafeFileHandle]$hSource
}
$wldp = New-CtypesLib wldp.dll
$hostInfo = [WLDP_HOST_INFORMATION]::new()
$hostInfo.dwRevision = $WLDP_HOST_INFORMATION_REVISION
$hostInfo.dwHostId = $WLDP_HOST_ID_POWERSHELL
$fs = [System.IO.File]::OpenRead("$pwd\signed.ps1")
try {
$hostInfo.szSource = "$pwd\signed.ps1"
$hostInfo.hSource = $fs.SafeFileHandle
$state = 0
$result = $wldp.WldpGetLockdownPolicy([ref]$hostInfo, [ref]$state, 0)
}
finally {
$fs.Dispose()
}
"Result 0x{0:X8}`nState 0x{1:X8} {2}" -f $result, $state, ([LockdownState]$state)
# When targeting a signed file
# Result 0x00000000
# State 0x80000000 WLDP_LOCKDOWN_DEFINED_FLAG
# When targeting an unsigned file
# Result 0x00000000
# State 0x80000004 WLDP_LOCKDOWN_UMCIENFORCE_FLAG, WLDP_LOCKDOWN_DEFINED_FLAG
All of these APIs require a file handle hence the need for the script to be loaded from the filesystem. There does exist new APIs added with WldpCanExecuteFile
called WldpCanExecuteBuffer and WldpCanExecuteStream which seem to support validating the script contents from a byte buffer or stream respectively. Unfortunately when decompiling the code for these functions it shows that they set WLDP_CAN_EXECUTE_BLOCKED
or WLDP_CAN_EXECUTE_REQUIRE_SANDBOX
if UCMI protection is enabled and do nothing to validate the buffer/stream is trusted. This makes them unfit for purpose and effectively a no-op. Maybe in the future this functionality might be added to the function but for now it cannot be used. See the below for a way to call it in PowerShell for future investigation:
$WLDP_EXECUTION_EVALUATION_OPTION_NONE = 0
enum WLDP_EXECUTION_POLICY {
WLDP_CAN_EXECUTE_BLOCKED = 0
WLDP_CAN_EXECUTE_ALLOWED = 1
WLDP_CAN_EXECUTE_REQUIRE_SANDBOX = 2
}
$wldp = New-CtypesLib wldp.dll
$hostId = [Guid]'8E9AAA7C-198B-4879-AE41-A50D47AD6458'
# Should be raw bytes but potentially it might want the UTF-16-LE encoding of
# the script due to how the pwsh Authenticode SIP provider works.
$fileBytes = [System.IO.File]::ReadAllBytes("$pwd\signed.ps1")
# $fileBytes = [System.Text.Encoding]::Unicode.GetBytes((Get-Content signed.ps1 -Raw))
$buffer = [System.Runtime.InteropServices.Marshal]::AllocHGlobal($fileBytes.Length)
try {
[System.Runtime.InteropServices.Marshal]::Copy($fileBytes, 0, $buffer, $fileBytes.Length)
$policy = 0
$res = $wldp.WldpCanExecuteBuffer(
[ref]$hostId,
$WLDP_EXECUTION_EVALUATION_OPTION_NONE,
$buffer,
$fileBytes.Length,
$wldp.MarshalAs("Test WldpCanExecuteBuffer", "LPWStr"),
[ref]$policy)
}
finally {
[System.Runtime.InteropServices.Marshal]::FreeHGlobal($buffer)
}
"Result 0x{0:X8}`nPolicy 0x{1:X8} {2}" -f $res, $policy, ([WLDP_EXECUTION_POLICY]$policy)
One common scenario used in Ansible is to use Add-Type to compile C# code at runtime. For example the default Ansible.Basic.AnsibleModule
type is sent across as a C# module util and compiled using the custom Add-CSharpType cmdlet. In Windows PowerShell 5.1, this mechanism will spawn csc.exe
to build a temporary .dll on the disk which is then loaded in the PowerShell process. On PowerShell 7+ this instead builds the assembly in memory in the powershell process itself. As csc.exe
is signed by Microsoft this is not blocked by default with the base WDAC policy so Add-Type
does work.
A lot of the online docs state that Add-Type
will not work in WDAC when compiling from a string rather than loading a .dll
directly as the DLL emitted by csc.exe
won't be signed. It seems like that is either no longer the case or potentially has been fixed in specific Windows versions (more investigation is needed). This https://posts.specterops.io/documenting-and-attacking-a-windows-defender-application-control-feature-the-hard-way-a-case-73dd1e11be3a blog post is an excellent and thorough breakdown as to what is happening in this case but to sum it up:
- PowerShell writes a temporary
.cs
file to$env:TEMP
- PowerShell calls
WldpSetDynamicCodeTrust
which has the Kernel set an NTFS extended attribute$KERNEL.PURGE.TRUSTCLAIM
on the.cs
file- Only the kernel can set
$KERNEL.
EAs and the.PURGE.
section means the kernel will remove it if the file is changed in any way
- Only the kernel can set
- PowerShell calls
csc.exe
with the argument/EnforceCodeIntegrity
csc.exe
will compile the code, check if the input.cs
had the$KERNEL.PURGE.TRUSTCLAIM
EA and get the kernel to also add it to the output.dll
- PowerShell then tries to load that dll checking if the EA is present
The end result is a .dll
that is not signed but still trusted to be loaded into the process because the caller of Add-Type
was trusted and running in FLM. It is also possible to use Add-Type
to output a permanent dll that is unsigned and load that in after:
Add-Type -TypeDefinition @'
using System;
public class Test
{
public static string TestMethod()
{
return "foo";
}
}
'@ -OutputAssembly test.dll
fsutil file queryea test.dll
# Extended Attributes (EA) information for file C:\Users\vagrant\dev\signed\test.dll:
# Total Ea Size: 0x2d
# Ea Buffer Offset: 0
# Ea Name: $KERNEL.PURGE.TRUSTCLAIM
# Ea Value Length: c
# 0000: 01 00 08 00 00 00 00 00 00 00 00 00 ............
This dll is treated as trusted until either
- The file is modified in some way
- The WDAC policy is refreshed which may have invalidated the trust (needs validation)
If the WDAC policy has explicitly disabled csc.exe
then Add-Type
will no longer work with PowerShell 5.1 and it will have to instead load pre-compiled .dll that has been signed and trusted in the WDAC policy.
As PowerShell 7 doesn't use csc.exe
to compile the dll but rather compiles it in process there is no special validation that occurs and just being in trusted mode is enough to get this working.
Some gotchas I've encountered which may be problematic when running a script in FLM:
- Using
Invoke-Expression
will run the expression in CLM even when in FLM
$ExecutionContext.SessionState.LanguageMode
# FullLanguage
Invoke-Expression '$ExecutionContext.SessionState.LanguageMode'
# ConstrainedLanguage
-
You cannot call
powershell.exe -File script.ps1
when the script has[CmdletBinding()]param()
- PowerShell/PowerShell#20508- You can hack it in with
powershell.exe -Command "& .\script.ps1"
(dealing with paths + args may be tricky)
- You can hack it in with
-
Trying to invoke a script that is unsigned or has an invalid signature errors with an misleading error
- It states the file/command doesn't exist rather than it saying it is not signed or has an invalid signature
./unsigned.ps1 : The term './unsigned.ps1' is not recognized as the name of a cmdlet, function, script file, or
operable program. Check the spelling of the name, or if a path was included, verify that the path is correct and try
again.
At C:\Users\vagrant\dev\signed\test.ps1:1 char:1
+ ./unsigned.ps1
+ ~~~~~~~~~~~~~~
+ CategoryInfo : ObjectNotFound: (./unsigned.ps1:String) [], CommandNotFoundException
+ FullyQualifiedErrorId : CommandNotFoundException
- How do we get the bootstrap wrapper running
- We might be able to slim it down further by just using builtin cmdlets/operators and move more logic to the exec wrapper
- How do we also achieve this with become and async which has a limited command line length
- Is it at all possible to get this working without touching the disk
- I don't think so short of finding a security flaw in pwsh
- How will we verify the content provided to us
- We cannot just trust the exec wrapper or even the manifest code as it is arbitrary data from stdin, need some way to validate the
- module/become/async/coverage wrapper actions
- C# utils from a string (or pre-compiled dll)
- pwsh utils from a string
- module code from a string
- Only way WDAC can check something is through a file
- Can we utilise some sort of temp unnamed file so nothing else can open it
- Could look at another signing mechanism outside of WDAC
- We cannot just trust the exec wrapper or even the manifest code as it is arbitrary data from stdin, need some way to validate the
- How should we adapt to C# utils or even inline Add-Type code
- We can ignore Add-Type code in the modules and just state
csc.exe
is needed - C# module utils are more difficult as we would need a way to validate the content is trusted or precompile them
- We can ignore Add-Type code in the modules and just state
- What does this look like with win_shell/win_powershell/raw
- New processes will run in CLM
- Providing a way to bypass this is going to open a security hole
- Need some way of validating inline pwsh scripts or just not supporting this at all
- How will we test this in CI
- How can we ensure that this doesn't impact non-WDAC execution
- Can we try and keep both paths the same for less complexity
- Will the extra sigs checks be done in both, is the authenticode CRL checks going to slow us down
- Will writing the content to the disk going to add any slowness
- Will pre-compiling C# code improve the speed or slow it down due to extra data being sent across the wire
- Should we pre-build dlls and ship them across as signed and avoid the pre-compile problem
- How can we deal with _low_level_execute_commands or shell actions
- This is important for a connection copy and fetch functionality
- Rebooting requires explicit commands (which callers can override)
- Shell actions like mkdtemp will run inline PowerShell code