原型污染
以下是 Eran Hammer 撰写的一篇文章。出于保存的目的,经许可在此转载。它已从原始 HTML 源代码重新格式化为 Markdown 源代码,但在其他方面保持不变。原始 HTML 可以从上面的许可链接中检索。
原型污染的历史
根据 Eran Hammer 的文章,此问题是由一个 Web 安全漏洞引起的。它也完美地说明了维护开源软件所需的努力以及现有通信渠道的局限性。
但首先,如果我们使用 JavaScript 框架来处理传入的 JSON 数据,请花点时间阅读一下关于原型污染的通用信息,以及此问题的具体技术细节。这可能是一个严重的问题,因此,我们可能需要首先验证您自己的代码。它侧重于特定的框架,但是,任何使用JSON.parse()
处理外部数据的解决方案都可能存在风险。
突发事件
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 层面上的任何解决方案都意味着破坏一些当前正在工作的代码。
正确的方法
在这一点上,我们正在面对一个破坏性极强的安全漏洞。它位列史诗级安全故障的顶端。我们只知道我们极其流行的数据验证库无法阻止有害数据,并且这些数据很容易潜入。您只需将__proto__
和一些垃圾添加到 JSON 输入中,并将其发送到使用我们的工具构建的应用程序中即可。
(戏剧性停顿)
我们知道我们必须修复 joi 以防止这种情况发生,但鉴于此问题的规模,我们必须以一种能够发布修复程序而不引起过多关注的方式来进行——至少在几天内不要使其太容易被利用——直到大多数系统收到更新为止。
偷偷摸摸地修复并不是最难完成的事情。如果您将其与代码的其他无目的重构结合起来,并加入一些无关的错误修复,或者可能是一个很酷的新特性,您就可以发布一个新版本,而不会引起人们对正在修复的真正问题的关注。
问题在于,正确的修复程序将破坏有效的用例。您看,joi 无法知道您是想让它忽略您设置的原型,还是阻止攻击者设置的原型。修复漏洞的解决方案将破坏代码,而破坏代码往往会引起很多关注。
另一方面,如果我们发布一个正确的(语义版本化)修复程序,将其标记为重大更改,并添加一个新的 API 来明确告诉 joi 您希望它如何处理原型,我们将向全世界分享如何利用此漏洞,同时也会使系统升级更加耗时(重大更改永远不会被构建工具自动应用)。
绕道
虽然手头的问题与传入的请求有效负载有关,但我们必须暂停并检查它是否也会影响来自查询字符串、cookie 和标头的數據。基本上,任何从文本序列化为对象的内容。
我们很快确认 Node 默认的查询字符串解析器以及其标头解析器都很好。我发现 base64 编码的 JSON cookie 以及自定义查询字符串解析器的使用存在一个潜在问题。我们还编写了一些测试以确认最流行的第三方查询字符串解析器——qs——不受影响(它不受影响!)。
发展
在整个分类过程中,我们只是假设带有其被污染的原型的违规输入来自 hapi,即连接 hapi.js 生态系统的 Web 框架。Lob 团队的进一步调查发现,问题有点细微差别。
hapi 使用JSON.parse()
来处理传入的数据。它首先将结果对象设置为传入请求的payload
属性,然后在传递给应用程序业务逻辑进行处理之前,将同一对象传递给 joi 进行验证。由于JSON.parse()
实际上不会泄漏__proto__
属性,因此它将带有无效键到达 joi 并导致验证失败。
但是,hapi 提供了两个扩展点,可以在验证之前检查(和处理)有效负载数据。它们都得到了适当的记录,并且大多数开发人员都非常了解。扩展点是为了允许您在验证之前与原始输入进行交互,以实现合法(且通常与安全相关的)原因。
如果在其中一个扩展点期间,开发人员对有效负载使用了Object.assign()
或类似方法,则__proto__
属性将泄漏并成为实际的原型。
如释重负
我们现在正在处理一个完全不同的糟糕程度。在验证之前操作有效负载对象并不常见,这意味着这不再是世界末日场景。它仍然可能造成灾难性后果,但暴露范围从每个 joi 用户下降到某些非常具体的实现。
我们不再关注一个秘密的 joi 版本发布。joi 中的问题仍然存在,但我们现在可以通过新的 API 和在未来几周内发布的重大版本来正确解决它。
我们还知道,我们可以在框架级别轻松缓解此漏洞,因为它知道哪些数据来自外部,哪些数据是内部生成的。框架确实是唯一可以保护开发人员免受此类意外错误的组件。
好消息,坏消息,没有消息?
好消息是,这不是我们的错。这不是 hapi 或 joi 中的错误。它只能通过一系列复杂的动作组合才能实现,而这并非 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?也许你应该考虑一下。
幸福结局中的后续
在这里,我必须利用这次事件重申可持续和安全的开源的成本和必要性。
我仅在一个问题上花费的时间就超过了 20 个小时。这相当于半个工作周。它发生在一个月底,在这个月里我已经花费了 30 多个小时发布了 hapi 的一个新的主要版本(大部分工作是在 12 月完成的)。这使我本月个人财务损失超过 5000 美元(我不得不减少付费客户工作以腾出时间)。
如果您依赖我维护的代码,那么这正是您想要(并且老实说——期望)的支持水平、质量和承诺。你们中的大多数人认为这是理所当然的——不仅是我的工作,还有数百位其他敬业的开源维护人员的工作。
因为这项工作很重要,所以我决定尝试使其不仅在财务上可持续,而且使其发展壮大。还有很多需要改进的地方。这正是我推动实施新的商业许可计划(将于 3 月推出)的动力。您可以在此处了解更多信息。