打破状态机:Web竞态条件的真正潜力
长期以来,Web竞态条件攻击仅局限于少数几种场景。由于复杂的工作流程、缺乏工具以及网络抖动掩盖了除最琐碎明显示例外的所有情况,其真正潜力一直被掩盖。
在本文中,我将介绍远超你可能已经熟悉的限制溢出利用的新型竞态条件类别。通过这些技术,我将利用多个知名网站以及Devise(一个流行的Rails身份验证框架)。
我还将介绍单包攻击;一种规避抖动的策略,可以将从墨尔本发送到都柏林的30个请求压缩到亚1毫秒的执行窗口中。本文附有全套免费的在线实验室,因此你可以立即尝试新技能。
这项研究论文伴随Black Hat USA、DEF CON和Nullcon的演讲:它也提供打印/下载友好的PDF格式。
大纲
- 真正潜力
- 超越限制溢出
- 单包攻击
- 方法论
- 案例研究
- 对象掩蔽
- 多端点
- 单端点
- 延迟
- 进一步研究
- 防御
- 要点
背景:竞态条件基础
首先,让我们回顾竞态条件基础。我会简要介绍 - 如果你更喜欢深入介绍,请查看我们新的Web安全学院主题。
大多数网站使用多个线程处理并发请求,所有线程都从单个共享数据库读写。应用程序代码很少考虑并发风险,因此竞态条件困扰着网络。利用通常是限制溢出攻击 - 它们使用同步请求来克服某种限制,例如:
- 多次兑换礼品卡
- 重复应用单个折扣码
- 多次评价产品
- 提取或转账超过账户余额
- 重复使用单个CAPTCHA解决方案
- 绕过反暴力破解速率限制
这些的根本原因也类似 - 它们都利用安全检查与受保护操作之间的时间间隙。例如,两个线程可能同时查询数据库并确认TOP10折扣码尚未应用到购物车,然后都尝试应用折扣,导致折扣被应用两次。
因此,你经常会发现这些被称为“检查时间,使用时间”(TOCTOU)缺陷。
请注意,竞态条件不限于特定的Web应用程序架构。多线程单数据库应用程序最容易推理,但更复杂的设置通常最终将状态存储在更多地方,而ORM只是将危险隐藏在抽象层下。像NodeJS这样的单线程系统暴露程度稍低,但仍可能最终易受攻击。
超越限制溢出利用
我曾经认为竞态条件是一个被充分理解的问题。我发现并利用了很多,在Turbo Intruder中实现了“最后字节同步”技术,并用它来利用各种目标,包括Google reCAPTCHA。随着时间的推移,Turbo Intruder已成为寻找Web竞态条件的事实工具。
然而,有一件事我不理解。Josip Franjković在2016年的一篇博客文章详细介绍了四个漏洞,虽然其中三个对我来说完全合理,但有一个不是。在文章中,Josip解释了他如何“以某种方式成功确认了一个随机电子邮件地址”是偶然的,直到两个月后,他和Facebook的安全团队才确定原因。这个错误?同时将你的Facebook电子邮件地址更改为两个不同的地址可能会触发一封包含两个不同确认码的电子邮件,每个地址一个:/confirmemail.php?e=user@gmail.com&c=13475&code=84751
我以前从未见过这样的发现,它挫败了所有可视化服务器端可能发生情况的尝试。有一件事是肯定的 - 这不是限制溢出。
七年后,我决定尝试弄清楚发生了什么。
Web竞态条件的真正潜力
竞态条件的真正潜力可以用一句话概括。每个渗透测试员都知道多步序列是漏洞的温床,但对于竞态条件,一切都是多步的。
为了说明这一点,让我们绘制一个我前段时间偶然发现的严重漏洞的状态机。当用户登录时,他们会看到一个“角色选择”页面,其中包含一系列按钮,这些按钮将分配角色并重定向到特定应用程序。请求流看起来像:
|
|
在我脑海中,用户角色的状态机是这样的:我试图通过强制从角色选择页面直接浏览到应用程序而不选择角色来提升权限,但这没有奏效,因此我得出结论认为它是安全的。
然而,这个状态机有一个错误。我错误地假设GET /role请求不会改变应用程序状态。实际上,应用程序正在用管理员权限初始化每个会话,然后在浏览器获取角色选择页面时立即覆盖它们。这是一个准确的状态机:
通过拒绝遵循重定向到/role并直接跳转到应用程序,任何人都可以获得超级管理员权限。
我只是通过极端运气发现了这一点,并且花了几小时的回顾日志挖掘才找出原因。这种漏洞模式 frankly 很奇怪,但我们可以从近乎失误中学到一些有价值的东西。
我的主要错误是假设GET请求不会改变应用程序状态。然而,还有一个更常见的第二个假设 - “请求是原子的”。如果我们也放弃这个假设,我们意识到这种模式可能发生在单个登录请求的范围内:
这种情况抓住了“对于竞态条件,一切都是多步的”的本质。每个HTTP请求可能使应用程序通过多个短暂、隐藏的状态转换,我将其称为“子状态”。如果你时机把握得当,你可以滥用这些子状态进行意外转换,破坏业务逻辑,并实现高影响利用。让我们开始吧。
单包攻击
子状态是应用程序在处理单个请求时转换通过的短暂状态,并在请求完成之前退出。子状态仅占用短暂的时间窗口 - 通常约为1毫秒(0.001秒)。我将这个时间窗口称为“竞争窗口”。
要发现子状态,你需要一个初始HTTP请求来触发通过子状态的转换,以及第二个在竞争窗口期间与同一资源交互的请求。例如,要发现前面提到的漏洞,你会发送一个登录请求,以及第二个尝试访问管理面板的请求。
由于网络抖动,具有小竞争窗口的漏洞历来极难发现。抖动会不稳定地延迟TCP数据包的到达,使得即使使用最后字节同步等技术,也很难让多个请求接近到达:
在寻找解决方案的过程中,我开发了“单包攻击”。使用这种技术,你可以让20-30个请求同时到达服务器 - 无论网络抖动如何:
我在开源Burp Suite扩展Turbo Intruder中实现了单包攻击。为了对其进行基准测试,我反复从墨尔本到都柏林17,000公里发送一批20个请求,并测量每批中第一个和最后一个请求的开始执行时间戳之间的差距。我已将基准测试脚本发布在示例文件夹中,以便你可以自行尝试。
| 技术 | 中位数传播 | 标准差 |
|---|---|---|
| 最后字节同步 | 4毫秒 | 3毫秒 |
| 单包攻击 | 1毫秒 | 0.3毫秒 |
通过这些测量,单包攻击的效果提高了4到10倍。在复制一个真实漏洞时,单包攻击在大约30秒后成功,而最后字节同步花了两个多小时。
一个很好的副作用是,我们能够推出一个Web安全学院主题,其中包含具有现实竞争窗口的实验室,而不会疏远离我们服务器较远或具有高抖动连接的用户。你可以通过使用single-packet-attack.py Turbo Intruder模板处理我们的限制溢出实验室来亲自尝试单包攻击。这个实验室的竞争窗口最终如此之小,以至于使用多个数据包进行利用几乎是不可能的。它也可以通过Burp Suite中新的“并行发送组”选项在Repeater中使用。
让我们看看内部情况。
开发单包攻击
单包攻击的灵感来自2020年USENIX演讲“永恒定时攻击”。在该演讲中,他们将两个完整的HTTP/2请求放入单个TCP数据包中,然后查看响应顺序以比较两个请求的服务器端处理时间:
这是HTTP/2的一个新颖可能性,因为它允许HTTP请求通过单个连接并发发送,而在HTTP/1.1中它们必须是顺序的。
使用单个TCP数据包完全消除了网络抖动的影响,因此这显然也有竞态条件攻击的潜力。然而,由于服务器端抖动 - 由不可控变量(如CPU争用)引起的应用程序请求处理时间的变化,两个请求不足以进行可靠的竞争攻击。
我发现了从HTTP/1.1“最后字节同步”技术中调整技巧的机会。由于服务器只在认为请求完成时才处理请求,也许通过保留每个请求的一小片段,我们可以预发送大部分数据,然后用单个TCP数据包“完成”20-30个请求:
经过几周的实验,我构建了一个在所有测试的HTTP/2服务器上都能工作的实现。
自己实现
这个概念 honestly 非常明显,在实现之后,我发现有人在2020年就有同样的想法,但当时没有人注意到,他们的算法和实现没有经过打磨、测试和集成,这些对于证明其真正价值至关重要。我对单包攻击如此兴奋的原因是它强大、通用且琐碎。即使花了几个月时间精炼它以在所有主要Web服务器上工作,算法仍然如此简单,可以放在单页上,并且如此容易实现,我预计它最终会出现在所有主要Web测试工具中。
它如此容易实现的主要原因是,由于对Nagle算法的一些创造性滥用,它不需要自定义TCP或TLS堆栈。你可以选择一个HTTP/2库来挂钩(相信我,编写你自己的并不有趣),并应用以下步骤:
首先,预发送每个请求的大部分:
- 如果请求没有正文,发送所有标头,但不要设置END_STREAM标志。保留一个带有END_STREAM集的空数据帧。
- 如果请求有正文,发送标头和所有正文数据,除了最后一个字节。保留一个包含最后一个字节的数据帧。
你可能想发送完整正文并依赖不发送END_STREAM,但这会在某些使用content-length标头决定消息何时完成的HTTP/2服务器实现上中断,而不是等待END_STREAM。
接下来,准备发送最终帧:
- 等待100毫秒以确保初始帧已发送。
- 确保TCP_NODELAY被禁用 - Nagle算法批处理最终帧至关重要。
- 发送ping数据包以预热本地连接。如果你不这样做,操作系统网络堆栈会将第一个最终帧放在单独的数据包中。
最后,发送保留的帧。你应该能够使用Wireshark验证它们是否落在单个数据包中。
这种方法在所有测试服务器的所有动态端点上都有效。它在某些服务器上的静态文件上不起作用,但由于静态文件与竞态条件攻击无关,我没有尝试为此找到解决方法。在Turbo Intruder中,静态文件怪癖导致负时间戳,因为响应在请求完成之前被接收。这种行为可以用作测试文件是否为静态的方法。
如果你不确定构建在哪个HTTP/2堆栈上,我认为Golang的可能是一个不错的选择 - 我以前见过它成功扩展用于高级HTTP/2攻击。如果你想看Kotlin中的参考实现,请随意使用Turbo Intruder。相关代码可以在SpikeEngine和SpikeConnection中找到。
适应目标架构
值得注意的是,许多应用程序位于前端服务器后面,这些服务器可能决定将一些请求通过现有连接转发到后端,并为其他请求创建新连接。
因此,重要的是不要将不一致的请求时序归因于应用程序行为,例如只允许单个线程一次访问资源的锁定机制。此外,前端请求路由通常基于每个连接进行,因此你可能能够通过执行服务器端连接预热 - 在发起攻击之前通过连接发送一些无关紧要的请求 - 来平滑请求时序。你可以在我们的多端点实验室中亲自尝试这种技术。
方法论
既然我们已经确立了“一切都是多步的”,并开发了一种允许精确请求同步并使竞态条件可靠的技术,是时候开始寻找漏洞了。经典的限制溢出漏洞可以使用琐碎的方法论发现:识别限制,并尝试溢出它。发现更高级攻击的可利用子状态并不那么简单。
经过几个月的测试,我开发了以下黑盒方法论来帮助。我建议使用这种方法,即使你有源代码访问权限;根据我的经验,通过纯代码分析识别竞态条件极具挑战性。
预测潜在碰撞
预测是关于效率的。既然一切都是多步的,理想情况下我们会测试整个网站上所有可能的端点组合。这是不切实际的 - 相反,我们需要预测漏洞可能发生的地方。一种诱人的方法是简单地尝试找到本文后面描述的漏洞的复制品 - 这很好且容易,但你会错过令人兴奋的、未发现的变体。
首先,识别你想要绕过的具有安全控制的对象。这通常包括用户和会话,以及一些业务特定概念,如订单。
对于每个对象,我们需要识别所有写入它或从中读取数据然后用于重要事项的端点。例如,用户可能存储在数据库表中,该表由注册、配置文件编辑、密码重置启动和密码重置完成修改。此外,网站的登录功能可能在创建会话时从用户表读取关键数据。
竞态条件漏洞需要“碰撞” - 对共享资源的两个并发操作。我们可以使用三个关键问题来排除不太可能引起碰撞的端点。对于每个对象和相关端点,询问:
- 状态如何存储? 存储在持久服务器端数据结构中的数据非常适合利用。一些端点完全在客户端存储其状态,例如通过电子邮件发送JWT的密码重置 - 这些可以安全跳过。
应用程序通常会在用户会话中存储一些状态。这些通常在一定程度上受到子状态的保护 - 稍后详述。
-
我们是编辑还是追加? 编辑现有数据的操作(例如更改帐户的主电子邮件地址)具有充足的碰撞潜力,而仅仅追加到现有数据的操作(例如添加额外的电子邮件地址)除了限制溢出攻击外不太可能易受攻击。
-
操作基于什么键? 大多数端点操作特定记录,该记录使用“键”查找,例如用户名、密码重置令牌或文件名。对于成功的攻击,我们需要两个使用相同键的操作。例如,设想两个合理的密码重置实现:
在第一个实现中,用户的密码重置令牌存储在数据库的用户表中,提供的用户ID充当键。如果攻击者同时使用两个请求为两个不同的用户ID触发重置,两个不同的数据库记录将被更改,因此没有碰撞潜力。通过识别键,你已经确定这个攻击可能不值得尝试。
在第二个实现中,状态存储在用户的会话中,令牌存储操作基于用户的会话ID。如果攻击者同时使用两个请求为两个不同的电子邮件触发重置,两个线程都将尝试更改同一会话的令牌和用户ID属性,会话可能最终包含一个用户的用户ID和发送给另一个用户的令牌。
探测线索
既然我们已经选择了一些高价值端点,是时候探测线索了 - 隐藏子状态存在的提示。我们尚不需要引起有意义的利用 - 我们此时的目标仅仅是唤起线索。因此,你将希望发送大量请求以最大化可见副作用的机会,并减轻服务器端抖动。将此视为基于混乱的策略 - 如果我们看到有趣的东西,我们稍后会弄清楚实际发生了什么。
准备你的请求混合,针对端点和参数以触发所有相关代码路径。在可能的情况下,使用多个请求多次触发每个代码路径,使用不同的输入值。
接下来,通过在每个请求之间间隔几秒发送你的请求混合来基准测试端点