第十四章:部署代码

71 阅读1小时+

原文:14. Deploying Code

译者:飞龙

协议:CC BY-NC-SA 4.0

作者:Jeremiah Spradlin 和 Mark Lodato

与 Sergey Simakov 和 Roxana Loza

在编写和测试代码时,前几章讨论了如何考虑安全性和可靠性。然而,直到代码构建和部署之后,该代码才会产生真正的影响。因此,对构建和部署过程的所有元素仔细考虑安全性和可靠性非常重要。仅通过检查构件本身很难确定部署的构件是否安全。软件供应链各个阶段的控制可以增加您对软件构件安全性的信心。例如,代码审查可以减少错误的机会,并阻止对手进行恶意更改,自动化测试可以增加您对代码操作正确性的信心。

围绕源代码、构建和测试基础设施构建的控制具有有限的效果,如果对手可以通过直接部署到您的系统来绕过它们。因此,系统应拒绝不是来自正确软件供应链的部署。为了满足这一要求,供应链中的每个步骤都必须能够提供其已正确执行的证据。

概念和术语

我们使用术语软件供应链来描述编写、构建、测试和部署软件系统的过程。这些步骤包括版本控制系统(VCS)、持续集成(CI)流水线和持续交付(CD)流水线的典型责任。

尽管实现细节在公司和团队之间有所不同,但大多数组织都有一个类似于图 14-1 的流程:

  1. 代码必须检入版本控制系统。

  2. 然后从检入的版本构建代码。

  3. 一旦构建完成,二进制文件必须经过测试。

  4. 然后将代码部署到某个环境中,进行配置和执行。

典型软件供应链的高层视图

图 14-1:典型软件供应链的高层视图

即使您的供应链比这个模型更复杂,您通常也可以将其分解为这些基本构建块。图 14-2 显示了典型部署流水线如何执行这些步骤的具体示例。

您应该设计软件供应链以减轻对系统的威胁。本章重点介绍了如何减轻内部人员(或恶意攻击者冒充内部人员)在第二章中定义的威胁,而不考虑内部人员是否具有恶意意图。例如,一个善意的工程师可能无意中构建了包含未经审查和未提交更改的代码,或者外部攻击者可能尝试使用受损工程师帐户的权限部署带有后门的二进制文件。我们同样考虑这两种情况。

在本章中,我们对软件供应链的步骤进行了广泛定义。

构建是将输入构件转换为输出构件的任何过程,其中构件是任何数据片段,例如文件、软件包、Git 提交或虚拟机(VM)镜像。测试是构建的特殊情况,其中输出构件是一些逻辑结果,通常是“通过”或“失败”,而不是文件或可执行文件。

典型的云托管基于容器的服务部署

图 14-2:典型的云托管基于容器的服务部署

构建可以链接在一起,并且一个构件可以经历多次测试。例如,发布过程可能首先从源代码“构建”二进制文件,然后从二进制文件“构建”Docker 镜像,然后通过在开发环境中运行 Docker 镜像来“测试”Docker 镜像。

部署是将某个构件分配到某个环境的任何过程。您可以将以下每个过程视为部署:

  • 推送代码:

  • 发布命令以导致服务器下载并运行新的二进制文件

  • 更新 Kubernetes 部署对象以使用新的 Docker 镜像

  • 启动虚拟机或物理机,加载初始软件或固件

  • 更新配置:

  • 运行 SQL 命令来更改数据库模式

  • 更新 Kubernetes 部署对象以更改命令行标志

  • 发布一个包或其他数据,将被其他用户使用:

  • 上传 deb 包到 apt 仓库

  • 上传 Docker 镜像到容器注册表

  • 上传 APK 到 Google Play 商店

本章不包括部署后更改。

威胁模型

在加固软件供应链以缓解威胁之前,您必须确定您的对手。在本讨论中,我们将考虑以下三种对手类型。根据您的系统和组织,您的对手清单可能会有所不同:

  • 可能犯错误的良性内部人员

  • 试图获得比其角色允许的更多访问权限的恶意内部人员

  • 外部攻击者入侵一个或多个内部人员的机器或帐户

第二章描述了攻击者的配置文件,并提供了针对内部风险建模的指导。

接下来,您必须像攻击者一样思考,尝试识别对手可以颠覆软件供应链以威胁您系统的所有方式。以下是一些常见威胁的例子;您应该根据您组织的具体威胁来调整这个清单。为了简单起见,我们使用术语工程师来指代良性内部人员,恶意对手来指代恶意内部人员和外部攻击者:

  • 工程师提交了一个意外引入系统漏洞的更改。

  • 恶意对手提交了一个启用后门或引入系统其他有意漏洞的更改。

  • 工程师意外地从包含未经审查的更改的本地修改版本的代码构建。

  • 工程师部署了一个带有有害配置的二进制文件。例如,更改启用了仅用于测试的生产调试功能。

  • 恶意对手部署了一个修改过的二进制文件到生产环境,开始窃取客户凭据。

  • 恶意对手修改了云存储桶的 ACL,允许他们窃取数据。

  • 恶意对手窃取用于签署软件的完整性密钥。

  • 工程师部署了一个带有已知漏洞的旧版本代码。

  • CI 系统配置错误,允许从任意源代码库构建请求。因此,恶意对手可以从包含恶意代码的源代码库构建。

  • 恶意对手上传一个自定义的构建脚本到 CI 系统,窃取签名密钥。然后对手使用该密钥对恶意二进制文件进行签名和部署。

  • 恶意对手欺骗 CD 系统使用带有后门的编译器或构建工具来生成恶意二进制文件。

一旦您编制了一个潜在对手和威胁的全面清单,您可以将您已经采取的缓解措施与您识别出的威胁进行映射。您还应该记录当前缓解策略的任何限制。这个练习将为您的系统中潜在风险提供一个全面的图片。没有相应缓解措施的威胁,或者现有缓解措施存在重大限制的威胁,都是需要改进的领域。

最佳实践

以下最佳实践可以帮助您缓解威胁,在您的威胁模型中填补任何安全漏洞,并持续改进您的软件供应链的安全性。

需要代码审查

代码审查是在提交或部署更改之前,让第二个人(或几个人)审查源代码的更改的做法。除了提高代码安全性外,代码审查还为软件项目提供了多种好处:它们促进知识共享和教育,灌输编码规范,提高代码可读性,减少错误,所有这些有助于建立安全和可靠的文化(有关这个想法的更多信息,请参见第二十一章)。

从安全的角度来看,代码审查是一种多方授权的形式,这意味着没有个人有权利单独提交更改。正如第五章中所描述的,多方授权提供了许多安全好处。

要成功实现,代码审查必须是强制性的。如果对手可以选择退出审查,那么他们将不会被阻止!审查还必须足够全面,以捕捉问题。审阅者必须理解任何更改的细节及其对系统的影响,或者向作者询问澄清问题,否则该过程可能会变得形式化。

许多公开可用的工具允许您实现强制性的代码审查。例如,您可以配置 GitHub、GitLab 或 BitBucket,要求每个拉取/合并请求都需要一定数量的批准。或者,您可以使用独立的审查系统,如 Gerrit 或 Phabricator,结合一个配置为只接受来自该审查系统的推送的源代码库。

从安全的角度来看,代码审查在安全方面存在一些限制,正如第十二章中所述。因此,最好将其作为“深度防御”安全措施之一,与自动化测试(在第十三章中描述)和第十二章中的建议一起实现。

依赖自动化

理想情况下,自动化系统应该执行软件供应链中的大部分步骤。自动化提供了许多优势。它可以为构建、测试和部署软件提供一致、可重复的流程。将人类从循环中移除有助于防止错误并减少劳动。当您在一个封闭的系统上运行软件供应链自动化时,您可以使系统免受恶意对手的颠覆。

考虑一个假设的场景,工程师根据需要在他们的工作站上手动构建“生产”二进制文件。这种情况会产生许多引入错误的机会。工程师可能会意外地从错误的代码版本构建,或者包含未经审查或未经测试的代码更改。同时,恶意对手,包括已经攻破工程师机器的外部攻击者,可能会故意用恶意版本覆盖本地构建的二进制文件。自动化可以防止这两种结果。

以安全的方式添加自动化可能会有些棘手,因为自动化系统本身可能会引入其他安全漏洞。为了避免最常见的漏洞类别,我们建议至少采取以下措施:

将所有构建、测试和部署步骤移至自动化系统。

至少,您应该编写所有步骤的脚本。这样可以让人类和自动化执行相同的步骤以保持一致性。您可以使用 CI/CD 系统(如Jenkins)来实现这一目的。考虑制定一个要求所有新项目都需要自动化的政策,因为将自动化应用到现有系统中通常是具有挑战性的。

软件供应链的所有配置更改都需要同行审查。

通常,将配置视为代码(如前所述)是实现这一目标的最佳方式。通过要求审查,您大大减少了出错和错误的机会,并增加了恶意攻击的成本。

锁定自动化系统,防止管理员或用户篡改。

这是最具挑战性的一步,实现细节超出了本章的范围。简而言之,考虑管理员可以在没有审查的情况下进行更改的所有路径——例如,通过直接配置 CI/CD 管道或使用 SSH 在机器上运行命令进行更改。对于每条路径,考虑采取措施以防止未经同行审查的访问。

有关锁定自动化构建系统的进一步建议,请参阅“可验证构建”。

自动化是双赢,减少了繁重的工作,同时增加了可靠性和安全性。尽可能依赖自动化!

验证构件,而不仅仅是人

如果对手可以绕过源、构建和测试基础设施的控制,直接部署到生产环境,那么这些控制的效果就会受到限制。仅仅验证发起了部署是不够的,因为该行为者可能会犯错误,或者可能是有意部署了恶意更改。相反,部署环境应该验证正在部署的内容。

部署环境应该要求证明部署过程的每个自动化步骤都已发生。除非有其他缓解控制检查该操作,否则人类不应能够绕过自动化。例如,如果您在 Google Kubernetes Engine(GKE)上运行,您可以使用二进制授权默认接受仅由您的 CI/CD 系统签名的镜像,并监视 Kubernetes 集群审计日志,以获取有人使用紧急功能部署不符合规定的镜像时的通知。

这种方法的一个局限性是,它假设您设置的所有组件都是安全的:即 CI/CD 系统仅接受允许在生产环境中使用的源的构建请求,如果使用签名密钥,则只能由 CI/CD 系统访问,等等。“高级缓解策略”描述了一种更健壮的方法,直接验证所需属性,减少了隐含的假设。

将配置视为代码

服务的配置对于安全性和可靠性同样至关重要。因此,关于代码版本控制和更改审查的所有最佳实践也适用于配置。将配置视为代码,要求在部署之前对配置更改进行检入、审查和测试,就像对任何其他更改一样。

举个例子:假设您的前端服务器有一个配置选项来指定后端。如果有人将您的生产前端指向后端的测试版本,那么您将面临严重的安全和可靠性问题。

或者,作为一个更实际的例子,考虑一个使用 Kubernetes 并将配置存储在版本控制下的YAML文件的系统。部署过程调用kubectl二进制文件并传递 YAML 文件,部署经过批准的配置。限制部署过程仅使用“经过批准”的 YAML——来自版本控制并需要同行审查的 YAML——使得误配置服务变得更加困难。

