安全软件设计指南-全-
安全软件设计指南(全)
原文:
zh.annas-archive.org/md5/07daa42f432c70281a720893daee08f6译者:飞龙
前言

本书是为那些希望更好地理解软件安全学科的基本概念,并学习如何实践安全软件设计和实现的艺术的软件专业人员编写的。这里涵盖的几个主题,我有幸自己进行了创新。其他的,我亲眼见证了它们的发展并扎根。基于我个人的行业经验,本书充满了可以立即付诸实践的可操作的想法,帮助你让你所工作的软件更加安全。
本书贯穿两个核心主题:鼓励软件专业人员在软件构建过程的早期就关注安全,并让整个团队参与到安全的过程以及对安全的责任中。无论在这两个领域中都有很大的改进空间,而本书展示了如何实现这些目标。
在我的职业生涯中,我有过在软件安全前线工作的独特机会,现在我希望尽可能广泛地分享我的经验。20 多年前,我曾是微软团队的一员,首次在大规模软件公司中应用威胁建模。几年后,在谷歌,我参与了同一基本实践的演进,并经历了一种全新的方式来应对这一挑战。本书的第二部分得益于我进行过上百次设计审查的经验。回顾我们所走过的路,让我拥有了一个很好的视角来重新阐释这一切。
设计、构建和操作软件系统本身就是一个具有风险的工作。每一个选择,每一步,都可能增加或降低引入安全漏洞的风险。本书涵盖了我最擅长的内容,这些都是我通过亲身经历学到的。我从基本原则传达安全思维,并展示如何在整个开发过程中融入安全。在此过程中,我提供了设计和代码的示例,基本独立于特定技术,以便尽可能广泛地适用。文本中穿插了大量的故事、类比和示例,以增加趣味性,并尽可能有效地传达抽象的理念。
安全思维对某些人来说比对其他人更容易获得,因此我专注于建立这种直觉,帮助你从新的角度思考,以便在工作中融入软件安全的视角。我还应该补充一点,在我个人的经验中,即使是对我们这些轻松掌握它的人来说,仍然有许多可以获得的见解。
这是一本简明的书,涵盖了大量内容。在编写这本书的过程中,我逐渐认识到它的简洁性对它可能取得的成功至关重要。软件安全是一个广度和深度都令人畏惧的领域,因此我希望通过简化书籍的篇幅,使其更易于广泛接触。我的目标是让你从新的角度思考安全,并让你能够轻松地将这种新视角应用到自己的工作中。
谁应该阅读本书?
本书适用于已经在某个软件设计和开发领域熟练的人员,包括架构师、UX/UI 设计师、项目经理、软件工程师、程序员、测试人员和管理层。只要技术专业人员了解软件的基本工作原理和构建方式,他们就能轻松理解本书中的概念性内容。软件的使用如此广泛且种类繁多,我不认为所有软件都需要安全保护;然而,大多数软件可能都需要,尤其是任何连接到互联网或与人类有较大交互的部分。
在写作过程中,我认为考虑三类潜在读者很有帮助,并希望在这里分别对这三类读者说几句话。
安全新手,尤其是那些对安全感到畏惧的人,是我写作的主要读者群体,因为每个从事软件工作的人都应该理解安全问题,以便他们能为提升安全性做出贡献。为了在未来开发出更安全的软件,我们需要每个人的参与,我希望这本书能帮助那些刚开始学习安全的人迅速入门。
安全意识读者是那些对安全感兴趣,但知识有限的读者,他们希望深化对安全的理解,并学习更多实用的方式将这些技能应用到自己的工作中。我写这本书的目的是填补这些知识空白,并提供多种方式帮助读者将所学内容立即付诸实践。
安全专家(你们知道自己是谁)也包含在其中。他们可能已经对大部分内容非常熟悉,但我相信这本书提供了一些新的视角,并且仍然有很多值得他们学习的东西。具体来说,本书讨论了一些重要的相关主题,如安全设计、安全评审,以及那些很少被书面提及的“软技能”。
本书的第三部分,涵盖实现漏洞和缓解措施,包含了用 C 或 Python 编写的简短代码示例。部分示例假设读者已熟悉内存分配的概念,并理解整数和浮点类型,包括二进制运算。在某些地方,我使用了数学公式,但仅限于模运算和指数运算。对于觉得代码或数学内容过于技术性或不相关的读者,可以跳过这些部分,完全不影响对整体内容的理解。像 man(1) 这样的引用是 *nix(Unix 系列操作系统)命令 (1) 和函数 (3)。
这本书涵盖了哪些主题?
本书由 13 章组成,分为三个部分,涵盖概念、设计和实现,最后是结论。
第一部分:概念
第一章至第五章为本书的其余部分提供了概念基础。第一章:基础概述了信息安全和隐私的基本知识。第二章:威胁介绍了威胁建模,详细讲解了在保护资产的背景下,攻击面和信任边界的核心概念。接下来的三章为读者介绍了构建安全软件时可用的有价值工具。第三章:缓解措施讨论了常用的策略,以防御性地缓解已识别的威胁。第四章:模式展示了一些有效的安全设计模式,并标出了一些需要避免的反模式。第五章:密码学通过工具箱的方式解释了如何使用标准的加密库来缓解常见风险,而不涉及底层的数学原理(这些在实践中很少需要)。
第二部分:设计
本书的这一部分可能是其对未来读者最独特和最重要的贡献。第六章:安全设计和第七章:安全设计评审提供了关于安全软件设计的指导,以及如何实现这些设计的实用技巧,分别从设计师和评审者的角度接近这一主题。在这个过程中,它们解释了为什么从一开始就将安全性融入软件设计中是如此重要。
这些章节基于本书第一部分中介绍的思想,提供了具体的方法论,说明如何将这些思想融入到安全设计的构建中。评审方法论直接来源于我的行业经验,包括一个逐步的过程,您可以根据自己的工作方式进行调整。在阅读这些章节时,考虑参考附录 A 中的示例设计文档,作为将这些思想付诸实践的例子。
第三部分:实现
第八章至第十三章涵盖了实施阶段的安全性,并涉及部署、操作和生命周期结束问题。一旦你有了安全的设计,本部分将解释如何在不引入额外漏洞的情况下开发软件。这些章节包括代码片段,展示了漏洞如何渗入代码,以及如何避免它们。第八章:安全编程 介绍了程序员面临的安全挑战,以及漏洞在代码中实际的表现形式。第九章:低级编码缺陷 讨论了计算机算术的弱点,以及 C 语言风格的动态内存分配显式管理如何破坏安全性。第十章:不可信的输入 和 第十一章:Web 安全 涵盖了许多常见的漏洞,这些漏洞已经被广泛认知多年,但似乎一直未能消失(例如注入、路径遍历、XSS 和 CSRF 漏洞)。第十二章:安全测试 讨论了常被忽视的实践——测试以确保代码安全。第十三章:安全开发最佳实践 总结了安全实施的指导方针,涵盖了一些通用的最佳实践,并提供了关于常见陷阱的警示。
本书中的代码摘录通常展示了需要避免的漏洞,并提供了修补后的版本,展示了如何使代码变得安全(分别标记为“漏洞代码”和“修复代码”)。因此,书中的代码并非用于生产软件的参考。即使是修复后的代码,在其他环境下也可能因为其他问题而存在漏洞,所以不应认为本书中提供的任何代码对于任何应用程序都是绝对安全的。
结论
后记总结了本书的内容,并描述了我希望它能产生的积极影响。在这里,我概述了书中的关键观点,尝试展望未来,并提出一些可能有助于提升软件安全性的方法,首先从本书如何为更安全的软件做出贡献的愿景开始。
附录
附录 A 是一个示例设计文档,展示了实际中安全意识设计的样貌。
附录 B 是书中出现的与软件安全相关术语的词汇表。
附录 C 包含一些开放式的练习和问题,雄心勃勃的读者可能会喜欢进行研究。
附录 D 包含了一些总结关键概念和过程的备忘单。
此外,可以在 designingsecuresoftware.com/ 找到本书提到的所有参考资料汇编(也可通过 nostarch.com/designing-secure-software/ 链接访问)。
良好、安全的乐趣
在我们开始之前,我想先提醒一下关于如何负责任地使用本书所提供的安全知识的一些重要警告。为了说明如何使软件安全,我不得不描述各种漏洞是如何工作的,以及攻击者是如何可能利用这些漏洞的。实验是一种很好的方式,可以从攻击和防御的角度提升技能,但使用这些知识时必须小心谨慎。
切勿在生产系统上随便进行安全测试。例如,当你阅读跨站脚本攻击(XSS)时,你可能会想尝试使用一些复杂的 URL 来浏览你最喜欢的网站,看看会发生什么。请不要这样做。即使出于最好的意图,这些实验也可能被网站管理员误认为是真正的攻击。尊重他人可能会将你的行为解读为威胁是非常重要的——当然,在某些国家,你可能还会违反法律。请运用你的常识,考虑你的行为可能会被如何解读,以及可能发生的错误和意外后果,并且要倾向于避免。相反,如果你想进行 XSS 实验,可以搭建自己的 Web 服务器并使用虚拟数据,然后尽情地进行实验。
此外,尽管本书基于我多年在软件安全领域的经验,提供了我能给出的最佳通用建议,但没有任何指导是完美无缺的,也并不适用于所有可能的情境。书中提到的解决方案从来不是“灵丹妙药”:它们只是建议,或者是值得了解的常见方法示例。在评估安全决策时,依赖你最好的判断力。没有任何一本书可以为你做出这些选择,但本书可以帮助你做出正确的决定。
第一部分
概念
基础
诚实是一种基础,通常是一个坚实的基础。即使因我所说的话惹上麻烦,那也是我能够站得住脚的东西。
—查尔马涅·撒·神

软件安全既是一种逻辑实践,也是一种艺术,基于直觉决策。它需要理解现代数字系统,但也需要对与这些系统互动并受其影响的人们保持敏感。如果这听起来令人生畏,那么你对本书所要解释的基本挑战有了很好的认识。这个视角也能解释为什么软件安全长久以来一直挑战这一领域,以及即使取得了一些进展,所付出的努力依然巨大,尽管它只解决了部分问题。然而,在这种情况下有一个好消息,因为这意味着我们所有人都可以通过提高对更好安全性的意识和参与,真正地在过程的每个阶段产生影响。
我们从考虑安全到底是什么开始。鉴于安全的主观性质,清楚地思考其基础至关重要。本书代表了我基于自己经验的最佳思考理解。信任是所有安全的基础,因为没有人能在真空中工作,现代数字系统复杂得无法单独从硅开始构建;你必须信任他人提供你没有自己创造的一切(从硬件、固件、操作系统到编译器)。在此基础上,我接下来介绍六个经典的安全原则:经典信息安全的三个组成部分和用于实施它的三部分“黄金标准”。最后,关于信息隐私的部分增加了重要的人类和社会因素,这些因素在数字产品和服务越来越多地融入现代生活的最敏感领域时必须考虑。
尽管读者无疑对安全、信任或保密性等词汇有良好的直觉理解,但在本书中,这些词汇有特定的技术含义,值得仔细探讨,因此建议认真阅读本章。作为对更高级读者的挑战,我邀请你们尝试自己写出更好的描述——无疑这将是一个对大家都有教育意义的练习。
理解安全
所有生物都有本能,能够避开危险,防御攻击,并朝向任何可以找到的避难所前进。
我们必须意识到,当我们的天生物理安全感正常工作时,它是多么出色。相比之下,在虚拟世界中,我们很少有真正的信号可以依赖——而假信号很容易被伪造。在从技术角度考虑安全之前,让我们通过一个真实的故事来说明人类能做些什么。(正如我们稍后将看到的,在数字领域,我们需要一整套全新的技能。)
以下是一个来自汽车销售员的真实故事。在进行了一次客户试驾后,销售员和客户返回了停车场。销售员下车并继续与客户交谈,同时绕到车前。“当我与他对视时,”销售员回忆道,“那一刻我意识到,‘哦不,这家伙要偷这辆车。’” 事件加速发展:那位变成小偷的客户将车挂档并飞驰而去,而销售员则紧紧抓住车头,经历了人生中最惊险的“车顶之旅”。犯罪分子驾车猛烈加速,企图把他甩下车外。(幸运的是,销售员没有受到严重伤害,犯罪嫌疑人很快被逮捕、定罪并被要求赔偿。)
当那两个人对视时,一种微妙的风险计算已经发生。在短短几分之一秒的时间里,那位销售员处理了复杂的视觉信号,这些信号来自客户的面部表情和肢体语言,最终提炼出了一个明确的敌对行为意图。现在,假设这位销售员成了鱼叉式网络钓鱼攻击的目标(这种欺诈性电子邮件旨在愚弄特定目标,而不是大众)。在数字世界里,没有了面对面与攻击者互动时所能感知到的信号,他会更容易上当受骗。
当谈到信息安全、计算机、网络和软件时,我们需要进行分析性思考,评估我们所面临的风险,如果我们想要确保数字系统的安全,必须如此。即使我们无法直接看到、闻到或听到比特或代码,我们也必须这样做。每当你在网上检查数据时,你是在使用软件将信息以人类可读的字体显示出来,通常,你和实际的比特之间有大量的代码;事实上,它可能是一面镜子迷宫。所以,你必须信任你的工具,并相信你真的在检查你认为自己在检查的数据。
软件安全的核心在于保护数字资产免受各种威胁,这一努力在很大程度上是由一组基本的安全原则驱动的,本章的其余部分将讨论这些原则。通过从这些基本原理出发分析系统,我们可以了解漏洞是如何进入软件的,并且如何主动避免和减轻问题。这些基础原则,以及后续章节中涉及的其他设计技术,适用于不仅仅是软件,还适用于自行车锁、银行金库或监狱的设计和运营。
信息安全一词专指数据保护及其访问权限的授予。软件安全是一个更广泛的术语,侧重于可信的软件系统的设计、实现和操作,包括对信息安全的可靠执行。
信任
在数字领域,信任同样至关重要,但往往被视为理所当然。软件安全最终依赖于信任,因为你无法控制系统的每个部分,无法编写所有自己的软件,也无法审查所有依赖的供应商。现代数字系统复杂到即便是大型科技巨头也无法从零开始构建完整的技术栈。从硅芯片到操作系统、网络、外设,以及使这一切运作的众多软件层,构成我们日常依赖的系统是巨大的技术成就,规模庞大且复杂。由于没有人能够独自构建这些系统,组织依赖于硬件和软件产品,这些产品通常是根据特性或价格来选择的——但重要的是要记住,每一个依赖也都涉及一个信任决策。
安全性要求我们仔细审视这些信任关系,尽管没有人有足够的时间或资源去调查和验证每一件事。不足够信任意味着在没有真实威胁的情况下,为保护系统做大量无谓的工作。另一方面,过度信任可能意味着之后被突然击中。直白地说,当你完全信任一个实体时,他们可以在没有后果的情况下失败。信任可以通过两种根本不同的方式被破坏:恶意(欺骗、撒谎、诡计)和无能(错误、误解、疏忽)。
在面对不完全信息时做出关键决策正是信任最适用的领域。但我们天生的信任感依赖于微妙的感官输入,这些输入完全不适用于数字领域。以下讨论从信任本身的概念开始,剖析我们体验中的信任是什么,然后转向信任与软件的关系。当你阅读时,试着找到其中的共通点,并将你对软件的理解与对信任的直觉联系起来。利用你现有的信任技能是一种强大的技巧,随着时间的推移,它将为你提供对软件安全的直觉感知,这比任何技术分析都更加有效。
体验信任
理解信任的最佳方式是留意在依赖信任时真实的感受。这里有一个思维实验——或者一个真实的练习,可以和你真正信任的人一起尝试——它能真正帮助你理解信任的意义。想象一下,你和朋友一起走在繁忙的马路上,车流从几英尺远的地方呼啸而过。你们前方看到一个人行横道,你告诉朋友你希望他们带你安全过马路,你会闭上眼睛,完全依赖他们。你和朋友牵手走到人行横道,朋友轻轻地转你面向马路,用触碰的方式示意你等一等。你能听到快速行驶的车辆声,你清楚知道你的朋友(现在是你的保护者)会等到安全时才会带你过马路,但你的心跳可能也明显加速了,你可能会专心聆听任何即将来临的危险声。
现在,你的朋友毫不犹豫地引导你前进,带你从路缘下去。如果你决定闭着眼睛走进马路,你所感受到的是纯粹的信任——或者说,某种程度的缺乏信任。你的大脑敏锐地感知到明显的风险,你的感官竭力确认安全,内心深处有某种东西在警告你不要这么做。你自己的内部安全监控系统没有足够的证据,并希望你在行动前睁开眼睛;如果你的朋友误判了情况,或者更糟的是,正在对你施展一个致命的恶作剧,你该怎么办?最终,正是你对朋友的信任,让你能够超越这些本能,穿过马路。
提高你对数字信任决策的意识,并帮助他人意识到它们对安全的重要影响。理想情况下,当你选择一个组件或为关键服务选择供应商时,你将能够依赖于像刚才描述的练习中所使用的那些直觉来指导信任决策。
你看不见比特
所有这些讨论旨在强调这样一个事实:当你认为自己是在“直接查看数据”时,实际上你是在查看一个远程的表示。事实上,你看到的是屏幕上的像素,你相信它们代表了某些字节的内容,而这些字节的物理位置你并不知道,可能在映射数据到你显示器上可读形式的过程中执行了数百万条指令。数字技术使得信任特别棘手,因为它如此抽象、快速,并且对直接观察来说是隐藏的。每当你检查数据时,记住,实际的数据在内存中与形成字符的像素之间,有大量的软件和硬件。如果其中有什么恶意地错误地表现出实际数据,你怎么可能知道呢?关于数字信息的实际真相是极其难以直接观察到的。
想一想网页浏览器地址栏中的锁形图标,它表示与网站的安全连接。这个标志的出现或缺失向用户传达了一个简单的信息:安全或不安全。在幕后,存在大量的数据和相当复杂的计算,正如第十一章将详细说明的那样,所有这些都汇总成一个二进制的“是/否”安全指示。即使是专家开发者也会面临一项艰巨的任务,试图手动确认仅仅一个实例的有效性。所以我们能做的只是信任软件——而且我们完全有理由信任它。这里的关键是要认识到这种信任有多么深远和普遍,而不是理所当然地接受它。
能力与不完美
大多数攻击开始时会利用软件缺陷或配置错误,而这些缺陷或错误通常是出于程序员和 IT 员工的诚意和善意努力所导致的,而他们也是人类,因此不完美。由于许可证通常明确声明几乎所有的责任,因此所有软件都是基于买者自负其责的原则使用的。如果,正如人们常说的,“所有软件都有漏洞”,那么其中一部分漏洞就会被利用,最终攻击者会找到一些漏洞并有机会恶意使用它们。软件专业人员因对恶意软件的错误信任而成为攻击受害者并进行直接攻击的情况相对较少。
幸运的是,关于操作系统和编程语言做出重大信任决策通常是容易的。许多大公司在提供和支持优质硬件和软件产品方面有着丰富的业绩记录,信任它们是非常合理的。信任那些缺乏业绩记录的公司可能会更有风险。虽然他们可能有很多熟练且积极的人员在努力工作,但行业缺乏透明度使得评估其产品安全性变得困难。开源提供了透明度,但依赖于项目所有者提供的监督程度,以防止贡献者插入有缺陷甚至是恶意的代码。值得注意的是,没有任何软件公司尝试通过承诺更高的安全性或在攻击事件中提供赔偿来与众不同,因此作为客户,我们没有这样的选择。法律、监管和商业协议为我们提供了额外的方式来减轻信任决策中的不确定性。
认真对待信任决策,但要认识到没有人能够 100% 准确地做出判断。坏消息是,这些决策永远不完美,因为正如美国证券交易委员会警告我们的那样,“过去的表现不能保证未来的结果”。好消息是,人类在判断信任方面高度进化——尽管这种判断在面对面交流时效果最佳,而绝非通过数字媒介——在绝大多数情况下,只要我们拥有准确信息并有意识地采取行动,我们通常能够做出正确的信任决策。
信任是一个光谱
信任总是以不同程度授予的,且信任评估总是存在一定的不确定性。在信任的最远端,比如在进行重大手术时,我们可能会将我们的生命字面意思地交给医疗专业人员,甘愿放弃对我们身体的控制,不仅仅是身体控制,还有对手术过程的意识和监控能力。在最坏的情况下,如果他们未能帮助我们,且我们没有幸存下来,我们实际上没有任何救济途径(法律上的遗产权除外)。日常信任则要有限得多:信用卡有额度限制,以防止银行因未付款而承担过大损失,而汽车则有代客钥匙,以便我们限制对后备箱的访问。
由于信任是一个连续体,“信任但验证”的政策是一个有用的工具,它弥合了完全信任和完全不信任之间的鸿沟。在软件中,你可以通过授权和严格审计的结合来实现这一点。通常,这涉及到自动化审计(准确检查大量大部分重复的活动日志)和手动审计(抽查、处理例外情况,并有人工参与做出最终决策)。我们将在本章稍后部分详细讨论审计。
信任决策
在软件中,你面临一个二选一的决定:信任,还是不信任?虽然一些系统确实会对应用程序施加多种权限限制,但你仍然需要决定是否允许或拒绝每个特定权限。在犹豫时,你可以安全地选择不信任,只要至少有一个候选方案能够合理地赢得你的信任。如果你对评估要求过高,没有任何产品能够获得你的信任,那么你只能面对自己构建组件的前景。
将信任决策视为从决策树上剪掉分支,否则决策树会变得几乎无穷大。当你可以信任一个服务或计算机是安全的时,你就节省了进行更深入分析的精力。另一方面,如果你不愿意信任,那么你需要构建并保护更多的系统部分,包括所有子组件。图 1-1 展示了一个做出信任决策的示例。如果没有一个你完全信任的云存储服务来存储你的数据,那么你必须自己运营这个服务,这涉及到更多的信任决策:选择一个你信任的托管服务,还是自己做,以及选择一个你信任的现有数据库软件,还是自己编写。请注意,当你不信任一个提供商时,更多的信任决策一定会随之而来,因为你无法做到所有事情。
对于明确不信任的输入——这应包括几乎所有输入,特别是来自公共互联网或任何客户端的内容——应当以怀疑态度和最高的谨慎度来处理这些数据(关于这一点,详见第四章第 68 页的“对信任的犹豫”)。即使是可信的输入,也不能假设它们是完全可靠的。考虑在容易实现的情况下,适时添加安全检查,即使只是为了减少整体系统的脆弱性,并在遇到无意的错误时防止错误的传播。

图 1-1:一个关于信任决策的决策树示例
隐式信任的组件
每个软件项目都依赖于一个庞大的技术栈,这些技术栈是隐式信任的,包括硬件、操作系统、开发工具、库和其他难以验证的依赖项,因此我们基于供应商的声誉来信任它们。然而,你应当保持对什么是隐式信任的基本理解,并在大规模扩大隐式信任的范围之前,认真考虑这些决策。
没有简单的方法可以管理隐式信任,但这里有一个有帮助的想法:最小化你信任的各方数量。例如,如果你已经决定使用微软(或苹果等)操作系统,倾向于使用它们的编译器、库、应用程序和其他产品与服务,以最小化暴露的风险。其大致原理是,信任更多公司会增加这些公司让你失望的机会。此外,实际上,一家公司的一系列产品往往在一起使用时更加兼容且经过更好的测试。
值得信赖
最后,不要忘记做出信任决策的另一面,即在提供产品和服务时,促进信任。每个软件产品必须说服最终用户它是值得信任的。通常,仅仅展示一个扎实的专业形象就足够了,但如果该产品履行的是关键功能,那么为客户提供一个扎实的信任基础至关重要。
以下是一些增强工作中信任的基本建议:
-
透明度能够培养信任。公开工作使客户能够评估产品。
-
引入第三方通过其独立性来建立信任(例如,使用雇佣的审计员)。
-
有时你的产品是与其他产品集成的第三方。信任逐渐建立,因为两个具有独立关系的各方之间很难串通。
-
当问题出现时,要虚心接受反馈,果断行动,并公开披露任何调查结果以及为防止问题再次发生所采取的措施。
-
特定的功能或设计元素可以使信任变得可见——例如,实时显示已保存并验证的备份数量的归档解决方案,展示分布式位置的备份情况。
行动产生信任,而空洞的声明则会削弱精明客户的信任。提供可以验证的可信证据,理想情况下以客户自己可以验证的方式。尽管很少有人会真正审核开源代码的质量,但知道他们可以审核(并且假设其他人也在这么做)几乎同样有说服力。
经典原则
信息安全的指导原则起源于计算机发展的初期,当时计算机刚从特殊的锁闭、空调、架空地板的房间中出来,开始连接到网络。这些传统模型是现代信息安全的“牛顿物理学”:适用于许多应用的良好且简明的指南,但并不是万能的。例如,信息隐私是现代数据保护和管理中一个更为细致的考虑,而传统的信息安全原则并未涵盖这一点。
基本原则可以很好地分为两组,每组三条。前三条原则,我称之为C-I-A,定义了数据访问的要求;另外三条则涉及如何控制和监控访问。我们将这三条原则称为黄金标准。这两组原则是相互依赖的,只有整体考虑,才能保护数据资产。
除了防止未经授权的数据访问外,还存在一个问题,即应该信任谁或哪些组件和系统可以访问数据。这是一个更复杂的信任问题,最终超出了信息安全的范围,尽管面对这个问题是确保任何数字系统安全不可避免的部分。
信息安全的 C-I-A
我们传统上建立软件安全是基于信息安全的三个基本原则:保密性、完整性和可用性。这三大支柱围绕数据保护的基本原则制定,它们的含义直观易懂:
保密性
- 仅允许授权的数据访问——不泄露信息。
完整性
- 准确维护数据——不允许未经授权的修改或删除。
可用性
- 维护数据的可用性——不允许显著的延迟或未经授权的关停。
每个简短的定义都描述了目标和防御措施,以防止其被破坏。在审查设计时,常常有助于考虑可能破坏安全的方式,并从中找到防御措施。
C-I-A 的三个组成部分代表了理想,避免坚持完美至关重要。例如,甚至对加密的网络流量进行分析,决心的窃听者也可能推断出关于两个端点之间通信的某些信息,比如交换的数据量。从技术上讲,这种数据交换削弱了端点之间交互的机密性;但出于实际考虑,我们无法在不采取极端措施的情况下修复这个问题,而且通常这种风险足够小,可以安全地忽略。(一种隐藏通信事实的方法是让端点始终交换恒定量的数据,根据需要在实际流量较低时添加虚拟数据包。)流量对应的活动是什么?对手可能如何利用这些知识?下一章将详细解释类似的威胁评估。
请注意,授权是 C-I-A(机密性、完整性、可用性)每个组件的内在部分,它要求只有正确的披露、数据修改或可用性控制。什么构成“正确”是一个重要的细节,授权策略需要明确这一点,但它不是这些基本数据保护原语概念的一部分。有关这一部分的内容将在第 14 页的《黄金标准》中讨论。
机密性
保持机密性意味着以授权的方式披露私人信息。这听起来简单,但在实际操作中涉及许多复杂因素。
首先,重要的是仔细识别哪些信息应被视为私密。设计文档应明确这一区别。尽管什么算作敏感信息有时看起来似乎很明显,但实际上人们的看法差异很大,若没有明确的说明,我们可能会产生误解。最安全的假设是将所有外部收集的信息默认为私密,直到通过明确的政策声明并解释如何以及为什么放宽这一规定。
这里列出了一些常常被忽视的将数据视为私密的原因:
-
终端用户可能自然期望他们的数据是私密的,除非另行通知,即使披露这些数据并不会造成危害。
-
人们可能会在为不同用途设计的文本框中输入敏感信息。
-
信息的收集、处理和存储可能受到许多不为人知的法律和规定的约束。(例如,如果欧洲人浏览您的网站,可能需要遵循欧盟的法律,例如《通用数据保护条例》。)
在处理私人信息时,需确定什么构成适当的访问权限。决定何时以及如何披露信息,最终是一个信任决策,这不仅需要明确规定规则,还需要解释这些规则背后的主观选择。
保密性妥协是一个连续的过程。在完全披露的情况下,攻击者会获取整个数据集,包括元数据。在该连续性较低的一端,可能是信息的轻微泄露,例如一个内部错误信息或类似的泄漏,实际上并不造成重大影响。作为部分泄露的例子,考虑将连续编号分配给新客户的做法:一个狡猾的竞争对手可以注册为新客户,并不时获得新的客户编号,然后计算相邻的差异,从而了解每个时间段内获得的客户数量。任何有关受保护数据的详细信息泄露,都会在某种程度上构成保密性的妥协。
很容易低估轻微泄露的潜在价值。攻击者可能会以与开发者最初意图完全不同的方式使用数据,且将小部分信息结合起来,往往能提供比任何单独部分更有力的洞察。知道某人的邮政编码可能并不能告诉你太多,但如果你还知道他们的近似年龄,并且他们是医学博士(MD),你也许可以将这些信息结合起来,识别出这个居住在稀疏地区的个人——这一过程被称为去匿名化或重新识别。通过分析 Netflix 发布的一个假定已匿名的数据集,研究人员能够将许多用户账户与 IMDb 账户匹配:事实证明,你最喜欢的电影是一个有效的独特个人身份标识手段。
完整性
在信息安全的语境中,完整性只是指数据的真实性和准确性,防止未经授权的篡改或删除。除了防止未经授权的修改外,数据的来源记录——即原始来源和任何经过授权的修改——也是完整性的一个重要且更强的保证。
对许多篡改攻击的经典防御方法是保留关键数据的多个版本并记录它们的来源。简而言之,保持良好的备份。增量备份是非常有效的缓解措施,因为它们简单高效,能够提供一系列快照,详细记录数据发生变化的时间和内容。然而,完整性的需求远不止于数据的保护,通常还包括确保组件、服务器日志、软件源代码和版本的完整性,以及其他法医信息,以便在出现问题时确定篡改的原始来源。除了有限的管理员访问控制外,安全摘要(类似于校验和)和数字签名也是强有力的完整性检查方法,如第五章所述。
请记住,篡改可以通过许多不同的方式发生,不一定只是修改存储中的数据。例如,在 Web 应用程序中,篡改可能发生在客户端、客户端和服务器之间的传输过程中、通过欺骗授权方进行更改、修改页面中的脚本,或以其他多种方式发生。
可用性
对可用性的攻击是互联网连接世界中的一种悲哀现实,而且可能是最难防御的攻击之一。在最简单的情况下,攻击者可能仅仅是向服务器发送异常重的流量负载,用看似有效的服务请求淹没服务器。这一原则意味着信息是暂时不可用的;虽然永久丢失的数据也不可用,但通常被视为对完整性的根本妥协。
匿名的拒绝服务(DoS)攻击,通常是为了勒索,威胁任何互联网服务,构成了一个巨大的挑战。为了最佳地防御这些攻击,应选择在大型服务平台上托管,拥有可以承受重负载的基础设施,并保持灵活性,在出现问题时迅速迁移基础设施。没有人知道 DoS 攻击的实际发生频率或代价,因为许多受害者会私下解决这些事件。但毫无疑问,你应该提前制定详细的应急计划来应对这种情况。
还有许多其他类型的可用性威胁也是可能的。例如,对于一个网络服务器,一个格式错误的请求可能触发一个漏洞,导致崩溃或无限循环,从而使其服务无法正常运行。其他攻击还可能使应用程序的存储、计算或通信能力超负荷,或者利用破坏缓存有效性的模式,这些都会带来严重的问题。未经授权的软件、配置或数据破坏(即便有备份,也可能造成延迟)同样可能对可用性产生不利影响。
金标准
如果 C-I-A 是安全系统的目标,那么金标准描述了实现这一目标的方法。Aurum是拉丁语中的“黄金”之意,因此其化学符号为“Au”,恰好安全执行的三个重要原则也都以这两个字母开头:
认证
- 高保障地确定主体的身份
授权
- 可靠地仅允许经过认证的主体执行操作
审计
- 保持对主体行为的可靠记录以供检查
主体是指任何经过可靠认证的实体:一个人、企业或组织、政府实体、应用程序、服务、设备,或任何其他具有执行能力的代理。
身份验证是可靠地确认主体凭证有效性的过程。系统通常允许注册用户通过证明他们知道与其账户相关联的密码来进行身份验证,但身份验证的范围可以更广泛。凭证可能是主体知道的某些信息(如密码)、拥有的某些物品(如智能卡),或者是主体的某些特征(如生物特征数据);我们将在下一节中进一步讨论凭证。
经过身份验证的主体的数据访问受到授权决策的约束,依据规定的规则,允许或拒绝其行为。例如,具有访问控制设置的文件系统可能会将某些文件设置为特定用户的只读文件。在银行系统中,职员可以记录一定金额以内的交易,但对于更大金额的交易,可能需要经理审批。
如果一个服务保存了一个准确记录主体行为的安全日志,包括任何执行某些操作时的失败尝试,管理员可以进行随后的审计,检查系统的表现,确保所有操作都是正当的。准确的审计日志是强大安全性的重要组成部分,因为它们提供了实际事件的可靠报告。详细的日志提供了发生了什么的记录,能清晰地揭示在出现异常或可疑事件时发生的具体情况。例如,如果你发现一个重要的文件丢失,日志应该理想地提供删除该文件的人员及其时间的详细信息,为进一步调查提供起点。
黄金标准充当保护 C-I-A 的执行机制。我们定义了保密性和完整性为防止未经授权的泄露或篡改,而可用性也受到授权管理员的控制。真正执行授权决策的唯一方式是确保使用该系统的主体已被正确验证。审计通过提供可靠的谁做了什么、何时做的日志来完善整个过程,定期审查异常情况,并追究相关方的责任。
安全设计应始终明确区分身份验证和授权,因为将二者结合会导致混乱,而当这些阶段清晰分开时,审计轨迹会更加明确。这两个现实生活中的例子说明了为什么这种区分很重要:
-
“你为什么让那个人进了金库?” “我不知道,但他看起来很合法!”
-
“你为什么让那个人进了金库?” “他的身份证明是‘Sam Smith’,并且他有分行经理的书面证明。”
第二个回应比第一个更为完整,后者完全没有帮助,除了证明保安是个傻瓜。如果保险库被入侵,第二个回应会提供明确的调查细节:分行经理是否有权限授予进入保险库的权限并写下便条?如果保安保留了身份证复印件,那么这些信息有助于确认并找到 Sam Smith。相比之下,如果分行经理的便条上只写了“让持票人进入保险库”——没有认证的授权——调查人员在安全被突破后几乎无法了解发生了什么或入侵者是谁。
身份验证
身份验证过程基于凭证来验证主体的身份声明,凭证表明他们确实是他们所声称的身份。或者服务可能使用更强的凭证形式,如数字签名或挑战,证明主体拥有与该身份关联的私钥,这也是浏览器通过 HTTPS 验证 Web 服务器身份的方式。数字签名是一种更好的身份验证形式,因为主体可以证明他们知道秘密而不泄露它。
适用于身份验证的证据可分为以下几类:
-
你知道的东西,比如密码
-
你拥有的东西,比如一个安全令牌,或者在模拟世界中某种无法伪造的证书、护照或签署的文件
-
你是的东西——即生物特征(指纹、虹膜图案等)
-
你所在的地方——你的验证位置,比如连接到一个安全设施中的私人网络
这些方法中的许多是相当不可靠的。你所知道的东西可能会被揭示,你拥有的东西可能会被盗取或复制,你的位置可以通过各种方式被操控,甚至你所“是”的东西也可能会被伪造(如果被泄露,你以后就无法改变你“是”的东西)。除此之外,在当今的网络化世界中,身份验证几乎总是发生在网络上,这使得任务比面对面的身份验证更为困难。例如,在互联网上,浏览器充当信任中介,首先进行本地身份验证,只有验证成功后,才将加密凭证传递给服务器。系统通常使用多重身份验证因素来减轻这些问题,定期审计这些因素也是另一个重要的防线。两个较弱的身份验证因素比一个要好(但没有好很多)。
然而,在组织可以为某人分配凭证之前,它必须解决一个棘手的问题:如何确定一个人在加入公司、注册账户或因忘记密码而联系帮助台恢复访问权限时的真实身份。
例如,当我加入谷歌时,所有新员工在一个周一早晨与几位 IT 管理员汇合,管理员们核对我们的护照或其他身份证明与新员工名单,然后才发放我们的员工卡和公司发放的笔记本电脑,并让我们设置登录密码。
通过检查我们提供的凭证(我们的身份证明)是否正确地确认了我们作为所声明之人的身份,IT 团队验证了我们的身份。身份验证的安全性取决于我们提供的政府颁发的身份证明和支持文件(例如出生证明)的完整性。这些身份证明的发行有多准确?它们被伪造或欺诈获得的难度有多大?理想情况下,从出生注册开始的身份链应在我们一生中始终保持完整,以便独特而真实地识别每个人。安全识别个人是一个挑战,主要是因为最有效的技术往往带有专制色彩,且在社会上不可接受,因此为了保持一定的隐私和自由,我们在日常生活中选择较弱的方法。本书的重点是金标准,而不是这一更难的身份管理问题,因此不在本书讨论范围之内。
在可行的情况下,依赖现有的可信认证服务,不要不必要地重复发明轮子。即使是简单的密码认证也很难做到安全,处理遗忘密码的安全性更是困难。通常来说,认证过程应检查凭证并提供通过或失败的响应。避免显示部分成功,因为这可能帮助攻击者通过试错逐步锁定凭证。为了缓解暴力破解的威胁,一种常见策略是使认证过程本身计算量大,或在过程中引入逐步延迟(另见第四章第 61 页中的“避免可预测性”)。
在验证用户身份后,系统必须找到一种安全地将身份绑定到主体的方法。通常,认证模块会向主体发放一个令牌,主体可以使用这个令牌代替完整的身份验证,用于后续的请求。其目的是,主体通过代理(如网页浏览器)呈现认证令牌,作为其身份的简要证明,从而为未来的请求创建一个安全上下文。这个上下文将存储的令牌与后续请求进行绑定,以代表已认证的主体。网站通常通过与浏览会话相关联的安全 cookie 来实现这一点,但对于其他类型的主体和接口,还有许多不同的技术。
已认证身份的安全绑定可能会以两种根本不同的方式受到破坏。一个明显的方式是攻击者篡夺受害者的身份。另一种方式是已认证的主体可能会串通并试图泄露他们的身份,甚至将其转嫁给他人。后者的例子包括共享付费流媒体订阅。由于绑定较为松散且依赖主体的合作,网络防御此类行为的手段并不十分有效。
授权
允许或拒绝关键操作的决策应基于通过认证确认的主体身份。系统通过业务逻辑、访问控制列表或其他正式的访问策略来实施授权。
匿名授权(即无认证的授权)在某些罕见的情况下是有用的;一个现实世界的例子可能是拥有繁忙车站公共储物柜的钥匙。基于时间的访问限制(例如,将数据库访问限制在营业时间内)是另一个常见的例子。
一个单独的保安应该对特定资源执行授权。分散在代码库中的授权代码会让维护和审计变得非常困难。相反,授权应依赖于一个统一的框架来授予访问权限。良好的结构化设计能够帮助开发人员做到这一点。在可能的情况下,尽量使用标准的授权模型,而不是混乱的临时逻辑。
基于角色的访问控制(RBAC)桥接了认证和授权之间的联系。RBAC 基于分配给已认证主体的角色授予访问权限,通过统一的框架简化了访问控制。例如,银行中的角色可能包括职员、经理、贷款专员、安全员、财务审计员和 IT 管理员。RBAC 并不是为每个人单独选择访问权限,而是根据每个人的职责指定一个或多个角色,并自动且统一地分配相关权限。在更高级的模型中,一个人可能拥有多个角色,并明确选择在特定访问场景下应用哪个角色。
授权机制可以比操作系统传统提供的简单读/写访问控制更加细化。通过设计更强大的授权机制,你可以在不丧失有用功能的情况下,通过限制访问来增强安全性。这些更先进的授权模型包括基于属性的访问控制(ABAC)、基于策略的访问控制(PBAC)等。
以一个简单的银行出纳员示例来观察如何通过细粒度授权来收紧政策:
限速
- 出纳员每小时最多可以进行 20 笔交易,但超过这个数量将被视为可疑。
时间限制
- 出纳员交易必须在营业时间内进行,且需打卡上班。
禁止自助服务
- 出纳员禁止与其个人账户进行交易。
多个主体
- 超过$10,000 的出纳交易需要单独的经理批准(避免了单一恶意行为者一次性转移大量资金的风险)。
最后,即使是只读访问,对于某些数据(如密码)来说,也可能是过高的权限。系统通常通过比较摘要值来检查登录密码,这样就避免了泄露明文密码的任何可能性。用户名和密码会传送到前端服务器,前端服务器计算密码的摘要并将其传递给身份验证服务,迅速销毁任何明文密码的痕迹。身份验证服务无法从凭据数据库中读取明文密码,但可以读取摘要,并将其与前端服务器提供的摘要进行比较。通过这种方式,系统检查凭据,但身份验证服务永远无法访问任何密码,因此即使被攻破,该服务也无法泄露密码。除非接口设计提供了这些替代方案,否则它们将错失这些减少数据泄漏可能性的机会。我们将在第四章第 57 页讨论“最小信息”模式时进一步探讨这一点。
审计
为了让组织能够审计系统活动,系统必须生成所有对维护安全至关重要的事件的可靠日志。这些事件包括身份验证和授权事件、系统启动和关闭、软件更新、管理访问等。审计日志还必须具备防篡改能力,理想情况下,即便是管理员也难以干预,才能被视为完全可靠的记录。审计是黄金标准的一个关键组成部分,因为事件是会发生的,身份验证和授权政策可能存在缺陷。审计还可以提供必要的监督,减少内部人员背叛信任的风险。
如果操作得当,审计日志对日常监控、衡量系统活动水平、检测错误和可疑活动至关重要,并且在发生事件后,能帮助确定攻击发生的时间和方式,以及评估损害程度。请记住,完全保护数字系统不仅仅是正确执行政策的问题;它更关乎如何负责地管理信息资产。审计确保了信任的主体在其广泛权限范围内正确行事。
2018 年 5 月,Twitter 披露了一个尴尬的漏洞:他们发现一次代码变更不小心导致原始登录密码出现在内部日志中。虽然这不太可能导致任何滥用,但无疑损害了客户信任,且本不该发生。日志应该记录操作细节,但不应存储任何实际的私人信息,以尽量减少泄露的风险,因为许多技术人员可能会定期查看日志。关于这一要求的详细处理,请参见附录 A 中的示例设计文档,详细描述了一种解决这一问题的日志工具。
系统还必须防止任何人篡改日志以掩盖不当行为。如果攻击者能够修改日志,他们就能清除所有活动痕迹。对于特别敏感且风险较高的日志,应由一个独立的系统在不同的管理和操作控制下来管理审计日志,以防止内部人员掩盖自己的行径。完全做到这一点非常困难,但独立监督的存在往往能有效地起到威慑作用,就像一个简易的围栏和显眼的视频监控摄像头能有效阻止非法入侵一样。
此外,任何试图规避系统的行为都将显得非常可疑,任何错误的举动都将导致对违法者的严重后果。一旦被抓住,他们将很难否认自己的罪行。
不可否认性是审计日志的重要属性;如果日志显示某个管理员在特定时间运行了某个命令,并且系统立即崩溃,很难将责任推给其他人。相反,如果一个组织允许多个管理员共享同一个账户(一个糟糕的主意),那么就无法确切知道到底是谁做了什么,从而为每个人提供了合理的否认。
最终,审计日志只有在你监控它们、仔细分析异常事件并跟进,必要时采取适当的行动时才有用。为此,按照金发姑娘原则记录适量的细节非常重要。过多的日志会膨胀数据量,增加监督的难度;而过于嘈杂或杂乱的日志则让人难以提取有用信息。另一方面,日志过于简略,缺乏足够的细节,可能会遗漏关键信息,因此找到合适的平衡是一个持续的挑战。
隐私
除了信息安全的基础——C-I-A 和黄金标准——另一个我想介绍的基本主题是信息隐私的相关领域。安全与隐私之间的界限难以明确定义,它们既密切相关又有所不同。在本书中,我想专注于它们的交集点,而不是试图统一它们,而是将安全与隐私结合到软件构建的过程中。
为了尊重人们的数字信息隐私,我们必须通过考虑额外的人为因素来扩展保密原则,包括:
-
客户对信息收集和使用的期望
-
关于适当的信息使用和披露的明确政策
-
与收集和使用各种类别信息相关的法律和监管问题
-
处理个人信息时的政治、文化和心理层面
随着软件在现代生活中变得越来越普及,人们在生活中使用软件的方式变得更加亲密,涉及到生活中许多敏感领域,导致了许多复杂的问题。过去的事故和滥用事件提高了对这些风险的关注,随着社会通过政治和法律手段应对新挑战,妥善处理私人信息变得越来越具有挑战性。
在软件安全的背景下,这意味着:
-
考虑所有数据收集和共享可能对客户和利益相关者产生的后果
-
标明所有潜在问题,并在必要时寻求专家建议
-
建立并遵循有关私人信息使用的明确政策和指南
-
将政策和指导转化为软件执行的检查和制衡
-
保持准确的数据获取、使用、共享和删除记录
-
审计数据访问授权和特别访问以确保合规
隐私工作往往比维护系统适当控制和提供适当访问权限的相对明确的安全工作更难界定。而且,随着社会在更多数据收集的未来中不断前进,我们仍在逐步明确隐私的期望和规范。鉴于这些挑战,您明智的做法是考虑最大限度的透明度,关于数据使用的政策应简明易懂,并且尽量收集最少的数据,特别是个人身份信息。
仅为特定目的收集信息,并仅在有用时保留。除非设计中设想了授权使用,否则应避免首次收集。为了“某天”使用而轻率收集数据是有风险的,几乎从来不是一个好主意。当某些数据的最后一次授权使用变得不再必要时,最好的保护方法是安全删除。对于特别敏感的数据,或者为了最大化隐私保护,可以采取更强的措施:当披露的潜在风险超过保留数据的潜在价值时,就删除数据。保留多年的电子邮件有时可能对某些事情有用,但通常没有明确的商业需求。然而,内部邮件如果泄露或披露(例如通过传票的方式)可能会带来法律责任。因此,与其为了“以防万一”无限期保存所有数据,通常最好的做法是删除它。
对信息隐私的完整讨论超出了本书的范围,但隐私与安全性是任何收集个人数据的系统设计中紧密相连的两个方面——而且几乎所有数字系统都以某种方式与人互动。只有在安全性得到保障的情况下,才能实现强有力的隐私保护,因此这些话是呼吁大家在软件设计中通过设计来考虑并融入隐私保护。
尽管其复杂性,隐私的最佳实践之一是众所周知的:必须清楚地传达隐私期望。与安全性不同,隐私政策在信息服务是否以及如何使用客户数据上,通常留有很大的余地。“我们将重用并出售您的数据”是隐私光谱的一端,但“有些时候我们可能不会保护您的数据”则不是安全性上的可行立场。隐私失败通常发生在用户的期望与实际隐私政策不一致,或当隐私政策明确却被违反时。前者问题的根源在于未能主动向用户解释数据处理方式。后者则发生在政策不明确,或负责人忽视了政策,或在安全漏洞中被破坏。
威胁
威胁通常比本身更可怕。
——索尔·阿林斯基

威胁无处不在,但如果管理得当,你是可以与它们共存的。软件也不例外,唯一不同的是你没有数百万年的进化来准备自己。这就是为什么你需要培养一种软件安全的思维方式,它要求你从开发者的视角转变为攻击者的视角。理解系统可能面临的威胁是将坚固的防御措施和缓解方案融入软件设计的基本起点。但是,要首先察觉这些威胁,你必须停止考虑典型的使用案例和按预期使用软件。相反,你必须把它看作它真正的面貌:一堆代码和组件,数据在其中流动并在各处存储。
例如,考虑一下回形针:它巧妙地设计用来将纸张固定在一起,但如果你适当弯曲回形针,它很容易被重新塑造成一根硬线。安全思维能够辨认出,你可以将这根线插入锁的钥匙孔,通过操控锁芯来打开锁,而无需钥匙。
值得强调的是,威胁包括所有可能造成伤害的方式。恶意攻击是讨论中的重要焦点,但这并不意味着你应当排除由于软件缺陷、人为错误、事故、硬件故障等带来的其他威胁。
威胁建模提供了一种视角,可以指导在整个软件开发过程中影响安全性的任何决策。以下内容侧重于概念和原则,而不是执行威胁建模的许多具体方法论。微软在 2000 年代初期首次实践的早期威胁建模被证明是有效的,但它需要大量的培训,并且投入了相当大的精力。幸运的是,你可以通过多种方式进行威胁建模,一旦理解了概念,便容易根据可用的时间和精力调整流程,同时仍能产生有意义的结果。
列举所有威胁并识别大型软件系统中所有的漏洞点是一项艰巨的任务。然而,聪明的安全工作目标是逐步提高标准,而不是追求完美。你的第一次努力可能只发现所有潜在问题的一部分,并且只能缓解其中一些:即便如此,这也是一次实质性的改进。这种努力或许能避免一次重大安全事件——这本身就是一项真正的成就。不幸的是,你几乎永远不知道被挫败的攻击,缺乏反馈可能会让人感到失望。你越是锻炼你的安全思维肌肉,就越能更好地识别威胁。
最后,重要的是要理解,威胁建模可以提供超出安全范围的对目标系统的新层次的理解。通过以新的方式审视软件,你可能会获得一些见解,这些见解建议了各种改进、效率、简化以及与安全无关的新功能。
对抗性视角
漏洞利用是我们在现实世界中体验到的最接近“魔法咒语”的东西:构建正确的咒语,便能远程控制设备。
—Halvar Flake
人类行为者是终极威胁;安全事件不会自动发生。对软件安全的任何深入分析都包括考虑假设的对手可能会尝试的行为,以预测和防御潜在的攻击。攻击者是一个杂乱无章的群体,从脚本小子(没有技术技能的罪犯使用自动化恶意软件)到复杂的国家级行为者,及其中的一切。只要你能从对手的角度思考,那是很好的,但不要自欺欺人地相信你能准确预测他们的每一步,也不要花太多时间试图进入他们的思维,就像一个高手侦探智胜一个狡猾的敌人一样。理解攻击者的思维方式很有帮助,但就我们构建安全软件的目的而言,实际技术细节——他们可能用来探测、渗透和窃取数据的手段——并不重要。
考虑系统内可能的明显目标(有时,对对手有价值的东西对你来说价值较低,反之亦然),确保这些资产得到充分保护,但不要浪费时间去揣测假设攻击者的想法。与其浪费不必要的精力,他们通常会集中精力在最薄弱的环节上,以实现他们的目标(或者他们可能是在无目的地瞎摸,这很难防范,因为他们的行为看起来是无方向和任意的)。错误无疑会引起注意,因为它们暗示了系统的弱点,攻击者一旦发现明显的漏洞,就会尝试创造性变体,看他们能否真正突破某些东西。泄露系统内部细节的错误或副作用(例如,详细的堆栈转储)是攻击者最乐于利用的跳板。
一旦攻击者发现了一个弱点,他们很可能会集中更多精力在其上,因为一些小瑕疵往往会在集中的攻击下扩展并产生更大的后果(如我们将在第八章详细讨论)。通常,将两个单独无关的小瑕疵结合起来就能产生重大攻击,因此认真对待所有漏洞是明智的。熟练的攻击者无疑了解威胁建模,尽管他们通常是没有内部信息的(至少在他们成功渗透之前)。
尽管我们永远无法真正预见对手将花时间在什么上,但考虑假想攻击者的动机,作为攻击勤奋程度的衡量标准,是有意义的。从某种角度来说,这就像是某个著名罪犯解释为什么抢银行:“因为那儿有钱。”关键是,攻击一个系统的潜在收益越大,你就能期望潜在的攻击者投入更高水平的技能和资源。尽管这一点具有一定的推测性,但这一分析作为相对指南非常有用:强大的企业、政府、军事和金融机构是大目标。你的猫咪照片不是。
最终,和所有形式的暴力一样,攻击和造成伤害总是比防御要容易得多。攻击者可以选择他们的切入点,凭借决心,他们可以尝试尽可能多的漏洞利用,因为他们只需要成功一次。这一切都意味着为何优先考虑安全工作至关重要:防御者需要所有可用的优势。
四个问题
曾在微软多年负责威胁建模的亚当·肖斯塔克(Adam Shostack)将该方法论简化为四个问题:
-
我们在做什么工作?
-
会发生什么问题?
-
我们将如何应对?
-
我们做得好吗?
第一个问题旨在确定项目的背景和范围。回答这个问题包括描述项目的需求和设计,组件及其相互作用,并考虑操作问题和使用案例。接下来,在该方法的核心部分,第二个问题试图预测潜在的问题,而第三个问题则探讨我们识别出的这些问题的缓解措施。(我们将在第三章更详细地研究缓解措施,但首先我们将探讨它们与威胁的关系。)最后,最后一个问题要求我们反思整个过程——软件做了什么,它可能出错的地方,以及我们如何有效地缓解这些威胁——以评估风险减少情况并确认系统足够安全。如果仍有未解决的问题,我们将重新审视这些问题,填补剩余的空白。
威胁建模远不止这些,但令人惊讶的是,仅仅从四个问题开始就能取得如此大的进展。掌握这些概念,并结合本书中的其他思想和技术,你可以显著提高你所构建和操作的系统的安全性。
威胁建模
“可能会出什么问题?”
我们经常问这个问题来开个讽刺的玩笑。但当问题没有讽刺意味时,它简洁地表达了威胁建模的出发点。回答这个问题要求我们识别和评估威胁;然后我们可以对这些威胁进行优先排序,并着手实施那些减少重要威胁风险的缓解措施。
让我们分解一下前面的句子。以下步骤概述了基本的威胁建模过程:
-
从系统模型出发,确保我们考虑到所有的范围。
-
识别系统中需要保护的资产(有价值的数据和资源)。
-
针对每个组件仔细检查系统模型,识别攻击面(攻击可能发生的地方)、信任边界(将系统中更受信任部分与较少受信任部分连接的接口)以及不同类型的威胁。
-
分析这些潜在的威胁,从最具体的到假设性的威胁。
-
对威胁进行排名,从最关键到最不关键进行排序。
-
提出减轻措施,以减少最关键威胁的风险。
-
添加缓解措施,从最有影响力且最容易实现的措施开始,逐步提高,直到边际效益递减的点。
-
测试缓解措施的有效性,从最关键的威胁的缓解措施开始。
对于复杂系统,列出所有潜在威胁的完整清单将是庞大的,全面分析几乎肯定是不可行的(就像列举出任何事情可能的每一种做法一样,如果你足够有创意,这个过程是没有尽头的,而攻击者常常就是如此)。实际上,第一次威胁建模时应当仅关注对高价值资产的最大且最可能的威胁。一旦你理解了这些威胁并采取了第一道防护措施,你就可以通过反复考虑已经识别出的其他较小威胁,来评估剩余风险。从那时起,你可以根据需要进行一次或多次额外的威胁建模,每次扩大范围,包括更多的资产、更深入的分析以及更多较不可能或较小的威胁。当你已经充分理解最重要的威胁,规划了必要的缓解措施,并认为剩余已知风险可以接受时,过程就可以结束了。
人们在日常生活中本能地会做类似于威胁建模的事情,我们称之为常识性预防措施。例如,在公共场所发送私人信息时,大多数人选择打字而不是大声朗读给手机听。用威胁建模的语言来说,我们可以认为信息内容是信息资产,而信息泄露是威胁。讲话声音在其他人可听范围内是攻击面,而使用静音的替代输入方法是一个良好的缓解措施。如果有一个好奇的陌生人在旁边看着,你还可以增加一个额外的缓解措施,比如用另一只手遮住手机屏幕以防泄露。然而,尽管我们在现实世界中常常自然而然地做这些事情,但将这些相同的技巧应用于复杂的软件系统时,由于我们的直觉不再适用,就需要更多的自律。
从模型开始
你需要采取严谨的方法来彻底识别威胁。传统上,威胁建模使用数据流图(DFD)或统一建模语言(UML)来描述系统,但你可以使用任何你喜欢的模型。无论你选择哪种高层次的系统描述,不管是 DFD、UML、设计文档,还是非正式的“白板讨论”,其核心思想是查看系统的抽象,只要它具有足够的粒度来捕捉你需要进行分析的细节。
更正式的方法通常更严谨,能够产生更准确的结果,但代价是需要更多的时间和精力。多年来,安全社区发明了许多替代方法,提供了不同的权衡,其中很大一部分原因是因为完整的威胁建模方法(包括使用像 DFD 这样的正式模型)既昂贵又费力。如今,你可以使用专门的软件来帮助这一过程。最好的软件能够自动化工作中的重要部分,尽管解释结果和做出风险评估始终需要人的判断。本书会告诉你所有你需要知道的内容,以便独立进行威胁建模,无需特殊的图表或工具,只要你足够了解系统,能够彻底回答四个问题。从这里开始,你可以根据需要进行更高级的建模。
无论你使用什么模型,都要在适当的分辨率下彻底覆盖目标系统。通过金发姑娘原则选择适当的细节层次:不要尝试过多细节,否则工作将变得无休无止;也不要过于高层次,否则你将遗漏重要细节。完成过程很快且几乎没有成果,通常是粒度不足的明显标志,就像在工作数小时后进展甚微则表明你的模型可能过于细化。
让我们考虑一下什么是通用 Web 服务器的适当粒度。你得到一个模型,这个模型包含一个块状图,图的左边是“互联网”,中间连接着一个“前端服务器”,右边是第三个组件“数据库”。这个模型并没有帮助,因为几乎每个开发的 Web 应用都适应这个模型。所有的资产可能都在数据库里,但它们到底是什么?系统和互联网之间肯定有一个信任边界,但这就是唯一的边界吗?显然,这个模型操作的层次过高。另一个极端是显示每个库的详细分解、框架的所有依赖项以及组件关系的模型,远低于你想分析的应用层级。
金发姑娘版本将落在这两个极端之间。数据库中存储的数据(资产)将被划分为类别,每个类别都可以视为一个整体:比如客户数据、库存数据和系统日志。服务器组件将被拆分为足够细化的部分,以揭示多个进程,包括每个进程运行的权限级别,可能还有主机上的内部缓存,以及用于与互联网和数据库通信的网络和通信渠道的描述。
确定资产
按照有条理的方式逐步完成模型,识别资产及其潜在威胁。资产是系统中必须保护的实体。大多数资产是数据,但它们也可能包括硬件、通信带宽、计算能力和物理资源,如电力。
对于威胁建模的初学者来说,自然希望保护一切,这在一个完美的世界中是理想的。然而在实际操作中,你需要优先考虑你的资产。例如,考虑任何一个网络应用程序:互联网上的任何人都可以通过浏览器或其他你无法控制的软件访问它,因此不可能完全保护客户端。此外,你应该始终保持内部系统日志的私密性,但如果日志包含对外部人员无价值的无害细节,那么投入大量精力去保护它们就没有意义。这并不意味着你完全忽视这些风险;只要确保不太重要的缓解措施不会耗费掉你在其他地方所需的精力。例如,通过设置权限使只有管理员可以读取日志内容,实际上只需要一分钟,这就是值得花费的努力。
另一方面,你可以有效地将表示财务交易的数据视为真实的货币,并相应地优先考虑它。个人信息是另一个日益敏感的资产类别,因为知道一个人的位置或其他身份细节可能会侵犯他们的隐私,甚至使他们面临风险。
此外,我通常建议不要试图进行复杂的风险评估计算。例如,避免试图为风险排名分配美元价值。为了做到这一点,你需要设法为许多无法预知的因素计算概率。会有多少攻击者针对你,攻击的强度如何,以及他们要做什么?他们成功的频率有多高,成功的程度如何?客户数据库的价值有多少?(请注意,它对公司的价值和攻击者能够出售的价格通常是不同的,用户对自己数据的价值评估也可能不同。)假设的安全事件将造成多少工作小时和其他费用?
相反,一种简便且出乎意料有效的资产优先排序方法是根据“T 恤尺寸”进行排名——这种简化方法我觉得很有用,尽管它并不是行业标准做法。将“Large”分配给必须保护的重要资产,将“Medium”分配给那些价值较高但不那么关键的资产,将“Small”分配给那些影响较小的次要资产(通常甚至不列出)。高价值系统可能会有“Extra-Large”资产,需要特殊保护级别,例如金融机构的银行账户余额,或支撑通讯安全的私人加密密钥。在这种简单的分类方法中,保护和缓解工作首先集中在 Large 资产上,然后机会性地集中在 Medium 资产上。机会性保护指的是低成本的工作,几乎没有下行风险。但即便你能非常机会性地保护 Small 资产,也应该先保护所有 Large 资产,再花时间处理这些。第十三章详细讨论了漏洞排序,这其中的大部分内容同样适用于威胁评估。
你选择优先保护的资产应该包括如客户资源、个人信息、业务文件、操作日志和软件内部结构等数据,仅举几例。优先保护数据资产需要考虑许多因素,包括信息安全(第一章讨论的 C-I-A 三原则),因为数据泄露、修改和销毁的危害可能差异很大。信息泄露,包括部分信息披露(例如信用卡号的最后四位数字),是难以评估的,因为你必须考虑攻击者能够利用这些信息做什么。当攻击者能够将多份信息碎片组合成近似完整的数据集时,分析变得更加复杂。
如果你将资产进行合并,可以显著简化分析,但要小心过程中可能失去的细节。例如,如果你管理多个数据库,并且以相似的方式授予访问权限、用于来自类似来源的数据、并存储在同一位置,将它们视为一个整体是合乎逻辑的。然而,如果这些因素中任何一项有显著差异,你就有充分的理由将它们分开处理。确保在风险分析和缓解过程中考虑这些差异。
最后,始终从各方的角度考虑资产的价值。例如,社交媒体服务管理着各种数据:公司内部计划、广告数据和客户数据。这些资产的价值会根据你是公司 CEO、广告商、客户,还是可能是寻求经济利益或政治议程的攻击者而有所不同。事实上,即使是在客户之间,你也可能会发现他们在如何看待通信隐私的重要性或他们对数据价值的看法上有很大差异。良好的数据管理原则表明,你对客户和合作伙伴数据的保护,应该超过对公司自己专有数据的保护(我曾听到过公司高管实际将这一点作为政策)。
并不是所有公司都采取这种方法。Facebook 的 Beacon 功能会自动将用户的购买详情发布到他们的动态中,随后在客户强烈反感并发生一些诉讼后迅速关闭。虽然 Beacon 并未危及 Facebook(除非损害品牌声誉),但它确实对客户构成了真正的危险。对客户信息泄露后果进行威胁建模,迅速揭示出圣诞礼物、生日礼物,甚至更糟的求婚戒指的购买信息的意外泄露,可能会带来麻烦。
确定攻击面
特别注意攻击面,因为这些是攻击者的第一道入口。你应该把任何减少攻击面机会的行动视为一次重大胜利,因为这样可以彻底切断潜在的麻烦源。许多攻击可能会在系统中扩散,因此尽早阻止它们可以成为一种有效的防御。这就是为什么安全的政府建筑会在唯一的公共入口处设置金属探测器检查点的原因。
软件设计通常比物理建筑设计复杂得多,因此确定整个攻击面并非易事。除非你能够将系统嵌入受信任的安全环境中,否则一些攻击面是不可避免的。互联网始终提供了一个巨大的暴露点,因为几乎任何人在任何地方都可以匿名连接。虽然将内网(私有网络)视为受信任的环境可能很诱人,但除非它具有非常高的物理和 IT 安全标准,否则你可能不应该这么做。至少,将其视为一个具有较低风险的攻击面。对于设备或自助服务终端应用,考虑将外部部分的盒子,包括屏幕和用户界面按钮,视为攻击面。
注意,攻击面不仅存在于数字领域之外。以自助终端为例:公共区域的显示屏可能通过“肩膀窥视”泄露信息。攻击者还可以进行更微妙的侧信道攻击,通过监测系统的电磁辐射、热量、功耗、键盘声音等,推测系统的内部状态。
识别信任边界
接下来,识别系统的信任边界。由于信任和权限几乎总是成对出现,如果你觉得“权限边界”这个概念更容易理解,可以将其视为权限边界。信任边界的类比在人类社会中可能是经理(能够了解更多内部信息的人)与员工之间的关系,或者是你家门口的门,你选择让谁进入。
考虑一个经典的信任边界示例:操作系统的内核与用户空间接口。这种架构在大型计算机成为常规,并且机器经常由多个用户共享的时代变得流行。系统启动时加载内核,它将应用程序隔离在不同的用户空间进程实例中(对应不同的用户账户),避免它们相互干扰或导致整个系统崩溃。每当用户空间代码调用内核时,执行就跨越了一个信任边界。信任边界很重要,因为进入更高权限的执行是带来更大麻烦的机会。
SSH 安全 Shell 守护进程(sshd(8))是信任边界安全设计的一个很好的例子。SSH 协议允许授权用户远程登录主机,然后通过互联网的安全网络通道运行 shell。但 SSH 守护进程需要非常谨慎的设计,因为它跨越了信任边界。该监听进程通常需要超级用户权限,因为当授权用户提供有效凭证时,它必须能够为任何用户创建进程。然而,它还必须监听公共互联网,暴露给外界进行攻击。
要接受 SSH 登录请求,守护进程必须生成一个安全的通信通道,该通道不能被窃听或篡改,然后处理和验证敏感的凭证。只有这样,它才能以正确的权限在主机计算机上启动一个 shell 进程。整个过程涉及大量的代码,这些代码以最高权限级别运行(因此它可以为任何用户账户创建进程),必须完美运行,否则将深刻危及系统安全。来自互联网上的请求可以来自任何地方,且最初无法与攻击区分开来,因此很难想象有比这更具吸引力的目标和更高的风险。
鉴于攻击面之广以及任何漏洞的严重性,守护进程的风险缓解需要付出大量的努力是有充分理由的。图 2-1 展示了它是如何设计来保护这一关键的信任边界的简化视图。

图 2-1:SSH 守护进程设计如何保护关键的信任边界
从顶部开始,每个传入连接都会分叉一个低权限的子进程,该进程监听套接字并与父进程(超级用户)进行通信。这个子进程还设置了协议的复杂安全通道加密,并接受登录凭据,然后将其传递给特权父进程,由父进程决定是否信任传入请求并授予其访问权限。为每个请求分叉一个新的子进程为信任边界提供了战略性的保护;它尽可能地将工作隔离开来,同时最小化了主守护进程中可能产生的意外副作用。当用户成功登录时,守护进程会创建一个新的 shell 进程,赋予已认证用户账户的权限。当登录尝试未能通过认证时,处理该请求的子进程会终止,从而避免将来对系统产生不良影响。
与资产类似,你需要决定何时将信任级别合并或拆分。在操作系统中,超级用户显然是最高信任级别,一些其他的管理员用户可能接近于你应视为与其同等特权的程度。授权用户通常排在信任等级的下一个位置。有些用户可能会形成一个更受信任的群体,享有特殊的权限,但通常,你不需要在这些用户之间决定谁更值得信任。访客账户通常排在信任级别的最低处,你可能应该更加关注保护系统免受它们的影响,而不是保护它们的资源。
网络服务需要抵御恶意客户端用户,因此,Web 前端系统可能会验证传入流量,仅转发格式正确的请求以提供服务,实际上是跨越了信任边界,连接到了互联网。Web 服务器通常会连接到防火墙后面更受信任的数据库和微服务。如果涉及到金钱(比如信用卡支付服务),应该由一个专用的高信任系统来处理支付,最好将其隔离在数据中心的一个封闭区域内。经过身份验证的用户应该被信任来访问他们自己的账户数据,但在此之外,你应该把他们视为完全不可信,因为通常任何人都可以创建一个登录账号。匿名公共网页访问代表了更低的信任级别,静态公共内容可以由不连接任何私有数据服务的机器提供。
始终通过明确定义的接口和协议来进行信任边界之间的过渡。你可以把这些想象成类似于国际边界和入境口岸的检查点,配有武装警卫。就像边境控制人员要求查看你的护照(身份验证的一种形式)并检查你的物品(输入验证的一种形式)一样,你应该把信任边界视为一个丰富的机会来减轻潜在的攻击。
最大的风险通常隐藏在低信任到高信任的过渡中,比如 SSH 监听器的例子,原因显而易见。然而,这并不意味着你应该忽视高信任到低信任的过渡。每当你的系统将数据传递给一个低信任的组件时,都值得考虑你是否在泄露信息,以及这样做是否可能成为问题。例如,即使是低权限的进程也能读取它们运行所在计算机的主机名,因此不要使用可能泄露敏感信息的机器名称,这样如果攻击者成功入侵并在系统上运行代码,可能会给他们提供线索。此外,每当高信任服务代表低信任请求工作时,如果用户端请求者设法使内核超负荷运行,就有可能遭受 DoS 攻击。
识别威胁
现在我们开始威胁建模的核心工作:识别潜在的威胁。从你的模型出发,仔细审视系统的各个部分。威胁往往集中在资产和信任边界周围,但也可能潜伏在任何地方。
我建议从粗略的审视开始(比如从系统的 10,000 英尺高度开始),然后再回过头来进行更为深入的检查(以 1,000 英尺的高度)——尤其是那些更有价值或更有趣的部分。保持开放的思维,并确保即使你暂时还无法看到如何利用它们,也不要排除任何可能性。
识别直接威胁你的资产的威胁应该是容易的,以及信任边界上的威胁,攻击者可能轻易地欺骗受信任的组件来为他们做事。本书中有许多具体情境下的威胁示例。然而,你也可能会发现一些间接的威胁,可能是因为没有立即可用的资产可以受到伤害,或者没有信任边界可以跨越。在没有考虑如何将这些威胁作为一系列事件的组成部分之前,不要立即忽视它们——把它们当作台球中的反弹球,或是构成路径的跳板。为了造成伤害,攻击者必须将多个间接威胁结合起来;或者,可能是与漏洞或设计不良的功能结合,这些间接威胁提供了攻击者进入的突破口。即便是较小的威胁,也可能值得缓解,这取决于它们看起来有多大潜力,以及风险资产的关键程度。
一个银行金库的例子
到目前为止,这些概念可能仍然显得相当抽象,因此让我们通过威胁建模一个虚构的银行金库来看看它们在实际中的应用。在阅读这篇教程时,专注于这些概念,如果你仔细观察,你应该能够扩展我提到的观点(这些观点故意并不详尽)。
想象一下你家乡的银行办公室。假设它是一座较老的建筑,前面有雄伟的罗马柱框住沉重的橡木双门。建造时劳动力和材料成本较低,厚重的钢筋混凝土墙看起来几乎不可穿透。为了这个例子,我们将仅关注存储在银行大楼中心的安全金库里的大量黄金:这是我们想要保护的主要资产。我们将使用建筑图纸作为模型,基于一张 10 英尺对 1 英寸比例的楼层平面图来概览整个建筑的布局。
主要的信任边界显然在金库门,但在柜台后面的员工专用区域有另一个信任边界,而第三个则是银行的正门,它将顾客大厅与外部隔开。为了简便起见,我们将模型中省略后门,因为它始终被非常牢固地锁住,只有在守卫在场时才会偶尔打开。这使得正门和易于进入的顾客大厅区域成为唯一的重大攻击面。
所有这些为寻找潜在威胁的实际工作奠定了基础。显然,黄金被盗是最大的威胁,但这一点太笼统,无法提供很多关于如何预防的洞见,因此我们继续寻找具体的细节。攻击者需要获得未经授权的金库访问权限,才能盗取黄金。为了做到这一点,他们需要进入金库所在的员工专用区域的未经授权的访问权限。到目前为止,我们还不知道如何发生这种抽象的威胁,但我们可以将其分解并变得更具体。以下是一些潜在的威胁:
-
偷偷观察金库密码。
-
猜测金库密码。
-
用化妆和假发冒充银行行长。
诚然,这些虚构的威胁相当愚蠢,但请注意我们是如何从一个模型出发,并从抽象的威胁转向具体的威胁的。
在更详细的第二轮检查中,我们现在使用一个包含完整建筑图纸、电气和管道布局以及金库设计规格的模型。通过更详细的信息,我们可以更容易地想象出具体的攻击方式。以我们刚刚列出的第一个威胁为例:攻击者观察金库密码。这可能通过几种方式发生。让我们看看其中的三种方式:
-
一名眼光敏锐的抢劫犯在大厅徘徊,观察金库的开启过程。
-
金库密码写在一张便签纸上,柜台的顾客可以看到。
-
街对面的同伙通过瞄准镜观察金库密码盘。
自然,单纯知道金库密码并不能让入侵者得到任何金子。一个外部人员学到密码是一个重大威胁,但它只是一个完整攻击的部分,这个攻击必须包括进入员工专用区域、进入金库,然后带着金子逃跑。
现在我们可以优先考虑列举的威胁,并提出缓解措施。以下是针对我们识别的每种潜在攻击的一些直接缓解措施:
-
大厅徘徊者:在金库前放置不透明屏风。
-
便签泄漏:实施一项政策,禁止未加密的书面副本。
-
监视间谍:安装不透明、半透明的玻璃窗户。
这些只是众多可能的防御性缓解措施中的一部分。如果在建筑设计时考虑了这些类型的攻击,也许布局本身就能消除一些威胁(例如,通过确保没有任何外部窗户能直接看到金库区域,从而避免需要改装不透明玻璃)。
真实的银行安全和财务风险管理当然复杂得多,但这个简化的例子展示了威胁建模过程是如何工作的,包括它如何推动分析向前发展。金库里的黄金是最简单的资产之一,但现在你应该在想,如何准确地检查一个复杂软件系统的模型,才能看到它所面临的威胁呢?
使用 STRIDE 对威胁进行分类
在 1990 年代后期,微软 Windows 主导了个人计算机的市场。随着个人计算机成为企业和家庭的必备工具,许多人认为该公司的销售将无止境地增长。但微软那时才刚刚开始弄清楚网络该如何工作。互联网(当时通常用大写字母 I 拼写)和这个被称为万维网的新事物正迅速流行起来,而微软的 Internet Explorer 浏览器也在积极地从开创性的 Netscape Navigator 那里抢夺市场份额。现在,公司面临了一个新的安全问题:谁知道将全世界的计算机连接起来可能会打开什么样的“潘多拉魔盒”?
当微软的测试团队创造性地工作,寻找安全漏洞时,其他人似乎找到了这些漏洞的速度要快得多。经过几年的被动应对,为暴露给网络的客户发布漏洞补丁后,公司成立了一个特别小组,试图走在前面。作为这项工作的组成部分,我与 Praerit Garg 共同撰写了一篇论文,描述了一种简单的方法,帮助开发者发现自己产品中的安全漏洞。基于STRIDE 威胁分类法的威胁建模推动了公司所有产品组的广泛教育工作。20 多年后,业界的研究人员仍在使用 STRIDE 及其众多独立衍生方法来列举威胁。
STRIDE 通过给出一个需要考虑的具体威胁类型清单,聚焦于威胁识别过程:什么可以被伪造(S)、篡改(T)或否认(R)?什么信息(I)可能被泄露?如何可能发生拒绝服务(D)或特权提升(E)?这些类别足够具体,能够聚焦你的分析,同时又足够宽泛,你可以根据特定设计进行细节补充并深入分析。
尽管安全社区的成员通常将 STRIDE 称为一种威胁建模方法论,但这其实是对该术语的误用(至少在我看来,作为创造这个首字母缩略词的人)。STRIDE 仅仅是软件威胁的一个分类法。这个首字母缩略词提供了一个简便且易记的助记符,确保你没有忽略任何威胁类别。它并不是一个完整的威胁建模方法论,后者必须包含我们在本章中已探讨的许多其他组成部分。
要了解 STRIDE 如何工作,我们从欺骗攻击开始。逐个查看模型的各个组件,考虑安全操作如何依赖于用户(或机器、或代码上的数字签名等)的身份。如果攻击者能够在这里伪造身份,他们可能会获得哪些优势?这种思维方式应该会给你提供很多可以深入挖掘的线索。通过从威胁的角度看待模型中的每个组件,你可以更容易地摒弃“应该如何工作”的想法,转而开始感知它可能如何被滥用。
这是我成功使用过许多次的一个极好的技巧:通过在白板上写下六种威胁名称来开始你的威胁建模会议。为了启动思路,在深入细节之前,先头脑风暴一下这些抽象的威胁。术语“头脑风暴”可以有不同的含义,但这里的重点是快速推进,覆盖广泛的领域,而不是过度思考或过早评判想法(你可以在后续过程中跳过那些无用的点子)。这种热身程序能够帮助你调整思维,明确要关注的内容,并帮助你切换到必要的心态。即使你已经熟悉这些威胁类别,逐一过一遍也是值得的,其中有些更为陌生且技术性较强的内容,值得仔细解释。
表 2-1 列出了六个安全目标,它们对应的威胁类别,以及每个类别中的一些威胁示例。安全目标和威胁类别是同一个问题的两面,有时从其中一个角度出发会更容易——从防守(目标)角度或进攻(威胁)角度。
表 2-1:STRIDE 威胁类别总结
| 目标 | STRIDE 威胁 | 示例 |
|---|---|---|
| 身份认证 | 欺骗攻击 | 网络钓鱼、盗用密码、冒充、重放攻击、BGP 劫持 |
| 完整性 | 篡改 | 未授权的数据修改和删除、Superfish 广告注入 |
| 不可否认性 | 否认 | 可信否认、日志不足、日志销毁 |
| 保密性 | 信息披露 | 数据泄露、侧信道攻击、弱加密、残留缓存数据、Spectre/Meltdown |
| 可用性 | 服务拒绝 | 同时请求淹没 Web 服务器、勒索软件、memcrashed |
| 授权 | 权限提升 | SQL 注入、xkcd 的“母亲的黑客行为” |
STRIDE 模型中的一半威胁直接涉及你在第一章中学习的信息安全基础:信息披露是机密性的敌人,篡改是完整性的敌人,服务拒绝攻击危害可用性。STRIDE 模型的另一半则针对黄金标准。欺骗通过假冒身份来颠覆真实性。权限提升通过越权行为来颠覆正确的授权。而否认则是对审计的威胁,这一点可能不是很明显,因此值得深入探讨。
根据黄金标准,我们应该对系统中采取的关键操作保持准确记录,并对这些操作进行审计。否认发生在某人可信地否认自己执行过某个操作时。在我从事软件安全工作多年中,我从未见过有人直接否认做过什么(没有人曾在我面前大喊“做了!”和“没做!”)。但确实会发生这样的情况:比如,某个数据库突然消失,而没人知道为什么,因为没有任何日志记录,丢失的数据也没有留下任何痕迹。组织可能会怀疑发生了入侵事件,或者是某个内部人员的恶意行为,或者可能是管理员的一个不幸失误。但没有任何证据,谁也不清楚发生了什么。这是一个大问题,因为如果在事件发生后无法解释发生了什么,就很难防止类似事件再次发生。在物理世界中,类似的完美犯罪很少见,因为像抢银行这样的活动通常需要实际的物理存在,必然会留下各种各样的痕迹。而软件世界则不同;除非你提供可靠的方式来收集证据和记录事件,否则不会留下任何指纹或泥泞的脚印作为证据。
通常,我们通过运行系统来缓解否认威胁,在这些系统中,管理员和用户都明白自己对自己的行为负责,因为他们知道存在准确的审计跟踪。这也是避免将管理员密码写在每个人都共享的便签上的另一个好理由。如果你这么做了,当麻烦发生时,每个人都可以合理地声称是其他人做的。即使你完全信任每个人,这一点也适用,因为意外总是会发生,且当麻烦出现时,你拥有的证据越多,恢复和修复就越容易。
电影中的 STRIDE
仅为好玩(并加深对这些概念的理解),请考虑将 STRIDE 威胁应用到电影《十一罗汉》的情节中。这部经典的抢劫故事很好地展示了威胁建模概念,包括从攻击者和防御者的角度看,涵盖了 STRIDE 的所有类别。对于情节的简化,抱歉,这是为了简洁和集中,另外还包含了一些剧透。
丹尼·欧申(Danny Ocean)违反了假释规定(特权提升),飞往与他的老搭档会面,然后前往拉斯维加斯。他向一位富有的赌场内部人士提出了一个大胆的抢劫计划,该内部人士向他透露了赌场的运营细节(信息披露),然后召集了他的前罪犯团队。他们利用一座为练习而建的全尺寸复制金库来规划他们的行动。在那个决定性的夜晚,丹尼出现在赌场,按预期被安保人员逮捕,为自己创造了完美的不在场证明(否认罪行)。很快,他通过通风管道悄然逃脱,经过一系列的阴谋,他和同伙们从金库中提取了大约一半的钱(篡改金库的完整性),并通过一辆遥控面包车将赃款带走。
该团队威胁要炸掉金库中剩余的数百万美元(一个非常昂贵的拒绝服务攻击),他们谈判要求将钱保存在面包车里。赌场老板拒绝了并叫来了特警队,在随后的混乱中,团队摧毁了金库的内容并成功逃脱。烟雾散去后,赌场老板检查了金库,哀叹自己的全盘亏损,然后注意到一个似乎不对劲的小细节。老板 confronts 丹尼——他已经回到拘留所,就像从未离开过一样——然后我们得知,特警队实际上就是那群人(伪装成警察),他们在假战斗后从战术装备包中拿走了藏匿的赃款。练习用的金库模型提供了视频,使得人们只认为(伪装位置)真正的金库已经被破坏,实际上直到赌场完全允许假特警队进入时才发生了这一切(对团队而言是一次特权提升)。丹尼和他的同伙带着赃款毫发无损地逃脱了——这是一个对犯罪者来说的圆满结局,要是赌场请了一个威胁建模顾问,结果可能会完全不同!
缓解威胁
在这一阶段,你应该已经收集了潜在的威胁。现在你需要评估和优先排序这些威胁,以便有效地指导防御。由于威胁充其量只是对未来事件的有根据的猜测,你的所有评估都会包含一定程度的主观性。
完全理解威胁到底意味着什么?这个问题没有简单的答案,但它涉及到对我们所知道的内容进行提炼,并保持健康的怀疑态度,以避免陷入认为自己已经掌握所有信息的陷阱。实际上,这意味着迅速扫描以收集大量主要是抽象的威胁,然后深入研究每个威胁以了解更多。也许我们会看到一两个比较明确的攻击,或者构成攻击的一部分。我们不断展开分析,直到遇到回报递减的瓶颈。
在此时,我们可以通过以下四种方式之一来应对已识别的威胁:
-
缓解风险,通常通过重新设计或增加防御措施来减少其发生频率或将伤害程度降低到可接受的水平。
-
如果不必要,可以移除受威胁的资产,或者如果移除不可行,可以尝试减少其暴露或限制增加威胁的可选功能。
-
转移风险,将责任转交给第三方,通常以补偿交换。(例如,保险就是一种常见的风险转移形式,或者将敏感数据的处理外包给有保护保密责任的服务商。)
-
接受经过充分了解后,认为合理承受的风险。
始终尝试缓解任何重大威胁,但要认识到结果通常是混合的。在实践中,最佳解决方案并非总是可行的,原因有很多:一个重大变更可能过于昂贵,或者你可能被迫使用超出你控制范围的外部依赖。其他代码可能也依赖于易受攻击的功能,因此修复可能会导致问题。在这些情况下,缓解意味着采取任何可以减少威胁的措施。任何防御上的优势都有帮助,哪怕是微小的。
下面是一些部分缓解方法的示例:
减少伤害发生的可能性
- 确保攻击仅在部分时间内有效。
减少伤害的严重性
- 确保只有一小部分数据可能被销毁。
使其能够撤销伤害
- 确保可以轻松地从备份中恢复任何丢失的数据。
明确表明伤害已经发生
- 使用防篡改包装,方便检测被篡改的产品,从而保护消费者。(在软件中,良好的日志记录在这里有帮助。)
本书的剩余部分大多涉及缓解:如何设计软件以最小化威胁,以及哪些策略和安全软件模式有助于制定各种类型的缓解措施。
隐私考虑
隐私威胁和安全威胁一样真实,它们在对系统进行全面威胁评估时需要单独考虑,因为它们为信息泄露的风险增添了人类因素。除了可能的监管和法律考虑外,个人信息处理可能涉及伦理问题,并且尊重利益相关者的期望也非常重要。
如果你收集任何形式的个人数据,应该将隐私视为基本立场来认真对待。把自己看作是人们私人信息的管理者。努力时刻关注用户的视角,仔细考虑他们可能存在的各种隐私担忧,并倾向于小心谨慎。在系统构建的逻辑中,软件开发者往往容易忽视个人数据的敏感性。代码中看似数据库模式中的又一个字段,可能是如果泄露,对某个人有实际后果的信息。随着现代生活日益数字化,移动计算的普及,隐私将越来越依赖于代码,可能以难以想象的新方式依赖于代码。所有这一切意味着,现在保持极高的警惕,远远走在技术前沿,会是明智之举。
一些常见的减少隐私威胁的考虑因素包括:
-
通过建模实际使用案例的场景来评估隐私,而不是从抽象的角度思考。
-
了解适用的隐私政策或法律要求,并严格遵守条款。
-
限制数据收集,仅限于必要的信息。
-
对可能显得令人不安的情况保持敏感。
-
在没有明确使用意图的情况下,绝不收集或存储私人信息。
-
当已经收集的信息不再使用或没有用处时,主动删除它。
-
尽量减少与第三方的信息共享(如果发生,应该有详细文档记录)。
-
尽量减少敏感信息的披露——理想情况下,这应该仅限于必要知情者。
-
保持透明,帮助最终用户理解你的数据保护措施。
随处进行威胁建模
这里描述的威胁建模过程是我们如何在世界中导航的一个形式化过程;我们通过将风险与机会进行平衡来管理风险。在危险的环境中,所有生物体都根据这些基本原则做出决策。一旦你开始寻找,就可以发现威胁建模无处不在。
当期待朋友带着小孩来访时,我们总是花几分钟做特别的准备。亚历克斯,一个活跃的三岁小孩,思维好奇,所以我们会检查房子进行“儿童安全防护”。这就是纯粹的威胁建模,我们按类别想象潜在的威胁——什么可能伤害到亚历克斯,什么可能被打破,哪些东西最好避免让孩子看到——然后寻找符合这些模式的资产。典型的威胁包括一把金属信件刀,他可能会插进墙壁插座;一个易碎的古董花瓶,他可能会打破;或者一本包含不适合儿童的摄影图像的咖啡桌书。攻击面是任何活跃的幼儿能够接触到的地方。缓解措施通常包括移除、减少或消除暴露或脆弱点:我们可以把易碎的花瓶换成一个仅装有干花的塑料花瓶,或者把它移到壁炉架上。有孩子的人都知道预见到他们可能做的事情有多么困难。例如,我们是否预见到亚历克斯可能堆足够多的书,爬上去够到我们认为够不着的书架?这就是软件之外的威胁建模,也说明了为什么预防性缓解措施值得付出努力。
以下是你在日常生活中可能注意到的其他威胁建模示例:
-
商店专门设计退货政策以缓解诸如盗窃后退货换取商店积分,或者穿过一次新衣物后再退货退款等滥用行为。
-
网站使用条款协议试图防止用户以恶意方式滥用网站。
-
交通安全法律、限速、驾驶执照以及强制汽车保险要求,都是为了使驾驶更安全的缓解机制。
-
图书馆设计借阅政策以缓解盗窃、囤积和对馆藏的损害。
你可能也能想到很多你应用这些技巧的方式。对大多数人来说,当我们能依赖自己对世界的物理直觉时,威胁建模是相当容易的。一旦你意识到软件威胁建模与其他情境中你已经熟练掌握的技能方式相同,你就可以开始将你的自然能力应用于软件安全分析,并迅速提升你的技能水平。
缓解
一切都可以通过艺术和勤奋来缓解。
—盖乌斯·普林纽斯·凯西利乌斯·塞孔都斯(小普林尼)

本章重点讨论了第二章中的四个问题中的第三个:“我们要怎么做?”预测威胁,然后防范潜在的漏洞,是安全思维转化为有效行动的过程。这种积极的应对方式被称为缓解——减少问题的严重性、范围或影响——正如你在上一章看到的,它是我们所有人一直在做的事情。为婴儿喂食时使用的防溅围兜、安全带、限速、火灾报警器、食品安全措施、公共卫生措施和工业安全法规仅仅是缓解措施的几个例子。它们的共同点在于采取主动措施,以避免或减少在面对风险时预期的危害。这也是我们为使软件更加安全所做的大部分工作。
重要的是要牢记,缓解措施可以降低风险,但不能完全消除风险。明确地说,如果你能通过某种方式消除风险——比如,通过移除已知不安全的遗留特性——那当然是可以的,但我不认为这算是缓解措施。相反,缓解措施的重点是使攻击变得不太可能、更困难,或者在发生时对系统造成的损害更小。即使是使漏洞更容易被检测到的措施,也可以视为缓解措施,就像防篡改包装一样,如果它们能够促使更快的响应和修复。每一项小小的努力都能提升系统整体的安全性,即使是微小的胜利,累积起来也能带来显著更好的保护。
本章首先从缓解的概念性讨论开始,然后介绍了一些通用技术。这里的重点是基于威胁建模视角的结构性缓解措施,这些措施对几乎任何系统设计的安全性都有用。接下来的章节将在这些思想的基础上,提供更详细的方法,深入探讨具体的技术和威胁。
本章的其余部分提供了在软件设计中遇到的常见安全挑战的指导:制定访问政策和访问控制、设计接口,以及保护通信和存储。这些讨论共同构成了应对常见安全需求的行动手册,在本书的其余部分将进一步展开。
应对威胁
威胁建模揭示了可能出错的地方,并通过此过程,将我们的安全注意力集中在最关键的地方。但如果认为我们可以始终消除所有漏洞,那将是天真的。风险点——关键事件或决策临界点——是缓解的绝佳机会。
正如你在上一章中学到的,你应该总是首先解决最大的威胁,尽可能地将其限制。以处理敏感个人信息的系统为例,未经授权的泄露威胁无疑是一个重大隐患。对于这一重大风险,可以考虑以下任何或所有措施:最小化对数据的访问,减少收集的信息量,在数据不再需要时主动删除旧数据,进行审计以便在系统被破坏时进行早期检测,并采取措施减少攻击者外泄数据的能力。在确保最高优先级风险的安全后,便捷地缓解较小的风险,前提是不会为设计带来过多开销或复杂性。
一个聪明的缓解措施的好例子是将每次登录尝试提交的密码与加盐哈希值进行检查,而不是直接与明文密码进行对比。保护密码至关重要,因为泄露密码会威胁到基本的认证机制。比较哈希值只需要比直接比较稍微多一些工作,但这是一大胜利,因为它消除了存储明文密码的需要。这意味着即使攻击者以某种方式突破系统,他们也不容易获取实际密码。
这个例子说明了减少危害的概念,但它仅仅适用于密码检查。现在让我们考虑更广泛适用的缓解策略。
结构性缓解策略
缓解措施通常归结为常识:在有机会时减少风险。威胁建模帮助我们从攻击面、信任边界和资产(需要保护的目标)的角度识别潜在漏洞。结构性缓解措施通常适用于模型的这些特征,但它们的实现依赖于设计的具体细节。接下来的子章节讨论了一些应该广泛适用的技术,因为它们在模型抽象层面操作。
最小化攻击面
一旦你识别出系统的攻击面,你就知道了最可能来自哪些漏洞,因此你所做的任何强化系统“外壳”的工作都会带来显著的收益。思考攻击面缩减的一个好方法是,从每个入口点下游涉及的代码和数据的数量来考虑。提供多种接口执行相同功能的系统可能会从统一这些接口中受益,因为这意味着会有更少的代码可能包含漏洞。以下是一些常用的技术示例:
-
在客户端/服务器系统中,你可以通过将功能推送到客户端来减少服务器的攻击面。任何需要服务器请求的操作都会增加一个额外的攻击面,恶意请求或伪造凭证可能会利用这一点。相比之下,如果必要的信息和计算能力存在于客户端,那么就可以减少服务器的负载和攻击面。
-
将功能从任何人都可以匿名调用的公开 API 转移到需要身份验证的 API,可以有效地减少你的攻击面。创建账户的额外步骤减缓了攻击,同时有助于追踪攻击者并执行速率限制。
-
使用内核服务的库和驱动程序可以通过最小化与内核的接口和代码来减少攻击面。这样不仅减少了攻击内核切换的机会,即使攻击成功,用户空间代码也无法造成太大损害。
-
部署和运维提供了许多攻击面减少的机会。对于企业网络,将任何可以放在防火墙后面的东西移动过去,通常是一个轻松的胜利。
-
一个允许通过网络进行远程管理的配置设置是另一个很好的例子:这个功能可能很方便,但如果它很少使用,可以考虑禁用它,必要时改用有线访问。
这些只是攻击面减少起作用的一些最常见场景。对于特定系统,你可能会发现更多富有创意的定制化机会。不断思考减少外部访问、最小化功能和接口的方法,并保护那些不必要暴露的服务。你对功能实际使用情况的理解越深入,就越能发现这些缓解措施。
窄化漏洞窗口
这种缓解技术类似于攻击面减少,但它不是通过比喻的表面积来减少,而是通过减少漏洞发生的有效时间间隔。基于常识,这也是为什么猎人在开枪前才会解除安全装置,开枪后会迅速重新开启安全装置的原因。
我们通常将这种缓解应用于信任边界,低信任数据或请求与高信任代码交互的地方。为了最好地隔离高信任代码,尽量减少它需要执行的处理。例如,在调用高信任代码之前进行错误检查,这样它就可以快速完成工作并退出。
代码访问安全**(CAS),一种如今很少使用的安全模型,完美地展示了这种缓解方法,因为它提供了对代码有效权限的细粒度控制。(完全披露:我曾是.NET Framework 1.0 版本的安全项目经理,该版本将 CAS 作为一个重要的安全特性进行推广。)
CAS 运行时根据信任授予不同代码单元不同的权限。以下伪代码示例演示了一个常见的通用权限用法,它可以授予对某些文件、剪贴板等的访问权限。实际上,CAS 确保高信任代码继承调用它的代码的较低权限,但在必要时,它可以临时声明其更高的权限。以下是权限声明的工作原理:
Worker(parameters) {
// When invoked from a low-trust caller, privileges are reduced.
DoSetup();
`permission`.Assert();
// Following assertion, the designated permission can now be used.
DoWorkRequiringPrivilege();
CodeAccessPermission.RevertAssert();
// Reverting the assertion undoes its effect.
DoCleanup();
}
这个示例中的代码拥有强大的权限,但它可能会被低信任的代码调用。当被低信任代码调用时,该代码最初以调用者的较低权限运行。从技术上讲,实际权限是授予代码、其调用者及其调用者的调用者等所有权限的交集(即最小权限),直到堆栈最上层。Worker 方法执行的某些操作需要比其调用者更高的权限,因此,在进行设置后,它会声明必要的权限,然后调用DoWorkRequiringPrivilege,该方法也必须拥有该权限。完成这部分工作后,它会立即通过调用RevertAssert来撤销特殊权限,然后继续执行剩余的、不需要特殊权限的操作并返回。在 CAS 模型中,时间窗口最小化为此类权限声明的使用提供了保障,当不再需要时可以迅速撤销。
以不同的方式考虑缩小漏洞窗口的应用。网上银行提供了便捷和快速的服务,移动设备使我们能够随时随地进行银行业务。但将银行凭证存储在手机中是有风险的——如果丢失手机,你不希望有人将你的银行账户清空,而移动设备丢失的可能性更大。一个很好的缓解措施,我希望在整个银行业中得到实施,就是能够为每个设备配置你感到舒适的权限级别。一位谨慎的客户可能会限制移动应用仅用于查看余额和设置适度的每日交易限额。然后,客户就可以自信地通过手机进行银行业务。进一步的有效限制可能包括时间窗口、地理位置、仅限本国货币等。这些所有的缓解措施都有帮助,因为它们限制了在任何妥协情况下的最坏情境。
最小化数据暴露
另一种减少数据泄露风险的结构性缓解措施是限制敏感数据在内存中的生命周期。这与前述的技术类似,但这里你要减少敏感数据可访问且可能暴露的时间,而不是减少高权限代码运行的时间。回想一下,进程内访问是很难控制的,所以数据一旦存在内存中,就处于风险之中。当风险很高时,比如处理极其敏感的数据,你可以把它理解为“计时器在运行”。对于最关键的信息——如私密的加密密钥或身份验证凭据(如密码)——一旦不再需要,立刻覆盖任何内存中的副本可能是值得的。这样可以减少通过任何手段泄露数据的时间。如我们将在第九章看到的,Heartbleed 漏洞威胁了整个网络的安全,暴露了许多敏感数据,这些数据就存储在内存中。限制这些数据在内存中的保留时间,可能会是一个有效的缓解措施(如果你愿意的话,可以说是“止血”),即便没有预先知道这个漏洞的存在。
你也可以将这一技术应用到数据存储设计中。当用户在系统中删除其账户时,通常会导致他们的数据被销毁,但系统往往提供一种手动恢复账户的方式,以防账户被意外或恶意关闭。实现这一点的简单方法是将已关闭的账户标记为待删除,并在数据保留 30 天(即手动恢复期已过)后最终删除所有数据。为了实现这一点,需要大量代码来检查账户是否被安排删除,以免意外访问用户指示销毁的账户数据。如果批量邮件任务忘记进行检查,可能会错误地发送通知,而这对于用户来说,可能看起来像是违反了他们在关闭账户后所做的决定。这一缓解措施建议了一个更好的选择:在用户删除账户后,系统应将其内容推送到离线备份并及时删除数据。即使需要手动恢复,仍可以通过备份数据来完成操作,并且现在没有任何方式可能由于程序错误导致这种情况的发生。
一般来说,主动擦除数据副本是一种极端措施,只有在处理最敏感数据或重要操作(如账户关闭)时才适用。一些编程语言和库可以自动执行此操作,除了性能问题外,简单的封装函数可以在内存回收之前清除其内容。
访问策略与访问控制
标准操作系统权限提供非常基本的文件访问控制。这些控制读取(机密性)或写入(完整性)访问,基于进程的用户和组所有权,以全有或全无的方式对单个文件进行控制。鉴于这一功能,设计资产和资源保护时很容易以相同的有限视角思考——但正确的访问策略可能会更加细化,并依赖于许多其他因素。
首先,考虑传统访问控制在许多现代系统中如何不合适。Web 服务和微服务设计上是代表那些通常与进程所有者不对应的主体进行工作的。在这种情况下,一个进程处理所有已认证的请求,需要随时访问所有客户端数据的权限。这意味着在存在漏洞的情况下,所有客户端数据都可能面临风险。
定义有效的访问策略是一个重要的缓解措施,因为它缩小了应该允许哪些访问与系统实际提供哪些访问控制之间的差距。与其从现有的操作系统访问控制开始,不如考虑通过系统操作的各个主体的需求,定义一个理想的访问策略,该策略准确描述什么构成了适当的访问。一个细化的访问策略可能提供丰富的选择:你可以限制每分钟、每小时或每天的访问次数,或强制最大数据量、与工作时间对应的基于时间的限制,或根据同行活动或历史速率设定可变的访问限制(仅举几个显而易见的机制)。
确定安全的访问限制是艰难的工作,但非常值得,因为它有助于你理解应用程序的安全需求。即使策略没有完全在代码中实现,它至少也会为有效的审计提供指导。在拥有正确控制集的情况下,你可以从宽松的限制开始,观察真实使用情况,然后随着对系统实际使用的了解,逐渐收紧策略。
例如,考虑一个假设的系统,它为一组客户服务代理提供服务。代理需要访问可能联系他们的任何客户的记录,但他们每天只与有限数量的客户互动。一个合理的访问策略可能会限制每个代理在一个班次中最多只能访问 100 个不同的客户记录。如果可以随时访问所有记录,那么不诚实的代理可能会泄露所有客户数据的副本,而有限的策略则大大限制了最坏情况下的每日损害。
一旦你有了一个精细化的访问政策,你就面临着设定正确限制的挑战。当你必须避免在极端边缘情况下妨碍合理使用时,这可能会很困难。例如,在客户服务的例子中,你可能会限制代理每个班次访问最多 100 个客户的记录,以便应对季节性高峰需求,尽管在大多数情况下,甚至需要 50 个记录也很不寻常。为什么?因为在全年调整政策配置是不实际的,并且你希望留有余地,确保限制永远不会妨碍工作。此外,基于固定日期定义更具体和详细的政策可能效果不佳,因为任何时候都可能出现意外的活动激增。
那么,是否有办法缩小正常情况与系统应允许的极少数高需求情况之间的差距呢?应对这种棘手情况的一个好方法是通过政策条款为自我声明的例外情况提供解决方案,以便在特殊情况下使用。这样的选项允许个别代理通过提供合理的解释,在短时间内提升自己的访问限制。有了这种“减压阀”机制,基本的访问政策可以被严格约束。当需要时,一旦代理达到访问限制,他们可以快速提交通知——例如,声明“今天电话量很大,我加班完成工作”——并获得额外的访问授权。这些通知可以进行审计,如果它们变得常见,管理层可以根据需求确实增加并了解背后的原因,从而调整政策。通过这种灵活的技术,你可以创建带有软性限制的访问政策,而不是那种通常显得随意的硬性限制。
接口
软件设计由与系统功能部分相对应的组件组成。你可以将这些设计可视化为框图,其中的线条表示组件之间的连接。这些连接表示接口,它们是安全分析的主要关注点——不仅因为它们揭示了数据和控制流,还因为它们作为定义良好的瓶颈,可以在其中加入缓解措施。特别是在存在信任边界的地方,主要的安全关注点是数据和控制从低信任组件流向高信任组件的过程,这也是通常需要采取防御措施的地方。
在大型系统中,通常存在网络之间、进程之间以及进程内部的接口。网络接口提供了最强的隔离性,因为几乎可以肯定,端点之间的任何交互都将在网络上传输,但其他类型的接口则更为复杂。操作系统在进程边界提供强大的隔离性,因此进程间通信接口几乎和网络接口一样值得信赖。在这两种情况下,通常无法绕过这些通道以进行其他方式的交互。攻击面被清晰地限制,因此这里也是大多数重要信任边界所在。因此,进程间通信和网络接口是威胁建模的主要关注点。
接口也存在于进程内部,在这些情况下,交互相对不受限制。编写良好的软件仍然可以在进程内创建有意义的安全边界,但这些边界只有在所有代码都能够良好协作并保持规范时才能有效。从攻击者的角度来看,进程内的边界要容易渗透得多。然而,由于攻击者可能仅通过某个特定漏洞获得有限的控制权限,因此任何你能提供的保护总比没有好。类比而言,可以把它想象成一个只有几秒钟时间行事的小偷:即使是一个薄弱的防范措施,也足以防止损失。
任何大型软件设计都面临着一个微妙的任务,即如何构建组件以最小化高权限访问区域,同时限制敏感信息的流动,从而降低安全风险。设计越是限制信息访问,只有极少数的、良好隔离的组件可以访问,攻击者获取敏感数据的难度就越大。相比之下,在设计较弱的系统中,数据流动四散,任何组件中的漏洞都可能导致更大的暴露。接口架构是决定系统能否成功保护资产的一个重要因素。
通信
现代网络化系统如此普遍,以至于不连接任何网络的独立计算机已成为罕见的例外。云计算模型结合移动网络连接,使得网络访问变得无处不在。因此,通信几乎是当今所有使用中的软件系统的基础,无论是通过互联网连接、私有网络,还是通过蓝牙、USB 等外围连接。
为了保护这些通信通道,必须物理上确保其免受窃听和监视,或者数据必须进行加密,以确保其完整性和保密性。依赖物理安全通常是脆弱的,因为如果攻击者绕过它,他们通常能够访问完整的数据流,而这种入侵很难被发现。现代处理器足够快,加密的计算开销通常是可以接受的,因此通常没有充分的理由不对通信进行加密。我在第五章讲解了基本的加密内容,第十一章则专门介绍了 Web 的 HTTPS。
然而,即使是最好的加密也不是万灵药。一个剩余的威胁是加密无法掩盖通信的事实。换句话说,如果攻击者能够读取通道中的原始数据,即使他们无法解读其中的内容,他们仍然可以看到数据正在被发送和接收,并大致估计数据流的量。此外,如果攻击者能够篡改通信通道,他们可能能够延迟或完全阻止传输。
存储
数据存储的安全性与通信的安全性非常相似,因为存储数据类似于将其发送到未来,届时你会为了某个目的而检索它。从这个角度来看,就像正在传输的数据在传输线上是脆弱的,存储的数据在存储介质上静态时也是脆弱的。保护静态数据免受潜在篡改或泄露,需要物理安全性或加密。类似地,数据的可用性依赖于备份副本的存在或成功的物理保护。
存储在系统设计中如此普遍,以至于很容易将数据安全的细节推迟到操作中去处理,但这样做会错失在设计阶段主动减少数据丢失的良机。例如,数据备份要求是软件设计中的重要组成部分,因为这些需求绝非显而易见,而且存在许多权衡。你可以规划冗余存储系统,旨在防止故障时的数据丢失,但这些系统可能成本高昂,并且会带来性能损耗。你的备份可以是整个数据集的副本,也可以是增量备份,记录交易,这些交易的累计可以用来重建一个准确的副本。无论哪种方式,它们都应该被可靠地独立存储,并具有特定的频率,在可接受的延迟范围内。云架构可以提供近实时的冗余数据复制,或许是最好的持续备份解决方案,但代价不小。
所有静态数据,包括备份副本,都面临被未经授权访问的风险,因此你必须物理保护或加密这些数据以确保安全。你制作的备份副本越多,由于副本数量过多,泄露的风险就越大。考虑到潜在的极端情况,这一点就很清楚。照片是珍贵的记忆,是每个家庭历史中不可替代的一部分,因此保存多个备份副本是明智的——如果没有任何副本,而原始文件丢失、损坏或被破坏,损失可能是毁灭性的。为了防止这种情况,你可以将家人的照片复制给尽可能多的亲戚以便保管。但这也有弊端,因为这提高了其中某个人数据被盗的可能性(通过恶意软件,或许是被盗的笔记本电脑)。这也可能是灾难性的,因为这些是私人记忆,如果这些照片被公开传播到互联网上,将侵犯隐私(如果这样会让陌生人以某种方式识别孩子,可能会导致被利用,威胁更大)。这是一个基本的权衡问题,需要你在数据丢失的风险和泄露的风险之间做出权衡——你无法同时最小化两者的风险,但你可以通过几种方式在一定程度上平衡这些担忧。
作为这些威胁之间的折衷,你可以将加密的照片发送给亲戚。(当然,这意味着他们无法查看这些照片。)然而,现在你要负责保管你决定不托付给他们的密钥,如果你丢失了密钥,加密的副本将变得毫无价值。
保存照片也提出了备份数据的一个重要方面,那就是介质的使用寿命和过时问题。物理介质(如硬盘或光盘)随着时间的推移不可避免地会退化,随着新硬件的出现,老旧介质的支持也会逐渐消失(本文作者记得曾经亲自将数据从几十张只能在过时计算机上使用的软盘转移到一只 USB 存储棒,现在这些数据已被复制到云端)。即使介质和设备仍然可用,新软件往往会停止对旧数据格式的支持。因此,数据格式的选择非常重要,广泛使用的开放标准是首选,因为专有格式一旦正式退役,就必须逆向工程。随着时间的推移,可能需要转换文件格式,因为软件标准不断演变,应用程序对旧格式的支持逐渐被弃用。
本章中提到的示例为了说明目的已被简化,尽管我们讨论了许多可以用来缓解已识别威胁的技术,但这些仅仅是冰山一角。具体的缓解措施应根据每个应用程序的需求进行调整,理想情况下将它们融入设计中。虽然听起来很简单,但在实践中,实施有效的缓解措施是具有挑战性的,因为必须考虑到每个系统中的多种威胁,而你能做的只是有限的工作。下一章介绍了具有有用安全属性的主要模式,以及需要警惕的反模式,它们在制定这些缓解措施时作为安全设计的一部分非常有用。
模式
艺术是由感性启发的模式。
—赫伯特·里德

建筑师们长期以来一直使用设计模式来构思新建筑,这种方法对于指导软件设计同样有用。本章介绍了许多促进安全设计的最有用模式。其中一些模式源自古老的智慧;诀窍在于知道如何将它们应用于软件,并了解它们如何增强安全性。
这些模式要么缓解,要么避免各种安全漏洞,构成了应对潜在威胁的重要工具箱。许多模式很简单,但其他模式则更难理解,最好的解释方式是通过示例。不要低估简单的模式,因为它们可以广泛应用,并且是最有效的模式之一。然而,其他一些概念可能更容易理解,作为反模式,它们描述了不该做的事情。我将这些模式按共享特征分组,你可以将它们视为工具箱的各个部分(图 4-1)。

图 4-1:本章涉及的安全软件模式的分组
何时以及在哪里应用这些模式需要判断。让必要性和简洁性引导你的设计决策。尽管这些模式非常强大,但不要过度使用;就像你不需要在门上安装七个防盗锁和链条一样,你也不需要为了解决一个问题而应用每一个可能的设计模式。当多个模式适用时,选择最佳的一两个,或者对于关键的安全需求,选择更多。过度使用可能适得其反,因为增加的复杂性和开销带来的回报递减,迅速超过额外的安全收益。
设计属性
第一组模式高层次地描述了安全设计的样貌:简单和透明。这些来源于格言“保持简单”和“你应该没有什么可以隐藏的”。尽管这些模式基本且可能显而易见,但它们可以广泛应用并且非常强大。
设计经济学
设计应该尽可能简洁。
设计经济性提高了安全性,因为更简单的设计通常有更少的漏洞,因此也有更少的未被发现的安全漏洞。尽管开发人员声称“所有软件都有漏洞”,但我们知道简单的程序确实可以做到无漏洞。为了安全机制,优先选择最简单的竞争设计,并且警惕那些执行关键安全功能的复杂设计。
乐高积木就是这一模式的一个很好的例子。一旦标准建筑元素的设计和制造得到了完善,就能够构建出无数种创意设计。由许多不那么通用的部件组成的类似系统将更难以构建;任何特定设计都会需要更多的部件库存,并且涉及其他技术挑战。
在大型网络服务的系统架构中,你可以找到许多设计经济性的例子,这些服务被构建为在大规模数据中心中运行。为了在大规模下保证可靠性,这些设计将功能分解成更小的、自包含的组件,这些组件共同完成复杂的操作。通常,一个基础的前端终止 HTTPS 请求,将传入的数据解析并验证为内部数据结构。该数据结构会被传送给多个子服务进行处理,这些子服务则通过微服务执行各种功能。
对于像网页搜索这样的应用,不同的机器可能独立地并行构建响应的不同部分,然后又有另一台机器将它们合并成完整的响应。构建许多小服务来完成整个任务的不同部分——查询解析、拼写修正、文本搜索、图片搜索、结果排序和页面布局——要比将所有功能都放入一个庞大的程序中要容易得多。
设计经济性并不是一个绝对的要求,要求所有事物必须始终简单。相反,它强调了简洁的巨大优势,并表示只有在复杂性能够带来显著价值时,才应当接受复杂性。考虑一下nix 和 Windows 中访问控制列表(ACL)的设计差异。前者很简单,指定按用户或用户组、或针对所有人的读/写/执行权限。后者则更加复杂,包含任意数量的允许和拒绝访问控制条目以及继承功能;特别地,评估依赖于条目在列表中的顺序。(这些简化的描述旨在强调设计点,并非完整描述。)这一模式正确地表明,*nix 的简单权限更容易正确地执行,此外,系统用户更容易正确理解 ACL 的工作原理,从而能够正确使用它们。然而,如果 Windows ACL 为特定应用提供了恰当的保护并且能够被准确配置,那么它可能是一个不错的解决方案。
设计经济性模式并不是说简化的选项无可非议地更好,或者更复杂的选项必然存在问题。在这个例子中,*nix ACLs 并不天然优于 Windows ACLs,Windows ACLs 也不一定有问题。然而,Windows ACLs 确实对开发者和用户而言有更多的学习曲线,使用它们更复杂的功能容易让人困惑,并可能引发意想不到的后果。这里的关键设计选择,我不打算做过多评判,是 ACL 设计在多大程度上最符合用户的需求。也许 *nix ACLs 太简单,未能满足实际需求;另一方面,也许 Windows ACLs 在典型的使用模式下功能过于繁琐、笨重。这些是我们每个人都必须根据自身目的来回答的难题,而这个设计模式提供了一些见解。
透明设计
强大的保护绝不应依赖于保密性。
或许最著名的一个未遵循透明设计模式的设计案例就是《星球大战》中的死星,它的热排气口为直接攻击战斗站核心提供了机会。如果达斯·维达像对待莫提海军上将一样严厉地要求他的建筑师遵循这一原则,故事的发展将会截然不同。揭示一个精心设计的系统的设计应该起到通过展示其不可战胜性来劝阻攻击者的作用,而不是让攻击者的任务变得更容易。对应的反模式可能更为人所知:我们称之为通过模糊性来保障安全。
该模式特别警告不要依赖设计的保密性。它并不意味着公开披露设计是强制性的,或者保密信息本身有什么问题。如果对设计的完全透明性削弱了设计的安全性,你应该修复设计,而不是依赖保密。这个原则并不适用于合法的秘密信息,例如加密密钥或用户身份信息,如果泄露将真正危及安全。这就是为什么该模式的名字是“透明设计”,而不是“绝对透明”。公开加密方法的设计——如密钥大小、消息格式、加密算法等等——应该完全不会削弱安全性。反模式则是一个明显的警告:例如,不要信任任何自封的“专家”,他们声称发明了如此伟大的加密算法,以至于无法公开其细节。毫无例外地,这些都是假的。
安全通过模糊化的问题在于,虽然它可能暂时帮助防止对手的攻击,但它极为脆弱。例如,假设某个设计使用了过时的加密算法:如果攻击者发现软件仍然在使用,比如 DES(1970 年代的传统对称加密算法),他们可能在一天内就能轻松破解它。相反,应该进行必要的工作,确保达到坚实的安全基础,这样就不需要隐藏任何东西,无论设计细节是否公开。
暴露最小化
最大的一组模式要求谨慎:要“尽量保持安全”。这些是基本风险/回报策略的表现,除非有重要理由,否则你应该保持安全。
最小权限
最安全的方法是仅为任务提供足够的权限。
永远不要清理上膛的枪支。更换锯片时要拔掉电锯的电源。这些常见的安全做法就是最小权限模式的例子,旨在减少在执行任务时出错的风险。这个模式也是为什么重要系统的管理员不应在工作时随意浏览互联网的原因;如果他们访问了恶意网站并被攻破,攻击可能会造成严重损害。
nix sudo(1) 命令正是执行这个目的。sudoers*(具有高权限的用户账户)需要小心,避免无意中滥用他们的超凡权限,或者如果账户被攻破时。为了提供这种保护,用户必须在超级用户命令前加上 sudo,这可能会提示用户输入密码,以便执行命令。在这种系统下,大多数命令(那些不需要 sudo 的命令)只会影响用户自己的账户,而不会对整个系统产生影响。这类似于火警开关上的“紧急情况下打破玻璃”保护,防止意外启动,因为这需要明确的步骤(对应 sudo 前缀)才能启动开关。有了玻璃罩,任何人都不能声称是无意中拉响了火警铃,就像一名合格的管理员永远不会无意中输入 sudo 和一个会破坏系统的命令一样。
这个模式之所以重要,是因为在漏洞被利用时,攻击者最好只有最少的权限可以作为 leverage。只有在严格必要时,才使用像超级用户权限这样的全能授权,而且使用的时间要尽可能短。即使是超人,也遵循了最小权限的原则——他只在有任务时穿上他的制服,拯救世界后立即换回克拉克·肯特的身份。
实际上,选择性地谨慎使用提升的权限确实需要更多的努力。就像需要断开电动工具的电源来进行维修一样,使用权限时的谨慎需要自律,但做到正确总是更安全。在利用攻击时,这意味着轻微入侵与完全系统妥协之间的差别。实践最小权限还可以减轻由于漏洞和人为错误造成的损害。
像所有经验法则一样,使用这种模式时要保持平衡感,避免过度复杂化。最小权限并不意味着系统应始终授予字面上最小的授权级别(例如,创建代码时,为了写入文件 X,只给予该文件的写入权限)。你可能会想,为什么不总是最大限度地应用这一优秀模式呢?除了保持一般的平衡感,并认识到任何缓解措施的收益递减外,这里一个重要因素是控制授权的机制的粒度,以及在上下调整权限时所付出的成本。例如,在 *nix 进程中,权限是基于用户和组 ID 访问控制列表授予的。除了在有效 ID 和真实 ID 之间切换的灵活性(这正是 sudo 的作用)外,没有简单的方法可以在不分叉进程的情况下临时放弃不需要的权限。代码应该在可以的地方使用较低的环境权限,在必要的部分使用较高的权限,并在自然的决策点进行转换。
最小信息
收集和访问完成任务所需的最少私人信息始终是最安全的做法。
最小信息模式,最小权限的类比数据隐私模式,有助于最小化无意泄露的风险。在调用子程序、请求服务或响应请求时,避免提供超过必要的私人信息,并在每个机会削减不必要的信息流。实施这一模式在实践中可能具有挑战性,因为软件往往将数据传递到标准容器中,而这些容器并没有针对特定目的进行优化,因此常常包括一些实际上不需要的额外数据。
很多时候,软件未能遵循这一模式,因为接口的设计随着时间的推移演变,服务于多个目的,并且为了保持一致性,方便地重用相同的参数或数据结构。因此,不严格必要的数据作为额外的负担被发送,看似无害。问题出在,当这些不必要的数据在系统中流动时,会为攻击提供额外的机会。
例如,假设一个大型的客户关系管理(CRM)系统被企业中的不同员工使用。不同的员工使用该系统执行各种任务,包括销售、生产、运输、支持、维护、研发和会计。根据他们的角色,每个人对这些信息的访问权限不同。为了实践最少信息原则,企业中的应用程序应仅请求执行特定任务所需的最少数据。考虑一下客户支持代表接听电话的情况:如果系统使用来电显示来查找客户记录,支持人员不需要知道客户的电话号码,只需要了解他们的购买历史。与此相比,一个更基础的设计要么允许,要么不允许查找包含所有数据字段的客户记录。理想情况下,即使代表拥有更多的访问权限,他们也应该能够仅请求执行特定任务所需的最少数据并进行处理,从而最小化泄露的风险。
在实现层面,最少信息设计包括在不再需要时清除本地缓存的信息,或者在屏幕上显示可用数据的子集,直到用户明确请求查看某些详细信息。将密码显示为 ******** 的常见做法就是使用这种模式来降低肩窥的风险。
在设计时应用此模式尤其重要,因为稍后实现时可能非常困难,因为接口的两端需要一起修改。如果您设计的组件是独立的,适用于特定任务并且需要不同的数据集,那么您更可能做对。处理敏感数据的 API 应提供灵活性,允许调用者指定所需数据的子集,以最小化信息暴露(表 4-1)。
表 4-1:最少信息如何改变 API 设计
| 不符合最少信息原则的 API | 符合最少信息原则的 API |
|---|---|
RequestCustomerData(id='12345') |
RequestCustomerData(id='12345', items=['name', 'zip']) |
{'id': '12345', 'name': 'Jane Doe', 'phone': '888-555-1212', 'zip': '01010', ...} |
{'name': 'Jane Doe', 'zip': '01010'} |
左列中的 RequestCustomerData API 忽视了最少信息原则,因为调用者只能请求通过 ID 获取完整的数据记录。他们并不需要电话号码,因此没有必要请求它,即使忽略它,仍然会增加攻击者尝试获取它的攻击面。右列中则有一个版本的相同 API,允许调用者指定所需的字段并仅返回这些字段,从而最小化私人信息的流动。
考虑到“默认安全”模式,items 参数的默认值应为最小的数据字段集,前提是调用者可以根据需要精确请求,从而最小化信息流动。
默认安全
软件应该始终是“开箱即用”的安全的。
设计你的软件时,要确保它是默认安全的,包括在初始状态下,以确保操作员的无动作不会代表一个风险。这适用于整个系统配置,以及组件的配置选项和 API 参数。带有默认密码的数据库或路由器无疑违反了这一模式,直到今天,这一设计缺陷仍然惊人地普遍存在。
如果你对安全非常重视,绝不应当配置一个不安全的状态,计划以后再将其设为安全,因为这会创建一个漏洞期,而且这种情况往往被遗忘。如果你必须使用带有默认密码的设备,例如,首先应在防火墙后的私有网络中安全配置它,然后再在网络中部署。加利福尼亚州在这一领域是先驱,已通过法律强制执行这一模式;其2018 年第 327 号参议院法案禁止在联网设备上使用默认密码。
默认安全适用于任何可能对安全产生不利影响的设置或配置,不仅仅是默认密码。权限应该默认设置为更严格的设置;用户如果需要修改,应该明确更改为不太严格的设置,而且只有在这样做是安全的情况下才能修改。默认情况下禁用所有潜在的危险选项。相反,默认启用提供安全保护的功能,使其从一开始就处于有效状态。当然,保持软件始终更新是很重要的;不要从一个旧版本开始(可能存在已知漏洞),并指望在某个时刻它会得到更新。
理想情况下,你不应该需要不安全的选项。仔细考虑提议的可配置选项,因为提供一个不安全的选项可能很简单,但它以后可能会成为他人的陷阱。还要记住,每增加一个选项,就增加了可能的组合数,确保所有这些组合的设置都实际上是有用且安全的任务也会变得越来越困难。每当你必须提供不安全的配置时,要主动向管理员解释风险。
默认安全的应用范围远不止配置选项。对于未指定的 API 参数,默认值应当是安全的选择。一个浏览器如果接受用户在地址栏中输入的 URL,但没有指定协议,应该假定该网站使用 HTTPS,只有在前者无法连接时,才回退到 HTTP。两个对等方在协商新的 HTTPS 连接时,应该默认优先接受更安全的密码套件选择。
白名单优于黑名单
在设计安全机制时,优先选择允许列表而不是禁止列表。允许列表是对安全活动的枚举,因此它们本质上是有限的。相比之下,禁止列表试图枚举所有不安全的事物,间接地允许一个无限的你希望是安全的活动集合。显而易见,哪种方法风险更大。
首先,这里有一个非软件的例子,帮助你理解“允许列表”和“禁止列表”的区别,以及为什么总是选择使用允许列表。在 COVID-19 居家紧急命令的初期,我所在州的州长下令关闭海滩,并附加了以下条件,简化后的内容如下:
除非“在海滩上跑步、慢跑或走路时,保持社交距离要求”得到遵守(过海滩去冲浪也是允许的),否则任何人不得在海滩上坐着、站着、躺着、闲逛、日光浴或徘徊。
第一条款是一个禁止列表,因为它列出了不允许的活动,而第二个例外条款是一个允许列表,因为它授权列出的活动。从法律问题的角度来看,使用这种语言可能有其合理性,但从严格的逻辑角度来看,我认为它还有很大改进空间。
首先让我们考虑禁止列表:我确信,在海滩上,人们可能做一些第一条款未禁止的高风险活动。如果该命令的目的是让人们保持活动,那么它遗漏了很多活动——例如,跪下、瑜伽和活人雕像表演。禁止列表的问题在于,任何遗漏都会成为缺陷,因此除非你能完全列举出每一个可能的坏情况,否则它是一个不安全的系统。
现在考虑允许列表中的允许海滩活动。虽然它也不完整——谁会反对跳跃也是可以的呢?——但这不会引发大的安全问题。或许只有极小一部分跳跃者会受到不公平的惩罚,但其伤害很小,更重要的是,不完整的枚举并不会打开一个漏洞,允许出现风险活动。最初遗漏的其他安全项目可以根据需要轻松添加到允许列表中。
更一般地说,可以将其视为一个连续体,左侧是禁止的,右侧是允许的,中间某个地方有一条分界线。目标是在分界线右侧允许好的内容,在左侧禁止不好的内容。允许列表从右侧划定分界线,然后逐渐将其移至左侧,随着允许的内容增多,涵盖了更多的连续体部分。如果你在允许列表中遗漏了某个好的内容,你仍然处在那个难以捉摸的、真正的分界线的安全一侧。你可能永远无法精确地达到那个允许所有安全操作的点,在那个时刻,任何向列表中添加的内容都会变得过多,但使用这种技术可以轻松保持在安全的一侧。与此相对的是黑名单方法:除非你列举出真正分界线左侧的所有内容,否则你就可能允许不应该允许的东西。最安全的黑名单将会包括几乎所有内容,而这可能会过于严格,因此无论哪种方式都不太有效。
许多时候,使用允许列表的方式显得非常明显,以至于我们没有注意到它是一个模式。例如,银行通常会授权一小部分信任的管理员批准高价值交易。没有人会想着维护一个不被授权的员工的黑名单,默许其他员工享有这种权限。然而,马虎的程序员可能会尝试通过检查值中是否包含无效字符来进行输入验证,而在这个过程中很容易忽略像 NUL(ASCII 0)或 DEL(ASCII 127)这样的字符。
具有讽刺意味的是,可能是销量最大的消费级安全产品——杀毒软件,试图阻止所有已知的恶意软件。现代的杀毒软件比老式版本更为复杂,后者依赖于与已知恶意软件数据库对比摘要,但它们仍然在某种程度上基于黑名单工作。(这就是一个典型的“安全通过模糊”例子,大多数商业杀毒软件都是专有的,所以我们只能推测。)它们依赖于黑名单技术是有道理的,因为它们知道如何收集恶意软件的样本,而且在软件发布之前以某种方式列出所有安全软件的可能性看似不切实际。我的观点并不是针对某个具体产品或其价值评估,而是关于使用黑名单进行保护的设计选择,以及为什么这不可避免地是有风险的。
避免可预测性
任何可预测的数据(或行为)都无法保持私密性,因为攻击者可以通过猜测来获取这些信息。
软件设计中的数据可预测性可能导致严重的缺陷,因为它可能导致信息泄露。例如,考虑一下为新客户分配账户 ID 的简单例子。当一个新客户在网站上注册时,系统需要一个唯一的 ID 来指定账户。一个显而易见且简单的方法是将第一个账户命名为 1,第二个账户命名为 2,以此类推。这种方法有效,但从攻击者的角度来看,它会泄露什么信息?
新账户 ID 现在为攻击者提供了一个轻松了解到当前创建的用户账户数量的途径。例如,如果攻击者定期创建一个新的临时账户,他们可以准确地知道网站上当前有多少个客户账户——这是大多数企业不愿意透露给竞争对手的信息。根据系统的具体情况,可能会有许多其他的陷阱。这个设计不良的另一个后果是,攻击者可以轻松猜测分配给下一个新账户的 ID,并凭借这个信息,他们可能能够通过冒充新账户来干扰新账户的设置,进而扰乱注册系统。
可预测性问题有多种表现形式,不同的设计可能会导致不同类型的信息泄露。例如,一个包含账户持有人姓名或邮政编码几个字母的账户 ID,会不必要地泄露关于账户所有者身份的线索。当然,这个问题同样适用于网页、事件等的 ID。最简单的缓解措施是,如果 ID 的目的是作为一个唯一标识符,你应该确保它确实是唯一的——永远不要使用用户的数量、用户的电子邮件或基于其他身份信息的内容。
避免这些问题的简单方法是使用安全随机 ID。真正的随机值是无法猜测的,因此不会泄露信息。(严格来说,ID 的长度泄露了最大可能的 ID 数量,但这通常不是敏感信息。)一种标准的系统工具,随机数生成器有两种类型:伪随机数生成器和安全随机数生成器。除非你确信可预测性无害,否则应使用较慢的安全选项。有关安全随机数生成器的更多信息,请参见第五章。
安全失败
如果发生问题,请确保最终进入安全状态。
在物理世界中,这种模式是常识性的。一个传统的电器保险丝就是一个很好的例子:如果电流过大,热量会融化金属,打开电路。物理法则使得无法以维持过大电流流动的方式失败。这个模式也许看起来最为显而易见,但软件由于其特殊性(我们并没有物理法则作为支撑),很容易被忽视。
许多看似简单的软件编程任务,往往因为错误处理而变得复杂。正常的程序流程可能很简单,但当连接断开、内存分配失败、输入无效或出现其他各种潜在问题时,代码需要在可能的情况下继续执行,否则优雅地回退。当编写代码时,你可能会觉得自己花更多时间处理这些分心问题而不是专注于任务本身,并且很容易迅速忽略错误处理代码的重要性,这也是常见的漏洞源。攻击者如果能触发这些错误情况,就会故意利用这些漏洞,希望能够找到可利用的弱点。
错误情况通常难以彻底测试,尤其是在多个错误组合可能导致新的代码路径时,这为攻击提供了可乘之机。确保每个错误要么被安全处理,要么导致请求被完全拒绝。例如,当用户向一个照片分享服务上传图片时,应该立即检查其格式是否正确(因为格式错误的图片常被恶意利用),如果格式错误,应迅速将数据从存储中移除,以防止其进一步使用。
强制执行
这些模式关注如何通过严格执行规则来确保代码的行为。漏洞是所有法律和规章的死敌,这些模式展示了如何避免创造出可以规避系统的漏洞。与其编写代码并推测它不会执行某些操作,不如从结构上设计它,使得禁止的操作不可能发生。
完全仲裁
保护所有访问路径,强制执行相同的访问规则,毫不例外。
一个看似模糊的术语,完全仲裁(Complete Mediation)指的是始终安全地检查对受保护资产的所有访问。如果资源有多种访问方式,则必须对所有访问方式进行相同的授权检查,不允许有任何绕过授权或更宽松政策的捷径。
举个例子,假设一个金融投资公司信息系统的政策规定,普通员工在没有经理批准的情况下不能查询客户的税号,因此系统会提供一个去除该字段的客户记录简化视图。经理可以访问完整记录,并且在少数情况下,非经理若有正当需求,可以请求经理来查询。员工通过多种方式帮助客户,其中之一是如果客户由于某种原因没有收到税务文件,员工可以提供替代的税务文档。确认客户身份后,员工请求重复的表格(PDF),然后将其打印并邮寄给客户。这个系统的问题在于,客户的税号出现在税务表格上,而员工不应该接触到这一信息:这是对完整中介的失败。不诚实的员工可以要求任何客户的税务表格,假装是为了替换,只是为了得知他们的税号,从而破坏了防止员工披露这一信息的政策。
尊重这一模式的最佳方式是,在可能的情况下,确保某个特定的安全决策有一个单一的执行点。这通常被称为守卫,或者非正式地称作瓶颈。其理念是,所有对某一资产的访问必须经过一个入口点。或者,如果这是不可行的,并且需要多个路径有守卫,那么对于相同访问的所有检查应当是功能等价的,理想情况下实现为相同的代码。
实际上,始终如一地实现这一模式可能会面临挑战。根据已设置的守卫,合规性程度会有所不同:
高合规性
- 资源访问只能通过一个共同的程序(瓶颈守卫)
中等合规性
- 资源访问在多个地方进行,每个地方都有相同的授权检查(常见的多个守卫)
低合规性
- 资源访问在多个地方进行,分别由不一致的授权检查保护(不完全中介)
一个反例展示了为什么设计简单授权策略、将授权检查集中在针对特定资源的单一瓶颈代码路径中,是确保此模式正确实现的最佳方式。最近,一位 Reddit 用户报告了一个如何容易出错的案例:
我看到我 8 岁的妹妹在她的 iPhone 6(iOS 12.4.6)上使用 YouTube,超过了她的屏幕时间限制。结果,她发现了一个屏幕时间的漏洞,允许用户使用 iMessage 应用商店中可用的应用。
苹果设计了 iMessage,允许它包括自己的应用程序,可以通过多种方式调用 YouTube 应用,但它没有在这一替代路径上实施屏幕时间检查——这是完整中介的经典失败。
避免为访问相同资源提供多个路径,每条路径都有自定义代码,这些代码可能会有细微的差别,因为任何不一致都可能意味着某些路径上的防护措施比其他路径要弱。多个防护措施将需要多次实现相同的基本检查,并且由于你需要在多个地方进行相应的更改,维护起来会更加困难。使用多个防护措施会增加出错的机会,并且需要更多的工作来进行全面的测试。
最小公共机制
通过最小化共享机制来保持独立进程之间的隔离。
为了更好地理解这一点以及它如何发挥作用,让我们考虑一个例子。多用户操作系统的内核管理着为不同用户上下文中运行的进程提供系统资源。内核的设计从根本上确保了进程的隔离,除非它们明确共享某些资源或通信渠道。在后台,内核维护着各种数据结构,以便为所有用户进程提供服务。这一模式指出,这些结构的公共机制可能无意间将进程连接起来,因此最好将这种机会最小化。例如,如果某些功能可以在用户空间代码中实现,由于进程边界本身就隔离了它,那么这些功能就不太可能会无意中连接不同的用户进程。在这里,连接一词特别指的是泄露信息,或者允许一个进程在未经授权的情况下影响另一个进程。
如果这仍然显得抽象,可以考虑一个非软件类比。你在报税截止日期前一天去见你的会计师,准备检查税表。会计师的桌子上堆满了纸张和文件夹,像迷你摩天大楼一样。在翻阅这堆混乱的文件后,他们终于拿出了你的文件并开始会议。你等待时,可以看到桌面上有其他人姓名和税号的税表和银行对账单。也许你的会计师不小心把你的税务笔记写到了别人文件里。这正是通过使用桌面作为公共工作空间所造成的独立方之间的桥接现象,而最小公共机制的目标正是避免这种情况发生。
明年,你雇佣了一个新的会计,当你和他们见面时,他们从一个文件柜中拿出你的档案,放在整洁的办公桌上,桌面上没有其他客户的文件。这样做就是正确实现最小公共机制的方法,能够最大限度地减少混淆或好奇客户看到其他文件的风险。
在软件领域,应用这种模式可以通过设计与独立进程或不同用户交互的服务来实现。与其将所有人的数据都存储在一个单一的数据库中,不如为每个用户提供一个独立的数据库,或者根据上下文来限定访问权限?虽然将所有数据放在一个地方可能有其合理的原因,但如果你选择不遵循这种模式,就要注意增加的风险,并明确执行必要的隔离措施。Web cookies 是使用这一模式的一个很好的例子,因为每个客户端独立存储自己的 cookie 数据。
冗余
冗余 是工程安全的核心策略,反映在许多常识性做法中,比如汽车的备用轮胎。这些模式展示了如何应用这一策略来使软件更安全。
深度防御
结合独立的保护层,可以形成更强大的整体防御,这种防御通常比任何单一层的保护更具协同效应,效果也更好。
这一强大的技术是我们为使难免会有漏洞的软件系统比其组成部分更加安全所使用的最重要模式之一。想象一下你想要将一个房间转变为暗室,你用胶合板把窗户遮住。你有足够的胶合板,但每块板上有人随意钻了几个小孔。钉上一块板,许多针孔就会破坏黑暗的效果。再钉上一块板,除非两个孔恰好对齐,否则你现在就有一个完全黑暗的房间。一个同时使用金属探测器和搜身的安全检查点是这一模式的另一个例子。
在软件设计领域,通过叠加两个或更多独立的保护机制来执行一个特别关键的安全决策,从而部署深度防御。就像那块有孔的胶合板一样,每种实现可能都有缺陷,但任何给定攻击穿透这两层防御的可能性微乎其微,就像两个胶合板的孔恰好对齐让光透过一样。由于两个独立的检查需要双倍的努力并且需要更长时间,因此应该谨慎使用这一技术。
这种技术的一个很好的例子,平衡了工作量和开销与收益的关系,就是实现一个沙箱,一个可以安全运行不受信任任意代码的容器。(现代网页浏览器运行WebAssembly 在安全沙箱中。)如果在系统中运行不受信任的代码,可能会带来灾难性的后果,这就证明了多层保护的开销是值得的(图 4-2)。

图 4-2:沙箱作为深度防御模式的一个例子
沙盒执行的代码首先会被分析器扫描(防御层之一),分析器会根据一组规则进行检查。如果发生任何违规,系统将完全拒绝该代码。例如,一条规则可能禁止调用内核;另一条规则可能禁止使用特定的特权机器指令。只有当代码通过扫描器时,才会加载到解释器中执行,同时执行过程中会强制执行一系列限制,旨在防止同样的过度特权操作。为了突破这个系统,攻击者必须首先通过扫描器的规则检查,并且还要欺骗解释器执行被禁止的操作。这个例子尤其有效,因为代码扫描和解释是从根本上不同的,因此两层中出现相同漏洞的可能性较低,特别是在它们是独立开发的情况下。即便扫描器有千分之一的概率会遗漏某个攻击技巧,解释器也是如此,但一旦它们结合起来,整个系统实际失败的几率大约是万亿分之一。这就是这种模式的强大之处。
特权分离
两个当事方比一个更可信。
也被称为职务分离,特权分离模式指的是一个无可争议的事实——当两个不同的人分别持有不同钥匙时,两个锁比一个锁更强大。虽然这两个人可能合谋,但这种情况很少发生;而且,有很多方法可以最大限度地降低这种风险,无论如何,这总比完全依赖一个人要好得多。
例如,保险箱的设计使得银行保持着所有保险箱所在金库的安全,而每个箱主都有一把单独的钥匙来打开他们的箱子。银行职员无法进入任何一个箱子,除非强行破坏它们,例如钻锁,但没有客户知道打开金库的组合。只有当客户从银行获得访问权限并使用自己的钥匙时,才能打开他们的箱子。
当保护资源有明显重叠的职责时,应用此模式。保护数据中心就是一个经典的例子:数据中心有一个系统管理员(或者一个团队,针对大型操作)负责操作机器并具有超级用户访问权限。此外,保安负责控制进入设施的物理访问。这些独立的职责,以及相应的凭证和访问密钥控制,应该归属于向组织中不同高层汇报的员工,从而降低共谋的可能性,并防止某个老板下令采取违反协议的非常规行动。具体来说,远程工作的管理员不应拥有数据中心机器的物理访问权限,而在数据中心的人员则不应知道任何用于登录机器的访问代码,或解密存储单元所需的密钥。为了完全破坏安全,必须有两个人在两个控制领域中共谋,一个从每个领域,以便同时获得物理和管理员访问权限。在大型组织中,不同小组可能负责管理数据中心内的不同数据集,从而增加额外的分离度。
这种模式的另一种用途,通常用于最关键的功能,是将一个责任分解为多个职责,以避免因单一行动者的错误或恶意意图导致严重后果。为了额外防止数据备份可能泄漏,你可以使用两把不同的密钥分别加密数据,这样只有在双方的帮助下才能使用它。一个极端的例子是,触发核导弹发射时,需要同时在相距 10 英尺的两个锁中插入两把钥匙,以确保任何单独行动的人都无法启动它。
通过权限分离来保护你的审计日志,一个团队负责记录和审查事件,另一个团队负责启动事件。这意味着管理员可以审计用户活动,但需要另一个独立的团队审计管理员。否则,恶意行为者可能会阻止记录他们自己腐败行为的日志,或者篡改审计日志以掩盖他们的踪迹。
你无法在单台计算机内实现权限分离,因为具有超级用户权限的管理员可以完全控制计算机,但仍有很多方法可以接近这一目标,并取得良好的效果。即使管理员最终能够突破,也可以通过实施多组件的独立设计作为一种缓解措施,因为它使得颠覆变得更加复杂;任何攻击都会花费更长的时间,而且攻击者更有可能在过程中犯错,从而增加被抓住的可能性。强有力的管理员权限分离可以通过迫使管理员通过一个特殊的ssh网关来工作,该网关由另行控制,完整记录他们的会话,并可能施加其他限制。
内部威胁很难,甚至在某些情况下无法消除,但这并不意味着采取缓解措施就是浪费时间。仅仅知道有人在监视,便足以构成巨大的威慑力。这些预防措施不仅仅是基于不信任:诚实的员工应当欢迎任何能够增加责任性并减少自身错误所带来风险的特权分离。迫使一名恶意内部人员费力地清理痕迹,会减缓他们的行动,并增加他们被当场抓获的几率。幸运的是,人类在与同事面对面交流时已经进化出了非常成熟的信任系统,因此,在实践中,内部的虚伪行为极为罕见。
信任与责任
信任与责任是促成合作的纽带。随着软件系统越来越相互连接和相互依赖,这些模式成为了重要的指路明灯。
不轻信
信任应当始终是一个明确的选择,并应基于确凿的证据。
这一模式认识到信任是珍贵的,因此倡导怀疑。在软件出现之前,犯罪分子利用人们天生的信任倾向,通过伪装成工人以获得访问权限、兜售假药,或进行各种各样的骗局。不轻信告诫我们不要认为穿制服的人一定合法,也要考虑那个声称自己是 FBI 的来电者可能是个骗子。在软件中,这一模式适用于在安装代码之前验证其真实性,并要求在授权前进行强认证。
HTTP Cookies 的使用是这一模式的一个极好例子,正如第十一章详细解释的那样。Web 服务器在响应客户端时会设置 Cookies,期望客户端在未来的请求中将这些 Cookies 发送回来。但由于客户端并没有实际的义务遵守这一行为,服务器应始终对 Cookies 保持谨慎态度,完全信任客户端始终如一地执行这一任务是一个巨大的风险。
即使在没有恶意的情况下,不轻信也非常重要。例如,在关键系统中,确保所有组件都达到同样高的质量和安全标准,以避免整体系统的安全性受到威胁,这一点至关重要。做出错误的信任决策,比如使用来自匿名开发者的代码(可能包含恶意软件,或者仅仅存在漏洞)来处理关键功能,容易迅速破坏安全性。这一模式简单而理性,但在实践中却可能具有挑战性,因为人们天生倾向于信任他人,拒绝信任有时可能显得偏执。
接受安全责任
所有软件专业人员都有明确的责任来对安全负责;他们应该在所生产的软件中反映出这种态度。
例如,设计师在审查要集成到系统中的外部组件时,应该考虑安全需求。在两个系统之间的接口处,双方应该明确承担各自的责任,并确认他们依赖于调用者遵守的任何保证。
你不希望遇到的反模式是,当出现问题时,两个开发者彼此说:“我以为是你负责安全,所以我就不用管了。”在一个大型系统中,双方很容易相互指责。想象一下这样的情况:组件 A 接收不可信的输入(例如,一个前端 web 服务器接收来自匿名互联网请求),并将其传递到组件 B 的业务逻辑中,可能会进行一些处理或重新格式化。组件 A 可能根本不承担任何安全责任,盲目地传递所有输入,假设 B 会安全地处理这些不可信的输入,进行适当的验证和错误检查。从组件 B 的角度来看,很容易假设前端已经验证了所有请求,只将安全请求传递给 B,所以 B 不需要担心这个问题。正确的处理方式是通过明确的协议;决定由谁来验证请求,以及是否有任何保证要提供给下游。为了最大限度的安全,采用深度防御,其中两个组件独立验证输入。
再考虑一种非常常见的情况,即设计师和用户之间的责任空隙。回顾我们在讨论“默认安全”模式时提到的配置选项的例子,特别是当提供了一个不安全的选项时。如果设计师知道某个可配置选项不安全,他们应该仔细考虑是否真的有必要提供这个选项。也就是说,不要仅仅因为“某人,总有一天,可能会想要这个选项”或者“这样做很简单”就提供该选项。这等于在设置一个陷阱,最终会有人不知不觉地掉进去。当存在潜在风险配置的有效理由时,首先应考虑改变设计,允许用安全的方式解决问题。倘若无法做到这一点,如果该需求本身就不安全,设计师应当告知用户,并在用户不知情的情况下保护他们不去配置该选项。文档中不仅要明确记录风险,并建议可能的缓解措施来弥补漏洞,还应该给用户提供清晰的反馈——理想情况下,应该比“你确定吗?(了解更多:)”这样的责任推卸对话框要好。
反模式
学会从他人的灾难中看到你应该避免的灾祸。
— 普布利柳斯·西鲁斯
有些技能最好通过观察大师的工作来学习,但另一种重要的学习方式来自于避免别人过去的错误。初学化学的学生学会了总是通过将酸加入水中来稀释酸,而绝不逆向操作,因为在大量酸的存在下,第一滴水会突然反应,产生大量热量,可能立即使水沸腾,迅速喷出水和酸,形成爆炸。没有人愿意通过模仿来学习这个教训,基于此,我在这里呈现出几个在安全方面最好避免的反模式。
以下简短部分列出了一些软件安全反模式。这些模式通常带有安全风险,因此最好避免,但它们不是实际的漏洞。与前面章节中提到的那些通常具有公认名称的模式不同,其中一些没有广泛接受的名称,因此我在这里为方便起见使用了描述性别名。
混淆代理
混淆代理问题是许多软件漏洞的根本安全挑战,可以说它是所有反模式的母亲。为了说明这个名字的含义,讲一个简短的故事是一个不错的起点。假设一位法官发布了逮捕令,指示他们的代理逮捕诺曼·贝茨。代理查找诺曼的地址,并逮捕了住在那里的人。那人坚持说这是一个错误,但代理之前听过这种借口。我们故事的情节转折(与惊魂记无关)是,诺曼早就预料到自己会被抓,数年来一直使用虚假的地址。代理被这种诡计弄得困惑,错误地使用了他们的逮捕权;你可以说,诺曼通过巧妙的手段利用了代理的合法权限,达到了自己的恶意目的。(恶性行为“引导警察到无辜的受害者处进行袭击”——错误报告紧急情况,指挥警方针对无辜的受害者进行袭击,正是混淆代理问题的一个完美例子,但我不想详细讲述这类悲惨的故事。)
常见的混淆代理实例包括内核在被用户空间代码调用时,或者当从互联网调用网页服务器时。被调用者是一个代理,因为高权限的代码被调用来代表低权限的调用者执行任务。这种风险直接源于信任边界的跨越,这也是为什么这些情况在威胁建模中如此引人关注。在后续章节中,将介绍多种混淆代理的方式,包括缓冲区溢出、输入验证不当和跨站请求伪造(CSRF)攻击,仅举几例。与依赖本能、过往经验和其他线索(包括常识)的人类代理不同,软件很容易被欺骗做出其本不打算做的事情,除非它在设计和实现时充分预见到并采取了所有必要的预防措施。
意图与恶意
回顾第一章,软件要值得信赖,需要满足两个要求:它必须由你信任的人构建,这些人既诚实又有能力交付高质量的产品。两者之间的区别在于意图。逮捕诺曼·贝茨的问题不在于代理不正直,而在于未能正确识别被捕者。当然,代码不会违背或懒惰,但写得不好的代码很容易以与原意不同的方式运行。虽然许多轻信的计算机用户,甚至偶尔一些技术熟练的软件专业人士,确实会被恶意软件欺骗,但许多攻击通过利用软件中的混淆代理来工作,这些软件本应是可信的,但恰巧存在缺陷。
通常,混淆代理漏洞是在代码的早期阶段丢失了原始请求的上下文时出现的——例如,如果请求者的身份不再可用。这种混淆在高权限和低权限调用者共享的公共代码中尤其容易发生。图 4-3 展示了这种调用的情况。

图 4-3:混淆代理反模式的示例
中央的Deputy代码为低权限和高权限的代码执行工作。当从右侧的高权限处调用时,它可能会执行一些潜在的危险操作,以服务其值得信赖的调用者。从低权限调用表示信任边界的跨越,因此Deputy应该只执行适合低权限调用者的安全操作。在实现过程中,Deputy使用一个子组件Utility来完成工作。Utility中的代码并不关心高权限和低权限调用者,因此可能会错误地代表Deputy执行一些低权限调用者本不应执行的潜在危险操作。
值得信赖的代理
让我们从考虑危险所在开始,分析如何成为一个值得信赖的代理。回想一下,信任边界是潜在混淆开始的地方,因为攻击混淆代理的目标是利用其更高的权限。只要代理理解请求内容及请求者,并且进行适当的授权检查,一切应该都没问题。
回想一下之前涉及Deputy代码的示例,问题发生在基础的Utility代码中,它在从低权限调用时未处理信任边界。从某种意义上讲,Deputy无意中让Utility成为了一个混淆代理。如果Utility本来就不打算防范低权限调用者,那么Deputy要么需要彻底保护它,避免被欺骗,要么Utility可能需要修改,以便识别低权限调用。
另一种常见的混淆代理失误发生在代理代表请求采取的行动中。数据隐藏是一种基本的设计模式,其中实现将其使用的机制隐藏在抽象背后,代理直接作用于机制,而请求方无法访问。例如,代理可能会将信息作为请求的副作用记录,但请求方无法访问日志。通过让代理写入日志,请求方实际上是在利用代理的权限,因此需要警惕未预料的副作用。如果请求方能够向代理提供一个格式错误的字符串,导致其流入日志并破坏数据使其无法读取,这就是一个混淆代理攻击,实际上清除了日志。在这种情况下,防御从注意到请求方的字符串可能会流入日志开始,考虑到可能产生的影响,必须进行输入验证。
在第三章提到的代码访问安全模型,专门设计用于防止混淆代理漏洞的产生。当低权限代码调用高权限代理代码时,实际权限会相应减少。当代理需要更高权限时,必须显式地声明,承认它是在低权限代码的指使下工作。
总结来说,在信任边界处,要小心处理低信任数据和低权限调用,以避免成为混淆的代理。整个任务执行过程中要保持与请求相关的上下文,以便在需要时能够完全检查授权。要注意,副作用不允许请求方超出其权限。
信任回流
信任回流出现在低信任组件控制高信任组件的情况下。一个例子是系统管理员使用个人计算机远程管理企业系统。尽管此人已经得到适当授权并且值得信任,但他们的个人计算机不在企业管理范围内,且不应使用管理员权限进行会话。实际上,你可以将其视为一种结构性特权提升,随时可能发生。
虽然在现实生活中没有人会明智地陷入这种反模式,但在信息系统中却意外容易忽视。记住,这里重要的是你给予组件的信任,而不是组件值得获得多少信任。通过明确地审视信任边界,威胁建模可以暴露出这种类型的潜在问题。
第三方钩子
信任反向流动反模式的另一种形式是,当系统内的组件钩子给予第三方不当访问权限时。考虑一个关键的业务系统,其中包括一个专有组件,在系统中执行某些专业的过程。也许它使用先进的 AI 来预测未来的商业趋势,消费机密的销售数据并每天更新预测。这个 AI 组件是前沿的,因此制造它的公司必须每天进行维护。为了让它像一个交钥匙系统一样工作,它需要一个直接的通道穿越防火墙,以访问管理接口。
这也是一种扭曲的信任关系,因为第三方可以直接访问企业系统的核心,完全不受管理员的监管。如果 AI 提供商不诚实或被攻破,他们可以轻易地窃取内部公司数据,甚至更糟,且根本无法得知。请注意,某种类型的钩子可能不会有这个问题,并且是可以接受的。例如,如果钩子实现了自动更新机制,并且只能下载和安装新版本的软件,在具备适当信任的情况下,可能是可以的。
无法修补的组件
几乎可以肯定,问题不是“是否”有人会发现任何流行组件中的漏洞,而是“什么时候”会发现。一旦这样的漏洞成为公众知识,除非它完全与任何攻击面隔离,否则必须及时修补。任何你无法修补的系统组件,最终将成为永久的负担。
具有预装软件的硬件组件通常是无法修补的,但从所有实际用途来看,任何发布者停止支持或公司倒闭的软件也可以视为无法修补。实际上,还有许多其他类别的实际无法修补的软件:仅提供二进制形式的未支持软件;使用过时的编译器或其他依赖项构建的代码;由于管理决策而停用的代码;涉及诉讼的代码;被勒索软件攻陷的代码;以及令人惊讶的,用 COBOL 等老旧语言编写的代码,这些语言如今的经验程序员已经很稀缺。主要操作系统提供商通常会在一段时间内提供支持和升级,之后软件就变得实际无法修补。即使是可更新的软件,如果制造商未能及时发布更新,也可能变得不再有效。当需要时,使用任何你不确定能够快速更新的软件,简直是在挑战命运。
加密学
加密通常是被绕过的,而不是被渗透的。
——阿迪·沙米尔

在高中时,我差点没通过驾照教育。这是很久以前的事了,那时候公立学校还有经费教授驾驶,而汽油中还含有铅(当时没有人预测到这个绝妙的主意会带来什么后果)。我第一次尝试驾驶并不顺利。我特别记得第一次坐进大众甲壳虫(手动变速车)的驾驶座时,体育教练坐在副驾驶座上的那种严肃神情。我很快就明白了,在下坡时踩下离合器,汽车会加速,而不是像我想的那样减速。但从那次错误之后,我突然就掌握了驾驶的技巧。教练对这个出乎意料的转折表现出了明显的惊讶和松了一口气的神情。回头看,我认为我的突破正是由于手动挡车的实际操作体验,它让我与车辆建立了更直接的联系,让我第一次能够凭直觉驾驶。
就像驾照教育教学生如何安全驾驶汽车,但不教授如何设计或进行重大维修,本章通过讨论如何正确使用加密技术来介绍加密学的基本工具集,而不深入探讨其内部原理。为了让不擅长数学的人也能理解加密,本章避免使用数学,除了一个例外,我忍不住包括了它,因为它非常巧妙。
这是一个非常规的讨论方式,但也是一个重要的方式。加密工具被低效利用,正因为加密学已被视为专家的领域,进入门槛较高。现代库提供了加密功能,但开发者需要知道如何使用这些库(以及如何正确使用它们),才能使其发挥作用。我希望本章能作为一个跳板,为你提供关于加密潜在用途的有用直觉。你应该根据具体的用途补充进一步的研究。
加密工具
从本质上讲,现代加密学很多内容源自纯粹的数学,因此当正确使用时,它确实有效。这并不意味着这些算法是无法攻破的,而是需要数学上的重大突破才能破解它们。
加密学提供了丰富的安全工具,但要使它们有效,你必须仔细思考如何使用它们。正如本书多次建议的那样,依赖于高质量的代码库,这些库提供了完整的解决方案。选择一个提供适当抽象层级接口的库是至关重要的,这样你才能完全理解它在做什么。
加密学的历史及其背后的数学非常迷人,但为了创建安全的软件,现代工具箱由一组基本工具组成。以下列表列出了基本的加密安全功能,并描述了每个功能的作用以及它们所依赖的安全性:
-
随机数在作为填充和一次性密钥时非常有用,但前提是它们是不可预测的。
-
消息摘要(或哈希函数)作为数据的指纹,但前提是它们对碰撞具有抗性。
-
对称加密通过双方共享的秘密密钥来隐藏数据。
-
非对称加密通过收件人知道的秘密来隐藏数据。
-
数字签名通过一个只有签名者知道的秘密来验证数据的真实性。
-
数字证书通过信任根证书来验证签名者的身份。
-
密钥交换允许双方在开放的通道上建立共享的秘密,尽管存在窃听。
本章的其余部分将更详细地介绍这些工具及其用途。
随机数
人类大脑很难理解随机性的概念。出于安全目的,我们可以将不可预测性视为随机数最重要的特性。正如我们将看到的,这在防止攻击者猜测正确时至关重要,就像一个可预测的密码会很弱一样。随机数的应用包括身份验证、哈希、加密和密钥生成,这些都依赖于不可预测性。以下小节描述了可供软件使用的两类随机数,它们在可预测性方面的差异,以及何时使用哪种类型。
伪随机数
伪随机数生成器(PRNGs)使用确定性计算生成看起来像是无限的随机数字序列。它们生成的输出可以轻松超过我们人类对模式的检测能力,但分析和对抗性软件可以轻易学会模仿 PRNG,因其可预测性而不适用于安全领域。
然而,由于计算伪随机数非常迅速,它们在广泛的非安全用途上非常理想。例如,如果你想运行蒙特卡洛模拟或随机分配 A/B 测试的网页设计,PRNG 就是最佳选择,因为即使某人预测了算法,实际也没有真正的威胁。
查看一个伪随机数的例子可能有助于加深你对它为什么不是真正随机的理解。考虑以下数字序列:
`94657640789512694683983525957098258226205224894077267194782684826`
这个序列是随机的吗?恰好有相对较少的 1 和 3,以及不成比例的许多 2,但在真正的随机数中,发现这些偏差并不令人不可思议。然而,尽管这个序列看起来很随机,如果你知道其中的规律,预测接下来的数字是很容易的。正如透明设计模式提醒我们的那样,假设我们能够保守我们的方式是有风险的。事实上,如果你将这个数字串输入到简单的网页搜索中,你会发现它们是圆周率从小数点后 200 位开始的数字,接下来的几个数字将是0147。
就像无理数的小数一样,π的数字具有统计学上正常的分布,且在通俗意义上完全随机。另一方面,作为一个容易计算且广为人知的数字,这个序列是完全可以预测的,因此不适合用于安全目的。
密码学安全的伪随机数
现代操作系统提供密码学安全的伪随机数生成器(CSPRNG)函数,以应对 PRNG 在需要安全随机位时的不足。你也可能看到它被写作 CSRNG 或 CRNG;重要的是“C”,表示它对于加密是安全的。“伪”一词的加入承认这些生成的随机数也可能无法达到完美的随机性,但专家认为它们足够不可预测,因此在实际应用中是安全的。
当安全性至关重要时,请使用这种类型的随机数生成器。换句话说,如果假设能够预测一个本应随机的数字的能力会削弱你的安全性,那么就使用 CSPRNG。这适用于本书中提到的所有安全随机数用途。
真正的随机数据,按定义,并不是由算法生成的,而是来自不可预测的物理过程。一个盖革计数器可以是一个硬件随机数生成器(HRNG),也称为熵源,因为放射性衰变事件的时机是随机的。HRNG 被集成到许多现代处理器中,或者你可以购买硬件附加设备。软件也可以提供熵,通常通过从事件的时序中获取熵,比如磁盘访问、键盘和鼠标输入事件以及依赖于与外部实体复杂交互的网络传输。
一家主要的互联网科技公司使用一组熔岩灯来色彩斑斓地生成随机输入。但考虑到这种技术的威胁模型:因为公司选择在其公司办公室展示这些熔岩灯,而且还是在接待区,潜在攻击者可能能够观察到该输入的状态,并对熔岩灯的熵源做出有根据的猜测。然而,在实际操作中,熔岩灯只是为一个(可能是)更传统的熵源提供额外的熵,从而降低了这种展示导致公司系统容易受到攻击的风险。
熵源需要时间来产生随机性,如果你要求太多比特太快,CSPRNG 会变得极其缓慢。这就是安全随机性的代价,也是为什么 PRNG 有作为一个可靠快速替代方案的重要作用。在没有快速 HRNG 的情况下,要节约使用 CSPRNG,并在吞吐量成为问题时,测试它是否会成为瓶颈。
消息认证码
消息摘要(也叫哈希)是通过单向函数从消息中计算出来的固定长度值。这意味着每个独特的消息都会有一个特定的摘要,任何篡改都会导致不同的摘要值。单向性很重要,因为这意味着摘要计算是不可逆的,因此攻击者无法找到一个不同的消息,其哈希结果恰好相同。如果你知道摘要匹配,那么就可以确定消息内容没有被篡改。
如果两个不同的消息产生相同的摘要,我们称之为碰撞。由于摘要将大块数据映射为固定长度的值,碰撞是不可避免的,因为可能的消息数量超过了摘要值的数量。一个好的摘要函数的决定性特征是,碰撞非常难以找到。碰撞攻击如果攻击者找到两个不同的输入,产生相同的摘要值,就会成功。对摘要函数的最具破坏性的攻击类型是预影像攻击,即在给定一个特定摘要值的情况下,攻击者可以找到一个输入来产生它。
密码学安全的摘要算法是强单向函数,它们使得碰撞变得极不可能,因此可以假设它们永远不会发生。这个假设对于利用摘要的力量至关重要,因为它意味着通过比较两个摘要的相等性,你实际上是在比较整个消息。可以把这看作是比较两个指纹(这也是摘要的一个非正式术语),以确定它们是否来自同一个手指。
如果每个人都对所有内容使用相同的摘要函数,那么攻击者就可以集中的研究和分析它,最终可能会找到一些碰撞或其他弱点。防范这种情况的一种方法是使用带密钥的哈希函数,它需要一个额外的秘密密钥参数来改变摘要计算。实际上,带密钥的哈希函数采用一个 256 位的密钥,是 2²⁵⁶种不同函数的一个类别。这些函数也被称为消息认证码(MAC),因为只要哈希函数的密钥是保密的,攻击者就无法伪造它们。也就是说,通过使用一个唯一的密钥,你可以得到一个专属于你的定制摘要函数。
使用 MAC 来防止篡改
MAC 通常用于防止攻击者篡改数据。假设爱丽丝想通过公共通道向鲍勃发送一条消息。两人已经私下共享了一个秘密密钥;他们不在乎窃听,因此不需要加密数据,但如果伪造消息未被检测到,则会造成问题。假设恶意的马洛里能够篡改通信内容,但她不知道密钥。爱丽丝使用密钥计算并发送一个 MAC 与每条消息一起发送。当鲍勃收到通信时,他计算收到消息的 MAC,并将其与爱丽丝发送的附带 MAC 进行比较;如果它们不匹配,他会将其忽略为伪造消息。
这种方案在防范聪明的 Mallory 时有多安全?首先,我们来看一下显而易见的攻击方式:
-
如果 Mallory 篡改了消息,它的 MAC 将与消息摘要不匹配(Bob 会忽略它)。
-
如果 Mallory 篡改了 MAC,它将与消息摘要不匹配(Bob 会忽略它)。
-
如果 Mallory 捏造一条全新的消息,她将无法计算 MAC(Bob 会忽略它)。
然而,我们还需要防范另一种情况。你能发现 Mallory 可能的另一种攻击方式,并且如何防御吗?
重放攻击
之前描述的 MAC 通信方案存在一个问题,它应该能让你明白,在面对决心攻击者时,使用加密工具是多么棘手。假设 Alice 每天向 Bob 发送订单,指明第二天她需要多少个小部件。Mallory 观察到这些流量,并收集 Alice 发送的消息和 MAC 对:第一天她订购了三个小部件,第二天订购了五个。第三天,Alice 订购了 10 个小部件。此时,Mallory 想到了一种篡改 Alice 消息的方法。Mallory 拦截了 Alice 的消息,并将其替换为第一天消息的副本(指定三个小部件),并附上了 Alice 已经计算好的相应 MAC,而 Mallory 之前已记录下这个 MAC。自然,这会骗过 Bob。
这就是 重放攻击,安全的通信协议需要解决这个问题。问题不在于加密技术本身的弱点,而在于它的使用不当。在这种情况下,根本问题在于,订购三个小部件的消息是完全相同的,这本质上是一个可预测性问题。
安全的 MAC 通信
有多种方法可以修复 Alice 和 Bob 的协议,击败重放攻击,所有这些方法都依赖于确保消息始终是唯一且不可预测的。一种简单的解决方法是,Alice 在消息中包含时间戳,并约定 Bob 忽略旧时间戳的消息。现在,如果 Mallory 在周三重放周一订购的三个小部件的消息,Bob 通过对比时间戳会发现并检测到欺诈行为。然而,如果消息发送频繁或网络延迟较大,时间戳可能就不太有效。
解决重放攻击威胁的更安全方案是,Bob 在 Alice 发送每条消息之前,先给 Alice 发送一个 nonce——一个一次性使用的随机数。然后,Alice 可以发送一条带有 Bob 的 nonce 和消息与 nonce 组合后的 MAC 的消息。这样可以阻止重放攻击,因为 nonce 每次交换时都会变化。Mallory 可以拦截并更改 Bob 发送的 nonce,但如果返回的 nonce 与预期不符,Bob 会发现问题。
这个简单示例的另一个问题是,消息很短,只包含几个小部件的数量。暂且不谈重放攻击的危险,短消息容易受到暴力破解攻击。计算带有密钥的哈希函数所需的时间通常与消息数据长度成正比,对于只有几位的数据,计算速度非常快。马洛里尝试不同哈希函数密钥的速度越快,猜测正确密钥以匹配真实消息的 MAC 值就越容易。知道密钥后,马洛里就可以冒充爱丽丝发送消息。
你可以通过用随机比特填充消息,直到它们达到合适的最小长度,来缓解短消息的脆弱性。计算这些较长消息的 MAC 值需要时间,但这很好,因为它减缓了马洛里的暴力破解攻击,几乎变得不可行。事实上,哈希函数计算的高昂代价正是为了这个目的。在这种情况下,填充必须是随机的(而不是可预测的伪随机的),以使马洛里尽可能费力。
对称加密
所有加密方法都通过将明文(或原始消息)转换为无法识别的形式,即密文,来隐藏消息。对称加密算法使用一个密钥来定制消息的转换,以供通信双方私下使用,他们必须事先就密钥达成一致。解密算法使用相同的密钥将密文转换回明文。我们称这种可逆转换为对称密码学,因为掌握了密钥就可以进行加密和解密。
本节介绍了几种对称加密算法,以说明它们的安全性特性,并解释了使用这些算法时必须采取的一些安全预防措施。
一次性密码本
密码学家很早就发现了理想的加密算法,尽管正如我们将看到的那样,它几乎从未真正被使用过,但它因其极其简单而成为讨论加密的一个很好的起点。这个算法被称为一次性密码本,要求通信双方事先就一个秘密的、随机的比特串达成一致,作为加密密钥。为了加密消息,发送方将消息与密钥进行异或操作,生成密文。接收方然后使用相同的密钥比特对密文进行异或操作,恢复明文消息。回想一下,在异或(⊕)操作中,如果密钥比特是零,则对应的消息比特不变;如果密钥比特是 1,则消息比特被反转。图 5-1 直观地展示了一个简单的一次性密码本加密和解密示例。

图 5-1:爱丽丝和鲍勃使用一次性密码本加密
后续的消息使用密钥位字符串中更远的位进行加密。当密钥用尽时,通信双方需要以某种方式达成一致,使用新的秘密密钥。之所以使用一次性密钥,有充分的理由,正如我稍后会解释的那样。假设密钥是随机的,消息位要么随机翻转,要么保持不变,因此攻击者无法在不知道密钥的情况下辨别原始消息。随机翻转一半的位是隐藏消息的完美方式,因为无论是展示还是翻转大多数位,都会部分揭示明文。尽管这种方法可能不易通过分析攻击,但很容易看出为什么这种方法很少被使用:密钥的长度限制了消息的长度。
让我们考虑一下禁止重复使用一次性密钥的规定。假设爱丽丝和鲍勃使用相同的秘密密钥K来加密两条不同的明文消息,M1和M2。马洛里拦截了这两条密文:M1 ⊕ K 和 M2 ⊕ K。如果马洛里将这两条加密的密文进行异或操作,密钥会被消除,因为对任何数进行异或操作时,结果会是零(1 变成 0,0 保持不变)。结果是这两条消息的弱加密版本:
`(M1 ⊕ K) ⊕ (M2 ⊕ K) = (M1 ⊕ M2) ⊕ (K ⊕ K) = M1 ⊕ M2`
虽然这不会直接泄露明文,但它开始泄露信息。去除密钥位后,分析可能会揭示消息中的模式线索。例如,如果任何消息包含一串零位,那么另一条消息的相应位将会泄漏出来。
一次性密钥使用限制是大多数应用的障碍:爱丽丝和鲍勃可能无法提前知道他们想要加密的数据量,这使得决定密钥需要多长时间变得不可行。
高级加密标准
高级加密标准(AES)是一种常用的现代对称加密块密码算法。在块密码中,长消息被分解成固定大小的块,较短的消息则通过随机位填充以填满剩余的块。AES 使用一个通常为 256 位长的秘密密钥来加密 128 位的消息块。爱丽丝使用与鲍勃相同的约定密钥来加密数据,而鲍勃使用该密钥解密。
让我们考虑一些可能的弱点。如果爱丽丝在一段时间内发送相同的消息块给鲍勃,这些消息块会生成相同的密文,而聪明的马洛里会注意到这些重复。即使马洛里无法解密这些消息的含义,这仍然代表了一个需要缓解的显著信息泄露问题。这个通信还容易受到重放攻击的威胁,因为如果爱丽丝能够重新发送相同的密文来传递相同的明文消息,那么马洛里也能这么做。
以相同的方式加密相同的消息称为电子密码本(ECB)模式。由于易受重放攻击的影响,这通常不是一个好的选择。为避免此问题,可以使用其他模式,在随后的数据块中引入反馈或其他差异,使得生成的密文依赖于前面数据块的内容或顺序中的位置。这样,即使明文数据块相同,密文结果也会完全不同。然而,虽然数据流块链式加密具有优势,但它要求通信方维护加密和解密的顺序上下文,以确保正确处理。因此,加密模式的选择通常取决于应用的具体需求。
使用对称加密
对称加密是现代加密的主力军,因为它在正确应用时既快速又安全。加密保护通过不安全通道传输的数据,以及存储中的静态数据。在使用对称加密时,重要的是要考虑一些基本的限制:
密钥建立
- 加密算法依赖于预先安排的密钥,但并未指定如何建立这些密钥。
密钥保密性
- 加密的有效性完全依赖于密钥的保密性,同时又能在需要时获得密钥。
密钥大小
- 较大的密钥更强(理论上,一次性密钥是理想的选择),但管理大密钥会变得成本高昂且难以处理。
对称加密本质上依赖于共享的密钥,除非爱丽丝和鲍勃可以直接会面进行信任交换,否则建立这样的密钥是很有挑战性的。为了解决这一限制,非对称加密提供了一些令人惊讶的新功能,适应了互联网连接世界的需求。
非对称加密
非对称加密是一种深刻反直觉的加密方式,这正是它的强大之处。使用对称加密时,爱丽丝和鲍勃可以使用相同的密钥加密和解密消息,但使用非对称加密时,鲍勃可以向爱丽丝发送他无法解密的秘密消息。因此,对于鲍勃来说,加密是一个单向函数,而只有爱丽丝知道能使她反转该函数的秘密(即解密消息)。
非对称加密使用一对密钥:用于加密的公钥和用于解密的私钥。我将描述 Bob 或者世界上的任何人如何向 Alice 发送加密消息;对于双向对话,Alice 将使用 Bob 完全独立的密钥对以同样的过程回复。使用这两个密钥进行的转换是反函数,然而仅知道其中一个密钥不会帮助找出另一个密钥;因此,如果保持一个密钥秘密,那么只有你能执行该计算。由于这种不对称性,Alice 可以创建一对密钥,然后发布一个供全世界查看的密钥(她的公钥),使任何人都能加密只有她能使用对应私钥解密的消息。这是一场革命,因为它基于了解一个秘密赋予了 Alice 独特的能力。我们将在接下来的页面中看到所有这些可能性。
有许多非对称加密算法,但这些的数学细节对于理解使用它们作为加密工具并不重要——重要的是你理解安全性的影响。我们将专注于 RSA,因为它在数学上是最不复杂的前辈。
RSA 加密系统
在麻省理工学院(MIT),我有幸与 RSA 加密系统的两位发明者合作,本科论文探讨了非对称加密如何提升安全性。以下简化讨论遵循原始 RSA 论文,尽管(出于各种技术原因,在这里我们不需要深入讨论)现代实现更为复杂。
RSA 的核心思想是两个大素数相乘容易,但是给定乘积后,将其分解为这些素数则是不可行的。为了开始,选择一对随机的大素数,你将保持它们秘密。接下来,将这对素数相乘。从结果中,我们称之为 N,你可以计算出一个唯一的密钥对。每个这些密钥,连同 N,允许你计算出两个反函数 D 和 E。也就是说,对于任何正整数x < N,D(E(x))是x,而 E(D(x))也是x。最后,选择密钥对的其中一个作为你的私钥,并向世界公布另一个作为相应的公钥,连同 N。只要你保持私钥和最初的两个素数秘密,只有你可以有效地计算出函数 D。
这里是 Bob 如何为 Alice 加密消息以及她如何解密它。这里的函数 E[A]和 D[A]基于 Alice 的公钥和私钥以及 N:
-
Bob 使用 Alice 的公钥为消息 M 加密得到密文 C:C = EA。
-
Alice 使用她的私钥解密 Bob 的密文 C 得到消息 M:M = DA。
由于公钥不是秘密,我们假设攻击者马洛里知道它,这也引发了一个特别针对公钥加密的新问题。如果窃听者能够猜测一个可预测的消息,他们可以使用公钥自己加密各种可能的消息,并将结果与传输中的密文进行比较。如果他们看到匹配的密文,他们就知道产生它的明文是什么。这样的选择明文攻击可以通过为消息添加适当数量的随机位来防止,使得猜测变得不切实际。
RSA 并不是第一个发布的非对称加密系统,但它引起了广泛关注,因为破解它(也就是从公钥推导出私钥)需要解决一个著名的难题——因式分解大质数的乘积。由于在 RSA 公开发布时,我与其发明者进行了适度的合作,因此我可以提供一段历史性的注释,或许对当时与现在的意义有所帮助。由于当时的计算机处理能力有限,这一算法对计算能力的要求非常高,因此它的使用需要昂贵的定制硬件。因此,我们曾设想它仅会被大型金融机构或军事情报机构使用。我们知道摩尔定律,它提出计算能力随着时间的推移呈指数增长——但当时没有人会想到,40 年后,普通人就会使用处理器强大到足以进行必要计算的联网智能手机!
今天,RSA 正被椭圆曲线算法等更新的方法所取代。这些算法依赖于不同的数学方法来实现类似的功能,提供了更多的“性价比”,在较少的计算量下产生强加密。由于非对称加密通常比对称加密计算开销更大,因此加密通常通过选择一个随机的秘密密钥,先对其进行非对称加密,然后再对消息本身进行对称加密来处理。
数字签名
公钥密码学还可以用于创建数字签名,为接收方提供真实性保证。与消息加密无关,爱丽丝的签名可以确保鲍勃这条消息确实来自她。它还可以作为通信的证据,以防爱丽丝否认自己发送了这条消息。正如你从第二章回忆的那样,真实性和不可否认性是通信中仅次于机密性的重要安全属性。
让我们通过一个例子来演示这个过程是如何工作的。爱丽丝使用与公钥加密相同的密钥对来创建数字签名。因为只有爱丽丝知道私钥,所以只有她才能计算签名函数 S[A]。鲍勃或任何拥有公钥(以及 N)的人,都可以通过使用函数 V[A]来验证爱丽丝的签名。换句话说:
-
爱丽丝签署消息 M 以生成签名 S = SA。
-
Bob 通过检查 M = VA 来验证消息 M 是否来自 Alice。
还有一些细节需要解释,以便你完全理解数字签名是如何工作的。由于验证仅依赖于公钥,Bob 可以证明 Alice 签署了一条消息,而无需泄露 Alice 的私钥。此外,签名和加密消息是独立的:你可以根据应用的需要执行其中之一、另一个或两者。我们在本书中不会深入探讨 RSA 的数学原理,但你应该知道,签名和解密功能(都需要私钥)实际上是相同的计算,验证和加密功能(使用公钥)也是如此。为了避免混淆,最好根据它们的目的使用不同的名称来称呼它们。
图 5-2 总结了左侧的对称加密和右侧的非对称加密之间的基本区别。对于对称加密,由于两方通信者都知道秘密密钥,因此无法进行签名。非对称加密的安全性依赖于只有一个通信者知道的私钥,因此只有他可以用它进行签名。由于验证只需要公钥,因此在此过程中没有泄露任何秘密。

图 5-2:对称加密和非对称加密的比较
数字签名广泛用于签署数字证书(下一节的主题)、电子邮件、应用程序代码、法律文档,以及保障如比特币等加密货币的安全。根据惯例,消息的摘要会被签名,以方便地使一个签名操作覆盖整个文档。现在你可以理解为什么对摘要函数成功的预图攻击是非常危险的。如果 Mallory 能够伪造一个与消息摘要相同的付款协议,那么 Bob 的本票也可以作为其有效签名。
数字证书
当我第一次向发明者学习 RSA 算法时,我们在 MIT 一起头脑风暴,讨论可能的未来应用。公钥加密的决定性优势是它提供的便利性。它让你可以使用一个密钥进行所有通信,而不必为每个通信者管理不同的密钥,只要你能将自己的公钥公开给全世界,让任何人都能使用。但那要怎么做呢?
我在论文研究中提出了一个答案,这个想法后来被广泛实施。为了推动数字公钥加密这一新现象的应用,我们需要一种新的组织形式,称为证书授权机构(CA)。为了开始,新的 CA 会广泛发布其公钥。随着时间的推移,操作系统和浏览器将预安装一组值得信任的 CA 根证书,这些证书通过各自的公钥进行自签名。
CA 收集申请者的公钥,通常需要付费,然后为每个申请者发布数字证书,列出他们的名字(如“ Alice”)以及其他相关信息,并附上他们的公钥。CA 会签名数字证书的摘要,以确保其真实性。从理论上讲,CA 服务的一个重要部分是审查申请,以确保它确实来自 Alice,只有在 CA 可靠地执行这一审查时,人们才会选择信任该 CA。在实际操作中,验证身份非常困难,特别是在互联网上,这已经证明是一个问题。
一旦 Alice 获得了数字证书,她可以在任何时候将其副本发送给人们,作为她与他们通信的凭证。如果他们信任颁发该证书的 CA,那么他们就拥有其公钥,并且可以验证数字证书的签名,从而确认“Alice”的公钥是什么。数字证书基本上是 CA 签名的消息,声明“ Alice 的公钥是 X”。此时,接收者可以立即开始加密发送给 Alice 的消息,通常是先发送自己的数字证书,以签名消息的形式向 Alice 保证她的消息已到达正确的人。
这个简化版的数字证书解释集中于受信 CA 如何验证一个名称与公钥之间的关联。实际上,情况要复杂得多;人们并不总是拥有唯一的名称,名称可能会发生变化,处于不同州的公司可能有相同的名称,等等。(第十一章深入探讨了在 Web 安全背景下的这些复杂问题。)今天,数字证书用于将密钥绑定到各种身份,包括 Web 服务器域名和电子邮件地址,并用于许多特定的用途,如代码签名。
密钥交换
Whitfield Diffie 和 Martin Hellman 在 RSA 发明之前不久开发了一种实用的密钥交换算法。为了理解密钥交换的奇迹,假设 Alice 和 Bob 以某种方式建立了通信渠道,但他们没有事先约定任何秘密密钥,甚至没有可以信任的 CA 作为公钥的来源。令人难以置信的是,密钥交换使得他们能够在一个开放的渠道上建立一个秘密,而 Mallory 能够观察到一切。事实上,能够做到这一点是如此反直觉,以至于在这种情况下,我想展示数学原理,让你亲自看到它是如何运作的。
幸运的是,数学足够简单,对于小数字来说,计算也很容易。唯一可能对某些读者不熟悉的符号是后缀 (mod p),意思是除以整数 p,然后得出除法的余数。例如,2⁷ (mod 103) 等于 25,因为 128 – 103 = 25。
这是 Diffie–Hellman 密钥交换算法的基础:
-
Alice 和 Bob 公开地同意一个素数 p 和一个随机数 g (1 < g < p)。
-
Alice 选择一个随机自然数 a (1 < a < p),并将 g^(a) (mod p) 发送给 Bob。
-
Bob 随机选择一个自然数 b (1 < b < p),并将 g^(b) (mod p) 发送给 Alice。
-
Alice 计算 S = (g(*b*)*)*(a) (mod p),作为她们共享的秘密 S。
-
Bob 计算 S = (g(*a*)*)*(b) (mod p),得到与 Alice 相同的共享秘密 S。
图 5-3 展示了一个使用小数字的玩具示例,说明这一过程是如何工作的。这个示例并不安全,因为进行大约 60 次可能性的穷举搜索是很容易的。然而,相同的数学原理适用于大数字,在几百位数的规模下,进行如此穷举搜索几乎是不可行的。

图 5-3:Alice 和 Bob 通过密钥交换安全地选择共享秘密
在这个示例中,由于数字较小,巧合的是,Alice 选择了 6,这恰好等于 Bob 的结果 (g^(b))。在实际情况中不会发生这种情况,但当然算法仍然有效,只有 Alice 会注意到这个巧合。
重要的是,双方必须从一个 CSPRNG(加密安全伪随机数生成器)中选择安全的随机数,以防止 Mallory 猜测他们的选择。例如,如果 Bob 使用某个公式从 p 和 g 计算他的选择,Mallory 可能通过观察许多密钥交换来推断出这一点,最终模仿这种方法,从而破坏密钥交换的保密性。
密钥交换基本上是一种魔术表演,不需要任何欺骗。Alice 和 Bob 从舞台的两侧走进来,Mallory 则站在正中间。Alice 大声喊出数字,Bob 作出回应,经过两轮交换后,Mallory 仍然一无所知。Alice 和 Bob 将他们共享的秘密数字写在大卡片上,信号一发,两人举起卡片,展示出相同的数字,代表他们商定的秘密。
今天,密钥交换对于在互联网上建立安全的通信通道至关重要,适用于任何两个端点。大多数应用使用椭圆曲线密钥交换,因为这些算法性能更优,但其概念基本相同。密钥交换特别适用于在互联网上设置安全通信通道(如通过 TLS 协议)。两个端点首先使用 TCP 通道进行通信——这一流量可能会被 Mallory 监视——然后进行密钥交换,与尚未确认的对端协商一个秘密。一旦他们拥有共享的秘密,加密通信就可以建立一个安全的私人通道。这就是任何一对通信者如何在没有预定秘密的情况下启动一个安全通道。
使用加密技术
本章以“驾驶员教育”级别讲解了加密工具箱中的工具。加密安全的随机数通过增加不可预测性来防止基于猜测的攻击。摘要是将数据的独特性提炼成相应的令牌进行完整性检查的安全方式。加密,既有对称形式也有非对称形式,保护了数据的机密性。数字签名是一种对消息进行身份验证的方法。数字证书通过利用对 CA 的信任,简化了共享真实公钥的过程。而密钥交换则补充了加密工具箱,使得远程方能够通过公共网络连接安全地就一个秘密密钥达成一致。
图 5-4 中的漫画说明了本章开头题词所要表达的观点:构建良好的加密技术如此强大,最大的威胁是它可能被绕过。或许本章最重要的收获是:正确使用加密技术至关重要,避免无意中为攻击者提供绕过的机会。
加密可以帮助解决在设计软件时遇到的许多安全挑战,或者通过威胁建模识别出的挑战。如果你的系统必须通过互联网将数据发送到合作伙伴的数据中心,那么对数据进行加密(确保机密性)并进行数字签名(确保完整性)是必须的——或者你也可以通过 TLS 安全通道轻松实现,它可以验证端点的身份。安全摘要提供了一种巧妙的方法来测试数据的相等性,包括作为 MAC(消息认证码),而无需存储数据的完整副本。通常,你会使用现有的加密服务,而不是自己构建,本章会让你了解在何时何种情况下使用它们,以及使用该技术时需要应对的一些挑战。

图 5-4:安全性与 5 美元扳手的对比(图源:Randall Munroe,xkcd.com/538)
财务账户余额和信用卡信息是必须保护的数据典型例子。这类敏感数据流经一个更大的分布式系统,即便在设施中只有有限的访问权限,你也不希望有人能够物理连接网络监控器并窃取敏感数据。一种有效的缓解措施是,在敏感数据第一次进入前端 web 服务器时立即进行加密。使用公钥立即加密信用卡号,使得你可以在处理交易时将加密后的数据作为不透明数据块传递。最终,这些数据会到达高度保护的财务处理机器,它知道私钥并能够解密数据,从而与银行系统核对交易。此方法允许大多数应用代码安全地传递敏感数据进行后续处理,而不会泄露数据本身。
另一种常见的技术是将对称加密的数据和密钥存储在不同的位置。例如,考虑一家企业希望将长期数据备份外包给第三方存储。他们会将加密的数据交给第三方保管,同时将密钥保存在自己的保险库中,以便在需要从备份恢复时使用。在威胁方面,数据存储服务被委托保护数据的完整性(因为他们可能会丢失数据),但只要密钥安全且加密方法正确,机密性就没有风险。
这些只是一些常见的用法,你会发现还有许多其他方法可以使用这些工具。(加密货币就是一个特别巧妙的应用。)现代操作系统和库提供了成熟的实现,支持当前可行的多种算法,因此你不必亲自去实现这些复杂的计算。
然而,加密并不是万能的,如果攻击者能够观察加密数据的频率和传输量或其他元数据,你可能会向他们泄露一些信息。例如,考虑一个基于云的安防摄像头系统,当它检测到房屋中的运动时,就会拍摄图像。当家人不在家时,没有运动,因此摄像头没有数据传输。即使图像被加密,一个能够监控家庭网络的攻击者仍然可以通过摄像头流量的减少,轻易推断出家庭的日常模式,并确认何时房子无人居住。
加密的安全性依赖于数学的已知极限和数字硬件技术的最新进展,而这两者都在不断发展。任何一天,若有数学家发现更高效的计算方法,可能会颠覆现代算法,届时他将获得巨大的声誉。此外,另一种可能的威胁是不同类型的计算技术,比如量子物理学。甚至有可能某个强大的国家已经取得了这样的突破,并正在秘密使用,以避免泄露其底牌。像所有的应对措施一样,加密本身也包含了权衡和未知的风险,但它仍然是一个值得使用的强大工具集。
第二部分
设计
安全设计
过载、杂乱和混乱不是信息的特征,而是设计的失败。
—Edward Tufte

一旦你对安全原则、模式和缓解措施有了深入理解,将安全性集成到软件设计中变得相对简单。随着你识别出设计中的威胁,你可以根据需要应用这些工具,并探索能够有机降低风险的更好设计方案。
本章重点讨论安全软件设计。它是第七章的补充,后者讲述了安全设计评审。这两个主题是同一活动的不同视角。软件设计师应当在整个设计过程中考虑本章讨论的概念,并应用这些方法;他们不应将系统的安全性留给评审员来事后修补。反过来,评审员应当从威胁与缓解的角度审视设计,作为额外的安全评估层。安全设计过程是整合性的,而安全设计评审则是分析性的——两者协同使用,能够产生更好的设计,并将安全性内嵌其中。
软件设计是一门艺术,本章仅聚焦于安全性方面。无论你是按照正式流程进行设计,还是在脑中完成设计,你都不需要改变工作方式来融入这里提出的理念。威胁建模和安全视角不需要主导设计,但它们应当为设计提供参考。
这里描述的安全设计实践遵循了大型企业的典型流程,但你可以根据自己的工作方式调整这些技术。较小的组织通常会运作得更加非正式,设计师和评审员可能是同一个人。所呈现的技术采用了普适的方法,能够轻松适应你喜欢的任何软件设计方式。
在设计中集成安全性
我认为概念完整性是系统设计中最重要的考虑因素。
—Fred Brooks(摘自 《神话般的人月》)
设计阶段为将安全原则和模式融入软件项目提供了一个黄金机会。在这一初期阶段,你可以轻松探索各种替代方案,在投入实施并被过去的决策所束缚之前。
在设计阶段,开发人员应当创建设计文档,以捕捉软件项目的主要高层次特征,类似于建筑结构的蓝图图纸。我强烈建议投入精力来记录你的设计,因为这有助于确保严谨性,并且创建了一个有价值的成果,使他人能够理解你所做的决策——尤其是在平衡威胁与缓解措施以及所涉及的权衡时。
设计文档通常包括功能描述(从外部视角看软件是如何工作的)和技术规格(从内部视角看它是如何工作的)。更正式的设计在以下情况下尤其有价值:当存在竞争的利益相关者时;当需要协调更大的工作时;当设计必须符合正式的需求规格或严格的兼容性要求时;当面临困难的权衡时,等等。
当你查看一个潜在的软件设计时,戴上你的“安全帽”。然后,在编码开始之前,你可以进行威胁建模、识别攻击面、绘制数据流图等。如果提议的设计在结构上使得确保系统安全变得具有挑战性,那么现在是考虑那些固有更安全的替代方案的最佳时机。你还应该在设计文档中指出重要的安全缓解措施,以便实施者提前看到这些需求。
更有经验的设计师会从一开始就将安全性纳入设计。如果这看起来让人畏惧,完全可以从一个“功能完整”的草图设计开始,然后再重点关注安全性进行第二轮修改,但这样会增加很多工作量。如果能够在早期发现问题,进行重大更改会更容易,从而避免事后重做的浪费。尽早探索新架构并玩转基本要求,而不是拖到后期那时才开始,那时修改会更加困难。正如 Josh Bloch 曾幽默地说:“一周的编码有时可以节省一个小时的思考。”
使设计假设明确化
在 1980 年代中期,我为一家设计并构建计算机的公司工作,那时这台计算机是一个强大的计算机:包括硬件和软件。在多年的开发工作后,当操作系统最终加载到原型硬件上时,两支团队的工作终于结合在一起...但结果是立刻失败。原来,硬件团队大多来自 IBM,使用的是大端字节序架构,而软件团队大多来自 HP,传统上使用的是小端字节序,因此“位 0”在硬件上表示高位,在软件上表示低位。在多年的规划、会议和原型制作过程中,大家只是默认了他们所来自公司文化中的字节序。(当然,最终是软件团队在弄清楚这一点后不得不做出必要的更改。)
未成文的假设可能会削弱安全设计审查的有效性,因此设计师应尽力记录这些假设(而审查者应询问任何不明确的地方)。一个良好的记录这些显式假设的地方是设计文档中的“背景”部分,放在设计正文之前。
记录假设的一种思路是预见到可能出现的严重误解,这样你就不会再听到有人说,“但我以为. . .” 以下是一些常见的假设清单,它们对于文档化非常重要,但在设计中容易被遗漏:
-
预算、资源和时间限制,限制了设计空间
-
系统是否可能成为攻击目标
-
不可谈判的要求,如与遗留系统的兼容性
-
对系统必须执行的安全级别的期望
-
数据的敏感性及其需要安全保护的重要性
-
对系统未来更改的预期需求
-
系统必须达到的特定性能或效率基准
假设的明确化对安全性至关重要,因为误解通常是导致接口设计薄弱或组件之间交互不匹配的根本原因,攻击者可以利用这一点。此外,它还确保设计审查员对项目有一个清晰、一致的视图。
在企业内部或任何一组相关项目中,许多假设在一组设计中会保持一致,在这种情况下,您可以在共享文档中编制一份提供共同背景的清单。各个设计随后只需参考这个共同基础,并详细说明假设变化的任何例外情况。例如,计费系统可能需要遵守比其他企业应用程序更高的安全标准,并需要符合特定的金融监管要求,例如针对信用卡处理组件的要求。
定义范围
如果对审查范围存在不确定性,就不可能对设计的安全性进行良好的审查。明确审查范围对于回答第二章中的“四个问题”中的第一个问题也至关重要:“我们在做什么?”为了理解这一点,可以考虑一个新的客户计费系统的设计。这个设计是否包括用于收集可计费小时报告的网页应用,还是这是一个独立的设计?那么,它依赖的现有数据库呢——这些系统的安全性是否在审查范围内?审查是否应包括您将用于向企业会计系统报告的新的基于 Web 的 API 设计?
通常,设计师会做出战略性决策,定义范围,决定要处理多少内容。当范围由他人定义时,设计师必须理解规定的范围及其原因。您可以将设计的范围定义为在进程中运行的代码、在框图中表示的系统的特定组件、库中的代码、源代码库中的划分,或任何其他最合适的方式,只要它对所有相关方来说都清晰明确。前面提到的计费系统设计可能应该包括新的 API,因为它是相同设计的扩展。相反,现有的数据库可能不在范围内,只要它们没有以根本新的方式使用,并且已经进行了足够的安全审查。
如果设计范围模糊,评审人员可能会假设某些重要的安全方面不在范围内,而设计者可能未意识到这一问题。由于遗漏,它可能会被忽视。例如,几乎所有的软件设计都涉及某种数据存储。除非数据是可丢弃的,这种情况很少见,否则保持良好的备份是应对各种威胁(无论是恶意还是意外)导致完整性丧失的明显缓解措施。设计者经常忽略这种不言而喻的点,但如果没有明确的设计范围声明,每个人可能都认为其他人定期为生产系统中的所有存储进行备份,结果这个任务就被忽视了——直到第一次失败发生时,痛苦的教训才被吸取。
不要让将设计生态系统的一部分排除在范围之外,导致它被忽视。当你继承一个遗留系统时,你首先需要关注的是其最敏感的部分,那些对安全至关重要的部分,或者可能是最明显的攻击目标。然后,审慎地审查系统的其他部分,尤其是构成独立组件的部分,直到你覆盖了所有内容。
你可以通过定义一个狭窄的范围来处理设计迭代、冲刺和现有系统的重大修订,该范围对应着重新设计发生的地方。一旦你为新的设计工作划定了边界,设计中会有一些明确的前提条件,它们超出了该范围,而你可以自由地在内部重新做一切。现有的设计文档使这项工作变得更加轻松和可靠,而更新的设计应推动对文档的变更跟踪。
重设计超出预定范围是常见的,通常也是一件好事,当这种情况发生时,你应该根据需要调整范围。例如,一个增量设计更改可能需要修改现有的接口或数据格式,如果更改涉及处理更敏感的数据,你可能需要根据新的安全假设在接口的另一端进行更改。
很少有软件设计是孤立存在的;它们依赖于现有的系统、流程和组件。确保设计与其依赖项协同工作至关重要。特别是,匹配安全期望是关键,因为你不能用不安全的组件构建一个安全的应用程序。并且需要注意,安全/不安全并不是一个二元选择;它是一个连续体,其中假设和期望需要对齐。阅读同类系统和依赖项的安全设计评审报告,以验证你的安全期望。
设置安全要求
安全需求主要来源于四个问题中的第二个:“可能出错的地方是什么?”C-I-A 三位一体是一个有用的起点:描述保护私人数据免受未经授权披露的需求(保密性),确保数据安全并进行备份的重要性(完整性),以及系统需要多大程度的鲁棒性和可靠性(可用性)。许多软件系统的安全需求是直接明了的,但仍然值得详细列出,以确保完整性并传达优先级。对你来说可能显而易见的事情,对其他人来说可能并非如此,因此明确所需的安全立场是一个好主意。
一个需要注意的极端情况是,当安全性不重要——或者至少,有人认为它不重要时。这是一个需要特别指出的重要假设,因为团队中的其他人可能认为安全性确实很重要(你可以想象在这种期望不匹配的情况下,最终会出现什么情况)。如果你正在设计一个处理人工虚拟数据的原型,你可以跳过安全审核,但需要记录下来,以免代码后来被重新使用并涉及个人信息。另一个低安全性应用的例子可能是多个研究小组共享的天气数据收集:温度和其他大气条件是任何人都可以测量的,且公开这些数据是无害的。
在另一个极端,安全关键的软件应给予额外的关注,并仔细列举其与安全相关的需求。这些将为威胁建模、安全审查和测试提供焦点,确保最高水平的质量。请参见样本设计文档(附录 A),了解安全需求如何影响设计的基本示例。受到复杂法规约束的大型系统可能有严格规定的安全要求,以确保高水平的合规性,但这是一个专业化的任务,超出了我们讨论的范围。
对于具有关键或特殊安全需求的软件设计,请考虑以下一般指南:
-
将安全需求表达为最终目标,而不是规定“如何实现”。
-
考虑所有利益相关者的需求,特别是在这些需求可能发生冲突的情况下,需要找到一个良好的平衡点。
-
确认关键缓解措施的可接受成本和权衡。
-
当有不寻常的需求时,解释它们的动机以及目标。
-
设置可实现的安全目标,而不是完美的强制要求。
以下极端示例展示了具有显著安全需求的系统要求声明可能是什么样的:
在国家安全局,为了保护国家最机密的秘密
系统管理员将能访问大量极为机密的文件,考虑到这对国家安全构成的威胁,我们必须将内部攻击的风险降到最低。具体来说,能够冒充具有广泛访问权限的高级官员的管理员,可能会窃取大量文件,并通过伪造多个独立访问事件的记录,掩盖其行踪,给人一种由不同主体进行的多次独立访问的印象。(有非官方的报道指出,爱德华·斯诺登在窃取 NSA 内部文件时使用了这种技术。)
大型金融机构的认证服务器
服务器的私有加密密钥一旦被泄露,将完全破坏我们所有面向互联网的系统的安全性。尽管内部攻击的可能性较低,但操作人员必须不能拥有合理的否认能力。要求可能包括将密钥存储在防篡改硬件设备中,并保存在物理上有防护的地点,同时密钥的创建和更换过程必须经过正式仪式,并且所有访问必须由至少两名可信人员共同参与。(注意:这包括“如何做”,作为最直接的方式来说明信任分配及物理和逻辑安全的重叠结合。)
一项昂贵的科学实验的数据完整性
我们计划只进行一次这个实验,而且为此所需的资金可能多年都不会再有,因此我们无法承受失去仪器收集到的信息的风险。流数据必须立即复制,并冗余存储在不同的存储介质上,同时通过两条独立的网络传输到物理分离的远程存储系统,以提供额外的备份。
威胁建模
提高软件架构安全性的最佳方法之一是将威胁建模纳入设计过程。设计软件涉及创意性地平衡竞争的需求和策略,反复决定系统的某些方面,有时还需要倒退一步以推动整体愿景的实现。通过威胁建模的视角来看待这一过程,可以揭示设计中的权衡,因此它具有巨大的潜力,能引导设计师朝着正确的方向前进——但要准确找到如何实现更好的结果,需要一定的试错过程。
首先,有一种将威胁建模集成到软件设计中的简化方法。这涉及到构思一系列潜在的设计方案,逐个进行威胁建模,用某种总结性评估对它们进行评分,然后选择最好的方案。在实践中,这些以安全为中心的评估会影响其他重要因素,包括可用性、性能和开发成本。但由于涉及到制作多个设计方案并逐一进行威胁建模的工作量过大,设计师通常需要凭直觉判断哪些权衡提供了有前景的可能性,然后通过分析它们的差异来比较设计替代方案,而不是从头开始重新评估每一个。
在软件系统设计的早期阶段,务必注意信任边界和攻击面,因为这些对于建立一个适合安全的架构至关重要。敏感信息的数据流应尽可能远离拓扑结构中最暴露的部分。例如,考虑一个需要离线访问客户联系信息的外勤销售人员应用,以便他们在外出拜访时进行销售电话。如果将整个客户数据库放入每个移动设备中,将会带来极大的暴露风险,但如果销售人员前往没有良好网络连接的偏远地区,这可能是必要的。威胁建模将突显这一风险,促使你评估替代方案。也许只需要数据库的区域性子集,随着销售代表的位置变化或根据旅行安排动态更新;或者,销售人员可以获得每个客户的代码,配合独特的 PIN 号码,通过转接服务拨打电话,这样就不需要访问客户的电话号码了。
设计师在构建软件时,应将基本威胁模型视为一种基准,从中衡量替代设计。我所指的,是对理想化设计中固有的安全风险的模型,无论它是如何构建的。例如,如果一个客户端/服务器系统正在收集客户端的个人身份信息(PII),那么这些信息在客户端、传输过程中或在处理数据的服务器上暴露的安全风险是不可避免的。没有任何设计上的“魔法”能够消除这些风险,尽管它们通常需要适当的缓解措施。
当固有的安全风险较高时,设计师应尽可能考虑替代方案。继续以 PII 为例,是否真有必要为所有用例收集所有(或任何)这些信息?如果没有,那么支持一些避免从源头收集某些信息的子用例,可能会非常值得。
一个重要的威胁模型引导设计的方式是通过突出设计决策中可能引入的额外风险。例如,选择为敏感数据添加缓存层以提高响应时间,可能会带来额外的风险。额外存储数据(可能是攻击者会针对的资产)必然增加新的风险,尤其是当缓存存储接近攻击面时。这说明设计的变化总是会改变威胁模型——无论是更好还是更糟——理解安全影响后,设计人员可以明智地权衡替代方案的优缺点。
最终,好的软件设计依赖于主观判断。这些判断平衡了涉及的各种因素,以找到一个如果不是最好的,至少是令人满意的结果。尽管安全性很重要,但它不是唯一的考虑因素,因此困难的决策是不可避免的。多年来,我发现,尽管有时它可能会让人害怕,但保持开放心态讨论妥协远比宣称安全问题至上来得更有效。
当最大化安全的成本较低时,推动这种做法很容易——但情况并不总是如此。当妥协是必要时,以下是一些值得牢记的好策略:
-
设计时要考虑灵活性,以便后续能够轻松添加安全保护(也就是说,不要把自己困在不安全的角落里)。
-
如果有特定的攻击特别需要关注,可以对系统进行监控,以便于发现滥用的尝试。
-
当可用性与安全性冲突时,探索用户界面替代方案。同时,在现实情况下对可用性进行原型设计和测量;有时可用性问题是想象出来的,实际上并没有表现出来。
-
通过潜在场景(源自威胁模型)解释安全风险,这些场景说明了某些设计的主要潜在缺点,并利用这些场景展示不实施缓解措施的成本。
构建缓解措施
在定义了软件系统的范围和安全需求,回答了四个问题中的前两个之后,是时候考虑第三个问题:“我们该怎么做?”这个问题引导设计人员将所需的保护和缓解措施融入设计中。在接下来的子章节中,我们将探讨如何针对接口和数据这两个在软件设计中最常见的主题进行缓解措施的设计。接下来的讨论和示例仅触及设计缓解措施的表面。前三章中的所有想法可以根据具体设计的需求进行应用。
设计接口
接口定义了系统的边界,划定了设计或其组成组件的限制。它们可能包括系统调用、库、网络(客户端/服务器或点对点)、进程间和内进程 API、共享数据结构以及常见数据存储中的更多内容。复杂的接口,如安全通信协议,通常需要单独的设计。
定义设计范围内的所有接口,确保你清楚了解共享接口的组件的安全责任。记录输入是否得到可靠验证,或者应将其视为不可信数据。如果存在信任边界,说明如何处理身份验证和授权以跨越该边界。
外部组件的接口(设计范围外的组件)应遵循这些组件的现有设计规范。如果没有相关信息可用,要么记录你的假设,要么考虑采取防御策略来弥补不确定性。例如,如果无法确认输入是否被验证,则假定其为不可信输入。
设计安全接口时,首先需要明确描述它们的工作方式,包括必要的安全属性(即 C-I-A、金标准或隐私要求)。审查接口的安全性相当于验证它们是否能够正常工作,并保持对潜在威胁的强大抵抗力。除非设计师明确安全要求,否则安全审查员(以及后续使用该接口的开发者)将不得不猜测设计师的意图,如果低估或高估了要求,可能会引起混淆。
有时,你不得不使用那些在设计时未考虑安全性或对你的要求安全性不足的现有组件——或者你根本不知道这些组件的安全性。如果你别无选择,请将其标记为问题,并尽可能做一些研究,了解有关这些组件安全性的信息(这可能包括尝试攻击测试样本)。在某些情况下,另一个选择是包装接口以增加安全保护。例如,给定一个容易泄露数据的存储组件,你可以设计一个额外的软件层,提供加密和解密,确保该组件仅存储加密数据,即使泄露也不会造成危害。
数据处理设计
数据处理是几乎所有设计的核心,因此确保数据安全是一个重要步骤。确保数据安全处理的一个良好起点是概述你的数据保护目标。当某一特定数据子集需要额外保护时,要明确指出,并确保在整个设计中一致处理。例如,在在线购物应用中,对信用卡信息应用额外的保护措施。
限制敏感数据流动的需求。这是一个在设计阶段显著减少风险暴露的关键机会(参见第四章中的“最少信息”模式),通常在后续的实施阶段难以做到。减少数据传输需求的一种方法是将数据与一个不透明的标识符关联,然后将标识符作为句柄,在必要时将其转换为实际数据。例如,在附录 A 中的示例设计中,你可以使用这种标识符来记录交易,从而将客户细节隔离在系统日志之外。在需要调查日志条目的少数情况下,审计员可以查找相关细节。
确定公共信息或任何其他不受保密要求约束的数据。这是数据处理要求的一个重要例外,允许在合适的情况下放宽保护措施。在应用这种方法时,记住数据是具有上下文敏感性的,因此公开的数据与其他信息结合时可能变得敏感。例如,大多数企业的地址和其首席执行官的名字通常是公共信息。然而,哪些命名的人在场时应当保持私密。
在没有明确决定的情况下,始终将个人信息视为敏感信息,只有在有特定用途时才收集此类数据。无限期存储敏感数据会产生无尽的保护义务。你可以通过在可能的情况下销毁不再使用的信息来避免这种情况(例如,在若干年没有使用后)。设计应当预见到在不再需要时最终删除私密数据的需求,并明确哪些条件将触发删除,包括备份副本的删除。
将隐私融入设计
未能保护私人信息经常成为新闻头条。我相信,将信息隐私考虑融入软件设计是公司提升表现的重要方式。隐私问题涉及数据保护的人类影响,不仅包括法律和监管问题,还涉及客户期望和未经授权的披露可能带来的影响。要做到这一点,既需要专业的知识,也需要主观判断。但问题的部分关键在于授予第三方使用数据的权限,这需要允许访问。从这个角度看,良好的软件设计可以建立控制措施,以尽量减少失误。
作为起点,设计师应该熟悉所有适用的隐私政策,并了解这些政策与设计的关系。提出问题,并最好从隐私政策的拥有者那里获得书面答复,以确保要求明确。这包括任何可能适用于通过合作伙伴获取的数据的第三方隐私政策义务。这些隐私政策规范数据的收集、使用、存储和共享,因此如果这些活动发生在设计中,政策条款就意味着需要遵守的要求。如果面向公众的隐私政策内容简略,考虑开发一个内部版本来描述必要的细节。
隐私失误通常发生在人员或流程误解政策中的承诺,或根本没有考虑这些承诺时。数据安全保护为设计中内建限制提供了机会,以确保合规性。首先考虑隐私政策作出的明确承诺,然后确保设计在可能的情况下执行这些承诺。例如,如果政策声明“我们不会共享你的数据”,那么除非采取其他措施确保配置错误不会暴露数据,否则应该谨慎使用便于共享的云存储服务。
审计是隐私管理的重要工具,即使仅仅是为了可靠地记录对敏感数据的适当访问。通过对访问的仔细监控,可以及早发现并修正问题访问和使用。在泄露发生后,如果没有记录谁访问了相关数据,那么就很难有效应对。
在可能的情况下,设计明确的隐私保护措施。在无法判断隐私合规性的情况下,请让负责隐私政策的官员在设计上签字。一些常见的将隐私整合到软件设计中的技巧包括:
-
确定新类型数据的收集,并确保其隐私政策的合规性。
-
确认政策允许你出于预定目的使用数据。
-
如果设计可能允许无限制的数据使用,考虑将访问权限仅限于那些熟悉隐私政策限制并能够审核合规性的员工。
-
如果政策限制数据保留的期限,设计一个确保及时删除的系统。
-
随着设计的发展,如果数据库中的某个字段不再使用,考虑将其删除,以减少泄露的风险。
-
考虑为数据共享建立审批流程,以确保接收方已获得管理层批准。
规划完整的软件生命周期
许多软件设计隐含地假设系统会永远存在,忽略了所有软件都有生命周期有限的现实。从系统的首次发布和部署,到更新和维护,再到最终的退役,系统生命周期的许多方面都具有重要的安全隐患,往往在后期容易被忽视。无论任何软件设计多么出色,无论它是取得成功还是失败,它都会随着环境的变化而经历变化。这些变化的影响最好是在设计过程中预见并加以解决,或者至少记录下来以备后续参考。在企业内部,许多这些问题是通用的,一般性处理应该能涵盖大多数系统,具体的例外则应在个别设计中做出说明。
当新的设计正在创建时,很难想象系统生命周期的终结,但大多数影响应该是清晰的,任何设计至少应考虑数据的长期处理方式。特定的法律或商业原因可能要求你保留数据一段时间,但当数据不再需要时,你应该将其销毁,包括备份副本。一些系统在接近生命周期末期时需要经历特定阶段,良好的设计可以通过从一开始就有合适的结构和配置选项来简化这一过程。例如,一个采购系统可能停止接受订单,但仍需要继续提供数据用于薪资和记录保存目的,可能需要再保留一年,然后将交易记录归档以长期保存。
做出权衡
在没有简单选择的情况下进行权衡取舍需要大量的工程判断,同时要权衡许多其他因素。实施更多的安全缓解措施可以减少风险,但只限于复杂性导致更多漏洞的前提下——你应该始终警惕开发投入增加而回报递减的现象。本书将一再建议设计人员在相互竞争的优先级之间做出妥协,但这说起来容易做起来难。本节将介绍一些用于做出这些重要平衡的经验法则。
预见最坏的情况:如果无法保护某个系统资产的机密性、完整性或可用性,情况会有多糟糕?对于每种情境,都有不同程度的灾难需要考虑:数据可能受到多大程度的影响?在什么情况下,系统不可用的时间段会变成严重问题?主要的缓解措施通常会限制最坏情况的发生;例如,按小时备份应该能确保最多只有一个小时的交易数据面临丢失的风险。需要注意的是,在最坏情况下,机密性的丧失尤其难以界定,因为一旦数据被窃取,通常无法想象有任何方法能够撤销信息泄露(2017 年 Equifax 数据泄露事件就是一个典型例子)。
大多数设计工作发生在企业或项目社区内,其中所需的安全级别通常在不同项目之间保持一致。对于某些特定的设计,可能需要偏离常规——要求更高或更低的安全级别——这种假设非常值得在设计前言中指出。一些例子可以帮助阐明这一点。一个在线商店网站应当考虑为处理信用卡支付的软件设定更高的安全标准,因为它是明显的攻击目标,并且由于其巨大的财务责任,受到特殊要求的约束。另一方面,一个网页设计公司可能会展示一个展示其设计案例的网站;由于该网站仅供信息展示,从不收集实际用户数据,因此其安全性要求合理地较低。
设计阶段是平衡软件中竞争需求的最佳时机。坦白说,安全性往往很难在面对时间表截止、预算和人力限制、遗留兼容性问题以及通常冗长的功能需求清单时,作为首要优先事项来全面支持——换句话说,几乎总是如此。设计人员最处于考虑多种替代方案(包括激进方案)并做出基础性更改的最佳位置,而这些改变后来再做将是不可行的。
在这些理想化原则与构建实际系统的务实需求之间找到正确的平衡,是安全软件设计的核心。完美的安全性从来不是目标,额外缓解措施的效益也是有限的。确切的平衡点往往难以确定,但那些能够明确这些权衡的设计更有可能找到一个合理的折衷方案。
设计的简洁性
简单即是终极的精致。
—列奥纳多·达·芬奇
讽刺的是,正如达·芬奇的名言所暗示的,要想设计出一个简单的方案,往往需要大量的思考和努力。早期的天文学家为天体力学开发了各种复杂的计算,直到哥白尼通过将太阳作为中心参考点,而非地球,简化了模型,进而使牛顿能够通过推导重力定律来大大简化计算。我最喜欢的精彩软件设计例子是*nix 操作系统的核心,其中很多部分至今仍在使用。追求创造出一个简洁美观的设计,即使很少实现,往往直接有助于提升安全性。
在软件设计中,简洁有许多不同的表现形式,但没有简单的公式可以发现最简洁、最优雅的设计。在第四章中讨论的几个模式倡导简洁,如设计经济性和最小共同机制。每当安全性依赖于将某些复杂的决策或机制做到完美时,务必小心:看看是否有更简单的方式达到相同的目的。
当复杂的功能与安全机制相互作用时,结果往往会变得非常复杂。一项研究得出结论,1979 年三里岛核电站事故没有特定原因,而是由于系统的极度复杂性,包括其许多冗余的安全措施。安全性可能会妨碍你正在做的事情,而与此同时,确保一切安全变得更加棘手。这里的解决方案通常是将安全性与功能分离,创建一个分层模型,通常是将安全性放在“外部”作为保护壳,所有功能则单独存在于“内部”。然而,当你设计一个硬壳和“软内芯”时,确保这种分离就变得至关重要。设计一个围绕城堡的安全护城河相对容易,但在软件中,很容易无意间打开一条通往内部的路径,从而绕过外部保护层。
安全设计评审
一次好的、富有同情心的评审总是一个令人惊喜的发现。
—乔伊斯·卡罗尔·欧茨

将安全性嵌入软件的最佳方式之一是以“安全帽”身份单独审查设计。本章解释了如何在安全设计评审(SDR)中应用上一章讨论的安全性和隐私设计概念。可以将这个过程类比为建筑师设计建筑物后,工程师审查设计以确保其安全可靠。设计师和评审者都需要了解结构工程和建筑规范,通过共同合作,他们可以实现更高的质量和信任水平。
理想情况下,安全评审员是没有参与设计工作的人员,这样可以保持距离和客观性,同时也是熟悉软件运行环境及其使用方式的人员。然而,这并不是硬性要求;对于不太熟悉设计的评审员来说,他们可能会问更多问题,但也能做得很好。
分享这些方法并鼓励更多的软件专业人员亲自进行 SDR(安全设计评审)是我写这本书的核心目标之一。你几乎可以肯定地在自己熟悉的、工作中使用的软件系统上进行更好的 SDR,而不是那些有更多安全经验但不熟悉这些系统的人。本书提供了帮助你完成这项任务的指南,我希望通过这样做,它能在某种程度上为提高软件安全水平做出小小的贡献。
SDR 后勤
在介绍 SDR 的方法论之前,了解一些基本的背景和基本的后勤安排是很重要的。SDR 的目的是为了什么?如果我们要进行 SDR,它应该在设计流程的哪个阶段进行?最后,我会给出一些关于准备工作的小建议,特别是文档的重要性。
为什么要进行 SDR?
做过几百次 SDR 后,我可以报告说,它从来不会让人觉得浪费时间。SDR 只占总设计时间的极小一部分,要么可以发现重要的改进来增强安全性,要么能提供强有力的保证,确保设计能够正确处理安全问题。简单直接的设计很快就能评审,而对于较大的设计,评审过程提供了一个有用的框架,帮助识别和验证主要的安全隐患。即使是评审一个表面上看似已经涵盖所有安全要点的设计,确认这一点也是一种良好的尽职调查。当然,当 SDR 发现重大问题时,这项工作就变得极为值得,因为在实施阶段发现这些问题会很困难,而事后解决它们的成本则非常高。
此外,SDR 还可以带来有价值的新见解,从而导致与安全无关的设计变更。SDR 提供了一个很好的机会,可以让不同的视角参与进来(如用户体验、客户支持、市场营销、法律等),每个人都能思考那些容易被忽视的话题,例如滥用的潜力和意外后果。
何时进行 SDR
计划在设计(或设计迭代)完成且稳定时进行 SDR,通常是在功能评审之后,但在设计最终确定之前,因为可能会需要做出更改。我强烈建议不要在功能评审中同时处理安全问题,因为思维方式和关注点差异太大。另外,确保每个人——不仅仅是评审者——都能专注于安全问题非常重要,而在功能评审和安全评审混合时,这一点就很难做到,因为大家的注意力往往集中在设计的工作原理上。
复杂或安全关键的设计通常会受益于一个额外的初步 SDR,在设计开始成型但尚未完全完成时,提前获取对主要威胁和总体策略的反馈。初步 SDR 可以更为非正式,预览特定的安全关注点(你预计在这些地方深入挖掘)并从高层次讨论安全权衡。优秀的软件设计师应该始终在设计过程中考虑并解决安全和隐私问题。明确来说,设计师绝不应忽视安全问题,指望 SDR 来解决这些问题。他们应该始终对自己设计的安全性负责,安全审查者则在支持角色中协助确保他们做得足够彻底。反过来,安全审查者也不应当居高临下地讲授,而应当清晰且有说服力地向设计师呈现他们的发现,而不做评判。
文档至关重要
有效的 SDR 依赖于最新的文档,以便各方对正在审查的设计有准确、一致的理解。非正式的口头传达式 SDR 总比没有好,但重要的细节很容易被遗漏或误传,而没有书面记录的情况下,宝贵的成果也容易丢失。就个人而言,我总是更倾向于在会议前预览设计文档,这样我可以提前开始研究设计,而不是在会议中浪费时间去了解我们正在处理的内容。
根据我的经验,设计文档的质量在交付一个优秀的 SDR 中是不可或缺的帮助。当然,实际操作中可能并没有完备的文档,而从第 122 页开始的案例研究讨论了如何处理这种情况。例如,任何设计文档模糊地指出“安全地存储客户数据”都应当引起极大的警惕,除非它进一步说明这是什么意思以及如何做到这一点。没有具体细节的笼统说法几乎总是表现出幼稚和对安全缺乏扎实理解的迹象。
SDR 过程
以下对 SDR 过程的解释描述了我在一家大型软件公司如何进行这些过程的,该公司有正式的、强制性的审查流程。话虽如此,软件设计有着无数种不同的实践方式,你可以将相同的策略和分析方法应用于不太正式的组织。
从一个清晰完整的书面设计开始,SDR 包括六个阶段:
-
学习设计和支持文档,以便对项目有一个基本的理解。
-
询问设计并提出关于基本威胁的澄清问题。
-
识别设计中最为安全关键的部分,以便重点关注。
-
合作与设计师一起识别风险并讨论缓解措施。
-
撰写调查结果和建议的总结报告。
-
跟进后续的设计更改,以确认问题已解决,然后再签字确认。
对于小型设计,你通常可以在一个会议中完成大部分工作;对于较大的设计,按阶段分解工作,一些阶段可能需要多次会议才能完成。专门与设计团队会面的会议是理想的,但如果有必要,审阅者可以独立工作,然后通过电子邮件或其他方式与设计团队交换笔记和问题。
每个人的风格都不同。有些审阅者喜欢直接深入,进行“马拉松式”的工作。我个人更倾向于(并推荐)分几天逐步进行,这样我可以有机会“过夜思考”,这通常是我最好的思考时刻。
以下是 SDR 过程的逐步讲解,解释了每个阶段,并列出了有用的技巧。你在执行 SDR 时可以参考每个阶段的要点,按照流程进行。
1. 学习
研究设计和支持文档,以便为审阅做好准备,从而获得对软件的基本理解。除了安全知识外,审阅者理想情况下还应具备领域特定的专业知识。如果没有这方面的知识,尽量在过程中多了解一些,并保持好奇心。大多数安全决策都会涉及权衡,因此单纯地追求更多的安全可能会做得过头,甚至有可能在过程中毁掉设计。为了理解过多的安全如何适得其反,可以想象一座只为减少火灾风险而设计的房子。它完全由混凝土建成,只有一扇厚重的钢门,没有窗户,这样的房子不仅成本高昂,而且丑陋,没人愿意住在里面。
在这个准备阶段:
-
首先,阅读文档,了解设计的高层次概况。
-
接下来,戴上你的“安全帽”,以威胁意识的心态重新审视设计。
-
记笔记,记录你的想法和观察,以备将来参考。
-
标记潜在问题,但在这个阶段进行深入的安全分析为时过早。
2. 询问
向设计师提出澄清性问题,了解系统的基本威胁。对于容易理解的简单设计,或者当设计师提供了非常完善的文档时,你可能可以跳过这一步。将其视为一个机会,在继续进行之前确认你对设计的理解,并解决任何模糊不清或未解答的问题。审阅者并不需要对设计了如指掌才能有效工作——那是设计师的工作——但你需要对设计的总体框架以及主要组件如何交互有一个扎实的了解。
这是一个在深入之前填补空白的机会。以下是一些建议:
-
确保设计文档清晰完整。
-
如果有遗漏或需要修正的地方,帮助在文档中修复它们。
-
理解设计到足以能进行交流,但不一定要达到专家级别。
-
询问团队成员他们最担心的是什么;如果他们没有安全方面的担忧,问一些后续问题以了解为什么没有。
作为安全审查员,你提出的问题不必仅限于设计文档中的内容。了解同行系统对于评估它们对设计安全性的影响非常有帮助。遗漏的细节往往最难发现。例如,如果设计隐式地存储数据,但没有提供如何处理这些数据的任何细节,应该询问存储方式及其安全性。
3. 识别
识别设计中关键的安全部分,并重点进行分析。基于基本原则,以安全视角进行思考:考虑 C-I-A、黄金标准、资产、攻击面和信任边界。虽然这些部分需要特别关注,但此时的安全审查应聚焦于整体设计,而不是完全忽视其他部分。话虽如此,对于与安全关系不大或几乎没有关系的设计部分,可以跳过。
在这个探索阶段,你应该:
-
审查接口、存储和通信——这些通常是重点关注的地方。
-
从最暴露的攻击面向最有价值的资产进行深入分析,就像攻击者会做的那样。
-
评估设计在多大程度上明确考虑了安全性。
-
如有必要,指出关键保护措施,并确保它们在设计中作为重要特性被明确标出。
4. 合作
与设计者合作,传达发现并讨论替代方案。理想情况下,设计者和审查员应该会面讨论,一一解决问题。这是一个学习过程:设计者获得对设计的新视角,同时学习安全知识;审查员则深入理解设计、设计者的意图,并加深对安全挑战和最佳缓解方案的理解。共同的目标是使设计整体变得更好;虽然安全是审查的重点,但并非唯一考虑因素。不必当场做出关于更改的最终决定,但最终需要达成一致,确定哪些设计更改值得考虑。
以下是有效合作的一些指南:
-
作为审查员,在需要的地方提供关于风险和缓解措施的安全视角。即使设计本身已经安全,这也能起到加强良好安全实践的作用。
-
考虑绘制一个场景,说明安全更改如何在未来带来收益,帮助说服设计者采取缓解措施的必要性。
-
当可能时,提供多种解决方案,并帮助设计者了解这些替代方案的优缺点。
-
接受设计者拥有最终决定权,因为他们对设计负有最终责任。
-
记录思想交流的过程,包括哪些内容会或不会被纳入设计。
扩展“最后决定”:在实践中,这一平衡将取决于组织及其文化、适用的行业标准、可能的监管要求和其他因素。在大型或高度规范化的组织中,最后决定可能涉及多个方签字,包括架构委员会、标准合规官、可用性评估人员和执行利益相关者。当需要多方批准时,设计师必须平衡相互冲突的利益,因此安全评审员应特别关注这一动态,并尽可能灵活。
5. 写作
撰写关于评审结果和建议的评估 报告。结果是安全评审员对设计安全性的评估。报告应着重于可能需要考虑的设计变更,以及对现有设计安全性的分析。设计师已同意的任何变更应明确标出,并且需在后续进行验证。可以考虑为建议的变更提供优先级排名,例如这个简单的三层方案:
-
Must是最强的排名,意味着应该没有选择,且通常暗示紧急性。
-
Ought是中等排名:我用它来表示我作为评审员倾向于“Must”,但这也是可以讨论的。
-
Should是针对可选推荐变更的最弱排名。
在设计阶段,给出更精确的排名较为困难,但如果你想尝试,13 章提供了关于如何系统地为安全漏洞分配更细化排名的指导,这些方法可以很容易地用于此目的。
SDRs 变化较大,因此我从未使用标准化的评估报告模板,而是编写描述发现结果的叙述。我喜欢根据自己在评审过程中做的粗略笔记来工作,报告的最终形式自然地演变。如果你能可靠地记住所有细节,可能会想在评审会议后再写报告。
以下提示也可作为写作的框架:
-
以具体的设计变更为中心组织报告,解决安全风险问题。
-
将大部分精力和文字投入到最高优先级问题上,对于较低优先级问题则相应减少。
-
提出替代方案和策略,但不要试图替设计师做他们的工作。
-
使用优先级排名来确定发现问题和建议的优先顺序。
-
聚焦于安全性,但也可以提供独立的意见供设计师参考。在 SDR 范围之外,保持更多的尊重,不要挑剔,避免稀释安全性信息。
将设计师和评审人员的角色分开很重要,但实际上如何分配角色会根据每个人的职责和协作能力有很大的差异。在评估报告中,避免做设计工作,而是为需要的修改提供明确的指导,以便设计师知道该做什么。如果当前评审的结果导致了重要的设计重做,可以提供审阅和反馈。作为一个经验法则,一个好的评审人员能帮助设计师看到安全威胁及其潜在后果,并提出缓解策略,而不是强加具体的设计变更。过于苛刻的评审人员往往会发现他们的建议无效,即使它是正确的,而且他们有可能迫使设计师做出自己并不完全理解或不认为必要的改变。
如果这种严格的写报告方式感觉过于繁琐,你可以减少报告的编写工作,但很有可能你或其他参与软件开发的人以后会希望将这些细节记录下来,以供日后参考。至少,我建议花时间向团队发送一封电子邮件总结,以留存记录。即使是一个简短的报告,也不应仅仅说“看起来不错!”,而应该用实质性的总结来支持这一点。如果设计涵盖了所有的安全要点,可以提到一些安全所依赖的最重要的设计特征,以突出它们的重要性。如果设计中安全性不是重点(例如,我曾审查过一个不收集任何私人信息的资讯网站),应当概述得出这一结论的原因。
这些报告的风格、长度和细节程度会根据组织文化、可用时间、利益相关者人数以及许多其他因素有很大的不同。当作为评审人员时,你与软件设计师紧密合作时,可能可以将所需的条款直接纳入设计文档,而不是在报告中列举需要改进的问题。即使是小型非正式项目,指定设计师和评审人员的角色也是值得的,这样可以有多个视角来审视工作,并确保安全问题得以充分考虑。然而,即使是独立设计,也能从设计师带着安全思维重新审视自己的工作中获得新视角的益处。
6. 后续跟进
跟进由于安全评审而进行的设计变更,以确认这些变更是否得到正确解决。当合作顺利时,我通常只检查文档更新是否完成,而不查看实现细节(根据我的经验,这种方法从未出错)。在其他情况下,并根据你的判断,评审者可能需要更加警觉。评审完成后,确认所有必要的更改都已完成,并签署评审意见。在项目缺陷追踪系统中指定 SDR 是可靠跟踪进展的好方法。否则,如果你更喜欢,也可以使用更正式或不那么正式的过程。以下是这一最终阶段的一些建议:
-
对于重大的安全设计变更,你可能需要与设计师合作,以确保变更正确地实施。
-
在意见分歧的情况下,评审者应陈述双方立场,并指出未采纳的具体建议,将其标记为一个未解决的问题。(第 121 页的“管理分歧”详细讨论了这个话题。)
在最佳情况下,设计师会把评审者视为安全资源,并在需要时继续进行合作。
评估设计安全性
现在我们已经涵盖了 SDR 过程,本节将深入探讨进行评审时的思维过程。到目前为止,本书中的材料为你提供了进行 SDR 所需的概念和工具。基础原则、威胁建模、设计技术、模式、缓解措施、加密工具——这些都为构建一个安全的设计奠定了基础。
使用四个问题作为指导
第二章用于威胁建模的四个问题是帮助你进行有效 SDR 的绝佳指南。显式的威胁建模如果你有时间并愿意投入精力是非常好的,但如果没有时间,使用这四个问题作为基准也是将威胁视角融入评审的好方法。后面的子章节将提供更详细的解释,但从最高层次来说,以下是这些问题如何映射到 SDR 的:
-
我们在做什么?
评审者应该理解设计的高层目标,以此作为评审的背景。实现目标的最安全方式是什么?
-
可能会出什么问题?
这就是“安全帽”思维的应用,以及如何应用威胁建模的地方。设计是否未能预见到或低估了一个关键威胁?
-
我们该怎么做?
回顾你在设计中发现的保护措施和缓解措施。我们能以更好的方式应对重要的威胁吗?
-
我们做得好吗?
评估设计中的缓解措施是否足够,是否需要进一步完善,或是否有任何遗漏。设计有多安全,如果不够,如何提升其安全性?
在编写 SDR 时,你可以使用“四个问题”作为提示。如果你已经阅读了设计文档,并注意到关注的领域,但尚不完全确定需要寻找什么内容,可以依次检查四个问题,特别是第 2 和第 3 个问题,并考虑它们如何适用于设计的具体部分。从那里,你的评估自然会转向第 4 个问题。如果答案不是“我们做得很好”,那么这很可能是一个值得讨论的主题,或者是你应当在评估报告中包含的内容。
我们正在做什么?
这个问题有几种具体的方式帮助你保持正轨。首先,了解设计的目的非常重要,这样你可以自信地提出削减那些带来风险但实际上不必要的部分。相反,当你提出更改时,你不希望破坏实际上是必须的功能。或许最重要的是,你可能能够提出一种替代方案,以避免风险较大的功能并采取新的方向。
例如,在隐私领域,如果你在审查一个收集所有员工个人信息的薪资系统,你可能会发现健康问题特别敏感。如果相关数据项确实多余,那么从设计中删除它是正确的选择。然而,如果它对业务功能至关重要,你可以提出一些方法来严格保护这些数据的泄露(例如,早期加密,或在短时间内删除数据)。
可能出错的地方?
审查应该确认设计者已经预见到系统面临的重要威胁。而且,仅仅知道这些威胁是不够的;设计者必须实际上创建一个能够应对这些威胁的设计。
某些威胁可能是可接受的,并且可以不加以缓解,在这种情况下,审查员的工作是评估这个决定。但是,重要的是要确保设计者意识到这个威胁,并选择省略缓解措施。如果设计中没有明确说明他们正在这么做,请在 SDR 中备注,以便再次确认这是故意的。同时,注明接受的风险并解释为什么它是可以容忍的。例如,你可以写道:“传输中的未加密数据构成了窃听威胁。然而,我们认为这个风险是可以接受的,因为数据中心已得到物理保护,且没有泄露个人身份信息或商业机密数据的潜在风险。”
尝试预见未来可能会使接受风险的决策失效的变化。以刚才提到的例子为基础,你可以补充道:“如果系统迁移到第三方数据中心,我们应当重新审视这个物理网络访问风险的决策。”
我们将如何处理它?
安全保护机制和缓解措施应该在设计中显而易见,审阅者在研究设计时通常会专注于这方面。审阅者通常将大部分时间花在最后两个问题上:识别设计的安全性以及评估其安全性。处理这个任务的一种方法是将威胁与缓解措施匹配,看看是否覆盖了所有基础。指出由此问题引发的问题并确认设计是否令人满意,是 SDR 最重要的贡献之一。
如果设计未能有效缓解安全风险,那么你应该列出缺少的内容。为了使反馈有用,你需要解释具体哪些威胁没有得到解决,以及为什么它们很重要,并可能提供一组粗略的选项来应对每个问题。出于多种原因,我建议不要在 SDR 中提出具体的解决方案。然而,非正式地提供帮助并与设计师合作,考虑替代方案或详细阐述设计更改是非常好的。例如,你的反馈可以是:“监控 API 不应该公开暴露,因为它透露了我们网站的使用水平,这可能会给竞争对手带来优势。我建议要求使用访问密钥来认证对 RESTful API 的请求。”
当设计确实为某个特定威胁提供了缓解措施时,评估其有效性,并考虑是否可能有更好的替代方案。有时候,设计师通过从头开始构建安全机制来“重新发明轮子”:一个好的反馈是建议使用标准库来代替。如果设计是安全的,但这需要付出很大的性能代价,如果可能的话,提出另一种方式。例如,指出冗余的安全机制,如加密通过加密的 HTTPS 连接发送的数据,并描述如何简化设计。
我们做得好吗?
最后一个问题归结到关键点:你认为这个设计安全吗?称职的设计师应该已经考虑了安全问题,因此,SDR 的价值在于确保他们已经看到了整个局面并预见了主要威胁。根据我的经验,SDR 能够迅速识别问题和机会,或者至少提出一些值得现在考虑的有趣权衡决策(因为之后你就无法轻松做出更改了)。
我建议在报告的顶部用一句话总结你对整个设计的整体评价。以下是一些可能的例子:
-
我发现当前设计已经足够安全,并没有建议的更改。
-
设计是安全的,但我有一些建议可以使其更加安全。
-
我对当前设计有一些担忧,并提出了一些建议,以增强其安全性。
在总结之后,如果有多个需要修复的不足部分,逐一列出并逐条解释。如果你能够将弱点归因于设计的某个具体部分,这将使设计师更容易找到问题,清晰地看到并采取必要的补救措施。
当然,没有设计是完美的,所以在评判一个设计存在不足时,明确你所依据的标准非常重要。这很难抽象地表达,因此一个好的方法是指出具体的威胁、漏洞和后果,以支持你的观点。最好将评估以与类似产品的安全性进行对比;例如,“我们的主要竞争对手声称自己能够抵御勒索病毒攻击,这是其主要卖点,但由于该设计将库存数据库保存在员工还用来上网的计算机上,这使得它特别容易受到此类攻击。”
挖掘的重点在哪里
在大型设计中深入每个角落是不可行的,因此审查员需要尽可能快速地关注那些对安全至关重要的关键区域。我鼓励安全审查员在决定将精力集中在哪些设计部分时,遵循自己的直觉。首先通读设计,并根据直觉标记出感兴趣的区域。接着,回到最有问题的区域,更加仔细地研究,并收集需要提问的问题,让潜在的威胁和“四个问题”成为你的指南。这些线索中,有些比其他的更有成效。如果你开始走上一条无效的路径,通常会很快意识到,从而可以将精力重新集中到其他地方。
对于与安全和隐私无关的设计部分,可以快速浏览,吸收足够的内容以对所有组件有一个基本了解。如果你把自己锁在家外,你会知道去检查是否有开着的窗户或未锁的门:没有人会花时间一寸寸地检查整个外部。同样,最有效的方式是集中精力关注设计中你发现的任何弱点,或者密切关注设计如何保护最重要的资产。
留意攻击面并给予足够的关注。攻击面越容易接触到——匿名的互联网暴露是最糟糕的经典情况——它们越可能成为潜在的攻击源。保护重要资源的信任边界,尤其是当这些资源可以从攻击面访问时,是设计中审查员应当特别强调的主要通用特征。有时,可以将宝贵的资产更好地与外部组件隔离开,但往往暴露是不可避免的。这些就是审查员在整个过程中需要寻找并评估的因素。
隐私审查
根据你的技能和组织责任,你可能希望在 SDR 范围内处理信息隐私,或者单独处理。SDR 中的隐私反馈应聚焦于适用的隐私政策,以及它们如何与设计范围内的数据收集、使用、存储和共享相关。
一种有效的技巧是浏览隐私政策并记录与设计相关的段落,然后寻找防止违规的方法。正如前一章所描述的,技术重点是确保设计符合政策要求。对于需要更多专业知识的问题,获取隐私专家和法律人员的签字批准。
审查更新
一旦发布,软件似乎拥有了自己的生命,随着时间的推移,变化是不可避免的。特别是在敏捷开发或其他迭代开发实践中,设计变更是一个持续的过程。设计文档很容易在过程中被忽视,几年后可能会丢失或变得无关紧要。然而,软件设计的变更可能影响其安全性特性,因此进行增量 SDR 更新以确保设计保持安全是明智之举。
设计文档应是活文档,记录软件架构形式的演变。版本化文档是设计如何成熟(或者在某些情况下变得复杂)的重要记录。你可以使用这些相同的文档作为指南,专注于自上次 SDR 以来的具体变更集(设计差异),以更新文档。当设计的安全关键区域发生变化(或接近变化)时,审查员通常应该跟进,确保设计文档中没有遗漏任何小但重要的细节,这些细节可能会产生重大影响。如果增量审查确实发现了重要内容,将其加入现有的评估报告中,使其现在能够完整地呈现情况。如果没有发现,更新报告以注明它覆盖的设计版本即可。
低估“简单变更”的影响是引发安全灾难的常见诱因,重新审查设计是主动有效评估此类影响的好方法。如果设计变更如此微小,以至于不需要审查,那么审查员也可以立刻确认没有安全影响。对于任何非琐碎的设计变更,我建议不要跳过 SDR 更新,因为忽视这个重要的保障措施可能带来风险。
管理分歧
无论你做什么,生活中要与你那些会与你争论的聪明人相伴。
—约翰·伍登
我从多年推广安全性的经验中得到的一个重要教训——虽然事后看来显而易见,但我通过艰难的方式学到的——是良好的沟通技巧对于成功进行 SDR 至关重要。分析当然是技术性的,但对设计进行批评需要良好的沟通和协作,因此人际因素也很关键。安全专家,无论是内部人员还是外包人员,往往会得到(是否应得的另说)他们过于挑剔、永远不满足的外号。这种看法在微妙地毒化互动时,不仅让工作变得困难,还对每个人的努力效果产生负面影响。我们必须意识到这一因素,以便做得更好。
巧妙沟通
SDR 本质上是对抗性的,因为它们主要是指出设计中的风险和潜在缺陷,而这些设计通常是人们投入了大量精力的。一旦发现,设计缺陷往往会在事后看起来非常明显,审查者很容易将其归咎为疏忽,甚至是无能——但以这种方式沟通永远不会有助于解决问题。相反,应该将出现的问题视为教学机会。一旦设计者理解了问题,他们通常会引导讨论进入审查者可能错过的其他有益领域。有人指出自己设计中的漏洞是学习安全性最有效的方式。
一个 SDR(安全设计审查员)如果在讲解如何将安全性最大化的同时,冷酷无情地拆解一个薄弱的设计,并且只是一味地进行单方面的讲座,往往不会达到预期效果(如果你站在接受方的立场上,应该很容易理解为什么)。虽然这种情况不幸有时确实会发生,但我认为这并不一定是因为审查者刻薄,而是因为在关注需要的技术性改动时,很容易忘记保持尊重的语气。为了维持良好的合作关系,并加强每个人都在同一个团队、带着多样的视角共同努力达成正确平衡的信念,做出额外的努力是值得的。体育教练们也常常走在这条微妙的界线上,指出他们看到的弱点(对手也许会利用这些弱点),而不会要求太多,目的是帮助他们的队伍完成必要的工作,打出最好的比赛。正如马克·库班所说:“善良比刻薄更有远见。”
与人相处,同时传达可能不受欢迎的消息,当然是理想的,但这往往比说起来容易做。由于这是一本技术软件书籍,我不会提供如何赢得朋友和影响开发人员的自助建议。但人类因素足够重要——或者更准确地说,忽视它足以潜在地破坏工作——因此值得重点提及。我的基本指导原则很简单:要意识到自己传递信息的方式,并考虑他人如何接收这些信息以及可能的反应。为了展示这种方式如何在 SDR 中运作,我提供了一个真实的故事和一套我已逐渐依赖的技巧。
案例研究:一次困难的评审
我最难忘的一个 SDR 是一个极好的示范,展示了软技能的重要性。它始于我发起的一封痛苦的电子邮件交换,目的是获取文档并询问一些基本问题。这次交换使得团队负责人立即清楚地意识到,SDR 在他们看来完全是浪费时间。更糟糕的是,由于他们之前不知道这个产品发布的要求,它突然成为了一个不受欢迎的新障碍,阻碍了他们一直在努力推进的发布。这个故事的第一个关键教训是,重要的是要认识到其他参与者对过程的看法,无论对错,并相应地做出调整。
我最终获得的文档我觉得很粗糙、不完整,而且相当过时。直接用这些话指出这一点会无益且进一步恶化关系。第二个关键点是,为了促进改进,绕过问题并有效处理 SDR,采用以下策略更具生产力:
-
提出修复或添加建议,包括每个建议背后的安全理由。
-
在可行的情况下,主动提出帮助审查文档、建议修改或其他任何可以促进过程的事情(但不至于代替他们的工作)。
-
将初步的 SDR 反馈呈现为“我的观点”,而不是要求。
-
使用“三明治”方法:先给出正面评价,再指出需要改进的地方,然后以正面的方式结束(例如说明这些改动如何有帮助)。
-
如果你的反馈内容很多,先询问如何最好地传达它。(不要让他们吃惊于一封有 97 个要点的邮件,或者突然提交大量的 bug。)
-
探索你注意到的所有线索,但将反馈限制在最重要的几点。(不要做完美主义者。)
-
一个好的经验法则是,如果缺失的信息对许多读者来说普遍有用,那么值得记录;但如果它仅仅是针对你的需求,那么就应该用更不正式的方式提问。(如有必要,可以在评估报告中包含问题的细节。)
与其抱怨或评判文档质量,不如寻找创造性的方法来了解软件,比如使用内部原型(如果有的话),或浏览代码和代码评审。请求观察常规团队会议可以是一个了解设计的好方法,而不会占用任何人的时间。
通过电子邮件时,我觉得他们有些粗鲁,但当我们终于见面时,我才明白那只是一个压力山大的首席开发人员。为了不完全依赖首席,我找到了另一位压力较小的团队成员,他很乐意回答我的问题。为了节省准备 SDR 会议的时间,我只追问那些需要提前解决的问题,其他问题留到会议上,当时我有了一个固定的听众。
准备 SDR 会议是一项平衡的工作。你不应该毫无准备就去开会,因为团队可能不喜欢必须重新描述一切,特别是在已经提供了文档的情况下。提前,尽量识别你不熟悉的主要组件和依赖关系,并至少了解足够的内容以便在会议中提问。在准备过程中,一个好的做法是记下问题和疑问,然后将它们分类:
-
在你与他们会面之前,可以提前提出的问题,以便准备深入探讨安全性
-
你可以自己找到答案的问题
-
最适合在会议上探讨的话题
-
你将在评估报告中包含的无需讨论的观察
当我们最终召开会议时,首席工程师明显不满,因为现在 SDR 成了推出产品的主要障碍。第一次会议有些波动,但我们取得了不错的进展,大家都保持专注。经过几次会议(每次会议逐渐变得更轻松和更简短),我批准了设计。我们在第一次会议上就同意了一些更改,但确认细节并召开会议以最终确定它们,对于大家来说都是一种重要的保障。如果你不花时间确认设计中需要的更改是否已经完成,很容易让误解漏掉。
说服忙碌的人相信你通过占用他们的时间在帮助他们,永远都不容易,仅仅告诉他们通常不起作用。然而,即使是小的提升安全性的机会,并展示这些如何贡献于最终产品,是达到互利结果的好方法。
到 SDR 完成时,产品团队对安全性有了更深的理解——也因此更了解了他们自己的产品。最终,他们确实看到了评审的价值,并承认产品因此得到了改善。更好的是,对于第二版,团队主动联系了我,我们顺利完成了更新的 SDR,成绩斐然。
升级的分歧
当设计师和审阅者未能达成共识时,他们应该同意各自保留不同意见。如果问题较小,审阅者可以在评估报告中简单记录分歧点,并尊重设计师的意见。在这种情况下,应明确指出分歧,可能会在一个名为“建议被拒绝”的章节中解释所建议的设计变更以及推荐的理由,并说明不进行更改的潜在后果。然而,如果对重大决策存在严重分歧,审阅者应当将问题上报。
在这种情况下,设计师和审阅者都应该写出各自的立场,首先尝试确定一些他们一致同意的共同起点,并交换草稿以便每个人都能了解双方的观点。他们各自的立场会合并成一份备忘录,解释风险、提出的结果及其成本。此备忘录是评估报告的补充,作为会议的基础,或作为管理层决定如何继续的指南。最终决策的结果以及升级备忘录应纳入评估报告。
多年来进行安全审查时,我从未遇到过需要升级问题的情况,但也曾有几次接近此种情形。强烈的分歧几乎总是源自于基本假设上的深刻分歧,一旦识别出这些分歧,通常会导致问题的解决。这种差异往往源于对软件使用方式的隐性假设,或对其将处理的数据的假设。实际上,如何使用软件是极难控制的,随着时间的推移,使用场景通常会发生变化,因此通常采取安全的做法是最明智的选择。
另一种主要的脱节原因是设计师未能意识到数据的机密性或完整性问题,通常是因为他们缺乏必要的终端用户视角或没有考虑到可能的全部使用场景。另一个需要考虑的重要因素是:假设我们在发布后改变主意,在那时进行更改会有多困难?没有人想在事后说“我早就说过”,但将相反的条件写下来通常是做出正确选择的最佳方式。
练习,练习,再练习
为了巩固你在本章学到的内容,并真正将其内化,我强烈建议读者迈出这一步,找一个软件设计,进行 SDR。如果目前你感兴趣的领域内没有现成的软件设计,可以选择任何现有的设计并进行审阅练习。如果你选择的软件没有正式的书面设计,可以先自己创建一个粗略的设计表示(不需要是完整或精细的文档,甚至一个框图也可以),然后进行审阅。通常,最好从一个适中的设计开始,这样你不会感到力不从心,或者从一个大型系统中选取一个组件进行审阅。读到这里应该为你开始实践做了充分准备。如果你还不够自信来分享你的评估报告,可以先为自己的使用进行快速审阅。
随着你掌握 SDR 的关键技能,你可以将它们应用到任何遇到的软件中。研究大量设计是学习软件设计艺术的好方法——既能看到大师们是如何做的,也能发现别人所犯的错误——并且以这种方式进行实践是锻炼技能的绝佳练习。
一个特别简单的起步方式是审阅附录 A 中的样本设计文档。文中已突出显示了安全条款,以提供一个现实的例子,帮助你识别设计中应该注意的部分。阅读设计文档,注意其中标注的部分,然后设想如果这些安全细节缺失,你该如何识别并补充它们。为了更大的挑战,可以寻找其他方法使设计更加安全(我并不声称或期望它是一个完美无缺的理想设计!)。
通过每次进行 SDR,你将提高自己的熟练度。即使你没有发现任何明显的漏洞,你也会加深对设计的理解,并提升你的安全技能。确实,亟需安全关注的软件不在少数,所以我邀请你开始实践。我相信你会惊讶于自己掌握这一宝贵技能的速度。
第三部分
实现
安全编程
第一个原则是你绝不能欺骗自己,而你是最容易被欺骗的人。
—理查德·P·费曼

一个完整的软件设计,经过考虑安全性后创建和审查,只是产品旅程的开始:接下来是实施、测试、部署、运营、监控、维护,最终在生命周期结束时退役的工作。尽管所有这些的具体细节在不同的操作系统和语言中会有很大的差异,但广泛的安全主题是如此常见,以至于几乎是普遍的。
开发人员不仅必须忠实地实现良好设计中的显式安全条款,而且在实现过程中还必须小心避免通过有缺陷的代码无意中引入额外的漏洞。一个基于建筑师设计图纸建造房屋的木匠是一个很好的比喻:用劣质材料和粗糙的施工会导致最终产品出现各种问题。如果木匠打钉子时不小心弯了钉子,问题是显而易见并且容易修复的。相比之下,有缺陷的代码容易被忽视,但它仍然可能会创建一个可以被利用并带来严重后果的漏洞。本章的目的不是教你如何编码——我假设你已经了解这方面的知识——而是教你代码如何变得脆弱,以及如何让代码更加安全。接下来的章节将涵盖许多普遍存在的实施漏洞,这些漏洞继续困扰着软件项目。
设计和实现之间的界限并不总是明确的,而且也不应该明确。深思熟虑的设计师可以预见编程问题,提供关于安全至关重要的领域的建议,等等。进行实现的程序员必须完善设计,并解决任何模糊之处,以便编写具有精确定义接口的功能性代码。他们不仅必须安全地呈现设计——这本身就是一项艰巨的任务——还必须避免在提供必要的详细代码过程中引入额外的漏洞。
在理想的世界中,设计应当指定主动的安全措施:为保护系统、资产和用户而构建的软件特性。相反,开发中的安全性是关于避免软件可能遇到的陷阱——如果你愿意,可以把它看作是组件和工具的粗糙边缘。当在实施过程中出现新风险时,应该针对这些风险采取相应的缓解措施,因为没有理由期望设计师能够预见到这些问题。
本章重点讨论一些漏洞是如何从错误中产生的,它们是如何发生的,以及如何避免各种陷阱。它以一般性的问题展开,为接下来的章节做铺垫,这些章节将深入探讨历史上被证明充满安全问题的主要领域。我们将从探讨安全编码挑战的本质开始,包括攻击者如何利用漏洞并将其影响扩展到代码中更深层次。我们还将讨论错误:漏洞是如何从错误中产生的,轻微的错误如何形成可能导致更大问题的漏洞链,以及从熵的角度看待代码。
避免代码中的漏洞需要保持警惕,但这需要了解代码如何破坏安全性。为了将编码漏洞的概念具体化,我们将通过一个简化版本的代码,展示一个致命的真实漏洞,说明如何通过一次简单的编辑失误导致全网的安全崩溃。接下来,我们将以几个常见漏洞的类别为例,展示一些可能被利用且后果严重的错误。
在第三部分中,大多数代码示例将使用 Python 和 C 语言,这两种语言广泛应用,涵盖了从高级到低级的各种抽象层次。这些是真正的代码,使用了特定语言的细节,但书中的概念是通用的。即使你不熟悉 Python 或 C 语言,代码片段也应该足够简单,让任何熟悉现代编程语言的读者都能跟得上。
挑战
“安全编程”这个术语是本章标题的显而易见选择,尽管它可能具有误导性。更准确地表达目标(虽然不适合作为章节标题)应该是“避免不安全的编码”。我的意思是,安全编码的挑战主要在于不引入会成为可被利用的漏洞的缺陷。程序员当然会构建主动提高安全性的保护机制,但这些机制通常在设计或 API 的功能中是明确的。我想主要关注那些无意中陷入的陷阱,因为它们不明显,并构成大多数安全失败的根本原因。可以把安全编码看作是学习如何发现道路上的坑洞,仔细驾驶,并始终如一地避开它们。
我相信许多程序员,或许有充分的理由,对软件安全持有不太友好的态度(在某些情况下,更直观地表现为对“安全警察”——或者更糟的称呼——的反感,因为他们认为这些人总是给他们添麻烦),因为他们通常听到的消息是“不要出错”。“不要出错!”对即将切割稀有钻石的珠宝商来说,这也是不太有帮助的建议:他们本打算尽全力做到最好,而额外的压力只会让他们更难集中注意力,做好工作。这些好意的“警察”确实在提供必要的建议,但他们往往没有以最友好、最建设性的方式表达出来。我自己也犯过这个错误很多次,所以在这里我尽力走好这条细线,也希望读者能够理解。
确实需要小心,因为程序员的一次失误(正如我们稍后在本章讨论的 GotoFail 漏洞所示)很容易导致灾难性的后果。问题的根源在于大型现代软件系统的脆弱性和复杂性,而这种问题在未来只会加剧。专业开发人员知道如何测试和调试代码,但安全性则是另一回事,因为易受攻击的代码在没有严密攻击的情况下通常能够正常工作。
软件设计师创造出理想化的概念,这些概念因为尚未实现,因此理论上可以是完全安全的。但要让软件真正运行起来,就会引入新的复杂性,并且需要填补设计之外的细节,这些都会不可避免地带来安全问题的风险。好消息是,完美并不是目标,导致大多数常见漏洞的编码失败模式已经被很好地理解,并且并不难做到正确。诀窍在于保持持续的警惕,学会识别代码中的危险缺陷。本章将介绍一些概念,帮助你更好地理解什么是安全代码与易受攻击的代码,并举一些例子。
恶意影响
在考虑安全编码时,一个关键的考虑因素是理解攻击者如何可能影响正在运行的代码。想象一个大型复杂的机器平稳运转,然后一个恶作剧者拿起一根棍子开始戳机械部件。有些部分,比如汽油发动机的气缸,会完全被机体保护起来,而另一些部分,比如风扇皮带,则是暴露在外的,容易被插入东西,导致故障。这就像攻击者试图渗透系统时的行为:他们从攻击面开始,利用巧妙设计的、出乎意料的输入来破坏机制,然后试图欺骗系统内部的代码,迫使它们按自己的意图行事。
不受信输入可能通过两种方式影响代码:直接和间接。从任何可以注入不受信输入的地方开始——例如字符串“BOO!”——他们进行尝试,希望数据能够避免被拒绝并进一步传播到系统中。通过 I/O 层和各种接口逐层向下,字符串“BOO!”通常会找到多条代码路径,并且它的影响会渗透到系统中。偶尔,不受信数据和代码的交互会触发一个错误,或者产生一个可能带来不良副作用的功能。一次“BOO!”的网页搜索可能涉及数据中心中的数百台计算机,每台都为搜索结果贡献一部分。因此,这个字符串必须在成千上万的地方写入内存。这是一个广泛的影响,如果存在即使微小的危害机会,也可能是危险的。
这种数据对代码影响的技术术语是污染,一些语言实现了跟踪污染的功能。Perl 解释器可以跟踪污染,旨在缓解注入攻击(在第十章中讨论)。早期版本的 JavaScript 曾出于类似原因进行污染检查,但由于缺乏使用,这一功能早已被移除。不过,理解数据来自不受信源对代码的影响是很重要的,以便防止漏洞。
输入数据还可以以间接的方式影响代码,而不需要存储数据。假设,给定输入字符串“BOO!”,代码避免存储其任何进一步的副本:这是否意味着它使系统免受其影响?显然并非如此。例如,考虑以下input = "BOO!":
if "!" in input:
PlanB()
else:
PlanA()
输入中感叹号的存在使得代码现在选择执行PlanB而非PlanA,即使输入字符串本身既未被存储也未传递到后续处理。
这个简单的例子说明了不受信输入如何深入传播到代码中,即便数据(此处为“BOO!”)本身并未广泛传播。在大型系统中,当你考虑从攻击面开始的传递闭包(所有路径的汇总扩展)时,可以更好地理解渗透到大量代码中的潜力。这种通过多层延伸的能力至关重要,因为它意味着攻击者可以进入比你预期更多的代码,从而控制代码的执行。我们将在第十章进一步讨论如何管理不受信输入。
漏洞即错误
如果调试是去除错误的过程,那么编程一定是将错误加入的过程。
—埃兹杰·戴克斯特拉
所有软件都有漏洞已经是如此广泛接受的事实,以至于现在几乎不需要再证明这一点。当然,这一普遍化也有例外:一些微不足道的代码、经过证明正确的代码以及用于航空、医疗或其他关键设备的高度工程化的软件。但对于其他所有软件,意识到漏洞的普遍性是接触安全编码的一个良好起点,因为其中一部分漏洞对攻击者有用。因此,错误是我们在这里关注的重点。
漏洞是软件错误的一个子集,攻击者可以利用这些漏洞造成危害。准确区分漏洞和其他错误几乎是不可能的,因此,最简单的方式可能是先识别出那些显然不是漏洞的错误——也就是说,完全无害的错误。我们来看一下在线购物网站中的一些错误实例。一个无害错误的典型例子可能是网页布局没有按照设计工作:页面有些凌乱,但所有重要内容都完全可见且功能正常。虽然从品牌形象或可用性的角度来看,这个问题可能需要修复,但显然这个错误并不会带来任何安全风险。然而,为了强调漏洞发现的困难性,类似的错误可能既破坏了布局,又可能带来危害,比如它遮掩了用户必须看到的关键信息,而这些信息对做出准确的安全决策至关重要。
在这个范围的危害端,这里有一个令人不寒而栗的漏洞值得深思:管理界面意外暴露并未加以保护,出现在互联网中。现在,任何访问该网站的人都可以点击按钮进入由管理员用来更改价格、查看机密业务和财务数据等的控制台。谁都能看出,这完全是授权失败,并且是一个明显的安全威胁。
当然,这些极端情况之间有一个连续体,中间有一个很大的模糊区域,需要根据错误可能造成的危害进行主观判断。正如我们将在下一节中看到的那样,多个错误的常常是不可预见的累积效应,使得判断它们的危害潜力变得特别具有挑战性。出于安全的考虑,自然地,我会建议你采取保守的态度,倾向于修复更多的错误,如果它们有可能是漏洞的话。
我参与过的每个项目都有一个追踪数据库,里面充满了大量的错误,但没有针对减少已知错误数量(这与实际错误数量不同)做出系统的努力。因此,可以说,通常我们都在一堆已知错误中编程,更不用提那些未知的错误。如果尚未主动执行,考虑处理已知错误并标记可能的漏洞以便修复。还需要提到的是,通常修复一个错误比调查并证明它无害要容易得多。第十三章提供了关于评估和排名安全漏洞的指导,帮助你优先处理漏洞。
漏洞链
漏洞链的背后理念是,看似无害的错误可能会结合在一起,产生一个严重的安全漏洞。这对攻击者而言就是错误的协同效应。想象一下你在散步时碰到一条想要穿越的溪流,溪流宽得无法跳过去,但你注意到有几块石头露出水面:通过从石头跳到石头,你可以轻松地穿越而不弄湿鞋子。这些石头代表的是轻微的错误,而不是漏洞本身,但它们一起形成了一条新的通道,穿过溪流,允许攻击者深入系统内部。这些“垫脚石”错误通过组合形成了一个可利用的漏洞。
这是一个简单的示例,说明在线购物 Web 应用中如何产生这样的漏洞链。在最近的一次代码更改后,应用的订单表单新增了一个字段,预填充了一个代码,表示哪个仓库将负责发货。以前,后台的业务逻辑是在客户下单后分配仓库。现在,由客户编辑的一个字段决定了处理订单的仓库。我们称之为 Bug #1。负责此更改的开发人员认为没有人会注意到这个新增字段,而且即便有人修改了系统默认提供的仓库分配,另一个仓库可能没有所需商品库存,所以系统会标记并修正: “没有害处,无所谓”。基于这一分析,但没有进行任何测试,团队决定将 Bug #1 安排在下一个发布周期进行处理。他们很高兴能够避免紧急处理和延期,将这个有问题的代码更改推入生产环境。
与此同时,Bug #2 正在错误数据库中以 Priority-3 的排名(意味着“某天修复”,也就是可能永远不会修复)萎靡不振,早已被遗忘。多年前,一位测试人员在发现如果使用错误的仓库指定下订单,系统会立即发放退款,因为该仓库无法履行订单;但随后另一个处理阶段将订单重新分配给正确的仓库,由该仓库履行并发货后,提交了 Bug #2。测试人员认为这是一个严重的问题——公司将免费赠送商品——并将其列为 Priority-1。在评审会议中,程序员坚持认为测试人员是在“作弊”,因为后台处理了仓库分配(在 Bug #1 引入之前)并确认了库存。换句话说,在发现时,Bug #2 纯粹是假设性的,在生产环境中根本不会发生。由于不同业务逻辑阶段的相互作用难以解开,团队决定不管它,并将 Bug 的优先级设为 Priority-3,很快就被遗忘了。
如果你跟随了这个“放任 Bug 不管”的故事,你可能已经能看出它以一个不太愉快的结局收场。随着 Bug #1 的引入,加上 Bug #2,现在已经存在了一个完全成熟的漏洞链,几乎可以肯定没人察觉到。现在,由于仓库指定字段可以被客户写入,触发 Bug #2 的错误仓库情况变得容易产生。只要有一个狡猾的,甚至是好奇的客户尝试编辑仓库字段;他们会高兴地发现能够免费获得商品并全额退款,下次可能会回来购买更多,或者与他人分享这个秘密。
让我们看看错误的 bug 评审过程在哪里出错了。Bug #2(较早发现的)是一个严重的脆弱性,他们本应该一开始就修复它。支持放任不管的推理依赖于仓库信任其他后台逻辑能够完美指引它,假设(当时是正确的)订单中的仓库指定字段与任何攻击面完全隔离。尽管如此,它显然是一个令人担忧的脆弱性,显然会带来不好的后果,而业务逻辑难以修复的事实暗示着重写可能是个好主意。
Bug #1,后来的引入,打开了一个新的攻击面,暴露了仓库指定字段的篡改风险。未修复这个问题的不幸决定源于错误的假设,认为篡改是无害的。事后看来,如果有人稍微进行一些测试(当然是在测试环境中进行,绝不能在生产环境中),他们本可以轻松发现他们推理中的漏洞,并在发布 Bug #1 之前做出正确的决定。理想情况下,如果发现 Bug #2 的测试人员,或任何熟悉该问题的人在场,他们可能会将两者联系起来,并将这两个 bug 列为 Priority-1 进行修复。
相比这个人为的例子,识别漏洞何时形成漏洞链通常是非常具有挑战性的。一旦你理解了这一概念,你就会明白,尽可能主动修复漏洞的智慧。更重要的是,即使你怀疑可能存在漏洞链,我还是要提醒你,在实际操作中,通常很难说服别人花时间修复看起来像是模糊假设的问题,特别是当修复这个漏洞需要大量工作时。很可能大多数大型系统中都充满了未被发现的漏洞链,而我们的系统因此变得更脆弱。
这个例子说明了两个漏洞如何形成因果链,就像一杆巧妙的台球击球,母球击中另一颗球,进而将目标球打入球袋。信不信由你,漏洞链可能比这更复杂:在 Pwn2Own 黑客竞赛中,有一支团队成功地将六个漏洞串联起来,达成了一个困难的攻击。
当你理解漏洞链时,你能更好地理解代码质量与安全性之间的关系。引入脆弱性的漏洞,尤其是在关键资产周围,应该积极修复。因为“它永远不会发生”(就像我们说的 Bug #2)而推迟修复是很危险的,你需要记住,一个人认为它没问题的观点,只是观点而已,并不是证据。这种思维类似于“安全即隐藏”的反模式,充其量只是一个临时措施,而不是一个好的最终筛选决策。
漏洞和熵
在了解漏洞和漏洞链之后,接下来考虑一下,软件也容易受到不那么精确的事件序列的影响,这些事件可能造成损害。一些漏洞倾向于以不可预测的方式破坏事物,这使得分析它们的可利用性(就像漏洞链一样)变得困难。作为这种现象的证据,我们常常重启手机和计算机,以清除随着时间积累的熵(这里我使用“熵”这个词有点宽泛,旨在唤起混乱和隐喻腐蚀的形象)。攻击者有时可以利用这些漏洞及其后效,因此采取对策可以帮助提升安全性。
由于执行线程之间意外的交互而引发的错误是一类容易出现这种问题的错误,因为它们通常以各种方式呈现,似乎是随机的。内存损坏错误是另一类此类错误,因为栈和堆的内容在不断变化。这些以不可预测的方式扰乱系统的错误,几乎是攻击的更有价值的目标,因为它们提供了潜在的无限可能性。攻击者可以非常熟练地利用这些混乱的错误,自动化使得他们可以轻松地反复尝试低效的攻击,直到运气好。另一方面,大多数程序员不喜欢处理这些难以捉摸的错误,因为它们难以定位,而且常常被认为是太不稳定以至于不值得关注,因此这些错误往往未能得到解决。
即使你无法确定一个明确的因果链,引发熵的错误仍然是危险的,值得修复。所有的错误都会向系统引入一定量的熵,因为它们是从正确行为中略微偏离的表现,而这些小的干扰迅速累积——特别是如果被狡猾的攻击者利用的话。类比热力学第二定律,熵不可避免地在封闭系统内积累,随着时间的推移,这类错误可能在某个时刻变得可以被利用,从而增加了伤害的风险。
警觉性
我喜欢徒步旅行,我所在地区的步道常常泥泞湿滑,露出的树根和岩石使得滑倒和摔跤成为一个常见的威胁。随着实践和经验的积累,滑倒的情况变得少见,但奇怪的是,在特别危险的地方,当我集中注意力时,我从未滑倒。尽管偶尔我还是会摔倒,但这通常不是因为有什么障碍,而是因为在步道的较容易部分,我没有注意到。这里的重点是,凭借警觉性,困难的挑战可以被克服;相反,漫不经心很容易让你失败,即使在轻松的时候也是如此。
软件开发人员面临的正是这样的挑战:如果没有意识到潜在的安全陷阱并保持持续的专注,很容易不知不觉地掉入其中。开发人员本能地编写代码以适应正常的使用情况,但攻击者往往会尝试出乎意料的情况,希望能找到一个可能导致漏洞的缺陷。正如前面提到的脆弱链和熵一样,保持警觉,预测所有可能的输入和事件组合,是编写安全代码的关键。
以下章节提供了一个广泛的代表性调查,展示了现代软件中常见的漏洞,并通过“玩具”代码示例来展示实现漏洞是什么样的。正如麻省理工学院人工智能传奇人物马文·明斯基(Marvin Minsky)指出的那样,“在科学中,人们通过研究最少的东西学到最多的东西。”在这个背景下,这意味着简化的代码示例通过让人们专注于关键缺陷,帮助解释问题。在实际应用中,漏洞往往是被融入大量代码的结构中,代码中还有很多其他对于任务来说很重要,但与安全隐患无关的内容,且这些漏洞并不容易被识别。如果你想查看真实的代码示例,可以浏览任何开源软件项目的错误数据库——这些项目肯定有安全漏洞。
警觉性最初需要一定的纪律,但随着实践的积累,知道该注意什么后,它会变成第二天性。记住,如果你的警觉性得到了回报,并且成功阻止了一个潜在的攻击者,你可能永远都不会知道——所以庆祝每一次小小的胜利,因为每次修复都在避免未来可能发生的攻击。
案例研究:GotoFail
一些漏洞是顽固的错误,它们不遵循任何模式,某种方式悄悄地绕过测试并被发布出去。漏洞的一个特点是它们通常在典型的使用情况下没有问题,只有在遭遇故意攻击时才会表现出有害行为,这使得漏洞比你想象的更容易发生。2014 年,苹果悄悄发布了一组关键的安全补丁,涵盖了大部分产品,但却没有解释问题的原因,称是“为了保护客户”。不久,世界就知道这个漏洞是由于一个明显的编辑失误,实际上破坏了一个关键的安全保护。通过检查实际代码的一个简短摘录,很容易理解发生了什么。我们来看看。
单行漏洞
为了设定场景,相关代码在安全连接建立过程中运行。它检查一切是否正常,以确保后续通信的安全。安全套接字层(SSL)协议的安全性依赖于检查服务器是否签署了经过认证的协商密钥,并且这个认证是根据服务器的数字证书进行的。更准确地说,服务器签署的是一个哈希值,这个哈希值是由临时密钥衍生出来的多个数据块组合而成。第十一章涵盖了 SSL 的基础知识,但即使你不了解这些细节,也可以理解这个漏洞背后的代码。这里是 C++代码:
漏洞代码
/*
* Copyright (c) 1999-2001,2005-2012 Apple Inc. All Rights Reserved.
*
* @APPLE_LICENSE_HEADER_START@
*
* This file contains Original Code and/or Modifications of Original Code
* as defined in and that are subject to the Apple Public Source License
* Version 2.0 (the ‘License’). You may not use this file except in
* compliance with the License. Please obtain a copy of the License at
* http://www.opensource.apple.com/apsl/ and read it before using this
* file.
*
* The Original Code and all software distributed under the License are
* distributed on an ‘AS IS’ basis, WITHOUT WARRANTY OF ANY KIND, EITHER
* EXPRESS OR IMPLIED, AND APPLE HEREBY DISCLAIMS ALL SUCH WARRANTIES,
* INCLUDING WITHOUT LIMITATION, ANY WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE, QUIET ENJOYMENT OR NON-INFRINGEMENT.
* Please see the License for the specific language governing rights and
* limitations under the License.
*
* @APPLE_LICENSE_HEADER_END@
*/
`--snip--`
if ((err = SSLHashSHA1.update(&hashCtx, &clientRandom)) != 0)
goto fail;
if ((err = SSLHashSHA1.update(&hashCtx, &serverRandom)) != 0)
goto fail;
goto fail;
if ((err = SSLHashSHA1.update(&hashCtx, &signedParams)) != 0)
goto fail;
`--snip--`
fail:
SSLFreeBuffer(&signedHashes);
SSLFreeBuffer(&hashCtx);
return err;
三次调用SSLHashSHA1.update将各自的数据块输入到哈希函数中,并检查非零返回值的错误情况。哈希计算的细节对于我们而言无关紧要,且没有展示;只需知道,这个计算对安全至关重要,因为其输出必须与预期值匹配,才能验证通信。
在函数的底部,代码释放了一些缓冲区,然后返回err的值:成功时为零,或者为非零的错误代码。
代码中的预期模式很清楚:不断检查返回值是否为非零,表示错误;如果一切正常,则通过零值继续执行,然后返回该值。你可能已经看到了错误——重复的goto fail行。尽管有提示性的缩进,这条语句无条件地将执行跳转到fail标签,跳过了剩余的哈希计算,并完全跳过了哈希检查。由于在额外跳转前最后一次对err的赋值是零值,因此该函数突然无条件地批准了所有内容。可以推测,这个 bug 没有被发现,因为有效的安全连接仍然能正常工作:代码没有检查哈希,但即使检查了,它们也会通过。
小心脚本陷阱
GotoFail 是构造代码时遵循缩进结构智慧的一个有力论据,就像 Python 等语言所做的那样。C 语言通过语法决定程序结构,提供了一种脚本陷阱(容易自伤的功能),这使得编写代码时按标准代码风格惯例缩进,可能会产生误导性的效果,因为它暗示了不同的语义,尽管编译器完全忽略了这些缩进。当你看到这段代码时:
if ((err = SSLHashSHA1.update(&hashCtx, &serverRandom)) != 0)
goto fail;
goto fail;
程序员可能很容易看到以下情况(除非他们很小心,并且在脑海中编译代码):
if ((err = SSLHashSHA1.update(&hashCtx, &serverRandom)) != 0) `{`
`goto fail;`
`goto fail;`
`}`
与此同时,编译器清楚地看到:
if ((err = SSLHashSHA1.update(&hashCtx, &serverRandom)) != 0) `{`
`goto fail;`
`}`
goto fail;
一个简单的编辑错误恰好容易被忽视,并且在关键的安全检查的核心部分极大地改变了代码。这就是一个严重漏洞的典型体现。
小心其他类似的脚本陷阱,出现在编程语言、API 以及其他编程工具和数据格式中。你将在接下来的章节中看到许多例子,但这里我想提到的另一个来自 C 语法的例子是写if (x = 8)而不是if (x == 8)。前者将8赋值给x,无条件执行 then-clause,因为该值非零;后者将x与8进行比较,仅当其为真时才执行 then-clause——这两者确实有很大的不同。虽然有些人可能从风格上反对,但我喜欢把这种 C 语句写成if (8 == x),因为如果我忘记写两个等号,它将会是一个语法错误,编译器会捕捉到。
编译器警告有助于标记这种疏忽。GCC 编译器的-Wmisleading-indentation警告选项专门用于解决像 GotoFail 漏洞所导致的此类问题。有些警告以更微妙的方式指示潜在问题。未使用的变量警告似乎无害,但假设有两个变量名字相似,你不小心在重要的访问测试中输入了错误的变量,结果触发了警告,并且使用了错误的数据进行关键测试。虽然警告并不是所有漏洞的可靠指示器,但它们容易检查,可能会在关键时刻救场。
从 GotoFail 中的教训
我们可以从 GotoFail 中学到几个重要的教训:
-
关键代码中的小失误可能对安全性造成毁灭性影响。
-
脆弱的代码在预期情况下仍然能正常工作。
-
从安全的角度看,测试此类代码是否能够拒绝无效情况,可能比测试它通过正常合法使用更为重要。
-
代码审查是防止由于疏忽引入的漏洞的重要检查环节。很难想象一个认真审查代码差异的审查员会错过这个问题。
这个漏洞提出了一些本可以防止其发生的对策。这些对策中有些特定于此特定漏洞,但即便如此,这些对策也应该能够提示你在其他地方应用相似的预防措施,以避免编写出有缺陷的代码。有效的对策包括:
-
当然,更好的测试。至少,应该为每个
if编写测试用例,以确保所有必要的检查都能正常工作。 -
注意不可达代码(许多编译器有选项来标记此类代码)。在 GotoFail 的情况下,这可能已经让程序员意识到漏洞的引入。
-
使代码尽可能明确,例如,使用圆括号和大括号,即使在可以省略的地方也要广泛使用。
-
使用源代码分析工具,如“代码检查工具”(linters),可以提高代码质量,在这个过程中,可能会标记出一些潜在漏洞,提前进行修复。
-
考虑使用临时的源代码过滤器来检测可疑的模式,例如在这种情况下,检测重复的源代码行,或任何其他重复的错误。
-
测量并要求对安全关键代码进行全面的测试覆盖。
这些只是你可以用来发现可能危害安全的漏洞的一些基本技巧。当你遇到新的漏洞类型时,考虑如何利用工具系统性地避免未来的重复发生——这样做应该能在长期减少漏洞。
编码漏洞
所有幸福的家庭都是相似的;每个不幸的家庭都有自己不幸的方式。
——列夫·托尔斯泰
遗憾的是,列夫·托尔斯泰的小说《安娜·卡列尼娜》中的著名开头句子在软件领域也同样适用:新的漏洞种类是无穷无尽的,试图列出所有潜在的软件漏洞将是愚蠢的。分类是有用的,我们将讨论许多分类,但不要把它们与涵盖所有可能性的完整分类体系混淆。
本书并未呈现所有潜在缺陷的详尽列表,但它确实覆盖了许多最常见类别的代表性内容。这一基本调查应该能为你提供一个良好的起点,随着经验的积累,你将开始直觉性地识别其他问题,并学会如何安全地避免它们。
原子性
我听到的许多最糟糕的编码“战斗故事”都涉及多线程或分布式进程由于意外事件的序列而在奇怪的方式中偶尔互动。漏洞往往源于这些相同的条件,而唯一的救命稻草是,所需的敏感时序可能使得攻击手段对实施者来说过于不可靠——尽管你不应该指望这轻易能劝阻他们继续尝试。
即使你的代码是单线程且表现良好,它几乎总是在有许多其他活动进程的机器上运行,因此,当你与文件系统或任何公共资源交互时,你可能正在处理涉及你一无所知的代码的竞态条件。软件中的原子性描述的是保证能够有效地作为单个步骤完成的操作。这在这种情况下是一个重要的防御武器,用于防止可能导致漏洞的意外情况。
为了说明可能发生的情况,考虑一个简单的例子:将敏感数据复制到临时文件。已弃用的 Python tempfile.mktemp 函数返回一个保证不存在的临时文件名,旨在供应用程序使用,作为它们创建并随后使用的文件名。不要使用它:改用新的 tempfile.NamedTemporaryFile。原因如下。在 tempfile.mktemp 返回临时文件路径与代码实际打开文件之间的时间间隙中,另一个进程可能有机会干扰。如果另一个进程能猜到下一个生成的文件名,它就能先创建文件,并且(在多种可能性中)将恶意数据注入临时文件。新函数提供的解决方案是使用原子操作来创建并打开临时文件,避免任何干预过程。
时间攻击
时序攻击是一种侧信道攻击,它通过操作所需的时间推测信息,间接地了解系统中本应保密的某些状态。时序的差异有时可以提供线索——即,它们泄露了一些受保护的信息——对攻击者有利。举一个简单的例子,假设任务是猜测一个介于 1 到 100 之间的秘密数字;如果已知回答“否”的时间与猜测的距离成正比,这种特性可以帮助猜测者更快速地锁定正确答案。
Meltdown 和 Spectre 是对现代处理器的时序攻击,发生在软件层面以下,但其原理是直接适用的。这些攻击利用了猜测执行的特点,在这种执行方式下,处理器会提前计算结果,同时暂时放松各种检查以提高速度。当这一过程涉及到通常不允许的操作时,处理器最终会检测到并取消这些结果,防止其最终生效。这种复杂的猜测执行完全依赖于处理器的设计,且对于实现我们享受的惊人速度至关重要。然而,在猜测执行期间,当计算访问内存时,副作用是导致内存被缓存。当猜测执行被取消时,缓存并不受影响,这种副作用就成为了一个潜在的线索,攻击者利用它来推断在猜测执行期间发生了什么。具体而言,攻击代码可以通过检查缓存的状态来推测在被取消的猜测执行期间发生了什么。内存缓存加速了执行,但不直接暴露给软件;然而,代码可以通过测量内存访问时间来判断内存位置内容是否在缓存中,因为缓存的内存速度远快于常规内存。这是对复杂处理器架构的复杂攻击,但就我们的目的而言,关键点是当时序与受保护信息状态相关联时,它可以被用作泄露的漏洞。
举一个更简单的、纯粹基于软件的时间攻击示例,假设你想确定你的朋友(或者“死对头”?)是否在某个特定的在线服务上有账户,但你不知道他们的账户名。“忘记密码”选项会要求用户提供账户名和电话号码,以便发送“提醒”。然而,假设实现首先在数据库中查找电话号码,如果找到了,就会继续查找关联的账户名,看看是否与输入的匹配。假设每次查找需要几秒钟,因此时间延迟对用户是可察觉的。首先,你尝试几个随机的账户名(比如随便按键盘)和电话号码,这些很可能不会与实际用户匹配,并且你会发现,通常需要三秒钟才能得到“没有此账户”的回应。接下来,你用自己的电话号码注册账户,并尝试使用自己的号码与一个随机未使用的账户名进行“忘记密码”操作。现在你观察到,在这种情况下,响应时间为五秒钟,几乎是原来的两倍。
有了这些事实,你可以尝试使用一个未使用的账户名来测试你朋友的电话号码:如果回复需要五秒钟,那么你就知道他们的电话号码在数据库中;如果只需要三秒钟,那么就说明电话号码不在数据库中。仅通过观察时间,你就可以推断给定的电话号码是否在数据库中。如果会员信息可能会泄露敏感的私人信息,例如在某些患者论坛中,这种时间攻击可能会导致有害的信息泄露。
由于软件的原因,时间差异是自然发生的,尤其是当有一系列缓慢的操作(想想if...if...if...if...)时,知道执行进度到达序列的哪个位置可以推断出有价值的信息。确切的时间差异大小取决于许多因素。在在线账户检查的示例中,由于网络访问的正常延迟,几秒钟的时间差足以表示一个清晰的信号。相比之下,当利用 Meltdown 或 Spectre 漏洞通过同一台机器上的代码执行时,亚毫秒级的时间差异可能是可测量的,并且同样具有重要意义。
最好的缓解选项是将时间差异缩小到一个可以接受的——即,无法察觉的——水平。为了防止电话号码出现在数据库中而泄露信息,只需将代码更改为使用单一数据库查找来处理两种情况即可。当存在固有的时间差异,并且这种时间侧信道可能导致严重的泄露时,最好的缓解措施就是引入人工延迟来模糊时间信号。
序列化
序列化是指将数据对象转换为字节流的常见技术,类似于 星际迷航 中的传送器,然后通过时间和空间“传送”它们。存储或传输生成的字节流可以让你通过 反序列化 重新构造相应的数据对象。这种“脱水”对象再“复水”的能力对面向对象编程非常有用,但如果在此过程中有任何篡改的可能性,这项技术本质上就是一个安全隐患。攻击者不仅可以使关键数据值发生变化,而且通过构造无效的字节序列,他们甚至可以使反序列化代码执行有害操作。由于反序列化只有在使用受信任的序列化数据时才是安全的,这就是不受信任输入问题的一个例子。
问题不在于这些库构建得不好,而在于它们需要信任才能执行构建任意对象所需的操作,从而完成它们的工作。反序列化实际上是一种解释器,它会执行其输入的序列化字节所指示的任何操作,因此,使用不受信任的数据来进行反序列化永远不是一个好主意。例如,Python 的反序列化操作(称为“反 pickle”)很容易通过在要反序列化的数据中嵌入恶意字节序列来诱使它执行任意代码。除非序列化的字节数据能够安全存储和传输,并且没有被篡改的可能性,比如使用 MAC 或数字签名(如第五章所讨论的),否则最好完全避免使用。
常见嫌疑人
魔鬼曾经用过的最伟大的伎俩就是让全世界相信他不存在。
— 查尔斯·波德莱尔
接下来的几章将讨论许多在代码中经常出现的“常见嫌疑人”漏洞。在本章中,我们考虑了 GotoFail 和原子性问题、时序攻击以及序列化问题。以下是我们接下来要探讨的主题预览:
-
固定宽度整数漏洞
-
浮点精度漏洞
-
缓冲区溢出和其他内存管理问题
-
输入验证
-
字符串处理不当
-
注入攻击
-
Web 安全
许多这些问题看起来显而易见,但它们依然是软件漏洞的根本原因,且大多数问题没有得到有效遏制,仍在不断出现。我们需要从过去的失败中吸取教训,因为这些漏洞类别已经存在了几十年。然而,将目光只放在过去,认为所有可能的安全漏洞都已经被详尽列举,是一种错误的思维方式。没有一本书可以预警所有可能的陷阱,但你可以通过研究这些例子,了解它们背后更深层的模式和教训。
底层编码缺陷
底层编程有益于程序员的心灵。
— 约翰·卡马克

接下来的几章将概述程序员需要了解的多种编码陷阱,尤其是出于安全原因的注意事项,从经典问题开始。本章涵盖了与接近机器层的代码相关的基本缺陷。这些问题出现在数据超出固定大小数字或分配内存缓冲区的容量时。现代语言往往提供更高层次的抽象,使代码免受这些危险,但在这些更安全的语言中工作的程序员仍然可以通过理解这些缺陷获益,即便只是为了充分理解为他们所做的一切,以及这些问题为何重要。
诸如 C 和 C++这类暴露底层功能的语言,在许多软件领域中仍然占据主导地位,因此它们所带来的潜在威胁绝非理论上的问题。现代语言如 Python 通常会将硬件抽象得足够高,以至于本章中描述的问题不会出现,但追求最大效率、接近硬件层的诱惑依然强大。几种流行语言为程序员提供了两者兼得的选择。除了类型安全的对象库外,Java 和 C#的基础类型包括固定宽度的整数,并且它们有“非安全”模式,可以去除通常提供的许多保护措施。Python 的float类型,如在第 149 页的《浮点精度漏洞》一节中所解释的,依赖于硬件支持,并带来其局限性,必须加以应对。
从不使用暴露底层功能的语言的读者,可能会倾向于跳过本章,而且这样做不会影响全书的整体叙述。然而,我仍然建议阅读本章,因为最好了解你所使用的语言和库提供了哪些保护措施,或者没有提供哪些保护措施,并充分理解为你所做的一切。
如果做得好,靠近硬件层编程是非常强大的,但也付出了增加的工作量和脆弱性的代价。在本章中,我们将重点讨论与低级抽象编程相关的最常见的漏洞类型。
由于本章讨论的是与代码接近或处于硬件层时出现的漏洞,你必须理解,许多操作的确切结果在不同的平台和语言之间会有所不同。我已将示例设计得尽可能具体,但实现上的差异可能会导致结果不同——正是因为计算结果可能不可预测地变化,这些问题才容易被忽视,并可能对安全性产生影响。具体细节会因硬件、编译器和其他因素而异,但本章介绍的概念是普遍适用的。
算术漏洞
不同的编程语言以不同的方式定义它们的算术运算符,或者是按数学方式,或者是按照处理器对应的指令,正如我们稍后将看到的那样,这两者并不完全相同。所谓的低级,指的是依赖于机器指令的编程语言特性,这需要处理硬件的怪癖和限制。
代码中充满了整数运算。它不仅用于计算数值,还用于字符串比较、数据结构的索引访问等。由于硬件指令比处理更大范围数值的软件抽象要快速且易于使用,因此很难抗拒它们,但这种便利和速度也带来了溢出的风险。当计算结果超出固定宽度整数的容量时,就会发生溢出,导致意外结果,从而可能产生漏洞。
浮点运算的范围比整数运算更广,但其有限的精度也可能导致意外的结果。即使是浮点数也有其限制(对于单精度,大约为 10³⁸),但是当超出这个限制时,它们具有一个很好的特性,即会得到一个特定的值,表示无限大。
对于那些有兴趣深入了解算术指令在硬件级别实现的读者,可以通过 Jonathan E. Steinhart 的《程序的秘密生活》(No Starch Press,2019)进一步学习。
固定宽度整数漏洞
在我第一份全职工作中,我在小型计算机上用汇编语言编写设备驱动程序。虽然按照现代标准看,它们的性能可笑地低,但小型计算机提供了一个学习硬件如何工作的好机会,因为你可以查看电路板,看到每个连接和每个芯片(每个芯片内部有有限数量的逻辑门)。我可以看到连接到算术逻辑单元(只能执行加法、减法和布尔运算)和内存的寄存器,因此我完全明白计算机是如何工作的。相反,现代处理器极为复杂,包含数十亿个逻辑门,远远超出了人类通过随意观察能够理解的范围。
如今,大多数程序员学习并使用高级语言,这些语言将他们与机器语言和 CPU 架构的复杂性隔离开。固定宽度整数是许多语言中最基本的构建块,包括 Java 和 C/C++,如果任何计算超过了它们的有限范围,你将得到错误的结果,默默地。
现代处理器通常具有 32 位或 64 位架构,但我们可以通过讨论更小的尺寸来理解它们是如何工作的。让我们通过一个基于无符号 16 位整数的溢出示例来看看。一个 16 位整数可以表示从 0 到 65,535(2¹⁶ – 1)之间的任何值。例如,300 乘以 300 应该得到 90,000,但这个数字超出了我们使用的定宽整数的范围。因此,由于溢出,我们实际上得到的结果是 24,464(比预期结果少 65,536)。
有些人将溢出从数学上看作是模运算,即除法的余数(例如,之前的计算给出了将 90,000 除以 65,536 的余数)。其他人则从二进制或十六进制截断的角度,或者从硬件实现的角度来考虑它——但如果这些都不适合你,只需记住,超大值的结果将不是你预期的。由于溢出的缓解措施通常会试图在第一次发生之前避免它,精确的结果值通常并不重要。
这里重要的是预见到二进制算术的怪癖,而不是准确知道计算结果是什么——这取决于语言和编译器,可能并不明确定义(也就是说,语言规范不保证任何特定的值)。在语言中,技术上被指定为“未定义”的操作可能看起来可预测,但如果语言规范没有提供保证,你就处于危险之中。安全的底线是,了解语言规范并避免潜在未定义的计算非常重要。不要心血来潮,试图找到一种巧妙的方法来检测未定义的结果,因为在不同的硬件或新的编译器版本下,你的代码可能会停止工作。
如果你计算错误,代码可能会以多种方式崩溃,效果往往会像滚雪球一样累积成一系列功能失常,最终导致崩溃或蓝屏。由于整数溢出导致的常见漏洞包括缓冲区溢出(在第 157 页的“缓冲区溢出”中讨论)、值的错误比较、在销售时给出信用而不是收费等情况。
最好在进行任何可能超出范围的计算之前就缓解这些问题,确保所有数字仍然在范围内。正确的简单方法是使用比最大允许值更大的整数大小,并在前面进行检查,确保无效值永远不会潜入。例如,要计算 300 × 300,如前所述,使用 32 位算术,它能够处理任何 16 位值的乘积。如果你必须将结果转换回 16 位,请用 32 位比较来保护它,以确保它在范围内。
这是将两个 16 位无符号整数相乘得到一个 32 位结果的 C 语言代码。为了清晰起见,我喜欢在类型转换周围加上一对额外的括号,尽管操作符优先级会先绑定类型转换,再进行乘法运算(稍后在本章中,我会提供一个更全面的例子,来更实际地展示这些漏洞是如何潜入的):
uint32_t simple16(uint16_t a, uint16_t b) {
return ((uint32_t)a) * ((uint32_t)b);
}
定宽整数容易发生静默溢出这一事实并不难理解,但在实际中,这些缺陷仍然困扰着即使是经验丰富的程序员。问题的一部分在于整数运算在编程中的普遍性——包括其隐式用法,如指针运算和数组索引,这些地方也必须应用相同的缓解措施。另一个挑战是必须始终严谨地记住,不仅要了解每个变量的合理值范围,还要了解代码可能遇到的值范围,考虑到狡猾攻击者的操作。
编程时,很多时候我们感觉自己只是在操作数字,但我们必须时刻牢记这些计算的脆弱性。
浮动点精度漏洞
浮动点数在很多方面比定宽整数更稳定、更少异常。对于我们的目的,你可以把浮动点数看作是一个符号位(用于表示正负数)、一个固定精度的分数,以及一个二的指数,分数会乘以这个指数。流行的 IEEE 754 双精度标准提供了 15 位十进制数字(53 位二进制数字)的精度,如果超出其极大范围,几个操作后会得到一个带符号的无穷大——或 NaN(非数字)——而不是像定宽整数那样被截断为不合理的值。
由于 15 位的精度足以用分币计算美国联邦预算(目前数万亿美元),因此精度丧失的风险很少成为问题。然而,它确实会在低位数字中悄无声息地发生,这可能令人惊讶,因为浮动点数的表示是二进制而非十进制。例如,由于十进制分数在二进制中不一定有准确的表示,0.1 + 0.2 会得到 0.30000000000000004——一个 不 等于 0.3 的值。这种混乱的结果可能发生,因为就像 1/7 在十进制中是一个无限循环小数一样,1/10 在二进制中也是无限循环(它是 0.00011001100...,1100 永远重复),因此最低位会有误差。由于这些误差是在低位数字中引入的,因此这种现象被称为 下溢。
即使下溢差异在比例上非常小,它们在值的大小不同的情况下仍然会产生不直观的结果。考虑以下用 JavaScript 编写的代码,在这个语言中,所有的数字都是浮动点数:
脆弱的代码
var a = 10000000000000000
var b = 2
var c = 1
console.log(((a+b)-c)-a)
数学上,最后一行表达式的结果应等于b-c,因为值a首先被加上,然后被减去。(console.log函数是输出表达式值的便捷方法。)但实际上,a的值足够大,以至于加减更小的值对结果没有影响,考虑到有限的精度,当值a最终被减去时,结果是零。
当像这个例子中的计算是近似值时,误差是无害的,但当你需要完全精确,或者当计算中涉及不同数量级的值时,好的程序员需要谨慎。如果这种误差可能影响代码中安全关键决策,漏洞就会出现。对于像校验和或复式记账这样的计算,溢出错误可能是一个问题,因为精确结果至关重要。
对于许多浮点计算,即使没有像我们刚才展示的那种显著下溢,当值没有精确表示时,小的误差也会在低位逐渐积累。通常不建议直接比较浮点值是否相等(或不相等),因为这个操作不能容忍即使是微小的计算差异。因此,应该改为在一个小范围内比较值(x > y - delta && x < y + delta),其中delta是适合该应用程序的值。Python 提供了math.isclose辅助函数,执行一个稍微更复杂的测试版本。
当你需要高精度时,可以考虑使用超高精度浮点数表示(IEEE 754 定义了 128 位和 256 位格式)。根据计算的要求,任意精度的小数或有理数表示可能是最佳选择。许多库为不包含原生支持的语言提供了此功能。
示例:浮点下溢
浮点下溢很容易被低估,但精度丧失有可能带来灾难性后果。下面是一个简单的 Python 例子,展示了一个在线订购系统的业务逻辑,其中使用了浮点数值。以下代码的任务是检查采购订单是否已完全支付,如果是,则批准发货:
脆弱代码
from collections import namedtuple
PurchaseOrder = namedtuple('PurchaseOrder', 'id, date, items')
LineItem = namedtuple('LineItem', 'kind, detail, amount, quantity',
defaults=(1,))
def validorder(po):
"""Returns an error text if the purchase order (po) is invalid,
or list of products to ship if valid [(quantity, SKU), ...].
"""
products = []
net = 0
for item in po.items:
if item.kind == 'payment':
net += item.amount
elif item.kind == 'product':
products.append(item)
net -= item.amount * item.quantity
else:
return "Invalid LineItem type: %s" % item.kind
if net != 0:
return "Payment imbalance: $%0.2f." % net
return products
采购订单由产品或付款明细组成。付款总额减去订购的产品总成本应为零。付款已经提前验证,我需要明确说明一个细节:如果客户立即取消全部费用,则信用和借记将作为明细项出现,而无需查询信用卡处理器,这会产生费用。我们还假设所列项目的价格是正确的。
聚焦于浮动小数点运算,看看在付款项中,金额是如何加到 net 上的,而在产品项中,金额乘以数量是如何被减去的(这些调用被写作 Python doctest,其中 >>> 行是要运行的代码,后跟预期的返回值):
>>> tv = LineItem(kind='product', detail='BigTV', amount=10000.00)
>>> paid = LineItem(kind='payment', detail='CC#12345', amount=10000.00)
>>> goodPO = PurchaseOrder(id='777', date='6/16/2022', items=[tv, paid])
>>> validorder(goodPO)
[LineItem(kind='product', detail='BigTV', amount=10000.0, quantity=1)]
>>> unpaidPO = PurchaseOrder(id='888', date='6/16/2022', items=[tv])
>>> validorder(unpaidPO)
'Payment imbalance: $-10000.00.'
代码按预期工作,批准了第一个完全支付电视的交易,并拒绝了未注明付款的订单。
现在是时候破坏这段代码并“偷”一些电视了。如果你已经看到了漏洞,这是一个很好的练习,尝试自己欺骗这个函数。以下是我如何免费获得 1,000 台电视的代码,代码后会有解释:
>>> fake1 = LineItem(kind='payment', detail='FAKE', amount=1e30)
>>> fake2 = LineItem(kind='payment', detail='FAKE', amount=-1e30)
>>> tv = LineItem(kind='product', detail='BigTV', amount=10000.00, \
quantity = 1000)
>>> nonpayment = [fake1, tv, fake2]
>>> fraudPO = PurchaseOrder(id='999', date='6/16/2022', items=nonpayment)
>>> validorder(fraudPO)
[LineItem(kind='product', detail='BigTV', amount=10000.0, quantity=1000)]
这里的技巧在于伪造一个巨额金额 1e30,即 10³⁰,然后立即撤销该费用。这些虚假的数字通过了会计检查,因为它们的和为零(10³⁰ – 10³⁰)。注意,在取消借方和贷方之间,有一项订单是 1,000 台电视。由于第一个数字如此庞大,当从中减去电视的成本时,它完全下溢;然后,当添加负数的信用时,结果为零。如果信用立即跟随支付,并紧接着是电视的订单项,那么结果会不同,并且错误会被正确地标记出来。
为了让你更准确地理解下溢——更重要的是,展示如何衡量安全值范围来确保代码安全——我们可以深入探讨一下。选择 10³⁰ 进行这次攻击是任意的,这个技巧也适用于大约 10²⁴ 这样的数字,但不适用于 10²³。1,000 台电视的成本是每台 $10,000,总共是 $10,000,000,或 10⁷。所以当伪造费用为 10²³ 时,数值 10⁷ 开始稍微改变计算结果,约对应 16 位精度(23 – 7)。之前提到的 15 位精度是一个安全的经验法则(由于二进制精度与 15.95 十进制位对应),这很有用,因为我们大多数人天生以 10 为基数思考,但由于浮动小数点表示实际上是二进制的,它可能会有几位不同。
根据这个推理,我们来修复这个漏洞。如果我们想使用浮动小数点,那么我们需要限制数字的范围。假设最小产品成本为 $0.01 (10^(–2)) 且有 15 位精度,我们可以设定最大支付金额为 $10¹³ (15 – 2),即 $10 万亿。这个上限可以避免下溢,尽管实际上,设定一个与实际最大订单金额相对应的较小限制会更好。
使用任意精度数字类型可以避免下溢:在 Python 中,这可以是原生整数类型,或者是 fractions.Fraction。高精度的浮点计算可以防止这种特定的攻击,但在遇到更极端的值时,仍然可能会出现下溢问题。由于 Python 是动态类型语言,当代码使用这些类型的值时,攻击会失败。但即使我们使用了这些任意精度类型并认为是安全的,如果攻击者设法通过某种方式偷偷注入了一个浮点数,漏洞依然会重新出现。这就是为什么进行范围检查,或者在调用方不能信任提供预期类型的情况下,在计算前将输入值转换为安全类型是很重要的原因。
示例:整数溢出
固定宽度整数溢出漏洞在事后往往是显而易见的,这类错误已被广泛认识多年。然而,有经验的程序员反复陷入这个陷阱,无论是因为他们不相信溢出会发生,还是因为他们误判其无害,或根本没有考虑到它。以下示例展示了一个较大计算中的漏洞,帮助你理解这些错误是如何轻易潜入的。实际上,易受攻击的计算往往更为复杂,变量的值也更难预测,但为了说明问题,简单的代码让我们更容易理解发生了什么。
考虑这个简单的工资计算公式:工作小时数乘以工资率得到总工资。这一简单计算将在分数小时和分数美元中进行,从而提供完整的精度。另一方面,使用四舍五入时,细节会变得有些复杂,正如将要看到的,整数溢出很容易发生。
使用 32 位整数进行精确计算,我们以分($0.01 单位)计算美元值,以千分之一小时(0.001 小时单位)计算工作时间,因此数字会变得很大。但由于最高的 32 位整数值 UINT32_MAX 超过 40 亿(2³² – 1),我们假设以下逻辑可以保证安全:公司政策将每周工作时间限制为 100 小时(100,000 千分之一小时),假设每小时工资上限为 400 美元(40,000 分),那么最大薪水为 4,000,000,000(40,000 美元是一周的不错工资)。
下面是用 C 语言计算工资的代码,所有变量和常量都定义为 uint32_t 类型:
if (millihours > max_millihours // 100 hours max
|| hourlycents > max_hourlycents) // $400/hour rate max
return 0;
return (millihours * hourlycents + 500) / 1000; // Round to $.01
if 语句,返回超出范围参数的错误指示,是防止后续计算中溢出的必要保护。
return 语句中的计算值得解释。由于我们将小时表示为千分之一,我们必须将结果除以 1,000 才能得到实际的工资,因此我们首先加上 500(除数的一半)以便进行四舍五入。一个简单的例子可以验证这一点:10 小时(10,000)乘以$10.00/小时(1,000)等于 10,000,000;加上 500 进行四舍五入,得到 10,000,500;然后除以 1,000,得到 10,000 或$100.00,这是正确的值。即便在此时,你也应该认为这段代码是脆弱的,因为它可能会由于固定宽度整数限制而面临截断的风险。
到目前为止,这段代码在所有输入下都能正常工作,但假设管理层宣布了一项新的加班政策。我们需要修改代码,将所有加班小时的工资(超过 40 小时后的工作时间)增加 50%。此外,百分比应为一个参数,以便管理层以后可以轻松更改它。
为了增加加班小时的额外工资,我们引入了overtime_percentage。这段代码没有显示,但它的值是 150,意味着加班小时按正常工资的 150%支付。由于工资会增加,$400/小时的限制将不再适用,因为它不够低,无法防止整数溢出。但这个工资限制本来就不太现实,所以我们将它减半,以确保安全,并设定$200/小时为最高工资率:
脆弱的代码
if (millihours > max_millihours // 100 hours max
|| hourlycents > max_hourlycents) // $200/hour rate max
return 0;
if (millihours > overtime_millihours) {
overage_millihours = millihours - overtime_millihours;
overtimepay = (overage_millihours * hourlycents * overtime_percentage
+ 50000) / 100000;
basepay = (overtime_millihours * hourlycents + 500) / 1000;
return basepay + overtimepay;
}
else
return (millihours * hourlycents + 500) / 1000;
现在,我们检查工作小时数是否超过了加班支付的临界点(40 小时),如果没有,应用相同的计算。对于加班情况,我们首先计算overtime_millihours,即超过 40.000 小时的工作时间(以千分之一小时为单位)。对于这些小时,我们将计算出的工资乘以overtime_percentage(150)。由于我们有一个百分比(两位小数)和千分之一小时(小数点后三位),因此在加上它的一半用于四舍五入后,我们必须除以 100,000(五个零)。在计算了前 40 小时的基本工资后,没有加班调整,代码将两者相加,得出总工资。为了提高效率,我们可以将这些相似的计算合并,但这里的目的是使代码在结构上与计算匹配,以便清晰明了。
这段代码大部分时间都能正常工作,但并非每次都如此。一个奇怪的结果是,60.000 小时工作,时薪$50.00,计算出的工资为$2,211.51(应该是$3,500.00)。问题出在与overtime_percentage(150)的乘法上,随着加班小时数的增加和较高的时薪,它很容易溢出。在整数运算中,我们不能将 150/100 作为一个分数预先计算——作为整数它的值是 1——所以我们必须先进行乘法运算。
为了解决这段代码的问题,我们可以将 (X*150)/100 替换为 (X*3)/2,但是这样会破坏加班百分比的参数化,并且如果费率变为一个不太容易处理的值,这种方法就无法使用。一个能够保持参数化的解决方案是将计算过程拆分,使得乘法和除法使用 64 位算术运算,并将结果转换为 32 位:
固定代码
if (millihours > max_millihours // 100 hours max
|| hourlycents > max_hourlycents) // $200/hour rate max
return 0;
if (millihours > overtime_millihours) {
overage_millihours = millihours - overtime_millihours;
product64 = overage_millihours * hourlycents;
adjusted64 = (product64 * overtime_percentage + 50000) / 100000;
overtimepay = ((uint32_t)adjusted64 + 500) / 1000;
return basepay + overtimepay;
}
else
return (millihours * hourlycents + 500) / 1000;
为了说明问题,64 位变量的名称中包含了这一标识。我们也可以通过大量显式转换来编写这些表达式,但这样会显得冗长且不易阅读。
三个值的乘法被拆分成先将其中两个值乘到一个 64 位变量中,防止溢出发生;一旦进行了上溢,和百分比的乘法就是 64 位的,并且会正确工作。虽然结果代码的确更为复杂,并且需要注释来解释推理过程,但最干净的解决方案是将所有变量升级为 64 位,牺牲一点效率。这就是使用固定宽度整数进行计算时所涉及的权衡。
安全算术
整数溢出比浮点下溢更常见问题,因为它可能会导致结果的剧烈变化,但我们同样不能安全地忽视浮点下溢问题。由于编译器在设计时进行的算术运算可能与数学正确性有所偏差,开发者必须负责处理其后果。一旦意识到这些问题,你可以采取几种缓解策略来帮助避免漏洞。
避免使用复杂的代码来处理潜在的溢出问题,因为任何错误都难以通过测试找到,并且可能代表潜在的可利用漏洞。此外,一种技巧可能在你的机器上有效,但无法移植到其他 CPU 架构或不同的编译器。以下是如何安全地进行这些计算的总结:
-
在使用可能截断或扭曲结果的类型转换时要小心,就像计算本身可能会出现的问题一样。
-
尽可能限制输入值,以确保所有可能的值都能被表示。
-
使用更大的固定大小整数来避免可能的溢出;在将结果转换回更小的整数之前,检查其是否在范围内。
-
记住,尽管最终结果始终在范围内,但中间计算的值可能会发生溢出,从而导致问题。
-
在检查与安全相关的代码中的算术正确性时要格外小心。
如果固定宽度整数和浮点计算的细节仍然显得晦涩难懂,请密切关注它们,并且准备好遇到看似简单的计算中可能出现的惊讶。一旦你知道它们可能很棘手,使用你选择的编程语言进行一些临时测试代码是一个很好的方式,可以帮助你了解计算机数学基本构件的极限。
一旦你确定了存在这些漏洞风险的代码,就可以创建测试用例,通过所有输入的极限值进行计算,并检查结果。精心设计的测试用例可以检测溢出问题,但有限的测试集并不能证明代码完全不受溢出问题的影响。
幸运的是,更现代的编程语言,如 Python,越来越多地使用任意精度整数,通常不会受到这些问题的困扰。正确进行算术计算的前提是精确理解你所使用的编程语言的工作原理。你可以通过记住网址floating-point-gui.de找到一个很好的参考资料,里面提供了几种流行语言的详细信息,包括深入的解释和最佳实践编码示例。
内存访问漏洞
另一类我们将讨论的漏洞是与不当内存访问有关的漏洞。直接管理内存既强大又潜在地高效,但如果代码出错,可能会带来任意的严重后果。
大多数编程语言提供完全托管的内存分配,并限制对适当边界的访问,但出于效率、灵活性,或有时是由于传统的惯性,其他语言(主要是 C 和 C++)将内存管理的工作交给程序员来负责。当程序员承担这项工作时——即使是经验丰富的程序员——也很容易出错,尤其是在代码变得复杂时,这可能会导致严重的漏洞。与之前提到的算术错误类似,最大的危险是当内存管理协议被违反且未被发现时,这种问题会悄无声息地持续发生。
本节重点讨论直接管理和访问内存且没有内置保护机制的代码的安全性问题。代码示例将使用原始 C 标准库中的经典动态内存函数,但这些教训通常适用于提供类似功能的许多变体。
内存管理
指针允许通过地址直接访问内存,它们可能是 C 语言中最强大的特性。但就像使用任何强力工具一样,管理附带风险时,采取负责任的安全预防措施非常重要。软件在需要时分配内存,在可用范围内工作,当不再需要时释放内存。任何超出这种空间和时间约定的访问都会导致意外后果,这就是漏洞产生的地方。
C 标准库提供了动态内存分配,用于处理大型数据结构或当数据结构的大小在编译时无法确定时。这块内存从堆中分配——堆是进程中用于提供工作内存的一大块地址空间。C 程序使用malloc(3)来分配内存,当不再需要时,通过调用free(3)释放每一块内存以供重用。虽然这些内存分配和释放函数有很多变种,但为了简化起见,我们将重点讨论这两个函数,但这些思路适用于任何直接管理内存的代码。
内存释放后仍然访问内存很容易发生,尤其是当许多代码共享一个最终会被释放的数据结构时,但指针的副本仍然存在并被错误使用。内存回收后,任何对这些旧指针的使用都会违反内存访问的完整性。另一方面,如果忘记释放内存,可能会导致堆内存耗尽,最终用尽内存。以下代码片段展示了堆内存的基本正确使用方法:
uint8_t *p;
// Don't use the pointer before allocating memory for it.
p = malloc(100); // Allocate 100 bytes before first use.
p[0] = 1;
p[99] = 123 + p[0];
free(p); // Release the memory after last use.
// Don't use the pointer anymore.
这段代码在分配和释放内存的调用之间访问内存,且在分配的内存范围内。
在实际使用中,内存分配、访问和释放可能分散在代码的不同位置,这使得始终正确地执行这些操作变得复杂。
缓冲区溢出
缓冲区溢出(或称缓冲区溢出错误)发生在代码访问了目标缓冲区外的内存位置时。理解其含义非常重要,因为相关术语可能会引起混淆。缓冲区是内存中任何区域的通用术语:数据结构、字符字符串、数组、对象或任何类型的变量。访问是一个涵盖所有读取或写入内存的术语。这意味着缓冲区溢出涉及在意图之外的内存区域进行读取或写入,尽管“溢出”更自然地描述的是写入操作。尽管读取和写入的效果在本质上不同,但将它们放在一起理解问题会更有帮助。
缓冲区溢出不仅仅限于堆内存,任何类型的变量都有可能发生缓冲区溢出,包括静态分配和栈上的局部变量。这些都可能以任意方式修改内存中的其他数据。意外的越界写入可能会改变内存中的任何内容,聪明的攻击者会不断改进这种攻击方式,力图造成最大损害。此外,缓冲区溢出错误可能会意外地读取内存,可能会泄露信息给攻击者,或导致代码行为异常。
不要低估正确获取显式内存分配、确保访问在边界内,并准确释放未使用存储的重要性和难度。最佳做法是采用简单的分配、使用和释放模式,包括异常处理,以确保释放操作从不被跳过。当一个组件进行分配并将引用交给其他代码时,必须明确指定责任,确保最终释放内存的操作由接口的某一方负责。
最后,请注意,即使在完全进行了范围检查、并且使用垃圾回收机制的语言中,你仍然可能会遇到问题。任何直接操作内存中数据结构的代码,都可能产生类似缓冲区溢出问题的错误。例如,考虑操作一个字节字符串,如 Python 数组中的 TCP/IP 数据包。读取内容并进行修改涉及计算数据的偏移量,即使没有发生数组外的访问,也可能会出错。
示例:内存分配漏洞
让我们看一个示例,展示动态内存分配出现问题的危险。我将这个示例简化,但在实际应用中,关键代码往往是分开的,这使得这些漏洞更加难以察觉。
一个简单的数据结构
这个示例使用一个简单的 C 数据结构来表示用户账户。该结构包括一个标志,如果用户是管理员则设置该标志、用户 ID、用户名和一组设置。除非isAdmin字段非零,因为这个字段赋予无限授权(使其成为攻击的一个诱人目标),否则这些字段的语义对我们并不重要:
#define MAX_USERNAME_LEN 39
#define SETTINGS_COUNT 10
typedef struct {
bool isAdmin;
long userid;
char username[MAX_USERNAME_LEN + 1];
long setting[SETTINGS_COUNT];
} user_account;
下面是一个创建这些用户账户记录的函数:
user_account* create_user_account(bool isAdmin, const char* username) {
user_account* ua;
if (strlen(username) > MAX_USERNAME_LEN)
return NULL;
ua = malloc(sizeof (user_account));
if (NULL == ua) {
fprintf(stderr, "malloc failed to allocate memory.");
return NULL;
}
ua->isAdmin = isAdmin;
ua->userid = userid_next++;
strcpy(ua->username, username);
memset(&ua->setting, 0, sizeof ua->setting);
return ua;
}
第一个参数指定用户是否为管理员。第二个参数提供一个用户名,用户名长度不得超过指定的最大长度。一个全局计数器(userid_next,声明未显示)提供连续的唯一 ID。所有设置的初始值都为零,除非发生错误导致返回NULL,否则代码会返回指向新记录的指针。请注意,代码在分配内存之前会检查username字符串的长度,以确保仅在内存将被使用时才进行分配。
写入索引字段
创建记录后,所有设置的值可以通过以下函数进行设置:
脆弱的代码
bool update_setting(user_account* ua,
const char *index, const char *value) {
char *endptr;
long i, v;
i = strtol(index, &endptr, 10);
if (*endptr)
return false; // Terminated other than at end of string.
if (i >= SETTINGS_COUNT)
return false;
v = strtol(value, &endptr, 10);
if (*endptr)
return false; // Terminated other than at end of string.
ua->setting[i] = v;
return true;
}
这个函数接受一个设置的索引和一个作为十进制数字字符串的值。将这些值转换为整数后,它将值作为索引设置存储在记录中。例如,要将设置 1 的值设置为 14,我们会调用函数update_setting(ua, "1", "14")。
函数strtol将字符串转换为整数值。strtol设置的指针(endptr)告诉调用者它解析了多远;如果不是空字符终止符,则说明字符串不是有效的整数,代码会返回错误。在确保索引(i)不超过设置数量后,它会以相同的方式解析值(v),并将设置的值存储在记录中。
缓冲区溢出漏洞
所有这些设置本身很简单,尽管 C 语言通常显得冗长。现在让我们直接切入正题。存在一个 bug:没有检查负索引值。如果攻击者能够将此函数调用为update_setting(ua, "-12", "1"),他们就能成为管理员。这是因为对设置的赋值会访问记录中的 48 个字节,向后偏移,因为每个项目的类型是long,占用 4 个字节。因此,赋值将1写入isAdmin字段,从而授予过多的权限。
在这种情况下,我们允许在数据结构中使用负索引,导致未授权的内存写入,违反了安全保护机制。你需要警惕此类问题的多种变体,包括由于缺少边界检查或算术错误(如溢出)而导致的索引错误。有时,一个错误的访问可能会修改其他数据,而这些数据恰好处于错误的位置。
修复方法是防止接受负索引值,从而将写入访问限制在有效的设置范围内。以下对if语句的补充会拒绝负值i,从而堵住漏洞:
if (`i < 0 ||`i >= SETTINGS_COUNT)
额外的i < 0条件现在会拒绝任何负索引值,从而阻止该函数进行任何非预期的修改。
内存泄漏
即使我们已经修复了负索引覆盖的问题,仍然存在漏洞。malloc(3)的文档警告并加下划线:“内存未初始化。” 这意味着内存可能包含任何内容,经过一些实验,我们发现其中确实有残留数据,因此回收未初始化的内存会导致私密数据泄露的潜在风险。
我们的create_user_account函数确实写入了结构体的所有字段,但它仍然泄露了作为回收内存的数据结构中的字节。编译器通常会对字段偏移量进行对齐,以便高效写入:在我的 32 位计算机上,字段偏移量是 4 的倍数(4 个字节等于 32 位),而其他架构也执行类似的对齐。之所以需要对齐,是因为写入跨越 4 的倍数地址的字段(例如,将 4 个字节写入地址 0x1000002)需要两次内存访问。因此,在这个示例中,偏移量为 0 的单字节布尔值isAdmin字段之后,userid字段位于偏移量 4 处,留下了中间的 3 个字节(偏移量 1–3)未使用。图 9-3 展示了数据结构的内存布局。

图 9-3:user_account 记录的内存布局
此外,strcpy在复制用户名时会留下另一块未初始化的内存。这一字符串复制函数会在遇到空字符终止符时停止复制,所以例如一个 5 个字符的字符串只会修改前 6 个字节,剩下的 34 个字节则会保留malloc为我们分配的任何内容。所有这一切的要点是,新的分配结构包含残留数据,这些数据可能会泄漏,除非每个字节都被覆盖。
减轻这些无意间发生的内存泄漏风险并不困难,但你必须勤奋地覆盖所有可能被暴露的数据结构的字节。你不应该试图精确预测编译器如何分配字段偏移量,因为这可能会随时间和平台的不同而变化。相反,避免这些问题的最简单方法是,在分配缓冲区后将其清零,除非你能确保它们已经完全写入,或者知道它们不会跨信任边界泄露。记住,即使你的代码本身不使用敏感数据,这条内存泄漏路径也可能暴露进程中的其他数据。
一般来说,你应该避免使用strcpy来复制字符串,因为有太多的方式可能出错。strncpy函数不仅将目标中未使用的字节填充为零,还能防止缓冲区溢出。但strncpy并不保证结果字符串会有一个空字符终止符。因此,分配缓冲区时必须确保其大小为MAX_USERNAME_LEN + 1,以保证总有空间留给空字符终止符。另一种选择是使用strlcpy函数,它可以确保空字符终止符的存在;然而,为了提高效率,它不会填充未使用的字节。正如这个例子所示,当你直接处理内存时,有许多因素需要小心应对。
现在我们已经介绍了内存分配的机制,并通过构造的示例看到了漏洞的表现,让我们考虑一个更现实的案例。以下示例基于几年前发生的一场引人注目的安全事件,该事件影响了世界上许多主要的网络服务。
案例研究:Heartbleed
2014 年 4 月初,头条新闻警告全球灾难险些发生。主要操作系统平台和网站推出了协调的修复措施,这些修复措辞匆忙且秘密安排,试图在新识别出的安全漏洞细节公之于众时,尽量减少其暴露。Heartbleed 不仅因为“第一个带有酷炫标志的安全漏洞”而成为新闻,而且它揭示了部署流行 OpenSSL TLS 库的任何服务器都有一个轻易被利用的漏洞。
接下来是对本十年最可怕的安全漏洞之一的深入分析,它应该能帮助你理解严重错误是如何产生的。这段详细讨论的目的,是为了说明管理动态分配内存的 bug 如何变成灾难性的漏洞。因此,我简化了代码和一些复杂的 TLS 通信协议细节,以展示漏洞的关键所在。从概念上讲,这与实际发生的情况直接对应,但代码部分更少,结构也更加简化。
Heartbleed 是 OpenSSL 实现 TLS 心跳扩展中的一个漏洞,该扩展在 2012 年通过了 RFC 6520 提议。这个扩展提供了一种低开销的方法来保持 TLS 连接活跃,从而避免客户端在一段时间不活动后重新建立连接。所谓的心跳本身是一种往返消息交换,包括一个 心跳请求,其有效负载大小在 16 到 16,384(2¹⁴)字节之间的任意数据,回传为一个 心跳响应,其中包含相同的有效负载。图 9-4 展示了该协议的基本请求和响应消息。

图 9-4:心跳协议(简化版)
客户端下载了 HTTPS 网页后,可能会在连接上发送心跳请求,以告知服务器它希望保持连接。在正常使用的示例中,客户端可能会发送一个 16 字节的消息“Hello!”(用零填充),作为请求,服务器会通过发送相同的 16 字节回应。(至少按理说应该是这样。)现在让我们来看看 Heartbleed 漏洞。
关键的漏洞出现在格式错误的心跳请求中,这些请求提供了一个较小的有效负载,但却声称有一个更大的有效负载字节数。为了准确了解这如何工作,我们首先来看一下简化版心跳消息的内部结构,这些消息是通信双方交换的。这个例子中的所有代码都用 C 语言编写:
typedef struct {
HeartbeatMessageType type;
uint16_t payload_length;
char bytes[0]; // Variable-length payload & padding
} hbmessage;
数据结构声明 hbmessage 显示了这些心跳消息的三个部分。第一个字段是消息 type,表示它是请求还是响应。接下来是消息有效负载的字节长度,称为 payload_length。第三个字段叫做 bytes,声明为零长度,但其目的是与动态分配一起使用,增加所需的适当大小。
一个恶意的客户端可能通过先建立一个 TLS 连接,然后发送一个包含 16,000 字节数的 16 字节心跳请求来攻击目标服务器。以下是该请求在 C 语言中的声明方式:
typedef struct {
HeartbeatMessageType type = **heartbeat_request**;
uint16_t payload_length = **16000**;
char bytes[16] = {**"Hello!"**};
} hbmessage;
发送此请求的客户端在撒谎:该消息声明其有效负载为 16,000 字节长,但实际的有效负载只有 16 字节。为了理解这条消息如何欺骗服务器,我们来看一下处理传入心跳请求消息的 C 代码:
hbmessage *hb(hbmessage *request, int *message_length) {
int response_length = request->payload_length+sizeof(hbmessage);
hbmessage* response = malloc(response_length);
response->type = heartbeat_response;
response->payload_length = request->payload_length;
memcpy(&response->bytes, &request->bytes, response->payload_length);
*message_length = response_length;
return response;
}
hb 函数调用时有两个参数:传入的心跳 request 消息和一个名为 message_length 的指针,它存储着函数返回的响应消息的长度。前两行计算响应的字节长度为 response_length,然后分配一个该大小的内存块作为 response。接下来的两行填充响应消息的前两个值:消息 type 和其 payload_length。
接下来是决定性错误。服务器需要返回请求中接收到的消息字节,因此它将请求中的数据复制到响应中。由于它信任请求消息已经准确地报告了其长度,函数复制了 16,000 字节,但由于请求消息中只有 16 字节,响应中包含了成千上万字节的内部内存内容。最后两行存储响应消息的长度,然后返回指向它的指针。
图 9-5 展示了这一消息交换过程,详细说明了前述代码如何泄露进程内存的内容。为了具体化这个漏洞的危害,我画了几个额外的缓冲区,这些缓冲区中包含着机密数据,已经位于请求缓冲区附近的内存中。从仅包含 16 字节有效负载的缓冲区复制 16,000 字节——这里通过过大的虚线区域进行了示意——导致机密数据最终出现在响应消息中,而该响应被服务器发送给客户端。

图 9-5:利用 Heartbleed 漏洞攻击(简化版)
这个漏洞相当于配置服务器,提供一个匿名 API,该 API 会将数千字节的工作内存快照并发送给所有调用者——这完全违背了内存隔离原则,暴露给互联网。毫不奇怪,使用 HTTPS 安全的 web 服务器的工作内存中会包含大量的机密信息。根据 Heartbleed 漏洞的发现者所说,他们轻易地从自己那儿偷取了“用于 X.509 证书的密钥、用户名和密码、即时消息、电子邮件以及关键的商业文档和通信”。由于泄漏的数据具体内容取决于内存分配的巧合,攻击者利用这个漏洞反复访问服务器内存,最终获取了各种敏感数据。有关 Heartbleed 更简明的视图,请参见 图 9-6。

图 9-6:Heartbleed 漏洞解释(由 Randall Munroe 提供,xkcd.com/1354)
回过头来看,修复其实很简单:预判那些“伪造”的心跳请求,它们要求的有效负载超过了它们实际提供的内容,按照 RFC 明确规定,应该忽略它们。多亏了 Heartbleed,世界认识到很多服务器对 OpenSSL 的依赖程度,以及许多志愿者为这些关键软件所做的工作,这些软件是如此多互联网基础设施的支柱。这个漏洞的特点是,许多安全漏洞之所以难以被发现,是因为在结构良好的请求情况下,系统一切正常,只有结构不良的请求会引发问题,而这些不良请求是善意的代码几乎不可能生成的。此外,心跳响应中泄露的服务器内存并不会直接对服务器造成伤害:只有通过对过量数据泄露的仔细分析,潜在的损害程度才变得明显。
作为近年来最严重的安全漏洞之一,Heartbleed 应该作为一个宝贵的例子,展示安全漏洞的性质,以及如何通过小小的缺陷导致系统安全的巨大破坏。从功能角度来看,很多人可能会认为这是一个小错误:它不太可能发生,而且比请求提供的更多的有效负载数据,看起来一开始是无害的。
Heartbleed 是一个很好的教训,展示了低级语言的脆弱性。小错误可能会带来巨大的影响。如果缓冲区溢出发生,并且内存中恰好存放着重要的秘密数据,它可能会暴露这些高价值的秘密。设计(协议规范)预见到了这个错误,并指示应该忽略具有不正确字节长度的心跳请求,但在没有明确测试的情况下,没人注意到这个漏洞,直到两年多以后。
这只是一个库中的一个漏洞。现在还有多少类似的漏洞存在?
不受信任的输入
我喜欢工程学,但我更热爱创意输入。
—约翰·戴克斯特拉

不受信任的输入 可能是开发人员编写安全代码时最需要担心的来源。这个术语本身可能令人困惑,最好理解为包含所有不是 受信任输入 的系统输入,即你可以信任的代码提供的格式良好的数据输入。不受信任的输入是指那些超出你控制范围并可能被篡改的数据,包括任何你不完全信任的数据进入系统。也就是说,它们是你 不应信任 的输入,而不是你 错误信任 的输入。
任何来自外部并进入系统的数据都应视为不可信。系统的用户可能是友好、值得信赖的人,但在安全方面,最好将他们视为不可信,因为他们可能做出任何事情——包括成为他人陷阱的受害者。不可信输入令人担忧,因为它们代表着一个 攻击向量,即进入系统并制造麻烦的途径。恶意构造的跨越信任边界的输入尤其值得关注,因为它们可能深入系统,导致特权代码中的漏洞,因此必须拥有强有力的第一道防线。世界上最大的来源不可信输入的地方无疑是互联网,而且由于软件很少能完全断开连接,这对几乎所有系统而言构成了严重威胁。
输入验证(或 输入清理)是一种防御性编程技术,它对输入施加限制,强制其符合预定规则。通过验证输入是否满足特定约束,并确保代码对所有有效输入都能正确工作,你可以成功防御这些攻击。本章重点讨论如何通过输入验证管理不可信输入,以及这样做对安全性的重要性。这个话题看似平凡,技术上也不难,但需求非常普遍,做得更好的输入验证可能是开发人员减少漏洞的最具影响力的低成本措施。因此,本章对这一内容进行了深入探讨。字符字符串输入存在特定的挑战,Unicode 的安全性隐患知之甚少,因此我们还将概述它们所带来的基本问题。接下来,我们将通过一些使用不可信数据进行的注入攻击示例,介绍各种技术:SQL、路径遍历、正则表达式和 XML 外部实体(XXE)。最后,我将总结针对这一广泛漏洞集合的可用缓解技术。
输入验证
在寻求他人验证之前,先试着在自己身上找到验证。
—Greg Behrendt
既然你已经了解了什么是不可信输入,接下来考虑它们在系统中的潜在影响以及如何防止危害。不可信输入经常流经系统,通常会深入许多层次的受信组件——因此,虽然你的代码是由受信代码直接调用的,但并不能保证这些输入是可信的。问题在于,组件可能正在传递来自任何地方的数据。攻击者操控数据的方式越多,这些输入就越不可信。接下来的例子将清楚地说明这一点。
输入验证是一种有效的防御手段,它将不可信的输入限制在应用程序可以安全处理的值范围内。输入验证的核心任务是确保不可信的输入符合设计规范,以便验证后的代码只处理格式正确的数据。假设你正在编写一个用户登录认证服务,该服务接收用户名和密码,如果凭证正确则发放认证令牌。通过将用户名限制在 8 到 40 个字符之间,并要求它们由 Unicode 代码点的某个明确定义的子集组成,你可以大大简化该输入的处理,因为它是一个已知的数量。后续代码可以使用固定大小的缓冲区来存储用户名的副本,并且无需担心难以处理的字符。基于这一保障,你可能还能以其他方式简化处理。
在上一章中,我们已经看到输入验证用于修复低级别的漏洞。工资单整数计算代码包含了一个if语句的输入验证,用来防止过大的输入值:
if (millihours > max_millihours // 100 hours max
|| hourlycents > max_hourlycents) // $200/hour rate
**return 0;**
对于这一点无需重复解释,但它作为基本输入验证的一个很好的示例。几乎任何你编写的代码在某些限制条件下才能正常工作:它不适用于极端值,例如巨大的内存大小,或者不同语言的文本。无论这些限制是什么,我们都不希望将代码暴露于它未设计处理的输入,因为这可能会导致意外后果,从而产生漏洞。缓解这一危险的一个简单方法是对输入施加人为的限制,筛除所有有问题的输入。
然而,有一些细节需要注意。当然,限制不应拒绝应该被合理处理的输入;例如,在工资单示例中,我们不能将 40 小时的工作周视为无效。如果代码不能处理所有有效输入,那么我们需要修复它,使其能够处理更广泛的输入范围。此外,输入验证策略可能需要考虑多个输入之间的相互作用。在工资单示例中,工资率和工作小时数的乘积可能超过固定宽度的整数大小,正如我们在第九章中看到的那样,因此验证可以限制这两个输入的乘积,或者分别为每个输入设置限制。前一种方法更宽松,但可能对调用方的适应性要求更高,因此正确的选择取决于应用程序的需求。
一般来说,你应该尽早验证不可信的输入,以尽量减少不受约束的输入流入下游代码的风险,这些下游代码可能无法妥善处理这些输入。一旦验证通过,后续代码就可以只处理规范的输入数据,这有助于开发人员编写安全代码,因为他们明确知道输入的范围。保持一致性至关重要,所以一个好的模式是,在处理输入数据的代码的第一层就进行输入验证,然后将有效的输入交给更深层次的业务逻辑,这样可以让后续代码更有信心地假设所有输入都是有效的。
我们主要将输入验证视为防御不可信输入的手段——特别是攻击面上的内容——但这并不意味着其他所有输入都可以被轻视。无论你多么信任某些数据的提供者,仍然有可能由于错误导致意外的输入,或者攻击可能通过某种方式破坏系统的部分内容,从而有效地扩展攻击面。出于这些原因,防御性输入验证是你的好伙伴。与其冒着创建细微漏洞的风险,不如在输入验证上多加冗余,尤其是在你不能确定输入数据是否经过可靠验证的情况下,你可能需要自行验证以确保安全。
确定有效性
输入验证的开始是决定什么是有效的。这并不像听起来那么简单,因为它实际上意味着要预测所有未来可能的有效输入值,并合理地找出如何拒绝其他值。这个决定通常由开发者做出,开发者必须权衡用户可能需要的输入与允许更广泛输入范围所需的额外编码工作。理想情况下,软件需求应该明确规定什么是有效输入,而良好的设计也会提供指导。
对于整数输入,32 位整数的完整范围似乎是一个显而易见的选择,因为它是标准数据类型。但考虑到未来,如果代码会在某个时刻将这些值相加,那么这将需要一个更大的整数,因此 32 位限制变得任意。或者,如果你能合理地为有效性设定一个下限,那么你可以确保这些值的总和能够适应 32 位。确定有效输入的正确答案将需要检查特定应用场景——这是领域知识对安全性至关重要的一个典型例子。一旦指定了有效值的范围,就很容易确定使用哪种适当的数据类型。
通常行之有效的做法是对输入建立一个明确的限制,并在实现中留出足够的冗余空间,以确保能够正确处理所有有效输入。所谓冗余空间,我的意思是如果你正在将一个文本字符串复制到一个 4,096 字节的缓冲区中,应该使用 4,000 字节作为最大有效长度,这样你就有一些额外的空间。(在 C 语言中,额外的空终止符溢出一个字节是一个经典的错误,容易犯。)一些程序员喜欢挑战,但如果你太宽松(允许尽可能广泛的输入范围),那么你就是在迫使实现承担一个比必要的更大更难的任务,从而导致代码复杂性和测试负担的增加。即使你的在线购物应用能够管理一个包含十亿个商品的购物车,处理这样一个不现实的交易也是适得其反的。最好的做法是拒绝输入(这可能是因为某人的猫坐在了键盘上)。
验证标准
大多数输入验证检查包括几个标准,确保输入不超过最大大小,数据以正确的格式到达,并且在可接受的值范围内。
检查值的大小是一个快速测试,主要用于避免 DoS 攻击,这种攻击会使你的应用在面对数兆的非信任输入时变得笨重甚至崩溃。数据格式可能是数字的数字序列,包含某些允许字符的字符串,或者是更复杂的格式,比如 XML 或 JSON。通常,明智的做法是按以下顺序进行检查:首先限制大小,以避免浪费时间处理过于庞大的输入,然后确保输入格式正确,再检查结果值是否在可接受范围内。
决定一个有效的值范围可能是最主观的选择,但设定具体的限制是非常重要的。这个范围的定义将取决于数据类型。对于整数,范围将不小于最小值且不大于最大值。对于浮点数,可能还有精度(小数位数)的限制。对于字符串,则是最大长度、编码,通常还包括通过正则表达式或类似方式确定的允许格式或语法。我建议以字符为单位指定最大字符串长度,而不是字节,这样非程序员至少能理解这个约束的含义。
将输入视为有目的的有效,而不是抽象的有效是非常有帮助的。例如,一个语言翻译系统可能会接受输入,首先验证它是否符合支持的字符集和所有支持语言的最大长度。如果下一阶段的处理分析文本以确定语言,那么一旦选择了语言,你就可以进一步限制文本到相应的字符集。
或者考虑验证一个整数输入,它表示在采购发票上订购的商品数量。任何客户可能订购的最大数量并不容易确定,但这是一个值得事先考虑的问题。如果你可以访问过去的数据,快速的 SQL 查询可能会返回一个有趣的例子,值得参考。虽然有人可能认为最大 32 位整数值是最不具限制性、因此是最好的选择,但实际上这通常没有太大意义。谁会认为订购 4,294,967,295 个产品不是什么错误呢?由于非程序员永远不会记住这种由二进制衍生出来的奇怪数字,选择一个更符合用户习惯的限制,比如 1,000,000,显然更有意义。如果某人真的碰到这样的限制,应该容易调整,并且这个情况也值得了解。更重要的是,开发者在这个过程中会了解到一个此前没有预想到的真实使用案例。
输入验证的主要目的是确保无效输入无法通过验证。实现这一点的最简单方法就是直接拒绝无效输入,就像我们至今讨论中所隐含的那样。一个更宽容的替代方法是检测任何无效输入并将其修改为有效的格式。我们来看一下这些不同的方法,并探讨何时使用哪种方法。
拒绝无效输入
拒绝不符合规定规则的输入是最简单且可以说是最安全的方法。完全接受或拒绝是最干净、最清晰的,通常也最容易做对。这就像常识性的建议,决定是否安全游泳时:“如果有疑虑,就不要下水。”这可以简单到如果任何表单字段填写错误就拒绝处理网页表单,或极端一些,因某条记录的单个违规而拒绝整个数据批次。
每当用户直接提供输入时,比如在网页表单的情况下,最为友善的做法是提供有用的错误信息,方便他们更正错误并重新提交。用户提交无效输入通常是由于疏忽或不了解验证规则,这两种情况都不理想。停止处理并要求数据源提供有效输入是输入验证的保守方法,并且为常规提供者提供了一个学习和适应的机会。
当输入验证拒绝用户输入的不良内容时,最佳实践包括:
-
在用户界面中解释什么构成有效条目,至少可以避免那些阅读它的人猜测并重试。(我怎么知道区号应该用连字符而不是括号表示?)
-
一次标记多个错误,以便可以在一步中进行更正并重新提交。
-
当用户直接提供输入时,保持规则简单明了。
-
将复杂的表单拆分成多个部分,每个部分对应一个单独的表单,这样用户可以看到他们在不断取得进展。
当输入来自其他计算机,而不是直接来自用户时,可能需要更加严格的输入验证。实现这些要求的最佳方式是编写文档,精确描述预期的输入格式和其他约束条件。在来自专业系统的输入情况下,完全拒绝整个输入批次,而不是尝试部分处理有效数据,可能更为合理,因为这表明某些数据不符合规范。这样可以在无需处理哪些数据被处理过、哪些没有的情况下,纠正错误并重新提交完整数据集。
修正无效输入
虽然坚持只接收完全有效的输入并拒绝所有其他输入是安全且简单的,但这并不总是最好的方法。对于在线商家来说,为了吸引客户,拒绝结账过程中的输入可能导致更多的“购物车遗弃”现象,从而失去销售机会。对于互动式用户输入来说,严格的规则可能会让用户感到沮丧,因此,如果软件能够帮助用户提供有效输入,那么它应该这么做。
如果你不想因为一个小错误而中断流程,那么你的输入验证代码可以尝试修正无效的输入,将其转换为有效值,而不是直接拒绝它们。简单的例子包括将过长的字符串截断到最大长度,或者去除多余的前后空格。其他修正无效输入的例子则更复杂。考虑常见的邮寄地址输入问题,要求精确符合邮政服务规定的格式。这是一个相当大的挑战,因为街道名称的拼写、间隔和缩写形式都非常严格。几乎唯一的解决方法是提供类似地址的最佳匹配项,供用户从中选择。
处理棘手的验证要求的最佳方法是将输入设计得尽可能简单。例如,许多人在输入电话号码时,会遇到需要括号的区号或某些位置的连字符的麻烦。与其要求这种格式,不如让电话号码仅由数字组成,避免使用语法规则。
虽然调整可能节省时间,但任何修正都引入了这样一个可能性:修正会以用户未预期的方式改变输入。以电话号表单字段为例,输入应该是 10 位数字。去除常见符号(如连字符)并接受结果为 10 位有效数字的输入似乎是安全的,但如果输入的数字过多,用户可能本来想提供一个国际号码,或者可能是输入错误。无论哪种情况,可能都不应该直接截断它。
正确的输入验证需要谨慎判断,但它能使软件系统更加可靠,从而更安全。它减少了问题空间,消除了不必要的棘手边缘案例,提高了可测试性,并使整个系统更加明确定义和稳定。
字符串漏洞
如果你是 2006 年工作的程序员,且不了解字符、字符集、编码和 Unicode 的基础知识,一旦我抓到你,我将惩罚你,让你在潜水艇里剥洋葱六个月。
—Joel Spolsky
几乎所有的软件组件都需要处理字符字符串,至少作为命令行参数或者在以可读形式显示输出时。某些应用程序广泛处理字符字符串;其中包括文字处理软件、编译器、网页服务器和浏览器等。字符串处理无处不在,因此,了解其中常见的安全陷阱至关重要。接下来将介绍一些问题,帮助你避免无意中创建安全漏洞。
长度问题
长度是第一个挑战,因为字符字符串的长度可能是无限的。极长的字符串在被复制到固定长度的存储区域时可能导致缓冲区溢出。即使处理得当,大量字符串仍可能导致性能问题,因为它们可能消耗过多的周期或内存,潜在地威胁到可用性。因此,第一道防线是将传入的不可信字符串的长度限制在合理范围内。冒昧地提醒一下,在分配缓冲区时,不要将字符数与字节长度混淆。
Unicode 问题
现代软件通常依赖 Unicode,这是一个涵盖世界上所有书面语言的丰富字符集,但这种丰富性带来了大量的隐藏复杂性,可能成为攻击的温床。世界上有许多字符编码方式来将文本表示为字节,但大多数软件将 Unicode 作为一种通用语言。Unicode 标准(13.0 版本)长达 1000 多页,指定了超过 14 万个字符、规范化算法、与旧版字符编码标准的兼容性以及双向语言支持;它覆盖了几乎所有世界上的书面语言,编码了超过一百万个代码点。
Unicode 文本有几种不同的编码方式,您需要了解它们。UTF-8 是最常见的编码方式,但还有 UTF-7、UTF-16 和 UTF-32 编码。准确地在字节和字符之间进行转换对安全至关重要,以免在过程中不小心改变文本内容。排序(排序顺序)取决于编码和语言,如果您没有意识到这一点,可能会产生意外的结果。某些操作在不同的语言环境下可能表现不同,比如在为其他国家或语言配置的计算机上运行时,因此在所有这些情况下测试正确性非常重要。当不需要支持不同语言环境时,考虑明确指定语言环境,而不是从系统配置中继承任意语言环境。
由于 Unicode 具有许多令人惊讶的特性,因此安全的底线是使用可信的库来处理字符字符串,而不是直接操作字节。可以说,在这方面,Unicode 类似于加密,因为最好将繁重的工作交给专家。如果你不知道自己在做什么,某些你从未听说过的字符或语言的怪异特性可能会引入安全漏洞。本节详细介绍了一些重要问题,值得了解,但要全面深入地研究 Unicode 的复杂性,可能需要一本完整的书。有关开发人员需要了解更细微之处的安全考虑,Unicode 联盟提供了详细的指南。UTR#36:Unicode 安全考虑 是一个很好的起点。
编码与字符形状
Unicode 编码的是字符,而不是 字符形状(字符的渲染视觉形式)。这一简单的原则有许多影响,但或许最容易解释的是,大写字母 I (U+0049) 和罗马数字一 (U+2160) 是不同的字符,虽然它们可能显示为相同的字符形状(称为 同形字)。网页 URL 支持多种语言,使用相似字符是攻击者用来欺骗用户的常见伎俩。著名的案例是,有人利用一个看起来和 PayPal 中字母 P 一模一样的西里尔字母(U+0420)成功获得了合法的服务器证书,从而创建了一个完美的钓鱼攻击场景。
Unicode 包括了组合字符,允许同一个字符有不同的表示方式。拉丁字母 Ç (U+00C7) 也有两字符表示形式,即大写字母 C (U+0043) 后跟“组合塞迪拉”(U+0327)字符。无论是一字符形式还是两字符形式,它们显示的字符形状相同,没有语义差异,因此代码通常应将它们视为等效形式。典型的编码策略是首先将输入字符串标准化为规范形式,但不幸的是,Unicode 有多种规范化方式,因此正确处理这些细节需要进一步研究。
大小写转换
将字符串转换为大写或小写是规范化文本的常见方法,这样代码就能将test、TEST、tEsT等视为相同。然而,事实证明,除了英文字母之外,还有一些字符在大小写转换中表现出令人惊讶的特性。
例如,以下字符串虽然不同,但对一般观察者几乎是相同的:'This ıs a test.' 和 'This is a test.'(注意第一句话中第二个小写i缺少点)。将其转换为大写后,它们都会变成相同的'THIS IS A TEST.',因为没有点的小写ı(U+0131)和常见的小写i(U+0069)都变成了大写的I(U+0049)。为了说明这如何导致漏洞,假设检查输入字符串是否包含<script>:代码可能会将其转换为小写,扫描该子字符串,然后再转换为大写进行输出。字符串<scrıpt>就会悄悄通过,但在输出中会显示为<SCRIPT>,这可能会允许在网页上进行脚本注入——这正是代码原本试图防止的事情。
注入漏洞
如果你把真理注入政治,你就没有政治了。
—威尔·罗杰斯
不请自来的信用卡广告占据了堆积如山的垃圾邮件的大部分,这些邮件堵塞了邮政系统,但有一个聪明的收件人设法让银行吃了亏。Dmitry Agarkov 没有丢弃他不喜欢的条款的促销信用卡申请,而是扫描了附带的合同,并仔细修改了文本,指定了对他极为有利的条款,包括 0%的利率、无限制的信用额度,以及如果银行取消信用卡他将收到一笔丰厚的款项。他签署了修改后的合同并将其返回给银行,很快收到了新的信用卡。Dmitry 享受了一段时间独特有利合同中的优厚条款,但当银行最终察觉到这一点时,事情变得棘手。经过一场漫长的法律斗争,其中包括一项支持修改合同有效性的有利判决,他最终在庭外达成了和解。
这是一个现实世界的注入攻击示例:合同不同于代码,但它们确实要求签署人执行规定的动作,就像程序的行为一样。通过修改合同条款,Dmitry 能够迫使银行违背其意愿行事,几乎就像他修改了管理信用卡账户的软件来使其有利于自己一样。软件也容易受到这种攻击:不受信任的输入可以诱使它执行意外的操作,而这实际上是一个相当常见的漏洞。
有一种常见的软件技术,通过构造一个字符串或数据结构,编码一个要执行的操作,然后执行它来完成指定的任务。(这类似于银行写合同定义其信用卡服务的操作方式,期望这些条款不被修改。)当涉及到来自不可信来源的数据时,它可能会影响执行时发生的情况。如果攻击者能改变操作的预期效果,这种影响可能会跨越信任边界,并由更高权限的软件执行。这就是抽象意义上的注入攻击概念。
在解释一些常见注入攻击的具体细节之前,让我们先考虑一个简单的例子,说明不可信数据的影响如何具有欺骗性。根据一个未经证实的故事,正是这种混淆被一支巧妙的校内垒球队成功利用,他们选择了名字“无比赛安排”(No Game Scheduled)。几次对方队伍在赛程上看到这个名字,误以为那天没有比赛,因此因未按时到场而被判弃权。这是一个注入攻击的例子,因为队伍名称是赛程系统的输入,但“无比赛安排”被误解为赛程系统的消息。
相同的注入攻击原理适用于许多不同的技术(即,表示操作的构造字符串形式),包括但不限于:
-
SQL 语句
-
文件路径名
-
正则表达式(作为 DoS 威胁)
-
XML 数据(特别是 XXE 声明)
-
Shell 命令
-
将字符串解释为代码(例如,JavaScript 的
eval函数) -
HTML 和 HTTP 头(第十一章介绍)
以下部分将详细解释前四种注入攻击。Shell 命令和代码注入的工作原理与 SQL 注入相似,都是由于不小心构造的字符串可以被不可信输入所利用。我们将在下一章介绍 Web 注入攻击。
SQL 注入
经典的 xkcd 漫画 #327 (图 10-1)描绘了一次大胆的 SQL 注入攻击,其中父母给他们的孩子起了一个不太可能且无法发音的名字,名字中包含特殊字符。当该名字被输入到当地学区的数据库时,这个名字破坏了学校的记录。

图 10-1:妈妈的利用(由 Randall Munroe 提供, xkcd.com/327)
为了理解这一如何运作,假设学校注册系统使用 SQL 数据库,并通过如下 SQL 语句添加学生记录:
INSERT INTO Students (name) VALUES ('**Robert**');
在这个简化的例子中,该语句将名字“Robert”添加到数据库中。(实际上,除了 name 之外,括号中的列表还会出现更多的列;为了简便起见,这里省略了这些列。)
现在,想象一个学生拥有一个荒谬的名字 Robert'); DROP TABLE students;--。考虑一下结果 SQL 命令,其中与学生姓名相关的部分已被高亮:
INSERT INTO Students (name) VALUES ('`Robert'); DROP TABLE Students;--`');
根据 SQL 命令语法规则,这个字符串实际上包含了两个语句:
INSERT INTO Students (name) VALUES ('Robert');
DROP TABLE Students; --');
这两个 SQL 命令中的第一个按预期插入一个“Robert”记录。然而,由于学生的姓名包含 SQL 语法,它还注入了一个第二个、不希望出现的命令 DROP TABLE,这个命令会删除整个表格。双破折号表示注释,因此 SQL 引擎会忽略后面的文本。这个技巧通过消耗结尾的语法(单引号和闭括号)来避免语法错误,从而使得执行能够正常进行。
现在,让我们仔细看看代码,了解 SQL 注入漏洞的样子以及如何防止它。假设的学校注册系统代码通过将 SQL 命令作为文本字符串形成,如我们之前讲解的第一个基本示例,然后执行它们。输入数据提供了学生的姓名和其他信息,用于填写学生记录。从理论上讲,我们甚至可以假设工作人员已将这些输入与官方记录进行核对,以确保其准确性(假设,在大多数情况下,法律名字中可以包含 ASCII 特殊字符)。
程序员的致命错误是在写出类似下面的字符串连接语句时,没有考虑到一个不寻常的名字可能会“跳出”单引号:
漏洞代码
sql_stmt = "INSERT INTO Students (name) VALUES ('" + student_name + "');";
缓解注入攻击并不难,但需要保持警惕,以免变得马虎,写出像这样的代码。混合不受信任的输入和命令字符串是漏洞的根本原因,因为这些输入可能会“跳出”引号,带来意外的有害后果。
确定哪些字符串构成有效的姓名是一个重要的需求问题,但让我们先集中关注 SQL 语句中作为单引号使用的撇号字符。由于一些名字(如 O’Brien)包含撇号,而撇号是打开 SQL 命令语法的关键,应用程序不能在输入验证时禁止这个字符。这个名字可以正确地写作引用字符串 'O''Brien',但可能还有许多其他特殊字符需要特殊处理,以有效消除完整解决方案中的漏洞。
作为进一步的防御措施,您应该配置 SQL 数据库,使得注册学生的软件没有删除任何表格的管理权限,因为它不需要做这项工作。(这是第四章中的“最小权限”模式的一个示例。)
与其“重新发明轮子”编写自定义 SQL 清理代码,最佳做法是使用专门构造 SQL 命令的库来处理这些问题。如果没有可靠的库可用,应该创建测试用例以确保尝试的注入攻击要么被拒绝,要么被安全处理,并确保像 O’Brien 这样的学生名字能够正常工作。
下面是几个简单的 Python 代码片段,展示错误和正确的处理方式。首先是错误的方式,使用 Bobby Tables 攻击的模拟:
脆弱的代码
import sqlite3
con = sqlite3.connect('school.db')
student_name = "Robert'); DROP TABLE Students;--"
# The WRONG way to query the database follows:
sql_stmt = "INSERT INTO Students (name) VALUES ('" + student_name + "');"
con.executescript(sql_stmt)
在创建与 SQL 数据库的连接(con)后,代码将学生的名字赋值给变量student_name。接下来,代码通过将字符串student_name插入VALUES列表中来构建 SQL INSERT语句,并将其赋值给sql_stmt。最后,执行该字符串作为 SQL 脚本。
处理这个问题的正确方式是让库插入涉及不可信数据的参数,如以下代码片段所示:
修复后的代码
import sqlite3
con = sqlite3.connect('school.db')
student_name = "Robert'); DROP TABLE Students;--"
# The RIGHT way to query the database follows:
con.execute("INSERT INTO Students (name) VALUES (?)", (student_name,))
在这个实现中,?占位符从以下包含student_name字符串的元组参数中填充。请注意,在INSERT语句字符串中不需要引号——这一切都为您处理了。这种语法避免了注入攻击,并安全地将 Bobby 的奇怪名字输入到数据库中。
这个例子中有一个细节需要澄清。要使原始漏洞生效,需要使用executescript库函数,因为execute只接受单个语句,这对抗了这种特定攻击。然而,认为所有注入攻击都涉及额外的命令,并且这种限制提供了很大的保护,是一个错误的看法。例如,假设学校里有另一个名字难以发音的学生,Robert', 'A+');--。他和普通的罗伯特都不及格——但是当他的成绩被记录到另一个 SQL 表中时,他的成绩被提升到了 A+。这是怎么回事?
当使用脆弱的代码提交普通的罗伯特的成绩时,命令会如以下所示输入预定的 F 成绩:
INSERT INTO Grades (name, grade) VALUES ('`Robert`', 'F');
但是当名字为Robert', 'A+');--时,该命令变成了:
INSERT INTO Grades (name, grade) VALUES ('`Robert', 'A+');--`', 'F');
对于 xkcd 的“Little Bobby Tables”例子,有一个最后的备注,细心的读者可能已经注意到。撇开前提的荒谬性不谈,令人惊讶的是,Bobby 的父母居然能够预见到数据库表格的特定名称(Students)是随意选择的。这最好的解释是艺术许可。
路径遍历
文件路径遍历是与注入攻击密切相关的常见漏洞。与我们在前一节例子中看到的从引号中逃逸的攻击不同,这种攻击是通过逃逸到父级目录,从而获得对文件系统其他部分的意外访问。例如,若要提供一组图像,一个实现可能会将图像文件存储在名为/server/data/image_store的目录中,然后通过获取由(不受信任的)输入名称X形成的路径/server/data/image_store/X来处理对名为X的图像的请求。
显而易见的攻击方式是请求名称../../secret/key,这将返回本应是私有的文件/server/secret/key。回想一下,.(点)是当前目录的特殊名称,而..*(点点)是父级目录,它允许穿越至文件系统根目录,正如这个等效路径序列所示:
-
/server/data/image_store/../../secret/key -
/server/data/../secret/key -
/server/secret/key
防止这种攻击的最佳方式是限制输入(例如我们例子中的X)中允许的字符集。通常,通过输入验证确保输入是一个字母数字字符串,足以完全关闭此漏洞。这个方法有效,因为它排除了需要突破目标文件系统部分的有问题的文件分隔符和父目录形式。
然而,有时这种方法过于限制。当需要处理任意文件名时,这种简单方法过于严格,因此你需要做更多工作(而且可能会变得复杂,因为文件系统本身就是复杂的)。此外,如果你的代码需要跨平台运行,你还需要注意可能存在的文件系统差异(例如,*nix 系统的路径分隔符是斜杠,而在微软 Windows 上是反斜杠)。
这是一个简单的例子,展示了一个函数在将输入字符串用作访问文件目录的子路径之前进行检查的过程,该目录是此 Python 代码所在的目录(由__file__表示)。其目的是仅提供对某个目录或其子目录中的文件的访问权限——但绝对不能访问其他地方的任意文件。在这里显示的版本中,守卫函数safe_path会检查输入是否包含前导斜杠(指向文件系统根目录)或父级目录的点点(..),并拒绝包含这些内容的输入。为了正确处理路径,你应该使用标准库中的路径处理功能,例如 Python 的os.path,而不是临时的字符串操作。但仅凭这一点并不足以确保不会突破目标目录:
易受攻击的代码
def safe_path(path):
"""Checks that argument path is a safe file path. If not, returns None.
If safe, returns the normalized absolute file path.
"""
if path.startswith('/') or path.startswith('..'):
return None
base_dir = os.path.dirname(os.path.abspath(__file__))
filepath = os.path.normpath(os.path.join(base_dir, path))
return filepath
这个保护机制的漏洞在于路径可以指定一个有效的目录,然后向上跳转到父目录,一直到可以突破路径。例如,由于该示例代码运行的当前目录距离根目录有五级,路径./../../../../../etc/passwd(带有五个点点)会解析为/etc/passwd文件。
我们可以通过拒绝包含“点点”路径的路径来改进基于字符串的无效路径测试,但这种方法可能存在风险,因为很难确保我们已经预见到所有可能的技巧并完全阻止它们。相反,有一个简单的解决方案,依赖于os.path库,而不是用自己的代码构建路径字符串:
修复后的代码
def safe_path(path):
"""Checks that argument path is a safe file path. If not, returns None.
If safe, returns the normalized absolute file path.
"""
base_dir = os.path.dirname(os.path.abspath(__file__))
filepath = os.path.normpath(os.path.join(base_dir, path))
if base_dir != os.path.commonpath([base_dir, filepath]):
return None
return filepath
这个保护机制是可以依赖的,原因如下。基础目录是一个可靠的路径,因为它没有涉及不受信任的输入:它完全由程序员控制的值构成。在与输入路径字符串连接后,该路径会被规范化,从而解决任何点点父目录引用,生成一个绝对路径(filepath)。现在我们可以检查这些路径的最长公共子路径是否为我们希望限制访问的目标目录。
正则表达式
高效、灵活且易于使用,正则表达式(regex)提供了广泛的功能,可能是我们解析文本字符串时最通用的工具。它们通常比特定代码更快(既能编写又能执行),且更可靠。正则表达式库编译了状态表,解释器(有限状态机或类似自动机)执行这些表来匹配字符串。
即使你的正则表达式构造正确,它也可能引发安全问题,因为某些正则表达式容易导致执行时间过长,如果攻击者能够触发这些正则表达式,就可能引发严重的 DoS 攻击。具体来说,如果正则表达式发生回溯——即它向前扫描很远,然后需要反向重新扫描多次以找到匹配项——执行时间可能会膨胀。安全隐患通常源于允许不受信任的输入来指定正则表达式;或者,如果代码中已经包含了一个回溯的正则表达式,来自不受信任的输入的长字符串可能会最大化计算工作量,从而导致问题。
一个回溯的正则表达式看起来可能无害,正如一个示例所展示的那样。以下的 Python 代码在我的普通 Raspberry Pi Model 4B 上运行超过三秒钟。你的处理器可能更快,但由于在示例中每增加一个D到 24 中就会使运行时间加倍,因此很容易用稍长的字符串使任何处理器陷入死锁:
import re
print(re.match(r'(D+)+$', 'DDDDDDDDDDDDDDDDDDDDDDDD!'))
无论是哪种解析不受信任输入的方式,只要涉及回溯或其他非线性计算,都会存在过度运行时间的风险。在下一节中,你将看到一个与此相关的 XML 实体示例,还有更多类似的例子。
缓解这些问题的最佳方式取决于具体的计算,但有几种常见的方法可以抵御这些攻击。避免让不受信任的输入影响可能导致爆炸的计算。在正则表达式的情况下,不要让不受信任的输入定义正则表达式,尽量避免回溯,并限制正则表达式匹配的字符串长度。找出最坏的计算情况,然后进行测试,确保它不会过于缓慢。
XML 的危险
XML 是表示结构化数据的最流行方式之一,因为它既强大又易于人类阅读。然而,你应该意识到 XML 的强大功能也可能被武器化。通过 XML 实体,有两种主要方式可以使不受信任的 XML 造成危害。
XML 实体声明 是一个相对晦涩的特性,不幸的是,攻击者在利用这些特性方面非常有创意。在下面的示例中,一个命名实体 big1 被定义为一个四字符字符串。另一个命名实体 big2 被定义为 big1 的八个实例(共计 32 个字符),big3 是另外八个,以此类推。等你到 big7 时,你正在处理一兆字节的数据,而且从那以后很容易继续增加。这个例子构造了一个 8 兆字节的 XML。正如你所看到的,只需再添加几行,你就能进入到吉字节级别:
<!DOCTYPE dtd[
<!ENTITY big1 "big!">
<!ENTITY big2 "&big1;&big1;&big1;&big1;&big1;&big1;&big1;&big1;">
<!ENTITY big3 "&big2;&big2;&big2;&big2;&big2;&big2;&big2;&big2;">
<!ENTITY big4 "&big3;&big3;&big3;&big3;&big3;&big3;&big3;&big3;">
<!ENTITY big5 "&big4;&big4;&big4;&big4;&big4;&big4;&big4;&big4;">
<!ENTITY big6 "&big5;&big5;&big5;&big5;&big5;&big5;&big5;&big5;">
<!ENTITY big7 "&big6;&big6;&big6;&big6;&big6;&big6;&big6;&big6;">
]>
<mega>&big7;&big7;&big7;&big7;&big7;&big7;&big7;&big7;</mega>
外部实体声明还可以使用更多技巧。考虑以下情况:
<!ENTITY snoop SYSTEM "file:///etc/passwd>" >
这正如你想象的那样:读取密码文件并使其内容在 XML 中出现 &snoop; 的位置随处可见。如果攻击者能够将其作为 XML 提供,并看到实体扩展的结果,他们就能公开任何他们可以命名的文件的内容。
针对这些问题的第一道防线是防止不受信任的输入进入你的代码处理的任何 XML。一些现代库会检查这种类型的攻击,但你应该检查是否需要依赖它。如果你不需要 XML 外部实体,那么通过将它们从输入中排除或禁用此类声明的处理来防范这种攻击。
缓解注入攻击
就像各种类型的注入攻击依赖于使用不受信任的输入来影响在应用程序上下文中执行的语句或命令一样,这些问题的缓解措施也有共同点,尽管具体细节有所不同。输入验证始终是一个很好的第一道防线,但根据可允许的输入内容,这单独并不一定足够。
避免将不可信的数据插入到构造的字符串中执行,例如作为命令。现代的 SQL 和其他可能遭受注入攻击的功能库应该提供助手函数,允许你将数据与命令分开传递。这些函数会处理引用、转义,或执行其他必要操作,以安全地执行所有输入的预定操作。我建议在库的文档中查找关于安全性的具体说明,因为确实存在一些草率的实现方式,它们只是将字符串拼接在一起,在 API 的外表下容易受到注入攻击。如果有疑虑,安全测试用例(见第十二章)是检查这一点的好方法。
如果你不能,或者不愿意,使用安全库——尽管我再次提醒不要陷入“有什么可能出错呢?”的滑坡思维——首先考虑找到一种替代方法,以避免注入的风险。例如,不要构造*nix ls命令来列出目录内容,而是使用系统调用。这样做的理由很明确:readdir(3)唯一可能做的就是返回目录条目信息;相反,调用 Shell 命令则可能执行任何操作。
在某些情况下,将文件系统用作自制数据存储可能是最快的解决方案,但我几乎不能推荐它作为一个安全的方法。如果你坚持走风险路线,请不要低估预测并阻止所有潜在攻击所需的工作,以确保完全安全。输入验证是你的朋友;如果你能将字符串限制为安全字符集(例如,只包含 ASCII 字母数字的名称),那么可能就没问题。作为额外的防御层,研究你正在构建的命令或语句的语法,并确保应用所有必要的引用或转义,以确保不会出错。仔细阅读适用的规范是值得的,因为可能会有你未曾注意到的隐晦形式。
好消息是,危险操作中,注入成为风险的地方通常在源代码中很容易扫描到。检查 SQL 命令是否使用参数安全地构造,而不是作为临时字符串构造。对于 Shell 命令注入,留意exec(3)及其变体的使用,并确保正确引用命令参数(Python 提供了shlex.quote来专门处理这个问题)。在 JavaScript 中,检查eval的使用,或者安全地限制它,或者考虑在不受信任的输入可能影响构造的表达式时,根本不使用它。
本章介绍了多种注入攻击及相关的常见漏洞,但注入是一种非常灵活的方法,可以以多种方式出现。在接下来的章节中,我们将再次看到它(两次),在网络漏洞的背景下。* *# 网络安全
当这些文字出现时,大家都说它们是一个奇迹。但没有人指出,整个网络本身才是一个奇迹。
—E. B. White(摘自夏洛的网)

万维网的巨大成功,很大程度上归功于一个显著的事实(如今,这已被视为理所当然):无数人每天都在使用它,却对其工作原理一无所知。这个对于如此复杂的技术集合的单一成就,既是祝福也是诅咒。毫无疑问,网络的易用性促进了其广泛的增长。另一方面,保障一个由无数独立数字服务组成的全球网络,且这些服务被无数对其毫无了解的用户使用,确实是一个极其困难的任务。安全性或许是这个复杂问题中最难的一部分。
使安全性尤其具有挑战性的一个复杂因素是,早期的网络设计相当天真,几乎没有考虑到安全性。因此,现代网络是标准长期演变的产物,受竞争性的“浏览器大战”和向后兼容性的制约。简而言之,网络是历史上最极端的“事后补充安全性”案例——尽管如今,距其发明已有超过四分之一世纪,我们所拥有的网络安全性已经变得相当可敬。
然而,尽管现代网络可以变得安全,但由于其错综复杂的历史,它仍然非常脆弱,并充满了许多“安全和隐私的不完美之处”,正如 RFC 6265 的作者——该规范为网页 Cookies——所形容的那样。软件专业人员需要理解这一切,以免在为网络构建时遇到这些问题。哪怕是微小的失误也能轻易造成漏洞。鉴于互联网的“荒野西部”性质,恶意行为者可以自由地探查网站的工作方式,并匿名寻找攻击的切入点。
本章重点介绍了网络安全模型如何演变的基础知识,以及正确和错误的使用方式。漏洞源于细节,安全网站必须精确处理许多方面。我们将涵盖网络安全的所有基础知识,从呼吁在安全框架上构建开始,框架为你处理复杂细节。接下来,我们将看到如何通过安全通信(HTTPS)、正确使用 HTTP 协议(包括 Cookies)以及同源策略(Same Origin Policy)来保护网站安全。最后,我们将讨论两种特定于网页的主要漏洞(XSS 和 CSRF),并讨论其他一些缓解措施,结合起来将大大提高现代网站服务器的安全性。尽管如此,本章绝不是网络安全的完整汇编,网络安全的具体内容非常庞大,且快速发展。
这里的目标是传达对常见陷阱的整体理解,以便你能识别并知道如何应对它们。Web 应用程序也会受到本书其他地方提到的众多漏洞的影响:本章的重点不应被解读为这些是唯一的安全隐患。
基于框架构建
使用设计作为框架,将混乱中带出秩序。
—Nita Leland
得益于现代 web 开发工具,构建一个网站已经几乎和使用网站一样容易了。我对构建安全网站的主要建议是:依赖一个高质量的框架,永远不要覆盖它提供的安全措施,让有能力的专家处理所有复杂的细节。
依赖一个稳固的框架应该能使你免受接下来部分所提到的漏洞影响,但理解框架到底能做什么和不能做什么,依然非常重要,这样你才能有效使用它。选择一个安全的框架从一开始就至关重要,因为你的代码将会严重依赖它,如果它失败了,后期切换会非常痛苦。你如何知道一个 web 框架是否真的安全?这归结为信任——对其开发者的良好意图和专业知识的信任。
Web 框架的流行程度和热度变化几乎和巴黎时尚一样迅速,你的选择将取决于许多因素,因此我不会尝试做推荐。然而,我可以建议一些你在评估时可以考虑的通用指南:
-
选择一个由值得信赖的组织或团队开发并维护的框架,以便跟上不断变化的 web 技术和实践。
-
在文档中查找明确的安全声明。如果没有找到,我会排除该框架。
-
研究过去的表现:框架不需要完美的记录,但响应缓慢或持续出现的问题模式是红旗。
-
构建一个小型原型,并检查生成的 HTML 是否进行了适当的转义和引用(使用本章示例中的输入)。
-
构建一个简单的测试环境,进行基本的 XSS 和 CSRF 攻击实验,如本章后续所述。
Web 安全模型
我有点高兴网络在某种程度上是完全无政府状态的。这对我来说没问题。
—Roger Ebert
网络是一个客户端/服务器技术,理解它的安全模型需要同时考虑这两种视角。这样做会很快变得有趣,因为双方的安全利益常常相互冲突,尤其是考虑到潜在攻击者通过互联网入侵的威胁。
以典型的在线购物网站为例,这里涉及的安全原则在某种程度上适用于所有种类的网络活动。为了开展业务,商家和客户必须在一定程度上相互信任,而在绝大多数情况下,确实会发生这种信任。然而,不可避免地有一些坏行为者,因此网站不能完全信任每个客户端,反之亦然。以下几点突出了商家和客户之间暂时互信的一些细微差别。
这是商家的基本要求:
-
其他网站不应该干扰我的客户互动。
-
我希望最大程度减少竞争对手抓取我的产品和库存细节的能力,同时能够为合法客户提供有用的信息。
-
客户不应该能够操控价格或订购没有库存的产品。
这是一些客户的要求:
-
我需要确保我访问的网站是可信的。
-
我要求确认在线支付是安全的。
-
我希望商家能够保持我的购物活动私密。
显然,双方必须保持警惕,才能让网络正常运作。话虽如此,客户对商家有许多期望。解决困惑或轻信客户的难题超出了本章节的范围,如果这件事甚至可能解决的话。相反,在 web 安全领域,我们侧重于从商家的角度保障网站安全。网络只有在服务器能够提供良好的安全性时才能正常工作,这使得诚实的终端用户有机会获得安全的网络体验。商家不仅要决定他们可以信任客户的程度,还要直觉地判断客户可能会信任他们的程度。
网络安全模型的另一个奇怪方面是客户端浏览器的角色。设计 web 服务是一项挑战,因为它们需要与完全无法控制的浏览器互动。一个恶意的客户端可以轻松使用经过修改的浏览器进行任何操作。或者,一个粗心的客户端可能正在运行一个充满安全漏洞的古老浏览器。即使 web 服务器试图限制客户端使用的浏览器类型为某些版本,请记住,浏览器可以轻松伪装自己以绕过这些限制。值得庆幸的是,诚实的客户端希望使用安全的浏览器并定期更新它们,因为这能保护他们自己的利益。最重要的是,只要服务器保持安全,一个恶意的客户端就无法干扰其他客户端所收到的服务。
web 服务器对潜在不可信的客户端浏览器的过度信任是许多 web 安全漏洞的根源。我强调这一点,冒着重复的风险,因为这个问题是如此容易且常常被忽视(正如我将在本章中解释的那样)。
HTTP 协议
任何认为协议不重要的人,显然从未与猫打过交道。
——罗伯特·A·海因莱因
HTTP 协议本身是 web 的核心,所以在我们深入讨论 web 安全之前,简要回顾一下它是如何工作的非常值得。这个高度简化的解释作为余下安全讨论的概念框架,我们将专注于安全如何介入的部分。对于许多人来说,网页浏览已经成为日常生活中如此普遍的活动,因此值得退后一步,仔细思考整个过程的所有步骤——许多步骤我们几乎没有注意到,因为现代处理器和网络通常能提供快速的响应。
网页浏览总是从统一资源定位符(URL)开始。以下示例展示了它的组成部分:
http://www.example.com/page.html?query=value#fragment
方案在冒号之前,指定浏览器必须使用的协议(在这里是http)来请求所需的资源。基于 IP 的协议以//开始,后面跟着主机名,对于网页来说,就是 web 服务器的域名(在这个例子中是www.example.com)。其余部分都是可选的:/后跟路径,?后跟查询,以及#后跟片段。路径指定了浏览器请求的网页。查询允许网页内容参数化。例如,当在网页上搜索某个内容时,搜索结果的 URL 路径可能是/search?q=something。片段标识页面内的二级资源,通常是作为链接目标的锚点。总之,URL 指定了如何以及在何处请求内容,网站上的具体页面,定制页面的查询参数,以及命名页面中特定部分的方法。
当你给浏览器提供一个 URL 时,浏览器需要做很多工作来显示网页。首先,它查询域名系统(DNS)以获取主机名的 IP 地址,从而知道该向何处发送请求。请求包含 URL 路径和其他作为请求头编码的参数(包括任何 cookie、用户的首选语言等),这些都被发送到 web 服务器主机。服务器返回的响应包含一个状态码和响应头(这些可能设置 cookie,以及其他许多内容),接着是包含网页 HTML 的内容体。对于所有嵌入的资源,如脚本、图片等,这个请求/响应过程会重复,直到内容完全加载并显示出来。
现在让我们看看网络服务器必须正确执行的操作,以保持安全。有一个重要的细节尚未提及,那就是请求指定了HTTP 动词。在这里,我们将专注于两个最常见的动词。GET请求从服务器获取内容。相比之下,客户端使用POST动词来发送表单提交或文件上传。GET 请求显式地不会改变状态,而 POST 请求则打算改变服务器的状态。尊重这种语义上的区分非常重要,正如我们在讨论 CSRF 攻击时将看到的那样。现在,记住尽管客户端指定了要使用的请求动词,但服务器才是决定如何处理该请求的主体。此外,通过在页面上提供超链接和表单,服务器实际上引导客户端进行后续的 GET 或 POST 请求。
一些挑剔的人会指出,确实可以运行一个响应 GET 动词请求而改变状态的服务器,并且反常地拒绝通过表单 POST 提交来改变状态。但如果严格遵守标准规则,确保服务器安全将变得容易得多。可以这样想:是的,的确可以翻越标有“禁止通行!”的围栏,在悬崖边缘走路而不掉下来,但这样做无谓地把你的安全置于危险之中。
一个相关的安全禁忌是将敏感数据嵌入 URL 中;相反,应该使用表单 POST 请求将数据发送到服务器。否则,REFERER头可能会泄露导致请求的网页 URL,从而暴露数据。例如,点击一个网页上的链接,URL 为https://example.com?param=SECRET,它会使用 GET 请求导航到目标链接,而REFERER头包含包含SECRET的 URL,从而泄露了秘密数据。此外,日志或诊断信息也可能会泄露 URL 中包含的数据。虽然服务器可以使用Referrer-Policy头来阻止这种情况,但它们必须依赖客户端遵守这一点——这并不是一个完美的解决方案。(REFERER头在规范中确实拼写错误,所以我们只能这样接受,但政策名称拼写是正确的。)
一个容易犯的错误是将用户名包含在 URL 中。即使是像用户名哈希这样的模糊标识符,也会泄露信息,因为它可能允许窃听者匹配两个分别观察到的 URL,并推断它们指向同一个用户。
数字证书与 HTTPS
如果传达的内容是错误的,那就很难称之为沟通。
—本杰明·梅斯
安全浏览网页的第一个挑战是可靠地与正确的服务器进行通信。为此,你必须知道正确的 URL 并查询提供正确 IP 地址的 DNS 服务。如果网络能够正确路由并传输请求,它应该能够到达目标服务器。要做到这一点,涉及到很多因素,而这些因素都可能成为攻击的切入点:恶意行为者可能会干扰 DNS 查找、路由过程或任何沿途的数据。假如请求被引导到一个恶意服务器,用户可能根本不会察觉;因为建立一个看似相同的网站并不难,几乎可以轻易欺骗任何人。
HTTPS 协议(也称为HTTP over TLS/SSL)是专门为减轻这些威胁而量身定制的。HTTPS 使用了第五章中介绍的许多技术来保障网络安全。它提供了一个安全的端到端防篡改加密通道,同时也向客户端保证,所请求的服务器确实存在于该通道的另一端。可以将这个安全通道看作是一个防篡改的管道,用于确认服务器的身份。窃听者可能看到加密的数据,但没有密钥,这些数据无法与随机比特区分开。攻击者也许能够在未保护的网络上篡改数据,但如果使用了 HTTPS,任何篡改都会被检测到。攻击者可能会通过物理切断电缆来阻止通信,但你可以放心,虚假的数据永远无法传递通过。
没有人质疑为了确保网页上的金融交易安全,HTTPS 是必需的,但许多大型网站推迟了全面启用 HTTPS 的时间。(例如,Facebook 直到 2013 年才实施。)在最初的实现阶段,协议存在一些微妙的缺陷,而当时硬件的计算能力也不足以支持其广泛应用。好消息是,随着时间的推移,开发者修复了这些漏洞并优化了协议。得益于协议优化、更高效的加密算法和更快的处理器,今天的 HTTPS 既快速又稳健,正在迅速走向普及。它广泛用于保护私人数据通信,但即使是仅提供公共信息的网站,使用 HTTPS 也非常重要,以确保真实性和强大的完整性。换句话说,它可以确保客户端正在与请求 URL 中指定的真实服务器进行通信,并且在它们之间传输的数据没有被窃听或篡改。今天,很难想出任何合理的理由不配置网站只使用 HTTPS。不过,仍然有许多不安全的 HTTP 网站。如果你使用这些网站,请记住,HTTPS 的优良安全特性并不适用,并且要采取适当的预防措施。
准确理解 HTTPS 在保护客户端/服务器交互中所做的(以及未做的)工作,对于理解它的价值、它如何帮助以及它能做和不能做的事情至关重要。除了确保服务器的真实性和 web 请求及响应内容的机密性与完整性外,安全通道还保护了 URL 路径(请求头的第一行——例如,GET /path/page.html?query=secret#fragment),防止任何窃听者看到客户端请求了网站的哪个页面。(HTTPS 也可以选择验证客户端身份。)然而,HTTPS 流量本身仍然可以在网络上被观察到,并且由于端点的 IP 地址未被保护,窃听者通常可以推断出服务器的身份。
表格 11-1 比较了 HTTP 和 HTTPS 的安全属性,重点在于攻击者在客户端/服务器通信的两个端点之间潜伏时的能力。
表格 11-1:HTTP 与 HTTPS 安全属性
| 攻击者能否… | HTTP | HTTPS |
|---|---|---|
| 查看客户端/服务器端点之间的 web 流量? | 是 | 是 |
| 确定客户端和服务器的 IP 地址? | 是 | 是 |
| 推断 web 服务器的身份? | 是 | 有时(见下文注释) |
| 查看请求了网站中的哪一页面? | 是 | 否(加密头信息中) |
| 查看网页内容和 POST 请求的主体? | 是 | 否(加密) |
| 查看头信息(包括 cookies)和 URL(包括查询部分)? | 是 | 否 |
| 修改 URL、头信息或内容? | 是 | 否 |
随着 HTTPS 和技术环境的成熟,广泛采用的最后障碍是获取服务器证书的开销。大型公司能够承担受信任证书颁发机构收取的费用,并有员工管理续期过程,而小型网站的所有者则对额外的成本和行政开销感到犹豫不决。到 2015 年,HTTPS 已经成熟,大多数互联网连接硬件的处理速度足以应对 HTTPS,且随着人们对网络隐私重要性认识的迅速增长,互联网社区逐渐达成共识,认为需要保护大部分的 web 流量。免费且简便的服务器证书的缺乏被证明是剩余的最大障碍。
多亏了电子前沿基金会的强力推广和各行业公司广泛的赞助,Let’s Encrypt——一个非营利性互联网安全研究小组的产品,提供了一个免费的、自动化的、开放的证书颁发机构。它为任何网站所有者提供免费的域名验证(DV)证书。以下是 Let’s Encrypt 工作原理的简化说明。请记住,以下过程在实践中是自动化的:
-
通过生成密钥对并发送公钥向 Let’s Encrypt 进行身份验证。
-
查询 Let’s Encrypt,询问你需要做什么来证明你控制该域名。
-
Let’s Encrypt 提供了一个挑战,例如为域名提供指定的 DNS 记录。
-
通过创建请求的 DNS 记录并请求 Let’s Encrypt 验证你所做的操作,你可以完成挑战。
-
一旦验证通过,生成的密钥对所属的私钥将被 Let’s Encrypt 授权用于该域名。
-
现在,你可以通过向 Let’s Encrypt 发送一个由授权私钥签名的请求来申请新的证书。
Let’s Encrypt 发放 90 天的 DV 证书,并提供一个“certbot”来处理自动续期。通过提供可自动续期的证书作为免费服务,今天的安全 Web 服务已广泛成为一个即插即用的解决方案,无需额外费用。2020 年,HTTPS 占 Web 流量的比例超过 85%,是 2016 年 Let’s Encrypt 启动时的 40%水平的两倍还多。
DV 证书通常就足以证明你网站的身份。DV 证书仅验证经过认证的 Web 服务器的域名,仅此而已。也就是说,example.com 证书仅会发放给 example.com Web 服务器的所有者。相比之下,提供更高信任级别的证书,如组织验证(OV)和扩展验证(EV)证书,不仅验证网站的身份,还在某种程度上验证所有者的身份和声誉。然而,随着免费的 DV 证书的普及,其他类型证书是否仍然具有可行性变得越来越不明确。很少有用户关心这些信任区分,而 OV 和 EV 证书的技术和法律细节很微妙。除非你是律师,否则它们的具体好处很难理解。
一旦你设置了你的 web 服务器使用 HTTPS 协议并配置了证书,你必须确保它始终使用 HTTPS。为了确保这一点,你必须拒绝降级攻击,这种攻击试图迫使通信使用弱加密或不加密。这些攻击有两种方式。在最简单的情况下,攻击者试图将 HTTPS 请求更改为 HTTP(这种方式可以被监听和篡改),而配置不当的 web 服务器可能会被欺骗而配合。另一种方法则利用 HTTPS 协议的选项,允许双方为加密通道协商密码套件。例如,服务器可能能够“讲”一套加密“方言”,而客户端则可能“讲”另一套,因此,双方需要事先就其中一种都能支持的选项达成一致。这个过程为攻击者提供了机会,攻击者可能会欺骗双方选择一个不安全的选项,从而破坏安全性。
最好的防御方法是确保你的 HTTPS 配置只使用安全的现代加密算法。判断哪些密码套件是安全的非常技术性,最好交给加密专家来做。你还必须在避免排除或降低旧版和性能较弱客户端的体验之间找到平衡。如果你没有可靠的专家建议,可以查看主要信任网站的做法并跟随它们。简单地认为默认配置永远是安全的,是失败的保障。
通过始终将 HTTP 重定向到 HTTPS,并将 Web cookies 限制为仅通过 HTTPS 访问,可以减少此类攻击的发生。同时,在响应的 HTTP 头部中包括Strict-Transport-Security指令,让浏览器知道该网站始终使用 HTTPS。为了确保 HTTPS 网页的安全性,它必须完全使用 HTTPS。这意味着服务器上的所有内容都应该使用 HTTPS,包括所有的脚本、图片、字体、CSS 以及其他引用的资源。如果不采取所有必要的预防措施,安全保护会大打折扣。
同源策略
疑问是智慧的源泉。
—勒内·笛卡尔
浏览器通过将不同网站的资源(通常是窗口或标签页)进行隔离,防止它们相互干扰。这一规则称为同源策略(SOP),只有当资源的主机域名和端口号匹配时,才允许它们之间进行交互。同源策略起源于网络的早期,并且随着 JavaScript 的出现而变得必要。网页脚本通过文档对象模型(DOM)与网页进行交互,DOM 是一个结构化的对象树,代表浏览器窗口及其内容。如果任何网页都可以通过脚本来window.open其他网站,并且能够任意操作内容,显然会带来无数问题。因此,最初实施的限制——包括对人们多年发现的规避方法的修复——最终演变成了今天的同源策略。
同源策略适用于脚本和 cookies(还有一些额外的变化),它们可能会在独立的网站之间泄露数据。然而,网页可以包含来自其他网站的图片和其他内容,比如网页广告。这是安全允许的,因为这些内容无法访问它们所在窗口的内容。
尽管同源策略防止来自其他网站页面的脚本访问,但网页总是可以选择访问其他网站,如果它们愿意,可以将其内容加载到窗口中。网页包含来自其他网站的内容是很常见的,像是展示图片、加载脚本或 CSS 等。引入来自其他网站的任何内容都是一个重要的信任决策;然而,这样做使得网页容易受到可能来自这些网站的恶意内容攻击。
Web Cookies
当事情变得艰难时,艰难的人会做出 cookies。
—厄尔玛·博姆贝克
Cookies 是服务器要求客户端代表其存储的小数据字符串,并在随后的请求中将其返回给服务器。这一巧妙的创新使得开发人员能够轻松地为特定客户端定制网页。服务器响应可能会将命名的 cookies 设置为某个值。然后,在 cookies 过期之前,客户端浏览器会在随后的请求中发送适用于给定页面的 cookies。由于客户端保留自己的 cookies,服务器不必识别客户端即可将 cookie 值与其绑定,因此该机制有可能保护隐私。
这里有一个简单的类比:如果我经营一家商店,并想计算每个顾客访问的次数,一种简单的方法是给每个顾客一张写着“1”的纸条,要求他们下次来时带回这张纸条。然后,每次顾客回来时,我拿到他们的纸条,数字加一,再还给他们。只要顾客遵守规则,我就不需要做任何账务处理,甚至不需要记住他们的名字,就能准确地统计。
我们在网页上使用 cookies 来做各种事情,其中最具争议的是跟踪用户。Cookies 通常用于建立安全会话,这样服务器就能可靠地区分所有客户端。为每个新客户端生成一个独特的会话 cookie,允许服务器通过请求中出现的 cookie 来识别客户端。
尽管任何客户端都可以篡改自己的 cookies 并伪装成一个不同的会话,但如果会话 cookie 设计得当,客户端不应该能够伪造一个有效的会话 cookie。此外,客户端可能会将其 cookie 的副本发送给其他方,但这样做只会损害自己的隐私。这种行为不会威胁到无辜的用户,相当于分享自己的密码。
假设有一个在线购物网站,将顾客购物车的当前内容以商品列表和总金额的形式存储在 cookies 中。没有任何东西可以阻止一个聪明而不道德的购物者修改本地的 cookie 存储。例如,他们可能会将一件贵重商品的价格更改为极低的金额。这并不意味着 cookies 是无用的;cookies 可以用来记住顾客的偏好、喜爱的商品或其他细节,篡改这些信息不会对商家造成损害。这只是意味着,你应该始终以“信任但验证”的原则使用客户端存储。如果这有用,可以将商品价格和购物车总额存储为 cookies,但在接受交易之前,务必在服务器端验证每个商品的价格,拒绝任何被篡改的数据。这个例子使问题一目了然。然而,其他形式的同样信任错误更加微妙,攻击者经常利用这种漏洞。
现在让我们从客户端的角度看这个相同的例子。当两个人使用一个在线购物网站并浏览到相同的/mycart网址时,他们会看到不同的购物车,因为他们有不同的会话。通常,唯一的 cookies 会建立独立的匿名会话,或者对于已登录用户,识别特定账户。
服务器设置带有过期时间的会话 cookies,但由于它们不能总是依赖客户端来尊重这个愿望,它们还必须强制执行会话 cookies 的有效期限制,这些 cookies 需要更新。(从用户的角度来看,这种过期表现为在一段时间不活动后被要求重新登录。)
Cookies 受限于同源策略(SOP),并明确规定了在子域名之间共享的条款。这意味着由example.com设置的 cookies 对cat.example.com和dog.example.com的子域名可见,但在这些子域名上设置的 cookies 彼此隔离。此外,尽管子域名可以看到父域名设置的 cookies,但不能修改它们。类比而言,州政府依赖国家级凭证(如护照),但无法签发这些凭证。在同一个域名内,cookies 还可以通过路径进一步限定(但这不是一种强安全机制)。表 11-2 详细说明了这些规则。此外,cookies 还可以指定Domain属性以便进行明确的控制。
表 11-2:同源策略(SOP)与子域名下的 cookie 共享
| 下面的主机所提供的网页是否. . . | . . .能看到为这些主机设置的 cookies 吗? |
|---|---|
| example.com | dog.example.com |
| --- | --- |
| example.com | 是(相同域名) |
| dog.example.com | 是(父域名) |
| cat.example.com | 是(父域名) |
| example.org | 否(SOP) |
脚本名义上可以通过 DOM 访问 cookies,但这个便利性会给恶意脚本提供一个可趁之机,若它能在网页中运行,则可能窃取 cookies,因此最好通过指定httponlycookie 属性来阻止脚本访问。HTTPS 网站还应应用secure属性,指示客户端仅通过安全通道发送 cookies。不幸的是,由于涉及的遗留限制过多,无法在此详细讨论,即使使用这两个属性,也仍然会出现完整性和可用性问题(有关详细信息,请参见 RFC 6265)。我提到这个不仅仅是作为警告,还作为一个网络安全中反复出现的模式的经典示例;向后兼容性与现代安全使用之间的紧张关系导致了妥协解决方案,这说明了如果从一开始就没有内建安全性,它往往会变得难以实现。
HTML5 为安全模型添加了许多扩展。一个典型的例子是跨源资源共享(CORS),它允许有选择地放宽同源策略的限制,以便其他受信任的网站可以访问数据。浏览器还提供了 Web Storage API,这是一种更现代的客户端存储能力,供 Web 应用使用,同样也受到同源策略的限制。这些新特性在安全方面设计得更好,但仍不能完全替代 Cookies。
常见的网页漏洞
网站应该在内外看起来都很好。
—保罗·库克森
现在我们已经了解了网站建设和使用中的主要安全亮点,是时候谈论一些常见的特定漏洞了。Web 服务器容易受到各种安全漏洞的威胁,包括本书其他部分讨论的许多漏洞,但在本章中,我们将重点关注与 Web 相关的安全问题。前面的章节解释了 Web 安全模型,包括许多潜在的避免削弱安全性的方法以及有助于更好保护你的网站的实用功能。即使假设你做对了所有这些事情,本节将继续介绍 Web 服务器可能出现的其他错误以及它们的漏洞。
第一个类别的网页漏洞,也可能是最常见的,就是跨站脚本攻击(XSS)。我们将在这里讨论的另一个漏洞,可能是我最喜欢的,因为它的隐蔽性:跨站请求伪造(CSRF)。
跨站脚本攻击
我不允许自己在网上“冲浪”,否则我可能会淹死。
—奥布里·普拉扎
同源策略提供的隔离对于构建安全网站至关重要,但如果我们没有采取必要的预防措施,这种保护很容易被突破。跨站脚本攻击(XSS)是一种特定于网页的注入攻击,恶意输入会改变网站的行为,通常导致未经授权的脚本被执行。
让我们考虑一个简单的例子,看看它是如何工作的,以及为什么必须保护自己免受攻击。攻击通常始于无辜的用户已经登录到一个可信网站。然后用户打开另一个窗口或标签页开始浏览,或者可能不小心点击了一封电子邮件中的链接,浏览到了一个攻击网站。攻击者通常旨在劫持用户在目标网站上的认证状态。他们甚至可以在没有打开受害网站标签页的情况下做到这一点,只要 Cookies 仍然存在(这就是为什么完成网上银行操作后最好登出)。让我们看看受害网站中的 XSS 漏洞是什么样子的,如何利用它,最后如何修复它。
假设出于某种原因,受害网站的某一页面(www.example.com)希望以几种不同的颜色渲染一行文本。开发者选择在 URL 查询参数中指定所需的颜色,而不是构建多个仅在该行颜色上有所不同的相同页面。例如,具有绿色文本一行的网页版本的 URL 可能是:
https://www.example.com/page?color=`green`
然后服务器将高亮的查询参数插入到以下 HTML 片段中:
<h1 style="color:`green`">This is colorful text.</h1>
如果正确使用,这段代码是完全没问题的,这也是这些漏洞容易被忽视的原因。要看到问题的根本,必须查看负责处理此任务的服务器端 Python 代码(以及一些巧妙的思维):
脆弱的代码
query_params = urllib.parse.parse_qs(self.parts.query)
color = query_params.get('color', ['black'])[0]
h = '<h1 style="color:%s">This is colorful text.</h1>' % color
第一行解析 URL 查询字符串(问号后的部分)。下一行提取color参数,若未指定,则默认为黑色。最后一行构建 HTML 片段,显示带有相应字体颜色的文本,使用内联样式来处理一级标题标签(<h1>)。然后,变量h构成了 HTML 响应的一部分,包含在网页中。
你可以在最后一行找到 XSS 漏洞。这里,程序员创建了一个路径,从 URL 的内容(在互联网上,任何人都可以发送到服务器)直接进入客户端的 HTML 内容。这是第十章中熟悉的注入攻击模式,构成了未受保护的信任边界跨越,因为参数输入字符串现在已被嵌入到网页 HTML 内容中。单单这一点就足以引起警觉,但要看清楚这个 XSS 漏洞的全部维度,我们需要尝试利用它。
攻击需要一点想象力。回顾一下<h1> HTML 标签,并考虑其他可能的替代高亮的颜色名称。跳出框框思考,或者在这个例子中,跳出双引号字符串style="color:green"。或者你能完全突破<h1>标签吗?这里有一个网址可以说明我所说的“突破”:
https://www.example.com/page?color=`orange"><SCRIPT>alert("Gotcha!")</SCRIPT><span%20id="dummy`
所有这些高亮的内容都会像之前一样,按要求插入到<h1> HTML 标签中,产生完全不同的结果。
在实际的 HTML 中,这段代码会呈现为一行,但为了可读性,我在这里进行了缩进,显示它是如何解析的:
<h1 style="color:`orange">`
`<SCRIPT>alert("Gotcha!")</SCRIPT>`
`<span id="dummy`">This is colorful text.
</h1>
新的 <h1> 标签是语法上的,指定了橙色。然而,请注意,攻击者提供的 URL 参数值供应了闭合的尖括号。这并非仅仅是为了好看:攻击者需要关闭 <h1> 标签,以便创建一个格式正确的 <SCRIPT> 标签并将其注入到 HTML 中,从而确保脚本能够执行。在这种情况下,脚本会弹出一个警告对话框——这是一个无害但明显的漏洞证明。在闭合的 </SCRIPT> 标签之后,剩余的注入内容仅仅是填充物,用来掩盖篡改行为。新的 <span> 标签具有 id 属性,仅仅是为了让后续的双引号和闭合尖括号作为 <span> 标签的一部分。浏览器通常会在缺失时自动提供闭合的 </span> 标签,因此被利用的页面依然是格式正确的 HTML,这使得修改对用户来说是不可见的(除非他们检查 HTML 源代码)。
为了远程攻击受害者,攻击者还需要做更多工作,以便让受害者访问恶意 URL。像这样的攻击通常只有在用户已经登录目标网站时才有效——也就是说,只有在存在有效的登录会话 cookie 时。否则,攻击者可以直接将 URL 输入自己的浏览器。攻击者真正想要的是你的网站会话,这可能显示你的银行余额或私人文件。一旦攻击者定义了一个恶意脚本,它将立即加载额外的脚本,并继续窃取数据或在用户的上下文中进行未授权的交易。
XSS 漏洞对于攻击者来说并不难发现,因为他们可以轻松查看网页的内容,了解 HTML 的内部工作原理。(准确来说,他们无法看到服务器上的代码,但通过尝试不同的 URL 并观察结果网页,他们很容易推断出网页的工作方式。)一旦他们注意到 URL 注入到网页中,他们就可以进行快速测试,比如这里展示的例子,检查服务器是否容易受到 XSS 攻击。此外,一旦他们确认 HTML 元字符,如尖括号和引号,从 URL 查询参数(或可能是其他攻击面)流入结果网页,他们可以查看页面的源代码并调整他们的尝试,直到成功注入恶意代码。
XSS 攻击有几种类型。本章的例子是一种 反射型 XSS 攻击,因为它是通过 HTTP 请求发起,并在服务器的即时响应中体现。相关的形式是 存储型 XSS 攻击,涉及两个请求。首先,攻击者以某种方式将恶意数据存储在服务器或客户端存储中。一旦设置完成,随后的请求会欺骗 Web 服务器将存储的数据注入到随后的请求中,从而完成攻击。存储型 XSS 攻击可以跨不同客户端生效。例如,在博客上,如果攻击者能发布导致评论渲染时触发 XSS 的评论,那么随后的用户查看网页时将会获取到恶意脚本。
第三种攻击形式,称为 基于 DOM 的 XSS,使用 HTML DOM 作为恶意注入的源,但其工作原理与前两种攻击相似。不论分类如何,关键在于,这些漏洞都来源于将不受信任的数据注入 Web 服务器,允许它流入网页,进而引入恶意脚本或其他有害内容。
一个安全的 Web 框架应该内置 XSS 保护,在这种情况下,只要你待在框架内,就应该是安全的。和任何注入漏洞一样,防御措施要么是避免任何不受信任的输入流入网页并可能突破,或者是进行输入验证,确保输入会被安全处理。在有色文本的例子中,前者的技巧可以通过简单地提供命名网页(例如 /green-page 和 /blue-page)而不使用复杂的查询参数来实现。或者,若 URL 中包含颜色参数,可以将查询参数的值限制在允许的白名单内。
跨站请求伪造
我们无法将蜘蛛网的形式与它的起源方式分开。
—Neri Oxman
跨站请求伪造(CSRF,有时也叫 XSRF)是一种针对相同来源策略基本限制的攻击。这些攻击所利用的漏洞概念上很简单,但极其微妙,因此问题的具体所在以及如何修复它,刚开始时可能很难发现。Web 框架应该提供 CSRF 保护,但对底层问题的深入理解仍然很有价值,这样你才能确认它是否有效,并确保不会干扰该机制。
网站当然可以并且通常会包含内容,比如通过 HTTP GET 从不同网站获取的图片。相同来源策略允许这些请求,同时隔离内容,确保图像数据不会在不同域名的不同网站之间泄露。例如,站点 X 可以在其页面中嵌入来自站点 Y 的图片;用户会看到嵌入的图片作为页面的一部分,但站点 X 自身无法“看到”该图片,因为浏览器通过 DOM 阻止了脚本对图像数据的访问。
但是,Same Origin Policy 对 POST 和 GET 请求的工作方式是相同的,而 POST 请求可以修改网站的状态。具体发生的事情是:浏览器允许网站 X 向网站 Y 提交表单,并且也会包含 Y 的 cookies。浏览器确保来自 Y 网站的响应与 X 网站完全隔离。威胁在于,POST 请求可以修改 Y 服务器上的数据,而 X 不应该能够做到这一点,设计上,任何网站都可以向任何其他网站发起 POST 请求。由于浏览器会促进这些未经授权的请求,网页开发者必须显式地防范这些修改服务器数据的尝试。
一个简单的攻击场景将展示 CSRF 漏洞是什么样的、如何利用这些漏洞,进而如何防御攻击。假设有一个社交网站 Y,许多用户每个人都有账户。网站 Y 正在进行投票,每个用户只能投一票。该网站会在投票页面为每个认证用户设置一个独特的 cookie,然后只接受每个用户的一次投票。
投票页面上发布的评论写道:“投票前请先查看这个!”并链接到另一个网站 X 的页面,提供关于如何投票的建议。许多用户点击了链接并阅读了页面。由于 Same Origin Policy 的保护,可能会出什么问题呢?
如果你还没看到问题,给你一个大提示:想一想 X 网站窗口里可能发生了什么。假设网站 X 是由一些邪恶且狡猾的作弊者运营,他们试图窃取投票。当用户浏览 X 网站时,该页面上的脚本会在用户的浏览器上下文中(使用他们来自 Y 的 cookies)提交网站所有者希望的投票给社交网站。
由于网站 X 被允许使用每个用户的 Y cookies 提交表单,这足以窃取投票。攻击者只需要在服务器上执行状态更改,他们不需要看到确认用户投票的响应页面,而这正是 Same Origin Policy 阻止的内容。
为了防止 CSRF,确保有效的状态更改请求是不可猜测的。换句话说,将每个有效的 POST 请求视为一个特殊的“雪花”,它仅在其预定用途的上下文中有效。一种简单的方法是在所有表单中包含一个秘密令牌作为隐藏字段,然后检查每个请求是否包含与给定网页会话相对应的秘密。创建和检查 CSRF 防护的秘密令牌时,涉及很多细节,因此值得深入探讨。一个好的 Web 框架应该会为你处理这些问题,但让我们来看一下这些细节。
以下是带有防 CSRF 秘密令牌的投票表单示例:
<form action="/ballot" method="post">
<label for="name">Voting for</label>
<input type="text" id="name" name="name" value=""/>
`<input type="hidden" name="csrf_token"`
`value="mGEyoi1wE6NBWCyhBN9IZdEmaJLQtrYxi0J23XuXR4o="/>`
<input type="submit" value="Vote"/>
</form>
隐藏的 csrf_token 字段不会出现在页面上,但会包含在 POST 请求中。该字段的值是 session cookie 内容的 SHA-256 哈希值的 base-64 编码,但任何每个客户端的秘密值都可以使用。以下是生成会话防 CSRF token 的 Python 代码:
def csrf_token(self):
digest = hashlib.sha256(self.session_id.encode('utf-8')).digest()
return base64.b64encode(digest).decode('utf-8')
该代码从会话 cookie(字符串值 self.session_id)中派生令牌,因此它对每个客户端都是唯一的。由于同源策略阻止站点 X 知道受害者站点 Y 的 cookie,因此 Y 的创建者无法构造一个满足这些条件的真实表单来执行 POST 操作并窃取投票。
Y 服务器上的验证代码简单地计算预期的令牌值,并检查传入表单中的相应字段是否与其匹配。以下代码通过在实际处理表单之前,如果令牌不匹配,则返回错误信息,从而防止 CSRF 攻击:
token = fields.get('csrf_token')
if token != self.csrf_token():
return 'Invalid request: Cross-site request forgery detected.'
有多种方法可以缓解 CSRF 攻击,但从会话 cookie 中派生令牌是一个不错的解决方案,因为所有进行检查所需的信息都会通过 POST 请求到达。另一个可能的缓解方法是使用 nonce——一种不可猜测的、一次性的令牌——但是为了防止 CSRF 攻击,你仍然需要将其与目标客户端会话绑定。这种解决方案包括为表单的 CSRF 令牌生成随机的 nonce,将令牌存储在按会话索引的表格中,然后通过查找该会话的 nonce 并检查是否匹配来验证表单。
现代浏览器支持 cookie 的 SameSite 属性来缓解 CSRF 攻击。SameSite=Strict 会阻止向页面上的任何第三方请求(到其他域)发送 cookie,这可以阻止 CSRF 攻击,但当导航到另一个期待其 cookie 的站点时,可能会破坏一些有用的行为。还有其他可用的设置,但不同浏览器品牌和旧版本的支持可能不一致。由于这是客户端的 CSRF 防御,完全依赖它对服务器来说可能有风险,因此应该将其作为额外的缓解措施,而非唯一防御。
更多漏洞与缓解措施
你只能通过越过界限才能知道界限在哪里。
—Dave Chappelle
总结:为了安全,你应该使用纯 HTTPS 构建网站,并使用高质量的框架。在你真正知道自己在做什么之前,不要覆盖框架提供的保护功能,这意味着要理解像 XSS 和 CSRF 这样的漏洞是如何产生的。现代网站通常会整合外部脚本、图像、样式等,你应该仅依赖你信任的资源,因为你让它们将内容注入到你的网页中。
自然,这并不是故事的结束,因为在将服务器暴露于 Web 时仍有很多方式会陷入麻烦。网站为公共互联网提供了一个巨大的攻击面,这些不可信的输入很容易触发服务器代码中的各种漏洞,例如 SQL 注入(Web 服务器通常使用数据库进行存储)以及其他所有漏洞。
还有一些其他值得注意的特定于 Web 的陷阱。以下是一些常见的附加问题(尽管这个列表并不详尽):
-
不要让攻击者将不受信任的输入注入到 HTTP 头部(类似 XSS 攻击)。
-
指定准确的 MIME 内容类型,以确保浏览器正确处理响应。
-
打开重定向可能是一个问题:不要允许重定向到任意 URL。
-
只嵌入你信任的网站使用
<IFRAME>。 (许多浏览器支持X-Frame-Options响应头的缓解措施。) -
在处理不受信任的 XML 数据时,要小心 XML 外部实体(XXE)攻击。
-
CSS
:visited选择器可能会泄露某个 URL 是否在浏览器历史记录中。
此外,网站应使用一个全新的功能——HTTP Content-Security-Policy 响应头,以减少暴露于 XSS 攻击的风险。它通过指定脚本或图像的授权来源(以及许多其他类似功能)来工作,从而允许浏览器阻止尝试注入内联脚本或其他来自其他域的恶意内容。现有许多浏览器,而浏览器对这一功能的兼容性仍然不一致,因此仅使用该响应头并不足以认为漏洞已完全修复。可以将其视为一道额外的防线,但由于它是客户端功能并且超出你的控制范围,因此不要将其视为对 XSS 完美免疫的“通行证”。
链接到不受信任的第三方网站可能会存在风险,因为浏览器可能会发送一个 REFERER 响应头,正如本章前面提到的,并将 window.opener 对象提供给目标页面。除非目标页面有用且可以信任,否则应该分别使用 rel="noreferrer" 和 rel="noopener" 属性来阻止这些行为。
对于大型现有网站,事后添加新的安全功能可能会让人望而却步,但有一种相对简单的方式可以朝着正确的方向前进。在测试环境中,在所有网页中添加限制性安全策略,然后测试网站并逐一跟踪被阻止的问题。如果你禁止从一个你知道是安全并且打算使用的站点加载脚本,通过逐步放宽脚本策略,你将很快找到正确的政策例外。通过自动化浏览器内测试,确保整个网站都经过测试,你应该能够以适度的努力取得显著的安全进展。
有许多 HTTP 响应头可以帮助你指定浏览器应该或不应该允许的内容,包括 Content-Security-Policy、Referrer-Policy、Strict-Transport-Security、X-Content-Type-Options 和 X-Frame-Options 响应头。相关规范仍在不断发展,且各浏览器的支持情况可能不同,因此这是一个复杂且不断变化的领域。理想情况下,首先在服务器端确保网站的安全,然后使用这些安全功能作为第二层防御,并且要记住,仅依赖客户端机制是有风险的。
考虑到网页可能出错的各种方式、它的发展历程以及它承载的关键数据量,实际上网络的安全性令人惊叹。或许,回顾过去,随着网页在全球范围内的广泛采用,安全技术随着时间的推移逐渐成熟是最合适的。如果早期的创新者曾试图设计一个完全安全的系统,那将是一项极其艰巨的任务,如果他们失败了,整个努力可能永远无法取得成果。
安全测试
测试会导致失败,而失败会带来理解。
—伯特·鲁坦

本章介绍了安全测试作为开发可靠、安全代码的重要组成部分。主动测试以发现安全漏洞是容易理解且不难实施的,但在实践中却极为不足,因此这代表了提高安全保障的一个重要机会。
本章以安全测试应用的简要概述开始,接着介绍了安全测试如何挽救世界免于一个重大漏洞。接下来,我们将学习如何编写安全测试用例,以检测和捕捉漏洞或其前兆。模糊测试是一种强有力的补充技术,有助于发现更深层次的问题。我们还将涵盖安全回归测试,针对现有漏洞创建,以确保同样的错误不会再次发生。本章最后讨论了如何通过测试预防拒绝服务(DoS)和相关攻击,并总结了安全测试的最佳实践(涵盖了广泛的安全测试思路,但并非全面)。
什么是安全测试?
首先,重要的是要定义安全测试的含义。大多数测试通过运行代码来检查功能是否按预期工作。安全测试则完全相反,确保那些不应该允许的操作确实没有发生(稍后的代码示例将清晰地说明这一点)。
安全测试不可或缺,因为它确保缓解措施有效。考虑到开发人员通常专注于确保功能在正常使用下按预期工作,针对不期而至的攻击,完全预测是十分困难的。前几章所涉及的内容应该立刻提示出众多安全测试的可能性。以下是一些与之前提到的主要漏洞类别相对应的基础安全测试用例:
整数溢出
- 确定允许的值范围,并确保能够检测并拒绝超出范围的值。
内存管理问题
- 测试代码是否能正确处理极大的数据值,并在数据过大时将其拒绝。
不可信输入
- 测试各种无效输入,确保它们要么被拒绝,要么被转换为有效的形式并安全地处理。
Web 安全
- 确保 HTTP 降级攻击、无效身份验证和 CSRF 令牌、以及 XSS 攻击失败(有关这些的详细信息,请参阅前一章)。
异常处理缺陷
- 强制代码通过其各种异常处理路径(使用依赖注入处理少见的路径),检查它是否能够合理地恢复。
这些测试的共同点是它们偏离了正常使用的常规路径,这也是它们容易被遗忘的原因。由于这些领域都是攻击的高风险区域,彻底的测试能起到很大的作用。安全测试通过预测这些情况并确认必要的保护机制始终有效,从而使代码更加安全。此外,对于安全关键的代码,我建议进行彻底的代码覆盖率测试,以确保尽可能高的质量,因为这些区域的漏洞往往会带来灾难性的后果。
安全测试可能是你开始提升应用程序安全性的最佳方式,而且并不难做到。关于软件行业中安全测试的公开统计数据并不存在,但反复出现的漏洞强烈表明,这是一个巨大的错失机会。
安全测试 GotoFail 漏洞
什么是字符逆境的测试?
——哈里·爱默生·福斯迪克
回顾我们在第八章中讨论的 GotoFail 漏洞,它导致了安全连接检查被绕过。扩展我们在那里的简化示例,让我们看看安全测试是如何轻松发现类似问题的。
GotoFail 漏洞是由一行代码意外重复导致的,如以下代码片段中高亮的行所示。由于那一行是 goto 语句,它跳过了一系列重要的检查,导致验证函数无条件地返回通过的代码。我们之前只看了关键的代码行(在我的简化版本中),但为了进行安全测试,我们需要检查整个函数:
易受攻击的代码
/*
* Copyright (c) 1999-2001,2005-2012 Apple Inc. All Rights Reserved.
*
* @APPLE_LICENSE_HEADER_START@
*
* This file contains Original Code and/or Modifications of Original Code
* as defined in and that are subject to the Apple Public Source License
* Version 2.0 (the 'License'). You may not use this file except in
* compliance with the License. Please obtain a copy of the License at
* http://www.opensource.apple.com/apsl/ and read it before using this
* file.
*
* The Original Code and all software distributed under the License are
* distributed on an 'AS IS' basis, WITHOUT WARRANTY OF ANY KIND, EITHER
* EXPRESS OR IMPLIED, AND APPLE HEREBY DISCLAIMS ALL SUCH WARRANTIES,
* INCLUDING WITHOUT LIMITATION, ANY WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE, QUIET ENJOYMENT OR NON-INFRINGEMENT.
* Please see the License for the specific language governing rights and
* limitations under the License.
*
* @APPLE_LICENSE_HEADER_END@
*/
int VerifyServerKeyExchange(ExchangeParams params,
uint8_t *expected_hash, size_t expected_hash_len) {
int err;
HashCtx ctx = 0;
uint8_t *hash = 0;
size_t hash_len;
if ((err = ReadyHash(&ctx)) != 0)
goto fail;
1 if ((err = SSLHashSHA1.update(ctx, params.clientRandom, PARAM_LEN)) != 0)
goto fail;
2 if ((err = SSLHashSHA1.update(ctx, params.serverRandom, PARAM_LEN)) != 0)
goto fail;
`goto fail;`
3 if ((err = SSLHashSHA1.update(ctx, params.signedParams, PARAM_LEN)) != 0)
goto fail;
if ((err = SSLHashSHA1.final(ctx, &hash, &hash_len)) != 0)
goto fail;
if (hash_len != expected_hash_len) {
err = -106;
goto fail;
}
4 if ((err = memcmp(hash, expected_hash, hash_len)) != 0) {
err = -100; // Error code for mismatch
}
SSLFreeBuffer(hash);
fail:
if (ctx)
SSLFreeBuffer(ctx);
}
return err;
}
VerifyServerKeyExchange 函数接受一个由三个字段组成的 params 参数,计算其内容的消息摘要哈希,并将结果与验证数据的 expected_hash 值进行比较。返回值为零表示哈希匹配,这是有效请求的必要条件。非零返回值表示存在问题:哈希 值 不匹配(-100),哈希 长度 不匹配(-106),或者由于未指定的错误,从哈希计算库返回了某个非零错误代码。安全性依赖于此:任何篡改哈希值或数据的行为都会导致哈希不匹配,从而标志着出现了问题。
让我们首先走一遍正确版本的代码,在引入重复的goto语句之前。设置一个HashCtx ctx上下文变量后,它依次对params的三个数据字段进行哈希(分别在 1、2 和 3 处)。如果发生任何错误,它会跳转到fail标签并返回变量err中的错误代码。否则,代码继续执行,将哈希结果复制到缓冲区并在第 4 步与预期的哈希值进行比较。比较函数memcmp返回0表示相等,如果哈希不同,代码会将错误代码-100赋值给err并继续返回该结果。
功能测试
在考虑安全测试之前,让我们先从VerifyServerKeyExchange函数的功能测试开始。功能测试检查代码是否按预期执行,这个简单的示例并不完整。这个示例使用了 C 语言的 MinUnit 测试框架。为了跟随示例,你只需要知道mu_assert(``condition``, message``)用来检查表达式condition是否为真;如果不为真,断言失败,并打印提供的message:
mu_assert(0 == VerifyServerKeyExchange(test0, expected_hash, SIG_LEN),
"Expected correct hash check to succeed.");
这个调用使用已知良好的参数,因此我们期望返回值为0,测试通过。在函数内部,这三个字段会被哈希(分别在 1、2 和 3 处)。哈希在 4 处比较相等。未显示的是三字段数据(ExchangeParams结构体中的test0)的测试值,以及服务器会签署的预计算正确哈希(expected_hash)。
含漏洞的功能测试
现在让我们引入 GotoFail 漏洞(即突出显示的那行代码),并看看它带来了什么影响。当我们在额外的goto语句后重新运行功能测试时,测试仍然通过。代码在重复的goto之前工作正常,但跳过了第三个数据字段的哈希(在 3 处)和哈希比较(在 4 处)。函数仍然会继续验证正确的输入,但现在它也会验证一些应该被拒绝的错误输入。然而,我们现在还不知道这一点。这正是安全测试如此重要的原因——也是为何它如此容易被忽视的原因。
更全面的功能测试可能包括额外的测试用例,例如检查验证失败(非零返回值)。然而,功能测试通常并不会彻底涵盖所有我们需要验证的用例,而这些用例需要验证函数拒绝输入的安全性。正是在这一点上,安全测试变得尤为重要,接下来我们将看到这一点。
安全测试用例
现在让我们编写一些安全性测试用例。由于需要对三块数据进行哈希处理,这意味着我们需要编写三个相应的测试用例;每个测试用例都会以某种方式改变数据值,导致哈希值与预期值不匹配。目标验证函数应该拒绝这些输入,因为改变的数据值可能代表数据篡改,而哈希比较正是用来防止这种情况的。这些实际的值(test1,test2,test3)是正确的test0的副本,只有其中一个数据字段稍作变化;这些值本身并不重要,且未显示。以下是三个测试用例:
``` mu_assert(-100 == VerifyServerKeyExchange(test1, expected_hash, SIG_LEN), "Expected to fail hash check: wrong client random."); mu_assert(-100 == VerifyServerKeyExchange(test2, expected_hash, SIG_LEN), "Expected to fail hash check: wrong server random."); mu_assert(-100 == VerifyServerKeyExchange(test3, expected_hash, SIG_LEN), "Expected to fail hash check: wrong signed parameters."); ``` All three of these will fail due to the bug. The verify function works fine up to the troublesome `goto`, but then unconditionally jumps to the label `fail`, leaving its hashing job incomplete and never comparing hash values 4. Since we wrote these tests to expect verification failure as correct, a return value of `0` causes the tests to fail. Now we have a testing safety net that would have caught this vulnerability before release, avoiding the resulting fiasco. In the spirit of completeness, another security test case suggests itself. What if all three values are correct, as in the `test0` case, but with a different signed hash (`wrong_hash`)? Here’s the test case for this: ``` mu_assert(-100 == VerifyServerKeyExchange(test0, wrong_hash, SIG_LEN), "Expected check against the wrong hash value to fail."); ``` This test fails as well with the errant `goto`, as we would expect. While for this particular vulnerability just one of these tests would have caught it, the purpose of security testing is to cover as broad a range of potential vulnerabilities as possible. ### The Limits of Security Tests Security testing aims to detect the potential major points of failure in code, but it will never cover all of the countless ways for code to go wrong. It’s possible to introduce a vulnerability that the tests we just wrote won’t detect, but it’s unlikely to happen inadvertently. Unless test coverage is extremely thorough, the possibility of crafting a bug that slips through the tests remains; however, the major threat here is inadvertent bugs, so a modest set of security test cases can be quite effective. Determining how thorough the security test cases need to be requires judgment, but the rules of thumb are clear: * Security testing is more important for code that is crucial to security. * The most important security tests often check for actions such as denying access, rejecting input, or failing (rather than success). * Security test cases should ensure that each of the key steps (in our example, the three hashes and the comparison of hashes) works correctly. Having closely examined a real security vulnerability with a simple (if unexpected) cause, and how to security test for such eventualities, let’s consider the general case and see how we could have anticipated this sort of problem and proactively averted it. ## Writing Security Test Cases > A good test case is one that has a high probability of detecting an as yet undiscovered error. > > —Glenford Myers A security test case confirms that a specific security failure does not occur. These tests are motivated by the second of the Four Questions: What can go wrong? This differs from *penetration testing*, where honest people ethically pound on software to find vulnerabilities so they can be fixed before bad actors find them, in that it does not attempt to scope out all possible exploits. Security testing also differs from penetration testing by providing protection against future vulnerabilities being introduced. A security test case checks that protective mechanisms work correctly, which often involves the rejection or neutralization of invalid inputs and disallowed operations. While nobody would have anticipated the GotoFail bug specifically, it’s easy to see that all of the `if` statements in the `VerifyServerKeyExchange` function are critical to security. In the general case, code like this calls for test coverage on each condition that enforces a security check. With that level of testing in place, when the extraneous `goto` creates a vulnerability, one of those test cases will fail and call the problem to your attention. You should create security test cases when you write other unit tests, not as a reaction to finding vulnerabilities. Secure systems protect valuable resources by blocking improper actions, rejecting malicious inputs, denying access, and so forth. Create security test cases wherever such security mechanisms exist to ensure that unauthorized operations indeed fail. General examples of commonplace security test cases include testing that login attempts with the wrong password fail, that unauthorized attempts to access kernel resources from user space fail, and that digital certificates that are invalid or malformed in various ways are always rejected. Reading the code is a great way to get ideas for good security test cases. ### Testing Input Validation Let’s consider security test cases for input validation. As a simple example, we’ll test input validation code that requires a string that is at least 10 characters and at most 20 characters long, consisting only of alphanumeric ASCII characters. You could create helper functions to perform this sort of standardized input validation, ensuring that it happens uniformly and without fail, then combine input validation with matching test cases to confirm that the validation checks work and that the code performs properly, right up to the allowable limits. In fact, since off-by-one errors are legion in programming, it’s good practice to check both right at and just beyond the limits. The following unit tests cover the input validation test cases for this example: * Check that a valid input of length 10 works, but an input of length 9 or less fails. * Check that a valid input of length 20 works, but an input of length 21 or more fails. * Check that inputs with one or more invalid characters always fail. Of course, the functional tests should have already checked that sample inputs that satisfy all constraints work properly. For another similar example, suppose the code under test stores a byte array parameter in a fixed-length buffer of *N* bytes. Security test cases should ensure that the code works as expected with inputs of sizes up to and including *N*, but that an input of size *N*+1 gets safely rejected. ### Testing for XSS Vulnerabilities Now let’s look at a more challenging security test case and some of the different test strategies that are available. Recall the XSS vulnerability from Chapter 11, where an untrusted input injects itself into HTML generated on the web server and breaks out into the page, such as by introducing script that runs to launch an attack. The root cause of this vulnerability is improper escaping, so that is where our security tests will focus. Say the code under test is the following Python function, which composes a fragment of HTML based on strings that describe its contents: **vulnerable code** ``` def html_tag(name, attrs): """Build and return an HTML fragment with attribute values. >>> html_tag('meta', {'name': 'test', 'content': 'example'}) '<meta name="test" content="example">' """ result = '<%s' % name for attr in attrs: result += ' %s="%s"' % (attr, html.escape(attrs[attr])) return result + ">" ``` The `doctest` (marked with the `>>>` prefix) example in the comments (delimited by `"""`) illustrates how to use this function to generate HTML text for a `<meta>` tag. The first line builds the first section of the text string result: the angle bracket (`<`) that opens every HTML tag, followed by the tag name. Then the loop iterates through the attributes (`attrs`), appending a space and its declaration (of the form `X="Y"`) for each attribute. The code applies the `html.escape` function to each attribute string value correctly, but we still should test it. (For our purposes, we’ll assume that attribute values are the only potential source of untrusted input that needs escaping. While in practice this is usually sufficient, anything is possible, so more escaping or input validation might be necessary in some applications.) Let’s write the test cases with Python’s `unittest` library: ``` class ExampleTestCases(unittest.TestCase): def test_basic(self): self.assertEqual(html_tag('meta', {'name': 'test', 'content': '123'}), '<meta name="test" content="123">') def test_special_char(self): self.assertEqual(html_tag('meta', {'name': 'test', 'content': 'x"'}), '<meta name="test" content="x"">') if __name__ == '__main__': unittest.main() ``` The first test case is a basic functional test that shows how these unit tests work. When run from the command line, the module invokes the unit test framework `main` in the last line. This automatically calls each method of all subclasses of `unittest.TestCase`, which contain the unit tests. The `assertEqual` method compares its arguments, which should be equal, or else the test fails. Now let’s look at the security test case, named `test_special_char`. Since we know XSS can exploit the code by breaking out of the double quotes that the untrusted input goes into, we test the escaping with a string containing a double quote. Correct HTML escaping should convert this to the HTML entity `"`, as shown in the expected string of the `assertEqual` statement. If we remove the `html.escape` function in the target method, this test will indeed fail, as we want it to. So far, so good. But note that in order to write the test we had to know in advance what kinds of inputs might be problematic (double quote characters). Since the HTML specification is fairly involved, how do we know there aren’t more important test cases needed? We could try a bunch of other special characters, a number of which the `escape` function would convert to various HTML entity values (for example, converting the greater-than sign to `>`). However, adjusting our test cases to cover all the possibilities like this would involve a lot of effort. Since we are working with HTML, we can use libraries that know all about the specification in detail to do the heavy lifting for us. The following test case checks the result of forming HTML tags as we did earlier for the same two test values, the normal case and the one with a string containing a double quote character, assigned to the variable `content` in turn: ``` def test_parsed_html(self): for content in ['x', 'x"']: result = html_tag('meta', {'name': 'test', 'content': content}) soup = BeautifulSoup(result, 'html.parser') node = soup.find('meta') self.assertEqual(node.get('name'), 'test') self.assertEqual(node.get('content'), content) ``` Inside the loop is the common code that tests both cases, beginning with a call to the target function to construct a string HTML `<meta>` tag. Instead of checking for an explicit expected value, we invoke the BeautifulSoup parser, which produces a tree of objects that logically represent the parsed HTML structure (colorfully referred to as a *soup* of objects). The variable `soup` is the root of the HTML node structure, and we can use it to navigate and examine its contents through an object model. The `find` method finds the first `<meta>` tag in the soup, which we assign to the variable `node`. The `node` object sports a `get` method that looks up the values of attributes by name. The code tests that both the `name` and `content` attributes of the `<meta>` tag have the expected values. The big advantage of using the parser is that it takes care of spaces or line breaks in the HTML text, handles escaping and unescaping, converts entity expressions, and does everything else that HTML parsing entails. Because we used the parser library, this security test case works on the parsed objects, shielded from the idiosyncrasies of HTML. If the XSS injects a malicious input that manages to break out of the double quotes, the parsed HTML won’t have the same value in the `node` object for the `<meta>` tag. So, even if you had no clue that double quote characters were problematic for some XSS attacks, you could easily try a range of special characters and rely on the parser to figure out which were working properly (or not). The next topic takes this idea of trying a number of test case variations and automates it at scale. ## Fuzz Testing *Fuzz testing* is a technique that automatically generates test cases in an effort to bombard the target code with test inputs. This helps you determine if particular inputs might cause the code to fail or crash the process. Here’s an analogy that might help: a dishwasher cleans by spraying water at many different angles from a rotating arm. Without knowledge of how dishware happens to be loaded or at what angle shooting water will be effective, it sprays at random and still manages to get everything clean. In contrast to how security test cases written with specific intentions, the scattershot method of fuzz testing can be quite effective at finding a wider range of bugs, some of which will be vulnerabilities. For security test cases, the typical approach is to “fuzz” untrusted inputs (that is, try lots of different values) and look for anomalous results or crashes. To actually identify a security vulnerability, you will need to investigate the leads that the results of fuzz testing produce. You could convert `test_parsed_html` from the previous section into a fuzz test by checking many more characters. ``` def test_fuzzy_html(self): for fuzz in string.punctuation: content = 'q' + fuzz result = html_tag('meta', {'name': 'test', 'content': content}) soup = BeautifulSoup(result, 'html.parser') node = soup.find('meta') self.assertEqual(node.get('name'), 'test') self.assertEqual(node.get('content'), content) ``` Rather than trying a chosen list of test cases, this code loops over all ASCII punctuation characters, which are defined by a constant in the standard string library. On each iteration, the variable `fuzz` takes the value of a punctuation character and prepends this with the letter `q` to construct the two-character `content` value. The rest of the code is identical to the original example, only here it runs many more test cases. This example is simplistic to the point of stretching the definition of fuzz testing a bit, but it illustrates the power of brute-force testing 32 cases programmatically instead of carefully choosing and writing a collection of test cases by hand. A more elaborate version of this code might construct many more cases using longer strings composed of the troublesome HTML quoting and escaping characters. There are many libraries that offer various fuzzing capabilities, from random fuzzing to the generation of variations based on the knowledge of specific formats such as HTML, XML, and JSON. If you have a particular testing strategy in mind, you can certainly write your own test cases and try them. The idea is that test cases are cheap, and generating lots of them is an easy way of getting good test coverage. ## Security Regression Tests > What regresses, never progresses. > > —Umar ibn al-Khattâb Once identified and fixed, security vulnerabilities are the last bugs we want to come back and bite us again. Yet this does happen, more often than it should, and when it does it’s a clear indication of insufficient security testing. When responding to a newly discovered security vulnerability, an important best practice is to create a *security regression test* that detects the underlying bug or bugs. This serves as a handy *repro* (a test case that reproduces the bug or bugs) as well as confirms that the fix actually eliminates the vulnerability. That’s the idea, anyway, but this practice seems to be less than diligently followed, even by the largest and most sophisticated software makers. For example, when Apple released iOS 12.4 in 2019, it reintroduced a bug identical to one already found and fixed in iOS 12.3, immediately re-enabling a vulnerability after that door should have been firmly closed. Had the original fix included a security regression test case, this should never have happened. It’s notable that in some cases security regressions can be far worse than new vulnerabilities. That iOS regression was particularly painful because the bug was already familiar to the security research community, so they quickly adapted the existing jailbreak tool built for iOS 12.3 to work on iOS 12.4 (a *jailbreak* is an escalation of privilege circumventing restrictions imposed by the maker limiting what the user can do on their device). I recommend writing the test case first, before tackling the actual fix. In an emergency, you might prioritize the fix if it’s clear-cut, but unless you’re working solo, having someone develop the regression test in parallel is a good practice. In the process of developing an effective regression test, you may learn more about the issue and even get clues about related potential vulnerabilities. A good security regression test should try more than a single specific test case that’s identical to a known attack; it should be more general. For example, for the SQL injection attack described in Chapter 10, it wouldn’t be sufficient to just test that the one known “Bobby Tables” attack now fails. Also try an excessively long name, which might suggest that input validation needs to length-check name input strings, too. Try variants on the attack, such as using a double quote instead of single quote, or a backslash (the SQL string escape character) at the end of the name. Also try similar attacks in other columns of the same table, or other tables. Just as you wouldn’t fix the SQL injection bug by narrowly rejecting only names beginning with `Robert');`, even though it would stop that specific attack, you shouldn’t write regression tests that way either. In addition to addressing the newly discovered vulnerability, it’s common that the investigation will suggest similar vulnerabilities elsewhere in the system that might also be exploitable. Use your superior knowledge of system internals and familiarity with the source code to stay ahead of potential adversaries. If possible, probe for the presence of similar bugs immediately, so you can fix them as part of the update that closes the original vulnerability. This can be important, since you can bet that attackers will also be thinking along these lines, and releasing a fix will be a big clue about new ways they might target your system. If there is no time to explore all the leads, file away the details for investigation later, when time permits. As an example, let’s consider how to write a security regression test for the Heartbleed vulnerability from Chapter 9\. Recall that the exploit worked by sending a packet containing a payload of arbitrary bytes with a much larger byte count; the server response honored the byte count and sent back additional memory contents, often causing a serious internal data leak. The correct behavior is to ignore such invalid requests. Some good security regression test cases include: * Test that known exploit requests no longer receive a response. * Test with request byte counts greater than 16,384 (the maximum). * Test requests with payloads of 0 bytes and the maximum byte size. * Investigate whether other types of packets in the TLS protocol could have similar issues, and if so test those as well. ## Availability Testing > Worry about being unavailable; worry about being absent or fraudulent. > > —Anne Lamott DoS attacks represent a unique potential threat because the load limits that systems should be able to sustain are difficult to characterize. In particular, the term *load* packs a lot of meaning in that statement, including: processing power, memory consumption, operating system resources, network bandwidth, disk space, and other potential bottlenecks (recall the entropy pool of a CSPRNG from Chapter 5). Operations staff typically monitor these factors in response to production use, but there are a few cases where security testing can avert attacks that intentionally exploit performance vulnerabilities. Security testing should include test cases for identifying code that may be subject to nonlinear performance degradation. We saw some examples of this kind of vulnerability in Chapter 10, when we considered backtracking regex and XML entity expansion blow-ups. Since these can adversely impact performance exponentially, they are particularly potent vulnerabilities. Of course, these are just two instances of a larger phenomenon, and the same issue can occur in all kinds of code. The next sections explain two basic strategies to test for this kind of problem: measuring the performance of specific functionality and monitoring overall performance against various loads. ### Resource Consumption For functionality that you know may be susceptible to an availability attack, add security test cases that measure and determine a sensible limit on the input to protect blow-ups from occurring. Then test further to ensure that input validation prevents larger inputs from overloading the system. For example, in the case of a backtracking regex, you could test with strings of length *N* and *N*+1 to estimate the geometric rate at which the computation time grows. Use that factor to extrapolate the time required for the longest valid input, and then check that it’s under the maximum threshold to pass the test. For the sake of argument, let’s say that *N* = 20 takes 1 second and *N* = 21 takes 2 seconds, so the additional character doubles the runtime. If the maximum input length is 30 characters, you can estimate this will take 1,024 (2¹⁰) seconds to process and decide if this is feasible or not. By extrapolating the processing time mathematically instead of actually executing the *N* = 30 case, you can avoid an extremely slow-running test case. However, bear in mind that actual performance times may depend on other factors, so more than two measurements may be necessary to validate a suitable model. In addition to this kind of targeted testing, measure performance metrics for the overall system and set generous upper limits so that if an iteration causes a significant degradation, the test will flag it for inspection. Often, these measurements can be easily added to existing larger tests, including smoke tests, load tests, and compatibility tests. One easy technique to guard against a code change causing dramatic increases in memory consumption is to run tests under artificially resource-constrained conditions. *Memory* here refers to stack and heap space, swap space, disk file and database, and so forth. Unit tests should run with little available memory; if the test suite ever hits the limit, that’s worth investigating. Larger integration tests will need resources comparable to those available in production, and when run with minimal headroom they can serve as a “canary in the coal mine.” For example, if you can test the system successfully with 80 percent of the memory available in production, that provides some assurance of 20 percent headroom (excess capacity). ### Threshold Testing One important but easily overlooked protection of system availability is to establish warning signs before fundamental limits are reached. A classic example of exceeding such a limit happened to a well-known software company not long ago, when the 32-bit counter that assigned unique IDs to the objects that the system managed wrapped from 2,147,483,647 to 0, resulting in the IDs of low-numbered objects being duplicated. It took hours to remedy the problem—a disaster that could easily have been averted by monitoring for the counter approaching its limit and issuing a warning when it reached, say, `0.99*INT_MAX`. Surely, in the early days of the product, it was difficult to imagine the counter ever reaching its maximum, but as the company grew and the prospect became a potential issue, nobody considered the possibility. Warnings for such thresholds are often considered the responsibility of operational monitoring rather than security tests, but these are so often missed, and so easy to fix, that covering these eventualities under both categories is often worthwhile. Be sure to also watch out for other limits where the system will hit a brick wall, not just counters. Storage capacity is another area where you’ll want significant advance warning, allowing you to respond smoothly. Rather than setting arbitrary thresholds, such as 99 percent of the limit, a more useful calculation looks at a *time series* (a set of measurements over time) and extrapolates the time it will take to reach the limit. Don’t forget to stay ahead of time limits, too. The expiration dates of digital certificates are easily ignored until suddenly they fail to validate. Systems that rely on the certificates of partners that supply data feeds should monitor those and provide a heads-up in order to avoid an outage that, to your customers, will look like your problem. The “Y2K bug” is now a distant memory of a non-event (possibly due to the extraordinary efforts made at the time to avoid the chaos that might have ensued in computer systems that stored years as two-digit values when the year changed from 1999 to 2000). However, we now have the “Y2k38 bug” to look forward to on January 19, 2038, when 2,147,483,647 seconds will have passed since 00:00:00 UTC on January 1, 1970 (the Unix epoch, as referenced in Figure 12-1). In less than two decades we will reach a point where the number of seconds elapsed since the epoch overflows the range of a 32-bit number, and this is almost certain to manifest all manner of nasty bugs. If it’s too soon to instrument your codebase for this, when is the right time?  Figure 12-1: Bug (courtesy of Randall Munroe, [xkcd.com/376](http://xkcd.com/376)) ### Distributed Denial-of-Service Attacks Denial-of-service (DoS) attacks are single actions that adversely impact availability; distributed denial-of-service (DDoS) attacks accomplish this through the cumulative effect of a number of concerted actions. For internet-connected systems, the open architecture of the internet creates an additional risk of DDoS attacks, such as from a coordinated botnet. Brute-force overloading from distributed anonymous sources generally ends up as a contest of scale of computing power. Mitigating these attacks typically requires reliance on DDoS protection vendors that have networking expertise backed by massive datacenter capacity. I point this out as separate from the other categories of availability threats because this isn’t something you can easily mitigate on your own should your server be unfortunate enough to become a target of a serious DDoS attack. ## Best Practices for Security Testing Writing solid security test cases is an important way to improve the security of any codebase. While security test cases can’t guarantee perfect security, they confirm that your protections and mitigations are working, and are thus a significant step in the right direction. A robust suite of security test cases, combined with security regression tests, dramatically lowers the chances of a major security lapse. ### Test-Driven Development Security test cases are especially important when you’re writing critical code and thinking through its security implications. I strongly endorse *test-driven development* *(**TDD)*, where you write test cases concurrently with new code—rigorous practitioners of this method actually make the tests first, only authoring new code in order to fix the initially failing tests. TDD with security test cases included from the start ensures that security is built into the code, rather than as an afterthought, but whatever methodology you use for testing, security test cases need to be part of your test suite. If others write the tests, developers should provide guidance that describes the security test cases needed, because they can be harder to intuit without a solid understanding of the security demands on the code. ### Leveraging Integration Testing *Integration testing* puts systems through their paces to ensure that all the components, already unit-tested individually, work together as they should. These are important tests for quality assurance purposes—but once you’ve invested the effort, it’s easy to extend them for a little security testing, too. In 2018, a major social media platform advised its customers to change their passwords due to a self-inflicted breach of security: a bug had caused account passwords to spew into an internal log in plaintext. By leveraging integration tests, they could easily have detected and fixed the code that introduced this vulnerability before it was released to production. Integration tests for this service should have included logging in with a fake user account, say, `USER1`, with some password, such as `/123!abc$XYZ` (even fake accounts should have secure passwords). After the test completed, a security test would scan the outputs for that distinctive password string and raise an error if it found any matches. This testing approach applies not just to log files, but to anywhere a potential leak could occur: in other residual files, publicly accessible web pages, client caches, and so forth. Tests like this can be as simple as a `grep(1)` command. Passwords are a convenient example for explanatory purposes, but this technique applies to any private data. Test systems require a bunch of synthetic data to stand in for actual user data in production, and all of that private content could potentially leak in just the same way. A more comprehensive leak test would scan all system outputs not explicitly protected as confidential for any traces of test input data that are private. ### Security Testing Catch-Up If you are working on a codebase bereft of security test cases, assuming that security is a priority, there is some important work that needs doing. If there is a design that considers security that has been threat modeled and reviewed, use it as a map of what code deserves attention first. It’s usually wise to divide the job into pieces with incremental milestones, do an achievable first iteration or two, and then assess the remaining need as you work through the tasks. Target the protection mechanisms and functional areas in order of importance, letting the code guide you in determining what needs testing. Review existing test cases, as some may already do some security testing or be close enough to easily adapt for security. If someone is new to the project and needs to learn the code, have them write some of the security test cases; this is a great way to educate them and will produce lasting value. ``# 13 安全开发最佳实践 > 他们说没有人是完美的。然后他们告诉你练习使人完美。我希望他们能明确自己的想法。 > > —温斯顿·丘吉尔
到目前为止,在第三部分,我们已经调查了在开发阶段出现的一系列安全漏洞。在本章中,我们将重点讨论开发过程本身与安全相关的方面以及可能出现的问题。我们将首先讨论代码质量:良好的代码卫生、彻底的错误和异常处理、记录安全属性以及代码审查在促进安全方面的作用。其次,我们将看看处理依赖关系:特别是它们如何将漏洞引入系统。我们将涵盖的第三个领域是错误分类——这是一项平衡安全与其他急需之间的关键技能。最后,安全开发取决于维护一个安全的工作环境,因此我提供了一些基本提示,告诉你需要做些什么来避免被入侵。 由于实际原因,接下来的指导是通用的。读者应该能够将其应用于自己的开发实践中。许多其他有效的技术是特定于编程语言、操作系统和给定系统的其他细节的。因此,重要的是你认识到以下讨论中的大模式,但也要警惕在自己的工作中出现的其他与安全相关的问题和机会。 ## 代码质量 > 质量永远是时尚。 > > —罗伯特·根 第三部分的前几章解释了漏洞如何潜入代码,但在这里我想重点讨论一般错误与安全之间的关系。如果你能提高代码质量,无论你是否意识到这一点,你都会使其更加安全。所有的漏洞都是错误,因此更少的错误意味着更少的漏洞和漏洞链。但当然,在消除所有错误之前,收益递减会在很早之前出现,因此最好采取一种平衡的方法。 以下讨论涵盖了一些关注安全的关键领域。 ### 代码卫生 程序员通常对他们正在处理的代码的质量有很好的感觉,但出于各种原因,他们经常选择接受已知缺陷而不是进行必要的改进。代码异味、意大利面代码和推迟的“TODO”注释标记的进一步工作都往往是漏洞的肥沃土壤。至少在安全特别关注的领域,识别和消除这些粗糙的边缘可能是避免漏洞的最佳方法之一,而无需进行任何安全分析即可看到漏洞可能如何被利用。 除了你对代码状况的本能感知之外,使用工具来标记这些问题也很重要。使用完整警告编译代码,然后修复代码以解决重要问题。一些自动警告,例如误导的缩进或没有执行路径的未使用代码,将识别我们在第八章和第十二章中讨论的 GotoFail 漏洞,并进行了安全测试。Lint 和其他静态代码分析工具提供了更丰富的代码审查,提供有时会揭示错误和漏洞的提示。 代码分析并不总是将安全漏洞识别为这样,因此你必须扩大范围。在开发过程中经常使用这些工具,以降低潜在漏洞的总数。这样,如果工具的输出发生显著变化,你就有更好的机会注意到它,因为新内容不会在大量旧消息中丢失。 如果容易做到的话,修复所有警告,或者当你发现问题可能严重时。例如,无法访问的代码表明尽管有人出于某种原因编写了代码,但现在已经不在画面中了,这是不对的。另一方面,关于变量命名约定的警告,虽然是好建议,但可能与任何安全漏洞无关。 找时间进行这种清理总是具有挑战性的。采取逐步的方法;即使每周只有一两个小时,随着时间的推移会产生很大的差异,这个过程是熟悉大型代码库的好方法。如果所有警告太多而无法处理,请从最有希望的警告开始(例如,GCC 的-Wmisleading-indentation),然后修复被标记的问题。 ### 异常和错误处理 1996 年阿丽亚娜 5 号 501 航班失败报告痛苦地详细描述了异常处理不当的后果。虽然灾难性的错误纯粹是自我造成的,没有涉及恶意行为,但它作为一个例子展示了攻击者如何利用结果行为来危害系统。 阿丽亚娜 5 号飞船发射后不久,一个计算中的浮点到整数转换引发了异常。异常处理机制触发了,但由于转换错误是意外的,异常处理程序代码没有为这种情况做好准备。代码关闭了发动机,导致飞行 36.7 秒后发生灾难性故障。 防范这类问题的方法始于认识到草率的异常处理的风险,然后仔细考虑即使是最不可能的异常的正确响应。一般来说,最好在可能的情况下尽早处理异常,因为在源头处理异常时有最多的上下文和最短的时间窗口来避免进一步的复杂情况。 也就是说,大型系统可能需要一个顶层处理程序来处理任何未处理的异常。一个很好的方法是识别一个行动单元并完全失败。例如,Web 服务器可能会在 HTTP 请求期间捕获异常并返回一个通用的 500(服务器错误)响应。通常,Web 应用程序应该将改变状态的请求作为事务处理,以便任何错误总是导致没有状态更改。这避免了可能导致系统处于脆弱状态的部分更改。 将与潜在漏洞相关的许多推理也适用于一般的错误处理。与异常一样,错误情况可能很少发生,因此开发人员很容易忘记它们,使它们不完整或未经测试。攻击者发现漏洞的常见技巧是尝试引发某种错误,然后观察代码在希望发现弱点的情况下的行为。因此,最好的防御措施是从一开始就实施可靠的错误处理。这是安全漏洞与其他错误不同的一个经典例子:在正常使用中,某些错误可能极为罕见,但在有组织的攻击的背景下,调用错误可能是一个明确的目标。 要正确处理错误和异常处理,测试非常重要。确保所有代码路径都有测试覆盖,特别是不常见的路径。监视生产中的异常日志并追踪其原因,以确保异常恢复正常。积极调查和修复间歇性异常,因为如果一个聪明的攻击者学会如何触发一个异常,他们可能能够从那里将其微调为恶意利用。 ### 记录安全 当你编写具有重要安全后果的代码时,你需要在注释中解释你的决定有多少,以便其他人(或者你自己忘记了几个月或几年后)不会意外破坏它。 对于关键代码,或者安全影响值得解释的地方,注释是重要的,因为它允许任何考虑更改代码的人了解风险。当你写有关安全的注释时,解释安全影响并具体说明:简单地写// 小心:安全后果并不是一个解释。要清晰并且切中要点:包含太多废话,人们要么会忽略它,要么放弃。回想我们在第九章和第十二章讨论的 Heartbleed 漏洞,一个好的注释会解释拒绝使用超出实际提供数据的字节计数的无效请求是至关重要的,因为这可能导致泄露超出缓冲区范围的私人数据。如果安全分析变得太复杂而无法在注释中解释,请将详细信息写在单独的文档中,然后提供对该文档的引用。 这并不意味着你应该尝试标记所有安全依赖的代码。相反,目标是警告读者关于未来可能被轻视的不明显问题。最终,注释不能完全替代对安全影响不断保持警惕的有经验的编码人员,这就是为什么这些工作并不容易。 编写一个好的安全测试用例(如第十二章所述)是支持文档的理想方式,以防止其他人在未来更改中无意中破坏安全。作为攻击看起来像的工作原型,这样的测试不仅可以防止意外的不利变化,还可以准确显示代码可能出错的地方。 ### 安全代码审查 专业软件开发过程包括同行代码审查作为标准实践,我想要为明确包括安全在这些审查中做出论据。通常最好在代码审查工作流程中的一个步骤中进行,与审查人员应该注意的潜在问题清单一起,包括代码正确性、可读性、风格等。 我建议同一个代码审查员在第一次阅读代码后添加一个明确的步骤来考虑安全性,然后再次审查代码时戴上他们的“安全帽”。如果审查人员觉得无法涵盖安全性,他们应该将这部分委托给有能力的人。当然,对于明显没有安全影响的代码更改,你可以跳过这一步。 为了安全审查代码更改,与 SDR(第七章的主题)不同,你正在查看一个系统的一个狭窄子集,而不是审查整个设计时获得的整体视图。确保考虑代码如何处理一系列不受信任的输入,检查任何输入验证是否健壮,并避免潜在的混淆副手问题。当然,对于安全至关重要的代码应该得到额外关注,并且通常值得更高的质量门槛。将额外的一双眼睛聚焦在代码的安全性上有很大潜力来改善整个系统。 代码审查也是确保已创建的安全测试用例(如第十二章所述)足够的绝佳机会。作为审查人员,如果你假设某些输入可能有问题,编写一个安全测试用例并查看结果,而不是猜测。如果你的探索性测试用例揭示了一个漏洞,提出问题并贡献测试用例以确保它得到修复。 ## 依赖关系 > 依赖导致服从。 > > —托马斯·杰斐逊 现代系统往往建立在大量外部组件的基础上。这些依赖关系在多方面都存在问题。许多平台,如npm,自动拉取许多难以跟踪的依赖关系。使用已知漏洞的旧版本外部代码是业界尚未系统消除的最大持续威胁之一。此外,存在在软件供应链中拾取恶意组件的风险。这可以通过几种方式发生;例如,使用与知名组件类似名称的软件包可能会被错误选择,你可以通过其他组件间接地通过它们的依赖关系获得恶意软件。 向系统添加组件可能会损害安全,即使这些组件旨在加强安全性。你必须信任不仅组件的来源,还要信任源代码信任的一切。除了额外代码带来的增加错误和整体复杂性的不可避免的风险外,组件还可能以意想不到的新方式扩展攻击面。二进制分发几乎是不透明的,但即使有源代码和文档,仔细审查和理解包中的所有内容通常是不可行的,因此通常归结为盲目信任。防病毒软件可以检测和阻止恶意软件,但它也使用深入系统的普遍钩子,需要超级用户访问权限,并且可能增加攻击面,例如当它打电话回家获取最新的恶意软件数据库并报告发现时。选择一个易受攻击的组件可能最终降低安全性,即使你的意图是增加额外的防御层。 ### 选择安全组件 为了整个系统安全,每个组件都必须是安全的。此外,它们之间的接口必须是安全的。在选择安全组件时,考虑以下一些基本因素: * 组件本身和其制造商的安全记录如何? * 组件的接口是专有的,还是有兼容的替代品?(更多选择可能提供更安全的替代品。) * 当(而不是如果)在组件中发现安全漏洞时,你有信心其开发人员会迅速做出反应并发布修复吗? * 保持组件最新的操作成本(换句话说,努力、停机
第一章:后记
我们被召唤成为未来的建筑师,而不是它的受害者。
—R·巴克敏斯特·富勒

在过去 50 年里看着计算技术发展,我已经明白,试图预测未来是愚蠢的。然而,为了总结本书,我想提出我关于安全领域未来方向的想法,尽管其中一些可能不太可能实现。我接下来的观点绝非预测,而是一些能够构成重大进展的可能性。
新生的互联网在 1988 年迎来了一个警钟,当时莫里斯蠕虫首次展示了在线恶意软件的潜在威力,以及它如何通过利用现有漏洞传播。30 多年过去了,尽管我们在许多方面取得了惊人的进展,但我仍然怀疑我们是否已经完全理解这些风险,并足够重视减轻风险的工作。攻击和私人数据泄露的报道仍然屡见不鲜,且看不到尽头。有时,似乎攻击者正在畅快攻击,而防御者却在拼命应对。而且值得记住的是,许多事件是保密的,甚至可能在未被察觉的情况下持续存在,所以现实情况几乎肯定比我们知道的更糟。很大程度上,我们已经学会了与脆弱的软件共存。
值得注意的是,尽管我们不完美的系统不断遭到破坏,一切 somehow 仍然能够持续运作。也许这就是安全问题持续存在的原因:现状“足够好”。但是,即使我理解投资回报的冷静逻辑,内心深处我仍然无法接受这一点。我相信,当我们作为一个行业接受现状为我们能做的最好时,我们就会阻碍真正的进步。为了安全而进行额外工作的辩护总是很困难,因为我们很少了解失败的攻击,甚至无法知道哪些防御措施是有效的。
本章总结了提升我们集体软件安全水平的有前景的未来方向。第一部分回顾了本书的核心主题,总结了如何有效地应用本书中的方法。接下来的部分设想了进一步的创新和未来的最佳实践,更具推测性。关于移动设备数据保护的讨论提供了一个例子,展示了在“最后一公里”上实际交付有效安全所需做的工作还有多少。我希望本书中的概念性和实践性思路能够激发你对这一重要且不断发展的领域的兴趣,并为你在软件安全方面的努力提供跳板。
行动号召
教育的伟大目标不是知识,而是行动。
—赫伯特·斯宾塞
本书围绕着两个简单的观点展开,我相信这将有助于提高软件安全性:让所有参与软件开发的人都参与到促进软件安全的工作中,并且从需求和设计阶段就将安全角度与策略整合进来。我恳请本书的读者们带头推动这一变革。
此外,持续关注我们所创建软件的质量将有助于提高安全性,因为较少的漏洞意味着较少可被利用的漏洞。高质量的软件需要付出努力:包括有竞争力的设计、细心的编码、全面的测试以及完整的文档,所有这些都需要随着软件的演进而保持更新。开发人员和最终用户都必须不断推动更高的质量和精细化标准,以确保这一关注点能够持续保持。
安全是每个人的责任
安全分析最好由深刻理解软件的人来做。本书阐述了良好安全实践的概念基础,使任何软件专业人员都能理解设计的安全方面,学习安全编码等内容。与其让专家来发现并修复因为安全性大多被忽视而产生的漏洞,不如大家一起努力,确保我们生产的所有软件至少达到一个基本的安全标准。然后,我们可以依赖专家来处理那些更为复杂和技术性的安全工作,这正是他们技能的最佳应用领域。以下是其逻辑:
-
尽管专家顾问精通安全,但作为外部人员,他们无法完全理解软件及其需求的背景,包括它如何在企业文化和最终用户的使用环境中运作。
-
安全性在整个软件生命周期中最为有效,但长期聘请安全顾问并不现实。
-
熟练的软件安全专业人员需求量大,难以找到,且在短时间内很难安排工作。雇佣他们的成本也很高。
安全思维并不难,但它是抽象的,起初可能会觉得不熟悉。大多数漏洞事后看来往往是显而易见的;然而,我们似乎总是一次又一次地犯相同的错误。当然,诀窍在于在问题显现之前就能够发现潜在的风险。本书提供了许多方法,帮助你学习如何做到这一点。好消息是,没有人能做到完美,所以即使从小处着手,也比什么都不做要好。随着时间的推移,你会越来越擅长此道。
更广泛的安全参与最好理解为一种团队合作,每个个体都发挥他们最擅长的部分。关键的观点不是每个个体能够独自完成整个工作,而是团队成员以多样化的技能相互协作,从而产生最佳的结果。无论你在生产、维护或支持软件产品中扮演什么角色,专注于你的主要贡献部分。但同时,考虑相关组件的安全性,并仔细检查团队成员的工作,确保他们没有忽视任何问题,也是非常有价值的。即使你的角色很小,你也可能发现一个至关重要的缺陷,就像足球门将偶尔会进球一样。
需要明确的是,外部专家对于执行任务如差距分析或渗透测试、平衡组织能力以及作为具有深厚经验的“新眼睛”非常有价值。然而,专业顾问应该补充扎实的内部安全理解和良好的实践,而不是仅仅承担起安全的重担。即使专家确实为整体安全状态做出贡献,他们也会在一天结束后去参与其他项目。因此,最好的做法是让尽可能多的团队成员在负责软件的过程中定期考虑安全问题。
安全性内置
桥梁、道路、建筑物、工厂、船舶、大坝、港口和火箭等都经过设计并仔细审查,以确保质量和安全,只有在确认无误后才开始建造。在任何其他工程领域,大家都承认在纸上精细打磨设计比事后再加装安全措施要好。然而,大多数软件都是先建造出来,之后再进行安全加固。
本书的一个核心前提是,作者在行业中屡次看到的事实:早期的安全审慎可以节省时间并带来显著的回报,提升结果的质量。当设计充分考虑到安全性时,实施者能够更容易地交付一个安全的解决方案。将安全性融入组件的结构设计使得预测潜在问题变得容易。
最坏的情况,也是将安全前置到设计阶段(业界流行的术语是“向左移动”)的最有力理由,就是为了避免设计中的安全漏洞。设计时引入的安全漏洞——无论是在组件化、API 结构、协议设计,还是架构的其他方面——都可能是灾难性的,因为事后修复几乎不可能而且会破坏兼容性。及早发现和修复这些问题是避免痛苦且费时的反应性重设计的最佳方法。
良好的安全设计决策通常会带来更大的好处,尽管这些好处往往未被注意到。良好设计的本质是简约而不妥协必要的功能。应用到安全时,这意味着设计最小化攻击面和关键组件的交互区域,这也意味着开发者在实现过程中犯错的机会更少。
专注于安全的设计评审非常重要,因为软件设计的功能评审通常会从不同的角度出发,提出一些忽视安全性的问题。“它是否满足所有必要的要求?操作和维护是否简便?有没有更好的方法?”事实上,一个不安全的设计完全可以顺利通过所有这些测试,而仍然容易受到毁灭性攻击。通过安全评估来补充设计评审,可以通过了解面临的威胁并考虑设计可能如何失败或被滥用,从而验证设计的安全性。
软件安全的实现方面包括学习并警惕避免多种潜在的方式,以免无意中创建漏洞,或者至少尽量避免常见的陷阱。安全设计通过最小化实现过程中可能引入漏洞的机会来减少风险,但它永远无法让软件做到万无一失。开发人员必须小心谨慎,避免踏入任何可能的陷阱,避免破坏安全性。
安全是贯穿整个软件系统生命周期的一个过程,从构思到最终的退休。数字系统复杂且脆弱,随着软件“吞噬”世界,我们变得越来越依赖它。我们是使用不完美组件来构建足够好的系统以应对不完美人的不完美的普通人。但正因为完美无法达到,并不意味着我们不能进步。相反,这意味着每一个修复的漏洞、每一个改进的设计、每一个新增的安全测试用例,都能在大大小小的方面帮助让系统变得更加可信。
未来的安全
未来取决于你今天所做的事情。
—圣雄甘地
本书围绕我实践过并且一直有效的安全改进方法展开,但除了这些之外,仍有很多需要做的事情。以下小节概述了我认为有前景的一些想法。尽管这些观念需要进一步发展,但我相信它们可能会带来显著的进展。
人工智能或其他先进技术提供了很大的潜力,但我的直觉是,很多所需的工作属于“劈柴、挑水”类型的工作。我们每个人都能贡献的一种方式是,通过确保我们所生产的软件质量,因为正是从错误中产生了漏洞。其次,随着我们的系统在能力和范围上增长,复杂性也必然增加,但我们必须进行管理,以免不堪重负。第三,在研究这本书时,我对世界软件状况及其安全性缺乏确凿数据感到失望(但并不惊讶):毫无疑问,更多的透明度将有助于我们更清晰地前进。第四,真实性、信任和责任是软件社区安全合作的基石,然而,实施这些机制的现代方式大多是临时和不可靠的——在这些领域的进展可能会改变游戏规则。
提高软件质量
“程序员们拿钱去加入错误,也拿钱去修复错误。” 这是我在 25 年前作为微软程序经理时听到的最令人难忘的观察之一,而这种对错误不可避免性的态度至今仍然存在,短期内似乎没有改变的风险。但错误是漏洞的构建块,因此了解有缺陷的软件的全部成本非常重要。
改善安全性的一种方法是通过增加传统的错误筛查,考虑每个错误是否可能是攻击链的一部分,并优先修复那些可能性较高且风险较大的错误。即使只有一小部分错误修复关闭了一个实际漏洞,我认为这些努力是完全值得的。
管理复杂性
一个不断发展的系统会增加其复杂性,除非采取措施将其减少。
—梅尔·莱曼
随着软件系统规模的增大,管理由此产生的复杂性变得更加具有挑战性,这些系统也面临变得更加脆弱的风险。最可靠的系统通过将复杂性划分为具有简单接口的组件,并以松耦合的容错配置进行组织,从而取得成功。大型 web 服务通过在多台机器之间分配请求来实现高弹性,这些机器执行特定功能以合成完整的响应。设计时考虑到冗余,在发生故障或超时的情况下,系统可以在必要时通过不同的机器重新尝试。
将大型信息系统中多个组件的各自安全模型进行隔离是成功的基本要求。组装组件之间的微妙相互作用可能影响安全性,使得保护系统的任务变得更加困难,因为相互依赖性不断加剧。除了优秀的测试外,良好文档化的安全要求和依赖关系是处理复杂系统时重要的第一道防线。
从最小化到最大化透明度
或许对软件安全现状最为悲观的评价来源于这个(被不同人归于)格言:“如果你不能衡量它,你就不能改进它。”遗憾的是,世界上关于软件质量,尤其是安全性的衡量标准匮乏。公众对安全漏洞的了解仅限于几个案例:开源软件、专有软件的公开版本(通常需要逆向工程二进制文件),或是研究人员发现漏洞并公开详细分析的情况。很少有企业会考虑公开其软件安全记录的完整细节。作为一个行业,我们从安全事件中学到的东西很少,因为完整的细节很少被披露——这在很大程度上是因为恐惧。虽然这种恐惧并非没有根据,但它需要与更广泛社区从更具信息量的披露中获得的潜在价值相平衡。
即使我们接受公开披露所有安全漏洞的障碍,仍然有很大的改进空间。主要操作系统的安全更新披露通常缺乏有用的细节,这对用户造成了不利影响,因为用户可能会发现额外的信息有助于响应和评估风险。在作者看来,主要的软件公司常常对它们所提供的信息进行掩盖,甚至到达了双重表述的地步。以下是最近一个操作系统安全更新中的一些例子:
-
“通过改进限制解决了一个逻辑问题。”(这几乎适用于任何安全漏洞。)
-
“通过改进内存处理解决了一个缓冲区溢出问题。”(除了改进内存处理,还有什么方法可以修复缓冲区溢出问题呢?)
-
“通过改进输入消毒解决了一个验证问题。”(同样,这可以说是任何输入验证漏洞的描述。)
这种缺乏细节的情况已经成为许多产品的惯例;它损害了客户的利益,软件安全社区也会从更具信息量的披露中受益。软件发布者几乎总是可以提供额外的信息,而不损害未来的安全性。实际上,敌对者会分析更新中的变化并提取基本细节,因此无用的发布说明只会剥夺诚实客户获得重要信息的机会。未来负责任的软件供应商最好从完全披露开始,然后根据需要进行删减,以免削弱安全性。更好的是,在利用风险过后,应该安全地披露那些尚未公开的额外细节,这些细节对于我们理解主要商业软件产品的安全性是有价值的,即使只是从事后视角来看。
提供漏洞的详细报告可能会让人尴尬,因为回顾时问题通常显而易见,但我认为,诚实地面对这些疏漏是健康且富有成效的。从完全公开中学习的潜力非常大,如果我们认真对待长期的安全性,就需要更大的透明度。作为客户,我会对一个软件供应商更有好感,如果他们的安全修复发布说明包括:
-
错误报告、分类、修复、测试和发布的日期,并解释任何不正常的延迟原因。
-
描述漏洞是何时以及如何产生的(例如,疏忽的编辑、对安全影响的无知、沟通失误或恶意攻击)。
-
关于包含缺陷代码的提交是否经过审查的信息。如果有,为什么会错过;如果没有,为什么没有审查?
-
是否有努力寻找类似缺陷的记录。如果有,发现了什么?
-
采取的任何预防措施,以防止将来出现回归或类似的缺陷。
将行业推动向分享更多漏洞、其原因和缓解措施的文化转变,使我们所有人都能从这些事件中学习。如果没有足够的细节或背景,这些披露只是走个过场,无法为任何人带来益处。
最佳实践的一个很好的例子是国家运输安全委员会(NTSB),该委员会发布了详细的报告,航空业和飞行员可以遵循这些报告,从事故中学习。由于多种原因,软件不能简单地遵循这一过程,但它为我们提供了一个值得效仿的榜样。理想情况下,领先的软件制造商应将公开披露视为一个机会,准确解释幕后发生了什么,展示他们在响应中的能力和专业性。这不仅有助于广泛学习并防止其他产品中出现类似问题,还有助于重建用户对其产品的信任。
改进软件的真实性、信任和责任
现代大型软件系统由许多组件构建而成,这些组件必须是可信的,由可靠的实体构建,使用安全的子组件和安全的工具栈。这个链条不断延续,追溯到现代数字计算的黎明。我们系统的安全性依赖于所有这些构建我们现代软件栈的迭代的安全性,但这些传承的具体链条已经逐渐消失在计算历史的迷雾中,回溯到最初的几款自编译编译器,正是它们开始了这一切。Ken Thompson 的经典论文《Reflections on Trusting Trust》优雅地展示了安全性如何依赖于这些历史,以及当恶意软件深度嵌入后,它是多么难以被发现。我们怎么知道有什么不良代码潜藏其中呢?
确保我们软件构建完整性的必要工具现在已经免费提供,而且可以合理假设它们如宣传所述运作。然而,它们的使用往往显得临时和手动,使得过程容易受到人为错误的影响,甚至潜在的破坏。比如,人们有时为了节省时间,理所当然地跳过了检查。举个例子,验证*nix 发行版的合法性。在从一个可信的网站下载镜像文件后,你还需要下载单独的授权密钥和校验和文件,然后使用一些命令(来自可信来源)来验证这些文件。只有在所有检查都通过后,才可以进行安装。但实际上,管理员们究竟多彻底地执行了这些额外步骤,尤其是当大型发行版的这些检查失败的情况鲜有耳闻时呢?即使他们每次都执行这些检查,我们也没有任何记录来做保障。
当前,软件发布者会对发布的代码进行签名,但签名仅能保证数据完整性,防止篡改。这意味着签名的代码是可信的,但如果后续发现漏洞,这并不会使签名失效,因此这根本不是什么安全的解读。
在未来,更好的工具,包括可审计的真实性链记录,可以提供更高的完整性保障,帮助做出信任决策并处理依赖,这些都是我们系统安全所依赖的。举例来说,新型计算机应该包括一个软件清单,记录操作系统、驱动程序、应用程序等是否真实可靠。记录并认证软件材料清单及其组件的构建环境需要大量的工作,但我们不应因其困难而放弃从部分解决方案入手,并随着时间逐步改进。如果我们开始认真对待软件来源和真实性,我们就能更好地确保重要的软件版本是基于安全组件构建的,未来也将感谢我们。
完成最后一英里
最漫长的一英里是最后一英里回家的路。
—匿名
如果你认真遵循每个最佳实践,应用本书中描述的技巧,注意避开常见的编程陷阱,进行代码审查,全面测试,并完全记录整个系统,我希望能说你的工作是完全安全的。但当然,这要复杂得多。安全工作永远没有终点,甚至设计良好、工程精湛的系统,在现实世界中也可能未能提供预期的安全水平。
“最后一公里”这一术语来源于电信和交通行业,指的是将单个客户连接到网络的挑战。这通常是提供服务中最昂贵、最困难的部分。例如,一个互联网服务提供商可能已经在你的社区铺设了高速光纤基础设施,但获取每个新客户需要一次服务访问,可能还需要铺设电缆并安装调制解调器。所有这些都无法很好地扩展,而且时间和费用成为了巨大的前期投资。同样地,部署一个设计良好的安全系统通常只是实际实现真正安全性的开始。
要理解这些“最后一公里”安全挑战,我们可以通过一个简单的问题来深入了解当前移动设备数据安全的技术现状:“如果我丢失了手机,其他人能读取其中的内容吗?”经过多年的密集工程努力,开发出一套功能强大的现代加密技术,今天高端手机的答案似乎是:“是的,他们很可能能获取你大部分的数据。”由于这是近年来最大规模的单一软件安全努力,因此了解其不足之处和原因变得非常重要。
以下讨论基于 2021 年论文“移动设备的数据安全:当前的技术现状、开放问题与提出的解决方案”,该论文由约翰·霍普金斯大学的三位安全研究员撰写。报告描述了几种重要的方式,表明实现强大软件安全性通常仍然是难以捉摸的。我将大大简化讨论,以突出这个例子所教给我们的更大的安全性教训。
首先,我们来谈谈数据保护的级别。移动应用程序做了许多有用的事情——多到一个单一的加密方案无法涵盖所有内容——因此移动操作系统提供了一系列选择。iOS 平台提供了三个级别的数据保护,主要在于它们如何通过减少加密密钥在内存中存在的时间窗口来提高对保护数据的访问效率。你可以将其类比为银行金库门开关的频率。早上打开沉重的大门,直到闭店时间才关门,提供了全天方便的访问,但这也意味着在不使用时金库更容易受到入侵。相反,如果工作人员每次需要进入金库时都要找银行经理开门,他们便在增加安全性的同时放弃了便捷性:金库大部分时间都是安全上锁的。对于移动设备来说,要求用户解锁加密密钥(通过密码、指纹或面部识别)以访问受保护数据,基本上相当于要求银行经理开金库门。
在最高级别的保护下,加密密钥仅在手机解锁并使用时可用。虽然这种保护非常安全,但对大多数应用程序来说却是一种障碍,因为当设备锁定时,它们无法访问数据。例如,考虑一个日历应用程序,它会在开会时间到来时提醒你。锁定的手机会导致该应用程序无法访问日历数据。后台操作,包括同步,也会在锁定状态下被阻止。这意味着,如果在手机锁定时向日历中添加了一个事件,那么除非你提前解锁手机进行同步,否则你将无法收到通知。即使是最不限制的保护类别——首次解锁后(AFU)——也存在严重的限制。正如其名称所示,重新启动后的设备将无法获得加密密钥,因此日历通知也会被阻止。
我们可以设想通过将数据划分到不同的保护类别下,依据何时需要来设计应用程序,以绕过这些限制。也许对于日历来说,时间可以不受保护,以便随时可用,这样通知就会模糊地显示“你有一个下午 4 点的会议”,需要用户解锁设备才能查看详细信息。缺少标题的通知可能会让人烦恼,但用户也希望他们的日历是加密的以保护隐私,因此需要做出权衡。这些信息的敏感性可能因用户而异,也可能取决于会议的具体情况,但让用户在每个情况下明确决定并不可行,因为人们希望应用程序能够自动运行。最终,大多数应用程序选择增加对其管理的数据的访问权限,最终使用较低级别的数据保护——或者,通常根本没有使用保护。
当大多数应用程序为了便利性而在“无保护”选项下运行时,所有这些数据都是攻击者轻易窃取的目标,只要攻击者能够检查设备。虽然不容易,但正如约翰霍普金斯报告所详细描述的,复杂的技术通常能够找到进入内存的方式。开启 AFU 保护后,攻击者所需要做的只是找到加密密钥,而由于设备大部分时间处于这种状态,密钥通常会保存在内存中。
保密消息应用程序是这一规则的主要例外;它们使用“完全保护”类别。考虑到它们的特殊用途,用户往往愿意忍受设备锁定时缺失的功能以及使用它们所需的额外努力。这些应用程序仅占少数,占本地存储用户数据的比例微乎其微,但大多数手机用户(即使是那些对安全有所关注的人)可能认为他们所有的数据都是安全的。
好像情况还不够糟糕一样,让我们再考虑一下云集成对许多应用的重要性,以及它与强数据保护之间的矛盾。云计算模型已经彻底改变了现代计算,我们现在习惯了能够随时随地访问无处不在的连接数据中心,进行网页搜索、实时翻译、图像和音频存储,以及其他各种服务。像使用面部识别搜索我们照片集中的人物这样的功能,远远超出了现代设备的计算能力,因此它非常依赖于云。云数据模型还使得多设备访问变得简单(不再需要同步),而且如果我们丢失了设备,数据就安全地存储在云端,因此我们只需购买新硬件。但为了利用云的强大功能,我们必须将数据交给云,而不是通过在设备上加密来锁定数据。
当然,所有这些无缝的数据访问与强数据保护是相对立的,特别是在丢失连接到云的手机的情况下。大多数移动设备都具有持久的云数据访问功能,因此任何找回该设备的人都有可能访问存储的数据。这些数据很可能没有加密;即使我们设想一个例如存储端到端加密数据的照片应用,那也意味着只能存储不透明的位数据,这样我们就失去了云端搜索或提供照片共享功能的能力。而且由于解密密钥必须严格保存在设备上,多设备访问的场景会变得困难。如果设备上的密钥出了问题,云中的所有数据也可能变得无用。基于这些原因,依赖云的应用几乎完全放弃了加密数据保护。
我们在这里仅仅触及了移动设备数据保护效果的技术细节,但对于我们的目的而言,更一般的问题轮廓应该已经很清晰了。移动设备存在于一个丰富且复杂的生态系统中,除非数据保护能涵盖所有组件和场景,否则它很快就变得不可行。最好的建议仍然是,避免在手机上存储任何一旦丢失可能会泄露的个人信息。
我想强调的这个故事的教训不仅仅局限于移动设备加密的设计,而是广泛适用于任何寻求提供安全的大型系统。关键是,尽管进行了细致的设计,并提供了丰富的数据保护功能,但在最后一公里提供完整安全的工作往往会落空。拥有强大的安全模型只有在开发人员使用它并且用户理解其好处时才有效。实现有效的安全性需要提供一个既能与应用程序合作而非对抗的、有用的功能平衡。所有需要保护的数据都必须得到保护,且与基础设施(如此例中的云)之间的交互或依赖不应削弱其有效性。最后,所有这些必须与典型的工作流集成,以便终端用户为安全机制做出贡献,而非与之对抗。
多年前,我亲眼目睹了在.NET 框架发布时的最后一公里失败。安全团队在将代码访问安全(CAS)——第三章中描述——引入这个新的编程平台时做了大量努力,但未能足够推动其使用。回想一下,CAS 要求托管代码被授予权限来执行特权操作,并在需要时进行断言——这是实现最小特权模式的理想工具。不幸的是,除了运行时团队之外,开发人员将其视为一种负担,并未看到该功能的安全益处。因此,应用程序通常不是在需要的地方使用系统提供的细粒度权限,而是在程序开始时断言完全的权限,然后完全不受限制地运行。这样做在功能上是有效的,但意味着应用程序以过多的权限运行——可以说,就像银行金库的门始终敞开一样——使得任何漏洞都比如果按预期使用 CAS 时暴露的风险更大。
这些考虑因素代表了所有系统面临的挑战,也是安全工作永远无法完成的一个重要原因。构建出一个优秀的解决方案后,我们需要确保开发人员和用户都能理解它,确保它被实际使用,并且使用得当。软件总是会以其创造者未曾预料的方式被使用,而在了解这些案例时,考虑安全的后果并在必要时做出调整非常重要。所有这些因素及更多因素对于构建真正有效的安全系统都是至关重要的。
结论
软件具有一个独特且幸运的特性,那就是它完全由比特组成——它只是一些 0 和 1——所以我们可以字面上从空气中创造它。材料是免费的,且可以无限量获取,因此我们的想象力和创造力是唯一的限制因素。这对于善良的力量和寻求伤害的人来说都是如此,因此,既有希望也有严峻的挑战是无边界的。
本章提供了行动号召和一些前瞻性的想法。在开发软件时,务必在早期考虑安全性影响,并让更多的人思考安全问题,以便提供更多元化的视角。提高安全意识有助于在整个软件生命周期内保持健康的怀疑态度和警觉性。减少对手动检查的依赖,提供更多的自动化验证。保留所有关键决策和行动的可审计记录,以便系统的安全性得以明确定义。明智地选择组件,同时测试假设和系统的重要属性。减少脆弱性;管理复杂性和变化。当漏洞出现时,调查其根本原因,从中学习,并主动减少未来的风险。批判性地审视现实场景,并致力于将安全带到最后一公里。尽可能负责地公开细节,以便其他人可以从您遇到的问题以及您的应对中学习。不断地以小步伐迭代,改善安全性并尊重隐私。
感谢您与我一同踏上这段穿越软件安全的山丘和山谷之旅。我们当然没有涵盖每一个细节,但现在您应该对这个领域有了初步的了解。我希望您在其中发现了有用的想法,并且通过更深入的理解,开始将它们付诸实践。这本书不是唯一的答案,但它提供了一些提高软件安全性的答案。最重要的是,请不时地戴上您的“安全帽”,并将这些概念和技巧应用到您的工作中,从今天开始。
第二章:A
示例设计文档

以下文档为假设性设计,旨在说明如何对实际设计执行安全设计评审(SDR)。作为学习工具,它省略了在真实设计中会出现的许多细节,重点放在安全方面。因此,它并不是一个完整的真实软件设计文档示例。
标题 – 私有数据日志记录组件设计文档
目录
-
第一部分 – 产品描述
-
第二部分 – 概述
-
2.1 目的
-
2.2 范围
-
2.3 概念
-
2.4 需求
-
2.5 非目标
-
2.6 未解决的问题
-
2.7 替代设计
-
-
第三部分 – 用例
-
第四部分 – 系统架构
-
第五部分 – 数据设计
-
第六部分 – API
-
6.1 Hello 请求
-
6.2 架构定义请求
-
6.3 事件日志请求
-
6.4 Goodbye 请求
-
-
第七部分 – 用户界面设计
-
第八部分 – 技术设计
-
第九部分 – 配置
-
第十部分 – 参考文献
第一部分 – 产品描述
本文档描述了一个日志记录组件(以下简称为 Logger),该组件提供标准的软件事件日志记录功能,以支持审计、系统监控和调试,旨在减少无意信息泄露的风险。Logger 将明确处理日志中的私有数据,以便非私有数据可以自由访问进行常规使用。在少数情况下,当这种访问级别不足时,可以提供对受保护的私有日志数据的有限访问,但需经过明确批准,并采取限制措施以最小化潜在的暴露风险。
在日志记录系统中明确单独处理私有数据的概念是安全为中心的设计思维的一个例子。与从一开始就设计此功能相比,将其添加到现有系统中将效率较低,并且需要大量的代码修改。
第二部分 – 概述
有关基础项目设计假设,请参见第十部分中列出的文档。
2.1 目的
数据中心中的所有应用程序需要记录重要软件事件的详细信息,由于这些日志可能包含私有数据,因此需要实施严格的访问控制。Logger 提供了生成日志、存储日志以及对授权人员执行适当访问控制的标准组件,同时保持对发生的访问行为可靠且不可否认的记录。由于系统的日志记录、访问和保留要求各不相同,Logger 基于简单的策略配置运行,并指定访问策略。
2.2 范围
本文档解释了 Logger 软件组件的设计,但并未强制要求选择实现语言、部署或操作考虑因素。
2.3 概念
日志的过滤视图的概念是设计的核心。其想法是允许相对自由地检查日志,同时过滤掉所有私密细节,这种访问级别应适用于大多数用途。此外,在需要时,可以检查记录的敏感数据,需经过额外授权。访问事件也会被记录下来,确保检查过程是可审计的。这种分级访问允许应用程序记录重要的私密细节,同时最大程度地减少这些数据在内部员工的合法使用中暴露的方式。对于那些敏感到绝不应出现在日志中的数据,根本就不应该将其记录在日志中。
例如,Web 应用程序通常会记录 HTTPS 请求,作为系统使用的记录以及其他许多原因。这些日志通常包含私密信息(包括 IP 地址、Cookie 以及更多),这些信息必须捕获,但很少需要。例如,IP 地址在调查恶意攻击时非常有用(用来识别攻击的来源),但对于其他用途并不重要。日志的过滤视图隐藏或“包装”私密数据,同时显示非敏感数据。在过滤视图中指定的化名可以显示,例如,所有标记为“IP7”的事件的 IP 地址是相同的,而不披露实际地址。这样的过滤视图通常为监控、统计收集或调试提供足够的信息。如果是这种情况,避免暴露任何私密数据是有利的。日志仍然包含完整的数据,在少数情况下,当需要保护的信息时,可以在经过适当授权的情况下以控制方式访问未过滤的视图。
假设一个 Web 应用程序接收到一个用户登录尝试,触发了一个错误,导致过程崩溃。以下是日志可能包含的简化示例:
2022/10/19 08:09:10 66.77.88.99 POST login.htm {user: "SAM", password: ">1<}2{]3[\4/"}
此日志中的项包括:时间戳(不敏感)、IP 地址(敏感)、HTTP 动词和 URL(不敏感)、用户名(敏感)以及密码(非常敏感)。调查时可能需要考虑所有这些信息以重现错误,但除非绝对必要,否则不应以明文显示这些数据,且只有授权的人员才能查看。
为了满足各种系统的安全需求,各种日志数据的敏感度应该是可配置的,且日志系统应仅选择性地揭示机密数据。例如,作为最佳实践,URL 不应包含敏感信息,但一个旧系统可能被认为违反了这一经验规则,需要额外的保护,而这种保护通常是不必要的——这使得过滤后的视图在某些调试时不太有用。在 URL 的情况下,正则表达式可以帮助配置某些 URL 为比其他 URL 更加敏感。
之前示例日志的过滤视图,省略或包装敏感数据,可能看起来是这样的:
2022/10/19 08:09:10 US1(v4) POST login.htm {user: USER1(3), password: PW1(12)}
IP 地址、用户名和密码都被包装为标识符以隐藏数据,但替代的标识符可以在上下文中用于查询其他具有匹配值的请求。例如,US1表示美国的 IP 地址;USER1表示与事件关联的用户名,但不具体透露;PW1表示提交的密码。括号中的后缀表示实际数据的格式或长度,添加了一个提示而不透露具体细节:我们可以看到它是一个 IPv4 地址,用户名有 3 个字符,密码有 12 个字符。例如,如果一个过长的密码导致了问题,仅凭其惊人的长度就能显现这一点。知道密码的长度会泄露一些信息,但在实际操作中不应构成安全威胁。
当过滤后的视图不足以完成当前任务时,可以发出额外请求来解包标识符,如US1。这使得查看敏感数据成为一个明确的选择,并允许逐步揭示数据。例如,如果只需要 IP 地址,则用户名和密码的值将保持不公开。
2.4 要求
日志可靠存储,在获得授权后可以立即访问,并在保留期结束后销毁。为了支持高频使用,日志捕获接口必须快速,一旦报告成功,生成应用程序可以合理地保证日志已存储。
可以在不知道私人详情的情况下监控日志,因此可以广泛提供过滤后的日志视图供大多数用途使用,仅在绝对必要时,才需要特殊授权才能查看完整数据(包括私人数据)。
该设计的一个重要目标是允许记录非常敏感的私人数据,这些数据可以用于调查可能的安全事件,或在极少数情况下,用于调试仅在生产环境中发生的问题。完全防范内部攻击是不切实际的目标,但采取所有合理的预防措施并保留可靠的审计跟踪作为威慑是非常重要的。
日志存储经过加密,以防止在物理介质被盗的情况下泄露。
生成日志的软件完全信任;它必须正确识别私人数据,以便 Logger 能正确处理它。
2.5 非目标
由于 Logger 是为管理员使用而设计的,因此无需华丽的用户界面。
内部攻击,例如代码篡改或滥用管理员 root 权限,超出了本设计的范围。
为了有效,Logger 需要仔细的配置和监督。如何实现这一点必须由系统管理定义,但应包括审查过程和检查与平衡的审计。
2.6 未解决的问题
日志访问配置、用户认证和未过滤访问授权的授予细节尚待确定。
查询加密的私密数据本身是很慢的。该设计设想日志数据量足够小,能够通过暴力破解(即不依赖索引)按需解密记录,并保持良好的性能。未来一个更为雄心勃勃的版本可能会解决加密数据的索引和快速查询问题。
需要识别错误情况并指定处理方法。
Logger 未来版本可以考虑的增强功能包括:
-
定义提供更多或更少详细信息的过滤视图级别
-
提供一种设施来捕获日志的部分内容进行长期安全存储,最终会定期删除
2.7 替代设计
最终选择的设计是完全信任 Logger 来存储所有敏感信息在日志中,”把所有鸡蛋放在一个篮子里“。曾考虑过一个替代方案,允许根据来源对敏感信息进行隔离。这种方案未被采纳,原因有几点(简要解释如下),这些原因似乎与重要的使用场景不兼容,但值得注意的是,这种方案可能会是一个更安全的日志记录解决方案。
替代设计
- 日志源将创建一对非对称加密密钥,并使用该密钥加密日志记录中敏感数据部分后发送给 Logger。如果小心处理,Logger 可能仍然能够为过滤后的视图生成化名(例如,将某个美国 IP 地址映射为
US1)。授权访问未过滤视图则需要使用私钥来解密数据。这种方法的主要优点是,存储的日志数据泄露不会泄露已加密的敏感数据,且 Logger 甚至没有必要的密钥。
未选择的原因
- 该设计将加密和密钥管理的责任放在日志源和授权访问者身上。数据敏感性及其如何进行分区的定义由日志源决定,并在那个时候固定。通过将信任集中在 Logger 上,可以根据需要重新配置这两个方面,并通过认证日志查看者来控制精细的访问权限。
第三部分 – 使用场景
数据中心的应用程序使用 Logger 生成重要软件事件的日志。常规监控软件和适当的操作人员可以访问过滤后的数据(无任何私密数据披露)以完成常规任务。操作统计数据,包括流量水平、活跃用户、错误率等,均来自过滤后的日志视图。
在少数情况下,当支持或调试需要访问未过滤的日志时,授权的人员可以根据策略获得有限的访问权限。访问请求指定所需日志的子集、它们的时间窗口以及访问原因。一旦批准,发放一个令牌,允许访问,并且会记录用于审计。完成后,请求者会添加一条描述调查结果的备注,审批人会审查该备注以确保合规性。
生成报告,详细总结请求、批准、审计审查、日志量趋势以及已过期日志数据删除的确认,以便向管理层提供信息。
第四部分 – 系统架构
在数据中心内,Logger 服务的实例运行在物理上独立的机器上,与它们所服务的应用程序独立运行,通过标准的发布/订阅协议进行通信。Logger 由三个新服务组成,组织如下:
Logger Recorder
- 一个日志存储服务。应用程序通过加密通道将日志事件数据流式传输到 Logger Recorder 服务,在那里它们被写入持久化存储。可以配置一个实例来处理多个应用程序的日志。
Logger Viewer
- 一个 web 应用程序,技术人员使用它手动检查过滤后的日志,并且根据策略授权可以显示未过滤的视图。
Logger Root Recorder
1. Logger Recorder 的一个特殊实例,用于记录 Logger Recorder 和 Viewer 的事件。为了简化,我们省略了这个日志的过滤和未过滤视图的细节。*
第五部分 – 数据设计
日志数据直接从应用程序收集,应用程序决定哪些事件以及哪些详细信息应该被记录。日志是只追加的记录,记录软件事件,除了在过期时被删除外,从不被修改。
应用程序定义了日志事件类型的模式,其中包含零个或多个预配置的数据项,如以下示例所示。所有日志事件必须具有时间戳和至少一个其他标识数据项。
{LogTypes: [login, logout, ...]}
{LogType: login, timestamp: time, IP: IPaddress, http: string,
URL: string, user: string, password: string, cookies: string}
{LogType: logout, timestamp: time, IP: IPaddress, http: string,
URL: string, user: string, cookies: string}
{Filters: {timestamp: minute, IP: country, verb: 0, URL: 0,
user: private, password: private, cookies: private}}
关于内建类型、格式化等的许多细节被省略,因为这些内容如何定义的基本思路应该从这个部分示例中可以清楚地看出。
请求和响应必须是 UTF-8 编码的有效 JSON 表达式,且长度不得超过 100 万字符。单个字段值的最大长度为 10,000 字符。
第一行(LogTypes)列出了该应用程序将产生的日志事件类型。对于每种类型,带有相应 LogType 条目的 JSON 记录(第二行是 LogType: login)列出了可以随日志提供的允许数据项。
第四行(Filters)声明了每个数据项的处理方式:0 表示非敏感数据,private 表示要“包装”的私密数据,以及其他特殊类型的数据处理,包括:
minute
- 时间值四舍五入到最接近的分钟(遮蔽了精确时间)
country
- 在过滤视图中,IP 地址会映射到原始国家。
过滤器应通过可插拔组件定义,并且容易扩展以支持各种应用程序所需的自定义数据类型。
请注意,“非敏感”数据仅应供有限的内部查看;此标签并不意味着这些数据应公开披露。 所有数据项必须声明,包括处置方式(私密或公开),这一要求是为了确保在应用程序上下文中对每项数据做出明确的决策。这些定义及其任何更新必须经过仔细审查,以确保日志处理的完整性。
这是该模式在未过滤视图中的日志条目示例:
2022/10/19 08:09:10 66.77.88.99 POST login.html {user: "SAM", password: ">1<}2{]3[\4/"}
这是相应的过滤视图:
2022/10/19 08:09 US1(v4) POST login.html {user: USER1(3), password: PW1(12)}
数据会被持久存储,并且在策略配置的到期日期之前可用,时间以事件日志时间戳为基准。
日志仅为瞬时数据,旨在用于监控、调试或在发生安全漏洞时进行取证,因此只会保留有限的时间。通过将数据存储在专用机器上,并使用 RAID(或类似)磁盘阵列进行冗余持久存储,可以降低潜在的数据丢失风险。日志作为短期存储用于审计和诊断目的。任何此类数据的长期存储应单独存储。
第六部分 – API
Logger 记录器的网络接口接受以下远程过程调用:
你好
- 必须是会话的第一个 API 调用;标识应用程序和版本
模式
- 定义日志数据模式(参见第五部分)
日志
- 发送事件数据(参见第五部分)以记录到指定的日志中
再见
- 当应用程序终止时发送,结束会话
每个应用程序通过专用通道连接到其日志服务。HTTPS 通过认证的端点确保 API 调用的安全;预配置的服务器名称通过其数字证书验证客户端是否连接到有效的 Logger 服务实例。 以下是请求类型。
6.1 你好请求
任何将使用 Logger 服务的进程都需要发送此请求以启动日志记录:
{"verb": "Hello", "source": "Sample application", "version": "1"}
以下响应确认请求并提供 OK 或错误消息,并为会话提供一个字符串令牌:
{"status": "OK", "service": "Logger", "version": "1", "token": "XYZ123"}
该令牌用于后续请求中,以识别与 你好 对应的启动应用程序的上下文。令牌是随机生成的,具有足够的复杂性和熵以避免猜测:推荐的最小令牌大小为 120 位,或约 20 个字符的 base64 编码。为了简洁起见,这里使用了较短的令牌。
6.2 模式定义请求
本请求定义了后续日志记录的数据模式,如第五部分所述:
{"verb": "Schema", "token": "XYZ123", ...}
为了简洁,省略了此请求的详细信息。
架构定义了将在日志内容中出现的字段名称、类型和其他属性,如下节中显示的示例事件日志请求所示(其中包括字段timestamp、ipaddr、http、url和error)。
6.3 事件日志请求
此请求实际上会记录一个条目到 Logger 服务中:
{"verb": "Event", "token": "XYZ123", "log": {
"timestamp": 1234567890, "ipaddr": "12.34.56.78",
"http": "POST", "url": "example", "error": "404"}}
log JSON 展示需要记录到日志中的内容,必须符合架构要求。
响应确认请求,返回 OK 或错误消息:
{"status": "OK"}
为了简洁,错误详情被省略。 日志错误(例如,存储空间不足)是严重的,需要立即处理,因为在没有日志记录的情况下,系统操作无法审核。
6.4 告别请求
此请求完成了日志记录的一个会话:
{"verb": "Goodbye", "token": "XYZ123"}
响应确认请求,返回 OK 或错误消息:
{"status": "OK"}
之后的令牌将不再有效。要恢复日志记录,客户端必须首先发送Hello请求。
第七部分 - 用户界面设计
日志记录器的用户界面是由 Logger Viewer 提供的 Web 界面,用于查看日志。Web 应用程序仅限授权操作人员访问,并通过企业单点登录进行身份验证。 经过身份验证的用户将看到他们被允许访问的日志,并可通过链接浏览或搜索最新的过滤日志条目,或者在允许的情况下,请求访问未过滤的日志,需经批准。
为简洁起见,本文仅提供 Web 界面的高层描述。
审批请求排队等待处理,Web 表单提供基本信息:
-
请求访问的原因,包括如客户问题票号等具体信息
-
请求的访问范围(通常是特定的用户账户或 IP 地址)
审批请求会触发自动邮件,发送给审批人,并提供一个链接,指向 Web 应用页面以审查这些请求。当每个决定作出时,系统会通过邮件通知请求者,内容如下:
-
审批或拒绝
-
拒绝的原因(如果适用)
-
批准访问的时间窗口
过滤和未过滤的日志在与每个日志对应的页面上可见。可以输入查询,指定要查看哪些日志条目。空查询显示最近的条目,并带有“下一页/上一页”链接,用于翻阅结果。
查询指定日志条目字段和值,并结合布尔运算符选择匹配的日志条目。默认情况下,最先显示最新的条目,除非查询中明确指定排序顺序。为简洁起见,查询语法的详细内容被省略。
过滤后的日志使用符号标识符显示(参见第 2.3 节),而不是原始日志内容。查询可以使用过滤日志内容中的符号标识符;例如,如果一个过滤后的日志条目显示 IP 地址US1,则查询[IP = US1]将找到来自该 IP 地址的其他日志,而不会透露实际的 IP 地址。
对过滤日志的查询必须禁止对具有精确值的过滤字段进行搜索。例如,即使 IP 地址未显示,如果用户能够猜到 [IP = 1.1.1.1] (等等),他们最终可能会找到一条日志条目,其中显示为 USA888,然后能够推断出实际值。
即使批准了未过滤的访问,用户也必须选择一个选项才能开始未过滤的查看和查询。最佳实践是最大限度地使用过滤日志,仅在需要时才揭示过滤后的值,且重要的是用户界面应鼓励这种做法。
用户在任务完成后可以放弃对未过滤日志的访问权。用户界面应在一段时间不活跃后提示此操作,以减少不必要访问的风险。
显示日志内容的网页不应被用户代理本地缓存,以避免无意的泄露,并确保在过期后,日志数据不再可用。
第八部分 – 技术设计
Logger Recorder 服务包括一个仅写接口,用于让应用程序流式传输日志事件数据,并将其写入持久化存储,同时提供查询接口以查看这些日志。存储是一个按顺序写入追加的文件序列,包含 UTF-8 文本行,每行代表一个日志事件。日志数据根据相关模式(见上文)映射到/从规范表示作为文本。格式化的细节在本示例中省略。
受过滤影响的日志数据字段应存储为过滤后的表示形式,并与使用服务生成的 AES 密钥加密的原始数据一起保存,每天使用一个新的密钥。使用硬件密钥存储或其他合适的方式来安全保护这些密钥。
由于存储空间耗尽会导致日志服务发生致命错误,因此写入速率是基于可用空间进行衡量的(free_storage_MB / avg_logging_MB_per_hour),如果空间不足以存储 10 小时的数据(假设写入量恒定),则会触发优先级操作警报(此警报的小时数可以配置)。
为了提高性能,可以考虑使用 SQL 数据库记录过滤后的日志事件信息(时间戳、日志类型、文件名和偏移量),以补充实际的日志文件,便于高效访问。
过滤日志通过符号标识符隐藏私人数据(例如,US1代表美国的 IP 地址)。为了避免存储未过滤的私人数据,这些映射从未过滤数据值的安全摘要映射到过滤后的标识符。 这种映射是临时的,由 Logger Viewer 根据每个日志的用户上下文单独维护。用户可以清除映射以重新开始,或者在 24 小时不使用后,它们会自动清除,以防止随着时间的推移积累无用数据。
第九部分 – 配置
日志保留配置如下。数据将在保留期结束后自动、安全并永久删除(不仅仅是移至回收站;请使用shred(1)命令或类似工具)。
Retention: {
"Log1": {"days": 10},
"Log2": {"hours": 24},
}
通过配置授权用户列表来授予日志访问权限:
Access: {
"Log1": {"filtered": ["u1", "u2", "u3", . . .],
"unfiltered": ["x1", "x2", "x3", . . .]},
"approval": ["a1", "a2", "a3", . . .]},
}
被允许过滤访问名为Log1的日志的用户将以括号表示,如上所示(例如,u1,u2,u3)。被允许不经过过滤访问的用户将以类似方式列出。这些用户只有在批准请求后才能获得访问权限。最后,有权授予有限不经过过滤访问权限的用户将以相同的方式列出。
第十部分 – 参考文献
以下文档对于理解本设计文档非常有用。
这些是虚构的。
-
企业基线设计假设文档(在第二部分中引用)
-
企业一般数据保护政策和指南
-
发布/订阅协议设计文档(在第四部分中引用)
文件结束*
第三章:B
术语表

专门针对软件安全的术语可能看起来很直观,但细微差别很重要,我根据在多个公司和众多项目中的经验,发展出了以下关于术语的安全特定含义,尽管这些定义普遍被接受,但如果你在实际使用中发现术语有所不同,也不要感到意外。如果你留心观察,你会发现安全专家在定义和使用相同的术语时,往往会带有稍微不同的含义,并从各自独特的角度来阐述这一领域的基础原则。你会听到很多变体,因为没有公认的标准词汇表;然而,这些变体通常可以根据上下文轻松推断出来。
受影响的用户
评估可能受到特定漏洞利用影响的用户比例。(DREAD的组成部分)
允许列表
允许的安全值的枚举。(参见 Blocklist)
评估报告
安全设计评审(SDR)的书面结果,包括按排名排列的发现和建议的总结,涉及特定设计变更和提高安全性的策略。
资产
有价值的数据或资源,特别是攻击的可能目标,需要被保护。
非对称加密
数据加密使用独立的密钥进行加密(公钥)和解密(私钥)。(参见 对称加密)
攻击
采取的行动,试图破坏安全性。
攻击者
一个恶意代理,试图破坏系统的安全性。(也称为 威胁行为者)
攻击面
系统中所有潜在入侵点的总和,用于攻击。
攻击向量
一系列步骤构成了一个完整的攻击过程,从攻击面开始,最终导致对资产的访问。
审计
保持可靠的记录,以便定期检查,检测可能表明不当活动的可疑行为。(金标准的组成部分)
身份验证(authN)
高度保证的主体身份确认。(金标准的组成部分)
真实性
确保数据真实性的保证,比数据完整性更强的声明。
授权(authZ)
确保特权访问仅限于某些已认证主体的安全策略控制。(金标准的组成部分)
可用性
确保数据访问始终对授权主体可用;换句话说,系统避免了妨碍合法访问的重大延迟或停机。(C-I-A的组成部分)
回溯
算法行为,如正则表达式匹配,其中进展可能会前进或回退,呈指数级重复。当回溯导致过多的计算时,可能会出现潜在的安全问题,从而降低可用性。
分组密码
一种对固定长度数据块进行加密处理的对称加密算法,与比特流相对。
阻止列表
一种列举应当禁止的危险值的方式。通常不推荐使用,因为除非完全列举,否则存在漏洞的风险。(参见允许列表)
瓶颈
代码执行路径中的单一节点,保护对特定资产的所有访问。瓶颈对于安全性至关重要,因为它们确保所有访问都进行统一的授权检查。
缓冲区溢出
一类涉及无效访问超出分配内存范围的漏洞。
证书授权机构(CA)
数字证书的颁发者。
瓶颈点
见瓶颈。
选择明文攻击
对加密的分析,攻击者能够学习明文对应的密文,从而削弱加密强度。
C-I-A
信息安全的基本模型。(见机密性、完整性和可用性)
密文
消息的加密形式,如果没有密钥则无法解读。
代码访问安全(CAS)
一种根据所有调用者的权限动态调整授权的安全模型,以减轻混淆代理漏洞。
碰撞
当两个不同的输入生成相同的消息摘要值时。
碰撞攻击
一种利用已知碰撞来颠覆依赖于加密消息摘要值唯一性的真实性的攻击。
命令注入
一种漏洞,允许恶意输入导致执行攻击者控制的任意命令。
机密性
强制只允许授权访问数据的信息安全基本属性。(C-I-A 组件)
混淆代理
一种脆弱模式,其中未经授权的代理可以欺骗授权的代理或代码,代表其执行有害的操作。
凭证
作为身份验证依据的身份、属性或权限的证据。
跨站请求伪造(CSRF 或 XSRF)
一种修改网站服务器状态的攻击,通常通过使用带有受害者客户端的 Cookie 上下文的 POST 请求。
跨站脚本攻击(XSS)
一种针对网站的特定注入攻击,其中恶意输入改变了网站的行为,通常会导致执行未经授权的脚本。
加密技术
将数据可逆地转换以隐藏其内容的数学方法。
加密安全伪随机数生成器(CSPRNG)
一种随机数源,认为其不可预测到足以无法猜测,因此适用于加密。(参见伪随机数生成器)
损害潜力
通过利用特定漏洞所造成的潜在危害程度的评估。(DREAD 组件)
去匿名化
对假定匿名的数据进行分析,推断出身份特征,从而破坏匿名性的程度。
解密
将密文转换回原始明文消息的过程。
拒绝服务(DoS)
一种消耗计算资源以降低可用性的攻击。(也是STRIDE组件)
依赖关系
软件库或系统中的其他组件,软件操作时所需的资源。
对话疲劳
人类对重复或无信息的软件下载对话框的反应,通常导致用户反射性地回应对话框以完成目标。当用户未能理解或考虑其行为的安全后果时,便会产生安全影响。
摘要
从任意大数据输入中计算出的唯一固定大小的数值。不同的摘要值保证输入不同,但可能会发生碰撞。(也叫做 哈希)
数字证书
一种经过数字签名的声明,宣称签名者的特定主张。常见的数字证书标准包括 TLS/SSL 安全通信(无论是服务器端还是客户端)、代码签名、电子邮件签名和证书颁发机构(根、中间、叶)。
数字签名
证明掌握私钥的一种计算方法,验证签名者的真实性。
可发现性
评估潜在攻击者如何容易地得知特定漏洞存在的过程。(DREAD 的组成部分)
分布式拒绝服务攻击(DDoS)
协调的拒绝服务攻击,通常使用大量机器人群体进行协调。
DREAD
用于评估漏洞严重性的五个组成部分的首字母缩略词。(见 损害潜力、可复现性、可利用性、受影响的用户 和 可发现性)
电子密码本(ECB)模式
一种块加密模式,其中每个块独立加密。由于相同的块会生成相同的输出,ECB 模式较弱,通常不推荐使用。
权限提升
任何通过攻击者利用漏洞获取更高权限的手段。(STRIDE 的组成部分)
加密
一种将明文转换为密文以秘密传达消息的算法。
熵源
一种生成不可预测比特流的随机输入源。
利用
违反安全规则并造成伤害的有效攻击方法。
可利用性
评估特定漏洞是否容易被利用的程度。通常这是一个主观的猜测,因为存在许多未知因素。(DREAD 的组成部分)
通信事实
知道两个通信者是否交换了信息,例如通过窃听者观察无法解密的加密消息。
缺陷
可能是漏洞也可能不是漏洞的缺陷,可能出现在设计或实现中。
脚枪
一种使得引入漏洞,尤其是安全漏洞,变得容易的软件特性。
模糊测试
通过自动化暴力测试和任意输入来发现软件缺陷。
金标准
三个基本安全执行机制的昵称。(见 审计、认证 和 授权)
守护
软件中的一种授权执行机制,用于控制对资源的访问。
硬件随机数生成器(HRNG)
一种硬件设备,旨在高效产生高度随机的数据。(见加密安全伪随机数生成器)
哈希
见摘要。
哈希消息认证码(HMAC)
一类消息摘要函数,其中每个密钥值决定唯一的消息摘要函数。
事件
安全攻击的特定实例。
信息泄露
未授权的信息泄露。(STRIDE组件之一)
注入攻击
一种安全攻击,利用恶意输入来利用漏洞,其中部分输入被以意外的方式解释。常见形式包括 SQL 注入、跨站脚本、命令注入和路径遍历。
输入验证
防御性检查输入数据,确保其格式有效,能够正确处理。
集成测试
多个组件协同操作的软件测试。(参见单元测试)
完整性
维护数据准确性的基本信息安全属性,或仅允许授权的修改和删除。(C-I-A组件之一)
密钥
决定数据如何转化的密码算法参数。(见私钥,公钥)
带密钥哈希函数
见哈希消息认证码(HMAC)。
密钥交换
一种协议,供两个通信方建立一个秘密密钥,即使所有交换的消息内容被攻击者揭露,密钥仍然安全。
消息认证码(MAC)
附带消息的数据,作为证据证明消息的真实性且未被篡改。(参见哈希消息认证码)
消息摘要
见摘要。
缓解措施
一种预防性对策,用于防止潜在攻击或减少其危害,如通过最小化损害、使攻击可恢复或便于检测来降低影响。
随机数
一次性使用的任意数字,例如在通信协议中防止重放攻击。
一次性密码本
用于消息加密的共享秘密密钥,由于重用会削弱其安全性,因此只能使用一次。
溢出
算术指令的错误结果,当值超出变量的容量时。未被检测到的溢出通常会通过引入意外的结果导致漏洞。
路径遍历
一种常见的漏洞,恶意输入将意外的内容注入文件系统路径,允许其指定超出预定访问范围的文件。
明文
加密前的原始消息,或由预定接收方解密后的消息。
预映像攻击
一种对消息摘要函数的攻击,试图找到一个输入值,该值生成特定的消息摘要值。
主要参与者
经过认证的代理:一个人、企业、组织、应用、服务或设备。
私钥
解密所需的参数,由授权接收方保密。
追溯
提供可靠的历史记录,追溯数据的来源和链条,增强数据有效性的信心。
伪随机数生成器(PRNG)
一种“相当好”的随机数生成器,易受到复杂分析的预测。这些随机数适用于许多用途,例如模拟,但由于它们不够随机,因此不适用于加密。(参见 加密安全伪随机数生成器)
公钥
为特定接收者加密消息所需的广泛已知参数。
随机数
一个任意选择的无法可靠预测的数字。
限速
一种减缓过程的方法,通常用于缓解依赖暴力重复才能成功的攻击。
重放攻击
通过重新发送先前的真实消息来攻击安全通信协议。如果攻击者重新发送之前的真实通信副本,并被误认为是原始发送者发送的后续相同消息,则重放攻击成功。
可重现性
评估在多次重复尝试中,特定漏洞被利用的可靠性。(DREAD的组成部分)
否认
行为的可信否认,特别是允许攻击者逃避责任。(STRIDE的组成部分)
根证书
自签名数字证书,授权信任证书颁发机构。
同源策略(SOP)
一组由网页客户端强制执行的限制,用于限制不同网站的不同窗口之间的访问。
沙箱
一种限制执行环境,旨在限制代码执行时可用的最大权限。
安全设计评审(SDR)
对软件设计安全性的结构化评审。
安全帽
一种表达方式,故意以安全思维为重点,思考事情可能出错的方式。
安全回归
已修复的已知安全漏洞的复发。
安全测试用例
检查安全控制是否正确实施的软件测试用例。
安全测试
确保安全控制正常工作的软件测试。
辅助通道攻击
一种间接推导机密信息的攻击方式,而不是直接破解保护机制。例如,通过计算结果的时间延迟可靠地推断计算结果的知识。
投机执行
现代处理器中使用的优化方法,通过提前执行未来指令来节省时间,如果不需要,则通过回溯逻辑丢弃结果。投机执行对缓存状态的影响可能泄露原本无法访问的信息,构成安全威胁。
欺骗
身份验证的颠覆,攻击者冒充授权主体。(STRIDE的组成部分)
SQL 注入
一种漏洞,允许攻击者构造恶意输入以运行任意 SQL 命令。
STRIDE
六种基本软件安全威胁的缩写,有助于指导威胁建模。(参见 欺骗、篡改、拒绝、信息披露、拒绝服务、权限提升)
对称加密
一种加密方法,使用相同的密钥进行加密或解密。其对称性在于任何可以加密的人也可以解密。(参见 非对称加密)
污染
追踪数据来源的过程,使用软件来缓解不可信输入,或由这些输入影响的数据,防止它们用于特权操作,如注入攻击。
篡改
数据的未经授权修改。(STRIDE 的组成部分)
威胁
潜在或假设的安全问题。
威胁行为者
参见 攻击者。
威胁建模
对系统模型的分析,用于识别需要缓解的威胁。
定时攻击
一种侧信道攻击,通过测量操作的时间来推测信息。
信任
选择依赖于一个主体或组件,而在发生失败时无法通过求助保护。
下溢
浮点计算结果的精度丧失。
单元测试
独立于其他组件对单个模块进行的软件测试。
不可信输入
来源于不可信来源的输入数据,特别是来自潜在攻击面。
漏洞
使安全攻击成为可能的软件缺陷。
漏洞链
一系列漏洞,当这些漏洞结合在一起时,构成了一个安全攻击。
弱点
导致脆弱性的错误,因此可能成为漏洞。
第四章:C
练习
探索是推动创新的引擎。
—埃迪丝·威德尔

本附录包含一些进一步探索的思路、未解的问题和挑战,适合那些希望超越本书内容的读者。
第一章:基础
- 本书侧重于传统计算机系统中的信息安全,但家电和设备也运行软件,并且这些设备越来越多地与互联网连接。我们如何将 C-I-A 等原则扩展到与物理世界互动的安全软件中?
第二章:威胁
-
对现有的软件设计进行威胁建模,或者仅对大型系统中的一个组件进行建模。
-
为了好玩,进行一个威胁建模,选择你最喜欢的电影或书中的场景,在那里对手为了争夺珍贵资产而展开激烈斗争。
第三章:缓解措施
-
编写帮助函数,限制内存中敏感数据的暴露,如第 47 页“最小化数据暴露”中所述。
-
故意编写一个混淆代理(Confused Deputy)并尝试利用它,或者挑战同事来做这件事。修复漏洞并确认代码安全。
-
设计一个库,强制执行现有数据访问 API 的可扩展访问策略。
第四章:模式
-
选取现有设计,或开展新的设计,看看你能使用本章中多少模式,将其做到尽可能安全。
-
你能想到哪些额外的安全模式和反模式?保持一个持续更新的清单,加入本章中提到的模式,并与同事分享。
-
白名单(allowlist)总比黑名单(blocklist)更好吗?想想有没有例外,或者解释为什么没有例外。
第五章:加密学
-
一种轻松体验真实加密工具的方法是使用 OpenSSL 命令行工具(
wiki.openssl.org/index.php/Main_Page)。你可以用它实验对称和非对称加密、MAC(在openssl(1)中称为摘要),甚至创建和检查你自己的证书。 -
找到一个高质量的加密库,并尝试用它实现本章中描述的基本操作。它的 API 使用起来如何?你对自己的实现安全性有多有信心?
-
如果前面的练习感觉困难,如何重新设计 API,让它更易用,同时也更加防错?
-
编写你想到的加密 API 改进,或者将原始库包装成提供更好 API 的形式。
第六章:安全设计
-
探索谷歌的设计文档写作指南(
www.industrialempathy.com/posts/design-docs-at-google/)。 -
如果你以前没有编写过软件设计文档,下一次有机会时可以尝试编写(可以尽量简洁高层次)。
-
如果您正在处理一个没有书面设计文档的代码库,请回溯性地创建一个。对于大型系统,可以一次创建一个组件的设计,重点关注对安全最重要或其他感兴趣的组件。
第七章:安全设计评审
-
查找现有设计并作为学习练习进行评审。不要仅仅寻找漏洞;要对设计的优势和弱点进行广泛评估,包括安全最重要的地方、设计如何增强安全性、缓解方案的替代方法,以及如何改进安全性或使其更易用。
-
与同事分享并讨论前面练习中的发现。
第八章:安全编程
-
为了感受现实世界中安全漏洞的例子,可以寻找已经在您的代码库或开源软件项目中发现并修复的安全漏洞。我建议专注于开源项目,因为漏洞通常会详细描述,并且您可以看到代码。美国国土安全部赞助了一个大型的公开已知漏洞数据库(
cve.mitre.org/)。Chromium 漏洞数据库是另一个很好的公开漏洞来源(bugs.chromium.org/p/chromium/issues/list/)。一个好的起点是过滤这些数据库中已修复的安全漏洞,以便查看实际的代码更改。 -
隐蔽编码,也称为混淆编码,是一种使用“脚枪”和其他诡计编写代码的精湛艺术,这些代码的行为与代码表面上的检查结果不同。隐蔽编码比赛挑战程序员展示他们在推动编程语言极限方面的创造力。但同样的技术也可以用来掩盖恶意代码,甚至如果不小心碰到,可能会成为“脚枪”。查看这些网站作为开始,或者尝试自己创作:
www.underhanded-c.org/和underhandedcrypto.com/。
第九章:低级编码缺陷
-
为什么提供定宽整数类型的语言没有提供任何检测溢出的机制?这样做有帮助吗?如果有,您将如何扩展 C 语言以利用这一点?
-
探索像 Valgrind 这样的分析工具如何检测内存管理问题(
valgrind.org/docs/manual/mc-manual.html)。 -
编写一个小程序,其中包含几种类型的内存管理漏洞,例如读写缓冲区溢出。使用类似 Valgrind 的工具查看它是否能检测到漏洞。尝试变更代码,使其更难被工具分析,并查看是否能让漏洞躲过工具的检测。
第十章:不受信任的输入
-
确定你所从事系统的主要攻击面上的不受信任输入,并检查输入验证的实现和测试是否足够全面。
-
如果你发现不受信任的输入可能代表漏洞,请实现输入验证。
-
通常,系统的输入验证是重复的。寻找机会使用公共代码或辅助函数来可靠地处理它。考虑将输入验证嵌入到框架中,以防止它被意外遗漏。
第十一章:网页安全
-
为创建和验证网页会话的组件编写安全要求。设计并进行威胁建模,找个朋友做安全审查。
-
将你的网页会话实现构建成一个简单的网页应用。尝试模拟另一个会话,或窃取必要的会话状态。更好的是,找一个朋友来“攻击”你的实现。
-
为组件添加 CSRF 保护机制,并在你的网页应用中进行测试。
-
探索不使用 cookies 来保护网页会话的方法,作为实验以理解安全挑战的本质。
-
查找一个网页框架的源代码(最好还有一个书面设计文档),了解它如何实现会话,防止 XSS 和 CSRF 漏洞,并确保 HTTPS 安全所有网页交互。通过威胁建模或其他方法,你能找到任何漏洞吗?如果想尝试攻击它,可以搭建一个自己的测试服务器进行测试。
第十二章:安全测试
-
在你选择的代码库中,找到一个安全性重要的区域,寻找应该添加的额外安全测试用例。编写并贡献新的安全测试用例。
-
考虑一下在 GotoFail 中漏洞的另一个示例,安全测试写得很不错,但它无法捕捉到这个漏洞——在额外的
goto fail;语句中,实际上应该插入这一行:if (expected_hash[0] == 0x23) goto fail; -
这种技术可能被用来偷偷地引入一个需要特定触发器的漏洞,作为一种后门。要检测这个漏洞,需要一个测试用例,其预期的哈希值的第一个字节是 0x23。你能写出测试用例来检测这种漏洞而不知道具体细节吗?
-
查看一个已知漏洞的开源软件项目的旧版本。运行测试套件并确保所有测试通过。编写一个安全回归测试以确认漏洞。同步到修复漏洞的下一个版本,合并回归测试。你的安全回归测试现在应该通过;如果没有,修复它。然后,在最新版本中检查是否存在其他相关漏洞。
第十三章:安全开发最佳实践
-
探索一些简单的方法来逐步提高代码质量,例如使用 lint 或代码扫描工具,以及检查错误和异常处理的测试覆盖率。
-
查看代码库中安全方面的文档化情况,并进行必要的改进。
-
每当进行代码审查时,适时地再戴上安全帽,做一次额外的检查。
-
在进行缺陷分类时要考虑安全性,或者在浏览你的缺陷数据库时要牢记安全性,看看是否有涉及安全问题的缺陷被忽视了。
后记
-
寻找机会在结论中提到的方向上进行改进,即使这意味着采取小步骤:更广泛的安全参与、更早地融入安全视角和策略、减少或管理复杂性、提高关于安全实践的透明度等等。
-
识别一个独特的安全挑战,并设计和开发一个可重用的组件来解决它。
-
寻找其他自己的想法,提升安全标准并传播这一理念。
第五章:D
备忘单
你的意识应该作为一个集中注意力的工具,而不是一个存储地方。
—David Allen
第一章
经典安全原则
| 信息安全 (C-I-A) |
|---|
| 保密性 |
| 完整性 |
| 可用性 |
| 黄金标准 |
| --- |
| 身份验证 |
| 授权 |
| 审计 |
第二章
四个问题
-
我们在做什么?
-
会出什么问题?
-
我们要如何处理这个问题?
-
我们做得好吗?
STRIDE
表 2-1:STRIDE 威胁类别摘要
| 目标 | STRIDE 威胁 | 示例 |
|---|---|---|
| 真实性 | 欺骗 | 网络钓鱼,密码被盗,冒充,重放攻击,BGP 劫持 |
| 完整性 | 篡改 | 未授权的数据修改和删除,Superfish 广告注入 |
| 不可否认性 | 否认 | 合理否认,日志不足,日志销毁 |
| 保密性 | 信息泄露 | 数据泄露,侧信道攻击,弱加密,残留缓存数据,Spectre/Meltdown |
| 可用性 | 拒绝服务 | 同时请求淹没网络服务器,勒索软件,memcrashed |
| 授权 | 权限提升 | SQL 注入,xkcd 的“妈妈的漏洞” |
第四章
安全设计模式

第七章
安全设计评审
安全设计评审的六个阶段:
-
研究设计和支持文档,以便对项目有基本的了解。
-
首先,阅读文档,获得对设计的高层次理解。
-
接下来,戴上你的“安全帽”,用威胁意识的思维再次检查设计。
-
做笔记,记录你的想法和观察,供将来参考。
-
标记潜在问题以便稍后处理,但此阶段进行安全分析为时尚早。
-
-
询问设计并就基本威胁提出澄清问题。
-
确保设计文档清晰且完整。
-
如果需要修正或遗漏,帮助修复文档中的问题。
-
了解设计,能够与人交流,但不一定是专家级别。
-
向团队成员询问他们最担心的问题;如果他们没有安全顾虑,问后续问题以了解原因。
-
-
识别设计中最为关键的安全部分,给予更多关注。
-
检查接口、存储和通信——这些通常是重点关注的中央点。
-
从最暴露的攻击面开始,向内工作,直到最有价值的资产,就像决定性攻击者一样。
-
评估设计在多大程度上明确地考虑了安全性。
-
如果需要,指出关键保护措施,并在设计中标注它们作为重要特性。
-
-
与设计师合作,识别风险并讨论缓解措施。
-
作为审阅者,在需要时提供关于风险和缓解措施的安全角度看法。
-
考虑绘制一个场景,展示安全性变更如何在未来带来回报,以帮助说服设计师采取缓解措施。
-
在可能的情况下,提供多种解决方案,并帮助设计师看到这些替代方案的优缺点。
-
接受设计师有最后的决定权,因为他们最终对设计负责。
-
记录思想交流,包括将会或不会进入设计的内容。
-
-
写一份总结报告,概述发现和建议。
-
根据解决安全风险的具体设计变更来组织报告。
-
将大部分精力和篇幅集中在最高优先级的问题上,低优先级的问题则相对少些。
-
提出替代方案和策略,但不要试图为设计师做他们的工作。
-
根据优先级排名对发现和建议进行优先排序。(将要点分类为必须/应该/建议。)
-
聚焦安全性,但也可以为设计师提供单独的备注供他们考虑。
-
-
跟进后续的设计变更,以确认问题解决后再签字确认。
-
对于重大安全设计变更,您可能需要与设计师合作,以确保变更得当。
-
在意见不一致的地方,审阅者应列出两种立场,并指出未被采纳的具体建议,标明这是一个待解决的问题。
-
第十三章
DREAD
损害潜力
- 如果被利用,后果会有多严重?
可复现性
- 攻击会每次成功、偶尔成功,还是只有极少成功?
可利用性
-
从技术难度、努力程度和成本的角度来看,利用这个漏洞有多困难?
-
攻击路径有多长?
受影响的用户
-
所有用户、一部分用户还是只有少数用户会受到影响?
-
是否可以轻易攻击特定目标,还是受害者是任意的?
可发现性
- 攻击者发现漏洞的可能性有多大?


浙公网安备 33010602011771号