PowerShell 技能连载 - 代码签名迷你系列(第 5 部分:审计签名)

当 Powershell 脚本携带一个数字签名后,您可以快速地找出是谁对这个脚本签的名,更重要的是,这个脚本是否未被篡改。在本系列之前的部分中,您学习了如何创建数字签名,以及如何对 PowerShell 文件签名。现在我们来看看如何验证脚本的合法性。

1
2
3
4
5
# this is the path to the scripts you'd like to examine
$Path = "$home\Documents"

Get-ChildItem -Path $Path -Filter *.ps1 -Recurse |
Get-AuthenticodeSignature

只需要调整路径,该脚本讲查找该路径下的所有 PowerShell 脚本,然后检查它们的签名。结果有如下可能性:

NotSigned:	没有签名
UnknownError:	使用非受信的证书签名
HashMismatch:	签名之后修改过
Valid:		采用受信任的证书签名,并且没有改动过

PowerShell 技能连载 - 代码签名迷你系列(第 4 部分:签名 PowerShell 文件)

在将 PowerShell 脚本发送给别人之前,最好对它进行数字签名。签名的角色类似脚本的“包装器”,可以帮助别人确认是谁编写了这个脚本以及这个脚本是否仍是原始本版本,或是已被篡改过。

要对 PowerShell 脚本签名,您需要一个数字代码签名证书。在前一个技能中我们解释了如何创建一个该证书,并且/或者从 pfx 或证书存储中加载该证书。以下代码假设在 $cert 中已经有了一个合法的代码签名证书。如果还没有,请先阅读之前的技能文章!

1
2
3
4
5
6
7
8
9
10
11
12
13
# make sure this PFX file exists or create one
# or load a code-signing cert from other sources
# (review the previous tips for hints)
$pfxFile = "$home\desktop\tobias.pfx"
$cert = Get-PfxCertificate -FilePath $pfxFile

# make sure this folder exists and contains
# PowerShell script that you'd like to sign
$PathWithScripts = 'c:\myScripts'

# apply signatures to all scripts in the folder
Get-ChildItem -Path $PathWithScripts -Filter *.ps1 -Recurse |
Set-AuthenticodeSignature -Certificate $cert

运行这段代码后,指定目录中的所有脚本都会添加上数字签名。如果您连接到了 Internet,应该考虑签名时使用时间戳服务器,并且将最后一行代码替换成这行:

1
2
3
# apply signatures to all scripts in the folder
Get-ChildItem -Path $PathWithScripts -Filter *.ps1 -Recurse |
Set-AuthenticodeSignature -Certificate $cert -TimestampServer http://timestamp.digicert.com

使用时间戳服务器会减慢签名的速度但是确保不会用过期的证书签名:例如当某天一本证书过期了,但是签名仍然有效。因为官方的时间戳服务器,签名仍然有效,因为官方的时间戳服务器证明该签名是在证书过期之前应用的。

PowerShell 技能连载 - 代码签名迷你系列(第 3 部分:从个人存储中读取证书)

可以通过加载至 Windows 证书存储的方式永久地安装证书。PowerShell 可以通过 cert: 驱动器存取这个存储。以下这行代码将显示所有个人证书:

1
2
3
4
5
6
7
8
9
10
PS C:\> Get-ChildItem -Path Cert:\CurrentUser\my


PSParentPath: Microsoft.PowerShell.Security\Certificate::CurrentUser\my

Thumbprint Subject
---------- -------
9F2F02100F6AE1DA83628906D60267F89377A6B2 CN=König von Timbuktu (Ost)
65C5ED677C9EEE9AB8D8F55354E920313FE427C2 CN=UniYork IT Security
322CA0B1F37F43B26D4D8DE17DCBF3E2C17CE111 CN=Tobias

请注意:如果您的个人证书存储是空的,您可能需要查看这个系列之前的文章来创建一些测试证书。

要只查看代码签名证书,请添加 -CodeSigningCert 动态参数。这将排除所有其它用途的证书以及没有私钥的证书:

1
PS C:\> Get-ChildItem -Path Cert:\CurrentUser\my -CodeSigningCert

证书是以它们唯一的指纹 ID 标识,它就像文件的名字一样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
PS C:\> Get-ChildItem -Path Cert:\CurrentUser\my


PSParentPath: Microsoft.PowerShell.Security\Certificate::CurrentUser\my

Thumbprint Subject
---------- -------
9F2F02100F6AE1DA83628906D60267F89377A6B2 CN=King of Timbuktu (Eastside)
65C5ED677C9EEE9AB8D8F55354E920313FE427C2 CN=UniYork IT Security
322CA0B1F37F43B26D4D8DE17DCBF3E2C17CE111 CN=Tobias


