Last active
December 6, 2024 06:23
-
-
Save jborean93/fa4721cefa7c25a96901546d555ccdf6 to your computer and use it in GitHub Desktop.
Pester tests for checking out how the PowerShell Authenticode SIP determines the file encoding for the signature
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#Requires -RunAsAdministrator | |
#Requires -Version 7.4 | |
using namespace System.IO | |
using namespace System.Formats.Asn1 | |
using namespace System.Globalization | |
using namespace System.Security.Cryptography | |
using namespace System.Security.Cryptography.Pkcs | |
using namespace System.Security.Cryptography.X509Certificates | |
using namespace System.Text | |
Describe "Authenticode PowerShell Encoding" { | |
BeforeAll { | |
Function Get-ExpectedAuthenticodeHash { | |
[OutputType([string])] | |
[CmdletBinding()] | |
param ( | |
[Parameter(Mandatory)] | |
[string] | |
$Path, | |
[Parameter(Mandatory)] | |
[Encoding] | |
$Encoding | |
) | |
$fileBytes = [File]::ReadAllBytes($Path) | |
$fileText = $Encoding.GetString($fileBytes) | |
$unicodeBytes = [Encoding]::Unicode.GetBytes($fileText) | |
$sha256Hash = [SHA256]::HashData($unicodeBytes) | |
[Convert]::ToHexString($sha256Hash) | |
} | |
Function Get-ActualAuthenticodeHash { | |
[OutputType([string])] | |
[CmdletBinding()] | |
param ( | |
[Parameter(Mandatory)] | |
[string] | |
$Path, | |
[Parameter(Mandatory)] | |
[Encoding] | |
$Encoding | |
) | |
$signedContent = [File]::ReadAllText($Path, $Encoding) | |
# $signedContent = Get-Content -Path $Path -Raw | |
$sigIndex = $signedContent.LastIndexOf("`r`n# SIG # Begin signature block`r`n") | |
if ($sigIndex -eq -1) { | |
throw "No signature present in file" | |
} | |
$sigIndex += 2 | |
$sig = $signedContent.Substring($sigIndex, $signedContent.Length - $sigIndex - 2) | |
$b64Sig = ($sig -split "\r?\n" | ForEach-Object { $_.Substring(2).TrimEnd() } | Where-Object { -not $_.StartsWith('SIG') }) -join "" | |
$cms = [SignedCms]::new() | |
$cms.Decode([Convert]::FromBase64String($b64Sig)) | |
<# | |
SpcIndirectDataContent ::= SEQUENCE { | |
data SpcAttributeTypeAndOptionalValue, | |
messageDigest DigestInfo | |
} --#public— | |
DigestInfo ::= SEQUENCE { | |
digestAlgorithm AlgorithmIdentifier, | |
digest OCTETSTRING | |
} | |
#> | |
$reader = [AsnReader]::new($cms.ContentInfo.Content, [AsnEncodingRules]::DER) | |
$spcIndirectDataContent = $reader.ReadSequence() | |
$null = $spcIndirectDataContent.ReadSequence() # SpcIndirectDataContent.data | |
$digestReader = $spcIndirectDataContent.ReadSequence() # SpcDIndirectDataContent.messageDigest | |
$null = $digestReader.ReadSequence() # DigestInfo.digestAlgorithm | |
$digest = $digestReader.ReadOctetString() # DigestInfo.digest | |
[Convert]::ToHexString($digest) | |
} | |
Function New-CodeSigningCertificate { | |
[OutputType([System.Security.Cryptography.X509Certificates.X509Certificate2])] | |
[CmdletBinding()] | |
param( | |
[Parameter(Mandatory)] | |
[string] | |
$Subject | |
) | |
$curve = [ECCurve]::CreateFromFriendlyName('nistP256') | |
$key = [ECDsa]::Create($curve) | |
$request = [CertificateRequest]::new( | |
$Subject, | |
$key, | |
[HashAlgorithmName]::SHA256) | |
$enhancedKeyUsageOids = [OidCollection]::new() | |
$null = $enhancedKeyUsageOids.Add([Oid]::new("1.3.6.1.5.5.7.3.3")) # Code Signing | |
@( | |
[X509KeyUsageExtension]::new([X509KeyUsageFlags]::DigitalSignature, $true) | |
[X509EnhancedKeyUsageExtension]::new($enhancedKeyUsageOids, $true) | |
[X509SubjectKeyIdentifierExtension]::new($request.PublicKey, $false) | |
) | ForEach-Object { $request.CertificateExtensions.Add($_) } | |
$notBefore = [DateTimeOffset]::UtcNow.AddDays(-1) | |
$notAfter = [DateTimeOffset]::UtcNow.AddDays(30) | |
$request.CreateSelfSigned($notBefore, $notAfter) | |
} | |
Function Assert-EncodingUsed { | |
[CmdletBinding()] | |
param( | |
[Parameter(Mandatory)] | |
[string] | |
$Content, | |
[Parameter(Mandatory)] | |
[Encoding] | |
$Encoding, | |
[Parameter()] | |
[Encoding] | |
$FileEncoding | |
) | |
$scriptPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath("TestDrive:\test-$([Guid]::NewGuid()).ps1") | |
if (-not $FileEncoding) { | |
$FileEncoding = $Encoding | |
} | |
[File]::WriteAllText($scriptPath, $Content, $FileEncoding) | |
$expected = Get-ExpectedAuthenticodeHash -Path $scriptPath -Encoding $Encoding | |
Set-AuthenticodeSignature -FilePath $scriptPath -Certificate $cert -HashAlgorithm SHA256 | |
$actual = Get-ActualAuthenticodeHash -Path $scriptPath -Encoding $FileEncoding | |
$actual | Should -Be $expected | |
} | |
$ansiEncoding = [Encoding]::GetEncoding([CultureInfo]::CurrentCulture.TextInfo.ANSICodePage) | |
$utf8Encoding = [UTF8Encoding]::new($false) | |
$cert = New-CodeSigningCertificate -Subject CN=Authenticode-Test | |
# Build the X509Certificate2 object without the associated key so that | |
# when importing it into the root store it doesn't also import the key. | |
$certWithoutKey = [X509Certificate2]::new($cert.Export([X509ContentType]::Cert)) | |
$root = Get-Item Cert:\LocalMachine\Root | |
$root.Open([OpenFlags]::ReadWrite) | |
$root.Add($certWithoutKey) | |
$root.Dispose() | |
$certWithoutKey.Dispose() | |
} | |
AfterAll { | |
if ($cert) { | |
$certPath = "Cert:\LocalMachine\Root\$($cert.Thumbprint)" | |
if (Test-Path -LiteralPath $certPath) { | |
Remove-Item -LiteralPath $certPath -Force | |
} | |
$cert.Dispose() | |
} | |
} | |
Context "File encoding with BOM" { | |
It "Uses <Name>" -TestCases @( | |
@{ Name = 'UTF-16-LE'; Encoding = [UnicodeEncoding]::new($false, $true) } | |
@{ Name = 'UTF-8'; Encoding = [UTF8Encoding]::new($true) } | |
) { | |
param([Encoding]$Encoding) | |
# If a BOM is present then that encoding is used. | |
Assert-EncodingUsed -Content "é" -Encoding $Encoding | |
} | |
} | |
Context "File encoding without BOM" { | |
It "Uses UTF-8 encoding when UTF-8 sequence is within 32 bytes" { | |
# é - C3 A9 is at byte 31 and 32 respectively | |
# This triggers the IsUtf8 check and thus the scripts contents are | |
# signed as it is a UTF-8 encoded file. | |
$content = "$('a' * 30)é" | |
Assert-EncodingUsed -Content $content -Encoding $utf8Encoding | |
} | |
It "Does not use ANSI encoding when UTF-8 sequence is within 32 bytes" { | |
# Opposite of the above, just tests that the encoding matters | |
$content = "$('a' * 30)é" | |
{ | |
Assert-EncodingUsed -Content $content -Encoding $ansiEncoding -FileEncoding $utf8Encoding | |
} | Should -Throw | |
} | |
It "Uses UTF-8 encoding even when file is encoded with ANSI" { | |
# Same test as above really but just further proof that the encoding | |
# used when creating the script file doesn't matter. What matters | |
# is that the first 32 bytes are checked and if they contain a valid | |
# UTF-8 sequence the script is signed as UTF-8 encoded file. | |
# é - C3 A9 which is a valid UTF-8 sequence for é | |
$content = "#test é" | |
Assert-EncodingUsed -Content $content -Encoding $utf8Encoding -FileEncoding $ansiEncoding | |
} | |
It "Uses ANSI encoding when file contains a valid UTF-8 sequence but also invalid UTF-8 sequence" { | |
# Same test as above really but just further proof that the encoding | |
# used when creating the script file doesn't matter. What matters | |
# is that the first 32 bytes are checked and if they contain a valid | |
# UTF-8 sequence the script is signed as UTF-8 encoded file. | |
# é - C3 A9 which is a valid UTF-8 sequence for é | |
# é - E9 which is not a valid UTF-8 sequence | |
$content = "#test é é" | |
Assert-EncodingUsed -Content $content -Encoding $ansiEncoding | |
} | |
It "Uses ANSI encoding when UTF-8 sequence is after 32-bytes" { | |
# é - C3 A9 is at byte 32 and 33 respectively | |
# As the IsUtf8 check stops at byte 32 the UTF-8 sequence is not | |
# complete and thus the script is not considered as UTF-8 encoded. | |
$content = "$('a' * 31)é" | |
Assert-EncodingUsed -Content $content -Encoding $ansiEncoding -FileEncoding $utf8Encoding | |
} | |
It "Does not use UTF-8 encoding when UTF-8 sequence is not within 32 bytes" { | |
# é - C3 A9 is at byte 32 and 33 respectively | |
$content = "$('a' * 31)é" | |
{ Assert-EncodingUsed -Content $content -Encoding $utf8Encoding } | Should -Throw | |
} | |
It "Does not use UTF-8 encoding when UTF-8 sequence is after 32 bytes" { | |
# é - C3 A9 is at byte 33 and 34 respectively | |
$content = "$('a' * 32)é" | |
{ Assert-EncodingUsed -Content $content -Encoding $utf8Encoding } | Should -Throw | |
} | |
# FUTURE: Implement checks for UTF-16 BOM-less detection, i.e. IsTextUnicode() | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment