由于「入侵发生时 PowerShell 被滥用」这类报告接连出现,有些组织试图在公司内部全面禁止使用 PowerShell。但无论从实效性还是业务影响来看,这都不划算。PowerShell 本身就是 Windows 的管理基础设施,一旦停用,运维自动化也会随之停摆;而攻击方则完全可以用其他手段实现同等效果。
现实可行的方针是不去禁止,而是做到可视化与限制。幸运的是,PowerShell 5.0 以后已经具备了一整套供防御方使用的功能:即使代码经过混淆,也能以展开后的形式记录下来的脚本块日志;在执行前把脚本内容交给杀毒软件检查的 AMSI(Antimalware Scan Interface,即让恶意软件防护产品检查脚本内容的 Windows 机制);限制可执行语法本身的语言模式;以及「只委任必要操作」的 JEA。把这些手段组合起来,就能在不停止业务的前提下构建出可审计的状态。
本文按效果由高到低,整理了在企业内部 Windows 环境中持续安全使用 PowerShell 所应配置的项目。执行策略与签名的内容已在《PowerShell 的执行策略与脚本签名》中讨论过,本文将在此基础上继续展开。
目标环境・前提条件
| 项目 | 内容 |
|---|---|
| 目标操作系统 | Windows 10/11、Windows Server。AMSI 以 Windows 10 及以上版本为前提1 |
| 目标版本 | 同时面向 Windows PowerShell 5.1 和 PowerShell 7 两者。策略的设置键与日志记录位置在 5.1 和 7 中各不相同,若终端上两者都安装了,请两者都进行设置(第 3、4 章) |
| 所需权限 | 第 3~4 章的日志设置・禁用 PowerShell 2.0,以及第 7 章的 JEA 终结点注册,都需要管理员权限。实际运维中不会逐台手动设置,而是通过组策略统一分发 |
| 本地/远程 | 第 3~6 章都在目标终端・服务器内部即可完成。只有第 7 章的 JEA 以启用 PowerShell 远程处理(WinRM)为前提(参见第 7 章开头) |
| 本文不涉及的内容 | 执行策略与签名的详细内容在另一篇文章(PowerShell 的执行策略与脚本签名)中讨论。本文只整理它们的定位(第 5 章) |
1. 先说结论
- 最优先事项是启用脚本块日志。已执行的代码会被记录到事件日志中,即使是经过混淆的代码,也会以展开后的形式留存下来(事件 ID 4104)。2
- 同时也启用转录(Transcription)。可以把包含输入输出在内的会话记录,集中保存到一个只能写入的共享中。2
- 启用日志之后,还要重新检查日志的容量设置。保持默认值的话,会在短时间内被覆盖。2
- 通过 AMSI,执行前的脚本会被传递给杀毒软件检查。在 PowerShell 5.0 以后、Windows 10 以后的环境中生效。1
- 旧版的 PowerShell 2.0 引擎应予以禁用。若仍保留,就会留下切换到这个日志与 AMSI 都不生效的旧引擎的余地。3
- 执行策略并非安全边界。官方文档对此有明确说明,请不要把它当作防御的主轴。4
- 语言模式应作为 WDAC/AppLocker 的结果来使用。手动设置容易被绕过,无法作为安全功能发挥作用。5
- 权限委任交给 JEA。只允许「这条命令的、这个参数」,不必分发管理员权限也能让运维正常运转。6
2. 要守护什么 ── 先可视化,后限制
本文涉及的 4 项对策,各自负责不同的层级。先来看整体结构。
flowchart TB
U["管理员・服务台・自动化脚本<br/>-以及入侵的攻击者"]
DEL["【委任】JEA-第7章<br/>不分发管理员权限,只传递被允许的操作"]
LIM["【限制】语言模式 + WDAC/AppLocker-第6章<br/>收窄可执行的代码与可用的语法"]
SCAN["【检查】AMSI-第4章<br/>把执行前的内容交给杀毒软件<br/>通过禁用 PowerShell 2.0 堵住绕过路径"]
RUN["在 PowerShell 中的操作"]
REC["【记录】脚本块日志 4104・转录-第3章<br/>以解除混淆后的形式留存"]
U --> DEL
DEL --> LIM
LIM --> SCAN
SCAN --> RUN
RUN --> REC
记录负责建立「事后可追溯」的状态,委任负责缩小权限本身,检查与限制则在执行之前就将其拦下。这 4 项彼此并非替代关系,而是分别负责不同的层级。在此基础上,引入的顺序仍有优先级之分。
| 阶段 | 要做的事 | 效果 |
|---|---|---|
| 1. 可视化 | 脚本块日志、转录 | 弄清发生了什么,使事后调查成为可能 |
| 2. 基础性的无害化 | 禁用 PowerShell 2.0、保持最新版本、确认 AMSI 状态 | 堵住绕过检查的路径 |
| 3. 权限限定 | 通过 JEA 委任、削减管理员权限 | 限定受害范围 |
| 4. 执行限制 | WDAC/AppLocker + 约束语言模式 | 阻止未经批准的代码本身的执行 |
在许多现场,仅做到 1 和 3 就能让局面大为改善。第 4 项的引入成本较高,需要一边验证对业务的影响一边推进。
3. 启用日志 ── 4104 最为重要
PowerShell 的日志分为 3 个层级。2
| 类型 | 记录内容 | 事件 ID |
|---|---|---|
| 模块日志 | 指定模块的管道执行详情 | 4103 |
| 脚本块日志 | 已执行代码的文本(解除混淆后) | 4104 |
| 记录目标日志 | 5.1 为 Microsoft-Windows-PowerShell/Operational,7 为 PowerShellCore/Operational |
─ |
| 转录 | 将会话的输入输出记录到文本文件 | ─(文件输出) |
以上均可通过组策略的「计算机配置 > 管理模板 > Windows 组件 > Windows PowerShell」进行设置,也可以直接在注册表中设置。2
# 启用脚本块日志(需要管理员权限。通常通过 GPO 分发)
$key = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\PowerShell\ScriptBlockLogging'
New-Item -Path $key -Force | Out-Null
# -Type 是注册表提供程序附加的动态参数,可以显式指定值的类型(DWord)
Set-ItemProperty -Path $key -Name 'EnableScriptBlockLogging' -Value 1 -Type DWord
# PowerShell 7(pwsh)读取的是另一个策略键。两边都要设置
$key = 'HKLM:\SOFTWARE\Policies\Microsoft\PowerShellCore\ScriptBlockLogging'
New-Item -Path $key -Force | Out-Null
Set-ItemProperty -Path $key -Name 'EnableScriptBlockLogging' -Value 1 -Type DWord
# 启用转录,并集中保存到只写共享
$key = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\PowerShell\Transcription'
New-Item -Path $key -Force | Out-Null
Set-ItemProperty -Path $key -Name 'EnableTranscripting' -Value 1 -Type DWord
Set-ItemProperty -Path $key -Name 'OutputDirectory' -Value '\\logsrv\pstranscripts$'
Set-ItemProperty -Path $key -Name 'EnableInvocationHeader' -Value 1 -Type DWord
# 转录在 PowerShell 7 一侧也是另一个键。写入相同的值
$key = 'HKLM:\SOFTWARE\Policies\Microsoft\PowerShellCore\Transcription'
New-Item -Path $key -Force | Out-Null
Set-ItemProperty -Path $key -Name 'EnableTranscripting' -Value 1 -Type DWord
Set-ItemProperty -Path $key -Name 'OutputDirectory' -Value '\\logsrv\pstranscripts$'
Set-ItemProperty -Path $key -Name 'EnableInvocationHeader' -Value 1 -Type DWord
PowerShell 7 读取的不是 Windows\PowerShell 之下的策略,而是 PowerShellCore 之下的策略。如果只设置了 5.1 一侧就以为万事大吉,用 pwsh 执行的脚本记录就会整体缺失。无论是脚本块日志还是转录,都请在两个键中写入相同的值(通过 GPO 分发时,也要分别在对应的模板中设置)。
脚本块日志的价值在于对混淆的抵抗力。即使是经过 Base64 编码的命令,或是通过字符串拼接组装出来的代码,执行时展开后的内容也会被记录下来。2 在攻击调查中能否还原「究竟执行了什么」,正是由这项设置的有无所决定的。
记录的确认方式是 Get-WinEvent(参见《用 Get-WinEvent 实务排查事件日志 ── 筛选速度决定调查时间》)。
# 确认最近 1 天内的脚本块日志。
# Windows PowerShell(5.1)与 PowerShell 7 写入的日志位置不同,
# 因此两者都要作为对象
Get-WinEvent -FilterHashtable @{
LogName = 'Microsoft-Windows-PowerShell/Operational', 'PowerShellCore/Operational'
ID = 4104
StartTime = (Get-Date).AddDays(-1)
} -ErrorAction SilentlyContinue |
Select-Object TimeCreated, LogName, @{ n='Script'; e={ $_.Message } } -First 20
判断是否成功启用,可以从以下 3 点入手。
- 设置完成后,打开一个新的 PowerShell 会话。脚本块日志只会记录启用之后新开始的会话。2 请不要在已经打开的窗口中测试,并因此误判为「没有生效」。
- 在新会话中执行一条容易识别的命令,观察它是否以 4104 事件返回。如果上面的查询返回了包含刚才执行的代码片段的事件,说明设置已生效。如果一条都没有返回,请用
Get-ItemProperty确认策略键的值(EnableScriptBlockLogging是否为1),以及写入的究竟是 5.1 侧还是 7 侧的键。 - 确认日志的记录位置是否弄错。如果是用
pwsh执行的,记录位置就是PowerShellCore/Operational。只看Microsoft-Windows-PowerShell/Operational就误判为「没有记录」,是这项设置中最常见的误解。
启用之后,请务必重新检查日志容量。保持默认大小的话,只需数小时到数天就会被写满一轮,等真正需要时早已不在了。
Get-WinEvent -ListLog 'Microsoft-Windows-PowerShell/Operational', 'PowerShellCore/Operational' |
Select-Object LogName, MaximumSizeInBytes, RecordCount
wevtutil sl Microsoft-Windows-PowerShell/Operational /ms:1073741824 # 扩容至 1GB 的示例
wevtutil sl PowerShellCore/Operational /ms:1073741824 # 别忘了 PowerShell 7 一侧
# 确认是否已生效。MaximumSizeInBytes 变为 1073741824 即为成功
Get-WinEvent -ListLog 'Microsoft-Windows-PowerShell/Operational', 'PowerShellCore/Operational' |
Select-Object LogName, MaximumSizeInBytes
wevtutil sl 即使执行成功也不会显示任何内容,因此比较设置前后的 MaximumSizeInBytes 是唯一的确认手段。如果不是以管理员权限执行,变更会被拒绝,所以数值没有变化时,请先怀疑这一点。
把转录的集中存放位置设为「可写但不可删」的共享
转录的输出位置,要点在于设为使用者可以写入、但无法删除的共享。放在本地的话,一旦终端被攻陷,记录就会被删除。
权限需要同时设置共享访问权限和 NTFS 访问权限(实际生效的权限取两者中较严格的一方)。要点是只给使用者「在文件夹中创建文件」和「读写自己创建的文件」这两项权限,不给予删除(DE)以及删除子文件夹和文件(DC)的权限。
首先要明确这套结构能守住什么、守不住什么。能够守住的是「完全无法触碰他人的记录」和「自己的记录也无法删除」这两点。守不住的是「覆写自己的记录」,这一点仅靠 ACL 无法堵住(原因见后文)。即便如此,只要被攻陷的账户无法删除他人的证据,受害范围的切割方式就会大不相同 ── 即使一台终端沦陷,其他终端的记录依然保留。
# 在日志汇总服务器一侧执行(需要管理员权限)
$path = 'D:\PSTranscripts'
$null = New-Item -Path $path -ItemType Directory -Force
# 创建共享。使用者一侧只到「更改(写入)」,管理权限只给日志管理员
New-SmbShare -Name 'pstranscripts$' -Path $path `
-ChangeAccess 'EXAMPLE\Domain Users' -FullAccess 'EXAMPLE\日志管理员'
# 切断来自父文件夹的继承(第 2 个参数 $false = 不继承已继承的 ACE)。
# 如果继承而来的 CREATOR OWNER 完全控制权限还残留着,
# 就会变成「自己创建的文件可以自己删除」的状态,汇总也就失去了意义。
#
# 但 SetAccessRuleProtection 摘除的只是「继承而来的 ACE」,
# 直接授予该文件夹的 ACE 会原样保留。上面的 New-Item -Force
# 即使对已存在的文件夹也会成功,所以如果这个文件夹之前曾经
# 直接把更改权限授予过 Domain Users 或 Everyone,那么即便
# 执行了以下所有行,那个授权依然会存活下来。使用者仍然能够
# 覆写・删除他人的转录文件。因此这里先把直接授予的权限全部
# 摘除一次,再只重新加入必要的部分
$acl = Get-Acl -Path $path
$acl.SetAccessRuleProtection($true, $false)
foreach ($ace in @($acl.Access)) { [void]$acl.RemoveAccessRuleSpecific($ace) }
# 摘干净之后,把管理方和 SYSTEM 放进同一个 $acl 中一次性应用。
# 如果把「清空」和「重新加入」拆成两次 Set-Acl,就会出现谁都碰不到的瞬间
foreach ($id in @('EXAMPLE\日志管理员', 'SYSTEM')) {
$acl.AddAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new(
$id, 'FullControl', 'ContainerInherit, ObjectInherit', 'None', 'Allow'))
}
Set-Acl -Path $path -AclObject $acl
# 只允许使用者对文件夹执行「创建」。要点是不附加 (OI),
# 这样这项许可就不会被继承到下级文件
# WD=创建文件 AD=创建文件夹 X=遍历文件夹 RA=读取属性
# 附加 (OI) 会连「向已有文件写入数据」都一并授予出去
icacls $path /grant 'EXAMPLE\Domain Users:(CI)(WD,AD,X,RA)'
# 让写入者只能读写「自己创建的文件」。
# CREATOR OWNER 会在文件创建时被替换为「该创建者」,
# 因此对他人的文件不起作用。不授予 DE(删除)
# RD,WD,AD = 数据的读取・写入・追加 RA,WA = 属性的读写
# REA,WEA = 扩展属性的读写 RC = 读取访问权限
# S = 同步。包含在 GENERIC_READ/GENERIC_WRITE 中,无法去掉
# 会很想只保留 AD(追加),但那样的话转录将一行都写不进去(后文说明)
icacls $path /grant 'CREATOR OWNER:(OI)(IO)(RD,WD,AD,REA,WEA,RA,WA,RC,S)'
# 这里是要点。仅靠上面这一行还不够。
# 创建了文件的使用者会成为该文件的「所有者」。
# Windows 会隐式赋予所有者 READ_CONTROL 和 WRITE_DAC,
# 因此即使既没有分配 WD 也没有分配 DE,所有者依然可以自行
# 改写 DACL,重新给自己加上覆写・删除的权限。也就是说,
# 被劫持的账户可以删除自己的证据。当存在 OWNER RIGHTS 的 ACE 时,
# 系统会忽略赋予所有者的隐式 READ_CONTROL / WRITE_DAC
icacls $path /grant 'OWNER RIGHTS:(OI)(IO)(RA,REA)'
icacls $path # 确认设置结果
如果打算沿用已有的文件夹,请务必彻查其中直接授予的 ACE。SetAccessRuleProtection($true, $false) 摘除的只是继承而来的 ACE,直接附加在该文件夹上的 ACE 会原样保留下来。7 由于 New-Item -Force 即便对已存在的文件夹也会执行成功,如果重用了「之前临时给 Domain Users 加过更改权限」这类有历史的文件夹,那么之后即使收紧了 CREATOR OWNER,也只有那次直接授予会存活下来,使用者依然能够覆写・删除他人的转录文件。上面的代码之所以先全部摘除、再重新加入,正是出于这个原因。如果是新建的文件夹则没有必要,但加上也没有坏处。应用之后请亲眼确认 icacls $path 的输出,检查其中有没有排列着意料之外的主体。
这里 (OI) 的附加方式,决定了对策的成败。如果给使用者的许可附加 (OI) 并分配 WD,这条 ACE 就会被继承到下级的全部文件,只要知道文件名,就能覆写・截断他人的转录文件。7 这样一来,「即使终端或账户被劫持,记录依然留存」这个目的就无法达成了。因此像上面那样,把对文件夹的创建权限(不让它被继承)与通过 CREATOR OWNER 只对自己创建的文件进行读写这两者分开处理。
另外,请不要漏掉 OWNER RIGHTS 这一行。创建了文件的使用者会成为该文件的所有者。由于 Windows 会隐式赋予所有者 READ_CONTROL 和 WRITE_DAC,8 即使 ACE 中没有分配 DE,所有者依然可以自行改写 DACL,重新给自己加上删除权限。如果始终保持「自己的记录自己能删除」的状态,那么收紧 CREATOR OWNER 也就失去了意义。放上 OWNER RIGHTS(S-1-3-4)这条 ACE 之后,系统就会忽略赋予所有者的隐式 READ_CONTROL / WRITE_DAC,到这里「可写但不可删」才终于成立。8
为什么不只保留 AD(追加)权限
「既然不想被覆写,那 CREATOR OWNER 只保留 AD 不就行了」── 这种想法很容易冒出来。但那样一来,转录将连一行都不会留下。
在 Windows 中,「只能追加」这一性质仅在写入者单独指定 FILE_APPEND_DATA 打开文件时才成立。微软的参考文档将 FILE_APPEND_DATA 定义为「(对本地文件而言,若在不伴随 FILE_WRITE_DATA 的情况下指定该标志,写入操作将不会覆盖已有数据)」。9 也就是说,追加专用是打开方式本身的性质,并不是仅靠在 ACE 中放置 AD 就能实现的。
而 Start-Transcript 的写入入口并不会以追加专用的方式打开。PowerShell 的实现是先以 FileMode.OpenOrCreate + FileAccess.ReadWrite 打开,只有在失败时才会改用 FileMode.Append + FileAccess.Write 重新打开。10 在 .NET 一侧,FileAccess.Read/Write 会分别被映射为 GENERIC_READ/GENERIC_WRITE,而 FileMode.Append 在内部也只是被替换为 FileMode.OpenOrCreate,再定位到文件末尾而已。11 不存在单独要求 FILE_APPEND_DATA 的路径。
FILE_GENERIC_WRITE 中包含 FILE_WRITE_DATA・FILE_WRITE_ATTRIBUTES・FILE_WRITE_EA・READ_CONTROL・SYNCHRONIZE,FILE_GENERIC_READ 中包含 FILE_READ_DATA・FILE_READ_ATTRIBUTES・FILE_READ_EA・READ_CONTROL・SYNCHRONIZE。9 访问检查会审视所申请的全部权利,因此只拥有 AD,RA,REA 的使用者,这次 CreateFile 会被拒绝。即便共享一侧允许了更改(Change),也会在 NTFS 一侧被挡下来。本想保护记录,结果却让记录本身无法生成。
因此这一节的 ACL 并没有只保留 AD,而是设计成「允许读写,但不给予 DE(删除)・WDAC(权限变更)・WO(所有者变更)」的形式。所有者可以覆写・截断自己的转录文件 ── 这一点请认清「仅靠 ACL 无法堵住」,并接受这个事实。
如果连覆写都想堵住,就只能把记录输出到写入者碰不到的地方。可选方案有 3 个。
- 如果使用 JEA,可以借助会话配置文件的
TranscriptDirectory(第 7 章)。这份输出的写入者不是接入进来的使用者,而是 Local System,微软的文档也要求「标准用户不应拥有访问该文件夹的权限」「应仅限于负责审计的安全管理员」。12 由于使用者本来就不需要这项权限,本节的困扰也就随之消失 - 纳入 Windows 事件转发或 SIEM。脚本块日志(4104)会出现在事件日志中,因此可以作为转发对象
- 搭建以追加专用方式打开的自建收集进程。只要自己实现到「单独要求
FILE_APPEND_DATA并打开」这一步,ACE 一侧的AD才会真正有意义
另外需要说明,ACL 能够守护的,终究只是针对「向该共享写入的使用者」而言。文件服务器的管理员可以取得所有权,而如果终端一侧已被完全掌控,攻击者本可以在写出到共享之前就动手脚。请把本节的权限设计理解为「担保使用同一共享的使用者之间无法互相删除」这一层保障。
icacls 的权限符号(WD AD DE DC 等)与继承指定((OI) (CI) (IO))的含义,可以在 Windows 命令参考中查到完整列表。7 另外,这套结构请务必先在验证机上试运行一次,再进行部署。由于转录是运行中的会话不断向同一个文件追加内容,一旦权限收得太紧,记录本身就会失败。切实确认以下 3 点,才是稳妥的做法:「能从使用者的终端写入」「使用者无法读取、也无法改写他人的既有文件」「使用者无法删除自己的文件」。
4. 堵住绕过路径 ── PowerShell 2.0 与 AMSI
通过 AMSI(Antimalware Scan Interface),在 PowerShell 5.0 及以后的版本中,脚本内容会在执行前的瞬间被传递给杀毒软件,以解除混淆后的状态接受检查。1 只要使用的是包括 Microsoft Defender 在内的兼容产品,无需额外设置即可生效。
问题在于,旧版引擎并不具备这套机制。如果 Windows PowerShell 2.0 引擎仍处于启用状态,就会留有通过 powershell.exe -Version 2 切换到日志和 AMSI 都不生效的环境的余地。该功能已被弃用,官方建议将其禁用。3
# 确认 PowerShell 2.0 引擎的状态并将其禁用(需要管理员权限・部分情况下需要重新启动)
Get-WindowsOptionalFeature -Online -FeatureName MicrosoftWindowsPowerShellV2* |
Select-Object FeatureName, State
Disable-WindowsOptionalFeature -Online -FeatureName MicrosoftWindowsPowerShellV2Root -NoRestart
另外,如果安装了 PowerShell 7(pwsh),其设置键和日志记录位置都与 5.1 不同(策略在 PowerShellCore 之下,日志为 PowerShellCore/Operational)。在两者并存的环境中,请对设置、日志容量、排查查询这些内容都同时进行两份处理。关于版本共存,请参见《Windows PowerShell 5.1 与 PowerShell 7 的区别》。
5. 不要弄错执行策略的定位
这里再次明确一下:执行策略并不是安全边界。官方文档明确指出,它是防止用户无意中执行脚本的安全功能,并不能阻止恶意操作。4
不过这并不代表它毫无价值。签名运维具备另一种价值 ── 可以确认「已分发的模块未被篡改」(参见《PowerShell 模块的内部分发与更新 ── PSResourceGet 与内部仓库》)。重要的是要正确理解它的角色并加以使用,「因为设成了 AllSigned 所以安全」这种理解才是最危险的。
6. 语言模式 ── 与应用程序控制搭配使用
PowerShell 的会话具有语言模式,可用的语言元素会受到限制。5
| 模式 | 可使用的内容 | 实务中的定位 |
|---|---|---|
| FullLanguage | 全部语言元素(默认) | 常规会话 |
| ConstrainedLanguage | 所有 cmdlet 均可运行,循环・条件分支・字符串展开・属性引用也都可用。但可用的 .NET 类型被限定为许可列表,Add-Type 只能加载已签名的程序集 |
在 WDAC/AppLocker 之下自动切换的模式。交互操作和常规管理工作大体上都能照常进行 |
| RestrictedLanguage | 可以执行命令,但无法使用脚本块。变量仅限于 $PSCulture $PSUICulture $true $false $null,比较运算符仅限于 -eq -gt -lt,不能赋值、引用属性或调用方法 |
用于加载模块清单(.psd1)的模式,并非供人交互使用 |
| NoLanguage | 脚本语言本身被禁用。既不能使用脚本,也不能使用变量,只能调用 cmdlet 和原生命令 | JEA 会话配置(RestrictedRemoteServer)的默认模式,用于构建「只能敲既定命令」的窗口 |
这 3 种受限模式并非等级递进的关系,而是用途各不相同。ConstrainedLanguage 是「让人可以在其中编写脚本使用的环境,只是阻止危险类型的使用」,NoLanguage 则是「一开始就不让人编写脚本」,后者只有在 JEA 这类受限窗口中才能成立。5
当前的模式可以用下面的方式确认。
$ExecutionContext.SessionState.LanguageMode
重要的是设置方式。约束语言模式是在通过 WDAC(Windows Defender Application Control)或 AppLocker 构建了许可列表方式的应用程序控制之后,由 PowerShell 自动切换而生效的。5 通过环境变量等方式手动设置的做法容易被绕过,无法作为安全功能发挥作用。请理解为:不引入应用程序控制、只收紧语言模式,是一件投入与效果不成正比的事。
7. JEA ── 只委任「必要的操作」
左右现实受害程度的,多数情况下是权限的宽度。如果处于「把管理员权限交给了服务台」「运维人员全都加入了 Domain Admin」这种状态,一台设备的沦陷就会演变为全公司的沦陷。
JEA(Just Enough Administration)是一种不交出管理员权限、只委任特定操作的机制。6 它恰好能满足「只把应用服务的重启工作交给服务台」这类需求。
唯独这一章的前提不同 ── JEA 建立在远程处理之上
第 3 章到第 6 章都可以只靠本地设置完成,与之相对,JEA 本身是建立在 PowerShell 远程处理(WinRM)这套机制之上的。13 所谓「用 JEA 来委任」,指的是在目标服务器上注册一个专用的连接目标(会话配置),并让对方连接到那里。能否在自己公司复现,首先就取决于这一点。
需要具备的前提有以下 3 点。
| 前提 | 内容 |
|---|---|
| PowerShell 版本 | JEA 可以在 PowerShell 5.0 及以后的版本中使用13 |
| 启用远程处理 | 目标服务器上需要启用 PowerShell 远程处理。Windows Server 2012 及以后默认已启用,未启用时请在具有管理员权限的 PowerShell 中执行 Enable-PSRemoting13 |
| 部署位置 | 角色能力文件(.psrc)要放在目标服务器上某个模块的 RoleCapabilities 文件夹中,会话配置(.pssc)要在目标服务器上注册。它们都不是放在使用者终端上的东西6 |
也就是说,以下步骤 1~3 都是在被委任一方的服务器上、以管理员权限进行的作业。使用者终端所需要的,仅仅是能够连接到那台服务器而已。远程处理本身的构建与安全设置,已整理在《PowerShell Remoting(WinRM)入门 ── 批量管理多台 Windows》中。
步骤 1:用角色能力文件(.psrc)定义允许的操作
角色能力文件必须放在 PowerShell 模块的 RoleCapabilities 文件夹中。仅仅创建一个文件夹并不会被识别为模块,后文的 RoleDefinitions 也就无法通过名称解析到它。因此,首先要创建一个作为容器的模块(带有清单的文件夹)。6
# 创建用于存放角色能力的模块(需要清单)
$moduleRoot = 'C:\Program Files\WindowsPowerShell\Modules\KsJea'
$null = New-Item -Path "$moduleRoot\RoleCapabilities" -ItemType Directory -Force
# 模块文件夹中必须至少有一个与文件夹同名的文件
$null = New-Item -Path "$moduleRoot\KsJea.psm1" -ItemType File -Force
New-ModuleManifest -Path "$moduleRoot\KsJea.psd1" -RootModule 'KsJea.psm1'
# 创建角色能力文件的雏形(文件名即角色名)。
# 角色名是在 PSModulePath 上的全部模块中仅凭「名称」解析的,
# 因此像 'HelpDesk' 这样常见的名字,可能会和其他模块的 .psrc 冲突。
# 一旦冲突,无法保证会选中哪一个,可能会赋予意料之外的权限。
# 应使用带有组织前缀的唯一名称
New-PSRoleCapabilityFile -Path "$moduleRoot\RoleCapabilities\KsHelpDesk.psrc"
# 确认是否已作为模块可见
Get-Module -Name KsJea -ListAvailable
# KsHelpDesk.psrc(节选)── 声明「允许做什么、允许到什么程度」
@{
GUID = '....'
# 只读命令可以照原样公开
VisibleCmdlets = @(
'Get-Service',
'Get-EventLog'
)
# 会改变状态的 Restart-Service 特意不放进 VisibleCmdlets(后文说明)
# VisibleFunctions 只是收窄「已加载到会话中的函数」,
# 并不会定义函数本身。自定义函数要用 FunctionDefinitions 编写内容,
# 然后再把名字列进 VisibleFunctions 中(两者都需要)
VisibleFunctions = @('Get-KsAppStatus', 'Restart-KsAppService')
FunctionDefinitions = @(
@{
Name = 'Get-KsAppStatus'
ScriptBlock = {
# 函数体在默认的语言模式下运行,因此不受 JEA 的约束。
# 不要把使用者的输入原样传给危险的命令
Get-Service -Name 'KsAppService' |
Microsoft.PowerShell.Utility\Select-Object Name, Status, StartType
}
},
@{
# 重启功能以「只能通过参数接收目标」的函数形式公开
Name = 'Restart-KsAppService'
ScriptBlock = {
param(
[Parameter(Mandatory)]
[ValidateSet('KsAppService', 'Spooler')]
[string] $Name
)
Microsoft.PowerShell.Management\Restart-Service -Name $Name
}
}
)
VisibleExternalCommands = @()
}
仅靠 VisibleCmdlets 的 ValidateSet,无法安全地收窄会改变状态的命令。Parameters 与 ValidateSet 所做的限制,只有在该参数实际被绑定时才会生效。由于 Restart-Service 可以通过管道接收 ServiceController,
Get-Service WinRM | Restart-Service
如果这样写,-Name 就不会被绑定,ValidateSet 也就被绕了过去。本来打算「只能重启 KsAppService 和 Spooler」的终结点,结果就变成了可以重启任意服务。
同样的情况在另一个参数集上也会发生。即使给 Get-WinEvent 的 LogName 加上 ValidateSet,
Get-WinEvent -ProviderName Microsoft-Windows-Security-Auditing -MaxEvents 1
只要这样写,LogName 就不会被绑定,限制也就不起作用。由于 JEA 会话是以虚拟管理员身份运行的,这种情况下甚至连 Security 日志都能读到。
因此上面的示例没有把 Restart-Service 放进 VisibleCmdlets,只公开了仅能通过参数接收目标的包装函数(Restart-KsAppService)。只要输入路径只有参数这一条,就无从绕过。
Parameters 与 ValidateSet 所做的限制,只有在「该参数被绑定」时才会起作用。对于能够通过管道输入或其他参数集绕开绑定的命令,限制本身就会失效。请这样理解:想要收窄参数的命令,原则上应该用包装函数包起来。
这里有 2 点容易踩坑的规范。第一点是,VisibleFunctions 并不会创建函数。仅仅列出名字的函数并不存在于会话中,在使用者看来就是「没有这条命令」。自定义函数请先用 FunctionDefinitions 定义,再把名字也列进 VisibleFunctions 中。14 如果数量增多,把它们拆分到脚本模块中,再通过 VisibleFunctions 公开该模块的函数,会更便于管理。
第二点是,函数体本身不受 JEA 的约束。14 如果想以本来的行为使用 Select-Object 等被 JEA 替换掉的受限命令,就要像上面那样以 Microsoft.PowerShell.Utility\Select-Object 这种完全限定名的方式调用。反过来说,这也意味着函数内部可以做任何事,因此请绝对避免把来自使用者的输入直接传给 Invoke-Expression 这类写法。
步骤 2:用会话配置文件(.pssc)定义把哪个角色分配给谁
# SessionType:受限的远程服务器(默认为 NoLanguage)
# RunAsVirtualAccount:以虚拟管理员账户身份运行
# TranscriptDirectory:记录执行内容
$pssc = @{
Path = '.\KsHelpDesk.pssc'
SessionType = 'RestrictedRemoteServer'
RunAsVirtualAccount = $true
TranscriptDirectory = 'C:\JeaTranscripts'
RoleDefinitions = @{ 'EXAMPLE\HelpDesk' = @{ RoleCapabilities = 'KsHelpDesk' } }
}
New-PSSessionConfigurationFile @pssc
步骤 3:注册
Register-PSSessionConfiguration -Name 'KsHelpDesk' -Path .\KsHelpDesk.pssc -Force
使用者一侧按以下方式连接。
Enter-PSSession -ComputerName 'appsrv01' -ConfigurationName 'KsHelpDesk'
# 只能使用被允许的命令。Restart-Service 也只能针对指定的服务
JEA 的要点有 3 个。6
- 使用者不具备管理员权限。执行是在虚拟账户一侧进行的
- 会话会被构建为受限的远程服务器。默认情况下语言模式受限,无法执行任意代码
- 执行内容会以转录的形式被记录下来。可以审计「谁做了什么」
另外,如前所述,角色能力文件必须位于某个模块的 RoleCapabilities 文件夹之下,其前提是这个模块能够从 $env:PSModulePath 中被找到。当 RoleDefinitions 中指定的角色名无法解析时,请先用 Get-Module -ListAvailable 确认该模块是否可见。6
已注册的终结点可以用 Get-PSSessionConfiguration 列出。正如开头所说,JEA 是以远程执行的构建为前提的,因此当无法连接时,请先从 WinRM 一侧、而不是 JEA 的定义那一侧进行排查(参见《PowerShell Remoting(WinRM)入门 ── 批量管理多台 Windows》)。
8. 实务检查清单
| 项目 | 优先级 | 状态 |
|---|---|---|
| 已启用脚本块日志(4104) | 高 | 通过 GPO 全公司分发215 |
| 已扩大 PowerShell 相关日志的容量 | 高 | 保持默认值的话数天内就会消失 |
| 已启用转录,并集中保存到只写共享 | 高 | 不放在终端本地2 |
| 已禁用 PowerShell 2.0 引擎 | 高 | 日志・AMSI 的绕过路径3 |
| 杀毒软件已支持 AMSI | 高 | 默认已启用1 |
| 没有把执行策略误解为安全边界 | 中 | 签名是另一种价值4 |
| 已盘点管理员权限的授予范围 | 高 | 决定受害范围的是权限的宽度 |
| 已用 JEA 委任定型作业 | 中 | 直接关系到管理员权限的削减6 |
| 已考虑引入 WDAC/AppLocker | 中 | 语言模式限制要与之配套5 |
| 已把日志设置也分发到 PowerShell 7 一侧 | 中 | 5.1 与 7 的设置各不相同 |
9. 总结
- 禁止 PowerShell 实效性低,还会让业务停摆。方针应该是「可视化与限制」。
- 最优先事项是启用脚本块日志(4104)。即使是经过混淆的代码,也会以展开后的形式被记录下来。请与扩大日志容量一并实施。
- 转录的要点在于,要集中保存到使用者无法删除的共享中。
- PowerShell 2.0 引擎应予以禁用,以免留下日志和 AMSI 都不生效的路径。
- 执行策略不是安全边界。签名作为篡改检测有其价值,但请不要把它当作防御的主轴。
- 权限的宽度决定了受害的广度。用 JEA 只委任「必要的操作」,就能在不分发管理员权限的情况下让运维正常运转。
示例代码下载
本文涉及的代码,已整理成可以直接运行的形式对外分发,其中包含日志设置的启用以及 JEA 终结点的创建。
本文的示例代码依赖于 Windows 及租户环境,因此未进行执行验证。所有文件都已完成语法解析和 PSScriptAnalyzer 静态解析,但实际运行效果请务必在您自己的验证机上确认。
# 语法解析 + 静态解析(在非 Windows 环境下也可以执行)
./Invoke-SampleTests.ps1
设置值(路径、服务器名、租户 ID 等)均为示例。请不要直接在生产环境中运行,务必按照贵公司的实际环境进行相应调整。
相关文章
- PowerShell 的执行策略与脚本签名 ── 从「用 Bypass 蒙混过关」的运维方式毕业的实务指南
- 用 Get-WinEvent 实务排查事件日志 ── 筛选速度决定调查时间
- PowerShell Remoting(WinRM)入门 ── 批量管理多台 Windows
- PowerShell 中凭据的安全处理方式 ── 把明文密码逐出脚本
- Windows 应用程序开发安全性最低限度检查清单
- 信息安全10大威胁2026——排行榜的正确打开方式,与中小企业真正应该防范的威胁
相关咨询领域
合同会社小村软件承接 Windows 运维环境的安全设置评审、包含权限委任(JEA)在内的运维设计,以及审计日志的整备与运用方面的咨询。
参考链接
-
Microsoft Learn,Antimalware Scan Interface (AMSI)。关于 AMSI 是一种让应用程序和服务把内容传递给任意反恶意软件产品进行检查的机制,包括 PowerShell 在内的 Windows 脚本引擎已与其集成,即使是经过混淆的脚本也能以运行时的内容接受检查。 ↩ ↩2 ↩3 ↩4
-
Microsoft Learn,about_Logging_Windows。关于模块日志・脚本块日志・转录这 3 种日志功能,通过组策略及注册表(HKLM\SOFTWARE\Policies\Microsoft\Windows\PowerShell 之下)进行启用,脚本块日志以事件 ID 4104 记录解除混淆后的代码,模块日志以事件 ID 4103 记录,以及转录的 OutputDirectory 和 EnableInvocationHeader 设置。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9
-
Microsoft Learn,Windows PowerShell 2.0 的弃用。关于 Windows PowerShell 2.0 引擎已被弃用,建议将其禁用,以及它是作为 Windows 的可选功能提供的,可以切换启用・禁用状态。 ↩ ↩2 ↩3
-
Microsoft Learn,about_Execution_Policies。关于执行策略并非安全边界,而是防止用户无意中执行脚本的安全功能,以及存在多种绕过手段。 ↩ ↩2 ↩3
-
Microsoft Learn,about_Language_Modes。关于 FullLanguage・ConstrainedLanguage・RestrictedLanguage・NoLanguage 各模式下可用的语言元素,通过 $ExecutionContext.SessionState.LanguageMode 确认当前模式,在启用了 WDAC 或 AppLocker 应用程序控制的环境中 PowerShell 会以约束语言模式运行,以及手动设置语言模式并非作为安全功能而设计。 ↩ ↩2 ↩3 ↩4 ↩5
-
Microsoft Learn,Just Enough Administration (JEA) 概述。关于 JEA 是一种不授予管理员权限、只委任特定管理工作的机制,角色能力文件(.psrc)中通过 VisibleCmdlets・参数与 ValidateSet 进行的限制,会话配置文件(.pssc)的 RestrictedRemoteServer・RunAsVirtualAccount・TranscriptDirectory・RoleDefinitions,通过 Register-PSSessionConfiguration 进行注册,以及 JEA 会话中语言模式会受到限制。关于角色能力文件必须放置在 PowerShell 模块的 RoleCapabilities 文件夹中(需要处于可被识别为模块的状态)、通过 New-PSRoleCapabilityFile 进行创建,请参见 JEA Role Capabilities。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7
-
Microsoft Learn,icacls。关于这是一个用于显示・更改文件和文件夹访问控制列表的命令,通过 /grant 进行权限授予、通过 /remove:g 删除已授予的权限,可在权限掩码中指定的详细权限符号(DE=删除、DC=删除子文件夹和文件、WD=写入数据/创建文件、AD=追加数据/创建子文件夹、WA=写入属性、WEA=写入扩展属性、RA=读取属性、X=执行/遍历、F=完全访问等),以及继承指定((OI)=对象继承、(CI)=容器继承)。共享的创建与访问权限的指定请参见 New-SmbShare(-FullAccess / -ChangeAccess / -ReadAccess),继承的禁用请参见 ObjectSecurity.SetAccessRuleProtection(第 1 个参数用于保护继承,第 2 个参数用于指定是否保留已继承的 ACE)。该方法能够指定的只是继承 ACE 的处理方式,直接授予该对象的 ACE 不在其范围之内。若要摘除直接授予的权限,需要用 ObjectSecurity.RemoveAccessRuleSpecific 等方法逐一移除。 ↩ ↩2 ↩3
-
Microsoft Learn,特殊标识组(Special Identity Groups)。关于
OWNER RIGHTS(S-1-3-4)是代表对象当前所有者的组,当对象上应用了带有该 SID 的 ACE 时,系统会忽略赋予所有者的隐式READ_CONTROL和WRITE_DAC。反过来说,如果没有这条 ACE,所有者就能隐式地改写 DACL。 ↩ ↩2 -
Microsoft Learn,File Access Rights Constants。关于
FILE_APPEND_DATA被定义为「For a file object, the right to append data to the file. (For local files, write operations will not overwrite existing data if this flag is specified without FILE_WRITE_DATA.)」,追加专用的写入仅在「不伴随FILE_WRITE_DATA、单独指定FILE_APPEND_DATA打开」时才成立,以及FILE_WRITE_DATA/FILE_ADD_FILE、FILE_DELETE_CHILD的含义。通用访问权限的映射关系(FILE_GENERIC_WRITE=FILE_APPEND_DATA+FILE_WRITE_ATTRIBUTES+FILE_WRITE_DATA+FILE_WRITE_EA+STANDARD_RIGHTS_WRITE+SYNCHRONIZE,FILE_GENERIC_READ=FILE_READ_ATTRIBUTES+FILE_READ_DATA+FILE_READ_EA+STANDARD_RIGHTS_READ+SYNCHRONIZE)请参见 File Security and Access Rights。 ↩ ↩2 -
PowerShell,
TranscriptionOption.FlushContentToDisk(src/System.Management.Automation/engine/hostifaces/MshHostUserInterface.cs)。关于转录的写入入口以new FileStream(this.Path, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.Read)打开,仅在失败时才会改用new FileStream(this.Path, FileMode.Append, FileAccess.Write, FileShare.Read)重新打开,以及不存在单独要求FILE_APPEND_DATA的路径。 ↩ -
.NET,
SafeFileHandle.Open(src/libraries/System.Private.CoreLib/src/Microsoft/Win32/SafeHandles/SafeFileHandle.Windows.cs)。关于FileAccess.Read/FileAccess.Write分别被映射为GENERIC_READ/GENERIC_WRITE,FileMode.Append只是在调用CreateFile之前被替换为FileMode.OpenOrCreate,访问掩码中不会单独使用FILE_APPEND_DATA。 ↩ -
Microsoft Learn,JEA 会话配置。关于在会话配置文件的
TranscriptDirectory中指定文件夹后会自动记录转录,以及「Transcripts are written to the folder by the Local System account, which requires read and write access to the directory. Standard users should have no access to the folder. Limit the number of security administrators that have access to audit the transcripts.」(标准用户不应拥有访问该文件夹的权限,应仅限于负责审计的管理员)。 ↩ -
Microsoft Learn,JEA 的前提条件。关于 JEA 可以在 PowerShell 5.0 及以后的版本中使用,PowerShell 远程处理是 JEA 的基础,在使用 JEA 之前远程处理必须已启用并得到妥善保护,Windows Server 2012 及以后 PowerShell 远程处理默认已启用,未启用时可以在具有管理员权限的 PowerShell 窗口中执行 Enable-PSRemoting 来启用。 ↩ ↩2 ↩3
-
Microsoft Learn,JEA Role Capabilities。关于必须先用 FunctionDefinitions 定义自定义函数,再把名字也列进 VisibleFunctions 中(「Don’t forget to add the name of your custom functions to the VisibleFunctions field so they can be run by the JEA users.」),函数体(脚本块)以系统默认的语言模式运行,不受 JEA 约束,若要以本来的实现使用被 JEA 替换掉的受限命令,需要使用完全限定名(Microsoft.PowerShell.Utility\Select-Object),函数较多时可拆分到脚本模块中再通过 VisibleFunctions 公开的方法,以及模块文件夹中需要有与文件夹同名的文件。 ↩ ↩2
-
Microsoft Learn,Set-ItemProperty。关于使用注册表提供程序时 -Type 会被追加为动态参数,可以指定注册表值的数据类型(String / ExpandString / Binary / DWord / MultiString / QWord 等)。包含在值不存在时会被创建这一点。 ↩
相关文章
共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。
用 Get-WinEvent 实务排查事件日志 ── 筛选速度决定调查时间
本文整理用 PowerShell 提升 Windows 事件日志调查效率的方法,涵盖用 Where-Object 筛选为何缓慢、FilterHashtable 与 XPath 的区分使用、重启・登录・应用异常终止的调查方法,直至多台计算机的日志收集。
Windows 安全审核策略与事件日志排查实务 ── 成为看得懂 4625 的信息系统人员
这是一份用于应对「帮忙查一下登录失败日志」需求的实务指南。内容涵盖基本审核策略与高级审核策略的关系、至少应启用的子类别、事件 ID 4624/4625/4688 的解读方法、Security 日志的容量设计,直至用 Get-WinEvent 提取日志。
Windows LAPS实务指南 ── 告别全部PC通用的本地管理员密码
全部PC通用的本地管理员密码,是「一台被攻破、全部沦陷」的Pass-the-Hash攻击温床。本文讲解已成为OS标准功能的Windows LAPS如何自动轮换密码、如何配置保存到AD/Entra ID,以及运维中的常见陷阱。
Windows证书存储实务指南 ── 应该放入用户存储还是计算机存储
客户端证书究竟应该放入用户存储还是计算机存储?本文从 certmgr.msc 与 certlm.msc 的区别、私钥的权限授予,到 PowerShell 的到期日盘点,系统性地梳理证书相关的常见事故与对策,是一份实务指南。
Windows 防火墙与业务应用 ── 入站规则要通过安装程序注册
「开发机上正常运行,但在客户现场却无法通信」的常见原因就是 Windows 防火墙。本文讲解入站默认阻止与网络配置文件、不能把生产环境交给通知对话框处理的原因,以及通过安装程序注册入站规则的方法与排查步骤。
相关主题
与本文相近的主题页面。以本文为起点,可以进一步了解相关服务和其他文章。
Windows 技术主题
汇整 KomuraSoft LLC 关于 Windows 开发、故障调查与既有资产活用文章的主题中心。
常见问题
汇总了咨询这一主题时常见的问题。
- 作为安全对策,是否应该在公司内部全面禁止使用 PowerShell?
- 不推荐这样做,因为实效性低而副作用大。PowerShell 本身就是 Windows 的管理基础设施,其实体是 .NET 之上的功能。即使封锁可执行文件,攻击者仍可以通过其他手段调用相同的 API,因此很难对攻击方造成实质障碍,而正规的管理工作与自动化流程反而会被确实地停止。现实可行的方针不是禁止,而是「可视化与限制」:用脚本块日志记录发生了什么,禁用旧版本引擎,并按需通过语言模式或 JEA 收窄可执行的范围。
- 启用脚本块日志后,日志量会不会暴增,造成困扰?
- 确实会增加,因此请务必与日志容量设置一并启用。如果让 Microsoft-Windows-PowerShell/Operational 日志的最大容量保持默认值,就会在短时间内被覆盖,等真正需要时反而已经不存在了。实务上应留出足够的日志容量,必要时通过 SIEM 或事件转发进行集中收集。另外,虽然还有能记录到更细粒度「调用开始・停止」的设置,但其输出量非常庞大,通常只启用默认的脚本块日志即可。
- 把执行策略设为 AllSigned,是否就能算作安全对策?
- 执行策略并不是安全边界。官方文档也明确指出,它只是防止用户无意中执行危险脚本的机制,并不能阻止恶意操作,因为存在多种绕过方法。签名运维确实有另一种价值 ── 可以确认分发物的完整性,但防御的主轴应放在通过日志实现的可视化、AMSI 检查,以及通过语言模式或 JEA 进行的权限限制上。
- 约束语言模式(ConstrainedLanguage)可以通过手动方式设置吗?
- 不建议通过环境变量等方式手动设置,因为很容易被绕过,无法作为安全功能发挥作用。约束语言模式本来的设计是:在通过 WDAC(Windows Defender Application Control)或 AppLocker 构建应用程序控制之后,PowerShell 会自动切换到这一模式。如果不引入应用程序控制,仅仅收紧语言模式,并不会形成有实效的防御。
- 希望只让服务台人员负责重启服务器上的特定服务,该怎么做?
- JEA(Just Enough Administration)正是为此而生的机制。可以在角色能力文件中定义「只允许这条命令的这个参数的这个值」,并将其注册为会话配置。使用者在不具备管理员权限的情况下,可以通过虚拟账户执行被允许的操作。该会话会被构建为受限的远程服务器,默认情况下语言模式也会受限,因此没有执行任意代码的余地。执行内容还可以以转录的形式记录下来。