密钥派生函数是否会对 API 构成拒绝服务威胁?

信息安全 api 拒绝服务
2021-09-05 10:26:22

想象一个简单的 API,它提供了一个端点POST /account/authenticate,它接受用户名和密码,然后在成功时返回 JWT,在失败时返回错误。在后端,端点使用一些密钥导出函数,如 Argon2 或 PBKDF2,参数调整为难以破解。

这样的端点不会允许非常简单的资源耗尽攻击吗?攻击者可以在服务器上造成高工作量,而无需自己做很多工作。根据 KDF 的配置方式,许多并行请求可能会消耗大量服务器内存。

这真的是一个问题吗?如果是这样,如何减轻这种情况?由于这是一个 API,因此无法使用 CAPTCHA 等典型的前端措施。

4个回答

是的,这是一个合理的担忧。至于如何减轻它,您有几个(不是相互排斥的)选项:

  1. 您已经实现的一件事不是对每个请求都进行慢速密钥派生,而是拥有一个接受密码(或等效)的身份验证端点,应用慢速密钥拉伸 KDF,验证结果的正确性并返回(通常是有时间限制的)令牌,可用于对后续请求进行快速身份验证。

    这样的令牌应该包含什么取决于您的后端实现。如果您可以在后端轻松安全地存储会话数据,那么最简单的解决方案可能是简单地生成一个(加密的)随机令牌,例如 128 位或 256 位,并将其返回给客户端。然后,您可以将后端处理所需的任何敏感信息(可能包括 KDF 输出的主伪随机密钥,或从它派生的一个或多个子密钥)存储在由随机令牌作为密钥的后端会话存储中。

    如果你希望你的后端是无状态的,事情会变得更加复杂。如果您可以安排后端访问秘密加密密钥,则一种选择是使用直接使用密钥加密的 JWE 令牌(使用经过身份验证的加密算法 - 但幸运的是 JWE 支持的所有加密算法都经过身份验证!) 并包含后端快速身份验证所需的任何信息。根据您在后端执行的操作,这可能包括从 KDF 输出派生的一个或多个密钥,但对于不需要在服务器上执行任何每用户加密或解密的应用程序,即使只是 ID经过身份验证的帐户可能就足够了。

    现在,显然,仅将慢速密钥派生限制到单个端点不会阻止该端点被 DoSed。但它确实减少了正常使用中密钥派生的服务器负载,也为进一步的 DoS 对策铺平了道路,例如:

  2. 速率限制您的身份验证端点。 有了适当的速率限制,对您的身份验证端点的 DoS 攻击应该只能拒绝对该端点的访问,而不会干扰已经过身份验证的客户端。虽然这仍然不理想,但这是一个显着的改进,特别是如果您允许您的客户建立相当长的会话(例如,1 天)。

    对于一些简单类型的 DoS 攻击,基于例如源 IP 地址的更细粒度的速率限制甚至比单一的全局速率限制更有效。是的,分布式攻击(例如通过僵尸网络)可以规避这种速率限制,但服务器端 KDF之所以能够首先吸引 DoS 目标,是因为它们可以在没有僵尸网络的大量带宽的情况下轻松实现 DoS。如果您的攻击者有一个僵尸网络,他们可能不需要针对您的 KDF。

  3. 如果您不能或不想实现显式速率限制,“软”替代方案可以是在单独的服务器上运行您的身份验证端点,或者至少在单独的资源受限容器中运行。这还可以防止对身份验证代码的 DoS 攻击破坏服务的其余部分。当然,为此,身份验证服务器和其他端点需要共享对相同会话存储和/或相同令牌加密密钥的访问权限。

  4. 正如ThoriumBR 的回答中所述,您还可以要求客户端提交工作证明作为身份验证请求的一部分。本质上,这迫使客户端在请求上花费的精力与服务器在计算 KDF 上花费的精力一样多,或者至少是其中的一些合理部分,从而消除或减少了攻击者的影响力。但是,我实际上并不推荐这种方法,因为如果你能做到这一点,还有一个更好的选择:

  5. IMO 通过慢速 KDF 避免 DoS 的绝对最佳方法是将慢速密钥派生卸载到客户端基本上,与其让客户端向服务器发送密码短语,后者然后使用慢速 KDF(如 PBKDF2 或 Argon2 等)从中派生伪随机主密钥,只需让客户端运行慢速 KDF 并发送其作为请求的一部分输出。

    这确实需要以某种方式确保客户端知道它需要使用哪些盐、迭代计数和其他 KDF 参数。处理此问题的最简单方法可能是让客户端在单独的请求中从服务器请求这些参数。对于大多数参数,这没有问题(尽管客户端绝对应该至少强制执行最小迭代次数!),但盐确实需要一些额外的考虑:

    • 如果您不希望您的预身份验证端点透露您的系统上存在哪些用户 ID,您将不得不为不存在的用户名生成假盐,例如通过将用户名与服务器端机密一起散列。(当然,防止用户 ID 泄露并不总是可取或实际的。)
    • 在任何情况下,您都会泄露用户的 salt,这意味着攻击者可能会观察到它的任何更改。如果您在用户更改密码时遵循更改盐的标准程序,这可以让攻击者确认用户 ID 存在(假设您的假盐没有改变)以及用户有(或没有)自攻击者上次查询以来更改了他们的密码。通常,这种泄漏似乎或多或少是不可避免的,除非为每个用户使用固定盐(这有其自身的问题)。
    • 您可能还希望客户端增加服务器发送的 salt,例如通过附加用户 ID 和可能的一些服务器或应用程序特定的字符串到它。这是为了防止中间人攻击者欺骗客户端在另一个服务上使用属于同一用户的 salt 和 KDF 参数,如果用户对这两个服务使用相同的密码,这可能是一个问题。

    此外,您可能仍希望通过服务器上的第二个 KDF 运行客户端发送的 KDF 输出 - 但第二个 KDF 可以是快速 KBKDF,例如 HKDF ( RFC 5869 )。根据您的应用程序,这可能不是严格要求的,但它不会造成伤害并且可以具有各种优势。特别是:

    • 它允许您从 KDF 输出中派生多个子键和/或检查任何所需长度的值,而客户端无需知道这一点;
    • 如果您使用(部分)KDF 输出进行用户身份验证,通过将其与存储在用户数据库中的“密码哈希”字符串进行比较,拥有服务器端 KDF 步骤可防止破坏您的数据库的攻击者使用存储的直接散列进行身份验证;
    • 它通过确保无论客户端向您发送什么,它都会在触及您服务器上的任何其他加密代码之前通过 HKDF,从而保护您免受使用格式错误的输入的潜在攻击。

