loading...

Signing PowerShell scripts

wozzo profile image Warren ・5 min read

Signing PowerShell scripts

I ❤️ PowerShell. It's a really useful tool for automating those tasks you do multiple times. But it can also be a gaping security hole if you let it.

PowerShell allows you to administer almost everything on your machine, so there is a lot of damage that could be done by someone able to run malicious scripts in your environments.

Previously confined to just Windows, since version 6 and now with the release of PowerShell 7.0, it can also be deployed on Linux and MacOS. However this article talks about Execution Policies which cannot be changed in non Windows environments so will provide no benefit to Linux/MacOS users (sorry).

Execution Policies

One way you can restrict the ability to run scripts in your Windows environments is to use PowerShell's execution policies. The tldr is that they can be used to restrict the scripts that will run in the environment. The options available from least secure to most secure are:

Bypass

Nothing is blocked.

Unrestricted (Always applies on Non-Windows machines)

Same as ByPass but prompts the user before running scripts from the internet.

RemoteSigned (default for Windows Servers)

Scripts that have been downloaded from the internet can only be run if they are signed. Scripts written on the machine can be run

AllSigned

All scripts must be signed before they will run

Restricted (default for Windows Clients)

Prevents running scripts. Can only run individual commands.

Running Scripts

So you want to run your own PowerShell scripts on your server. But you're getting a PSSecurityException like the following.

PS C:\Users\Administrator\Downloads> .\wibble.ps1
.\wibble.ps1 : File .\wibble.ps1 cannot be loaded. The file
.\wibble.ps1 is not digitally signed. You cannot run this script on the current system.
For more information about running scripts and setting execution policy, see about_Execution_Policies at
http://go.microsoft.com/fwlink/?LinkID=135170.
At line:1 char:1
+ .\wibble.ps1
+ ~~~~~~~~~~~~
    + CategoryInfo          : SecurityError: (:) [], PSSecurityException
    + FullyQualifiedErrorId : UnauthorizedAccess

One way to resolve this issue is to change the execution policy using the Set-ExecutionPolicy command and reduce the security of the machine. This is a generally a bad idea.

Personally, I would never use anything less secure than RemoteSigned on a server, but AllSigned ideally.

What is a script signature?

Presumably you want to maintain the security of the machine and not open up a vulnerability so the option left to you is to sign your scripts.

Signing a script means that someone with access to the private key for a code signing certificate has added a signature block at the end of the script file.

function Get-Wibble {
    return "Wibble"
}
# SIG # Begin signature block
# vpnIUpm2XxLRhU1no0iuA62xKxYzR6m95z9Ax21ppeTC9NoRd8ocoSGr1zAd
# qlMOlz4lZoVWR4ZmtdCgzde1dVxzv4jjHb6ziDiY2o05UXswD2bl6XaOrUpd
# Li0Qjg3d3y2r1nrpO8hos906bgXQswysvouegUJcpt8ftmqBKfEYNeBgnBFm
# SIG # End signature block

(Example only. The signature block is usually much longer)

The signature block will contain a hash of the script that has been encrypted using the private key. When attempting to run the script this is decrypted using the public key and compared to the actual hash. If it matches then we can confirm that the script has not been tampered with as the hash would change as soon as that happened.

Self Signing certificates

If your scripts are only going to be run by machines in your organisation then you will most likely be able to self sign the certificates. The alternative is to spend $$$ and buy a code signing certificate each year.

Self signing means you will generate the certificate yourself and sign the scripts using that.

How to create a self signing certificate

PowerShell has the very useful New-SelfSignedCertificate command for producing self signed certificates. We can then export them and place them in different stores.

By default the command will create a certificate that expires after 1 year. You can change this using the -NotAfter parameter and providing it the date you wish the certificate to expire.

To create a certificate fire up a PowerShell session and run the following

$CertificateName = Read-Host "Input your certificate name"
$OutputPFXPath  = "$CertificateName.pfx"
$OutputCERPath = "$CertificateName.cer"
$Password = Get-Credential -UserName Certificate -Message "Enter a secure password:"

$certificate = New-SelfSignedCertificate -subject $CertificateName -Type CodeSigning -CertStoreLocation "cert:\CurrentUser\My"
$pfxCertificate = Export-PfxCertificate $certificate -FilePath $OutputPFXPath -password $Password.password
Export-Certificate -Cert $certificate -FilePath $OutputCERPath
Import-PfxCertificate $pfxCertificate -CertStoreLocation cert:\CurrentUser\Root -Password $password.password
Write-Output "Private Certificate '$CertificateName' exported to $OutputPFXPath"
Write-Output "Public Certificate '$CertificateName' exported to $OutputCERPath"

This will create the certificate and export it to two separate files with the extensions .pfx and .cer.

PFX

The PFX certificate is the one that will need to be installed on the machines that will be doing the signing. In our case that is each of our developers machines.

It should be installed in to the cert:\CurrentUser\Root store, also known as the "Trusted Root Certification Authorities" store.

Certificate Manager - Trusted Root Certification Authorities Store

This file should be kept somewhere secure. The password should then be secured separately (use a password manager). If a bad actor gets hold of both of these then they can sign their malicious scripts as if they were you.

CER

The CER certificate file is what you're going to have to install on the machines that will be running the scripts. This won't require a password to install but cannot be used to sign scripts. It only contains the public key used to decrypt the signature.

Because it doesn't contain the private key this certificate can be freely distributed to all environments that will be running the scripts.

This will need to be installed in two certificate stores on the machines that will be running the scripts; The cert:\LocalMachine\Root and cert:\LocalMachine\TrustedPublisher stores.

Signing a script

The Set-AuthenticodeSignature function is provided to sign the scripts. We need to pass in the path to the script and the certificate from the store. We wrote a wrapper function that can sign the scripts.

function Set-ScriptSignatures {
    param (
        [string]
        $pathToScripts = "."
    )
    $certificateName = "PowerShell Signing Certificate"
    $scripts = Get-ChildItem -Path "$pathToScripts\*" -Include *.ps1, *.psm1 -Recurse

    $certificate = @(Get-ChildItem cert:\CurrentUser\My -codesign | Where-Object { 

    $_.issuer -like "*$certificateName*" } )[0]

    foreach ($script in $scripts)
    {
        Write-Host "Signing $script"
        Set-AuthenticodeSignature $script -Certificate $certificate
    }
}

The $certificateName variable should contain the name used when creating the certificate.

You can use the function by providing a path to the directory that contains the scripts and it will loop through them signing them all.

Once you've installed the scripts on the target machine you should now be able to run the run the scripts without any issues.

Discussion

markdown guide