您可以重复使用本章推荐的所有控件和最佳实践,以保护您服务的配置。重用这些方法通常比其他方法更容易,后者通常需要完全独立的多方授权系统来保护部署后的配置更改。

版本控制和审查配置的做法并不像代码版本控制和审查那样普遍。即使实现了配置即代码的组织通常也不会对配置应用代码级别的严格要求。例如,工程师通常知道他们不应该从本地修改的源代码构建生产版本的二进制文件。这些工程师可能在未将更改保存到版本控制并征求审查的情况下部署配置更改。

实现配置即代码需要改变你的文化、工具和流程。在文化上,你需要重视审查流程。在技术上,你需要工具,允许你轻松比较提议的更改(即diffgrep),并提供在紧急情况下手动覆盖更改的能力。

防范威胁模型

现在我们已经定义了一些最佳实践,我们可以将这些流程映射到我们之前确定的威胁上。在评估这些流程与您特定的威胁模型相关时,问问自己:所有最佳实践都是必要的吗?它们是否足以缓解所有威胁?表 14-1 列出了示例威胁,以及它们对应的缓解措施和这些缓解措施的潜在限制。

表 14-1. 示例威胁、缓解措施和缓解措施的潜在限制

威胁缓解限制
一个工程师提交了一个意外引入系统漏洞的更改。代码审查加自动化测试(见第十三章)。这种方法显著减少了错误的机会。
一个恶意的对手提交了一个改变,使系统启用了后门或引入了其他有意的漏洞。代码审查。这种做法增加了攻击的成本和检测的机会——对手必须仔细制定更改以通过代码审查。不能防止串通或外部攻击者能够 compromise 多个内部账户。

一个工程师意外地从包含未经审查的更改的本地修改版本的代码构建。一个自动化的 CI/CD 系统总是从正确的源代码库中拉取执行构建。

一个工程师部署了一个有害的配置。例如,更改启用了仅用于测试的生产环境中的调试功能。将配置视为源代码,并要求进行相同级别的同行审查。并非所有配置都可以被视为“代码”。

一个恶意的对手部署了一个修改后的二进制文件到生产环境,开始窃取客户凭据。生产环境需要证明 CI/CD 系统构建了二进制文件。CI/CD 系统配置为只从正确的源代码库中拉取源代码。对手可能会想出如何绕过这一要求,使用紧急部署 breakglass 程序。充分的日志记录和审计可以缓解这种可能性。

一个恶意的对手修改了云存储桶的 ACL,使他们能够窃取数据。考虑资源 ACL 作为配置。云存储桶只允许部署过程进行配置更改,因此人类无法进行更改。不能防止串通或外部攻击者能够 compromise 多个内部账户。

一个恶意的对手窃取了用于签署软件的完整性密钥。将完整性密钥存储在一个密钥管理系统中,该系统配置为只允许 CI/CD 系统访问密钥,并支持密钥轮换。有关更多信息,请参见第九章。有关构建特定的建议,请参见“高级缓解策略”。

图 14-3 显示了一个更新的软件供应链,其中包括前面表中列出的威胁和缓解措施。

典型的软件供应链-对手不应能够绕过流程

图 14-3:典型的软件供应链-对手不应能够绕过流程

我们尚未将几个威胁与最佳实践中的缓解措施相匹配:

  • 工程师部署了一个带有已知漏洞的旧版本代码。

  • CI 系统配置错误,允许从任意源代码仓库构建请求。因此,恶意对手可以从包含恶意代码的源代码仓库构建。

  • 一个恶意对手上传了一个自定义的构建脚本到 CI 系统,用于窃取签名密钥。然后对手使用该密钥对恶意二进制文件进行签名和部署。

  • 一个恶意对手欺骗 CD 系统使用一个带有后门的编译器或构建工具来生成恶意二进制文件。

为了解决这些威胁,您需要实现更多的控制,我们将在下一节中介绍。只有您才能决定是否值得为您特定的组织解决这些威胁。

深入探讨:高级缓解策略

您可能需要复杂的缓解措施来解决软件供应链中一些更高级的威胁。因为本节中的建议在行业内尚未成为标准,您可能需要构建一些自定义基础设施来采用这些建议。这些建议最适合规模大和/或特别安全敏感的组织,对于暴露于内部风险较低的小型组织可能没有意义。

二进制来源

每次构建都应该生成描述给定二进制工件是如何构建的“二进制来源”:输入、转换和执行构建的实体。

为了解释原因,考虑以下激励性例子。假设您正在调查一个安全事件,并且看到在特定时间窗口内发生了部署。您想确定部署是否与该事件有关。逆向工程二进制将成本过高。检查源代码将更容易得多,最好是查看版本控制中的更改。但是您如何知道二进制来自哪个源代码?

即使您不预期需要这些类型的安全调查,您也需要基于来源的二进制来源来制定基于来源的部署策略,如本节后面所讨论的。

二进制来源中应包含的内容

您应该在来源中包含的确切信息取决于您的系统内置的假设和最终需要来源的消费者的信息。为了实现丰富的部署策略并允许临时分析,我们建议以下来源字段:

真实性(必需)

暗示了构建的隐式信息,例如产生它的系统以及您为何可以信任来源。这通常是通过使用加密签名来保护二进制来源的其他字段来实现的。¹¹

输出(必需)

适用于此二进制来源的输出工件。通常,每个输出都由工件内容的加密哈希标识。

输入

构建中的内容。此字段允许验证者将源代码的属性与工件的属性进行关联。它应包括以下内容:

来源

构建的“主”输入工件,例如顶层构建命令运行的源代码树。例如:“来自https://github.com/mysql/mysql-server的 Git 提交270f...ce6d”¹²或“文件foo.tar.gz的 SHA-256 内容78c5...6649。”

依赖

构建所需的所有其他工件,如库、构建工具和编译器,这些工件在源代码中没有完全指定。这些输入都可能影响构建的完整性。

命令

用于启动构建的命令。例如:“bazel build //main:hello-world”。理想情况下,该字段应结构化以允许自动化分析,因此我们的示例可能变为“{"bazel": {"command": "build", "target": "//main:hello_world"}}”。

环境

需要重现构建的任何其他信息,例如架构细节或环境变量。

输入元数据

在某些情况下,构建者可能会读取有关下游系统将发现有用的输入的元数据。例如,构建者可能包括源提交的时间戳,然后策略评估系统在部署时使用。

调试信息

任何不必要用于安全性但可能对调试有用的额外信息,例如构建运行的机器。

版本控制

构建时间戳和溯源格式版本号通常很有用,以便进行将来的更改,例如使旧构建无效或更改格式而不易受到回滚攻击。

您可以省略隐含或由源代码本身覆盖的字段。例如,Debian 的溯源格式省略了构建命令,因为该命令始终是dpkg-buildpackage

输入工件通常应列出标识符(例如 URI)和版本(例如加密哈希)。通常使用标识符来验证构建的真实性,例如验证代码是否来自正确的源代码库。版本对于各种目的都很有用,例如临时分析、确保可重现的构建以及验证链式构建步骤,其中步骤i的输出是步骤i+1 的输入。

注意攻击面。您需要验证构建系统未检查的任何内容(因此由签名隐含)或包含在源代码中(因此经过同行审查)的内容。如果启动构建的用户可以指定任意编译器标志,则验证器必须验证这些标志。例如,GCC 的-D标志允许用户覆盖任意符号,因此也可以完全更改二进制文件的行为。同样,如果用户可以指定自定义编译器,则验证器必须确保使用了“正确”的编译器。一般来说,构建过程可以执行的验证越多,越好。

有关二进制溯源的一个很好的例子,请参见 Debian 的deb-buildinfo格式。有关更一般的建议,请参见可重现构建项目的文档。要签名和编码此信息的标准方法,请考虑JSON Web Tokens(JWT)

基于溯源的部署策略

“验证工件,而不仅仅是人”建议官方构建自动化流水线应该验证正在部署的内容。如何验证流水线配置正确?如果您想为某些部署环境提供特定的保证,而这些保证不适用于其他环境,该怎么办?

您可以使用明确的部署策略来描述每个部署环境的预期属性,以解决这些问题。然后,部署环境可以将这些策略与部署到它们的工件的二进制溯源进行匹配。

这种方法比纯粹基于签名的方法有几个优点:

  • 它减少了软件供应链中隐含的假设数量,使分析和确保正确性变得更容易。

  • 它澄清了软件供应链中每个步骤的合同,减少了配置错误的可能性。

  • 它允许您在每个构建步骤中使用单个签名密钥,而不是每个部署环境,因为现在您可以使用二进制溯源进行部署决策。

例如,假设您有一个微服务架构,并希望保证每个微服务只能从提交到该微服务源存储库的代码构建。使用代码签名,您需要每个源存储库一个密钥,并且 CI/CD 系统需要根据源存储库选择正确的签名密钥。这种方法的缺点是很难验证 CI/CD 系统的配置是否符合这些要求。

使用基于溯源的部署策略,CI/CD 系统生成了二进制溯源,说明了源存储库,总是由单一密钥签名。每个微服务的部署策略列出了允许的源存储库。与代码签名相比,正确性的验证要容易得多,因为部署策略在一个地方描述了每个微服务的属性。

部署策略中列出的规则应该减轻对系统的威胁。参考您为系统创建的威胁模型。您可以定义哪些规则来减轻这些威胁?例如,以下是一些您可能想要实现的示例规则:

  • 源代码已提交到版本控制并经过同行审查。

  • 源代码来自特定位置,比如特定的构建目标和存储库。

  • 构建是通过官方的 CI/CD 流水线进行的(参见“可验证构建”)。

  • 测试已通过。

  • 二进制文件在此部署环境中明确允许。例如,不要在生产环境中允许“测试”二进制文件。

  • 代码或构建的版本足够新。¹³

  • 代码不包含已知的漏洞,如最近的安全扫描所报告的。¹⁴

in-toto 框架提供了实现溯源策略的一个标准。

实现政策决策

如果您为基于溯源的部署策略实现自己的引擎,请记住需要三个步骤:

  1. 验证溯源的真实性。这一步还隐式地验证了溯源的完整性,防止对手篡改或伪造它。通常,这意味着验证溯源是否由特定密钥进行了加密签名。

  2. 验证溯源是否适用于构件。这一步还隐式地验证了构件的完整性,确保对手不能将一个“好”的溯源应用于一个“坏”的构件。通常,这意味着比较构件的加密哈希与溯源有效负载中找到的值。

  3. 验证溯源是否符合所有策略规则

这个过程的最简单的例子是一个规则,要求构件必须由特定密钥签名。这个单一的检查实现了所有三个步骤:它验证了签名本身是否有效,构件是否适用于签名,以及签名是否存在。

让我们考虑一个更复杂的例子:“Docker 镜像必须从 GitHub 存储库mysql/mysql-server构建。”假设您的构建系统使用密钥*K[B]*以 JWT 格式签署构建溯源。在这种情况下,令牌有效负载的模式将如下所示,其中主题subRFC 6920 URI

{
  "sub": "ni:///sha-256;...",
  "input": {"source_uri": "..."}
}

