代码签名是把”这段程序是谁发布的、发布后有没有被改过”变成可验证事实的唯一手段。很多团队的自研工具在内部流传时从不签名,结果是运维每次双击 exe 都要过一遍 SmartScreen 警告,脚本被人替换了也没人发现。本文给出一套能在内网闭环落地的方案:自己建 CA 签发证书,用 signtool 完成签名与时间戳,再用组策略强制”未签名不许跑”。
适用场景
三类情况值得立刻动手:一是自研的运维工具、安装包、升级程序在分发时被打上”未知发布者”,用户习惯性点”仍要运行”,等于把安全提示训练成了噪音;二是 PowerShell 脚本作为计划任务或登录脚本下发,存在被中途替换的可能,需要执行前验签;三是等保或客户审计要求提供”软件完整性保护”的证据,而签名加白名单是最直接的证明材料。
需要说明边界:签名解决的是来源与完整性,不解决程序本身是否有漏洞。签过名的恶意软件照样能运行,所以签名必须和”仅信任特定签发者”的策略配合,单靠签名没有意义。
前置条件
- 一台 Windows 主机安装 Windows SDK 中的签名工具 signtool.exe(只装 SDK 的 Signing Tools 组件即可)。
- 内部 CA 环境:域环境可用 AD CS,非域环境用 OpenSSL 或系统自带命令自建根证书。
- 所有待签名的程序已确定发布流程与版本号规则,签名必须放在打包完成之后。
- 具备组策略或本地安全策略的修改权限,用于下发 AppLocker 规则。
原理说明:Authenticode 到底签了什么
Windows 的代码签名机制叫 Authenticode,它不修改程序逻辑,而是对 PE 文件的结构做两件事:先对文件头部、节表和各节内容计算哈希(默认 SHA-256),把哈希值与签名者证书链一起写入文件末尾的证书表;再用私钥对这段哈希签名。验证时系统重算哈希,用公钥解得签名值做比对,同时检查证书链是否可信、证书是否过期、是否被吊销。
这里有个关键细节:签名只覆盖 PE 结构,不覆盖附加在文件尾部或运行时释放的数据。所以签名验证通过不等于文件里没有额外载荷,仍然需要配合哈希清单或杀毒扫描。
另一个必须理解的概念是时间戳。如果不加时间戳,证书一到期,所有已签名的程序立刻变成”证书过期”,验证失败。加上 RFC 3161 时间戳后,验证方以签名时刻为准,证书过期也不影响历史签名的有效性。所以 /tr 参数不是可选项,是必选项。
信任链的根决定了谁能签。用公共 CA 的代码签名证书(OV/EV)能过 SmartScreen,但要每年付费且私钥托管风险高;内网自建 CA 的好处是可控、免费、可批量签发,代价是必须先把根证书分发到所有终端的”受信任的根证书颁发机构”,否则验证一律失败。内网工具用后者更合适。
操作步骤
第一步:建立内部根 CA 并签发代码签名证书
非域环境用 OpenSSL 建最小可用的两层结构。先生成根证书,有效期 10 年,私钥务必离线保存。
# 生成根 CA 私钥与自签证书
openssl genrsa -aes256 -out rootCA.key 4096
openssl req -x509 -new -key rootCA.key -sha256 -days 3650 ^
-subj "/C=CN/O=Corp/CN=Corp Internal Root CA" ^
-out rootCA.crt
# 生成代码签名用的中间实体私钥与请求
openssl genrsa -out codesign.key 2048
openssl req -new -key codesign.key -subj "/C=CN/O=Corp/CN=Corp Code Signing" -out codesign.csr
# 关键:extendedKeyUsage 必须是 codeSigning,否则系统不认可
echo extendedKeyUsage=codeSigning > codeext.cnf
openssl x509 -req -in codesign.csr -CA rootCA.crt -CAkey rootCA.key ^
-CAcreateserial -days 1095 -sha256 ^
-extfile codeext.cnf -out codesign.crt
# 合并为带私钥的 PFX,供 signtool 使用
openssl pkcs12 -export -out codesign.pfx -inkey codesign.key -in codesign.crt ^
-certfile rootCA.crt -passout pass:ChangeMe123
注意 extendedKeyUsage=codeSigning 这一行。少了它,证书就是一张普通客户端证书,signtool 会报 SignTool Error: No certificates were found that met all the given criteria。这是自建 CA 最常踩的坑。
随后把 rootCA.crt 通过组策略下发到”计算机配置 → Windows 设置 → 安全设置 → 公钥策略 → 受信任的根证书颁发机构”。
第二步:用 signtool 给 exe 与 dll 签名
签名顺序固定为:先设置文件版本信息,再签名,最后打包安装程序。签名后不要再改动文件,任何字节变化都会让签名失效。
signtool sign /fd SHA256 /f codesign.pfx /p ChangeMe123 ^
/tr http://timestamp.digicert.com /td SHA256 ^
/d "Corp Ops Tool" ^
"C:\build\OpsTool.exe" "C:\build\OpsAgent.dll"
# 批量签名整个目录下的可执行文件
for %f in ("C:\build\*.exe" "C:\build\*.dll") do ^
signtool sign /fd SHA256 /f codesign.pfx /p ChangeMe123 ^
/tr http://timestamp.digicert.com /td SHA256 "%f"
三个参数必须成对出现:/fd SHA256 指定文件摘要算法,/td SHA256 指定时间戳摘要算法,两者都为 SHA256 才算现代签名;/tr 是 RFC 3161 时间戳地址,不要用已废弃的 /t。
第三步:给脚本加签名
脚本签名走的是同一套 Authenticode,只是载体换成了文本文件,用系统命令即可完成,不需要 signtool。
$cert = Get-ChildItem Cert:\CurrentUser\My -CodeSigningCert |
Where-Object { $_.Subject -like "*Corp Code Signing*" } |
Select-Object -First 1
$ts = "http://timestamp.digicert.com"
Set-AuthenticodeSignature -FilePath "C:\deploy\bootstrap.ps1" -Certificate $cert -TimestampServer $ts -HashAlgorithm SHA256
脚本签名后文件末尾会多出一段 # SIG # Begin signature block,这段内容被删除或修改,签名立即失效。因此下发脚本时要用只读共享,避免运维现场编辑。
第四步:验签与篡改检测
签名做完必须验证,而且要在目标终端上验证——签名者在自己的机器上验签通过,不代表终端信任了根证书。
:: 校验单个文件,/pa 表示按默认验证策略检查
signtool verify /pa /v "C:\build\OpsTool.exe"
:: 批量校验并输出签发者
Get-ChildItem C:\build\*.exe, C:\build\*.dll | ForEach-Object {
$s = Get-AuthenticodeSignature $_.FullName
[PSCustomObject]@{
File = $_.Name
Status = $s.Status
Signer = $s.SignerCertificate.Subject
}
} | Format-Table -AutoSize
返回 Valid 才算通过。HashMismatch 说明文件被改过,NotTrusted 说明终端没装根证书,NotSigned 说明压根没签。这三种状态对应的处置完全不同,不要一律当”签名失败”处理。
第五步:用 AppLocker 强制仅允许已签名程序运行
签名只有配上强制策略才有防护价值。AppLocker 的发布者规则正是基于 Authenticode,可以做到”只允许本公司证书签名的程序在指定目录运行”。先建规则再切强制,顺序反了会导致终端大面积无法启动程序。
<AppLockerPolicy Version="1">
<RuleCollection Type="Exe" EnforcementMode="AuditOnly">
<FilePathRule Id="a1b2c3d4-0001-4000-8000-000000000001"
Name="Allow Program Files">
<Conditions><FilePathCondition Path="%PROGRAMFILES%\*" /></Conditions>
<Action>Allow</Action>
</FilePathRule>
<FilePublisherRule Id="a1b2c3d4-0002-4000-8000-000000000002"
Name="Allow Corp Signed">
<Conditions>
<FilePublisherCondition PublisherName="O=Corp, L=, S=, C=CN"
ProductName="*" BinaryName="*">
<BinaryVersionRange LowSection="*" HighSection="*" />
</FilePublisherCondition>
</Conditions>
<Action>Allow</Action>
</FilePublisherRule>
<FilePublisherRule Id="a1b2c3d4-0003-4000-8000-000000000003"
Name="Deny Unsigned In Tools">
<Conditions>
<FilePublisherCondition PublisherName="*" ProductName="*" BinaryName="*">
<BinaryVersionRange LowSection="*" HighSection="*" />
</FilePublisherCondition>
</Conditions>
<Action>Deny</Action>
</FilePublisherRule>
</RuleCollection>
</AppLockerPolicy>
导入前先用 AuditOnly 模式跑一周,从”应用程序和服务日志 → Microsoft → Windows → AppLocker → EXE and DLL”里看会被拦的项,把业务必须的例外补进规则,再改成 Enforce。先审后拦是这套流程唯一安全的推进方式。
配置验证
- 证书链:
certutil -verify -urlfetch codesign.crt,确认无吊销与链错误。 - 签名完整性:
signtool verify /pa /v返回Successfully verified,且显示Issued to: Corp Code Signing。 - 时间戳:验签输出中出现
Timestamp Verified,缺失则说明/tr未生效。 - 篡改能被发现:复制一份已签名的 exe,用文本编辑器追加一个字节,再跑
Get-AuthenticodeSignature,状态应为HashMismatch。这条测试比任何文档都有说服力。 - 策略生效:在测试机放一个未签名 exe 到受控目录,双击应被阻止并弹出”已被组策略阻止”。
常见问题
Q1:签名之后程序还是被 SmartScreen 拦,是不是签名没成功?
先分清两种提示。”未知发布者”是签名问题,检查验签状态;”Windows 已保护你的电脑”同时显示发布者名称,则是信誉问题——签名有效,但这个证书和这个文件哈希在微软的信誉库里没有下载量积累。内网自建 CA 签名的程序永远不会积累公网信誉,所以内网场景不要指望靠签名过 SmartScreen,正确做法是通过组策略把内部工具目录加入 SmartScreen 例外,或用 AppLocker 白名单替代提示。公网分发的安装包才需要 OV/EV 证书加持续分发量。
Q2:证书到期后,之前签好的程序还能用吗?
加了时间戳就能用。验证方以签名时的时间点判断证书有效性,所以只要时间戳服务器当时是可信的,证书过期不影响历史签名。反过来,如果签名时没加 /tr,证书一过期所有历史文件立刻变成 NotTimeValid,只能重新签名。这也是为什么时间戳必须写进发布流程,而不是靠人记得加。
Q3:多个部门各自签名,根证书怎么管?
用两层结构:一个根 CA 离线保存,下面按部门签发中间 CA,各部门用中间 CA 签发自己的代码签名证书。这样任何部门私钥泄露,只需吊销对应中间 CA,其他部门不受影响。根私钥必须离线存放(纸质备份口令、保险柜),永远不要放在连着内网的机器上。
总结
代码签名的落地路径就四步:内部 CA 签发带 codeSigning 用途的证书、signtool 加 SHA256 与 RFC 3161 时间戳、在目标终端验签、用 AppLocker 发布者规则强制只信本企业签发者。四步缺任何一步,签名都只是装饰。
最容易被忽略的是最后一步。没有强制策略时,签名只影响用户看到的提示文案;有了强制策略,未签名的程序才真正跑不起来,篡改也才会在启动前被拦下。这也是审计时唯一能拿得出手的证据链。