原型污染
原型污染的历史背景
根据Eran Hammer的文章,该问题是由一个Web安全漏洞引起的。 它也是开源软件维护所需努力和现有沟通渠道局限性的完美例证。
首先,如果我们使用JavaScript框架来处理传入的JSON数据,请花点时间阅读关于原型污染 的一般性介绍,
以及该问题的具体 技术细节 。
这可能是一个关键问题,因此我们可能需要先验证自己的代码。
虽然重点是特定框架,但任何使用 JSON.parse()
处理外部数据的解决方案都可能存在风险。
BOOM
Lob(长期慷慨支持我工作的公司)的工程团队报告了一个他们在我们的数据验证模块——joi 中发现的关键安全漏洞。他们提供了一些技术细节和一个提议的解决方案。
数据验证库的主要目的是确保输出完全符合定义的规则。如果不符,验证将失败。如果通过,则我们可以放心地认为你正在处理的数据是安全的。事实上,大多数开发人员从系统完整性角度来看待经过验证的输入时会将其视为绝对安全!
在这种情况下,Lob团队提供了一个示例,在该示例中,一些数据能够绕过验证逻辑并通过而未被检测到。这是验证库可能存在的最糟糕缺陷。
概述原型
要理解这一点,我们需要先了解JavaScript的工作原理。 在JavaScript中,每个对象都可以有一个原型。它是一组方法和属性的集合, 这些方法和属性是从另一个对象“继承”而来的。这里使用了引号来表示“继承”,因为JavaScript实际上并不是一种面向对象的语言。它是基于原型的面向对象语言。
很久以前,出于一些无关紧要的原因,有人决定使用特殊属性名__proto__
来访问(并设置)一个对象的原型。虽然这种方法已经被弃用,但仍然得到了全面支持。
为了演示:
> const a = { b: 5 };
> a.b;
5
> a.__proto__ = { c: 6 };
> a.c;
6
> a;
{ b: 5 }
该对象没有c
属性,但是它的原型有。
在验证对象时,验证库会忽略原型,并且只验证对象自身的属性。这使得c
可以通过原型“潜入”。
另一个重要部分是JSON.parse()
——语言提供的一个工具,用于将JSON格式的文本转换为对象——处理这个神奇的__proto__
属性名的方式。
> const text = '{"b": 5, "__proto__": { "c": 6 }}';
> const a = JSON.parse(text);
> a;
{b: 5, __proto__: { c: 6 }}
注意,对象a
有一个__proto__
属性。这不是一个原型引用。它只是一个普通的对象属性键,就像b
一样。从第一个示例中可以看到,我们实际上不能通过赋值创建这个键,因为这会触发原型魔法并设置实际的原型。然而,JSON.parse()
却设置了具有这个危险名称的简单属性。
单独来看,由JSON.parse()
创建的对象是完全安全的。它没有自己的原型。它有一个看似无害的属性,恰好与JavaScript内置的魔术名重叠。
但是其他方法就没有这么幸运了:
> const x = Object.assign({}, a);
> x;
{ b: 5}
> x.c;
6;
如果我们使用前面通过 JSON.parse()
创建的 a
对象,并将其传递给有用的 Object.assign()
方法(用于执行浅拷贝,将 a
的所有顶级属性复制到提供的空 {}
对象中),那么神奇的 __proto__
属性会“泄漏”,并成为 x
的实际原型。
意外!
如果你获取一些外部文本输入,并使用 JSON.parse()
进行解析,然后对该对象执行一些简单的操作(例如浅拷贝并添加一个 id
),并将结果传递给我们的验证库,那么通过 __proto__
属性的注入将不会被检测到。
哦,joi!
首先的问题当然是,为什么验证模块 joi 会忽略原型,并允许潜在的有害数据通过?我们也有同样的疑问,我们的第一反应是“这是一个疏忽”。一个错误——一个非常严重的错误。joi 模块不应该允许这种情况发生。但是……
虽然 joi 主要用于验证 web 输入数据,但它也有一部分用户使用它来验证内部对象,其中一些对象具有原型。joi 忽略原型这一事实是一个有用的“特性”。这使得可以验证对象自身的属性,而忽略可能非常复杂的原型结构(包含许多方法和字面量属性)。
任何在 joi 层级上的解决方案都意味着会破坏当前正在运行的代码。
正确的做法
此时,我们发现了一个极其严重的安全漏洞。
这个漏洞可以与历史上最糟糕的安全失败相提并论。我们知道的是,
我们的流行数据验证库无法阻止有害的数据,并且这些数据很容易被绕过。
你只需要在 JSON 输入中添加 __proto__
和一些垃圾代码,然后将其发送到使用我们工具构建的应用程序即可。
(戏剧性停顿)
我们知道必须修复 joi 以防止这种情况发生。但是鉴于问题的规模, 我们必须采取一种不会引起太多关注的方式来发布补丁——至少在几天内,直到大多数系统收到更新之前,不要让攻击者轻易利用它。
悄悄地发布一个补丁并不是最难的事情。如果你结合一次没有实际意义的代码重构,并加入一些无关的 bug 修复和可能的新功能,你可以发布一个新的版本而不会引起对真正问题的关注。
问题是,正确的修复方法将会破坏有效的用例。你看,joi 没有办法知道你希望它忽略你设置的原型还是阻止攻击者设置的原型。 一个可以解决漏洞的解决方案会破坏代码,并且破坏代码往往会引起很多关注。
另一方面,如果我们发布了一个正式的(语义化版本)修复补丁,将其标记为重大变更,并添加一个新的 API 明确告诉 joi 你希望它如何处理原型, 我们将向世界展示如何利用这个漏洞,同时也会让系统升级变得更加耗时(构建工具从不会自动应用破坏性更改)。
一个绕行
虽然当前的问题是关于传入请求负载,但我们不得不暂停并检查它是否也会影响通过查询字符串、cookie 和头信息传递的数据。基本上,任何从文本序列化为对象的内容都可能受到影响。
我们很快确认了 node 默认的查询字符串解析器和头部解析器都没有问题。我还发现了一个潜在的问题,即 base64 编码的 JSON cookie 以及自定义查询字符串解析器的使用。我们也编写了一些测试来确认最流行的第三方查询字符串解析库——qs ——没有漏洞(它确实没有漏洞)。
新发现
在整个排查过程中,我们一直假设带有中毒原型的输入是从 hapi 框架传入 Joi 的。Lob 团队进一步调查后发现问题更为复杂。
hapi 使用 JSON.parse()
来处理传入的数据。它首先将结果对象设置为传入请求的 payload
属性,然后在传递给应用业务逻辑进行处理之前,将其传递给 Joi 进行验证。由于 JSON.parse()
实际上不会泄漏 __proto__
属性,因此该属性会到达 Joi 时无效,并且验证失败。
然而,hapi 提供了两个扩展点,在这些扩展点中可以在验证前检查(和处理)负载数据。这在文档中有详细说明并且大多数开发者都清楚这一点。这些扩展点允许你在验证之前与原始输入进行交互,以合法的(通常涉及安全相关的原因)方式操作。
如果在这两个扩展点中的任何一个地方,开发人员使用了 Object.assign()
或类似的方法来处理 payload,那么 __proto__
属性就会泄漏并成为一个实际原型。
如释重负
我们现在面对的是一个完全不同级别的糟糕情况。在验证之前操作负载对象并不常见,这意味着这不再是一个世界末日般的场景。尽管仍然可能造成灾难性后果,但受影响的范围从所有 Joi 用户缩小到了一些特定的实现方式。
我们不再需要秘密发布 Joi 版本了。Joi 中的问题依然存在,但我们可以在接下来的几周内通过新的 API 和破坏性版本来妥善解决它。
我们也知道可以在框架层面轻松缓解这一漏洞,因为框架可以识别哪些数据来自外部,哪些是由内部生成的。框架是唯一能够保护开发人员免于犯下此类意外错误的部分。
好消息,坏消息,无消息?
好消息是这不是我们的错。这并不是 hapi 或 joi 中的 bug。这是通过一系列复杂的操作组合导致的问题,并且这种问题不仅仅发生在 hapi 或 joi 上。其他任何 JavaScript 框架也可能发生这种情况。如果 hapi 出现问题,那么整个世界都可能出现类似的问题。
太好了——我们解决了责任推诿的问题。
坏消息是当没有明确的责任对象(除了 JavaScript 本身)时,修复这些问题会更加困难。
一旦发现安全漏洞,人们问的第一个问题是是否会有 CVE 发布。CVE —— 常见漏洞和暴露数据库 —— 是一个记录已知安全问题的数据库 。它是网络安全的关键组成部分。发布 CVE 的好处是它立即触发警报并通知相关人员,并且通常会中断自动化构建直到问题解决。
但是我们该将这个问题归咎于什么呢?
可能,什么都没有。我们现在还在讨论是否应该在某些 hapi 版本上添加警告标签。“我们”指的是 node 安全流程。由于现在有了一个新的 hapi 版本默认解决了这个问题,可以认为这是一个修复措施。但由于这个修复并不是针对 hapi 本身的问题,因此不能确切地说旧版本是有害的。
为了提醒人们注意并升级而为之前的 hapi 版本发布安全建议是一种滥用建议流程的行为。我个人认为为了提高安全性而滥用它是可以接受的,但这不是我的决定。截至撰写本文时,这个问题仍在讨论中。
解决方案业务
缓解这个问题并不难。但要使其可扩展且安全则复杂得多。既然我们知道有害数据可以进入系统的途径,并且知道我们在何处使用了有问题的JSON.parse()
,我们可以用一个更安全的实现来替换它。
一个问题。验证数据的成本可能很高,我们现在计划对每个传入的JSON文本进行验证。内置的JSON.parse()
实现非常快。真的非常快。我们不太可能构建出既更安全又同样快速的替代方案。尤其是在一夜之间完成且不引入新错误的情况下更是如此。
很明显我们需要用一些附加逻辑来封装现有的JSON.parse()
方法。我们只需要确保它不会增加太多开销即可。这不仅是一个性能方面的考虑,也是一个安全性问题。如果我们让通过发送特定数据就能轻易减慢系统变得容易,那么执行一个拒绝服务攻击 的成本将非常低。
我提出了一种极其简单的解决方案:首先使用现有的工具解析文本。如果这没有失败,则扫描原始的纯文本查找违规字符串”proto”。只有在找到它时,才执行对象的实际扫描。我们不能阻止对”proto”的所有引用——有时它是完全有效的值(例如,在这里写到它并将其发送给Medium进行发布)。
这使得“正常路径”几乎和以前一样快。它只是增加了一个函数调用、一个快速文本扫描(再次,非常快的内置实现),以及一个条件返回。该解决方案对预期通过它的绝大多数数据的影响可以忽略不计。
下一个问题。原型属性不必在传入对象的顶层。它可以嵌套得很深。这意味着我们不能仅仅检查顶层是否存在它。我们需要递归地遍历整个对象。
尽管递归函数是常用的工具,但在编写注重安全性的代码时可能会带来灾难性的影响。你看,递归函数会增加运行时调用栈的大小。循环次数越多,调用栈就越长。在某个时刻——砰的一声——你就会达到最大长度限制,进程也会因此崩溃。
如果你无法保证传入数据的结构,递归迭代就变成了一个公开的安全威胁。攻击者只需要构造出足够深的对象就能使你的服务器崩溃。
我使用了一个扁平化的循环实现方式,这种方式不仅更节省内存(调用次数少,传递临时参数也少),而且更加安全。我不是为了炫耀这一点,而是想强调基本的工程实践如何能够创造(或避免)安全漏洞。
测试效果
我将代码发送给了两个人。首先发给Nathan LaFreniere ,以再次确认解决方案的安全性,然后发给Matteo Collina 进行性能审查。他们都是各自领域的顶尖专家,并且经常是我的首选联系人。
性能基准测试证实了“正常路径”几乎不受影响。有趣的是,移除违规值比抛出异常更快。这引发了关于新模块(我将其命名为bourne)默认行为应该是错误还是清理的问题。
再次引发的担忧是暴露应用程序遭受拒绝服务攻击的风险。如果发送包含__proto__
请求会使处理速度降低500%,那么这可能是一个容易被利用的向量。但在更多的测试之后,我们确认发送任何无效的JSON文本都会产生类似的性能开销。
换句话说,如果你解析JSON,无论是什么原因导致其无效,无效值都会增加你的成本。同样重要的是要记住,虽然基准测试显示扫描可疑对象所占的百分比成本显著,但实际上在CPU时间上的花费仍然只是一小部分毫秒。这一点需要注意和测量,但并不实际有害。
hapi 之后的故事
有许多事情值得感激。
Lob 团队最初的披露非常完美。他们私下向正确的人报告了问题,并提供了正确的信息。他们还跟进并提供了额外的发现,给我们时间和空间以正确的方式解决问题。Lob 还多年以来一直是我在 hapi 上工作的主要赞助商,这种财务支持对于其他一切的发生至关重要。稍后会详细说明这一点。
处理过程中虽然压力很大,但有合适的人参与其中。像 Nicolas Morel 、Nathan 和 Matteo 等人随时准备并愿意提供帮助是至关重要的。没有这种压力的情况下处理这些问题已经不容易了,但如果缺乏适当的团队合作,很可能会犯错误。
我们在实际漏洞方面也很幸运。最初看起来像是一个灾难性的问题,最终却变成了一个虽然微妙但解决起来相当直接的问题。
我们还因为能够从源头上缓解这个问题而感到幸运——不需要向一些未知的框架维护者发送邮件并希望得到快速回复。hapi 对其所有依赖项的完全控制再次证明了它的实用性和安全性。不使用 hapi ?也许你应该考虑一下 。
幸福结局中的 after
这里我必须利用这次事件重申可持续和安全的开源软件的成本以及其必要性。
我在解决这个问题上花费的时间超过了20小时。这相当于半个工作周。这是在一个月内我已经花了超过30个小时发布hapi的新主要版本之后(大部分工作是在12月完成的)。这意味着这个月我个人财务损失已经超过5000美元(为了腾出时间,我不得不减少付费客户的工作)。
如果你依赖于我维护的代码,这就是你想要的支持、质量和承诺水平(让我们坦诚地说——这也是你的期望)。你们中的大多数人都认为这是理所当然的——不仅是我一个人的工作,还有数百名其他敬业的开源维护者的工作。
因为这项工作很重要,所以我决定尝试让它不仅仅在财务上可持续,还要扩大和发展。还有很多可以改进的地方。这正是促使我实施新的商业许可计划 的原因,该计划将于三月推出。你可以在这里了解更多关于它的信息 。