要评估构件是否符合此规则,引擎需要验证以下内容:

  1. JWT 签名使用密钥*K[B]*进行验证。

  2. sub 匹配了构件的 SHA-256 哈希。

  3. input.source_uri 正好是"https://github.com/mysql/mysql-server"

可验证构建

我们称构建为可验证的,如果构建产生的二进制溯源是可信的。¹⁵ 可验证性取决于观察者。您是否信任特定的构建系统取决于您的威胁模型以及构建系统如何融入您组织更大的安全故事。

考虑以下非功能性需求示例是否适合你的组织,¹⁶并添加符合你特定需求的任何需求:

  • 如果单个开发者的工作站受到损害,二进制出处或输出物品的完整性不会受到损害。

  • 对手无法在不被察觉的情况下篡改出处或输出物品。

  • 一个构建不能影响另一个构建的完整性,无论是并行运行还是串行运行。

  • 构建不能产生包含错误信息的出处。例如,出处不应该声称一个物品是从 Git 提交abc...def构建的,而实际上是来自123...456

  • 非管理员不能配置用户定义的构建步骤,比如 Makefile 或 Jenkins Groovy 脚本,以违反此列表中的任何要求。

  • 在构建后至少N个月内,所有源物品的快照都可以用于潜在的调查。

  • 构建是可复现的(参见“隔离的、可复现的或可验证的?”)。即使在可验证的构建架构中没有要求,这种方法也可能是可取的。例如,在发现安全事件或漏洞后,可复现的构建可能有助于独立重新验证物品的二进制出处。

可验证的构建架构

可验证构建系统的目的是增加验证者对构建系统产生的二进制出处的信任。无论可验证性的具体要求如何,都有三种主要的架构可供选择:

可信的构建服务

验证者要求原始构建是由验证者信任的构建服务执行的。通常,这意味着可信的构建服务用只有该服务才能访问的密钥对二进制出处进行签名。

这种方法的优点是只需要构建一次,不需要可复现性(参见“隔离的、可复现的或可验证的?”)。Google 在内部构建中使用这种模型。

你自己进行的重建

验证者在飞行中重现构建,以验证二进制出处。例如,如果二进制出处声称来自 Git 提交abc...def,验证者会获取该 Git 提交,重新运行二进制出处中列出的构建命令,并检查输出是否与问题物品完全相同。有关可复现性的更多信息,请参见下面的侧边栏。

虽然这种方法可能一开始看起来很吸引人,因为你信任自己,但它并不具备可扩展性。构建通常需要几分钟甚至几小时,而部署决策通常需要在毫秒内做出。这还要求构建是完全可复现的,这并不总是切实可行;有关更多信息,请参见侧边栏。

重建服务

验证者要求“重建者”中的一些“重建者”已经重现了构建并证明了二进制出处的真实性。这是前两种选项的混合体。在实践中,这种方法通常意味着每个重建者监视一个软件包存储库,主动重建每个新版本,并将结果存储在某个数据库中。然后,验证者在N个不同的数据库中查找条目,这些条目以问题物品的加密哈希为键。当中央管理模式不可行或不可取时,像Debian这样的开源项目使用这种模型。

实现可验证的构建

无论可验证的构建服务是“可信的构建服务”还是“重建服务”,都应该牢记一些重要的设计考虑。

基本上,几乎所有的 CI/CD 系统都按照图 14-4 中的步骤运行:服务接收请求,获取任何必要的输入,执行构建,并将输出写入存储系统。

一个基本的 CI/CD 系统

图 14-4:一个基本的 CI/CD 系统

有了这样的系统,您可以相对容易地向输出添加签名的来源,如图 14-5 所示。对于一个采用“中央构建服务”模型的小型组织来说,这个额外的签名步骤可能足以解决安全问题。

向现有的 CI/CD 系统添加签名

图 14-5:向现有的 CI/CD 系统添加签名

随着您的组织规模的增长和您有更多的资源投入到安全中,您可能希望解决另外两个安全风险:不受信任的输入和未经身份验证的输入。

不受信任的输入

对手可能使用构建的输入来颠覆构建过程。许多构建服务允许非管理员用户定义在构建过程中执行的任意命令,例如通过 Jenkinsfile、travis.yml、Makefile 或BUILD。从安全的角度来看,这种功能实际上是“远程代码执行(RCE)设计”。在特权环境中运行的恶意构建命令可以执行以下操作:

  • 窃取签名密钥。

  • 在来源中插入错误信息。

  • 修改系统状态,影响后续构建。

  • 操纵另一个同时进行的构建。

即使用户不被允许定义自己的步骤,编译是一个非常复杂的操作,提供了充分的机会进行 RCE 漏洞。

您可以通过特权分离来减轻这种威胁。使用一个受信任的编排器进程来设置初始的已知良好状态,启动构建,并在构建完成时创建签名的来源。可选地,编排器可以获取输入以解决下一小节中描述的威胁。所有用户定义的构建命令应在另一个环境中执行,该环境无法访问签名密钥或任何其他特权。您可以通过各种方式创建这个环境,例如通过与编排器相同的机器上的沙盒,或者在单独的机器上运行。

未经身份验证的输入

即使用户和构建步骤是可信的,大多数构建都依赖于其他工件。任何这样的依赖都是对手可能潜在颠覆构建的表面。例如,如果构建系统在没有 TLS 的情况下通过 HTTP 获取依赖项,攻击者可以进行中间人攻击以修改传输中的依赖项。

因此,我们建议使用隔离构建(参见“隔离的、可重现的或可验证的?”)。构建过程应该提前声明所有输入,只有编排器应该获取这些输入。隔离构建大大提高了在来源中列出的输入是正确的信心。

一旦您考虑了不受信任和未经身份验证的输入,您的系统就类似于图 14-6。这样的模型比图 14-5 中的简单模型更抵抗攻击。

一个“理想”的 CI/CD 设计,解决了不受信任和未经身份验证输入的风险

图 14-6:解决不受信任和未经身份验证输入风险的“理想”CI/CD 设计

部署阻塞点

要“验证工件,而不仅仅是人”,部署决策必须发生在部署环境内的适当阻塞点。在这种情况下,“阻塞点”是所有部署请求必须流经的点。对手可以绕过不在阻塞点发生的部署决策。

以 Kubernetes 为例,设置部署瓶颈,如图 14-7 所示。假设您想要验证特定 Kubernetes 集群中所有部署到 pod 的部署。主节点将成为一个良好的瓶颈,因为所有部署都应该通过它进行。为了使其成为一个合适的瓶颈,配置工作节点只接受来自主节点的请求。这样,对手就无法直接部署到工作节点。

Kubernetes 架构-所有部署必须通过主节点

图 14-7:Kubernetes 架构-所有部署必须通过主节点

理想情况下,瓶颈执行策略决策,可以直接执行,也可以通过 RPC 执行。Kubernetes 为此目的提供了准入控制器webhook。如果您使用 Google Kubernetes Engine,二进制授权提供了一个托管的准入控制器和许多其他功能。即使您不使用 Kubernetes,您也可以修改“准入”点以执行部署决策。

或者,您可以在瓶颈前放置一个“代理”,并在代理中执行策略决策,如图 14-8 所示。这种方法需要配置您的“准入”点,只允许通过代理访问。否则,对手可以通过直接与准入点通信来绕过代理。

使用代理进行策略决策的替代架构

图 14-8:使用代理进行策略决策的替代架构

部署后验证

即使在部署时执行部署策略或签名检查时,记录和部署后验证几乎总是可取的,原因如下:

  • 策略可能会更改,在这种情况下,验证引擎必须重新评估系统中现有的部署,以确保它们仍然符合新的策略。这在首次启用策略时尤为重要。

  • 由于决策服务不可用,请求可能已被允许继续进行。这种故障开放设计通常是必要的,以确保服务的可用性,特别是在首次推出执行功能时。

  • 在紧急情况下,操作员可能使用了紧急开关机制来绕过决策,如下一节所述。

  • 用户需要一种方法,在提交之前测试潜在的策略更改,以确保现有状态不会违反新版本的策略。

  • 出于类似于“故障开放”用例的原因,用户可能还希望有一种干预运行模式,在部署时系统始终允许请求,但监控会发现潜在问题。

  • 调查人员可能需要在事故发生后出于取证目的获取信息。

执行决策点必须记录足够的信息,以便验证器在部署后评估策略。²⁰通常需要记录完整的请求,但并不总是足够的-如果策略评估需要一些其他状态,则日志必须包括该额外状态。例如,当我们为 Borg 实现部署后验证时遇到了这个问题:因为“作业”请求包括对现有“分配”和“包”引用,我们必须连接三个日志来源-作业,分配和包-以获取做出决策所需的完整状态。²¹

实用建议

多年来,在各种情境中实现可验证的构建和部署策略时,我们学到了一些经验教训。这些经验教训大多与实际技术选择无关,而更多地与如何部署可靠、易于调试和易于理解的更改有关。本节包含一些建议,希望您会发现有用。

逐步进行

提供高度安全、可靠和一致的软件供应链可能需要您进行许多更改,从编写构建步骤到实现构建来源,再到实现配置即代码。协调所有这些更改可能很困难。这些控件中的错误或缺失功能也可能对工程生产力构成重大风险。在最坏的情况下,这些控件中的错误可能导致服务中断。

如果您一次专注于保护供应链的一个特定方面,可能会更成功。这样,您可以最大程度地减少中断风险,同时还可以帮助同事学习新的工作流程。

提供可操作的错误消息

当部署被拒绝时,错误消息必须清楚地解释出了什么问题以及如何解决。例如,如果工件被拒绝是因为从错误的源 URI 构建的,解决方法可以是更新策略以允许该 URI,或者从正确的 URI 重新构建。您的策略决策引擎应该给用户提供可操作的反馈,提供这样的建议。简单地说“不符合策略”可能会让用户感到困惑和手足无措。

在设计架构和策略语言时,请考虑这些用户旅程。一些设计选择会使为用户提供可操作的反馈变得非常困难,因此请尽早发现这些问题。例如,我们早期的策略语言原型提供了许多表达策略的灵活性,但阻止我们提供可操作的错误消息。我们最终放弃了这种方法,转而采用了一种非常有限的语言,可以提供更好的错误消息。

确保来源清晰

谷歌的可验证构建系统最初将二进制来源异步上传到数据库。然后在部署时,策略引擎使用工件的哈希作为键在数据库中查找来源。

虽然这种方法大多运行良好,但我们遇到了一个主要问题:用户可以多次构建工件,导致相同哈希的多个条目。考虑空文件的情况:我们有数百万条与空文件的哈希相关的来源记录,因为许多不同的构建生成了空文件作为其输出的一部分。为了验证这样的文件,我们的系统必须检查任何来源记录是否符合策略。这反过来又导致了两个问题:

  • 当我们未能找到通过记录时,我们无法提供可操作的错误消息。例如,我们不得不说,“源 URI 是X,但策略应该是Y”,而不是“这 497,129 条记录中没有一条符合策略”。这是糟糕的用户体验。

  • 验证时间与返回的记录数量成正比。这导致我们的延迟 SLO 超出了 100 毫秒数倍!

