Windows 应用程序开发的安全最低限度检查清单

· 更新日期: · · Windows 开发, 安全, 设计, C# / .NET, Win32

更新记录(2 条,最后更新 2026年09月03日)

本文的修改记录。已保存的更新前版本,可通过带有 DOI 的永久链接阅读。

本文此前是日文原文的节译,缺少大量章节、表格、Mermaid 图、图题、脚注与 FAQ。现已改写为日文原文的完整译文,技术主张与日文版一致,并补上了此前缺失的图与表,同时统一了全篇的术语译法。 查看更新前的版本 (DOI: 10.5281/zenodo.22276779)
补充了日文原文中已有的咨询引导(consultation_services)。正文内容没有改动。 查看更新前的版本 (DOI: 10.5281/zenodo.21615457)
首次发布
引用本文(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

下载 Excel 版检查清单

这个文件的内容与第 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 应用程序开发中,与其想一次性把所有事情都做到完美,不如先不留下明显危险的漏洞更现实。 这里按设计、实现、分发、运维的顺序,把最低限度不想遗漏的要点整理成便于检查的形式。

本文的推进方式表示本文的思路是先堵住基本漏洞再上高级防御,并按设计、实现、分发、运维的顺序整理最低限度要点的图。在这之前高级防御(零信任等)堵住基本的漏洞设计实现分发运维

图1:先堵住基本的漏洞再谈高级防御,并按设计、实现、分发、运维的顺序逐一来看。

1. 先说结论

  • 最先不想遗漏的是:不要求不必要的管理员权限、进行签名、不以明文保存机密信息、不停用证书验证。
  • Windows 应用程序的分发物本身就是攻击面。把 EXE / DLL / MSI / MSIX / 自动更新模块都纳入视野会更安全。
  • ServerCertificateValidationCallback => true、明文连接字符串、LoadLibrary("foo.dll") 这种粗糙的加载,以及用字符串拼接执行 SQL,这些即便按最低限度的标准也应当避免。
  • 如果只有部分处理需要管理员权限,不要把整个应用程序提升权限,而是只把那部分拆分到单独的 EXE 或 service 中,这样更安全。
  • 在 Windows 上分发的应用程序,最好以签名 + 时间戳为前提来考虑。这不仅能提升面向用户的可信度,也让篡改检测和运维说明更容易做。
  • 保存时的机密信息,要根据用途分别使用 DPAPI / ProtectedData 或 Credential Locker。至少应该摆脱把它以明文放在 appsettings.json 里的状态。
  • 日志并不是越多越好。如果把 token、密码、连接字符串、个人信息、完整的请求正文原样留下,日志本身就会变成事故的主角。

最低限度的安全性,与其说是增加特殊功能,不如说是不要留下危险的默认行为和粗糙的实现。

最低限度安全性的思路表示最低限度的安全性不是增加特殊功能,而是不留下危险的默认行为和粗糙实现这一梳理结果的图。不是最低限度的着眼点这才是着眼点增加特殊功能最低限度的安全性不留下危险的默认行为和粗糙的实现

图2:最低限度的底线不是增加功能,而是不留下危险的默认行为和粗糙的实现。

图中实线表示始终成立的关系,虚线表示带条件的关系(成立条件写在详情页各关系的说明中)。关系的完整列表(共 21 条,附依据与确信度)以及主要概念的定义,汇总在知识地图详情页(日文)。数据:JSON-LD / Turtle

2. 本文的对象范围与“最低限度”的含义

2.1. 涉及的范围

本文设想的是下面这类 Windows 应用程序。

  • WPF / WinForms / WinUI 桌面应用程序
  • C++ / C# 编写的 Win32 应用程序
  • 内部分发工具、设备联动工具、监控工具
  • 包含辅助 EXE、Windows service、更新程序的构成
  • 以 EXE / MSI / MSIX 形式分发的业务软件

这里所说的“最低限度”,不是指能通过审计的最终形态,而是指一旦缺失就会实实在在出问题的项目。

本文中“最低限度”的含义表示本文的最低限度不是指能通过审计的最终形态,而是指一旦缺失就会实实在在出问题的项目这一界定的图。这里不是指它指的是它能通过审计的最终形态本文的“最低限度”缺失就会实实在在出问题的项目

图3:“最低限度”指的不是应对审计的最终形态,而是缺了就会实实在在出问题的那些项目。

代码示例的前提也先统一一下。C# 的示例以 .NET 8 及以后为前提,C++ 的示例以 Win32 API 为前提。在 .NET Framework 4.8 上思路同样适用,但有些地方推荐的写法已经变了。典型例子就是 3.6 节的 ServicePointManager 相关内容,新代码已经改为以 IHttpClientFactory 和 HttpClient 为前提。重新阅读老一代代码时,请只在这一点上多加留意。

2.2. 不涉及的范围

另一方面,也有一些内容不放在本文的核心里。

  • 企业整体的零信任设计
  • EDR / SIEM / DLP / MDM 的整体运维
  • 内核驱动的详细加固
  • 从零开始进行加密设计本身
  • 高级威胁分析或数字取证流程

也就是说,本文处理的不是“组织整体的庞大安全举措”,而是Windows 应用程序开发者在发布前靠自身力量就不该漏掉的基本底线。

涉及范围与不涉及范围的界定表示把组织整体的庞大安全举措排除在外,而以 Windows 应用程序开发者在发布前靠自身力量就不该漏掉的基本底线为对象这一范围界定的图。本文不讨论讨论的是它组织整体的庞大举措本文的范围开发者靠自身力量就不该漏掉的基本底线

图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 被替换、配置文件误读、外部输入的缺陷,都会直接以强权限执行。

让整个应用程序以管理员权限运行的危险表示如果整个应用程序以管理员权限运行,bug、DLL 被替换、配置文件误读、外部输入的缺陷都会直接以强权限执行的图。让整个应用程序以管理员权限运行bugDLL 被替换配置误读、输入缺陷直接以强权限执行

图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>

“以管理员身份运行更省事”这种想法,基本上都会在后面反噬回来。 以最小权限运行,再把确实需要的操作单独切出来,事故的波及半径会小很多。

只分离出需要提升权限的处理的结构表示普通 UI 应用程序以 asInvoker 运行,只把需要管理员权限的处理交给单独 EXE 或 service 形式的 broker,并且只在必要的瞬间才提升权限这一结构的图。只在必要的瞬间发出请求UI 应用程序(asInvoker)broker(单独 EXE / service)只执行需要提升权限的处理传给 broker 的输入也要验证

图6:平时用 asInvoker 运行,只把需要提升权限的处理交给 broker,事故半径就会变小。

3.3. 对二进制文件和安装程序签名

在 Windows 上,分发物的可信度很起作用。 用户接触到的不是源代码,而是 EXE、DLL、MSI、MSIX、更新程序。这里如果未签名,运维上的说明、篡改检测、分发时的安心感都会变弱。

用户接触到的是分发物表示用户接触到的不是源代码而是 EXE、DLL、安装程序、更新程序,那里未签名就会削弱篡改检测与运维说明的图。用户接触到的东西EXE / DLLMSI / MSIX更新程序未签名则篡改检测和说明都很弱

图7:成为攻击面的是分发物本身,一直不签名就会失去可信度的依据。

最低限度想看的是下面这些。

  • 对 EXE / DLL / MSI / MSIX 签名
  • 不只是安装程序,更新时使用的辅助二进制文件也要签名
  • 附加时间戳
  • 把证书的有效期和更新流程纳入 release 流程

尤其是没有附加时间戳的签名,在证书过期后的验证中很容易出问题。 与其认为“签了名就完事”,不如把签名 + 时间戳整个纳入 release 流程,这样更稳妥。

签名与时间戳表示没有附加时间戳的签名在证书过期后的验证中容易出问题,而把签名与时间戳都纳入 release 流程会更稳妥的图。只有签名证书过期后的验证容易出问题签名 + 时间戳过期后的验证不易出问题纳入 release 流程

图8:不要止步于签名,把时间戳也一起纳入 release 流程。

如果使用 MSIX,包签名是前提。 即便是 MSI / EXE 分发,至少也应该对安装程序本体和主要的可执行二进制文件签名。

3.4. 固定更新路径,加入篡改检测

在如今的 Windows 应用程序里,更新路径比首次安装被使用得更久。 这里做得粗糙的话,即便本体做得再仔细,更新程序也会变成最薄弱的一环。

更新路径被使用的时间最长表示更新路径比首次安装被使用得更久,因此更新相关部分做得粗糙时更新程序会成为整个应用程序中最薄弱环节的图。只用一次会长期持续被使用首次安装应用程序的生命周期更新路径做得粗糙就会成为最薄弱的一环

图9:更新路径比首次安装用得更久,做得粗糙就会成为最大的弱点。

更新方面最低限度要考虑的是这 5 点。

  • 更新文件的获取以 HTTPS 为前提
  • 对下载到的更新物验证签名或哈希
  • 不让更新来源 URL 能被代码或配置无限制地替换
  • 更新模块本身也要签名
  • 确定回滚以及失败时的恢复流程

如果能采用 MSIX + App Installer,就更容易把更新机制靠向操作系统一侧。 另一方面,如果自带更新程序,就需要同时确认通信的安全性和分发物的真实性这两点。仅靠 HTTPS 虽然能保住“通信路径”,却无法保证“这个文件确实是自己发布的”。

更新中要确认的两件事表示自带更新程序时需要同时确认 HTTPS 带来的通信安全性,以及通过签名或哈希验证下载到的更新物真实性这两点的图。用 HTTPS 获取更新文件验证签名或哈希只应用通过验证的内容HTTPS 保护的只是通信路径确认是不是自己发布的东西

图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 以另一个账户运行,或者运维中存在切换用户的情况,不先把这里定下来,后面就会卡在“读不出来”或者“不该读到却读到了”这两种局面之一。

DPAPI 中谁能解密表示 DPAPI 的作用域设为 CurrentUser 时只有保存它的用户能解密,设为 LocalMachine 时同一台机器上的任何人都能解密,因此需要在设计阶段先决定谁能解密的图。CurrentUserLocalMachine在设计上决定谁能解密只有保存的用户能解密同一台机器上的任何人都能解密不定下来后面会卡住

图11:DPAPI 的解密范围随作用域的选择而变化,所以要先定好“谁能解密”。

另外,DPAPI 把密钥放在用户配置文件中,因此文档明确写明在配置文件未加载的状态(例如 impersonation 期间)下解密会失败。如果考虑从 service 中使用,请也确认这一点。

DPAPI 的密钥与用户配置文件表示 DPAPI 把密钥放在用户配置文件中,因此在 impersonation 期间等配置文件未加载的状态下解密会失败的图。配置文件未加载(例如 impersonation 期间)DPAPI 的密钥位于用户配置文件中解密失败从 service 使用时需要确认

图12:DPAPI 的密钥位于用户配置文件中,配置文件未加载时就无法解密。

如果是 SQL Server 连接,在本地部署环境中有时可以把 Windows 身份验证作为首选。 如果无论如何都要在连接字符串中包含凭据,至少要保持 Persist Security Info=False,不要一直把它放在明文配置文件里。

3.6. 通信以 HTTPS 为前提,不要停用证书验证

只打算在开发期间用一下的后门,就那么留在了正式环境。 通信方面的事故,大多是这种模式。

尤其容易残留在出货物里的,是这类代码或配置。

  • ServicePointManager.ServerCertificateValidationCallback += ... => true
  • HttpClientHandler.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 的通信也一起放行。

旧回调作用于当前通信的路径表示 ServicePointManager 的 ServerCertificateValidationCallback 在 .NET 9 及以后会被映射到 SocketsHttpHandler 的验证回调,因此某处一行返回 true 有可能连 HttpClient 的通信也一起放行的路径图。.NET 9 及以后会被映射ServicePointManager 的验证回调SocketsHttpHandler 一侧的验证对 HttpClient 的通信也起作用一行返回 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 而被拦下。这才是正确的行为。如果无法准备吊销确认手段,就请用缩短证书有效期,或者事先准备好能重新分发钉扎值的通道这两者之一来补上。“在无法吊销的情况下钉扎一张长期证书”是最危险的状态。

证书钉扎时的判定流程表示无错误则放行、链错误以外一律拒绝、固定主机名与指纹、即便链状态也要拒绝过期与吊销这一作为例外放宽验证时的判定流程的图。None链错误以外仅链错误不匹配匹配过期、吊销等仅内部 CA 未部署的情况确认 errors放行拒绝核对主机名与指纹确认链状态

图14:即便做了钉扎也不当作没看见的判定流程,过期与吊销照常被拒绝。

3.7. 把所有外部输入都当作“不可信输入”处理

Windows 应用程序不是 Web 应用程序,所以输入 validation 容易变得松懈。 但实际上,外部输入的入口比想象中多得多。

  • 文件路径
  • CSV / Excel / JSON / XML
  • 命令行参数
  • named pipe / socket / COM / RPC / gRPC
  • 传给数据库的字符串
  • 注册表值
  • 剪贴板
  • URL / deep link
  • 来自外部设备或 SDK 的返回数据

其中最低限度不想遗漏的是下面 3 点。

  1. SQL 必须参数化 不要用字符串拼接来组装 SQL。
  2. 文件路径要先规范化再使用 不要把用户指定的路径原样用于删除、覆盖、解压。
  3. 读取外部文件时要加入大小上限和格式检查 “能打开”不等于“安全”。

以 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,都会照常进来。

内部工具也会收到损坏的输入表示即便是内部工具,损坏的 CSV、意料之外的文件名、陈旧的数据库数据、手工输入失误、半成品 JSON 也会照常进来,因此要把所有外部输入都当作不可信输入处理的图。损坏的 CSV应用程序的输入意料之外的文件名手工输入失误、陈旧数据全部当作不可信输入来验证

图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,就会悄无声息地坏掉。 它不只关乎安全性,对故障预防也相当有效。

固定 DLL 的加载来源表示仅凭名称加载 DLL 时会随搜索顺序捡到意料之外位置的 DLL,应在早期阶段设置 SetDefaultDllDirectories、用 AddDllDirectory 明确搜索目标、尽可能指定绝对路径这些对策的图。仅凭名称加载随搜索顺序捡到意料之外的 DLL尽早设置 SetDefaultDllDirectories用 AddDllDirectory 显式添加尽可能指定绝对路径加载来源不再含糊

图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、重试次数

仅仅是这样区分开,信息泄露与可排查性之间的平衡就会好很多。

错误提示的区分输出表示发生错误时对用户只给出简洁的提示,而在内部日志中留下失败的目标主机、错误类型、关联 ID、stack trace 等排查信息这一区分输出方式的图。发生错误面向用户:只给简洁的提示内部日志:主机、类型、关联 ID 等在防止泄露的同时保住可排查性

图17:仅把面向用户的显示和内部日志分开,泄露与可排查性的平衡就会变好。

3.10. 不要放任依赖库和开发工具不管

最后一项虽然不起眼,但效果很大。 即便把应用程序本体做得很仔细,如果一直搭载着旧的运行时或存在已知漏洞的依赖库,根基还是会塌。

要看的项目本身并不多。

  • 让 .NET SDK / runtime 保持在受支持的版本内
  • 定期确认 NuGet / 开源依赖的更新
  • 如果是 C++,要管理运行时再分发组件和外部 DLL 的版本
  • 把漏洞信息的确认纳入 release 前检查
  • 准备好 smoke test,避免依赖更新把东西弄坏

在这里,“以后一起做”是最危险的。 放置半年、一年之后,更新的差异会变得过大,安全应对本身就会变成一项重活。

放任依赖更新不管的后果表示把依赖关系的更新放置半年到一年后更新差异会变得过大,安全应对本身会变成重活的流程图。能避免这一点以后一起做放置半年~1 年更新差异变得过大应对本身变成重活定期确认

图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 条命中”也留下来,下次发布只看差异就够了。

把确认记录下来的效果表示发布前的确认即便结果为 0 条也记录下来,下次发布时就只需查看差异这一流程的图。发布前执行确认把搜过了、0 条命中都记录下来下次发布只看差异就够

图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 加载、文件保存位置上大多会变得难受。 从长期来看,最小权限更稳定。

“用管理员身份运行就解决了”的结局表示常态化使用管理员权限起初虽省事,但之后会在 UAC、分发、支持、权限边界、DLL 加载、文件保存位置上变得难受,而最小权限在长期看来更稳定的图。长期看更稳定用管理员身份运行就解决了一开始很省事在 UAC、分发、支持上变难受在权限边界、保存位置上变难受以最小权限运行能避开这些难受

图20:管理员常态化的省事只在开头,长期看还是最小权限更稳定。

6. 大致的优先顺序

如果一次性全做太重,优先顺序大致是这样。

排序的标准,是出安全事故时损失的大小与修复成本之低的乘积。第 3 章是按“权限 → 分发 → 实现 → 运维”这一设计流程排列的,而这里是按“从危险的开始”的顺序,所以与章节顺序并不一致。这里把对应的小节也附上。

  1. 重新审视管理员权限(3.2) 首先停止常态化使用 requireAdministrator。受影响范围会跨一个台阶,而作为设计改动往往规模不大。
  2. 签名与时间戳(3.3) 把分发物的可信度理顺。只要纳入流程即可,而事后再加就需要重新分发。
  3. 转移机密信息(3.5) 把机密从源代码、明文配置中移出去。泄露时损失大,而且泄露之后无法挽回。
  4. 修正 HTTPS + 证书验证(3.6) 从出货物中清掉 => true 一类的写法。多数情况下删掉就能修好,放着不管则整条通信都不可信。
  5. 重新审视 SQL / 文件 / IPC 输入(3.7) 减少字符串拼接和无验证的输入。数量多,但可以一处一处地改。
  6. 固定 DLL 加载(3.8) 停止仅凭名称加载、交给 PATH 的做法。这是修改启动处理的一部分的工作,同时也有助于故障预防。
  7. 日志的遮蔽(3.9) 避免日志在事故时变成二次灾害。输出的地方多,因此要花时间。
  8. 依赖更新的常态化(3.10) 做成每次发布都确认一次的流程。一次做不完,把它变成机制才算完成。

更新路径(3.4)没有进入这个排序,是因为也有不带自动更新的应用程序。如果有,请按与第 2 项签名相同的优先级来看。更新模块比本体更薄弱,却处在能改写本体的位置上。

优先顺序的确定方式表示用出安全事故时损失的大小与修复成本之低的乘积从危险的开始排序,并且在带自动更新的应用程序中把更新路径按与签名相同优先级看待这一思路的图。损失的大小用乘积决定顺序修复成本之低从危险的开始逐个堵住有自动更新的话,更新路径与签名同优先级

图21:优先顺序由损失大小与修复成本的乘积决定,从危险的漏洞开始堵。

按这个顺序推进的话,就容易以“先堵住明显危险的漏洞”的方式往前走。

7. 总结

Windows 应用程序开发的安全性,在引入特殊产品或庞大机制之前, 只要把权限、签名、机密信息、通信、输入、DLL、日志这 7 点理顺,就会有相当大的改观。

把最低限度的底线各用一句话概括,就是这样。

  • 不让整个应用程序以管理员权限运行
  • 对分发物和更新物签名,并附加时间戳
  • 不把机密信息放在源代码或明文配置中
  • 即使用了 HTTPS,也不停用证书验证
  • 不信任 SQL、文件、IPC 等外部输入
  • 不让 DLL 的加载来源含糊不清
  • 不在日志中输出机密
  • 不放任依赖库不管

安全性的话题很宽泛,但并不需要从一开始就全做。 不过,不把危险的默认行为原样出货这条最低限度,值得在相当早的阶段就凑齐。

8. 参考资料

共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。

与本文相近的主题页面。以本文为起点,可以进一步了解相关服务和其他文章。

本文与以下服务页面相关联,欢迎从最接近的入口查看。

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 与签名校验检测篡改的形式。

作者简介

本文作者的个人简介页面。

Go Komura

小村软件有限公司 代表

以 Windows 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。

返回博客列表