PS C:\> $cert = Get-Item -Path Cert:\CurrentUser\My\9F2F02100F6AE1DA83628906D60267F89377A6B2

PS C:\> $cert


PSParentPath: Microsoft.PowerShell.Security\Certificate::CurrentUser\My

Thumbprint Subject
---------- -------
9F2F02100F6AE1DA83628906D60267F89377A6B2 CN=King of Timbuktu (Eastside)

如果还不知道唯一的指纹 ID,您需要先找出它,因为这个 ID 能够唯一确认证书。一个查找的方法是按其它属性过滤,例如 subject:

1
2
3
4
5
6
7
8
PS C:\> dir Cert:\CurrentUser\my | where subject -like *tobias*


PSParentPath: Microsoft.PowerShell.Security\Certificate::CurrentUser\my

Thumbprint Subject
---------- -------
322CA0B1F37F43B26D4D8DE17DCBF3E2C17CE111 CN=Tobias

在本迷你系列的第一部分,已经介绍了如何用 PowerShell 创建新的证书。

现在您已经学习了如何从 pfx 文件和个人证书存储中读取已有的证书。

请关注下一个技能来学习如何使用证书来实际签名 PowerShell 代码!

PowerShell 技能连载 - 代码签名迷你系列(第 2 部分:从 PFX 文件读取证书)

在前一个技能中我们创建了新的代码签名测试证书,它既是一个 pfx 文件,同时也位于您的证书存储中。今天,我们将看看如何在 PowerShell 中加载这些(或来自其它来源的任意其它证书)。

要从 pfx 文件加载证书,请使用 Get-PfxCertificate

1
2
3
4
$Path = "$home\desktop\tobias.pfx"
$cert = Get-PfxCertificate -FilePath $Path

$cert | Select-Object -Property *

Get-PfxCertificate 将提醒输入 pfx 文件创建时输入的密码。有一些 pfx 文件并没有使用密码保护或者是通过您的用户账户来保护证书,这些情况下不会显示提示。

如果您需要自动读取 pfx 证书,以下是一个通过参数输入密码,并且可以无人值守地从 pfx 文件读取证书:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function Load-PfxCertificate
{
param
(
[String]
[Parameter(Mandatory)]
$FilePath,

[SecureString]
[Parameter(Mandatory)]
$Password
)

# get clear text password
$plaintextPassword = [PSCredential]::new("X", $Password).GetNetworkCredential().Password


[void][System.Reflection.Assembly]::LoadWithPartialName("System.Security")
$container = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2Collection
$container.Import($FilePath, $plaintextPassword, 'PersistKeySet')
$container[0]
}

以下是这个函数工作的方式:

1
2
3
4
5
6
7
8
9
PS C:\> $pwd = 'secret' | ConvertTo-SecureString -AsPlainText -Force
PS C:\> $path = "$home\desktop\tobias.pfx"
PS C:\> $cert = Load-PfxCertificate -FilePath $path -Password $pwd

PS C:\> $cert

Thumbprint Subject
---------- -------
322CA0B1F37F43B26D4D8DE17DCBF3E2C17CE111 CN=Tobias

修改 Load-PfxCertificate 的最后一行,可以支持多于一个证书。改函数永远返回第一个证书 ($container[0]),但是可以选择任意另一个下标。

请关注下一个技能,学习如何存取您个人证书存储中的证书。

PowerShell 技能连载 - 代码签名迷你系列(第 1 部分:创建证书)

要使用数字签名,以及探索如何对脚本和模块签名,您首先需要代码签名证书。如果您无法从公司的 IT 部门获取到代码签名证书,PowerShell 可以为您创建一个(假设您使用的是 Windows 10 或者 Server 2016)。