我们还遇到了与数据库的异步上传问题。上传可能会悄无声息地失败,这种情况下我们的策略引擎会拒绝部署。与此同时,用户不明白为什么被拒绝。我们本可以通过使上传同步来解决这个问题,但这种解决方案会使我们的构建系统不太可靠。

因此,我们强烈建议使来源清晰。在可能的情况下,避免使用数据库,而是内联传播来源与工件。这样做可以使整个系统更可靠,延迟更低,更易于调试。例如,使用 Kubernetes 的系统可以添加一个传递给 Admission Controller webhook 的注释。

创建明确的策略

与我们推荐的工件来源的方法类似,适用于特定部署的策略应该是明确的。我们建议设计系统,以便任何给定的部署只适用一个策略。考虑另一种选择:如果有两个策略适用,那么两个策略都需要通过吗,还是只需要一个策略通过?最好完全避免这个问题。如果您想在整个组织中应用全局策略,可以将其作为元策略实现:实现一个检查,以确保所有个体策略符合一些全局标准。

包括部署 breakglass

在紧急情况下,可能需要绕过部署策略。例如,工程师可能需要重新配置前端以将流量从失败的后端转移,相应的配置即代码更改可能需要通过常规 CI/CD 管道部署太长时间。绕过策略的 breakglass 机制可以让工程师快速解决故障,并促进安全和可靠性的文化(见第二十一章)。

由于对手可能利用 breakglass 机制,所有 breakglass 部署必须迅速引发警报并进行审计。为了使审计变得实用,breakglass 事件应该是罕见的——如果事件太多,可能无法区分恶意活动和合法使用。

重新审视威胁模型的安全防护

现在我们可以将高级缓解措施映射到以前未解决的威胁,如表 14-2 所示。

表 14-2. 复杂威胁示例的高级缓解措施

威胁缓解
工程师部署了一个存在已知漏洞的旧版本代码。部署策略要求代码在过去的N天内进行了安全漏洞扫描。
CI 系统配置错误,允许从任意源代码库构建请求。结果,恶意对手可以从包含恶意代码的源代码库构建。CI 系统生成描述其拉取源代码库的二进制来源。生产环境强制执行部署策略,要求来源证明部署的工件来自批准的源代码库。
恶意对手向 CI 系统上传自定义构建脚本,窃取签名密钥。然后对手使用该密钥签名和部署恶意二进制文件。可验证构建系统分离权限,以便运行自定义构建脚本的组件无法访问签名密钥。
恶意对手欺骗 CD 系统使用带有后门的编译器或构建工具生成恶意二进制文件。严格构建要求开发人员在源代码中明确指定编译器和构建工具的选择。这个选择像所有其他代码一样经过同行评审。

通过在软件供应链周围采取适当的安全控制,您可以缓解甚至是高级和复杂的威胁。

结论

本章的建议可以帮助您加强软件供应链对各种内部威胁的防范。代码审查和自动化是防止错误和增加恶意行为攻击成本的基本策略。代码作为配置将这些好处扩展到传统上受到比代码更少关注的配置。同时,基于工件的部署控制,特别是涉及二进制来源和可验证构建的控制,可以提供对复杂对手的保护,并允许您随着组织的增长而扩展。

7.这些建议有助于确保您编写和测试的代码(遵循第十二章和第十三章的原则)实际上是部署在生产环境中的代码。然而,尽管您已经尽力,您的代码可能并不总是按预期行为。当发生这种情况时,您可以使用下一章介绍的一些调试策略。

18.(1)代码审查也适用于对配置文件的更改;请参阅“将配置视为代码”。

11.(2)Sadowski, Caitlin 等人。2018 年。“现代代码审查:谷歌的案例研究。”第 40 届国际软件工程大会论文集:181-190。doi:10.1145/3183519.3183525。

2.(3)当与配置即代码和本章描述的部署策略结合时,代码审查构成了任意系统的多方授权系统的基础。

1.(4)有关代码审查者的责任,请参阅“审查文化”

3.(5)步骤的“链”不一定需要完全自动。例如,通常可以接受人类能够启动构建或部署步骤。但是,人类不应该能够以任何有意义的方式影响该步骤的行为。

16.(6)尽管如此,这样的授权检查对于最小特权原则仍然是必要的(请参阅第五章)。

10.(7)紧急开关机制可以绕过策略,允许工程师快速解决故障。请参阅“紧急开关”

4.(8)这个概念在SRE 书籍第八章和 SRE workbook 的第十四章和第十五章中有更详细的讨论。所有这些章节中的建议都适用于这里。

12.(9)YAML 是 Kubernetes 使用的配置语言

5.(10)您必须记录和审计这些手动覆盖,以防对手使用手动覆盖作为攻击向量。

15.(11)请注意,真实性意味着完整性。

6.(12)Git 提交 ID 是提供整个源代码树完整性的加密哈希。

8.(13)有关回滚到有漏洞版本的讨论,请参阅“最低可接受的安全版本号”

13.(14)例如,您可能需要证明Cloud Security Scanner在运行此特定代码版本的测试实例时未发现任何结果。

14.(15)请记住,纯签名仍然算作“二进制来源”,如前一节所述。

17.(16)请参阅“设计目标和要求”

9.(17)例如,SRE 书籍将“hermetic”和“reproducible”这两个术语互换使用。Reproducible Builds 项目将“reproducible”定义为本章定义的方式,但有时也会将“reproducible”重载为“可验证”。

¹⁸ 作为一个反例,考虑一个构建过程,在构建过程中获取依赖的最新版本,但在其他方面产生相同的输出。只要两次构建大致同时发生,这个过程就是可重现的,但不是完全隔离的。

¹⁹ 实际上,必须有一种方式将软件部署到节点本身——引导加载程序、操作系统、Kubernetes 软件等等——而且该部署机制必须有自己的策略执行,这很可能是与用于 pod 的实现完全不同的实现。

²⁰ 理想情况下,日志是非常可靠和防篡改的,即使在停机或系统受到威胁的情况下也是如此。例如,假设 Kubernetes 主节点在日志后端不可用时接收到一个请求。主节点可以暂时将日志保存到本地磁盘。如果机器在日志后端恢复之前死机了怎么办?或者如果机器的空间用完了怎么办?这是一个具有挑战性的领域,我们仍在开发解决方案。

²¹ Borg 分配(简称 分配)是机器上一组保留的资源,其中可以在容器中运行一个或多个 Linux 进程集。软件包包含 Borg 作业的二进制文件和数据文件。有关 Borg 的完整描述,请参见 Verma, Abhishek 等人 2015 年的文章《Google 的 Borg 大规模集群管理》。第 10 届欧洲计算机系统会议论文集:1–17。doi:10.1145/2741948.2741964。

第十五章:调查系统

原文:google.github.io/building-se…

译者:飞龙

协议:CC BY-NC-SA 4.0

Pete Nuttall、Matt Linton 和 David Seidman

与 Vera Haas、Julie Saracino 和 Amaya Booker

在理想的世界中,我们都会构建完美的系统,我们的用户只会怀有最好的意图。然而,在现实中,您会遇到错误,并需要进行安全调查。当您观察生产中运行的系统时,您会确定需要改进的地方,以及可以简化和优化流程的地方。所有这些任务都需要调试和调查技术,以及适当的系统访问。

然而,即使是只读调试访问也会带来滥用的风险。为了解决这一风险,您需要适当的安全机制。您还需要在开发人员和运营人员的调试需求与存储和访问敏感数据的安全要求之间取得谨慎的平衡。

在本章中,我们使用术语调试器来指代调试软件问题的人类,而不是GDB(GNU 调试器)或类似的工具。除非另有说明,我们使用“我们”一词来指代本章的作者,而不是整个谷歌公司。

从调试到调查

“我完全意识到,我余生的很大一部分时间将花在查找自己程序中的错误上。”

——Maurice Wilkes,《计算机先驱的回忆录》(麻省理工学院出版社,1985 年)

调试声誉不佳。错误总是在最糟糕的时候出现。很难估计错误何时会被修复,或者何时系统会“足够好”让许多人使用。对大多数人来说,编写新代码比调试现有程序更有趣。调试可能被认为是没有回报的。然而,它是必要的,当通过学习新的事实和工具的视角来看待时,你甚至可能会发现这种实践是令人愉快的。根据我们的经验,调试也使我们成为更好的程序员,并提醒我们有时我们并不像我们认为的那么聪明。

示例:临时文件

考虑以下停机事件,我们(作者)在两年前调试过。[1]调查开始时,我们收到了一个警报,称Spanner 数据库的存储配额即将用完。我们经历了调试过程,问自己以下问题:

  1. 是什么导致数据库存储空间不足?

快速分诊表明,问题是由谷歌庞大的分布式文件系统Colossus中创建了许多小文件积累导致的,这可能是由用户请求流量的变化触发的。

  1. 是什么在创建所有这些小文件?

我们查看了服务指标,显示这些文件是 Spanner 服务器内存不足导致的。根据正常行为,最近的写入(更新)被缓存在内存中;当服务器内存不足时,它将数据刷新到 Colossus 上的文件中。不幸的是,Spanner 区中的每台服务器只有少量内存来容纳更新。因此,与其刷新可管理数量的较大、压缩的文件,[2]每台服务器都会刷新许多小文件到 Colossus。

  1. 内存使用在哪里?

每个服务器都作为一个 Borg 任务(在容器中)运行,这限制了可用的内存。[3]为了确定内核内存的使用位置,我们直接在生产机器上发出了slabtop命令。我们确定目录条目(dentry)缓存是内存的最大使用者。

  1. 为什么 dentry 缓存这么大?

我们做出了一个合理的猜测,即 Spanner 数据库服务器正在创建和删除大量临时文件——每次刷新操作都会有一些。每次刷新操作都会增加 dentry 缓存的大小,使问题变得更糟。

  1. 我们如何确认我们的假设?

为了验证这个理论,我们在 Borg 上创建并运行了一个程序,通过在循环中创建和删除文件来复现这个错误。几百万个文件后,dentry 缓存已经使用了容器中的所有内存,证实了假设。

  1. 这是一个内核 bug 吗?

我们研究了 Linux 内核的预期行为,并确定内核缓存了文件的不存在——一些构建系统需要这个特性来确保可接受的性能。在正常操作中,当容器满时,内核会从 dentry 缓存中驱逐条目。然而,由于 Spanner 服务器反复刷新更新,容器从未变得足够满以触发驱逐。我们通过指定临时文件不需要被缓存来解决了这个问题。

这里描述的调试过程展示了我们在本章讨论的许多概念。然而,这个故事最重要的收获是我们调试了这个问题—而你也可以!解决和修复问题并不需要任何魔法;它只需要缓慢和有条理的调查。要分解我们调查的特征:

  • 在系统显示出退化迹象后,我们使用现有的日志和监控基础设施调试了这个问题。

  • 我们能够调试这个问题,即使它发生在内核空间和调试人员以前没有见过的代码中。

  • 我们在这次停机之前从未注意到这个问题,尽管它可能已经存在了几年。

  • 系统的任何部分都没有损坏。所有部分都按预期工作。

  • Spanner 服务器的开发人员惊讶地发现,临时文件在文件被删除后仍然可以消耗内存。

  • 我们能够通过使用内核开发人员提供的工具来调试内核的内存使用情况。尽管我们以前从未使用过这些工具,但由于我们在调试技术上接受了培训并且有丰富的实践经验,我们能够相对快速地取得进展。

  • 我们最初误诊了错误为用户错误。只有在检查了我们的数据后,我们才改变了主意。

  • 通过提出假设,然后创建一种测试我们理论的方法,我们在引入系统更改之前确认了根本原因。

