Skip to content

Instantly share code, notes, and snippets.

@jborean93
Last active December 6, 2024 06:23
Show Gist options
  • Save jborean93/fa4721cefa7c25a96901546d555ccdf6 to your computer and use it in GitHub Desktop.
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
#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