我们将这些细节封装为一个名为 New-CodeSigningCert 的易用的函数,它可以在个人证书存储中创建新的代码签名证书,并且以 pfx 文件的形式返回新创建的证书。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
function New-CodeSigningCert
{
[CmdletBinding(DefaultParametersetName="__AllParameterSets")]
param
(
[Parameter(Mandatory)]
[String]
$FriendlyName,

[Parameter(Mandatory)]
[String]
$Name,

[Parameter(Mandatory,ParameterSetName="Export")]
[SecureString]
$Password,

[Parameter(Mandatory,ParameterSetName="Export")]
[String]
$FilePath,

[Switch]
$Trusted
)

$cert = New-SelfSignedCertificate -KeyUsage DigitalSignature -KeySpec Signature -FriendlyName $FriendlyName -Subject "CN=$Name" -KeyExportPolicy ExportableEncrypted -CertStoreLocation Cert:\CurrentUser\My -NotAfter (Get-Date).AddYears(5) -TextExtension @('2.5.29.37={text}1.3.6.1.5.5.7.3.3')


if ($Trusted)
{
$Store = New-Object system.security.cryptography.X509Certificates.x509Store("Root", "CurrentUser")
$Store.Open("ReadWrite")
$Store.Add($cert)
$Store.Close()
}


$parameterSet = $PSCmdlet.ParameterSetName.ToLower()

if ($parameterSet -eq "export")
{
$cert | Export-PfxCertificate -Password $Password -FilePath $FilePath
$cert | Remove-Item
explorer.exe /select,$FilePath
}
else { $cert }
}

以下是如何以 pfx 文件的形式创建代码签名证书的方法:

1
PS> New-CodeSigningCert -FriendlyName 'Tobias Code-Signing Test Cert' -Name TobiasCS -FilePath "$home\desktop\myCert.pfx"

您将会收到提示,要求输入用来保护 pfx 文件的密码。请记住该密码,一会儿导入 pfx 文件的时候需要该密码。

以下是如何在个人证书存储中创建代码签名证书的方法:

1
PS> New-CodeSigningCert -FriendlyName 'Tobias Code-Signing Test Cert' -Name TobiasCS -Trusted

调用这个函数之后,您的证书现在位于 cert: 驱动器中,您现在可以像这样查看它:

1
PS C:\> dir Cert:\CurrentUser\my

同样地,您可以打开个人证书存储来管理它:

1
PS C:\> certmgr.msc

请继续关注后续的技能,来学习现在能对代码签名证书做哪些事!

请注意自签名证书仅在复制到受信任的根证书发布者容器之后才能被信任。当使用 -Trusted 开关参数之后,会自动应用以上操作。

PowerShell 技能连载 - 将安全字符串转换为明文

安全字符串的内容并不能很轻易地查看:

1
2
3
4
5
6
$password = Read-Host -Prompt 'Your password' -AsSecureString



PS C:\> $password
System.Security.SecureString

然而,如果您是第一个要求安全字符串的人,您可以用这个聪明的技巧轻松地将它转换为纯文本:

1
2
$txt = [PSCredential]::new("X", $Password).GetNetworkCredential().Password
$txt

本质上,SecureString 是用来创建一个 PSCredential 对象,并且一个 PSCredential 对象包含了 GetNetworkCredential() 方法,它能够自动地将加密的密码转换为明文。

通过这种方式,您可以使用 Red-Hsot -AsSecureString 提供的遮罩输入框来输入敏感信息,即便您需要该信息的明文字符串:

1
2
3
4
5
6
7
8
9
10
11
12
13
function Read-HostSecret([Parameter(Mandatory)]$Prompt)
{
$password = Read-Host -Prompt $Prompt -AsSecureString
[PSCredential]::new("X", $Password).GetNetworkCredential().Password
}



PS C:\> Read-HostSecret -Prompt 'Your secret second first name'
Your secret second first name: ********
Valentin

PS C:\>

PowerShell 技能连载 - 使用目录文件

目录文件支持 (.cat) 是 PowerShell 5.1 新引入的特性。目录文件基本上是包含哈希值的文件列表。您可以用它们来确保一个指定的文件结构没有改变。

以下是一个简单的示例。它输入一个文件夹(请确保文件夹存在,否则将它重命名)并对整个文件夹的内容创建一个目录文件。请注意这将对文件夹中的每个文件创建一个哈希值,所以请留意文件夹的体积不要太大:

1
2
3
$path = c:\folderToCheck"
$catPath = "$home\Desktop\summary.cat"
New-FileCatalog -Path $path -CatalogVersion 2.0 -CatalogFilePath $catPath

New-FileCatalog 将在桌面上创建一个 summary.cat 文件。这个文件可以用于测试文件夹结构并确保文件没有改变过:

1
Test-FileCatalog -Detailed -Path $path -CatalogFilePath $catPath

结果看起来类似这样:

Status        : Valid
HashAlgorithm : SHA1
CatalogItems  : {[remoting systeminventar.ps1,
                F43C8D6F9CB93FB9AA5DBA6733D9996645832256], [klonen und
                prozessprio.ps1, F3DE20424CD90CDB5B85933B777A2F9A3F3D3187],
                [scriptblock rueckgabewerte.ps1,
                EB239D7906EF42E2639CACBE68C6FDD8F4AD899F], [Untitled4.ps1,
                E5E4DC20934287ED869230706A1DEEDEB550B8DE]...}