调试技术

本节分享了一些系统化调试技术。(4) 调试是一种可以学习和实践的技能。SRE 书的第十二章提供了成功调试的两个要求:

  • 了解系统应该如何工作。

  • 要有系统性:收集数据,假设原因,测试理论。

第一个要求更加棘手。以一个由单个开发人员构建的系统为例,突然离开公司,带走了对系统的所有了解。系统可能会继续工作数月,但有一天它神秘地崩溃了,没有人能够修复它。接下来的一些建议可能有所帮助,但事先了解系统是没有真正替代品的(参见第六章)。

区分马和斑马

当你听到马蹄声时,你首先想到的是马还是斑马?教师有时会向学习如何分诊和诊断疾病的医学生提出这个问题。这是一个提醒,大多数疾病是常见的——大多数马蹄声是由马引起的,而不是斑马。你可以想象为什么这对医学生是有帮助的建议:他们不想假设症状会导致罕见疾病,而实际上这种情况是常见的,而且很容易治疗。

相比之下,经验丰富的工程师在足够大的规模下会观察到常见的罕见的事件。构建计算机系统的人可以(也必须)努力完全消除所有问题。随着系统规模的增长,运营商随着时间消除了常见问题,罕见问题出现得更频繁。引用Bryan Cantrill的话:“随着时间的推移,马被找到了;只有斑马留下了。”

考虑一种非常罕见的内存损坏问题:位翻转引起的内存损坏。现代纠错内存模块每年遇到无法纠正的位翻转的概率不到 1%⁵。一位工程师调试一个意外的崩溃可能不会想到,“我打赌这是由于内存芯片中极不可能的电气故障引起的!”然而,在非常大的规模下,这些罕见事件变得确定。一个假设的云服务使用 25,000 台机器,可能使用 400,000 个 RAM 芯片的内存。考虑到每个芯片每年发生无法纠正错误的风险不到 0.1%,服务的规模可能导致每年发生 400 次。运行云服务的人可能每天都会观察到内存故障。

调试这些罕见事件可能具有挑战性,但通过正确类型的数据是可以实现的。举个例子,谷歌的硬件工程师曾经注意到某些 RAM 芯片的故障率远远超出预期。资产数据使他们能够追踪故障 DIMM(内存模块)的来源,并且他们能够将这些模块追溯到单个供应商。经过大量的调试和调查,工程师们确定了根本原因:在生产 DIMM 的单个工厂的无尘室中发生了环境故障。这个问题是一个“斑马”——一个只在规模上可见的罕见错误。

随着服务的增长,今天的奇怪异常错误可能会成为明年的常见错误。在 2000 年,谷歌对内存硬件损坏感到惊讶。如今,这样的硬件故障是常见的,我们通过端到端的完整性检查和其他可靠性措施来计划处理它们。

近年来,我们遇到了一些其他罕见事件:

  • 两个网络搜索请求散列到相同的 64 位缓存密钥,导致一个请求的结果被用来替代另一个请求。

  • C++将int64转换为int(只有 32 位),导致在 2³²个请求后出现问题(有关此错误的更多信息,请参见“清理代码”)。

  • 分布式再平衡算法中的一个错误只有在代码同时在数百台服务器上运行时才会触发。

  • 有人让负载测试运行了一个星期,导致性能逐渐下降。我们最终确定机器逐渐出现了内存分配问题,导致了性能下降。我们发现了这个罕见的错误,因为一个通常寿命较短的测试运行时间比正常情况下长得多。

  • 调查缓慢的 C++测试表明,动态链接器的加载时间与加载的共享库数量呈超线性关系:在加载了 10,000 个共享库时,启动运行main可能需要几分钟。

在处理较小、较新的系统时,预计会出现常见的错误。在处理较老、较大和相对稳定的系统时,预计会出现罕见的错误——操作员可能已经观察到并修复了随时间出现的常见错误。问题更有可能在系统的新部分出现。

为调试和调查留出时间

安全调查(稍后讨论)和调试通常需要很长时间,需要连续数小时的工作。前一节中描述的临时文件情况需要 5 到 10 小时的调试。在运行重大事件时,通过将调试人员和调查人员与逐分钟的响应隔离开来,为他们提供专注的空间。

调试奖励缓慢、系统的、持久的方法,人们在这种方法中会反复检查他们的工作和假设,并愿意深入挖掘。临时文件问题也提供了调试的一个负面例子:最初的第一响应者最初诊断停机是由用户流量引起的,并将系统行为不佳归咎于用户。当时,团队处于运营超载状态,并因非紧急页面而感到疲劳。

注意

SRE 工作手册的第十七章讨论了减少运营超载。SRE 书的第十一章建议每班次将工单和寻呼器数量控制在两个以下,以便工程师有时间深入研究问题。

记录你的观察和期望

写下你所看到的。另外,写下你的理论,即使你已经拒绝了它们。这样做有几个优点:

  • 这为调查引入了结构,并帮助你记住调查过程中所采取的步骤。当你开始调试时,你不知道解决问题需要多长时间——解决可能需要五分钟或五个月。

  • 另一个调试器可以阅读你的笔记,了解你观察到的情况,并快速参与或接管调查。你的笔记可以帮助队友避免重复工作,并可能激发其他人想到新的调查途径。有关这个主题的更多信息,请参见SRE 书的第十二章中的“负面结果是魔术”。

  • 在潜在的安全问题的情况下,保留每次访问和调查步骤的日志可能是有帮助的。以后,你可能需要证明(有时是在法庭上)哪些行动是攻击者执行的,哪些是调查人员执行的。

在你写下你观察到的内容之后,写下你期望观察到的内容以及原因。错误经常隐藏在你对系统的心理模型和实际实现之间的空间中。在临时文件的例子中,开发人员假设删除文件会删除所有对它的引用。

了解你的系统的正常情况

通常,调试人员开始调试实际上是预期的系统行为。以下是我们经验中的一些例子:

  • 一个名为abort的二进制文件在其shutdown代码的末尾。新开发人员在日志中看到了abort调用,并开始调试该调用,没有注意到有趣的故障实际上是shutdown调用的原因。

  • 当 Chrome 网页浏览器启动时,它会尝试解析三个随机域名(比如cegzaukxwefark.local)以确定网络是否在非法篡改 DNS。甚至 Google 自己的调查团队也曾将这些 DNS 解析误认为是恶意软件试图解析命令和控制服务器主机名。

调试器经常需要过滤掉这些正常事件,即使这些事件看起来与问题相关或可疑。安全调查人员面临的额外问题是持续的背景噪音和可能试图隐藏行动的积极对手。你通常需要过滤掉常规的嘈杂活动,比如自动 SSH 登录暴力破解、用户输错密码导致的认证错误以及端口扫描,然后才能观察到更严重的问题。

了解正常系统行为的一种方法是在你不怀疑有任何问题时建立系统行为的基线。如果你已经有了问题,你可以通过检查问题发生之前的历史日志来推断你的基线。

例如,在第一章中,我们描述了由于对通用日志库的更改而导致的全球 YouTube 中断。这个更改导致服务器耗尽内存(OOM)并失败。由于该库在 Google 内部被广泛使用,我们在事后调查中质疑这次中断是否影响了所有其他 Borg 任务的 OOM 数量。虽然日志表明那天我们有很多 OOM 条件,但我们能够将这些数据与前两周的数据基线进行比较,结果显示 Google 每天都有很多 OOM 条件。尽管这个错误很严重,但它并没有对 Borg 任务的 OOM 指标产生实质性影响。

注意不要将最佳实践的偏差标准化。通常,错误会随着时间变成“正常行为”,您不再注意到它们。例如,我们曾经在一台服务器上工作,其堆内存碎片化达到了约 10%。经过多年的断言,约 10%是预期的,因此是可以接受的损失量,我们检查了碎片化配置文件,很快发现了节省内存的重大机会。

运营超载和警报疲劳可能导致您产生盲点,并因此标准化偏差。为了解决标准化偏差,我们积极倾听团队中的新人,并为了促进新的视角,我们轮换人员参与值班轮换和响应团队——编写文档和向他人解释系统的过程也可能促使您质疑自己对系统的了解程度。此外,我们使用红队(见第二十章)来测试我们的盲点。

重现错误

如果可能的话,尝试在生产环境之外重现错误。这种方法有两个主要优点:

  • 您不会影响为实际用户提供服务的系统,因此可以随意使系统崩溃并损坏数据。

  • 因为您没有暴露任何敏感数据,所以您可以在不引起数据安全问题的情况下让许多人参与调查。您还可以启用与实际用户数据不适当的操作和额外日志记录等功能。

有时,在生产环境之外进行调试是不可行的。也许错误只会在规模上触发,或者您无法隔离其触发器。临时文件示例就是这样一种情况:我们无法通过完整的服务堆栈重现错误。

隔离问题

如果您能重现问题,下一步是隔离问题,理想情况下是将问题隔离到仍然出现问题的代码最小子集。您可以通过禁用组件或临时注释子程序来做到这一点,直到问题显现出来。

在临时文件示例中,一旦我们观察到所有服务器上的内存管理表现出异常,我们就不再需要在每台受影响的机器上调试所有组件。再举一个例子,考虑一个单独的服务器(在一个大型系统集群中)突然开始引入高延迟或错误。这种情况是对您的监控、日志和其他可观察性系统的标准测试:您能否快速找到系统中众多服务器中的一个坏服务器?有关更多信息,请参见“卡住时该怎么办”。

您还可以在代码内部隔离问题。举一个具体的例子,我们最近调查了一个具有非常有限内存预算的程序的内存使用情况。特别是,我们检查了线程堆栈的内存映射。尽管我们的心理模型假设所有线程具有相同的堆栈大小,但令我们惊讶的是,我们发现不同的线程堆栈有许多不同的大小。一些堆栈非常大,有可能消耗大量内存预算。最初的调试范围包括内核、glibc、Google 的线程库以及所有启动线程的代码。基于 glibc 的pthread_create的一个简单示例创建了相同大小的线程堆栈,因此我们可以排除内核和 glibc 作为不同大小的来源。然后我们检查了启动线程的代码,发现许多库只是随机选择了线程大小,解释了大小的变化。这种理解使我们能够通过专注于少数具有大堆栈的线程来节省内存。

谨慎对待相关性与因果关系

有时调试器会假设同时开始的两个事件或表现出类似症状的事件具有相同的根本原因。然而,相关性并不总是意味着因果关系。两个平凡的问题可能同时发生,但有不同的根本原因。

