第三部分:实现系统
原文:Part III. Implementing Systems
译者:飞龙
一旦您分析并设计了您的系统,就该是实现计划的时候了。在某些情况下,实现可能意味着购买现成的解决方案。第十一章提供了谷歌在决定构建定制软件解决方案时的思考过程的一个例子。
本书的第三部分着重于在软件开发生命周期的实现阶段集成安全性和可靠性。第十二章重申了框架将简化您的系统的观念。在测试过程中添加静态分析和模糊测试,正如第十三章所描述的那样,将加固代码。第十四章讨论了为什么您还应该投资于可验证的构建和进一步的控制——如果对手能够绕过它们达到您的生产环境,那么围绕编码、构建和测试的保障措施的效果将受到限制。
即使您的整个软件供应链对安全性和可靠性故障具有弹性,当问题出现时,您仍然需要分析您的系统。第十五章讨论了您必须在授予适当的调试访问权限和存储和访问日志的安全要求之间保持谨慎平衡。
第十一章:案例研究:设计、实现和维护公信 CA
原文:11. Case Study: Designing, Implementing, and Maintaining a Publicly Trusted CA
译者:飞龙
作者:Andy Warner、James Kasten、Rob Smits、Piotr Kucharski 和 Sergey Simakov
公信证书颁发机构的背景
公信证书颁发机构通过为传输层安全性(TLS)、S/MIME 等常见的分布式信任场景颁发证书,充当互联网传输层的信任锚点。它们是浏览器、操作系统和设备默认信任的 CA 集合。因此,编写和维护一个公信 CA 引发了许多安全性和可靠性考虑。
要成为公信并保持这一地位,CA 必须通过跨不同平台和用例的一系列标准。至少,公信 CA 必须接受诸如WebTrust和欧洲电信标准化协会(ETSI)等组织设定的标准的审计。公信 CA 还必须满足CA/Browser 论坛基线要求的目标。这些评估评估逻辑和物理安全控制、程序和实践,一个典型的公信 CA 每年至少花费四分之一的时间进行这些审计。此外,大多数浏览器和操作系统都有自己独特的要求,CA 必须在被默认信任之前满足这些要求。随着要求的变化,CA 需要适应并愿意进行流程或基础设施的变更。
你的组织很可能永远不需要构建一个公信 CA——大多数组织依赖第三方获取公共 TLS 证书、代码签名证书和其他需要用户广泛信任的证书。考虑到这一点,本案例研究的目标不是向您展示如何构建一个公信 CA,而是强调我们的一些发现可能与您环境中的项目产生共鸣。主要的收获包括以下内容:
-
我们选择的编程语言以及在处理第三方生成的数据时使用分段或容器使整体环境更加安全。
-
严格测试和加固代码——无论是我们自己生成的代码还是第三方代码——对于解决基本的可靠性和安全性问题至关重要。
-
当我们在设计中减少复杂性并用自动化替换手动步骤时,我们的基础设施变得更安全、更可靠。
-
了解我们的威胁模型使我们能够构建验证和恢复机制,使我们能够更好地提前为灾难做准备。
我们为什么需要一个公信 CA?
随着时间的推移,我们对公信 CA 的业务需求发生了变化。在谷歌早期,我们从第三方 CA 购买了所有的公共证书。这种方法存在三个固有问题,我们希望解决:
依赖第三方
业务需求需要高度的信任——例如,向客户提供云服务——这意味着我们需要对证书的发放和处理进行强有力的验证和控制。即使我们在 CA 生态系统中进行了强制性的审计,我们仍然不确定第三方是否能够达到高标准的安全性。公信 CA 中的显著安全漏洞巩固了我们对安全性的看法。
自动化的需求
谷歌拥有成千上万的公司拥有的域名,为全球用户提供服务。作为我们普遍的 TLS 工作的一部分(参见“示例:增加 HTTPS 使用率”),我们希望保护我们拥有的每个域,并经常更换证书。我们还希望为客户提供获取 TLS 证书的简便方法。自动获取新证书很困难,因为第三方公信 CA 通常没有可扩展的 API,或者提供的 SLA 低于我们的需求。因此,这些证书的请求过程很大程度上涉及容易出错的手动方法。
成本
考虑到谷歌想要为自己的网络属性和客户代表使用数百万个 TLS 证书,成本分析显示,与继续从第三方根 CA 获取证书相比,设计、实现和维护我们自己的 CA 将更具成本效益。
建设或购买决策
一旦谷歌决定要运营一个公信 CA,我们就必须决定是购买商业软件来运营 CA,还是编写我们自己的软件。最终,我们决定自己开发 CA 的核心部分,并在必要时集成开源和商业解决方案。在许多决定因素中,这个决定背后有一些主要的动机:
透明度和验证
CA 的商业解决方案通常没有我们对代码或供应链的审计能力,这是我们对于如此关键的基础设施所需的。尽管它与开源库集成并使用了一些第三方专有代码,但编写和测试我们自己的 CA 软件使我们对正在构建的系统更加有信心。
集成能力
我们希望通过与谷歌的安全关键基础设施集成,简化 CA 的实现和维护。例如,我们可以在Spanner中的配置文件中设置定期备份。
灵活性
更广泛的互联网社区正在开发新的倡议,以提高生态系统的安全性。证书透明度——一种监视和审计证书的方式——以及使用 DNS、HTTP 和其他方法进行域验证⁵是两个典型的例子。我们希望成为这类倡议的早期采用者,自定义 CA 是我们能够迅速增加这种灵活性的最佳选择。
设计、实现和维护考虑
为了保护我们的 CA,我们创建了一个三层分层架构,其中每一层负责发行过程的不同部分:证书请求解析、注册机构功能(路由和逻辑)和证书签发。每一层都由具有明确定义责任的微服务组成。我们还设计了一个双信任区域架构,其中不受信任的输入在不同的环境中处理关键操作。这种分割创建了精心定义的边界,促进了可理解性和审查的便利。该架构还使得发动攻击更加困难:由于组件的功能受到限制,攻击者如果获得对特定组件的访问权限,也将受到功能受限的限制。要获得额外的访问权限,攻击者必须绕过额外的审计点。
每个微服务都是以简单性作为关键原则进行设计和实现的。在 CA 的整个生命周期中,我们不断地以简单性为考量对每个组件进行重构。我们对代码(包括内部开发和第三方)和数据进行严格的测试和验证。当需要提高安全性时,我们还会对代码进行容器化。本节详细描述了我们通过良好的设计和实现选择来解决安全性和可靠性的方法。
编程语言选择
对于接受任意不受信任输入的系统部分的编程语言选择是设计的一个重要方面。最终,我们决定用 Go 和 C++的混合编写 CA,并根据其目的选择使用哪种语言来处理每个子组件。Go 和 C++都可以与经过充分测试的加密库进行互操作,表现出优异的性能,并拥有强大的生态系统框架和工具来实现常见任务。
由于 Go 是内存安全的,它在处理 CA 处理任意输入时具有一些额外的安全优势。例如,证书签名请求(CSR)代表 CA 的不受信任输入。CSR 可能来自我们内部系统中的一个,这可能相对安全,也可能来自互联网用户(甚至是恶意行为者)。代码中解析 DER(用于证书的编码格式)的代码存在与内存相关的漏洞的悠久历史,因此我们希望使用一种提供额外安全性的内存安全语言。Go符合要求。
C++不是内存安全的,但对于系统的关键子组件具有良好的互操作性,特别是对于谷歌核心基础设施的某些组件。为了保护这些代码,我们在安全区域中运行它,并在数据到达该区域之前验证所有数据。例如,对于 CSR 处理,我们在 Go 中解析请求,然后将其传递给 C++子系统进行相同的操作,然后比较结果。如果存在差异,则不进行处理。
此外,我们在所有 C++代码的提交前强制执行良好的安全实践和可读性,谷歌的集中式工具链实现了各种编译时和运行时缓解措施。这些包括以下内容:
通过复制 shellcode 并跳转到该内存来破坏mmap和PROT_EXEC的常见利用技巧。这种缓解措施不会造成 CPU 或内存性能损失。
用户模式安全堆分配器。
一种安全缓解技术,可防范基于堆栈缓冲区溢出的攻击。
复杂性与可理解性
作为一种防御措施,我们明确选择实现 CA 的功能有限,与标准中提供的完整选项相比(参见“设计可理解的系统”)。我们的主要用例是为具有常用属性和扩展的标准 Web 服务颁发证书。我们对商业和开源 CA 软件选项的评估显示,它们试图适应我们不需要的奇特属性和扩展导致了系统的复杂性,使软件难以验证且更容易出错。因此,我们选择编写具有有限功能和更好可理解性的 CA,以便更容易审计预期的输入和输出。
我们不断努力简化 CA 的架构,使其更易于理解和维护。有一次,我们意识到我们的架构创建了太多不同的微服务,导致维护成本增加。虽然我们希望获得模块化服务和明确定义的边界的好处,但我们发现将系统的某些部分合并更简单。在另一种情况下,我们意识到我们对 RPC 调用的 ACL 检查是在每个实例中手动实现的,这为开发人员和审阅人员出现错误提供了机会。我们重构了代码库,以集中 ACL 检查并消除添加新的 RPC 而不使用 ACL 的可能性。
保护第三方和开源组件
我们的自定义 CA 依赖于第三方代码,即开源库和商业模块。我们需要验证、加固和容器化这些代码。作为第一步,我们专注于 CA 使用的几个众所周知且广泛使用的开源软件包。即使是在安全环境中广泛使用的开源软件包,也可能来自具有强大安全背景的个人或组织,也容易受到漏洞的影响。我们对每个软件包进行了深入的安全审查,并提交了修补程序以解决我们发现的问题。在可能的情况下,我们还将所有第三方和开源组件都提交到下一节详细介绍的测试制度中。
我们使用两个安全区域——一个用于处理不受信任的数据,另一个用于处理敏感操作——也为我们提供了一些分层保护,以防止错误或恶意插入到代码中。前面提到的 CSR 解析器依赖于开源 X.509 库,并作为 Borg 容器中不受信任区域的微服务运行。这为这段代码提供了额外的保护层。
我们还必须保护专有的第三方闭源代码。运行一个公开可信的 CA 需要使用硬件安全模块(HSM)——由商业供应商提供的专用加密处理器——作为保护 CA 密钥的保险库。我们希望为与 HSM 交互的供应商提供的代码提供额外的验证层。与许多供应商提供的解决方案一样,我们可以进行的测试种类有限。为了保护系统免受内存泄漏等问题的影响,我们采取了以下步骤:
-
我们构建了必须与 HSM 库进行交互的 CA 的部分,采取了防御性措施,因为我们知道输入或输出可能存在风险。
-
我们在nsjail中运行第三方代码,这是一个轻量级的进程隔离机制。
-
我们向供应商报告了我们发现的问题。
测试
为了保持项目的卫生,我们编写单元测试和集成测试(见第十三章)来覆盖各种场景。团队成员应在开发过程中编写这些测试,而同行评审则确保遵守这一做法。除了测试预期行为外,我们还测试负面情况。每隔几分钟,我们生成符合良好标准的测试证书签发条件,以及包含严重错误的条件。例如,我们明确测试了当未经授权的人员进行签发时,准确的错误消息是否会触发警报。拥有正面和负面测试条件的存储库使我们能够非常快速地对所有新的 CA 软件部署进行高可信度的端到端测试。
通过使用谷歌的集中式软件开发工具链,我们还获得了在提交前和构建后的集成自动代码测试的好处。正如在“将静态分析集成到开发人员工作流程中”中讨论的那样,谷歌所有的代码更改都经过 Tricorder,我们的静态分析平台的检查。我们还对 CA 的代码进行各种消毒剂的检查,如 AddressSanitizer(ASAN)和 ThreadSanitizer,以识别常见错误(见“动态程序分析”)。此外,我们对 CA 代码进行有针对性的模糊测试(见“模糊测试”)。
CA 密钥材料的弹性
CA 面临的最严重风险是 CA 密钥材料的盗窃或滥用。公开可信的 CA 的大多数强制性安全控制措施都是针对可能导致这种滥用的常见问题,包括使用 HSM 和严格的访问控制等标准建议。
我们将 CA 的根密钥材料离线保存,并用多层物理保护来保护它,每个访问层都需要两方授权。对于日常证书签发,我们使用在线可用的中间密钥,这是行业标准做法。由于让公众信任的 CA 被广泛纳入生态系统(即浏览器、电视和手机)的过程可能需要多年时间,因此在遭受损害后旋转密钥(参见“旋转签名密钥”)作为恢复工作的一部分并不是一个简单或及时的过程。因此,密钥材料的丢失或被盗可能会造成重大中断。为了防范这种情况,我们在生态系统中成熟了其他根密钥材料(通过将材料分发给使用加密连接的浏览器和其他客户端),这样我们就可以在必要时替换备用材料。
数据验证
除了密钥材料的丢失,签发错误是 CA 可能犯的最严重的错误。我们努力设计我们的系统,以确保人为判断不能影响验证或签发,这意味着我们可以将注意力集中在 CA 代码和基础设施的正确性和健壮性上。
持续验证(见“持续验证”)确保系统的行为符合预期。为了在 Google 的公众信任 CA 中实现这一概念,我们自动在签发过程的多个阶段通过 linter 运行证书。linter 检查错误模式,例如确保证书具有有效的生命周期或者subject:commonName具有有效的长度。一旦证书经过验证,我们将其输入到证书透明日志中,这允许公众进行持续验证。为了防范恶意签发,我们还使用多个独立的日志系统,通过逐个比较两个系统的条目来确保一致性。这些日志在到达日志存储库之前会被签名,以提供进一步的安全性和以备需要时进行后续验证。
结论
证书颁发机构是对安全性和可靠性有严格要求的基础设施的一个例子。使用本书中概述的最佳实践来实现基础设施可以带来长期的安全性和可靠性。这些原则应该是设计的一部分,但您也应该在系统成熟时使用它们来改进系统。
¹ TLS 的最新版本在RFC 8446中有描述。
² 安全/多用途互联网邮件扩展是一种加密电子邮件内容的常见方法。
³ 我们意识到许多组织确实构建和运营私有 CA,使用诸如微软的 AD 证书服务等常见解决方案。这些通常仅供内部使用。
⁴ DigiNotar 在遭受攻击者的侵害和滥用其 CA 后破产了。
⁵ 域验证指南的良好参考是CA/Browser Forum 基线要求。
⁶ Mitre CVE 数据库中包含了各种 DER 处理程序发现的数百个漏洞。
⁷ Borg 容器在 Verma, Abhishek 等人的 2015 年的论文“Large-Scale Cluster Management at Google with Borg.”中有描述。Proceedings of the 10th European Conference on Computer Systems: 1–17. doi:10.1145/2741948.2741964。
⁸ 例如,ZLint是一个用 Go 编写的 linter,用于验证证书的内容是否符合 RFC 5280 和 CA/Browser Forum 的要求。
第十二章:编写代码
译者:飞龙
由 Michał Czapiński 和 Julian Bangert 撰写
与 Thomas Maufer 和 Kavita Guliani 合作
安全性和可靠性不能轻易地加入软件中,因此在软件设计的早期阶段就考虑它们是很重要的。在发布后添加这些功能是痛苦且不太有效的,可能需要您改变代码库的其他基本假设(有关此主题的更深入讨论,请参见第四章)。
减少安全性和可靠性问题的第一步,也是最重要的一步是教育开发人员。然而,即使是训练有素的工程师也会犯错——安全专家可能会编写不安全的代码,SRE 可能会忽略可靠性问题。在同时考虑构建安全和可靠系统所涉及的许多考虑因素和权衡是困难的,尤其是如果你还负责生产软件的话。
与其完全依赖开发人员审查代码的安全性和可靠性,不如让 SRE 和安全专家审查代码和软件设计。这种方法也是不完美的——手动代码审查不会发现每个问题,也不会每个安全问题都能被审查人员发现。审查人员也可能会受到自己的经验或兴趣的影响。例如,他们可能自然而然地倾向于寻找新的攻击类型、高级设计问题或加密协议中的有趣缺陷;相比之下,审查数百个 HTML 模板以查找跨站脚本(XSS)漏洞,或者检查应用程序中每个 RPC 的错误处理逻辑可能会被视为不那么令人兴奋。
虽然代码审查可能不会发现每个漏洞,但它们确实有其他好处。良好的审查文化鼓励开发人员以便于审查安全性和可靠性属性的方式构建他们的代码。本章讨论了使审查人员能够明显看到这些属性的策略,并将自动化整合到开发过程中。这些策略可以释放团队的带宽,让他们专注于其他问题,并建立安全和可靠性的文化(参见第二十一章)。
强制执行安全性和可靠性的框架
如第六章所讨论的,应用程序的安全性和可靠性依赖于特定领域的不变量。例如,如果应用程序的所有数据库查询仅由开发人员控制的代码组成,并通过查询参数绑定提供外部输入,那么该应用程序就可以防止 SQL 注入攻击。如果所有插入 HTML 表单的用户输入都经过适当转义或清理以删除任何可执行代码,Web 应用程序就可以防止 XSS 攻击。
理论上,您可以通过仔细编写维护这些不变量的应用程序代码来创建安全可靠的软件。然而,随着所需属性的数量和代码库的规模增长,这种方法几乎变得不可能。不合理地期望任何开发人员都是所有这些主题的专家,或者在编写或审查代码时始终保持警惕是不合理的。
如果需要人工审查每个更改,那么这些人将很难维护全局不变量,因为审查人员无法始终跟踪全局上下文。如果审查人员需要知道哪些函数参数是由调用者传递的用户输入,哪些参数只包含开发人员控制的可信值,他们还必须熟悉函数的所有传递调用者。审查人员不太可能能够长期保持这种状态。
更好的方法是在通用框架、语言和库中处理安全和可靠性。理想情况下,库只公开一个接口,使得使用常见安全漏洞类的代码编写变得不可能。多个应用程序可以使用每个库或框架。当领域专家修复问题时,他们会从框架支持的所有应用程序中删除它,从而使这种工程方法更好地扩展。与手动审查相比,使用集中的强化框架还可以减少未来漏洞的可能性。当然,没有框架可以防止所有安全漏洞,攻击者仍然有可能发现未预料到的攻击类别或发现框架实现中的错误。但是,如果您发现了新的漏洞,您可以在一个地方(或几个地方)解决它,而不是在整个代码库中。
举一个具体的例子:SQL 注入(SQLI)在常见安全漏洞的 OWASP](oreil.ly/TnBaK)和[SAN… 注入漏洞:TrustedSqlString”),这类漏洞就不再是问题。类型使这些假设变得明确,并且由编译器自动执行。
使用框架的好处
大多数应用程序都具有类似的安全构建块(身份验证和授权、日志记录、数据加密)和可靠性构建块(速率限制、负载平衡、重试逻辑)。为每个服务从头开始开发和维护这些构建块是昂贵的,并且会导致每个服务中不同错误的拼接。
框架实现了代码重用:开发人员只需定制特定的构建块,而不需要考虑影响给定功能或特性的所有安全和可靠性方面。例如,开发人员可以指定传入请求凭据中哪些信息对授权很重要,而无需担心这些信息的可信度——框架会验证可信度。同样,开发人员可以指定需要记录哪些数据,而无需担心存储或复制。框架还使传播更新更容易,因为您只需要在一个位置应用更新。
使用框架可以提高组织中所有开发人员的生产力,有助于建立安全和可靠文化(参见第二十一章)。对于一个团队的领域专家来说,设计和开发框架构建块要比每个团队单独实现安全和可靠特性更有效率。例如,如果安全团队处理加密,其他所有团队都会从他们的知识中受益。使用框架的开发人员无需担心其内部细节,而可以专注于应用程序的业务逻辑。
框架通过提供易于集成的工具进一步提高了生产力。例如,框架可以提供自动导出基本操作指标的工具,比如总请求数、按错误类型分解的失败请求数量,或者每个处理阶段的延迟。您可以使用这些数据生成自动化监控仪表板和服务的警报。框架还使与负载均衡基础设施集成更容易,因此服务可以自动将流量重定向到超载实例之外,或者在负载较重时启动新的服务实例。因此,基于框架构建的服务表现出更高的可靠性。
使用框架还可以通过清晰地将业务逻辑与常见功能分离,使对代码的推理变得容易。这使开发人员可以更有信心地对服务的安全性或可靠性做出断言。总的来说,框架可以降低复杂性——当跨多个服务的代码更加统一时,遵循常见的良好实践就更容易了。
开发自己的框架并不总是有意义。在许多情况下,最好的策略是重用现有的解决方案。例如,几乎任何安全专业人士都会建议您不要设计和实现自己的加密框架,而是可以使用像 Tink 这样的成熟和广泛使用的框架(在“示例:安全加密 API 和 Tink 加密框架”中讨论)。
在决定采用任何特定框架之前,评估其安全姿态是很重要的。我们还建议使用积极维护的框架,并不断更新您的代码依赖项,以纳入对您的代码依赖的任何代码的最新安全修复。
以下案例是一个实际示例,演示了框架的好处:在这种情况下,是用于创建 RPC 后端的框架。
示例:RPC 后端框架
大多数 RPC 后端遵循类似的结构。它们处理特定于请求的逻辑,并通常还执行以下操作:
-
日志记录
-
认证
-
授权
-
限流(速率限制)
我们建议使用一个可以隐藏这些构建块的实现细节的框架,而不是为每个单独的 RPC 后端重新实现这些功能。然后开发人员只需要定制每个步骤以适应其服务的需求。
图 12-1 展示了一个基于预定义拦截器的可能框架架构,这些拦截器负责前面提到的每个步骤。您还可以使用拦截器来执行自定义步骤。每个拦截器定义了在实际 RPC 逻辑执行之前和之后要执行的操作。每个阶段都可以报告错误条件,这会阻止进一步执行拦截器。但是,当发生这种情况时,已经调用的每个拦截器的之后步骤会以相反的顺序执行。拦截器之间的框架可以透明地执行其他操作,例如导出错误率或性能指标。这种架构导致了在每个阶段执行的逻辑的清晰分离,从而增加了简单性和可靠性。
图 12-1:RPC 后端潜在框架中的控制流:典型步骤封装在预定义的拦截器中,授权作为示例突出显示
在这个例子中,日志拦截器的之前阶段可以记录调用,之后阶段可以记录操作的状态。现在,如果请求未经授权,RPC 逻辑不会执行,但是“权限被拒绝”的错误会被正确记录。之后,系统调用认证和日志拦截器的之后阶段(即使它们是空的),然后才将错误发送给客户端。
拦截器通过它们传递给彼此的上下文对象共享状态。例如,认证拦截器的之前阶段可以处理与证书处理相关的所有加密操作(注意从重用专门的加密库而不是重新实现一个来提高安全性)。然后系统将提取和验证的关于调用者的信息包装在一个方便的对象中,并将其添加到上下文中。随后的拦截器可以轻松访问此对象。
然后框架可以使用上下文对象来跟踪请求执行时间。如果在任何阶段明显地请求不会在截止日期之前完成,系统可以自动取消请求。通过快速通知客户端,还可以提高服务的可靠性,这也节省了资源。
一个好的框架还应该使您能够处理 RPC 后端的依赖关系,例如负责存储日志的另一个后端。您可以将这些注册为软依赖或硬依赖,框架可以不断监视它们的可用性。当它检测到硬依赖不可用时,框架可以停止服务,报告自身不可用,并自动将流量重定向到其他实例。
迟早,过载、网络问题或其他问题将导致依赖不可用。在许多情况下,重试请求是合理的,但要小心实现重试,以避免级联故障(类似于多米诺骨牌的倒塌)。¹最常见的解决方案是使用指数退避。²一个好的框架应该提供对这样的逻辑的支持,而不是要求开发人员为每个 RPC 调用实现逻辑。
一个优雅处理不可用依赖并重定向流量以避免过载服务或其依赖的框架自然地提高了服务本身和整个生态系统的可靠性。这些改进需要开发人员的最少参与。
示例代码片段
示例 12-1 到 12-3 演示了 RPC 后端开发人员与安全或可靠性框架合作的视角。这些示例使用 Go 并使用Google Protocol Buffers。
12-1 示例。初始类型定义(拦截器的前阶段可以修改上下文;例如,身份验证拦截器可以添加有关调用者的验证信息)
type Request struct {
Payload proto.Message
}
type Response struct {
Err error
Payload proto.Message
}
type Interceptor interface {
Before(context.Context, *Request) (context.Context, error)
After(context.Context, *Response) error
}
type CallInfo struct {
User string
Host string
...
}
12-2 示例。示例授权拦截器,只允许来自白名单用户的请求
type authzInterceptor struct {
allowedRoles map[string]bool
}
func (ai *authzInterceptor) Before(ctx context.Context, req *Request) (context.Context, error) {
// callInfo was populated by the framework.
callInfo, err := FromContext(ctx)
if err != nil { return ctx, err }
if ai.allowedRoles[callInfo.User] { return ctx, nil }
return ctx, fmt.Errorf("Unauthorized request from %q", callInfo.User)
}
func (*authzInterceptor) After(ctx context.Context, resp *Response) error {
return nil // Nothing left to do here after the RPC is handled.
}
12-3 示例。示例日志拦截器,记录每个传入请求(阶段前)然后记录所有失败的请求及其状态(阶段后);WithAttemptCount 是一个由框架提供的 RPC 调用选项,实现指数退避
type logInterceptor struct {
logger *LoggingBackendStub
}
func (*logInterceptor) Before(ctx context.Context,
req *Request) (context.Context, error) {
// callInfo was populated by the framework.
callInfo, err := FromContext(ctx)
if err != nil { return ctx, err }
logReq := &pb.LogRequest{
timestamp: time.Now().Unix(),
user: callInfo.User,
request: req.Payload,
}
resp, err := logger.Log(ctx, logReq, WithAttemptCount(3))
return ctx, err
}
func (*logInterceptor) After(ctx context.Context, resp *Response) error {
if resp.Err == nil { return nil }
logErrorReq := &pb.LogErrorRequest{
timestamp: time.Now().Unix(),
error: resp.Err.Error(),
}
resp, err := logger.LogError(ctx, logErrorReq, WithAttemptCount(3))
return err
}
常见安全漏洞
在大型代码库中,少数类占据了大部分安全漏洞,尽管不断努力教育开发人员并引入代码审查。OWASP 和 SANS 发布了常见漏洞类别的列表。表 12-1 列出了根据OWASP列出的前 10 个最常见的漏洞风险,以及在框架级别上缓解每个漏洞的一些潜在方法。
表 12-1。根据 OWASP 列出的前 10 个最常见的漏洞风险
| OWASP 前 10 大漏洞 | 框架加固措施 |
|---|---|
| [SQL]注入 | TrustedSQLString(请参阅下一节)。 |
| 损坏的身份验证 | 在将请求路由到应用程序之前,要求使用像 OAuth 这样经过充分测试的机制进行身份验证。(参见“示例:RPC 后端框架”。) |
| 敏感数据泄露 | 使用不同的类型(而不是字符串)来存储和处理信用卡号等敏感数据。这种方法可以限制序列化以防止泄漏并强制适当的加密。框架还可以强制执行透明的传输保护,如使用 LetsEncrypt 的 HTTPS。加密 API,如Tink,可以鼓励适当的秘密存储,例如从云密钥管理系统加载密钥,而不是从配置文件加载。 |
| XML 外部实体(XXE) | 使用未启用 XXE 的 XML 解析器;确保支持它的库中禁用这个风险特性。ᵃ |
| 破损的访问控制 | 这是一个棘手的问题,因为它通常是特定于应用程序的。使用一个要求每个请求处理程序或 RPC 具有明确定义的访问控制限制的框架。如果可能的话,将最终用户凭据传递到后端,并在后端强制执行访问控制策略。 |
| 安全配置错误 | 使用默认提供安全配置并限制或不允许风险配置选项的技术堆栈。例如,使用一个在生产中不打印错误信息的 Web 框架。使用一个标志来启用所有调试功能,并设置部署和监控基础设施以确保这个标志不对公共用户启用。Rails 中的environment标志就是这种方法的一个例子。 |
| 跨站脚本(XSS) | 使用 XSS 强化的模板系统(参见“预防 XSS:SafeHtml”)。 |
| 不安全的反序列化 | 使用专为处理不受信任输入而构建的反序列化库,例如Protocol Buffers。 |
| 使用已知漏洞的组件 | 选择受欢迎且积极维护的库。不要选择有未修复或缓慢修复安全问题历史的组件。另请参见“评估和构建框架的教训”。 |
| 日志记录和监控不足 | 不要依赖于临时日志记录,适当地记录和监控请求和其他事件在低级库中。有关示例,请参见前一节中描述的日志拦截器。 |
| ᵃ有关更多信息,请参见XXE 预防备忘单。 |
SQL 注入漏洞:TrustedSqlString
SQL 注入是一种常见的安全漏洞类别。当不可信的字符串片段被插入到 SQL 查询中时,攻击者可能会注入数据库命令。以下是一个简单的密码重置网页表单:
db.query("UPDATE users SET pw_hash = '" + request["pw_hash"]
+ "' WHERE reset_token = '" + request.params["reset_token"] + "'")
在这种情况下,用户的请求被定向到一个具有与其帐户特定的不可猜测的reset_token的后端。然而,由于字符串连接,恶意用户可以制作一个带有额外 SQL 命令(例如' or username='admin)的自定义reset_token并将其注入到后端。结果可能会重置不同用户的密码哈希—在这种情况下是管理员帐户。
在更复杂的代码库中,SQL 注入漏洞可能更难以发现。数据库引擎可以通过提供绑定参数和预编译语句来帮助您防止 SQL 注入漏洞:
Query q = db.createQuery(
"UPDATE users SET pw_hash = @hash WHERE token = @token");
q.setParameter("hash", request.params["hash"]);
q.setParameter("token", request.params["token"]);
db.query(q);
然而,仅仅建立一个使用预编译语句的准则并不能导致可扩展的安全流程。您需要教育每个开发人员遵守这个规则,并且安全审查人员需要审查所有应用程序代码,以确保一致使用预编译语句。相反,您可以设计数据库 API,使用户输入和 SQL 的混合在设计上变得不可能。例如,您可以创建一个名为TrustedSqlString的单独类型,并通过构造强制执行所有 SQL 查询字符串都是由开发人员控制的输入创建的。在 Go 中,您可以实现该类型如下:
struct Query {
sql strings.Builder;
}
type stringLiteral string;
*// Only call this function with string literal parameters.*
func (q *Query) AppendLiteral(literal stringLiteral) {
q.sql.writeString(literal);
}
*// q.AppendLiteral("foo") will work, q.AppendLiteral(foo) will not*
该实现通过构造保证了q.sql的内容完全是从您的源代码中存在的字符串字面量连接而成的,用户无法提供字符串字面量。为了在规模上强制执行这个合同,您可以使用一种特定于语言的机制,确保AppendLiteral只能与字符串字面量一起调用。例如:
在 Go
使用包私有类型别名(stringLiteral)。包外的代码不能引用此别名;但是,字符串字面量会被隐式转换为这种类型。
在 Java
使用Error Prone代码检查器,它为参数提供了@CompileTimeConstant注释。
在 C++中
使用依赖于字符串中每个字符值的模板构造函数。
您可以在其他语言中找到类似的机制。
您无法仅使用编译时常量构建某些功能,比如设计为由拥有数据的用户提供任意 SQL 查询的数据分析应用程序。为了处理复杂的用例,在 Google,我们允许通过安全工程师的批准绕过类型限制的方法。例如,我们的数据库 API 有一个单独的包unsafequery,它导出一个独特的unsafequery.String类型,可以从任意字符串构造并附加到 SQL 查询中。只有很小一部分查询使用了未经检查的 API。对于数百到数千名活跃开发人员,审核不安全的 SQL 查询的新用途和其他受限 API 模式的负担由一名(轮换的)工程师兼职处理。参见“评估和构建框架的教训”以了解审核豁免的其他好处。
防止 XSS:SafeHtml
我们在前一节中描述的基于类型的安全方法并不特定于 SQL 注入。Google 使用更复杂的相同设计的版本来减少 Web 应用程序中的跨站脚本漏洞。³
在核心上,XSS 漏洞发生在 Web 应用程序在没有适当清理的情况下呈现不可信的输入时。例如,一个应用程序可能会将一个受攻击者控制的$address值插入到 HTML 片段中,如<div>$address</div>,然后显示给另一个用户。攻击者可以将$address设置为<script>exfiltrate_user_data();</script>并在另一个用户页面的上下文中执行任意代码。
HTML 没有绑定查询参数的等价物。相反,不可信的值必须在插入到 HTML 页面之前得到适当的清理或转义。此外,不同的 HTML 属性和元素具有不同的语义,因此应用程序开发人员必须根据它们出现的上下文来不同对待值。例如,攻击者控制的 URL 可以使用javascript:方案来执行代码。
类型系统可以通过为不同上下文中的值引入不同的类型来捕获这些要求,例如,SafeHtml用于表示 HTML 元素的内容,SafeUrl用于安全导航到的 URL。每种类型都是一个(不可变的)字符串包装器;构造函数负责维护每种类型的合同。构造函数构成了负责确保应用程序安全属性的受信任代码库。
Google 为不同的用例创建了不同的构建器库。可以使用构建器方法构造单个 HTML 元素,该方法要求每个属性值都具有正确的类型,并且对于元素内容使用SafeHtml。具有严格上下文转义的模板系统可以保证更复杂的 HTML 的SafeHtml合同。该系统执行以下操作:
-
解析模板中的部分 HTML
-
确定每个替换点的上下文
-
要么要求程序传递正确类型的值,要么正确转义或清理不受信任的字符串值
例如,如果您有以下 Closure 模板:
{template .foo kind="html"}<script src="{$url}"></script>{/template}
尝试使用$url的字符串值将失败:
templateRendered.setMapData(ImmutableMap.of("url", some_variable));
相反,开发人员必须提供TrustedResourceUrl值,例如:
templateRenderer.setMapData(
ImmutableMap.of("x", TrustedResourceUrl.fromConstant("/script.js"))
).render();
如果 HTML 来自不受信任的来源,您不希望将其嵌入到应用程序的 Web UI 中,因为这样做会导致易受攻击的 XSS 漏洞。相反,您可以使用 HTML 清理器解析 HTML 并执行运行时检查,以确定每个值是否符合其合同。清理器会删除不符合其合同的元素,或者无法在运行时检查合同的元素。您还可以使用清理器与不使用安全类型的其他系统进行交互,因为许多 HTML 片段在清理过程中保持不变。
不同的 HTML 构建库针对不同的开发人员生产力和代码可读性权衡。但是,它们都强制执行相同的合同,并且应该同样值得信赖(除了它们受信任的实现中的任何错误)。实际上,为了减少谷歌的维护负担,我们从声明性配置文件中为各种语言代码生成构建器函数。该文件列出了 HTML 元素和每个属性值的所需合同。我们的一些 HTML 清理器和模板系统使用相同的配置文件。
在Closure Templates中提供了成熟的开源安全类型实现,目前正在进行引入基于类型的安全性的工作,作为 Web 标准。
评估和构建框架的教训
前几节讨论了如何构建库以建立安全性和可靠性属性。但是,并非所有这样的属性都可以通过 API 设计优雅地表达,有些情况下甚至无法轻松更改 API,例如与 Web 浏览器公开的标准化 DOM API 交互时。
相反,您可以引入编译时检查,以防止开发人员使用风险 API。流行编译器的插件,如 Java 的Error Prone和 TypeScript 的Tsetse,可以禁止危险的代码模式。
我们的经验表明,编译器错误提供了即时和可操作的反馈。在代码审查时运行的工具(如 linter)或在代码审查时提供的反馈要晚得多。到代码发送进行审查时,开发人员通常已经有了一个完成的、可工作的代码单元。在开发过程的这么晚阶段得知需要进行一些重新架构才能使用严格类型的 API 可能会令人沮丧。
向开发人员提供编译器错误或更快的反馈机制(如 IDE 插件,可以标出有问题的代码)要容易得多。通常,开发人员会快速解决编译问题,并且已经必须修复其他编译器诊断,如拼写错误和语法错误。因为开发人员已经在处理受影响的特定代码行,他们有完整的上下文,所以进行更改更容易,例如将字符串的类型更改为SafeHtml。
通过建议自动修复,甚至可以进一步改善开发人员的体验,这些自动修复可以作为安全解决方案的起点。例如,当检测到对 SQL 查询函数的调用时,可以自动插入对TrustedSqlBuilder.fromConstant的调用,其中包含查询参数。即使生成的代码不能完全编译(也许是因为查询是字符串变量而不是常量),开发人员也知道该怎么做,无需通过找到正确的函数、添加正确的导入声明等来烦恼 API 的机械细节。
根据我们的经验,只要反馈周期快,修复每个模式相对容易,开发人员就会更愿意接受固有安全的 API,即使我们无法证明他们的代码是不安全的,或者他们在使用不安全的 API 编写安全代码时做得很好。我们的经验与现有的研究文献形成对比,后者侧重于降低误报和漏报率。(4)
我们发现,专注于这些速率通常会导致复杂的检查器,需要更长时间才能发现问题。例如,一个检查可能需要分析复杂应用程序中的整个程序数据流。通常很难解释如何从静态分析检测到的问题中删除问题,因为检查器的工作方式比简单的语法属性要难得多。理解一个发现需要和在 GDB(GNU 调试器)中追踪错误一样多的工作。另一方面,在编写新代码时在编译时修复类型安全错误通常并不比修复微不足道的类型错误困难得多。
简单、安全、可靠的常见任务库
构建一个安全的库,涵盖所有可能的用例并可靠地处理每个用例可能非常具有挑战性。例如,一个在 HTML 模板系统上工作的应用程序开发人员可能会编写以下模板:
<a onclick="showUserProfile('{{username}}');">Show profile</a>">
为了防止 XSS 攻击,如果“用户名”受攻击者控制,模板系统必须嵌套三种不同的上下文层:单引号字符串,JavaScript 内部,HTML 元素属性内部。创建一个可以处理所有可能的边缘情况组合的模板系统是复杂的,并且使用该系统不会简单。在其他领域,这个问题可能变得更加复杂。例如,业务需求可能会规定谁可以执行动作,谁不能。除非您的授权库像通用编程语言一样具有表达力(并且难以分析),您可能无法满足所有开发人员的需求。
相反,您可以从一个简单的、小型的库开始,只涵盖常见用例,但更容易正确使用。简单的库更容易解释、文档化和使用。这些特性减少了开发人员的摩擦,并可能帮助您说服其他开发人员采用安全设计的库。在某些情况下,提供针对不同用例进行优化的不同库可能是有意义的。例如,您可能既有用于复杂页面的 HTML 模板系统,也有用于短片段的构建器库。
您可以通过专家审查的方式满足其他用例,访问一个不受限制的、风险的库,绕过安全保证。如果您看到类似的重复请求,您可以在固有安全的库中支持该功能。正如我们在“SQL 注入漏洞:TrustedSqlString”中观察到的,审查负载通常是可以管理的。
由于审查请求的数量相对较少,安全审查人员可以深入查看代码并提出广泛的改进建议——审查往往是独特的用例,这保持了审查人员的积极性,并防止了由于重复和疲劳而导致的错误。豁免也作为一个反馈机制:如果开发人员反复需要豁免某个用例,库作者应该考虑为该用例构建一个库。
推出策略
我们的经验表明,对安全属性使用类型对于新代码非常有用。事实上,使用安全类型的谷歌内部广泛使用的一个 Web 框架创建的应用程序,报告的 XSS 漏洞要少得多(少两个数量级)比没有使用安全类型编写的应用程序,尽管进行了仔细的代码审查。少数报告的漏洞是由于应用程序的组件没有使用安全类型造成的。
将现有代码调整为使用安全类型更具挑战性。即使您从头开始创建一个全新的代码库,您也需要一个迁移遗留代码的策略——您可能会发现您想要保护的新的安全性和可靠性问题类别,或者您可能需要完善现有的合同。
我们已经尝试了几种重构现有代码的策略;我们在下面的小节中讨论了我们最成功的两种方法。这些策略要求您能够访问和修改应用程序的整个源代码。Google 的大部分源代码都存储在一个单一的存储库中⁵,并具有用于制作、构建、测试和提交更改的集中式流程。代码审查人员还会强制执行常见的可读性和代码组织标准,这减少了改变陌生代码库的复杂性。在其他环境中,大规模的重构可能更具挑战性。获得广泛的一致意见有助于每个代码所有者都愿意接受对他们源代码的更改,这有助于建立一个安全可靠的文化。
注意
Google 公司的全公司风格指南融入了语言可读性的概念:工程师理解给定语言的 Google 最佳实践和编码风格的认证。可读性确保了代码质量的基线。工程师必须在他们正在使用的语言中具有可读性,或者从具有可读性的人那里获得代码审查。对于特别复杂或至关重要的代码,面对面的代码审查可能是改进代码库质量最有效和高效的方式。
增量推出
一次性修复整个代码库通常是不可行的。不同的组件可能在不同的存储库中,对多个应用程序进行更改的编写、审查、测试和提交通常是脆弱且容易出错的。相反,在 Google,我们最初豁免遗留代码的执行,并逐个解决现有不安全 API 的使用者。
例如,如果您已经有一个带有doQuery(String sql)函数的数据库 API,您可以引入一个重载,doQuery(TrustedSqlString sql),并将不安全版本限制为现有的调用者。使用 Error Prone 框架,您可以添加一个@RestrictedApi(whitelistAnnotation={LegacyUnsafeStringQueryAllowed.class})注解,并将@LegacyUnsafeStringQueryAllowed注解添加到所有现有的调用者。
然后,通过引入Git hooks来分析每个提交,您可以防止新代码使用基于字符串的过载。或者,您可以限制不安全 API 的可见性——例如,Bazel 可见性白名单将允许用户仅在安全团队成员批准拉取请求(PR)时调用 API。如果您的代码库正在积极开发,它将自然地向安全 API 迁移。在达到只有少部分调用者使用已弃用的基于字符串的 API 的时候,您可以手动清理剩余部分。在那时,您的代码将因设计而免疫 SQL 注入。
遗留转换
将所有的豁免机制整合到一个在源代码中明显的函数中通常也是值得的。例如,您可以创建一个函数,它接受任意字符串并返回一个安全类型。您可以使用这个函数来替换所有对字符串类型的 API 的调用,使其更精确地调用。通常,类型会比使用它们的函数少得多。与限制和监控许多遗留 API 的移除(例如,每个消耗 URL 的 DOM API)不同,您只需要删除每种类型的一个遗留转换函数。
简单导致安全可靠的代码
在实际情况下,尽量保持代码简洁和简单。关于这个主题有很多出版物,⁶所以这里我们专注于两个轻量级的故事,这些故事发表在Google 测试博客上。这两个故事都强调了避免快速增加代码库复杂性的策略。
避免多级嵌套
多层嵌套是一种常见的反模式,可能导致简单的错误。如果错误出现在最常见的代码路径中,它很可能会被单元测试捕获。但是,单元测试并不总是检查多层嵌套代码中的错误处理路径。错误可能导致可靠性降低(例如,如果服务在错误处理不当时崩溃)或安全漏洞(如错误处理授权检查错误)。
您能在图 12-2 的代码中发现错误吗?这两个版本是等价的。⁷
图 12-2:在多层嵌套代码中,错误通常更难发现
“错误编码”和“未经授权”的错误被交换了。在重构版本中更容易看到这个错误,因为检查发生在处理错误时。
消除 YAGNI 气味
有时,开发人员通过添加可能在将来有用的功能“以防万一”来过度设计解决方案。这违反了YAGNI(你不会需要它)原则,该原则建议仅实现您需要的代码。YAGNI 代码会增加不必要的复杂性,因为它需要进行文档化、测试和维护。考虑以下示例:⁸
class Mammal { ...
virtual Status Sleep(bool hibernate) = 0;
};
class Human : public Mammal { ...
virtual Status Sleep(bool hibernate) {
age += hibernate ? kSevenMonths : kSevenHours;
return OK;
}
};
Human::Sleep代码必须处理hibernate为true的情况,即使所有调用者应始终传递false。此外,调用者必须处理返回的状态,即使该状态应始终为OK。因此,在您需要除Human之外的其他类之前,此代码可以简化为以下内容:
class Human { ...
void Sleep() { age += kSevenHours; }
};
如果开发人员对未来功能的可能需求做出的假设实际上是正确的,他们可以通过遵循增量开发和设计原则轻松地稍后添加该功能。在我们的例子中,基于几个现有类进行概括时,将更容易创建具有更好公共 API 的Mammal接口。
总之,避免 YAGNI 代码会提高可靠性,简化代码会减少安全漏洞,减少出错的机会,并减少开发人员维护未使用代码的时间。
偿还技术债务
开发人员通常会使用 TODO 或 FIXME 注释标记需要进一步关注的地方。短期内,这种习惯可以加快最关键功能的交付速度,并允许团队满足早期的截止日期,但也会产生技术债务。不过,只要您有清晰的流程(并分配时间)来偿还这样的债务,这并不一定是一种坏习惯。
技术债务可能包括对异常情况的错误处理以及将不必要的复杂逻辑引入代码(通常编写以解决其他技术债务领域的问题)。任何一种行为都可能引入安全漏洞和可靠性问题,这些问题在测试期间很少被检测到(因为罕见情况的覆盖不足),因此成为生产环境的一部分。
您可以以许多方式处理技术债务。例如:
-
保持具有代码健康度指标的仪表板。这些可以是简单的仪表板,显示测试覆盖率或 TODO 的数量和平均年龄,也可以是包括圈复杂度或可维护性指数等指标的更复杂的仪表板。
-
使用诸如 linter 之类的分析工具来检测常见的代码缺陷,例如死代码、不必要的依赖关系或特定于语言的陷阱。通常,这些工具还可以自动修复您的代码。
-
当代码健康度指标下降到预定义阈值以下或自动检测到的问题数量过高时创建通知。
此外,重要的是要保持一个拥抱并专注于良好代码健康的团队文化。领导可以通过多种方式支持这种文化。例如,您可以安排定期的修复周,在这些周内,开发人员专注于改善代码健康和修复未解决的错误,而不是添加新功能。您还可以通过奖金或其他形式的认可来支持团队内对代码健康的持续贡献。
重构
重构是保持代码库清洁和简单的最有效方法。即使健康的代码库偶尔也需要在扩展现有功能集、更改后端等情况下进行重构。
重构在处理旧的、继承的代码库时特别有用。重构的第一步是测量代码覆盖率,并将覆盖率提高到足够的水平。⁹一般来说,覆盖率越高,对重构的安全性的信心就越高。不幸的是,即使测试覆盖率达到 100%,也不能保证成功,因为测试可能没有意义。您可以通过其他类型的测试来解决这个问题,例如fuzzing,这在第十三章中有介绍。
注意
无论重构背后的原因是什么,您都应始终遵循一个黄金法则:永远不要在单个提交到代码存储库中混合重构和功能更改。重构更改通常很重要,可能难以理解。如果提交还包括功能更改,那么作者或审阅者可能会忽略错误的风险更高。
重构技术的完整概述超出了本书的范围。有关此主题的更多信息,请参阅 Martin Fowler 的优秀著作¹⁰以及 Wright 等人提供的自动化大规模重构工具的讨论(2013 年),¹¹ Wasserman(2013 年),¹²和 Potvin 和 Levenberg(2016 年)。
默认安全性和可靠性
除了使用具有强大保证的框架外,您还可以使用其他几种技术来自动改善应用程序的安全性和可靠性姿态,以及团队文化的姿态,您将在第二十一章中了解更多。
选择正确的工具
选择语言、框架和库是一项复杂的任务,通常受多种因素的影响,例如:
-
与现有代码库的集成
-
库的可用性
-
开发团队的技能或偏好
要意识到语言选择对项目安全性和可靠性的巨大影响。
使用内存安全语言
在 2019 年 2 月的以色列 BlueHat 大会上,微软的 Matt Miller 声称,大约 70%的安全漏洞都是由内存安全问题引起的。¹³这个统计数据在过去至少 12 年中一直保持一致。
在 2016 年的一次演讲中,来自 Google 的 Nick Kralevich 报告称,Android 中 85%的所有错误(包括内核和其他组件中的错误)都是由内存管理错误引起的(第 54 页)。¹⁴ Kralevich 得出结论:“我们需要转向内存安全语言。”通过使用具有更高级内存管理的任何语言(如 Java 或 Go)而不是具有更多内存分配困难的语言(如 C/C++),您可以默认避免这整类安全(和可靠性)漏洞。或者,您可以使用代码消毒剂来检测大多数内存管理陷阱(请参阅“消毒您的代码”)。
使用强类型和静态类型检查
在强类型语言中,“每当对象从调用函数传递到被调用函数时,其类型必须与被调用函数中声明的类型兼容。”没有这个要求的语言被称为弱类型或松散类型。您可以在编译期间(静态类型检查)或运行时(动态类型检查)执行类型检查。
强类型和静态类型检查的好处在于在大型代码库中与多个开发人员合作时特别明显,因为您可以在编译时强制执行不变量,并消除各种错误,而不是在运行时。这导致在生产环境中更可靠的系统,更少的安全问题和更高性能的代码。
相比之下,在使用动态类型检查时(例如在 Python 中),除非代码具有 100%的测试覆盖率,否则您几乎无法推断代码的任何信息——这在原则上很好,但在实践中很少见。在弱类型语言中,推理代码变得更加困难,通常会导致意外行为。例如,在 JavaScript 中,每个文字默认都被视为字符串:[9, 8, 10].sort() -> [10, 8, 9]。在这两种情况下,由于不变量在编译时未被强制执行,您只能在测试期间捕获错误。因此,您更容易在生产环境中而不是在开发过程中检测到可靠性和安全性问题,特别是在较少频繁使用的代码路径中。
如果要使用默认具有动态类型检查或弱类型的语言,我们建议使用以下扩展来提高代码的可靠性。这些扩展提供了对更严格类型检查的支持,您可以逐步将它们添加到现有的代码库中:
使用强类型
使用未类型化的基元(例如字符串或整数)可能会导致以下问题:
-
向函数传递概念上无效的参数
-
不需要的隐式类型转换
-
难以理解的类型层次结构
-
令人困惑的测量单位
第一种情况——向函数传递概念上无效的参数——发生在函数参数的原始类型没有足够的上下文,并且在调用时变得令人困惑。例如:
-
对于函数
AddUserToGroup(string, string),不清楚组名是作为第一个参数还是第二个参数提供的。 -
在
Rectangle(3.14, 5.67)构造函数调用中,高度和宽度的顺序是什么? -
Circle(double)是否期望半径还是直径?
文档可以纠正歧义,但开发人员仍然可能犯错。如果我们尽了责任,单元测试可以捕捉到大多数这些错误,但有些错误可能只在运行时出现。
使用强类型时,您可以在编译时捕捉到这些错误。回到我们之前的例子,所需的调用将如下所示:
-
Add(User("alice"), Group("root-users")) -
Rectangle(Width(3.14), Height(5.67)) -
Circle(Radius(1.23))
其中User、Group、Width、Height和Radius是围绕字符串或双精度基元的强类型包装器。这种方法不太容易出错,并使代码更具自我说明性——在这种情况下,在第一个示例中,只需调用函数Add即可。
在第二种情况下,隐式类型转换可能导致以下情况:
-
从较大的整数类型转换为较小的整数类型时的截断
-
从较大的浮点类型转换为较小的浮点类型时的精度损失
-
意外对象创建
在某些情况下,编译器将报告前两个问题(例如,在 C++中使用{}直接初始化语法时),但许多实例可能会被忽视。使用强类型可以保护您的代码免受编译器无法捕获的此类错误。
现在让我们考虑难以理解的类型层次结构的情况:
class Bar { public: Bar(bool is_safe) {...} }; void Foo(const Bar& bar) {...} Foo(false); *// Likely OK, but is the developer aware a Bar object was created?* Foo(5); *// Will create Bar(is_safe := true), but likely by accident.* Foo(NULL); *// Will create Bar(is_safe := false), again likely by accident.*
这里的三个调用将编译并执行,但操作的结果是否符合开发人员的期望呢?默认情况下,C++编译器会尝试隐式转换(强制)参数以匹配函数参数类型。在这种情况下,编译器将尝试匹配类型Bar,它恰好有一个接受bool类型参数的单值构造函数。大多数 C++类型会隐式转换为bool。
构造函数中的隐式转换有时是有意的(例如,将浮点值转换为std::complex类时),但在大多数情况下可能是危险的。为了防止危险的结果,至少要使单值构造函数显式——例如,explicit Bar(bool is_safe)。请注意,最后一次调用将导致编译错误,因为使用nullptr而不是NULL,因为没有到bool的隐式转换。
最后,单位混淆是错误的无尽源泉。这些错误可能被描述如下:
无害的
例如,设置一个 30 秒的定时器而不是 30 分钟,因为程序员不知道Timer(30)使用的单位。
危险的
例如,加拿大航空的“吉姆利滑翔机”飞机在地勤人员计算所需的燃料时使用的是磅而不是千克,导致它只有所需燃料的一半。
昂贵的
例如,科学家们失去了价值 1.25 亿美元的火星气候轨道飞行器,因为两个独立的工程团队使用了不同的测量单位(英制与公制)。
与以前一样,强类型是解决此问题的一种方法:它们可以封装单位,并且只表示抽象概念,如时间戳、持续时间或重量。这些类型通常实现以下功能:
明智的操作
例如,添加两个时间戳通常不是一个有用的操作,但是减去它们会返回一个对许多用例有用的持续时间。类似地,添加两个持续时间或重量也是有用的。
单位转换
例如,Timestamp::ToUnix, Duration::ToHours, Weight::ToKilograms。
一些语言本身提供这样的抽象:例如 Go 中的time包和即将到来的 C++20 标准中的chrono库。其他语言可能需要专门的实现。
Fluent C++博客对 C++中强类型的应用和示例实现进行了更多讨论。
净化您的代码
自动验证代码是否遇到任何典型的内存管理或并发陷阱非常有用。您可以将这些检查作为每个更改列表的预提交操作运行,也可以作为持续构建和测试自动化工具的一部分运行。要检查的陷阱列表取决于语言。本节介绍了 C++和 Go 的一些解决方案。
C++:Valgrind 或 Google Sanitizers
C++允许进行低级内存管理。正如我们之前提到的,内存管理错误是安全问题的主要原因,并且可能导致以下故障场景:
-
读取未分配的内存(
new之前或delete之后) -
读取超出分配内存范围的内容(缓冲区溢出攻击场景)
-
读取未初始化的内存
-
当系统丢失已分配内存的地址或不及时释放未使用的内存时,会发生内存泄漏
Valgrind是一个流行的框架,允许开发人员捕捉这些类型的错误,即使单元测试没有捕捉到它们。Valgrind 的好处在于提供了一个解释用户二进制的虚拟机,因此用户无需重新编译代码即可使用它。Valgrind 工具Helgrind还可以检测常见的同步错误,例如:
-
对 POSIX pthreads API 的误用(例如,解锁未锁定的互斥锁,或者由另一个线程持有的互斥锁)
-
由于锁定顺序问题而产生的潜在死锁
-
由于访问内存而未进行充分锁定或同步而引起的数据竞争
或者,Google Sanitizers 套件提供了各种组件,可以检测 Valgrind 的 Callgrind(缓存和分支预测分析器)可以检测到的所有相同问题:
-
AddressSanitizer (ASan)检测内存错误(缓冲区溢出,释放后使用,不正确的初始化顺序)。
-
LeakSanitizer (LSan)检测内存泄漏。
-
MemorySanitizer (MSan)检测系统是否正在读取未初始化的内存。
-
ThreadSanitizer (TSan)检测数据竞争和死锁。
-
UndefinedBehaviorSanitizer (UBSan)检测具有未定义行为的情况(使用未对齐的指针;有符号整数溢出;转换到、从或在浮点类型之间溢出目标)。
Google Sanitizers 套件的主要优势是速度:它比 Valgrind 快高达 10 倍。像CLion这样的流行 IDE 还提供了与 Google Sanitizers 的一流集成。下一章将更详细地介绍 sanitizers 和其他动态程序分析工具。
Go:竞争检测器
虽然 Go 旨在禁止 C++典型的内存损坏问题,但仍可能受到数据竞争条件的影响。Go 竞争检测器可以检测这些条件。
结论
本章介绍了几个指导开发人员设计和实现更安全可靠代码的原则。特别是,我们建议使用框架作为一种强大的策略,因为它们重用了已被证明对于代码的敏感区域(身份验证、授权、日志记录、速率限制和分布式系统中的通信)的建设块:框架还倾向于提高开发人员的生产力,无论是编写框架的人还是使用框架的人,并使对代码的推理变得更加容易。编写安全可靠代码的其他策略包括追求简单性,选择合适的工具,使用强类型而不是原始类型,并持续对代码进行消毒。
在编写软件时,额外投入精力改善安全性和可靠性将在长期内得到回报,并减少您在部署应用程序后需要花费的审查应用程序或修复问题的工作量。
¹ 有关级联故障的更多信息,请参见SRE 书的第 22 章。
² 也在SRE 书的第 22 章中有描述。
³ 有关该系统的更多详细信息,请参见 Kern, Christoph. 2014. “保护纠缠不清的网络。” ACM 通讯 57(9): 38–47. https://oreil.ly/drZss.
⁴ 请参见 Bessey, Al 等人。2010. “数十亿行代码之后:使用静态分析在现实世界中查找错误。” ACM 通讯 53(2): 66–75. doi:10.1145/1646353.1646374.
⁵ Potvin, Rachel, and Josh Levenberg. 2016. “为什么 Google 将数十亿行代码存储在单个存储库中。” ACM 通讯 59(7): 78–87. doi:10.1145/2854146.
⁶ 例如,Ousterhout, John. 2018. 软件设计哲学. Palo Alto, CA: Yaknyam Press.
⁷ 来源:Karpilovsky, Elliott. 2017. “代码健康:减少嵌套,减少复杂性。” https://oreil.ly/PO1QR.
⁸ 来源:Eaddy, Marc. 2017. “代码健康:消除 YAGNI 气味。” https://oreil.ly/NYr7y.
⁹ 有很多代码覆盖工具可供选择。有关概述,请参阅Stackify 上的列表。
¹⁰ Fowler, Martin. 2019 年。 重构:改善现有代码的设计。马萨诸塞州波士顿:Addison-Wesley。
¹¹ Wright, Hyrum 等。 2013 年。 “使用 Clang 进行大规模自动重构。” 软件维护国际会议第 29 届论文集:548–551。 doi:10.1109/ICSM.2013.93。
¹² Wasserman, Louis. 2013 年。 “可扩展的基于示例的重构与 Refaster。” 2013 年重构工具 ACM 研讨会论文集:25–28 doi:10.1145/2541348.2541355。
¹³ Miller, Matt. 2019 年。 “软件漏洞缓解领域的趋势、挑战和战略转变。” BlueHat IL。 https://goo.gl/vKM7uQ。
¹⁴ Kralevich, Nick. 2016 年。 “防御的艺术:漏洞如何塑造 Android 中的安全功能和缓解措施。” BlackHat。 https://oreil.ly/16rCq。
¹⁵ Liskov, Barbara 和 Stephen Zilles。 1974 年。 “使用抽象数据类型进行编程。” ACM SIGPLAN 非常高级语言研讨会论文集:50–59。 doi:10.1145/800233.807045。
¹⁶ 有关 JavaScript 和 Ruby 中更多的惊喜,请参见Gary Bernhardt 在 CodeMash 2012 的闪电演讲。
¹⁷ 参见 Eaddy, Mark. 2017 年。 “代码健康:对基元着迷?” https://oreil.ly/0DvJI。
第十三章:测试代码
译者:飞龙
作者:Phil Ames 和 Franjo Ivančić
作者:Vera Haas 和 Jen Barnason
无论开发软件的工程师多么小心,都不可避免地会出现一些错误和被忽视的边缘情况。意外的输入组合可能会触发数据损坏或导致像 SRE 书的第 22 章中的“死亡查询”示例中的可用性问题。编码错误可能会导致安全问题,如缓冲区溢出和跨站脚本漏洞。简而言之,在现实世界中,软件容易出现许多故障。
本章讨论的技术在软件开发的不同阶段和环境中具有各种成本效益概况。例如,模糊测试——向系统发送随机请求——可以帮助您在安全性和可靠性方面加固系统。这种技术可能有助于捕捉信息泄漏,并通过暴露服务于大量边缘情况来减少服务错误。要识别无法轻松快速修补的系统中的潜在错误,您可能需要进行彻底的前期测试。
单元测试
单元测试可以通过在发布之前找出个别软件组件中的各种错误来提高系统安全性和可靠性。这种技术涉及将软件组件分解为没有外部依赖关系的更小、自包含的“单元”,然后对每个单元进行测试。单元测试由编写测试的工程师选择的不同输入来执行给定单元的代码组成。许多语言都有流行的单元测试框架;基于xUnit架构的系统非常常见。
遵循 xUnit 范例的框架允许通用的设置和拆卸代码与每个单独的测试方法一起执行。这些框架还定义了各个测试框架组件的角色和职责,有助于标准化测试结果格式。这样,其他系统就可以详细了解到底出了什么问题。流行的例子包括 Java 的 JUnit,C++的 GoogleTest,Golang 的 go2xunit,以及 Python 中内置的unittest模块。
示例 13-1 是使用 GoogleTest 框架编写的简单单元测试。
示例 13-1。使用 GoogleTest 框架编写检查提供的参数是否为质数的函数的单元测试
TEST(IsPrimeTest, Trivial) {
EXPECT_FALSE(IsPrime(0));
EXPECT_FALSE(IsPrime(1));
EXPECT_TRUE(IsPrime(2));
EXPECT_TRUE(IsPrime(3));
}
单元测试通常作为工程工作流程的一部分在本地运行,以便在开发人员提交更改到代码库之前为他们提供快速反馈。在持续集成/持续交付(CI/CD)流水线中,单元测试通常在提交合并到存储库的主干分支之前运行。这种做法旨在防止破坏其他团队依赖的行为的代码更改。
编写有效的单元测试
单元测试的质量和全面性可以显著影响软件的健壮性。单元测试应该快速可靠,以便工程师立即得到反馈,了解更改是否破坏了预期的行为。通过编写和维护单元测试,您可以确保工程师在添加新功能和代码时不会破坏相关测试覆盖的现有行为。如第九章所讨论的,您的测试还应该是隔离的——如果测试无法在隔离的环境中重复产生相同的结果,您就不能完全依赖测试结果。
考虑一个管理团队在给定数据中心或区域可以使用的存储字节数的系统。假设该系统允许团队在数据中心有可用的未分配字节时请求额外的配额。一个简单的单元测试可能涉及验证在由虚构团队部分占用的虚构集群中请求配额的情况,拒绝超出可用存储容量的请求。以安全为重点的单元测试可能检查涉及负字节数的请求是如何处理的,或者代码如何处理容量溢出,例如导致接近用于表示它们的变量类型的限制的大型转移。另一个单元测试可能检查系统在发送恶意或格式不正确的输入时是否返回适当的错误消息。
经常有用的是使用不同的参数或环境数据对相同的代码进行测试,例如我们示例中的初始起始配额使用情况。为了最小化重复的代码量,单元测试框架或语言通常提供一种以不同参数调用相同测试的方法。这种方法有助于减少重复的样板代码,从而使重构工作变得不那么乏味。
何时编写单元测试
一个常见的策略是在编写代码后不久编写测试,使用测试来验证代码的预期性能。这些测试通常与新代码一起提交,并且通常包括工程师手动检查的情况。例如,我们的示例存储管理应用程序可能要求“只有拥有服务的组的计费管理员才能请求更多的配额。”您可以将这种要求转化为几个单元测试。
在进行代码审查的组织中,同行审查者可以再次检查测试,以确保它们足够健壮,以维护代码库的质量。例如,审阅者可能会注意到,尽管新的测试伴随着变化,但即使删除或停用新代码,测试也可能通过。如果审阅者可以在新代码中用if (false)或if (true)替换类似if (condition_1 || condition_2)的语句,并且没有新的测试失败,那么测试可能已经忽略了重要的测试用例。有关 Google 自动化这种突变测试的经验的更多信息,请参见 Petrović和 Ivanković(2018)。^([2](ch13.html#ch13fn2))
测试驱动开发(TDD)方法鼓励工程师根据已建立的需求和预期行为在编写代码之前编写单元测试,而不是在编写代码之后编写测试。在测试新功能或错误修复时,测试将在行为完全实现之前失败。一旦功能实现并且测试通过,工程师就会进入下一个功能,然后该过程重复。
对于没有使用 TDD 模型构建的现有项目,通常会根据错误报告或积极努力增加对系统的信心来慢慢整合和改进测试覆盖率。但即使您实现了全面覆盖,您的项目也不一定是无错误的。未知的边缘情况或稀疏实现的错误处理仍可能导致不正确的行为。
您还可以根据内部手动测试或代码审查工作编写单元测试。您可能会在标准开发和审查实践中编写这些测试,或者在像发布前的安全审查这样的里程碑期间编写。新的单元测试可以验证建议的错误修复是否按预期工作,并且以后的重构不会重新引入相同的错误。如果代码难以理解并且潜在的错误会影响安全性,例如在编写具有复杂权限模型的系统中的访问控制检查时,这种类型的测试尤为重要。
注意
为了尽可能涵盖多种情景,你通常会花费更多时间编写测试而不是编写被测试的代码,特别是在处理非平凡系统时。这额外的时间从长远来看是值得的,因为早期测试会产生质量更高的代码库,减少需要调试的边缘情况。
单元测试如何影响代码
为了改进测试的全面性,你可能需要设计新的代码来包含测试规定,或者重构旧代码使其更易于测试。通常,重构涉及提供拦截对外部系统的调用的方法。利用这种内省能力,你可以以各种方式测试代码,例如验证代码调用拦截器的次数是否正确,或者使用正确的参数。
考虑一下如何测试一段代码,当满足某些条件时在远程问题跟踪器中打开票证。每次单元测试运行时创建一个真实的票证会产生不必要的噪音。更糟糕的是,如果问题跟踪系统不可用,这种测试策略可能会随机失败,违反了快速、可靠测试结果的目标。
要重构这段代码,你可以删除对问题跟踪服务的直接调用,并用一个抽象来替换这些调用,例如一个IssueTrackerService对象的接口。用于测试的实现可以在接收到“创建问题”等调用时记录数据,测试可以检查元数据以做出通过或失败的结论。相比之下,生产实现将连接到远程系统并调用公开的 API 方法。
这种重构大大减少了依赖于现实世界系统的测试的“不稳定性”。因为它们依赖于不能保证的行为,比如外部依赖性,或者从某些容器类型中检索项目时元素的顺序,不稳定的测试通常更像是一种麻烦而不是帮助。尽量在出现不稳定的测试时进行修复;否则,开发人员可能会养成在提交更改时忽略测试结果的习惯。
注意
这些抽象及其相应的实现被称为模拟、存根或伪装。工程师有时会将这些词用法混淆,尽管这些概念在实现复杂性和功能上有所不同,因此确保你的组织中的每个人都使用一致的词汇是很重要的。如果你进行代码审查或使用风格指南,你可以通过提供团队可以对齐的定义来帮助减少混淆。
很容易陷入过度抽象的陷阱,测试断言关于函数调用顺序或它们的参数的机械事实。过度抽象的测试通常并不提供太多价值,因为它们往往“测试”语言的控制流实现,而不是你关心的系统的行为。
如果每次方法更改时都必须完全重写测试,你可能需要重新考虑测试,甚至是系统本身的架构。为了避免不断重写测试,你可以考虑要求熟悉服务的工程师为任何非平凡的测试需求提供合适的虚拟实现。这种解决方案对于负责系统的团队和测试代码的工程师都是有利的:拥有抽象的团队可以确保它跟踪服务的功能集随着其发展的变化,而使用抽象的团队现在有了一个更真实的组件用于测试。
集成测试
集成测试超越了单个单元和抽象,用真实的实现替换了抽象的假或存根实现,如数据库或网络服务。因此,集成测试涵盖了更完整的代码路径。由于你必须初始化和配置这些其他依赖项,集成测试可能比单元测试更慢、更不稳定——执行测试时,这种方法会将网络延迟等真实世界变量纳入其中,因为服务端到端地进行通信。当你从测试代码的单个低级单元转移到测试它们在组合在一起时的交互方式时,最终结果是对系统行为符合预期的更高程度的信心。
集成测试采用不同的形式,这取决于它们所涉及的依赖项的复杂性。当集成测试需要的依赖相对简单时,集成测试可能看起来像一个设置了一些共享依赖项(例如,处于预配置状态的数据库)的基类,其他测试从中继承。随着服务复杂性的增加,集成测试可能变得更加复杂,需要监督系统来编排依赖项的初始化或设置,以支持测试。谷歌有专门致力于基础设施的团队,为常见的基础设施服务提供标准化的集成测试设置。对于使用像Jenkins这样的持续构建和交付系统的组织,集成测试可以根据代码库的大小和项目中可用测试的数量,与单元测试一起运行,或者单独运行。
注意
在构建集成测试时,请牢记第五章中讨论的原则:确保测试的数据和系统访问要求不会引入安全风险。诱人的做法是将实际数据库镜像到测试环境中,因为数据库提供了丰富的真实数据,但你应该避免这种反模式,因为它们可能包含敏感数据,将对使用这些数据库运行测试的任何人都可用。这种实现与最小特权原则不一致,可能会带来安全风险。相反,你可以使用非敏感的测试数据来填充这些系统。这种方法还可以轻松地将测试环境清除到已知的干净状态,减少集成测试不稳定性的可能性。
编写有效的集成测试
与单元测试一样,集成测试可能受到代码中设计选择的影响。继续我们之前关于问题跟踪器的例子,一个单元测试模拟可能只是断言该方法被调用以向远程服务提交一个问题。而集成测试更可能使用一个真实的客户端库。与其在生产中创建虚假的错误,集成测试会与 QA 端点进行通信。测试用例将使用触发对 QA 实例的调用的输入来执行应用逻辑。监督逻辑随后可以查询 QA 实例,以验证从端到端的角度成功地进行了外部可见的操作。
了解为什么集成测试失败,而所有单元测试都通过可能需要大量的时间和精力。在集成测试的关键逻辑交汇处进行良好的日志记录可以帮助你调试和理解故障发生的位置。还要记住,因为集成测试超越了单个单元,检查组件之间的交互,它们只能告诉你有关这些单元在其他场景中是否符合你的期望的有限信息。这是在开发生命周期中使用每种类型的测试的许多原因之一,因为一种测试通常不能替代另一种。
深入探讨:动态程序分析
程序分析允许用户执行许多有用的操作,例如性能分析、检查与安全相关的正确性、代码覆盖报告和死代码消除。正如本章后面讨论的那样,您可以静态地执行程序分析来研究软件而不执行它。在这里,我们关注动态方法。动态程序分析通过运行程序来分析软件,可能在虚拟化或模拟环境中,用于除了测试之外的目的。
性能分析器(用于发现程序中的性能问题)和代码覆盖报告生成器是最常见的动态分析类型。上一章介绍了动态程序分析工具Valgrind,它提供了一个虚拟机和各种工具来解释二进制代码,并检查执行是否存在各种常见的错误。本节重点介绍依赖于编译器支持(通常称为instrumentation)来检测与内存相关的错误的动态分析方法。
编译器和动态程序分析工具允许您配置仪器化,以收集编译器生成的二进制文件的运行时统计信息,例如性能分析信息、代码覆盖信息和基于配置的优化。当二进制文件执行时,编译器插入额外的指令和回调到后端运行时库,以显示和收集相关信息。在这里,我们关注 C/C++程序的安全相关内存误用错误。
Google Sanitizers 套件提供了基于编译的动态分析工具。它们最初作为LLVM编译器基础设施的一部分开发,用于捕获常见的编程错误,并且现在也得到了 GCC 和其他编译器的支持。例如,AddressSanitizer (ASan)可以在 C/C++程序中找到许多常见的与内存相关的错误,比如越界内存访问。其他流行的 sanitizers 包括以下内容:
执行未定义行为的运行时标记
检测竞争条件
检测未初始化内存的读取
检测内存泄漏和其他类型的泄漏
随着新的硬件功能允许对内存地址进行标记,有提案利用这些新功能进一步提高 ASan 的性能。
ASan 通过构建程序分析的自定义仪器化二进制文件来提供快速性能。在编译过程中,ASan 添加了某些指令,以便调用提供的 sanitizer 运行时。运行时维护有关程序执行的元数据,例如哪些内存地址是有效的访问。ASan 使用影子内存来记录给定字节对程序访问是否安全,并使用编译器插入的指令在程序尝试读取或写入该字节时检查影子内存。它还提供自定义内存分配和释放(malloc和free)实现。例如,malloc函数在返回请求的内存区域之前立即分配额外的内存。这创建了一个缓冲内存区域,使 ASan 能够轻松报告关于溢出和下溢的精确信息。为此,ASan 将这些区域(也称为red zones)标记为poisoned。同样,ASan 将已释放的内存标记为poisoned,使您能够轻松捕获使用后释放的错误。
以下示例说明了使用 Clang 编译器运行 ASan 的简单过程。shell 命令对具有使用后释放错误的特定输入文件进行插装和运行。当读取先前释放的内存区域的内存地址时,将发生使用后释放错误。安全漏洞可以利用这种类型的访问作为构建块。选项-fsanitize=address打开 ASan 插装:
$ cat -n use-after-free.c
1 #include <stdlib.h>
2 int main() {
3 char *x = (char*)calloc(10, sizeof(char));
4 free(x);
5 return x[5];
6 }
$ clang -fsanitize=address -O1 -fno-omit-frame-pointer -g use-after-free.c
编译完成后,当执行生成的二进制文件时,我们可以看到 ASan 生成的错误报告。(为了简洁起见,我们省略了完整的 ASan 错误消息。)请注意,ASan 允许错误报告指示源文件信息,例如行号,使用 LLVM 符号化程序,如 Clang 文档中的“符号化报告”部分所述。正如您在输出报告中所看到的,ASan 发现了一个 1 字节的使用后释放读取访问(已强调)。错误消息包括原始分配、释放和随后的非法使用的信息:
% ./a.out
=================================================================
==142161==ERROR: AddressSanitizer: heap-use-after-free on address 0x602000000015
at pc 0x00000050b550 bp 0x7ffc5a603f70 sp 0x7ffc5a603f68
READ of size 1 at 0x602000000015 thread T0
#0 0x50b54f in main use-after-free.c:5:10
#1 0x7f89ddd6452a in __libc_start_main
#2 0x41c049 in _start
0x602000000015 is located 5 bytes inside of 10-byte region [0x602000000010,0x60200000001a)
freed by thread T0 here:
#0 0x4d14e8 in free
#1 0x50b51f in main use-after-free.c:4:3
#2 0x7f89ddd6452a in __libc_start_main
previously allocated by thread T0 here:
#0 0x4d18a8 in calloc
#1 0x50b514 in main use-after-free.c:3:20
#2 0x7f89ddd6452a in __libc_start_main
SUMMARY: AddressSanitizer: heap-use-after-free use-after-free.c:5:10 in main
[...]
==142161==ABORTING
深入探讨:模糊测试
模糊测试(通常称为模糊测试)是一种补充先前提到的测试策略的技术。模糊测试涉及使用模糊引擎(或模糊器)生成大量候选输入,然后通过模糊驱动程序传递给模糊目标(处理输入的代码)。然后,模糊器分析系统如何处理输入。各种软件处理的复杂输入都是模糊测试的热门目标,例如文件解析器、压缩算法实现、网络协议实现和音频编解码器。
您还可以使用模糊测试来评估相同功能的不同实现。例如,如果您正在考虑从库 A 迁移到库 B,模糊器可以生成输入,将其传递给每个库进行处理,并比较结果。模糊器可以将任何不匹配的结果报告为“崩溃”,这有助于工程师确定可能导致微妙行为变化的原因。这种在不同输出上崩溃的操作通常作为模糊驱动程序的一部分实现,如在 OpenSSL 的BigNum 模糊器中所见。⁵
由于模糊测试可能会无限期地执行,因此不太可能阻止每次提交都要进行扩展测试的结果。这意味着当模糊器发现错误时,该错误可能已经被检入。理想情况下,其他测试或分析策略将首先防止错误发生,因此模糊测试通过生成工程师可能没有考虑到的测试用例来作为补充。作为额外的好处,另一个单元测试可以使用在模糊目标中识别错误的生成输入样本,以确保后续更改不会使修复退化。
模糊引擎的工作原理
模糊引擎的复杂性和精密度可以有所不同。在光谱的低端,一种通常称为愚蠢模糊的技术简单地从随机数生成器中读取字节,并将它们传递给模糊目标,以寻找错误。通过与编译器工具链的集成,模糊引擎变得越来越智能。它们现在可以利用先前讨论的编译器插装功能生成更有趣和有意义的样本。在工业实践中,使用尽可能多的模糊引擎集成到构建工具链中,并监视代码覆盖的百分比等指标被认为是一种良好的做法。如果代码覆盖在某个点停滞不前,通常值得调查为什么模糊器无法到达其他区域。
一些模糊引擎接受来自规范或语法的有趣关键字字典,这些规范或语法来自规范良好的协议、语言和格式(如 HTTP、SQL 和 JSON)。 模糊引擎可以生成可能被测试程序接受的输入,因为如果输入包含非法关键字,则生成的解析器代码可能会简单地拒绝输入。 提供字典可以增加通过模糊测试达到实际想要测试的代码的可能性。 否则,您可能最终会执行基于无效标记拒绝输入的代码,并且永远找不到任何有趣的错误。
像Peach Fuzzer这样的模糊引擎允许模糊驱动程序作者以编程方式定义输入的格式和字段之间的预期关系,因此模糊引擎可以生成违反这些关系的测试用例。 模糊引擎通常还接受一组示例输入文件,称为种子语料库,这些文件代表了被模糊化的代码所期望的内容。 然后,模糊引擎会改变这些种子输入,以及执行任何其他支持的输入生成策略。 一些软件包包含示例文件(例如音频库的 MP3 文件或图像处理的 JPEG 文件)作为其现有测试套件的一部分 - 这些示例文件非常适合作为种子语料库的候选文件。 否则,您可以从真实世界或手动生成的文件中策划种子语料库。 安全研究人员还会发布流行文件格式的种子语料库,例如以下提供的种子语料库:
近年来,编译器工具链的改进已经在使更智能的模糊引擎方面取得了重大进展。 对于 C/C++,例如 LLVM Clang 等编译器可以对代码进行仪器化(如前所述),以允许模糊引擎观察处理特定样本输入时执行的代码。 当模糊引擎找到新的代码路径时,它会保留触发代码路径的样本,并使用它们来生成未来的样本。 其他语言或模糊引擎可能需要特定的编译器 - 例如AFL的 afl-gcc 或go-fuzz 引擎的 go-fuzz-build,以正确跟踪执行路径以增加代码覆盖率。
当模糊引擎生成触发经过消毒处理的代码路径中的崩溃的输入时,它会记录输入以及从程序中提取的元数据,这些元数据包括诸如堆栈跟踪(指示触发崩溃的代码行)或进程在那个时间的内存布局等信息。 此信息为工程师提供了有关崩溃原因的详细信息,这有助于他们了解其性质、准备修复或优先处理错误。 例如,当组织考虑如何为不同类型的问题设置优先级时,内存读取访问违规可能被认为比写入访问违规不太重要。 这种优先级有助于建立安全和可靠的文化(参见第二十一章)。
当模糊引擎触发潜在错误时,程序对其做出反应的方式取决于各种各样的情况。 如果遇到错误会触发一致和明确定义的事件,例如接收信号或在发生内存损坏或未定义行为时执行特定函数,那么模糊引擎最有效地检测错误。 这些函数可以在系统达到特定错误状态时明确地向模糊引擎发出信号。 之前提到的许多消毒剂都是这样工作的。
一些模糊引擎还允许您为处理特定生成的输入设置一个上限时间。例如,如果死锁或无限循环导致输入超过时间限制,模糊器将把样本归类为“崩溃”。它还保存该样本以供进一步调查,以便开发团队可以防止可能导致服务不可用的 DoS 问题。
通过使用正确的模糊驱动程序和消毒剂,可以相对快速地识别导致 Web 服务器泄漏内存(包括包含 TLS 证书或 Cookie 的内存)的 Heartbleed 漏洞(CVE-2014-0160)。Google 的fuzzer-test-suite GitHub 存储库包含一个演示成功识别该漏洞的 Dockerfile 示例。以下是 Heartbleed 漏洞的 ASan 报告摘录,由消毒剂编译器插件插入的__asan_memcpy函数调用触发(重点添加):
==19==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x629000009748 at pc
0x0000004e59c9 bp 0x7ffe3a541360 sp 0x7ffe3a540b10
READ of size 65535 at 0x629000009748 thread T0
#0 0x4e59c8 in __asan_memcpy /tmp/final/llvm.src/projects/compiler-rt/lib/asan/asan_interceptors_memintrinsics.cc:23:3
#1 0x522e88 in tls1_process_heartbeat /root/heartbleed/BUILD/ssl/t1_lib.c:2586:3
#2 0x58f94d in ssl3_read_bytes /root/heartbleed/BUILD/ssl/s3_pkt.c:1092:4
#3 0x59418a in ssl3_get_message /root/heartbleed/BUILD/ssl/s3_both.c:457:7
#4 0x55f3c7 in ssl3_get_client_hello /root/heartbleed/BUILD/ssl/s3_srvr.c:941:4
#5 0x55b429 in ssl3_accept /root/heartbleed/BUILD/ssl/s3_srvr.c:357:9
#6 0x51664d in LLVMFuzzerTestOneInput /root/FTS/openssl-1.0.1f/target.cc:34:3
[...]
0x629000009748 is located 0 bytes to the right of 17736-byte region [0x629000005200,
0x629000009748)
allocated by thread T0 here:
#0 0x4e68e3 in __interceptor_malloc /tmp/final/llvm.src/projects/compiler-rt/lib/asan/asan_malloc_linux.cc:88:3
#1 0x5c42cb in CRYPTO_malloc /root/heartbleed/BUILD/crypto/mem.c:308:8
#2 0x5956c9 in freelist_extract /root/heartbleed/BUILD/ssl/s3_both.c:708:12
#3 0x5956c9 in ssl3_setup_read_buffer /root/heartbleed/BUILD/ssl/s3_both.c:770
#4 0x595cac in ssl3_setup_buffers /root/heartbleed/BUILD/ssl/s3_both.c:827:7
#5 0x55bff4 in ssl3_accept /root/heartbleed/BUILD/ssl/s3_srvr.c:292:9
#6 0x51664d in LLVMFuzzerTestOneInput /root/FTS/openssl-1.0.1f/target.cc:34:3
[...]
输出的第一部分描述了问题的类型(在本例中是“堆缓冲区溢出”——具体来说是读取访问违规)和一个易于阅读的符号化堆栈跟踪,指向读取超出分配的缓冲区大小的代码行。第二部分包含了有关附近内存区域的元数据,以及如何分配这些元数据,以帮助工程师分析问题并了解进程如何达到无效状态。
编译器和消毒剂仪器使这种分析成为可能。然而,这种仪器有限:当软件的某些部分是手写汇编以提高性能时,使用消毒剂进行模糊处理效果不佳。编译器无法对汇编代码进行仪器化,因为消毒剂插件在更高层操作。因此,未被仪器化的手写汇编代码可能会导致误报或未检测到的错误。
完全不使用消毒剂进行模糊处理是可能的,但会降低您检测无效程序状态和分析崩溃时可用的元数据的能力。例如,如果您不使用消毒剂,为了使模糊处理产生任何有用的信息,程序必须遇到“未定义行为”场景,然后将此错误状态通知外部模糊引擎(通常是通过崩溃或退出)。否则,未定义的行为将继续未被检测。同样,如果您不使用 ASan 或类似的仪器,您的模糊器可能无法识别内存已被损坏但未被使用以导致操作系统终止进程的状态。
如果您正在使用仅以二进制形式提供的库,编译器仪器化就不是一个选择。一些模糊引擎,如 American Fuzzy Lop,还与处理器模拟器(如 QEMU)集成,以在 CPU 级别仪器化有趣的指令。这种集成可能是一个吸引人的选择,用于模糊化需要模糊化的仅以二进制形式提供的库,但会降低速度。这种方法允许模糊引擎了解生成的输入可能触发的代码路径,但与使用编译器添加的消毒剂指令构建的源代码构建一样,它不提供太多的错误检测帮助。
许多现代模糊引擎,如libFuzzer、AFL和Honggfuzz,使用先前描述的技术的某种组合,或这些技术的变体。可以构建一个单一的模糊驱动程序,可以与多个模糊引擎一起使用。在使用多个模糊引擎时,最好确保定期将每个引擎生成的有趣输入样本移回其他模糊引擎配置为使用的种子语料库中。一个引擎可能成功地接受另一个引擎生成的输入,对其进行变异,并触发崩溃。
编写有效的模糊驱动程序
为了使这些模糊概念更具体,我们将更详细地介绍使用 LLVM 的 libFuzzer 引擎提供的框架编写模糊驱动程序的步骤,该引擎包含在 Clang 编译器中。这个特定的框架很方便,因为其他模糊引擎(如 Honggfuzz 和 AFL)也可以使用 libFuzzer 入口点。作为模糊器作者,使用这个框架意味着您只需要编写一个实现函数原型的驱动程序:
int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size);
随后的模糊引擎将生成字节序列并调用您的驱动程序,该驱动程序可以将输入传递给您想要测试的代码。
模糊引擎的目标是通过驱动尽快执行模糊目标,并生成尽可能多的独特和有趣的输入。为了实现可重现的崩溃和快速模糊,请尽量避免在模糊驱动程序中出现以下情况:
-
非确定性行为,比如依赖随机数生成器或特定的多线程行为。
-
慢操作,如控制台日志记录或磁盘 I/O。相反,考虑创建“模糊器友好”的构建,禁用这些慢操作,或者使用基于内存的文件系统。
-
故意崩溃。模糊测试的理念是找到你没有意图发生的崩溃。模糊引擎无法区分故意的崩溃。
这些属性对于本章描述的其他类型的测试也可能是理想的。
您还应该避免任何对手可以在生成的输入样本中“修复”的专门完整性检查(如 CRC32 或消息摘要)。模糊引擎不太可能生成有效的校验和,并在没有专门逻辑的情况下通过完整性检查。一个常见的约定是使用编译器预处理器标志,如-DFUZZING_BUILD_MODE_UNSAFE_FOR_PRODUCTION,以启用这种模糊器友好的行为,并帮助通过模糊测试识别出的崩溃。
一个示例模糊器
本节遵循编写一个名为Knusperli的简单开源 C++库的模糊器的步骤。Knusperli 是一个 JPEG 解码器,如果它对用户上传的内容进行编码或处理来自网络的图像(包括潜在的恶意图像),可能会看到各种各样的输入。
Knusperli 还为我们提供了一个方便的接口来进行模糊测试:一个接受字节序列(JPEG)和大小参数的函数,以及一个控制解析图像的哪些部分的参数。对于不提供这样直接接口的软件,您可以使用辅助库,如FuzzedDataProvider,来帮助将字节序列转换为目标接口的有用值。我们的示例模糊驱动程序针对这个函数。
bool ReadJpeg(const uint8_t* data, const size_t len, JpegReadMode mode,
JPEGData* jpg);
Knusperli 使用Bazel 构建系统。通过修改*.bazelrc*文件,您可以创建一个方便的快捷方式来使用各种消毒剂构建目标,并直接构建基于 libFuzzer 的模糊器。以下是 ASan 的示例:
$ cat ~/.bazelrc
build:asan --copt -fsanitize=address --copt -O1 --copt -g -c dbg
build:asan --linkopt -fsanitize=address --copt -O1 --copt -g -c dbg
build:asan --copt -fno-omit-frame-pointer --copt -O1 --copt -g -c dbg
在这一点上,您应该能够构建启用了 ASan 的工具版本:
$ CC=clang-6.0 CXX=clang++-6.0 bazel build --config=asan :knusperli
您还可以为我们即将编写的模糊器在BUILD文件中添加规则:
cc_binary(
name = "fuzzer",
srcs = [
"jpeg_decoder_fuzzer.cc",
],
deps = [
":jpeg_data_decoder",
":jpeg_data_reader",
],
linkopts = ["-fsanitize=address,fuzzer"],
)
示例 13-2 展示了模糊驱动程序的简单尝试可能是什么样子。
示例 13-2. jpeg_decoder_fuzzer.cc
1 #include <cstddef>
2 #include <cstdint>
3 #include "jpeg_data_decoder.h"
4 #include "jpeg_data_reader.h"
5
6 extern "C" int LLVMFuzzerTestOneInput(const uint8_t *data, size_t sz) {
7 knusperli::JPEGData jpg;
8 knusperli::ReadJpeg(data, sz, knusperli::JPEG_READ_HEADER, &jpg);
9 return 0;
10 }
我们可以使用以下命令构建和运行模糊驱动程序:
$ CC=clang-6.0 CXX=clang++-6.0 bazel build --config=asan :fuzzer
$ mkdir synthetic_corpus
$ ASAN_SYMBOLIZER_PATH=/usr/lib/llvm-6.0/bin/llvm-symbolizer bazel-bin/fuzzer \
-max_total_time 300 -print_final_stats synthetic_corpus/
上述命令在使用空输入语料库的情况下运行模糊器五分钟。LibFuzzer 将有趣的生成样本放在*synthetic_corpus/*目录中,以便在未来的模糊会话中使用。您将收到以下结果:
[...]
INFO: 0 files found in synthetic_corpus/
INFO: -max_len is not provided; libFuzzer will not generate inputs larger than
4096 bytes
INFO: A corpus is not provided, starting from an empty corpus
#2 INITED cov: 110 ft: 111 corp: 1/1b exec/s: 0 rss: 36Mb
[...]
#3138182 DONE cov: 151 ft: 418 corp: 30/4340b exec/s: 10425 rss: 463Mb
[...]
Done 3138182 runs in 301 second(s)
stat::number_of_executed_units: 3138182
stat::average_exec_per_sec: 10425
stat::new_units_added: 608
stat::slowest_unit_time_sec: 0
stat::peak_rss_mb: 463
添加 JPEG 文件(例如,在广播电视上看到的彩条图案)到种子语料库中也会带来改进。这个单一的种子输入带来了执行的代码块的>10%的改进(cov指标):
#2 INITED cov: 169 ft: 170 corp: 1/8632b exec/s: 0 rss: 37Mb
为了进一步达到更多的代码,我们可以使用不同的值来设置JpegReadMode参数。有效值如下:
enum JpegReadMode {
JPEG_READ_HEADER, *// only basic headers*
JPEG_READ_TABLES, *// headers and tables (quant, Huffman, ...)*
JPEG_READ_ALL, *// everything*
};
与编写三种不同的模糊器不同,我们可以对输入的子集进行哈希处理,并使用该结果在单个模糊器中执行不同组合的库特性。要小心使用足够的输入来创建多样化的哈希输出。如果文件格式要求输入的前N个字节都看起来相同,那么在决定哪些字节会影响设置哪些选项时,请至少使用比N多一个的字节。
其他方法包括使用先前提到的FuzzedDataProvider来分割输入,或者将输入的前几个字节专门用于设置库参数。然后,剩余的字节作为输入传递给模糊目标。与对输入进行哈希处理可能会导致不同的配置,如果单个输入位发生变化,那么将输入拆分的替代方法允许模糊引擎更好地跟踪所选选项与代码行为方式之间的关系。要注意这些不同方法如何影响潜在现有种子输入的可用性。在这种情况下,想象一下,通过决定依赖前几个输入字节来设置库的选项,您可以创建一个新的伪格式。结果,除非您首先对文件进行预处理以添加初始参数,否则您将不再能够轻松地使用世界上所有现有的 JPEG 文件作为可能的种子输入。
为了探索将库配置为生成输入样本的函数的想法,我们将使用输入的前 64 个字节中设置的位数来选择JpegReadMode,如示例 13-3 所示。
示例 13-3。通过拆分输入进行模糊
#include <cstddef>
#include <cstdint>
#include "jpeg_data_decoder.h"
#include "jpeg_data_reader.h"
const unsigned int kInspectBytes = 64;
const unsigned int kInspectBlocks = kInspectBytes / sizeof(unsigned int);
extern "C" int LLVMFuzzerTestOneInput(const uint8_t *data, size_t sz) {
knusperli::JPEGData jpg;
knusperli::JpegReadMode rm;
unsigned int bits = 0;
if (sz <= kInspectBytes) { *// Bail on too-small inputs.*
return 0;
}
for (unsigned int block = 0; block < kInspectBlocks; block++) {
bits +=
__builtin_popcount(reinterpret_cast<const unsigned int *>(data)[block]);
}
rm = static_cast<knusperli::JpegReadMode>(bits %
(knusperli::JPEG_READ_ALL + 1));
knusperli::ReadJpeg(data, sz, rm, &jpg);
return 0;
}
当将彩色条作为唯一的输入语料库使用五分钟时,这个模糊器给出了以下结果:
#851071 DONE cov: 196 ft: 559 corp: 51/29Kb exec/s: 2827 rss: 812Mb
[...]
Done 851071 runs in 301 second(s)
stat::number_of_executed_units: 851071
stat::average_exec_per_sec: 2827
stat::new_units_added: 1120
stat::slowest_unit_time_sec: 0
stat::peak_rss_mb: 812
每秒执行次数下降了,因为更改使库的更多特性生效,导致这个模糊驱动器达到了更多代码(由上升的cov指标表示)。如果您在没有任何超时限制的情况下运行模糊器,它将继续无限期地生成输入,直到代码触发了一个消毒器错误条件。在那时,您将看到一个类似于之前显示的 Heartbleed 漏洞的报告。然后,您可以进行代码更改,重新构建,并运行您使用保存的工件构建的模糊器二进制文件,以重现崩溃或验证代码更改是否会修复问题。
持续模糊
一旦您编写了一些模糊器,定期在代码库上运行它们可以为工程师提供宝贵的反馈循环。持续构建管道可以在您的代码库中生成每日构建的模糊器,以供运行模糊器、收集崩溃信息并在问题跟踪器中提交错误。工程团队可以利用结果来专注于识别漏洞或消除导致服务未达到 SLO 的根本原因。
示例:ClusterFuzz 和 OSSFuzz
ClusterFuzz是由 Google 发布的可扩展模糊基础设施的开源实现。它管理运行模糊任务的虚拟机池,并提供一个 Web 界面来查看有关模糊器的信息。ClusterFuzz 不构建模糊器,而是期望持续构建/集成管道将模糊器推送到 Google Cloud Storage 存储桶。它还提供诸如语料库管理、崩溃去重和崩溃的生命周期管理等服务。ClusterFuzz 用于崩溃去重的启发式是基于崩溃时程序的状态。通过保留导致崩溃的样本,ClusterFuzz 还可以定期重新测试这些问题,以确定它们是否仍然重现,并在最新版本的模糊器不再在有问题的样本上崩溃时自动关闭问题。
ClusterFuzz 网络界面显示了您可以使用的指标,以了解给定模糊器的性能如何。可用的指标取决于构建流水线中集成的模糊引擎导出的内容(截至 2020 年初,ClusterFuzz 支持 libFuzzer 和 AFL)。ClusterFuzz 文档提供了从使用 Clang 代码覆盖支持构建的模糊器中提取代码覆盖信息的说明,然后将该信息转换为可以存储在 Google Cloud Storage 存储桶中并在前端显示的格式。使用此功能来探索上一节中编写的模糊器覆盖的代码将是确定输入语料库或模糊驱动程序的其他改进的下一个良好步骤。
OSS-Fuzz将现代模糊技术与托管在谷歌云平台上的可扩展分布式 ClusterFuzz 执行相结合。它发现安全漏洞和稳定性问题,并直接向开发人员报告——自 2016 年 12 月推出以来的五个月内,OSS-Fuzz 已经发现了一千多个错误,自那时以来,它已经发现了成千上万个错误。
一旦项目与 OSS-Fuzz 集成,该工具就会使用持续和自动化测试,在修改的代码引入上游存储库后的几小时内发现问题,而不会影响任何用户。在谷歌,通过统一和自动化我们的模糊工具,我们已经将我们的流程整合到基于 OSS-Fuzz 的单个工作流程中。这些集成的 OSS 项目还受益于谷歌内部工具和外部模糊工具的审查。我们的集成方法增加了代码覆盖率,并更快地发现错误,改善了谷歌项目和开源生态系统的安全状况。
深入探讨:静态程序分析
静态分析是一种分析和理解计算机程序的方法,它通过检查其源代码而不执行或运行它们。静态分析器解析源代码并构建适合自动化分析的程序的内部表示。这种方法可以在源代码中发现潜在的错误,最好是在代码被检入或部署到生产环境之前。许多工具可用于各种语言,以及用于跨语言分析的工具。
静态分析工具在分析深度与分析源代码成本之间做出不同的权衡。例如,最浅的分析器执行简单的文本或基于抽象语法树(AST)的模式匹配。其他技术依赖于对程序的基于状态的语义构造进行推理,并基于程序的控制流和数据流进行推理。
工具还针对分析误报(错误警告)和漏报(遗漏警告)之间的不同分析权衡。权衡是不可避免的,部分原因是静态分析的基本限制:静态验证任何程序都是一个不可判定的问题——也就是说,不可能开发出一个能够确定任何给定程序是否会执行而不违反任何给定属性的算法。
鉴于这一约束,工具提供商专注于在开发的各个阶段为开发人员生成有用的信号。根据静态分析引擎的集成点,对于分析速度和预期分析反馈的不同权衡是可以接受的。例如,集成在代码审查系统中的静态分析工具可能只针对新开发的源代码,并会发出专注于非常可能的问题的精确警告。另一方面,正在进行最终的预部署发布分析的源代码(例如,用于航空电子软件或具有潜在政府认证要求的医疗设备软件等领域)可能需要更正式和更严格的分析。⁶
以下部分介绍了针对开发过程不同阶段的各种需求而调整的静态分析技术。我们重点介绍了自动化代码检查工具、基于抽象解释的工具(有时这个过程被称为深度静态分析)以及更消耗资源的方法,比如形式化方法。我们还讨论了如何将静态分析器集成到开发人员的工作流程中。
自动化代码检查工具
自动化代码检查工具针对语言特性和使用规则对源代码进行了句法分析。这些工具通常被称为linters,通常不对程序的复杂行为进行建模,比如过程间数据流。由于它们执行的分析相对较浅,这些工具很容易扩展到任意大小的代码——它们通常可以在大约相同的时间内完成源代码分析,就像编译代码一样。代码检查工具也很容易扩展——您可以简单地添加涵盖许多类型错误的新规则,特别是与语言特性相关的错误。
在过去几年中,代码检查工具已经专注于风格和可读性的改变,因为这些代码改进建议被开发人员高度接受。许多组织默认强制执行风格和格式检查,以便在大型开发团队中维护一个更易管理的统一代码库。这些组织还定期运行检查,以揭示潜在的代码异味和高度可能的错误。
以下示例侧重于执行一种特定类型的分析的工具——AST 模式匹配。 AST是程序源代码的树形表示,基于编程语言的句法结构。编译器通常将给定的源代码输入文件解析为这样的表示,并在编译过程中操纵该表示。例如,AST 可能包含一个表示if-then-else结构的节点,该节点有三个子节点:一个节点用于if语句的条件,一个节点表示then分支的子树,另一个节点表示else分支的子树。
Error Prone用于 Java,Clang-Tidy用于 C/C++在 Google 的项目中被广泛使用。这两种分析器都允许工程师添加自定义检查。例如,截至 2018 年初,已有 162 位作者提交了 733 个 Error Prone 检查。对于某些类型的错误,Error Prone 和 Clang-Tidy 都可以提出建议的修复方案。一些编译器(如 Clang 和 MSVC)还支持社区开发的C++核心指南。借助指南支持库(GSL)的帮助,这些指南可以防止 C++程序中的许多常见错误。
AST 模式匹配工具允许用户通过编写对解析 AST 的规则来添加新的检查。例如,考虑absl-string-find-startsWith Clang-Tidy 警告。该工具试图改进使用 C++ string::find API检查字符串前缀匹配的代码的可读性和性能:Clang-Tidy 建议改用ABSL提供的StartsWith API。为了执行其分析,该工具创建了一个 AST 子树模式,比较了 C++ string::find API 的输出与整数值 0。Clang-Tidy 基础设施提供了在被分析程序的 AST 表示中找到 AST 子树模式的工具。
考虑以下代码片段:
std::string s = "...";
if (s.find("Hello World") == 0) { /* do something */ }
absl-string-find-startsWith Clang-Tidy 警告标记了这段代码,并建议以以下方式更改代码:
std::string s = "...";
if (absl::StartsWith(s, "Hello World")) { /* do something */ }
为了提出修复建议,Clang-Tidy(从概念上讲)提供了根据模式转换 AST 子树的能力。图 13-1 的左侧显示了 AST 模式匹配。(为了清晰起见,AST 子树进行了简化。)如果工具在源代码的解析 AST 树中找到匹配的 AST 子树,它会通知开发人员。AST 节点还包含行和列信息,这使得 AST 模式匹配器能够向开发人员报告特定的警告。
图 13-1. AST 模式匹配和替换建议
除了性能和可读性检查外,Clang-Tidy 还提供了许多常见的错误模式检查。考虑在以下输入文件上运行 Clang-Tidy:⁷
$ cat -n sizeof.c
1 #include <string.h>
2 const char* kMessage = "Hello World!";
3 int main() {
4 char buf[128];
5 memcpy(buf, kMessage, sizeof(kMessage));
6 return 0;
7 }
$ clang-tidy sizeof.c
[...]
Running without flags.
1 warning generated.
sizeof.c:5:32: warning: 'memcpy' call operates on objects of type 'const char'
while the size is based on a different type 'const char *'
[clang-diagnostic-sizeof-pointer-memaccess]
memcpy(buf, kMessage, sizeof(kMessage));
^
sizeof.c:5:32: note: did you mean to provide an explicit length?
memcpy(buf, kMessage, sizeof(kMessage));
$ cat -n sizeof2.c
1 #include <string.h>
2 const char kMessage[] = "Hello World!";
3 int main() {
4 char buf[128];
5 memcpy(buf, kMessage, sizeof(kMessage));
6 return 0;
7 }
$ clang-tidy sizeof2.c
[...]
Running without flags.
这两个输入文件只在kMessage的类型声明上有所不同。当kMessage被定义为指向初始化内存的指针时,sizeof(kMessage)返回指针类型的大小。因此,Clang-Tidy 产生了clang-diagnostic-sizeof-pointer-memaccess警告。另一方面,当kMessage的类型为const char[]时,sizeof(kMessage)操作返回适当的预期长度,Clang-Tidy 不会产生警告。
对于某些模式检查,除了报告警告外,Clang-Tidy 还可以建议代码修复。前面提到的absl-string-find-startsWith Clang-Tidy 警告建议就是这样一个例子。图 13-1 的右侧显示了适当的 AST 级别替换。当这样的建议可用时,您可以告诉 Clang-Tidy 自动将它们应用到输入文件中,使用--fix命令行选项。
您还可以使用自动应用的建议来使用 Clang-Tidy 的modernize修复更新代码库。考虑以下命令序列,其中展示了modernize-use-nullptr模式。该序列查找了用于指针赋值或比较的零常量的实例,并将它们更改为使用nullptr。为了运行所有modernize检查,我们使用 Clang-Tidy 选项--checks=modernize-*;然后--fix将建议应用到输入文件。在命令序列的末尾,我们通过打印转换后的文件来突出显示这四个更改(已强调):
$ cat -n nullptr.cc
1 #define NULL 0x0
2
3 int *ret_ptr() {
4 return 0;
5 }
6
7 int main() {
8 char *a = NULL;
9 char *b = 0;
10 char c = 0;
11 int *d = ret_ptr();
12 return d == NULL ? 0 : 1;
13 }
$ clang-tidy nullptr.cc -checks=modernize-* --fix
[...]
Running without flags.
4 warnings generated.
nullptr.cc:4:10: warning: use nullptr [modernize-use-nullptr]
return 0;
^
nullptr
nullptr.cc:4:10: note: FIX-IT applied suggested code changes
return 0;
^
nullptr.cc:8:13: warning: use nullptr [modernize-use-nullptr]
char *a = NULL;
^
nullptr
nullptr.cc:8:13: note: FIX-IT applied suggested code changes
char *a = NULL;
^
nullptr.cc:9:13: warning: use nullptr [modernize-use-nullptr]
char *b = 0;
^
nullptr
nullptr.cc:9:13: note: FIX-IT applied suggested code changes
char *b = 0;
^
nullptr.cc:12:15: warning: use nullptr [modernize-use-nullptr]
return d == NULL ? 0 : 1;
^
nullptr
nullptr.cc:12:15: note: FIX-IT applied suggested code changes
return d == NULL ? 0 : 1;
^
clang-tidy applied 4 of 4 suggested fixes.
$ cat -n nullptr.cc
1 #define NULL 0x0
2
3 int *ret_ptr() {
4 return nullptr;
5 }
6
7 int main() {
8 char *a = nullptr;
9 char *b = nullptr;
10 char c = 0;
11 int *d = ret_ptr();
12 return d == nullptr ? 0 : 1;
13 }
其他语言也有类似的自动化代码检查工具。例如,GoVet分析 Go 源代码中常见的可疑结构,Pylint分析 Python 代码,Error Prone 为 Java 程序提供分析和自动修复能力。以下示例简要演示了通过 Bazel 构建规则运行 Error Prone(已强调)。在 Java 中,对Short类型的变量i进行减法操作i-1会返回int类型的值。因此,remove操作不可能成功:
$ cat -n ShortSet.java
1 import java.util.Set;
2 import java.util.HashSet;
3
4 public class ShortSet {
5 public static void main (String[] args) {
6 Set<Short> s = new HashSet<>();
7 for (short i = 0; i < 100; i++) {
8 s.add(i);
9 s.remove(i - 1);
10 }
11 System.out.println(s.size());
12 }
13 }
$ bazel build :hello
ERROR: example/myproject/BUILD:29:1: Java compilation in rule '//example/myproject:hello'
ShortSet.java:9: error: [CollectionIncompatibleType] Argument 'i - 1' should not be
passed to this method;
its type int is not compatible with its collection's type argument Short
s.remove(i - 1);
^
(see http://errorprone.info/bugpattern/CollectionIncompatibleType)
1 error
将静态分析集成到开发者工作流程中
尽早在开发周期中运行相对快速的静态分析工具被认为是良好的行业实践。早期发现错误很重要,因为如果将它们推送到源代码存储库或部署给用户,修复它们的成本会大大增加。
将静态分析工具集成到 CI/CD 流水线中的门槛很低,对工程师的生产力可能会产生很高的积极影响。例如,开发人员可以收到有关如何修复空指针解除引用的错误和建议。如果他们无法推送他们的代码,他们就不会忘记修复问题并意外地导致系统崩溃或暴露信息,这有助于建立安全和可靠的文化(见第二十一章)。
为此,谷歌开发了 Tricorder 程序分析平台⁸和 Tricorder 的开源版本Shipshape。Tricorder 每天对大约 50,000 个代码审查更改进行静态分析。该平台运行许多类型的程序分析工具,并在代码审查期间向开发人员显示警告,当时他们习惯于评估建议。这些工具旨在提供易于理解和易于修复的代码发现,用户感知的误报率低(最多为 10%)。
Tricorder 旨在允许用户运行许多不同的程序分析工具。截至 2018 年初,该平台包括 146 个分析器,涵盖 30 多种源语言。其中大多数分析器是由谷歌开发人员贡献的。一般来说,通常可用的静态分析工具并不是非常复杂。Tricorder 运行的大多数检查器都是自动化的代码检查工具。这些工具针对各种语言,检查是否符合编码风格指南,并查找错误。如前所述,Error Prone 和 Clang-Tidy 在某些情况下可以提供建议的修复方法。代码作者随后可以通过点击按钮应用修复。
图 13-2 显示了给定 Java 输入文件的 Tricorder 分析结果的屏幕截图,呈现给代码审查人员。结果显示了两个警告,一个来自 Java linter,一个来自 Error Prone。Tricorder 通过允许代码审查人员通过“无用”链接对出现的警告提供反馈来衡量用户感知的误报率。Tricorder 团队使用这些信号来禁用个别检查。代码审查人员还可以向代码作者发送“请修复”个别警告的请求。
图 13-2:通过 Tricorder 提供的代码审查期间的静态分析结果的屏幕截图
图 13-3 显示了在代码审查期间由 Error Prone 建议的自动应用的代码更改。
图 13-3:来自图 13-2 的 Error Prone 警告的预览修复视图的屏幕截图
抽象解释
基于抽象解释的工具静态地执行程序行为的语义分析。这种技术已成功用于验证关键安全软件,如飞行控制软件。考虑一个简单的例子,一个程序生成 10 个最小的正偶数。在正常执行期间,程序生成整数值 2、4、6、8、10、12、14、16、18 和 20。为了允许对这样一个程序进行高效的静态分析,我们希望使用一个紧凑的表示来总结所有可能的值,覆盖所有观察到的值。使用所谓的区间或范围域,我们可以代表所有观察到的值,使用抽象区间值[2, 20]。区间域允许静态分析器通过简单地记住最低和最高可能的值来高效地推理所有程序执行。
为了确保我们捕捉到所有可能的程序行为,重要的是用抽象表示覆盖所有观察到的值。然而,这种方法也引入了可能导致不精确或错误警告的近似。例如,如果我们想要保证实际程序永远不会产生值 11,使用整数域的分析将导致一个错误的阳性。
利用抽象解释的静态分析器通常为每个程序点计算一个抽象值。为此,它们依赖于程序的控制流图(CFG)表示。CFG 在编译器优化和静态分析程序中通常被使用。CFG 中的每个节点代表程序中的一个基本块,对应于按顺序执行的程序语句序列。也就是说,在这个语句序列中没有跳转,也没有跳转目标在序列中间。CFG 中的边代表程序中的控制流,其中跳转发生在程序内部的控制流(例如,由于if语句或循环结构)或由于函数调用的程序间的控制流。请注意,CFG 表示也被覆盖引导的模糊器(之前讨论过)使用。例如,libFuzzer 跟踪在模糊过程中覆盖的基本块和边。模糊器使用这些信息来决定是否考虑将输入用于未来的变异。
基于抽象解释的工具执行关于程序中数据流和控制流的语义分析,通常跨越函数调用。因此,它们的运行时间比之前讨论的自动化代码检查工具要长得多。虽然你可以将自动化代码检查工具集成到交互式开发环境中,比如代码编辑器,但抽象解释通常不会被类似地集成。相反,开发人员可能会偶尔(比如每晚)在提交的代码上运行基于抽象解释的工具,或者在差异设置中进行代码审查,分析只有改变的代码,同时重用未改变的代码的分析事实。
许多工具依赖于抽象解释来处理各种语言和属性。例如,Frama-C 工具允许您查找 C 语言程序中的常见运行时错误和断言违规,包括缓冲区溢出、由于悬空或空指针导致的分段错误以及除零。正如之前讨论的那样,这些类型的错误,特别是与内存相关的错误,可能具有安全影响。Infer 工具推理程序执行的内存和指针更改,并可以在 Java、C 和其他语言中找到悬空指针等错误。AbsInt 工具可以对实时系统中任务的最坏执行时间进行分析。应用安全改进(ASI)程序对上传到 Google Play 商店的每个 Android 应用进行复杂的过程间分析,以确保安全性。如果发现漏洞,ASI 会标记漏洞并提出解决问题的建议。图 13-4 显示了一个样本安全警报。截至 2019 年初,该程序已导致 Play 商店中超过 300,000 个应用开发者修复了超过 100 万个应用。
图 13-4:应用安全改进警报
形式化方法
形式化方法允许用户指定对软件或硬件系统感兴趣的属性。其中大多数是所谓的安全属性,指定某种不良行为不应该被观察到。例如,“不良行为”可以包括程序中的断言。其他包括活性属性,允许用户指定期望的结果,例如提交的打印作业最终由打印机处理。形式化方法的用户可以验证特定系统或模型的这些属性,甚至使用基于正确构造的方法开发这些系统。正如“分析不变量”中所强调的,基于形式化方法的方法通常具有相对较高的前期成本。这部分是因为这些方法需要对系统要求和感兴趣的属性进行先验描述。这些要求必须以数学严谨和形式化的方式进行规定。
基于形式化方法的技术已成功整合到硬件设计和验证工具中。¹¹在硬件设计中,使用电子设计自动化(EDA)供应商提供的形式化或半形式化工具现在是标准做法。这些技术也已成功应用于专门领域的软件,如安全关键系统或加密协议分析。例如,基于形式化方法的方法不断分析计算机网络通信中 TLS 使用的加密协议。¹²
结论
测试软件的安全性和可靠性是一个广泛的话题,我们只是触及了表面。本章介绍的测试策略,结合编写安全代码的实践(参见第十二章),对帮助谷歌团队可靠扩展、最小化停机时间和安全问题起到了关键作用。从开发的最早阶段就考虑可测试性,并在整个开发生命周期中进行全面测试是非常重要的。
在这一点上,我们要强调将所有这些测试和分析方法完全整合到您的工程工作流程和 CI/CD 流水线中的价值。通过在整个代码库中一致地结合和定期使用这些技术,您可以更快地识别错误。您还将提高在部署应用程序时检测或预防错误的能力,这是下一章将涵盖的主题。
-
1 我们建议查看SRE 书的第十七章,以获得一个以可靠性为重点的视角。
-
2 Petrović,Goran 和 Marko Ivanković。2018 年。“Google 的突变测试现状。” 第 40 届国际软件工程大会论文集:163-171。doi:10.1145/3183519.3183521。
-
3 有关在 Google 遇到的常见单元测试陷阱的更多讨论,请参阅 Wright,Hyrum 和 Titus Winters。2015 年。“你所有的测试都很糟糕:来自前线的故事。” CppCon 2015。https://oreil.ly/idleN。
-
4 请参阅SRE 工作手册的第二章。
-
5 模糊目标比较 OpenSSL 内部两个模块化指数实现的结果,如果结果有任何不同,将会失败。
-
6 例如,参见 Bozzano,Marco 等人。2017 年。“航空航天系统的形式方法。”在从架构分析视角设计物理系统,由 Shin Nakajima,Jean-Pierre Talpin,Masumi Toyoshima 和 Huafeng Yu 编辑。新加坡:Springer。
-
7 您可以使用标准软件包管理器安装 Clang-Tidy。通常称为 clang-tidy。
-
8 请参阅 Sadowski,Caitlin 等人。2018 年。“在 Google 构建静态分析工具的经验教训。” ACM 通讯 61(4):58-66。doi:10.1145/3188720。
-
9 请参阅 Cousot,Patrick 和 Radhia Cousot。1976 年。“程序动态属性的静态确定。” 第 2 届国际编程研讨会论文集:106-130。https://oreil.ly/4xLgB。
-
10 Souyris,Jean 等人。2009 年。“航空电子软件产品的形式验证。” 第 2 届形式方法世界会议论文集:532-546。doi:10.1007/978-3-642-05089-3_34。
-
11 例如,参见 Kern,Christoph 和 Mark R. Greenstreet。1999 年。“硬件设计中的形式验证:一项调查。” ACM 电子系统设计交易 4(2):123-193。doi:10.1145/307988.307989。另请参见 Hunt Jr.等人。2017 年。“使用 ACL2 进行工业硬件和软件验证。” 皇家学会哲学交易 A 数学物理和工程科学 375(2104):20150399。doi:10.1098/rsta.2015.0399。
-
12 请参阅 Chudnov,Andrey 等人。2018 年。“Amazon s2n 的持续形式验证。” 第 30 届国际计算机辅助验证会议论文集:430-446。doi:10.1007/978-3-319-96142-2_26。