引用本文(DOI: 10.5281/zenodo.21615456)
本文保存于 Zenodo。以下同时提供始终指向最新版本的 DOI,以及固定于您正在阅读版本的 DOI。
小村 豪(2026)。《Windows 应用程序开发的安全最低限度检查清单》。小村软件有限公司。https://doi.org/10.5281/zenodo.21615456 https://comcomponent.com/zh-CN/blog/2026/03/14/001-windows-app-security-minimum-checklist/
- DOI(最新版本)
- 10.5281/zenodo.21615456
- DOI(此版本)
- 10.5281/zenodo.22281972
这个文件的内容与第 4 章的发布前检查清单(8 个类别 32 个项目)相同。区别只在于它附带了 Status 与 Notes 的填写栏,并且用 Checklist-ja / Checklist-en 两个工作表同时收录了日语和英语两种版本。一边读一边确认就看第 4 章,要作为评审记录分发就用 Excel 版,这样区分使用就够了。
一说到 Windows 应用程序的安全性,话题很容易一下子变得很大。 零信任、EDR、SBOM(Software Bill of Materials,软件物料清单)、证书运维、漏洞管理。这些都很重要,但在实际工作中,在这之前还有不少不想遗漏的基本要点。
尤其是下面这类应用程序,与其上“高级防御”,不如先堵住基本的漏洞更有效果。
- WPF / WinForms / WinUI 桌面应用程序
- C++ / C# 编写的 Win32 应用程序
- 设备联动、文件联动、数据库连接、内部分发工具
- 具有自动更新机制的业务应用程序
- 包含 Windows service 或辅助 EXE 的构成
在 Windows 应用程序开发中,与其想一次性把所有事情都做到完美,不如先不留下明显危险的漏洞更现实。 这里按设计、实现、分发、运维的顺序,把最低限度不想遗漏的要点整理成便于检查的形式。
flowchart TB
accTitle: 本文的推进方式
accDescr: 表示本文的思路是先堵住基本漏洞再上高级防御,并按设计、实现、分发、运维的顺序整理最低限度要点的图。
adv1["高级防御(零信任等)"] -.->|"在这之前"| base1["堵住基本的漏洞"]
base1 --> o1["设计"]
o1 --> o2["实现"]
o2 --> o3["分发"]
o3 --> o4["运维"]
图1:先堵住基本的漏洞再谈高级防御,并按设计、实现、分发、运维的顺序逐一来看。
1. 先说结论
- 最先不想遗漏的是:不要求不必要的管理员权限、进行签名、不以明文保存机密信息、不停用证书验证。
- Windows 应用程序的分发物本身就是攻击面。把 EXE / DLL / MSI / MSIX / 自动更新模块都纳入视野会更安全。
ServerCertificateValidationCallback => true、明文连接字符串、LoadLibrary("foo.dll")这种粗糙的加载,以及用字符串拼接执行 SQL,这些即便按最低限度的标准也应当避免。- 如果只有部分处理需要管理员权限,不要把整个应用程序提升权限,而是只把那部分拆分到单独的 EXE 或 service 中,这样更安全。
- 在 Windows 上分发的应用程序,最好以签名 + 时间戳为前提来考虑。这不仅能提升面向用户的可信度,也让篡改检测和运维说明更容易做。
- 保存时的机密信息,要根据用途分别使用 DPAPI / ProtectedData 或 Credential Locker。至少应该摆脱把它以明文放在
appsettings.json里的状态。 - 日志并不是越多越好。如果把 token、密码、连接字符串、个人信息、完整的请求正文原样留下,日志本身就会变成事故的主角。
最低限度的安全性,与其说是增加特殊功能,不如说是不要留下危险的默认行为和粗糙的实现。
flowchart TB
accTitle: 最低限度安全性的思路
accDescr: 表示最低限度的安全性不是增加特殊功能,而是不留下危险的默认行为和粗糙实现这一梳理结果的图。
add1["增加特殊功能"] -.->|"不是最低限度的着眼点"| goal1["最低限度的安全性"]
rm1["不留下危险的默认行为和粗糙的实现"] -->|"这才是着眼点"| goal1
图2:最低限度的底线不是增加功能,而是不留下危险的默认行为和粗糙的实现。
图中实线表示始终成立的关系,虚线表示带条件的关系(成立条件写在详情页各关系的说明中)。关系的完整列表(共 21 条,附依据与确信度)以及主要概念的定义,汇总在知识地图详情页(日文)。数据:JSON-LD / Turtle
2. 本文的对象范围与“最低限度”的含义
2.1. 涉及的范围
本文设想的是下面这类 Windows 应用程序。
- WPF / WinForms / WinUI 桌面应用程序
- C++ / C# 编写的 Win32 应用程序
- 内部分发工具、设备联动工具、监控工具
- 包含辅助 EXE、Windows service、更新程序的构成
- 以 EXE / MSI / MSIX 形式分发的业务软件
这里所说的“最低限度”,不是指能通过审计的最终形态,而是指一旦缺失就会实实在在出问题的项目。
flowchart TB
accTitle: 本文中“最低限度”的含义
accDescr: 表示本文的最低限度不是指能通过审计的最终形态,而是指一旦缺失就会实实在在出问题的项目这一界定的图。
au1["能通过审计的最终形态"] -.->|"这里不是指它"| mn1["本文的“最低限度”"]
ac1["缺失就会实实在在出问题的项目"] -->|"指的是它"| mn1
图3:“最低限度”指的不是应对审计的最终形态,而是缺了就会实实在在出问题的那些项目。
代码示例的前提也先统一一下。C# 的示例以 .NET 8 及以后为前提,C++ 的示例以 Win32 API 为前提。在 .NET Framework 4.8 上思路同样适用,但有些地方推荐的写法已经变了。典型例子就是 3.6 节的 ServicePointManager 相关内容,新代码已经改为以 IHttpClientFactory 和 HttpClient 为前提。重新阅读老一代代码时,请只在这一点上多加留意。
2.2. 不涉及的范围
另一方面,也有一些内容不放在本文的核心里。
- 企业整体的零信任设计
- EDR / SIEM / DLP / MDM 的整体运维
- 内核驱动的详细加固
- 从零开始进行加密设计本身
- 高级威胁分析或数字取证流程
也就是说,本文处理的不是“组织整体的庞大安全举措”,而是Windows 应用程序开发者在发布前靠自身力量就不该漏掉的基本底线。
flowchart TB
accTitle: 涉及范围与不涉及范围的界定
accDescr: 表示把组织整体的庞大安全举措排除在外,而以 Windows 应用程序开发者在发布前靠自身力量就不该漏掉的基本底线为对象这一范围界定的图。
org1["组织整体的庞大举措"] -.->|"本文不讨论"| sc1["本文的范围"]
dev1["开发者靠自身力量就不该漏掉的基本底线"] -->|"讨论的是它"| sc1
图4:讨论的不是组织整体的举措,而是开发者在发布前靠自己就能把住的基本底线。
3. 先看的检查清单
在展开细节讨论之前,先放一张能纵览全局的表。 光看这里,也大致能摸清需要重新审视的地方。
3.1. 总体概览
| 要确认的项目 | 最低限度要做的事 | 典型的错误做法 |
|---|---|---|
| 执行权限 | 以 asInvoker 为基本,只把需要提升权限的处理分离出去 |
把整个应用程序设为 requireAdministrator |
| 分发物的可信度 | 对 EXE / DLL / MSI / MSIX 做代码签名,并附加时间戳 | 未签名就直接分发 |
| 更新 | 固定更新来源,用 HTTPS 与签名校验检测篡改 | 通过 HTTP 下载后直接原样覆盖 |
| 机密信息 | 不把机密放在源代码或明文配置里,改用 DPAPI / Credential Locker 等 | 把 API 密钥或连接字符串以明文放在配置文件中 |
| 通信 | 使用 HTTPS,不停用证书验证 | 用 return true 始终跳过证书验证 |
| 外部输入 | 对 SQL、文件、IPC、URI、CSV、JSON 等全部做验证 | 以“反正是内部工具”为由直接放行 |
| DLL 加载 | 使用绝对路径、SetDefaultDllDirectories、安全的搜索顺序 |
让 LoadLibrary("foo.dll") 依赖当前目录 |
| 日志 | 遮蔽 token、密码、PII,面向用户的错误信息区分开 | 原样显示或保存异常详情和连接字符串 |
| 依赖关系 | 持续更新 SDK、NuGet、VC++ 运行时、开源依赖 | 数年固定不动,也不追踪漏洞信息 |
3.2. 权限以 asInvoker 为基本
在 Windows 应用程序中,最先想重新审视的就是这里。 如果整个应用程序以管理员权限运行,那么 bug、DLL 被替换、配置文件误读、外部输入的缺陷,都会直接以强权限执行。
flowchart TB
accTitle: 让整个应用程序以管理员权限运行的危险
accDescr: 表示如果整个应用程序以管理员权限运行,bug、DLL 被替换、配置文件误读、外部输入的缺陷都会直接以强权限执行的图。
all1["让整个应用程序以管理员权限运行"] --> bug1["bug"]
all1 --> swp1["DLL 被替换"]
all1 --> inp1["配置误读、输入缺陷"]
bug1 --> pw1["直接以强权限执行"]
swp1 --> pw1
inp1 --> pw1
图5:把整个应用程序提升权限,等于让它身上所有的缺陷都以强权限执行。
基本方针是这样的。
- 普通的 UI 应用程序用
asInvoker - 只把需要管理员权限的处理分离到单独的进程或 service 中
- 只在必要的瞬间才提升权限
- 传给辅助 EXE 或 service 的输入也要验证
如果平时只有查看和编辑功能的桌面应用程序,只有安装或修改防火墙配置才需要管理员权限,那么与其把整个应用程序设为 requireAdministrator,不如只把需要提升权限的部分交给 broker,这样更安全。
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges>
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
“以管理员身份运行更省事”这种想法,基本上都会在后面反噬回来。 以最小权限运行,再把确实需要的操作单独切出来,事故的波及半径会小很多。
flowchart TB
accTitle: 只分离出需要提升权限的处理的结构
accDescr: 表示普通 UI 应用程序以 asInvoker 运行,只把需要管理员权限的处理交给单独 EXE 或 service 形式的 broker,并且只在必要的瞬间才提升权限这一结构的图。
ui1["UI 应用程序(asInvoker)"] -->|"只在必要的瞬间发出请求"| br1["broker(单独 EXE / service)"]
br1 --> el1["只执行需要提升权限的处理"]
ui1 -.-> vd1["传给 broker 的输入也要验证"]
图6:平时用 asInvoker 运行,只把需要提升权限的处理交给 broker,事故半径就会变小。
3.3. 对二进制文件和安装程序签名
在 Windows 上,分发物的可信度很起作用。 用户接触到的不是源代码,而是 EXE、DLL、MSI、MSIX、更新程序。这里如果未签名,运维上的说明、篡改检测、分发时的安心感都会变弱。
flowchart TB
accTitle: 用户接触到的是分发物
accDescr: 表示用户接触到的不是源代码而是 EXE、DLL、安装程序、更新程序,那里未签名就会削弱篡改检测与运维说明的图。
us1["用户接触到的东西"] --> bin1["EXE / DLL"]
us1 --> pkg1["MSI / MSIX"]
us1 --> upd1["更新程序"]
bin1 --> ns1["未签名则篡改检测和说明都很弱"]
pkg1 --> ns1
upd1 --> ns1
图7:成为攻击面的是分发物本身,一直不签名就会失去可信度的依据。
最低限度想看的是下面这些。
- 对 EXE / DLL / MSI / MSIX 签名
- 不只是安装程序,更新时使用的辅助二进制文件也要签名
- 附加时间戳
- 把证书的有效期和更新流程纳入 release 流程
尤其是没有附加时间戳的签名,在证书过期后的验证中很容易出问题。 与其认为“签了名就完事”,不如把签名 + 时间戳整个纳入 release 流程,这样更稳妥。
flowchart TB
accTitle: 签名与时间戳
accDescr: 表示没有附加时间戳的签名在证书过期后的验证中容易出问题,而把签名与时间戳都纳入 release 流程会更稳妥的图。
sg2["只有签名"] --> tr1["证书过期后的验证容易出问题"]
ts1["签名 + 时间戳"] --> st2["过期后的验证不易出问题"]
ts1 -.-> fl1["纳入 release 流程"]
图8:不要止步于签名,把时间戳也一起纳入 release 流程。
如果使用 MSIX,包签名是前提。 即便是 MSI / EXE 分发,至少也应该对安装程序本体和主要的可执行二进制文件签名。
3.4. 固定更新路径,加入篡改检测
在如今的 Windows 应用程序里,更新路径比首次安装被使用得更久。 这里做得粗糙的话,即便本体做得再仔细,更新程序也会变成最薄弱的一环。
flowchart TB
accTitle: 更新路径被使用的时间最长
accDescr: 表示更新路径比首次安装被使用得更久,因此更新相关部分做得粗糙时更新程序会成为整个应用程序中最薄弱环节的图。
ins1["首次安装"] -.->|"只用一次"| ap1["应用程序的生命周期"]
up2["更新路径"] -->|"会长期持续被使用"| ap1
up2 --> wk2["做得粗糙就会成为最薄弱的一环"]
图9:更新路径比首次安装用得更久,做得粗糙就会成为最大的弱点。
更新方面最低限度要考虑的是这 5 点。
- 更新文件的获取以 HTTPS 为前提
- 对下载到的更新物验证签名或哈希
- 不让更新来源 URL 能被代码或配置无限制地替换
- 更新模块本身也要签名
- 确定回滚以及失败时的恢复流程
如果能采用 MSIX + App Installer,就更容易把更新机制靠向操作系统一侧。 另一方面,如果自带更新程序,就需要同时确认通信的安全性和分发物的真实性这两点。仅靠 HTTPS 虽然能保住“通信路径”,却无法保证“这个文件确实是自己发布的”。
flowchart TB
accTitle: 更新中要确认的两件事
accDescr: 表示自带更新程序时需要同时确认 HTTPS 带来的通信安全性,以及通过签名或哈希验证下载到的更新物真实性这两点的图。
dl1["用 HTTPS 获取更新文件"] --> vf1["验证签名或哈希"]
vf1 --> ap2["只应用通过验证的内容"]
dl1 -.-> lim1["HTTPS 保护的只是通信路径"]
vf1 -.-> own1["确认是不是自己发布的东西"]
图10:即便用 HTTPS 获取,真实性仍是另一回事,要通过签名或哈希验证后再应用。
3.5. 不把机密信息放在源代码或明文配置中
这里在实际工作中真的很容易出安全事故。 一句“反正是内部工具”“反正只是分发 exe”,连接字符串、API 密钥、共享文件夹凭据、固定 token 就很容易被放进源代码或配置文件。
最低限度,这类放法应该避免。
- 直接写在源代码中的 API 密钥
appsettings.json或app.config中的明文密码- 进了版本库的连接字符串
- 把解密密钥和密文放在同一处的设计
- 不区分用户、所有人共用的固定凭据
在 Windows 应用程序中现实可行的选项,大致就这 4 种。
- 想保存 Windows 凭据 packaged desktop app / WinUI 一类可以考虑 Credential Locker
- 想在本地加密保存机密
Win32 / .NET 可以使用 DPAPI /
ProtectedData - 连接目标可以使用 Windows 身份验证或集成身份验证 尽可能不让应用程序持有密码
- 可以在云端或服务器端管理机密 优先采用不在客户端嵌入长期机密的设计
对 C# 来说,哪怕只是像下面这样使用 DPAPI,也比明文保存好得多。把保存与读取的往返都写出来,就是这样。
// C# / .NET 8。ProtectedData 仅限 Windows,
// 在 .NET 中需要 NuGet 包 System.Security.Cryptography.ProtectedData。
using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;
public static class SecretStore
{
// 解密时也需要同一个值。为 null 也能工作,但加上更安全。
private static readonly byte[] Entropy = [0x4b, 0x53, 0x2d, 0x76, 0x31];
public static void Save(string path, string secretText)
{
byte[] plaintext = Encoding.UTF8.GetBytes(secretText);
byte[] ciphertext = ProtectedData.Protect(
plaintext,
Entropy,
DataProtectionScope.CurrentUser);
// 直接以字节序列写入文件也可以,但要放进配置文件就转成 Base64。
File.WriteAllText(path, Convert.ToBase64String(ciphertext));
CryptographicOperations.ZeroMemory(plaintext);
}
public static string Load(string path)
{
byte[] ciphertext = Convert.FromBase64String(File.ReadAllText(path));
// 如果不是保存时的同一用户、同一 entropy,就会抛出 CryptographicException。
byte[] plaintext = ProtectedData.Unprotect(
ciphertext,
Entropy,
DataProtectionScope.CurrentUser);
try
{
return Encoding.UTF8.GetString(plaintext);
}
finally
{
CryptographicOperations.ZeroMemory(plaintext);
}
}
}
调用方是这样的。
string path = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"SampleApp",
"token.dat");
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
SecretStore.Save(path, "example-api-key");
string restored = SecretStore.Load(path);
这里重要的不是“加密了所以安全”,而是要在设计上决定谁能解密。
选 CurrentUser 还是 LocalMachine,含义差别相当大。CurrentUser 只有保存它的那个用户能解密,LocalMachine 则是同一台机器上的任何人都能解密。如果要作为 service 以另一个账户运行,或者运维中存在切换用户的情况,不先把这里定下来,后面就会卡在“读不出来”或者“不该读到却读到了”这两种局面之一。
flowchart TB
accTitle: DPAPI 中谁能解密
accDescr: 表示 DPAPI 的作用域设为 CurrentUser 时只有保存它的用户能解密,设为 LocalMachine 时同一台机器上的任何人都能解密,因此需要在设计阶段先决定谁能解密的图。
dc1["在设计上决定谁能解密"] -->|"CurrentUser"| cu1["只有保存的用户能解密"]
dc1 -->|"LocalMachine"| lm1["同一台机器上的任何人都能解密"]
dc1 -.-> lt1["不定下来后面会卡住"]
图11:DPAPI 的解密范围随作用域的选择而变化,所以要先定好“谁能解密”。
另外,DPAPI 把密钥放在用户配置文件中,因此文档明确写明在配置文件未加载的状态(例如 impersonation 期间)下解密会失败。如果考虑从 service 中使用,请也确认这一点。
flowchart TB
accTitle: DPAPI 的密钥与用户配置文件
accDescr: 表示 DPAPI 把密钥放在用户配置文件中,因此在 impersonation 期间等配置文件未加载的状态下解密会失败的图。
ky1["DPAPI 的密钥"] --> pf1["位于用户配置文件中"]
pf1 -->|"配置文件未加载(例如 impersonation 期间)"| fe1["解密失败"]
fe1 -.-> sv2["从 service 使用时需要确认"]
图12:DPAPI 的密钥位于用户配置文件中,配置文件未加载时就无法解密。
如果是 SQL Server 连接,在本地部署环境中有时可以把 Windows 身份验证作为首选。
如果无论如何都要在连接字符串中包含凭据,至少要保持 Persist Security Info=False,不要一直把它放在明文配置文件里。
3.6. 通信以 HTTPS 为前提,不要停用证书验证
只打算在开发期间用一下的后门,就那么留在了正式环境。 通信方面的事故,大多是这种模式。
尤其容易残留在出货物里的,是这类代码或配置。
ServicePointManager.ServerCertificateValidationCallback += ... => trueHttpClientHandler.DangerousAcceptAnyServerCertificateValidator- 停用证书吊销确认后就那样出货
- 把以开发用自签名证书为前提的代码留在正式环境
最低限度的方针很简单。
- 正式环境的通信使用 HTTPS
- 不要始终跳过证书验证
- 如果确实需要作为例外放宽验证,就要限定目标主机和证书
- 通过构建条件或配置,确保排除开发用的绕过代码
- 在 .NET 中也要留意吊销确认
不好的例子大致是这样。
ServicePointManager.ServerCertificateValidationCallback +=
(_, _, _, _) => true;
乍看很省事,但这几乎等同于“这条 HTTPS 通信连到谁都放行”。 一旦去掉证书验证,即使用着 HTTPS,内容也已经被掏空得差不多了。
以哪个 .NET 为前提,写法就会不同
把全局配置放在 ServicePointManager 上,是 .NET Framework 时代的写法。在新代码中,从 IHttpClientFactory 取得 HttpClient,需要 TLS 相关配置时交给 SocketsHttpHandler 或 HttpClientHandler 一侧,这样更自然。
不过,认为“反正是旧 API,应该已经不起作用了”而放着不管是危险的。微软的文档中写到,ServicePointManager.ServerCertificateValidationCallback 在 .NET 9 及以后会被映射到 SocketsHttpHandler.SslOptions 的 RemoteCertificateValidationCallback。也就是说,某处一行 => true,有可能连 HttpClient 的通信也一起放行。
flowchart TB
accTitle: 旧回调作用于当前通信的路径
accDescr: 表示 ServicePointManager 的 ServerCertificateValidationCallback 在 .NET 9 及以后会被映射到 SocketsHttpHandler 的验证回调,因此某处一行返回 true 有可能连 HttpClient 的通信也一起放行的路径图。
old1["ServicePointManager 的验证回调"] -->|".NET 9 及以后会被映射"| new1["SocketsHttpHandler 一侧的验证"]
new1 --> ef1["对 HttpClient 的通信也起作用"]
ef1 -.-> rk2["一行返回 true 就可能掏空整个通信"]
图13:放在旧 API 上的验证跳过,会通过映射一路作用到如今 HttpClient 的通信。
想要作为例外放宽验证时,不要用作用于整个进程的全局配置,而要做成只闭合在那一个 handler 内的形式。
// C# / .NET 8。只把特定主机和证书作为例外处理的示例。
// 即便是为开发而放宽,不限定目标的话,和全局停用没有区别。
using System;
using System.Linq;
using System.Net.Http;
using System.Net.Security;
using System.Security.Cryptography.X509Certificates;
// 目标证书的指纹。也可以从配置读取。
const string ExpectedThumbprint = "2B0C4E6A8D1F3B5D7F9A1C3E5A7C9E1B3D5F7A91";
var handler = new HttpClientHandler
{
CheckCertificateRevocationList = true, // 去查吊销状态。默认为 false
ServerCertificateCustomValidationCallback = (request, certificate, chain, errors) =>
{
if (errors == SslPolicyErrors.None)
{
return true;
}
// 只认可“这台终端不信任内部 CA”这一种情况。
// 证书取不到、主机名不匹配,都不认可
if (errors != SslPolicyErrors.RemoteCertificateChainErrors)
{
return false;
}
// 固定住对端和证书
if (request.RequestUri?.Host != "device.internal.example"
|| certificate is null
|| !string.Equals(certificate.Thumbprint, ExpectedThumbprint,
StringComparison.OrdinalIgnoreCase))
{
return false;
}
// 即便是钉住的这一张,也不认可过期和吊销。
// 一旦认可,就成了继续使用“因密钥泄露而被吊销的证书”的通道
return chain is not null
&& chain.ChainStatus.All(s =>
s.Status is X509ChainStatusFlags.NoError
or X509ChainStatusFlags.UntrustedRoot
or X509ChainStatusFlags.PartialChain);
},
};
using var client = new HttpClient(handler);
ExpectedThumbprint 用常量或配置给出目标证书的指纹。
这里重要的是,不要把 errors 和 chain.ChainStatus 当作没看见。如果只因为指纹匹配就返回 true,那么即使该证书已经过期,或者因密钥泄露而被吊销之后,也会一直放行。证书钉扎的含义是“只信任这一张”,而不是“只要是这一张,无论出什么事都信”。上面的代码所允许的只有 UntrustedRoot 和 PartialChain(=内部 CA 没装进这台终端),NotTimeValid(过期)和 Revoked(吊销)会照常被拒绝。
要查看吊销状态需要 CheckCertificateRevocationList = true(默认是 false,不会确认吊销)。反过来说,如果内部 CA 既不发布 CRL 也不发布 OCSP,就会因 RevocationStatusUnknown 而被拦下。这才是正确的行为。如果无法准备吊销确认手段,就请用缩短证书有效期,或者事先准备好能重新分发钉扎值的通道这两者之一来补上。“在无法吊销的情况下钉扎一张长期证书”是最危险的状态。
flowchart TB
accTitle: 证书钉扎时的判定流程
accDescr: 表示无错误则放行、链错误以外一律拒绝、固定主机名与指纹、即便链状态也要拒绝过期与吊销这一作为例外放宽验证时的判定流程的图。
e0["确认 errors"] -->|"None"| pass1["放行"]
e0 -->|"链错误以外"| rj1["拒绝"]
e0 -->|"仅链错误"| hchk["核对主机名与指纹"]
hchk -->|"不匹配"| rj1
hchk -->|"匹配"| cchk["确认链状态"]
cchk -->|"过期、吊销等"| rj1
cchk -->|"仅内部 CA 未部署的情况"| pass1
图14:即便做了钉扎也不当作没看见的判定流程,过期与吊销照常被拒绝。
3.7. 把所有外部输入都当作“不可信输入”处理
Windows 应用程序不是 Web 应用程序,所以输入 validation 容易变得松懈。 但实际上,外部输入的入口比想象中多得多。
- 文件路径
- CSV / Excel / JSON / XML
- 命令行参数
- named pipe / socket / COM / RPC / gRPC
- 传给数据库的字符串
- 注册表值
- 剪贴板
- URL / deep link
- 来自外部设备或 SDK 的返回数据
其中最低限度不想遗漏的是下面 3 点。
- SQL 必须参数化 不要用字符串拼接来组装 SQL。
- 文件路径要先规范化再使用 不要把用户指定的路径原样用于删除、覆盖、解压。
- 读取外部文件时要加入大小上限和格式检查 “能打开”不等于“安全”。
以 SQL 为例,这种写法应该避免。
var sql = "SELECT * FROM Users WHERE Name = '" + userName + "'";
最低限度也要靠向这种写法。
using System.Data;
using Microsoft.Data.SqlClient;
using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT * FROM Users WHERE Name = @name";
cmd.Parameters.Add("@name", SqlDbType.NVarChar, 256).Value = userName;
“因为是内部工具,所以输入可信”是相当危险的前提。 现实中,损坏的 CSV、意料之外的文件名、陈旧的数据库数据、运维人员的手工输入失误、其他工具写出的半成品 JSON,都会照常进来。
flowchart TB
accTitle: 内部工具也会收到损坏的输入
accDescr: 表示即便是内部工具,损坏的 CSV、意料之外的文件名、陈旧的数据库数据、手工输入失误、半成品 JSON 也会照常进来,因此要把所有外部输入都当作不可信输入处理的图。
csv1["损坏的 CSV"] --> in2["应用程序的输入"]
fn1["意料之外的文件名"] --> in2
hm1["手工输入失误、陈旧数据"] --> in2
in2 --> tr2["全部当作不可信输入来验证"]
图15:内部工具也会照常收到损坏的输入,所以要在每个入口验证后再使用。
3.8. 不要让 DLL 的加载来源含糊不清
这是很有 Windows 特色的陷阱。
如果像 LoadLibrary("foo.dll") 那样仅凭名称加载 DLL,随搜索顺序的不同,有可能捡到意料之外位置的 DLL。
要做的事情是确定的。
- 尽可能指定 DLL 的绝对路径
- 在很早的阶段设置
SetDefaultDllDirectories(LOAD_LIBRARY_SEARCH_DEFAULT_DIRS) - 用
AddDllDirectory显式添加搜索目标 - 避免把
SearchPath的结果直接传给LoadLibrary的设计 - 不要完全依赖 safe DLL search mode
例如对于原生代码,在进程初始化的早期阶段加入下面这行是很有力的设计。
SetDefaultDllDirectories(LOAD_LIBRARY_SEARCH_DEFAULT_DIRS);
然后,只把必要的追加目录用 AddDllDirectory 注册进来。
这里因为“平时能跑”而容易被放着不管,但一旦分发环境的工作目录发生变化,或者其他产品的 DLL 进了 PATH,就会悄无声息地坏掉。 它不只关乎安全性,对故障预防也相当有效。
flowchart TB
accTitle: 固定 DLL 的加载来源
accDescr: 表示仅凭名称加载 DLL 时会随搜索顺序捡到意料之外位置的 DLL,应在早期阶段设置 SetDefaultDllDirectories、用 AddDllDirectory 明确搜索目标、尽可能指定绝对路径这些对策的图。
nm1["仅凭名称加载"] --> pick2["随搜索顺序捡到意料之外的 DLL"]
fix1["尽早设置 SetDefaultDllDirectories"] --> add2["用 AddDllDirectory 显式添加"]
add2 --> abs1["尽可能指定绝对路径"]
abs1 --> safe1["加载来源不再含糊"]
图16:不再把加载交给名称,而是明确搜索目标、固定加载来源。
3.9. 不在日志和异常中输出机密信息
为了排查故障而增加日志是很重要的。 但日志同样很容易变成机密的坟场。
日志方面最低限度想重新审视的是以下几点。
- 不把密码、Bearer token、API 密钥输出到日志
- 不把连接字符串整个输出
- 对个人信息和业务数据正文做遮蔽
- 异常详情在面向用户的界面和内部日志之间分开
- 不在正式环境启用 debug 用的 PII 日志
- 重新审视 dump 和 trace 保存位置的权限
在最近的 .NET 中,以 redaction 为前提来梳理也变得更容易了。 至少,“什么都字符串化后原样写 log”这种做法应该停掉。
举几个常见的失误。
- 把 HTTP request / response body 整个保存下来
- 身份验证失败时输出 token 或整个请求头
- 把异常信息原样显示在 MessageBox 里
- 在维护用的 ZIP 里把机密日志全部打包进去
错误提示可以像下面这样区分。
- 面向用户:“连接服务器失败。请检查网络配置和 URL。”
- 内部日志:失败的目标主机、TLS 错误类型、关联 ID、stack trace、重试次数
仅仅是这样区分开,信息泄露与可排查性之间的平衡就会好很多。
flowchart TB
accTitle: 错误提示的区分输出
accDescr: 表示发生错误时对用户只给出简洁的提示,而在内部日志中留下失败的目标主机、错误类型、关联 ID、stack trace 等排查信息这一区分输出方式的图。
er1["发生错误"] --> usr1["面向用户:只给简洁的提示"]
er1 --> lg2["内部日志:主机、类型、关联 ID 等"]
lg2 -.-> bl1["在防止泄露的同时保住可排查性"]
图17:仅把面向用户的显示和内部日志分开,泄露与可排查性的平衡就会变好。
3.10. 不要放任依赖库和开发工具不管
最后一项虽然不起眼,但效果很大。 即便把应用程序本体做得很仔细,如果一直搭载着旧的运行时或存在已知漏洞的依赖库,根基还是会塌。
要看的项目本身并不多。
- 让 .NET SDK / runtime 保持在受支持的版本内
- 定期确认 NuGet / 开源依赖的更新
- 如果是 C++,要管理运行时再分发组件和外部 DLL 的版本
- 把漏洞信息的确认纳入 release 前检查
- 准备好 smoke test,避免依赖更新把东西弄坏
在这里,“以后一起做”是最危险的。 放置半年、一年之后,更新的差异会变得过大,安全应对本身就会变成一项重活。
flowchart TB
accTitle: 放任依赖更新不管的后果
accDescr: 表示把依赖关系的更新放置半年到一年后更新差异会变得过大,安全应对本身会变成重活的流程图。
pt1["以后一起做"] --> ac2["放置半年~1 年"]
ac2 --> df1["更新差异变得过大"]
df1 --> hw1["应对本身变成重活"]
rg1["定期确认"] -.->|"能避免这一点"| hw1
图18:依赖的更新越放任,差异越膨胀,应对本身也越来越重。
3.11. 各个项目怎么确认
检查清单只有和确认方法配成一套才真正起作用。针对 3.2 到 3.10 的项目,这里列出发布前实际能敲出来跑的东西。
| 想确认的内容 | 确认方法 |
|---|---|
| 是否要求了提升权限 | 在应用程序的清单中查看 requestedExecutionLevel 的值。有源代码就看 app.manifest,只有分发物就用 Sysinternals 的 Sigcheck 或资源编辑器确认 |
| 签名与时间戳 | 在 PowerShell 中执行 Get-AuthenticodeSignature .\app.exe,看 Status 是否为 Valid、TimeStamperCertificate 是否有值。随附的 DLL 和 updater 也要一个个看 |
| 是否停用了证书验证 | 在整个源代码中用 ServerCertificateValidationCallback、DangerousAcceptAnyServerCertificateValidator、ServerCertificateCustomValidationCallback、CheckCertificateRevocationList 搜索 |
| 机密信息的硬编码 | 用 Password=、ApiKey、Secret、Token、ConnectionString 搜索。不只是当前的源代码,版本库的历史也要纳入范围 |
| SQL 的组装方式 | 搜索包含 "SELECT、"INSERT、+ 的字符串拼接,确认是否走了 Parameters.Add |
| DLL 的加载来源 | 用 Process Monitor 限定到目标进程,加上 Path ends with .dll 和 Result is NAME NOT FOUND 的过滤条件。这样能看到它按什么顺序去哪些地方找过,据此确认有没有去看不该看的文件夹 |
| 依赖关系的已知漏洞 | 执行 dotnet list package --vulnerable --include-transitive |
| 日志中是否输出了机密 | 先跑一次,然后用 Bearer 、Password、Authorization 搜索输出的日志 |
代码搜索用 rg(ripgrep)或 Visual Studio 的搜索都可以。要一次性跑完,就是这个形式。
Get-ChildItem -Recurse -Include *.cs,*.vb,*.cpp,*.h,*.config,*.json |
Select-String -Pattern 'ServerCertificateValidationCallback|DangerousAcceptAnyServerCertificateValidator|Password=|ApiKey' |
Select-Object Path, LineNumber, Line
重要的是把确认过这件事本身记录下来。把“搜过了”“0 条命中”也留下来,下次发布只看差异就够了。
flowchart TB
accTitle: 把确认记录下来的效果
accDescr: 表示发布前的确认即便结果为 0 条也记录下来,下次发布时就只需查看差异这一流程的图。
chk2["发布前执行确认"] --> rec1["把搜过了、0 条命中都记录下来"]
rec1 --> nx1["下次发布只看差异就够"]
图19:把“确认过了”这一事实也记录下来,下次开始就只需确认差异。
4. 发布前检查清单
做成可以直接当作评审或出货判定模板来用的形式。 为了便于用表格确认,按类别列出发布前最低限度想看的项目。
4.1. 权限与执行方式
| 检查项目 | 确认 | 备注 |
|---|---|---|
正常启动能以 asInvoker 运行 |
□ | |
| 需要管理员权限的处理已分离到单独的 EXE / service 等中 | □ | |
| 使用 service 时,没有采用强度超出必要的执行账户 | □ | |
已区分 %ProgramFiles% 下与用户数据下的职责 |
□ |
4.2. 分发与签名
| 检查项目 | 确认 | 备注 |
|---|---|---|
| 已对 EXE / DLL / MSI / MSIX / updater 签名 | □ | |
| 签名中附加了时间戳 | □ | |
| 证书的有效期与更新流程已纳入 release 流程 | □ | |
| 已确定分发物的哈希确认或篡改检测方法 | □ |
4.3. 更新
| 检查项目 | 确认 | 备注 |
|---|---|---|
| 更新的获取通过 HTTPS 进行 | □ | |
| 下载后会验证签名或哈希 | □ | |
| 设计上不易随意替换更新来源 URL | □ | |
| 有更新失败时的回滚或重试方针 | □ |
4.4. 机密信息
| 检查项目 | 确认 | 备注 |
|---|---|---|
| 没有把密码、API 密钥、连接字符串直接写进源代码 | □ | |
| 没有把机密放在明文配置文件中 | □ | |
| 需要本地保存的机密已用 DPAPI / Credential Locker 等加以保护 | □ | |
| 可行之处已靠向 Windows 身份验证或用户凭据 | □ |
4.5. 通信
| 检查项目 | 确认 | 备注 |
|---|---|---|
| 正式环境的通信使用 HTTPS | □ | |
出货物中没有残留 DangerousAcceptAnyServerCertificateValidator 或 => true |
□ | |
| 有留意吊销确认和主机名验证 | □ | |
| 正式环境中没有混入以开发用证书为前提的代码或配置 | □ |
4.6. 输入与数据访问
| 检查项目 | 确认 | 备注 |
|---|---|---|
| SQL 已参数化 | □ | |
| 命令行、文件、IPC、URI 等输入有上限和格式检查 | □ | |
| 路径操作已规范化,防止越出根目录 | □ | |
| 没有把异常信息原样输出到界面 | □ |
4.7. DLL 与运行环境
| 检查项目 | 确认 | 备注 |
|---|---|---|
| 已明确 DLL 的加载来源 | □ | |
已用 SetDefaultDllDirectories / AddDllDirectory 等控制搜索顺序 |
□ | |
| 没有把 DLL 加载交给当前目录或 PATH | □ | |
| 已掌握分发环境中动态加载所需的全部文件 | □ |
4.8. 日志与运维
| 检查项目 | 确认 | 备注 |
|---|---|---|
| 没有把 token、密码、PII 输出到日志 | □ | |
| 内部日志与面向用户的消息已分开 | □ | |
| 已重新审视 dump / trace / log 保存位置的权限 | □ | |
| 已确认 SDK 与依赖库的更新情况 | □ |
5. 常见的错误做法
在实际工作中经常见到的,大多是这类想当然。
5.1. “因为是内部工具所以没问题”
即便是内部工具,损坏的文件、误操作、带进来的终端、共享文件夹、旧的 DLL、粗糙的权限配置也都很常见。 即使没有对互联网公开,攻击面也不会消失。
5.2. “因为是 HTTPS 所以安全”
HTTPS 固然重要,但一旦停用证书验证,意义就大打折扣。 另外,在更新分发中,除了 HTTPS,还需要确认分发物的真实性。
5.3. “因为加密了所以安全”
如果没有梳理清楚解密密钥的存放位置、解密权限、用户边界、机器边界,仅靠加密是不够的。
尤其是把用 LocalMachine 保护的值当成“按用户区分的机密”来用,后面会陷入混乱。
5.4. “日志多了就能排查”
如果日志只是多,而 token 或个人信息却在到处流淌,这本身就会成为一次事故。 如果想要可排查性,先决定留下什么、隐藏什么才是正事。
5.5. “用管理员身份运行就解决了”
一开始很省事,但之后在 UAC、分发、支持、权限边界、DLL 加载、文件保存位置上大多会变得难受。 从长期来看,最小权限更稳定。
flowchart TB
accTitle: “用管理员身份运行就解决了”的结局
accDescr: 表示常态化使用管理员权限起初虽省事,但之后会在 UAC、分发、支持、权限边界、DLL 加载、文件保存位置上变得难受,而最小权限在长期看来更稳定的图。
ez1["用管理员身份运行就解决了"] --> ez2["一开始很省事"]
ez2 --> pain1["在 UAC、分发、支持上变难受"]
ez2 --> pain2["在权限边界、保存位置上变难受"]
lp1["以最小权限运行"] -->|"长期看更稳定"| ok2["能避开这些难受"]
图20:管理员常态化的省事只在开头,长期看还是最小权限更稳定。
6. 大致的优先顺序
如果一次性全做太重,优先顺序大致是这样。
排序的标准,是出安全事故时损失的大小与修复成本之低的乘积。第 3 章是按“权限 → 分发 → 实现 → 运维”这一设计流程排列的,而这里是按“从危险的开始”的顺序,所以与章节顺序并不一致。这里把对应的小节也附上。
- 重新审视管理员权限(3.2)
首先停止常态化使用
requireAdministrator。受影响范围会跨一个台阶,而作为设计改动往往规模不大。 - 签名与时间戳(3.3) 把分发物的可信度理顺。只要纳入流程即可,而事后再加就需要重新分发。
- 转移机密信息(3.5) 把机密从源代码、明文配置中移出去。泄露时损失大,而且泄露之后无法挽回。
- 修正 HTTPS + 证书验证(3.6)
从出货物中清掉
=> true一类的写法。多数情况下删掉就能修好,放着不管则整条通信都不可信。 - 重新审视 SQL / 文件 / IPC 输入(3.7) 减少字符串拼接和无验证的输入。数量多,但可以一处一处地改。
- 固定 DLL 加载(3.8) 停止仅凭名称加载、交给 PATH 的做法。这是修改启动处理的一部分的工作,同时也有助于故障预防。
- 日志的遮蔽(3.9) 避免日志在事故时变成二次灾害。输出的地方多,因此要花时间。
- 依赖更新的常态化(3.10) 做成每次发布都确认一次的流程。一次做不完,把它变成机制才算完成。
更新路径(3.4)没有进入这个排序,是因为也有不带自动更新的应用程序。如果有,请按与第 2 项签名相同的优先级来看。更新模块比本体更薄弱,却处在能改写本体的位置上。
flowchart TB
accTitle: 优先顺序的确定方式
accDescr: 表示用出安全事故时损失的大小与修复成本之低的乘积从危险的开始排序,并且在带自动更新的应用程序中把更新路径按与签名相同优先级看待这一思路的图。
dmg1["损失的大小"] --> mul1["用乘积决定顺序"]
cst1["修复成本之低"] --> mul1
mul1 --> ord1["从危险的开始逐个堵住"]
ord1 -.-> upn1["有自动更新的话,更新路径与签名同优先级"]
图21:优先顺序由损失大小与修复成本的乘积决定,从危险的漏洞开始堵。
按这个顺序推进的话,就容易以“先堵住明显危险的漏洞”的方式往前走。
7. 总结
Windows 应用程序开发的安全性,在引入特殊产品或庞大机制之前, 只要把权限、签名、机密信息、通信、输入、DLL、日志这 7 点理顺,就会有相当大的改观。
把最低限度的底线各用一句话概括,就是这样。
- 不让整个应用程序以管理员权限运行
- 对分发物和更新物签名,并附加时间戳
- 不把机密信息放在源代码或明文配置中
- 即使用了 HTTPS,也不停用证书验证
- 不信任 SQL、文件、IPC 等外部输入
- 不让 DLL 的加载来源含糊不清
- 不在日志中输出机密
- 不放任依赖库不管
安全性的话题很宽泛,但并不需要从一开始就全做。 不过,不把危险的默认行为原样出货这条最低限度,值得在相当早的阶段就凑齐。
8. 参考资料
- Administrator Broker Model - Win32 apps
- How User Account Control works
- Authenticode Digital Signatures
- Time Stamping Authenticode Signatures
- Sign a Windows app package
- Credential Locker for Windows apps
- CryptProtectData function (dpapi.h)
- CA5359: Do not disable certificate validation
- CA5399: Enable HttpClient certificate revocation list check
- Configuring parameters - ADO.NET Provider for SQL Server
- Connection String Syntax - ADO.NET
- Dynamic-Link Library Security - Win32 apps
- SetDefaultDllDirectories function (libloaderapi.h)
- Data redaction in .NET
- ProtectedData Class - 关于仅限 Windows,以及配置文件未加载时的注意事项。
- ServicePointManager.ServerCertificateValidationCallback - .NET 9 及以后会被映射到 SocketsHttpHandler 的配置。
- dotnet list package 命令 - 用
--vulnerable可以确认已知漏洞。 - Get-AuthenticodeSignature
相关文章
共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。
在 Windows 应用中把“只需要管理员权限的处理”分离出来的具体写法
在 Windows 应用中让 UI 保持 asInvoker 不变,只把需要管理员权限的处理分离到 helper EXE。本文围绕 UAC、runas、命名管道与输入校验,具体梳理这套设计该怎么落地。
意外异常发生时,应用该终止还是继续的判断表
从状态破坏、外部副作用、线程、原生边界这几个角度,梳理发生意料之外的异常时,应该让应用终止还是继续运行。
Windows 应用的敏感信息存储 - 用 DPAPI 摆脱明文配置
为了不把连接凭据和 API 令牌以明文形式存进 Windows 应用的配置文件,本文梳理 DPAPI / ProtectedData 的思路、CurrentUser 与 LocalMachine 的差别,以及实现时的注意事项。
命名管道实务 ── 从设计到安全,吃透 Windows 进程间通信的标准方案
以实务视角讲解 Windows 进程间通信的标准方案——命名管道。依据一手资料梳理字节模式与消息模式的取舍、同时接纳多个客户端的服务器结构、ACL 与模拟的安全设计,以及 .NET 的命名管道流。
Windows I/O 底层原理(第 6 篇·完结篇)——迷你过滤器的机制与用 Procmon 排查延迟
讲解迷你过滤器监视和控制文件 I/O 的机制。梳理 FltMgr、高度、pre/post 回调与 fltmc 的读法,汇总用 Procmon 定位慢操作的步骤,以及排除项设置和 Dev Drive 的注意事项。
相关主题
与本文相近的主题页面。以本文为起点,可以进一步了解相关服务和其他文章。
Windows 技术主题
汇整 KomuraSoft LLC 关于 Windows 开发、故障调查与既有资产活用文章的主题中心。
与本主题相关的服务
本文与以下服务页面相关联,欢迎从最接近的入口查看。
Windows 应用程序开发
这是一个把权限设计、分发方式、更新方式、日志设计都包含在内、对 Windows 应用程序整体做一次重新审视的话题,与 Windows 应用程序开发 的契合度很高。
技术咨询 & 设计评审
如果希望从既有应用程序的安全复查、权限边界梳理、更新程序方针的重新设计入手,可以按技术咨询 & 设计评审来推进。
常见问题
汇总了咨询这一主题时常见的问题。
- Windows 应用程序的安全性,首先应该看哪些地方?
- 有四点:不要求不必要的管理员权限、进行代码签名、不以明文保存机密信息、不停用证书验证。最低限度的安全性,与其说是增加特殊功能,不如说是不要留下危险的默认行为和粗糙的实现。始终跳过证书验证、明文连接字符串、依赖当前目录的 DLL 加载、用字符串拼接执行 SQL,这些即便按最低限度的标准也应当避免。
- 整个应用程序都以管理员权限运行不行吗?
- 应当避免。如果整个应用程序以管理员权限运行,那么 bug、DLL 被替换、配置文件误读、外部输入的缺陷,都会直接以强权限执行。基本方针是:普通的 UI 应用程序以 asInvoker 运行,只把需要管理员权限的处理分离到单独的进程或 service 中,只在必要的瞬间才提升权限。
- API 密钥和连接字符串应该保存在哪里?
- 首先要摆脱把它们以明文放在源代码或 appsettings.json 等配置文件里的状态。保存时的机密信息,应根据用途分别使用 DPAPI / ProtectedData 或 Credential Locker。另外,如果日志中原样保留 token、密码、连接字符串、个人信息,日志本身就会变成事故的主角,因此需要做遮蔽。
- 内部分发的应用程序也需要代码签名吗?
- 最好把它当成前提。在 Windows 应用程序中,分发物本身(EXE / DLL / MSI / MSIX / 自动更新模块)就是攻击面。有了代码签名 + 时间戳,就能获得篡改检测、面向用户的可信度,以及运维上更易于说明这几点好处。更新机制也应固定更新来源,做成能通过 HTTPS 与签名校验检测篡改的形式。