有些相关性是微不足道的。例如,延迟增加可能导致用户请求减少,仅仅是因为用户等待系统响应的时间更长。如果一个团队反复发现事后看来微不足道的相关性,可能是因为他们对系统应该如何工作的理解存在差距。在临时文件的例子中,如果你知道删除文件失败会导致磁盘满,你就不会对这种相关性感到惊讶。

然而,我们的经验表明,调查相关性通常是有用的,特别是在停机开始时发生的相关性。通过思考,“X出问题了,Y出问题了,Z出问题了;这三者之间有什么共同点?”你可以找到可能的原因。我们还在基于相关性的工具上取得了一些成功。例如,我们部署了一个系统,可以自动将机器问题与机器上运行的 Borg 任务进行相关联。因此,我们经常可以确定一个可疑的 Borg 任务导致了广泛的问题。这种自动化工具产生的相关性比人类观察更有效、统计学上更强大,而且更快。

错误也可能在部署过程中显现——参见SRE 书中的第十二章。在简单的情况下,正在部署的新代码可能存在问题,但部署也可能触发旧系统中的潜在错误。在这些情况下,调试可能错误地集中在正在部署的新代码上,而不是潜在的问题。系统性的调查——确定发生了什么,以及为什么——在这些情况下是有帮助的。我们曾经见过的一个例子是,旧代码的性能比新代码差得多,这导致了对整个系统的意外限制。当其性能改善时,系统的其他部分反而过载。停机与新部署相关联,但部署并不是根本原因。

用实际数据测试你的假设

在调试时,很容易在实际查看系统之前就对问题的根本原因进行推测。在处理性能问题时,这种倾向会引入盲点,因为问题通常出现在调试人员长时间没有查看的代码中。例如,有一次我们在调试一个运行缓慢的 Web 服务器。我们假设问题出在后端,但分析器显示,将每一点可能的输入记录到磁盘,然后调用sync导致了大量的延迟。只有当我们放下最初的假设并深入研究系统时,我们才发现了这一点。

可观察性是通过检查系统的输出来确定系统正在做什么的属性。像DapperZipkin这样的追踪解决方案对这种调试非常有用。调试会话从基本问题开始,比如,“你能找到一个慢的 Dapper 跟踪吗?”⁶

注意

对于初学者来说,确定最适合工作的工具,甚至了解存在哪些工具可能是具有挑战性的。Brendan Gregg 的《系统性能》(Prentice Hall,2013)提供了全面的工具和技术介绍,是性能调试的绝佳参考。

重新阅读文档

考虑来自Python 文档的以下指导:

比较运算符之间没有暗示的关系。x==y的真实性并不意味着x!=y是假的。因此,在定义__eq__()时,应该同时定义__ne__(),以便运算符的行为符合预期。

最近,谷歌团队花了大量时间调试内部仪表板优化。当他们陷入困境时,团队重新阅读了文档,并发现了一个明确的警告消息,解释了为什么优化从未起作用。人们习惯了仪表板的性能缓慢,他们没有注意到优化完全无效。⁷最初,这个错误似乎很显著;团队认为这是 Python 本身的问题。在他们找到警告消息后,他们确定这不是斑马,而是马——他们的代码从未起作用。

练习!

调试技能只有经常使用才能保持新鲜。通过熟悉相关工具和日志,您可以加快调查的速度,并将我们在这里提供的提示保持在脑海中。定期练习调试还提供了机会来脚本化过程中常见和乏味的部分,例如自动化检查日志。要提高调试能力(或保持敏锐),请练习,并保留在调试会话期间编写的代码。

在谷歌,我们通过定期的大规模灾难恢复测试(称为 DiRT,或灾难恢复测试计划)⁸和安全渗透测试(见第十六章)来正式练习调试。规模较小的测试,涉及一两名工程师在一个小时内进行测试,更容易设置,但仍然非常有价值。

当你陷入困境时该怎么办

当您调查一个问题已经几天了,仍然不知道问题的原因时,您应该怎么办?也许它只在生产环境中表现出来,而且您无法在不影响实际用户的情况下重现错误。也许在缓解问题时,日志轮转时丢失了重要的调试信息。也许问题的性质阻止了有用的日志记录。我们曾经调试过一个问题,其中一个内存容器耗尽了 RAM,内核为容器中的所有进程发出了 SIGKILL,停止了所有日志记录。没有日志,我们无法调试这个问题。

在这些情况下的一个关键策略是改进调试过程。有时,使用开发事后总结的方法(如第十八章中所述)可能会提出前进的方法。许多系统已经投入生产多年甚至几十年,因此改进调试的努力几乎总是值得的。本节描述了改进调试方法的一些途径。

提高可观察性

有时,您需要查看一些代码在做什么。这段代码分支被使用了吗?这个函数被使用了吗?这个数据结构可能很大吗?这个后端在 99th 百分位数上很慢吗?这个查询使用了哪些后端?在这些情况下,您需要更好地了解系统。

在某些情况下,像添加更多结构化日志以提高可观察性的方法是直接的。我们曾经调查过一个系统,监控显示它服务了太多的 404 错误,⁹但是 Web 服务器没有记录这些错误。在为 Web 服务器添加额外的日志记录后,我们发现恶意软件试图从系统中获取错误文件。

其他调试改进需要严肃的工程努力。例如,调试像Bigtable这样的复杂系统需要复杂的仪器。Bigtable 主节点是 Bigtable 区域的中央协调器。它在 RAM 中存储服务器和平板的列表,并且几个互斥体保护这些关键部分。随着谷歌的 Bigtable 部署随着时间的推移而增长,Bigtable 主节点和这些互斥体成为了扩展瓶颈。为了更好地了解可能的问题,我们实现了一个包装器,围绕互斥体暴露诸如队列深度和互斥体持有时间等统计信息。

像 Dapper 和 Zipkin 这样的追踪解决方案对于这种复杂的调试非常有用。例如,假设您有一个 RPC 树,前端调用一个服务器,服务器调用另一个服务器,依此类推。每个 RPC 树在根处分配了一个唯一的 ID。然后,每个服务器记录有关其接收的 RPC、发送的 RPC 等的跟踪。Dapper 集中收集所有跟踪,并通过 ID 将它们连接起来。这样,调试器可以看到用户请求所触及的所有后端。我们发现 Dapper 对于理解分布式系统中的延迟至关重要。同样,Google 在几乎每个二进制文件中嵌入了一个简单的 Web 服务器,以便提供对每个二进制文件行为的可见性。该服务器具有调试端点,提供计数器、所有运行线程的符号化转储、正在进行的 RPC 等。有关更多信息,请参阅 Henderson(2017)。

注意

可观察性并不是了解系统的替代品。它也不是调试时批判性思维的替代品(遗憾!)。我们经常发现自己在疯狂地添加更多日志记录和计数器,以便了解系统正在做什么,但只有当我们退后一步并思考问题时,真正发生的情况才会变得清晰。

可观察性是一个庞大且快速发展的主题,它不仅对调试有用。如果您是一个开发资源有限的较小组织,可以考虑使用开源系统或购买第三方可观察性解决方案。

休息一下

与问题保持一定的距离往往会在回到问题时带来新的见解。如果您一直在进行调试并遇到停顿,请休息一下:喝点水,出去走走,锻炼一下,或者读一本书。有时候,好好睡一觉后,bug 会显露出来。我们的法医调查团队的一位资深工程师在团队的实验室里放了一把大提琴。当他真正陷入问题时,他通常会退到实验室 20 分钟左右来演奏;然后他重新充满活力和专注。另一位调查员随时保持一把吉他,其他人在桌子上保留着素描和涂鸦本,这样他们在需要进行心智调整时可以画画或制作一个愚蠢的动画 GIF 与团队分享。

还要确保保持良好的团队沟通。当您离开一会儿休息时,让团队知道您需要休息并且正在遵循最佳实践。记录调查的进展和您陷入困境的原因也是有帮助的。这样做可以使另一位调查员更容易接手您的工作,也可以让您回到离开的地方。第十七章有关于保持士气的更多建议。

清理代码

有时您怀疑代码块中存在 bug,但却看不到它。在这种情况下,试图通用地提高代码质量可能会有所帮助。正如本章前面提到的,我们曾经调试过一段代码,它在 2³²个请求后在生产环境中失败,因为 C++将int64转换为int(仅 32 位)并截断了它。尽管编译器可以使用-Wconversion警告您有关此类转换,但我们没有使用该警告,因为我们的代码有许多良性转换。清理代码使我们能够使用编译器警告来检测更多可能的 bug,并防止与转换相关的新 bug。

以下是一些清理的其他提示:

  • 提高单元测试覆盖率。针对您怀疑可能存在 bug 的函数,或者具有出现 bug 的记录的函数。 (有关更多信息,请参见第十三章。)

  • 对于并发程序,请使用消毒剂(参见“消毒您的代码”)和注释互斥锁

  • 改善错误处理。通常,围绕错误添加一些更多的上下文就足以暴露问题。

删除它!

有时候错误隐藏在传统系统中,特别是如果开发人员没有时间熟悉或保持熟悉代码库,或者维护已经中断。传统系统也可能受到损害或产生新的错误。与其调试或加固传统系统,不如考虑删除它。

删除传统系统也可以改善您的安全姿态。例如,有一位作者曾经通过谷歌的漏洞奖励计划(如第二十章中所述)被一位安全研究人员联系,后者在我们团队的一个传统系统中发现了一个安全问题。团队之前已经将该系统隔离到自己的网络中,但已经有一段时间没有升级该系统了。团队的新成员甚至不知道传统系统的存在。为了解决研究人员的发现,我们决定删除该系统。我们不再需要它提供的大部分功能,并且我们能够用一个更简单的现代系统来替换它。

注意

在重写传统系统时要慎重。问问自己,为什么您重写的系统会比传统系统做得更好。有时候,您可能想重写一个系统,因为添加新代码很有趣,而调试旧代码很乏味。替换系统有更好的理由:有时候系统的需求会发生变化,只需少量工作,您就可以删除旧系统。或者,也许您从第一个系统中学到了一些东西,并且可以将这些知识融入到第二个系统中,使其变得更好。

当事情开始出错时停下来

许多错误很难找到,因为错误源和其影响在系统中可能相距甚远。我们最近遇到了一个问题,网络设备正在破坏数百台机器的内部 DNS 响应。例如,程序将对机器exa1进行 DNS 查找,但收到的是exa2的地址。我们的两个系统对这个错误有不同的响应:

  • 一个系统,一个档案服务,会连接到exa2,错误的机器。然而,系统随后检查连接的机器是否是预期的机器。由于机器名称不匹配,档案服务作业失败。

  • 另一个收集机器指标的系统会从错误的机器exa2收集指标。然后,系统会在exa1上触发修复。我们只有在一名技术人员指出他们被要求修复一个没有五个磁盘的机器的第五个磁盘时才检测到这种行为。

在这两种响应中,我们更喜欢档案服务的行为。当系统中的问题及其影响相距甚远时,例如当网络导致应用程序级错误时,使应用程序失败可以防止下游影响(例如怀疑错误系统上的磁盘故障)。我们在第八章中更深入地讨论了是选择失败开放还是失败关闭的话题。

改进访问和授权控制,即使对于非敏感系统也是如此