PathItems     : {[beispielsyntax.ps1,
                5183C82B7F0F3D0623242DD5F97A658724BE3B81], [closure.ps1,
                D2A036B068548B3E773E3BEBCF40997231576ED1], [debug1.ps1,
                3547D2659792A9ABA9E6E12F287D7A8116540FCF], [debug2.ps1,
                76C63FA578C09F30DF2BE055C37C039AFB1EFEDE]...}
Signature     : System.Management.Automation.Signature

请注意 New-FileCatalog 当前并不支持路径中的特殊字符,例如德语的 “Umlaute”。

PowerShell 技能连载 - 增强错误记录的可读性

当 PowerShell 遇到错误时,它将抛出包含关于该错误详细信息的错误记录。不幸的是,这些对象有点儿难懂,而且缺省情况下不会显示所有信息。相比之下,以下函数有着无价的帮助信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
function ConvertFrom-ErrorRecord
{
param
(
# we receive either a legit error record...
[Management.Automation.ErrorRecord[]]
[Parameter(
Mandatory,ValueFromPipeline,
ParameterSetName='ErrorRecord')]
$ErrorRecord,

# ...or a special stop exception which is raised by
# cmdlets with -ErrorAction Stop
[Management.Automation.ActionPreferenceStopException[]]
[Parameter(
Mandatory,ValueFromPipeline,
ParameterSetName='StopException')]
$Exception
)



process
{
# if we received a stop exception in $Exception,
# the error record is to be found inside of it
# in all other cases, $ErrorRecord was received
# directly
if ($PSCmdlet.ParameterSetName -eq 'StopException')
{
$ErrorRecord = $Exception.ErrorRecord
}

# compose a new object out of the interesting properties
# found in the error record object
$ErrorRecord | ForEach-Object { [PSCustomObject]@{
Exception = $_.Exception.Message
Reason = $_.CategoryInfo.Reason
Target = $_.CategoryInfo.TargetName
Script = $_.InvocationInfo.ScriptName
Line = $_.InvocationInfo.ScriptLineNumber
Column = $_.InvocationInfo.OffsetInLine
}
}
}
}

您可以在 $error 中辨认出收集到的错误信息:

1
PS C:\> $Error | ConvertFrom-ErrorRecord | Out-GridView

您也可以在 try..catch 代码快中使用它:

1
2
3
4
5
6
7
8
9
try
{
Get-Service -Name foo -ErrorAction Stop

}
catch
{
$_ | ConvertFrom-ErrorRecord
}

结果类似这样:

Exception : Cannot find any service with service name 'foo'.
Reason    : ServiceCommandException
Target    : foo
Script    :
Line      : 5
Column    : 3

您甚至可以用 -ErrorVariable 通用参数来收集一个 cmdlet 运行时发生的所有错误记录:

1
2
$r = Get-ChildItem -Path $env:windir -Filter *.ps1 -Recurse -ErrorAction SilentlyContinue -ErrorVariable test
$test | ConvertFrom-ErrorRecord

相同地,结果类似这样:

Exception : Access to the path 'C:\Windows\AppCompat\Appraiser\Telemetry' is
            denied.
Reason    : UnauthorizedAccessException
Target    : C:\Windows\AppCompat\Appraiser\Telemetry
Script    :
Line      : 3
Column    : 6

Exception : Access to the path 'C:\Windows\AppCompat\Programs' is denied.
Reason    : UnauthorizedAccessException
Target    : C:\Windows\AppCompat\Programs
Script    :
Line      : 3
Column    : 6

Exception : Access to the path 'C:\Windows\CSC\v2.0.6' is denied.
Reason    : UnauthorizedAccessException
Target    : C:\Windows\CSC\v2.0.6
Script    :
Line      : 3
Column    : 6

...

PowerShell 技能连载 - 创建写保护的函数

PowerShell 的函数缺省情况下可以在任何时候被覆盖,而且可以用 Remove-Item 来移除它:

1
2
3
4
5
6
7
8
9
10
function Test-Lifespan
{
"Hello!"
}

Test-Lifespan

Remove-Item -Path function:Test-Lifespan

Test-Lifespan

对于安全相关的函数,您可能希望以某种不会被删除的方式创建它们。以下是实现方法:

1
2
3
4
5
6
7
$FuncName = 'Test-ConstantFunc'
$Expression = {
param($Text)
"Hello $Text, I cannot be removed!"
}