您不需要验证码,您可以让客户端发送工作证明令牌以及用户名和密码。

它会是这样的:

  1. 客户GET /account/challenge并收到一个随机数

  2. 客户必须MD5(nonce + random string)直到他找到第一个(或最后一个)4位数字为零的散列

  3. POST /account/authenticate具有随机数、随机字符串、用户名和密码的客户端

  4. 如果您计算哈希并检查,您可以执行 KDF

我在这里推荐 MD5,因为它是一个快速散列,并且您希望服务器端的快速散列花费很少的时间来计算它。如果需要,您可以增加工作量证明的难度,它不会改变服务器上的负载,只会改变客户端上的负载。

是的,如果您没有任何类型的速率限制,它们可能是 DoS 问题(对于 CPU 或内存)。但是,如果您没有限制登录请求的速率,那么您也会遇到各种其他安全问题。

速率限制、验证码、IP 阻塞等也可用于防止这种情况。您还需要在调整散列算法时找到适当的平衡——破解散列的难度越大,登录的成本就越高,因此您就越容易遇到资源耗尽问题(无论是来自故意攻击还是只是流量大)。

在某些情况下,允许输入很长的密码可能会导致 DoS(因为这些哈希的成本要高得多)。Django 早在 2013 年就发布关于此问题的公告,并实施了 4096 字节的最大密码长度来防止它发生。

在继续执行更昂贵的密钥派生功能之前,您的端点可能会检查用户名是否存在于您的帐户数据库中(一种廉价的检查)。大概你的攻击者不知道大量的用户名来敲击你的端点。因此,您应该能够通过限制每个用户名的尝试次数来在一定程度上减轻这种类型的攻击。