“厨房里有太多厨师”是有可能的——也就是说,您可能会遇到许多人可能是错误源的调试情况,这使得难以隔离原因。我们曾经因为一个损坏的数据库行而导致了一次宕机,我们无法找到损坏数据的来源。为了消除有人可能会错误地写入生产数据库的可能性,我们最小化了具有访问权限的角色数量,并要求对任何人类访问进行理由说明。尽管数据并不敏感,但实现标准的安全系统帮助我们预防和调查未来的错误。幸运的是,我们还能够从备份中恢复该数据库行。

协作调试:一种教学方法

许多工程团队通过亲自(或通过视频会议)共同解决实际的实时问题来教授调试技术。除了保持经验丰富的调试者的技能更新外,协作调试还有助于为新团队成员建立心理安全感:他们有机会看到团队中最优秀的调试者遇到困难、后退或者有其他困难,这向他们表明,出错和遇到困难是可以接受的。有关安全教育的更多信息,请参见第二十一章。

我们发现以下规则优化了学习体验:

  • 只有两个人可以打开笔记本电脑:

  • “驱动者”,执行其他人要求的操作

  • “笔记记录者”

  • 每个行动都应由观众决定。只有驱动者和笔记记录者被允许使用计算机,但他们不确定采取的行动。这样,参与者不会独自进行调试,然后提出答案而不分享他们的思考过程和故障排除步骤。

团队共同确定要检查的一个或多个问题,但房间里没有人事先知道如何解决这些问题。每个人都可以要求驱动者执行一个操作来排除故障(例如,打开仪表板,查看日志,重新启动服务器等)。由于每个人都在场见证这些建议,每个人都可以了解参与者建议的工具和技术。即使是经验丰富的团队成员也会从这些练习中学到新东西。

正如 SRE 书的第 28 章所述,一些团队还使用“Wheel of Misfortune”模拟练习。这些练习可以是理论性的,通过口头解决问题的步骤,也可以是实际的,测试者在系统中引入故障。这些场景还涉及两种角色:

  • “测试者”,构建和呈现测试的人

  • “测试者”,试图解决问题,也许在队友的帮助下

一些团队更喜欢安全的分阶段练习环境,但实际的 Wheel of Misfortune 练习需要非常复杂的设置,而大多数系统总是有实时问题需要共同调试。无论采取何种方法,保持一个包容的学习环境非常重要,让每个人都感到安全,积极地做出贡献。

协作调试和 Wheel of Misfortune 练习是向团队介绍新技术和强化最佳实践的绝佳方式。人们可以看到这些技术在现实情况下的用处,通常是解决最棘手的问题。团队也可以一起练习调试问题,使他们在真正的危机发生时更加有效。

安全调查和调试的不同之处

我们期望每个工程师都能调试系统,但我们建议受过训练和有经验的安全和取证专家来调查系统的妥协。当“错误调查”和“安全问题”之间的界限不清晰时,两组专家团队之间有合作的机会。

错误调查通常在系统出现问题时开始。调查侧重于系统中发生了什么:发送了什么数据,这些数据发生了什么,服务如何开始与其意图相反。安全调查开始有点不同,并迅速转向问题,比如:提交了那份工作的用户在做什么?该用户还负责其他什么活动?我们的系统中有活跃的攻击者吗?攻击者接下来会做什么?简而言之,调试更加关注代码,而安全调查可能很快就会关注攻击背后的对手。

我们之前推荐的用于调试问题的步骤在安全调查期间可能也会适得其反。添加新代码、废弃系统等可能会产生意想不到的副作用。我们曾应对过许多事件,其中调试器删除了通常不属于系统的文件,希望解决错误行为,结果发现这些文件是攻击者引入的,因此提醒了调查。在一个案例中,攻击者甚至以删除整个系统的方式做出了回应!

一旦您怀疑发生了安全妥协,您的调查可能也会变得更加紧迫。系统被故意颠覆的可能性引发了一些看起来严肃而紧迫的问题。攻击者的目的是什么?还可能颠覆其他系统吗?您是否需要呼叫执法部门或监管机构?随着组织开始解决运营安全问题,安全调查的复杂性会逐渐增加(第十七章进一步讨论了这个话题)。其他团队的专家,如法律团队,可能会在您开始调查之前介入。简而言之,一旦您怀疑发生了安全妥协,现在是向安全专业人员寻求帮助的好时机。

决定何时停止调查并宣布发生了安全事件可能是一个困难的判断。许多工程师天生倾向于避免通过升级尚未被证明与安全相关的问题来“制造场面”,但继续调查直到证明可能是错误的举动。我们的建议是记住“马和斑马”的区别:绝大多数的错误实际上都是错误,而不是恶意行为。然而,同时也要警惕那些黑白相间的条纹迅速经过。

收集适当和有用的日志

在本质上,日志和系统崩溃转储都只是您可以收集的信息,以帮助您了解系统中发生了什么,并调查问题——无论是意外还是故意的。

在启动任何服务之前,重要的是考虑服务将代表用户存储的数据类型,以及访问数据的途径。假设任何导致数据或系统访问的行为都可能成为未来调查的范围,并且将需要对该行为进行审计。调查任何服务问题安全问题都严重依赖于日志。

我们这里讨论的“日志”是指系统中结构化的、带有时间戳的记录。在调查过程中,分析人员可能还会严重依赖其他数据源,如核心转储、内存转储或堆栈跟踪。我们建议尽量像处理日志一样处理这些系统。结构化日志对许多不同的业务目的都很有用,比如按使用量计费。然而,我们这里关注的是为安全调查收集的结构化日志——你现在需要收集的信息,以便在未来出现问题时可以使用。

设计您的日志记录为不可变

您构建用于收集日志的系统应该是不可变的。当日志条目被写入时,应该很难对其进行更改(但不是不可能;参见“考虑隐私”),并且更改应该有一个不可变的审计跟踪。攻击者通常会在建立牢固立足后立即从所有日志源中擦除其在系统上的活动痕迹。对抗这种策略的一个常见最佳做法是将日志远程写入集中和分布式的日志服务器。这增加了攻击者的工作量:除了攻击原始系统外,他们还必须攻击远程日志服务器。务必仔细加固日志系统。

在现代计算机时代之前,特别关键的服务器直接记录到连接的线打印机上,就像图 15-1 中的那个,它会在生成记录时将日志记录到纸上。为了抹去它们的痕迹,远程攻击者需要有人物理上取下打印机上的纸并将其烧掉!

线打印机

图 15-1:线打印机

考虑隐私

隐私保护功能的需求在系统设计中变得越来越重要。虽然隐私不是本书的重点,但在设计安全调查和调试日志时,您可能需要考虑当地法规和您组织的隐私政策。在这个话题上一定要与您组织内的隐私和法律同事进行咨询。以下是一些您可能想要讨论的话题:

日志深度

为了对任何调查最大限度地有用,日志需要尽可能完整。安全调查可能需要检查用户(或使用其帐户的攻击者)在系统内执行的每个操作,他们登录的主机以及事件发生的确切时间。就日志记录而言,达成组织政策上的一致意见,关于可以记录哪些信息是可以接受的,是很重要的,因为许多隐私保护技术都不鼓励在日志中保留敏感用户数据。

保留

对于某些调查,长时间保留日志可能是有益的。根据 2018 年的一项研究,大多数组织平均需要约200 天才能发现系统被入侵。谷歌的内部威胁调查依赖于可以追溯数年的操作系统安全日志。您组织内部关于保留日志的时间长度的讨论是很重要的。

访问和审计控制

我们推荐用于保护数据的许多控制措施也适用于日志。一定要像保护其他数据一样保护日志和元数据。请参阅第五章以获取相关策略。

数据匿名化或假名化

匿名化不必要的数据组件——无论是在写入时还是一段时间后——是一种越来越常见的隐私保护方法,用于处理日志。您甚至可以实现此功能,以便调查人员和调试人员无法确定给定用户是谁,但可以清楚地构建该用户在调试过程中的会话期间的时间线。匿名化很难做到。我们建议咨询隐私专家并阅读有关此话题的已发表文献。¹³

加密

您还可以使用数据的非对称加密来实现隐私保护的日志记录。这种加密方法非常适合保护日志数据:它使用一个非敏感的“公钥”,任何人都可以使用它来安全地写入数据,但需要一个秘密(私钥)来解密数据。像每日密钥对这样的设计选项可以让调试人员从最近的系统活动中获取小的日志数据子集,同时防止某人获取大量的日志数据。一定要仔细考虑如何存储密钥。

确定要保留哪些安全日志

尽管安全工程师通常更喜欢有太多的日志而不是太少的日志,但在记录和保留日志时要有所选择也是值得的。存储过多的日志可能会很昂贵(如“日志预算”中所讨论的),并且筛选过大的数据集可能会减慢调查人员的速度并使用大量资源。在本节中,我们讨论了一些您可能想要捕获和保留的日志类型。

操作系统日志

大多数现代操作系统都内置了日志记录功能。Windows 拥有 Windows 事件日志,而 Linux 和 Mac 拥有 syslog 和 auditd 日志。许多供应商提供的设备(如摄像头系统、环境控制和火警面板)也有标准操作系统,同时也会产生日志(如 Linux)在幕后。内置的日志框架对于调查非常有用,而且几乎不需要任何努力,因为它们通常默认启用或者很容易配置。一些机制,比如 auditd,出于性能原因默认情况下未启用,但在现实世界的使用中启用它们可能是可以接受的折衷方案。

主机代理

许多公司选择通过在工作站和服务器上安装主机入侵检测系统(HIDS)或主机代理来启用额外的日志记录功能。

现代(有时被称为“下一代”)主机代理使用旨在检测日益复杂威胁的创新技术。一些代理结合了系统和用户行为建模、机器学习和威胁情报,以识别以前未知的攻击。其他代理更专注于收集有关系统操作的额外数据,这对离线检测和调试活动很有用。一些代理,如OSQueryGRR,提供了对系统的实时可见性。

主机代理总是会影响性能,并且经常成为最终用户和 IT 团队之间摩擦的源头。一般来说,代理可以收集的数据越多,其性能影响可能就越大,因为它需要更深入的平台集成和更多的主机处理。一些代理作为内核的一部分运行,而另一些作为用户空间应用程序运行。内核代理具有更多功能,因此通常更有效,但它们可能会因为要跟上操作系统功能变化而遭受可靠性和性能问题。作为应用程序运行的代理更容易安装和配置,并且往往具有较少的兼容性问题。主机代理的价值和性能差异很大,因此我们建议在使用之前对主机代理进行彻底评估。

应用程序日志

日志记录应用程序——无论是供应商提供的,如 SAP 和 Microsoft SharePoint,还是开源的,或者是自定义的——都会生成您可以收集和分析的日志。然后,您可以使用这些日志进行自定义检测,并增强调查数据。例如,我们使用来自 Google Drive 的应用程序日志来确定受损计算机是否下载了敏感数据。

在开发自定义应用程序时,安全专家和开发人员之间的合作可以确保应用程序记录安全相关的操作,例如数据写入、所有权或状态的更改以及与帐户相关的活动。正如我们在“提高可观察性”中提到的,为日志记录仪器化您的应用程序也可以促进调试,以解决其他情况下难以排查的安全和可靠性问题。