Set-Item -Path function:$FuncName -Value $Expression -Options Constant,AllScope

这个新函数是用 Set-Item 直接在 function: 驱动器内创建。通过这种方式,您可以对该函数增加新的选项,例如 ConstantAllScope。这个函数能够以期待的方式工作:

1
2
PS C:\> Test-ConstantFunc -Text $env:username
Hello DemoUser, I cannot be removed!

Constant“ 确保该函数无法被覆盖或是被删除:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
PS C:\> function Test-ConstantFunc { "Got you!!" }
Cannot write to function Test-ConstantFunc because it is read-only or constant.
At line:1 char:1
+ function Test-ConstantFunc { "got you!!" }
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : WriteError: (Test-ConstantFunc:String) [], Sessio
nStateUnauthorizedAccessException
+ FullyQualifiedErrorId : FunctionNotWritable


PS C:\> Remove-Item -Path function:Test-ConstantFunc
Remove-Item : Cannot remove function Test-ConstantFunc because it is constant.
At line:1 char:1
+ Remove-Item -Path function:Test-ConstantFunc
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : WriteError: (Test-ConstantFunc:String) [Remove-It
em], SessionStateUnauthorizedAccessException
+ FullyQualifiedErrorId : FunctionNotRemovable,Microsoft.PowerShell.Command
s.RemoveItemCommand

更重要的是,”AllScope“ 确保该函数无法在子作用域中被掩盖。有了写保护之后,在一个常见的用独立的子作用于来定义一个同名的新函数的场景中:

1
2
3
4
5
& {
function Test-ConstantFunc { "I am the second function in a child scope!" }
Test-ConstantFunc

}

结果是,因为 “AllScope“ 的作用,将原来的保护函数覆盖的操作不再起作用:

Cannot write to function Test-ConstantFunc because it is read-only or constant.
At line:4 char:3
+   function Test-ConstantFunc { "I am a second function in a child sco ...
+   ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : WriteError: (Test-ConstantFunc:String) [], Sessio
    nStateUnauthorizedAccessException
    + FullyQualifiedErrorId : FunctionNotWritable

Hello , I cannot be removed!

PowerShell 技能连载 - $FormatEnumerationLimit 作用域问题

如前一个技能所述,$FormatEnumerationLimit 隐藏变量决定了输出时会在多少个元素后截断。以下是再次演示该区别的例子:

1
2
3
4
5
6
7
8
9
$default = $FormatEnumerationLimit

Get-Process | Select-Object -Property Name, Threads -First 5 | Out-Default
$FormatEnumerationLimit = 1
Get-Process | Select-Object -Property Name, Threads -First 5 | Out-Default
$FormatEnumerationLimit = -1
Get-Process | Select-Object -Property Name, Threads -First 5 | Out-Default

$FormatEnumerationLimit = $default

输出结果类似这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
Name       Threads
---- -------
acrotray {3160}
AERTSr64 {1952, 1968, 1972, 8188}
AGSService {1980, 1988, 1992, 2000...}
armsvc {1920, 1940, 1944, 7896}
ccSvcHst {2584, 2644, 2656, 2400...}



Name Threads
---- -------
acrotray {3160}
AERTSr64 {1952...}
AGSService {1980...}
armsvc {1920...}
ccSvcHst {2584...}



Name Threads
---- -------
acrotray {3160}
AERTSr64 {1952, 1968, 1972, 8188}
AGSService {1980, 1988, 1992, 2000, 2024, 7932}
armsvc {1920, 1940, 1944, 7896}
ccSvcHst {2584, 2644, 2656, 2400, 3080, 3120, 3124, 3128, 3132, 3136, 3140,...

然而这在函数(或是脚本块等情况)中使用时可能会失败:

1
2
3
4
5
6
7
function Test-Formatting
{
$FormatEnumerationLimit = 1
Get-Process | Select-Object -Property Name, Threads -First 5
}

Test-Formatting

虽然 $FormatEnumerationLimit 设置为 1,但数组仍然按缺省的显示 4 个元素。这是因为 $FormatEnumerationLimit 只对全局作用域有效。您需要在全局作用域中改变该变量才有效。所以需要用这种方法来写一个函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function Test-Formatting
{
# remember the current setting
$default = $global:FormatEnumerationLimit

# change on global scope
$global:FormatEnumerationLimit = 1
Get-Process | Select-Object -Property Name, Threads -First 5

# at the end, clean up and revert to old value
$global:FormatEnumerationLimit = $default
}

Test-Formatting