云日志

越来越多的组织正在将其业务或 IT 流程的部分转移到基于云的服务,从软件即服务(SaaS)应用程序中的数据到运行关键客户端工作负载的虚拟机。所有这些服务都呈现出独特的攻击面,并生成独特的日志。例如,攻击者可以破坏云项目的帐户凭据,部署新的容器到项目的 Kubernetes 集群,并使用这些容器从集群的可访问存储桶中窃取数据。云计算模型通常每天启动新实例,这使得在云中检测威胁变得动态和复杂。

在检测可疑活动时,云服务具有优势和劣势。使用诸如 Google 的 BigQuery 之类的服务,收集和存储大量日志数据甚至在云中直接运行检测规则都很容易且相对便宜。Google 云服务还提供了内置的日志记录解决方案,如Cloud Audit LogsStackdriver Logging。另一方面,由于有许多种云服务,很难识别、启用和集中所有您需要的日志。由于开发人员很容易在云中创建新的 IT 资产,许多公司发现很难识别所有基于云的资产。云服务提供商还可能预先确定对您可用的日志,并且这些选项可能无法配置。了解您的提供商日志记录的限制以及您潜在的盲点非常重要。

各种商业软件,通常本身就基于云,旨在检测针对云服务的攻击。大多数成熟的云提供商提供集成的威胁检测服务,例如 Google 的事件威胁检测。许多公司将这些内置服务与内部开发的检测规则或第三方产品结合使用。

云访问安全代理(CASBs)是一类显着的检测和预防技术。CASBs 作为终端用户和云服务之间的中介,以强制执行安全控制并提供日志记录。例如,CASB 可能会阻止用户上传某些类型的文件,或记录用户下载的每个文件。许多 CASB 具有警报检测功能,可向检测团队发出关于潜在恶意访问的警报。您还可以将 CASB 的日志集成到自定义检测规则中。

基于网络的日志记录和检测

自 20 世纪 90 年代末以来,捕获和检查网络数据包的网络入侵检测系统(NIDSs)和入侵预防系统(IPSs)已成为常见的检测和记录技术。IPSs 还可以阻止一些攻击。例如,它们可能捕获有关哪些 IP 地址交换了流量以及有关该流量的有限信息,例如数据包大小。一些 IPSs 可能具有根据可定制标准记录某些数据包的整个内容的能力,例如发送到高风险系统的数据包。其他人还可以实时检测恶意活动并向适当的团队发送警报。由于这些系统非常有用且成本较低,我们强烈建议几乎任何组织使用它们。但是,请仔细考虑谁能有效地处理它们产生的警报。

DNS 查询日志也是有用的基于网络的来源。DNS 日志使您能够查看公司中是否有任何计算机解析了主机名。例如,您可能想查看网络上是否有任何主机对已知恶意主机名执行了 DNS 查询,或者您可能想检查先前解析的域以识别攻击者控制的每台机器访问的域。安全运营团队还可能使用 DNS“陷阱”,虚假解析已知恶意域,以便攻击者无法有效使用。然后,检测系统往往在用户访问陷阱域时触发高优先级警报。

您还可以使用用于内部或出口流量的任何网络代理的日志。例如,您可以使用网络代理扫描网页以查找钓鱼或已知漏洞模式的指示器。在使用代理进行检测时,您还需要考虑员工隐私,并与法律团队讨论使用代理日志。一般来说,我们建议尽可能将检测调整到恶意内容,以最小化您在处理警报时遇到的员工数据量。

日志预算

调试和调查活动会消耗资源。我们曾经处理过的一个系统有 100TB 的日志,其中大部分从未被使用过。由于日志记录消耗了大量资源,并且在没有问题的情况下日志通常被监视得较少,因此很容易在日志记录和调试基础设施上投资不足。为了避免这种情况,我们强烈建议您提前预算日志记录,考虑您可能需要多少数据来解决服务问题或安全事件。

现代日志系统通常整合了关系型数据系统(例如 Elasticsearch 或 BigQuery),以便实时快速地查询数据。该系统的成本随着需要存储和索引的事件数量、需要处理和查询数据的机器数量以及所需的存储空间而增长。因此,在长时间保留数据时,有必要优先考虑来自相关数据源的日志以进行长期存储。这是一个重要的权衡决定:如果攻击者擅长隐藏自己的行踪,可能需要相当长的时间才能发现发生了事件。如果只存储一周的访问日志,可能根本无法调查入侵事件!

我们还建议以下投资策略用于面向安全的日志收集:

  • 专注于具有良好信噪比的日志。例如,防火墙通常会阻止许多数据包,其中大部分是无害的。即使是被防火墙阻止的恶意数据包也可能不值得关注。收集这些被阻止的数据包的日志可能会消耗大量带宽和存储空间,但几乎没有任何好处。

  • 尽可能压缩日志。因为大多数日志包含大量重复的元数据,压缩通常非常有效。

  • 将存储分为“热”和“冷”。您可以将过去的日志转移到廉价的离线云存储(“冷存储”),同时保留与最近或已知事件相关的日志在本地服务器上以供立即使用(“热存储”)。同样,您可能会长时间存储压缩的原始日志,但只将最近的日志放入具有完整索引的昂贵关系型数据库中。

  • 智能地轮换日志。通常,最好首先删除最旧的日志,但您可能希望保留最重要的日志类型更长时间。

强大、安全的调试访问

要调试问题,通常需要访问系统和它们存储的数据。恶意或受损的调试器能否看到敏感信息?安全系统的故障(记住:所有系统都会出现故障!)能否得到解决?您需要确保您的调试系统是可靠和安全的。

可靠性

日志记录是系统可能出现故障的另一种方式。例如,系统可能会因为磁盘空间不足而无法存储日志。在这种情况下,采取开放式失败会带来另一个权衡:这种方法可以使整个系统更具弹性,但攻击者可能会干扰您的日志记录机制。

计划应对可能需要调试或修复安全系统本身的情况。考虑必要的权衡,以确保您不会被系统锁定,但仍然可以保持安全。在这种情况下,您可能需要考虑在安全位置离线保存一组仅用于紧急情况的凭据,当使用时会触发高置信度的警报。例如,最近的一次Google 网络故障导致严重的数据包丢失。当响应者试图获取内部凭据时,认证系统无法连接到一个后端并且失败关闭。然而,紧急凭据使响应者能够进行身份验证并修复网络。

安全性

我们曾经使用的一个用于电话支持的系统允许管理员模拟用户并从他们的角度查看用户界面。作为调试工具,这个系统非常棒;您可以清楚快速地重现用户的问题。然而,这种类型的系统提供了滥用的可能性。从模拟到原始数据库访问的调试端点都需要得到保护。

对于许多事件,调试异常系统行为通常不需要访问用户数据。例如,在诊断 TCP 流量问题时,线上的速度和质量通常足以诊断问题。在传输数据时加密可以保护数据免受第三方可能的观察尝试。这有一个幸运的副作用,即在需要时允许更多的工程师访问数据包转储。然而,一个可能的错误是将元数据视为非敏感信息。恶意行为者仍然可以通过跟踪相关的访问模式(例如,在同一会话中注意到同一用户访问离婚律师和约会网站)从元数据中了解用户的很多信息。您应该仔细评估将元数据视为非敏感信息的风险。

此外,一些分析确实需要实际数据,例如,在数据库中查找频繁访问的记录,然后找出这些访问为什么是常见的。我们曾经调试过一个由单个帐户每小时接收数千封电子邮件引起的低级存储问题。"零信任网络"有关这些情况的访问控制的更多信息。

结论

调试和调查是管理系统的必要方面。重申本章的关键点:

  • 调试是一种必不可少的活动,通过系统化的技术而不是猜测来取得结果。您可以通过实现工具或记录来提供对系统的可见性,从而使调试变得更加容易。练习调试以磨练您的技能。

  • 安全调查与调试不同。它们涉及不同的人员、策略和风险。您的调查团队应包括经验丰富的安全专业人员。

  • 集中式日志记录对于调试目的很有用,对于调查至关重要,并且通常对业务分析也很有用。

  • 通过查看一些最近的调查,并问自己什么信息会帮助您调试问题或调查问题,来迭代。调试是一个持续改进的过程;您将定期添加数据源并寻找改进可观察性的方法。

  • 设计安全性。您需要日志。调试工具需要访问系统和存储的数据。然而,随着您存储的数据量的增加,日志和调试端点都可能成为对手的目标。设计日志系统以收集您需要的信息,但也要求具有强大的权限、特权和政策来获取这些数据。

调试和安全调查通常依赖于突然的洞察力和运气,即使最好的调试工具有时也会不幸地被置于黑暗中。记住,机会青睐有准备的人:通过准备好日志和一个用于索引和调查它们的系统,您可以利用到来的机会。祝你好运!

¹尽管故障发生在一个大型分布式系统中,但维护较小和自包含系统的人会看到很多相似之处,例如,在涉及单个邮件服务器的故障中,其硬盘已经用完空间!

² Spanner 将数据存储为日志结构合并(LSM)树。有关此格式的详细信息,请参阅 Luo, Chen 和 Michael J. Carey. 2018 年的“基于 LSM 的存储技术:一项调查。”arXiv 预印本arXiv:1812.07527v3

³有关 Borg 的更多信息,请参阅 Verma, Abhishek 等人 2015 年的“在 Google 使用 Borg 进行大规模集群管理。”第 10 届欧洲计算机系统会议论文集:1-17。doi:10.1145/2741948.2741964。

⁴ 您可能还对 Julia Evans 的博客文章“调试程序是什么样子?”感兴趣。

⁵ Schroeder, Bianca, Eduardo Pinheiro 和 Wolf-Dietrich Weber. 2009 年。《野外的 DRAM 错误:大规模实地研究》。ACM SIGMETRICS Performance Evaluation Review 37(1)。doi:10.1145/2492101.1555372。

⁶ 这些工具通常需要一些设置;我们将在“当你陷入困境时该怎么办”中进一步讨论它们。

⁷ 这是另一个规范偏差的例子,人们习惯于次优行为!

⁸ 请参阅 Krishnan, Kripa. 2012 年。《应对意外情况》。ACM Queue 10(9)。https://oreil.ly/xFPfT

⁹ 404 是“文件未找到”的标准 HTTP 错误代码。

¹⁰ 亨德森,弗格斯。2017 年。《谷歌的软件工程》。arXiv 预印本arXiv:1702.01715v2

¹¹ 要全面了解这个主题,请参阅 Cindy Sridharan 的“云原生时代的监控”博客文章。

¹² 请参阅 Julia Rozovsky 的博客文章“成功的谷歌团队的五个关键”和查尔斯·杜希格的纽约时报文章“谷歌从寻求打造完美团队的过程中学到了什么”

¹³ 例如,参见 Ghiasvand, Siavash 和 Florina M. Ciorba. 2017 年。《用于隐私和存储收益的系统日志匿名化》。arXiv 预印本arXiv:1706.04337。另请参阅 Jan Lindquist 关于个人数据假名化以符合《通用数据保护条例》(GDPR)的规定。

¹⁴ 例如,参见 Joxean Koret 在 44CON 2014 年的演讲“破解杀毒软件”