博客
你的限流器挡不住猜密码
按 IP 地址限流挡得住请求洪泛,挡不住猜密码。登录节流该按什么计数、计数存在哪里,以及我犯过的四个错误。
作者 FoxAuth ·
仪表盘一片平稳。每秒请求数正常,错误率也正常。限流器(rate limiter,一种计数器,防止任何单个 IP 地址发送过多请求)整晚一次都没有触发。而有一个账户,登录失败了两千多次。
攻击者没做任何巧妙的事。他手里有几千个 IP 地址。他从每个地址大约每二十秒对同一个邮箱尝试一个密码。我们给每个 IP 地址的额度,是照着浏览器犯点傻的情形调的,这些地址每一个都远远没有用完额度。限流器不折不扣地完成了它的本职:不让任何单个地址把服务器冲垮。可根本没有人在冲击服务器。他们是在猜一个密码——慢慢地,从四面八方同时猜。
我一直以为,登录端点前面的限流器能防暴力破解(brute force,一个个试密码,直到试中为止),这样想的时间长得我都不好意思承认。它防不了,而且怎么调参都没用。限流器按 IP 地址统计请求。而 IP 地址恰恰是攻击者可以自己选的。
按被攻击的邮箱地址计数,而不是按 IP 地址
攻击者选择用哪些 IP 地址。他们选择攻击速度、用多少台机器、何时停下、何时重新开始。他们唯一选不了的,是正在攻击的那个账户:那个账户本来就是攻击的目的。所以,限制猜测的计数器必须按账户计数,也就是按被猜密码的那个邮箱地址计数,而不是按发请求的一方。它设计上的其他所有决定,都由这一点推导出来。
第二个决定是计数存在哪里。我们的限流器把计数器放在服务器进程的内存里。就它的用途而言这没问题,尽管这样存放有三个弱点。如果服务器以多个副本(replica)运行,每个副本有自己的一套计数器,额度就会乘以副本数。重启会清空计数器。另外,计数器表的大小有上限:大量不断变换的地址会把旧计数器挤出去,计数器被挤掉的地址又能拿到一份完整额度。这时限流器会悄悄放行请求。这叫失效放行(fail open):防护出故障时,它是打开而不是关闭。
对限流器来说,这些都无关紧要,因为最坏情况不过是服务器比应有的更忙。登录节流是另一回事:它是针对单个邮箱地址的登录失败计数器,会把这个地址的登录关闭一段时间。它的最坏情况是账户被盗。所以节流把计数器存在数据库里,和我们其他所有数据走同一个存储层。无论在哪个副本上、重启前还是重启后、猜测来自多少个 IP 地址,计数都是同一个。
严格地说,节流同时按两样东西计数:用户桶和邮箱地址。用户桶是本服务器对一组终端用户的叫法,这组用户有自己的登录规则。(用户桶有了自己的 URL 之后,还会以自己的名义签发令牌,成为一个独立的签发者(issuer)。)
这两者都在我们的登录端点前运行。我想说清楚:限流器并没有错。它保护的是服务器资源:让服务器保持运行。它只是不是登录节流。登录节流是一道安全边界:让账户保持关闭。当我不再指望限流器去干节流的活,真正的节流该怎么设计,就归结为围绕数据库里一个计数器的几个决定。每个决定都可能以一种我手上任何测试都发现不了的方式出错。
锁定必须逐次加长,记录必须比锁定活得更久
不妨把一个邮箱地址的登录想象成一扇门。最简单的节流有一个上限和一个窗口:输错五次密码,门就关上十五分钟。这十五分钟就是窗口。对付有耐心的攻击者,这几乎毫无用处。一天有 96 个十五分钟的窗口,每个窗口五次猜测:对一个账户一天就是 480 次猜测。更糟的是,攻击者会读你的错误页面。第一次被锁定时他就摸清了时间表,然后定个闹钟。所以窗口必须变长。每当一个邮箱地址用完五次机会,我们的窗口就加倍:十五分钟,然后三十分钟,然后一小时,最长不超过一小时(这就是封顶)。到了封顶,一天有 24 个窗口,每个五次猜测,所以持续攻击每天大约能猜 120 次。
美国的身份认证指南 NIST SP 800-63B 划线的方式不同:连续失败 100 次。这是另一种限制。我们的是每天的速率,而且不限天数。所以持续攻击确实会超过连续 100 次失败,大约一天就会超过。我们最终定在每天 120 次,是因为锁定也必须让偶尔输错密码的真实用户承受得了。
我的第一个错误就出在这种升级上,也就是窗口的逐次加长。错就错在我做了最自然的事。保存计数的记录有一个 TTL:存活时间,到期后数据库会删掉这条记录。显而易见的 TTL 就是锁定时长本身:门重新打开,记录消失,一切从头开始。我们针对第二因素验证码的节流正是这样做的,在那里这样做是对的,因为它在窗口之间没有什么需要记住的。密码节流则有。它必须记住这个地址已经被锁定过几次,因为这决定了下一次锁多久。如果门一打开记录就消失,节流就会遗忘,下一次锁定又从十五分钟开始。攻击者只要等着,就能永远保持最短的窗口。没有任何东西会报告问题。每一个「门在窗口结束后重新打开」的测试都会通过,因为门确实重新打开了。
所以,同一条记录上有两个期限,它们的意思不同。窗口决定门何时重新打开,以分钟计。过期时间(也就是保留期)决定记录何时被遗忘:最近一次失败之后的二十四小时。过期时间必须始终晚于窗口结束。我们不要求运维人员自己维持两个数字的先后顺序。服务器会在启动时检查配置,拒绝长于保留期的封顶值。
保留期从最后一次失败算起,而不是第一次。所以在没有失败的平静一天之后,记录过期,下一次锁定又从十五分钟开始。一个周一过得很糟的诚实用户,到周五时就不会还停留在六十分钟那一档上。
先计入这次尝试,再检查密码,否则十五次同时尝试会冲过五次的上限
第二个错误是我最不引以为豪的。它是一个教科书式的竞态条件(race condition):几个请求在同一时刻读取同一个值,每个都在不知道其他请求的情况下行动。而我还是写出来了。登录代码先读取计数器,再检查密码,失败时把计数器加一后写回。读取、修改、写入。一个接一个地发送五个错误密码,它工作得完美无缺。同时发送十五个,十五个请求都会在任何一个写回之前读到计数器。每一个都发现门开着,每一个的密码都会被检查。
事后计数器的读数很可能已经高到足以关门。所以在这波突发结束后检查门状态的测试会通过。而统计这波突发让服务器检查了多少次密码的测试则会失败。我们现在有的正是这个测试。
修复方法是在检查密码之前计入这次尝试,并且以原子方式进行:作为一个不可分割的操作,其他请求无法在中途打断它。然后由计数来决定这次尝试能否继续:
export async function recordAttempt(bucketId: string, email: string): Promise<boolean> { const key = throttleKey(bucketId, email); const now = epochTime(); const prior = await held(key);
if (!prior) { await counters().create(key, { failures: 0, windowStart: now, step: 0 }, RETENTION); } else if (now - prior.windowStart >= windowFor(prior.step)) { const exhausted = prior.failures >= cap(); await counters().upsert( key, { failures: 0, windowStart: now, step: exhausted ? prior.step + 1 : prior.step }, RETENTION ); }
// One atomic $inc, answering this attempt its own number. Fifteen at once get 1..15. const attempts = (await counters().increment(key, 'failures', RETENTION)) ?? 1; return attempts <= cap();}这个自增在我们支持的两种数据库上都是一个操作:在 MongoDB 上是带 $inc 的 findOneAndUpdate,在 PostgreSQL 上是对 jsonb 列执行的带 RETURNING 的 UPDATE。因此十五个同时到达的尝试会拿到十五个不同的数字,从 1 到 15,只有前五个在上限之内。同一个操作还把记录的过期时间推到从现在起二十四小时之后。正因如此,「在最后一次失败后保留二十四小时」不需要第二次写入。
这个函数没有显示的,是让计数保持诚实的那一点。如果密码随后被证明是正确的,记录就会被清除。所以留在计数里的只有失败。而因为门关着被拒绝的尝试,根本不会走到检查密码那一步。
最后这一点很重要。用 Argon2(我们用来对密码做哈希的算法)检查密码,被刻意设计得很慢。在节流检查还没挪到账户查找前面的时候,针对已知地址的每一次猜测,都让服务器付出一次完整的哈希计算。放在密码检查之后的节流能限制猜测次数,却让 CPU 成本原封不动。放在密码检查之前,一扇关着的门只需要一次快速的按索引读取。
第 5 步(清除计数器)刻意放在第 6 步(账户是否停用?邮箱是否未验证?是否需要第二因素?)之前。一个已停用、或邮箱尚未验证的账户,仍然会在第 6 步被拒绝。但它的计数器会先被清除:密码是对的,而已经被证明正确的密码不存在被猜的问题。如果把清除放到最底部、和登录成功放在一起,那么一个密码正确但邮箱未验证的用户就永远走不到那一步。他会因为知道自己的密码而被节流。
拒绝和计数器都不能透露账户是否存在
那张图右侧的框——所有情况共用同一种拒绝——就是我第三个错误的修复。这个错误,错在我太想帮忙。我原本想写、并且在别处也上线过的版本,会说「尝试次数过多,请 14 分钟后再试」。一条友善的消息。但它也是一个预言机(oracle):一种回答了攻击者本不该有机会提出的问题的响应。它告诉攻击者,他们正在探测的地址确实有账户(不然为什么要计数?)。它还准确地告诉他们什么时候再来。
修复分为两部分,第二部分不如第一部分显而易见。第一部分:这扇门上的每一次拒绝,都来自代码里的同一个表达式,它渲染同一个页面。因此关着的门、未知的地址、错误的密码和已停用的账户,得到的响应完全相同,一个字节都不差。这一点由代码本身保证:页面只从一个地方产生,而不是同一段文字的四份副本,靠人去保持一致。
第二部分:对提交的任何地址都会写入计数器,包括没有账户的地址。如果节流只为真实账户计数,它对真实地址和编造的地址就会表现不同。一旦这种差异在任何地方显露出来(响应时间、某个响应头、未来的某个 bug),节流本身就会变成一种查出哪些地址有账户的手段。这叫枚举攻击(enumeration)。
所以存放这些计数器的存储区域被刻意设计成不属于任何账户。它的记录不带账户 ID:一个不存在的账户的计数器,不可能有账户 ID。记录里也没有单独的邮箱字段。地址本来就在记录的键里。再用一个字段重复它,只会给任何人在登录表单里输入过的每一个邮箱再添一份带索引的副本。
同样的决定也适用于事件。当节流拒绝一次登录时,服务器会发出一个事件,事件里只指明用户桶,别的一概没有。运维人员需要知道哪组用户正在遭受攻击。他们不需要一份能从中读出别人邮箱地址的日志。
键要和账户查找做同样的规范化,否则一个地址会有 65,536 个计数器
第四个错误通过了我所有的测试,而且会一直通过下去。账户查找不区分大小写,所以 alice@example.com 和 Alice@Example.com 是同一个用户。但计数器的键是地址被输入时的原样。所以一个地址的大小写有多少种组合,它就有多少个独立的计数器。每个字母都可大写可小写,所以一个含十六个字母的地址有 65,536 种写法,每种都有自己的五次上限。每个测试用的都是小写地址。每个测试都通过了。
/* * THE RULE IS PARITY WITH THE ACCOUNT LOOKUP, not "lower-case it". findByEmail is * case-insensitive, so two spellings that resolve to one account must resolve to * one counter. If the lookup ever starts trimming, folding Unicode or stripping * +tags, this function is what has to follow it. */export function emailScopedId(bucketId: string, email: string): string { return `${bucketId}:${email.toLowerCase()}`;}有意思的是注释,而不是代码。人们很容易把它读成「记得转成小写」,然后就此翻过。但规则不是这样。账户查找会把同一地址的不同写法统一成一种形式(这叫规范化)。查找做什么规范化,键就必须做同样的规范化。假设有一天有人让查找忽略 Gmail 地址里的点号。从那天起,光转小写就不够了。注释把规则写成「与查找保持一致」,而不是某一种具体的文本变换。这样,下一个改动查找的人就知道这个函数也得跟着改。
这也是为什么它是一个函数,由三个以邮箱地址为键的存储区域(LoginThrottle、PasswordResetThrottle 和 VerificationResend)共享,而不是把同一个表达式写三遍。还是三份副本的时候,其中一份已经漏掉了转小写的调用。它之所以没造成危害,只是因为 MongoDB 用户存储——当时生产环境中唯一的用户存储(PostgreSQL 支持是后来才加入的)——恰好会对存入的地址做规范化。
完成密码重置就能解除锁定:账户主人做得到,攻击者做不到
逐次加长的锁定,只有在账户主人有一条回到账户的路、而猜密码的人没有时,才能接受。否则你就造出了一个拒绝服务工具:任何人都能把它对准任何邮箱地址,把账户主人挡在门外。到了封顶,这就是某人一天中的一个小时,而且攻击者可以反复来。
显而易见的回路是「输入我们发到你邮箱的验证码,以继续尝试」。在把它为什么错写下来之前,我在这条路上走了挺远。第一,这条提示只对有账户的地址才是真话。只对这些地址显示它,就把预言机又带了回来。第二,它会按需向任何人输入的任何地址发邮件。所以任何人都能用它轰炸别人的收件箱(邮件轰炸)。所以它需要自己的上限,而锁定只不过挪到了那里。第三,邮件一出问题,登录就跟着失效。而在这个服务器的其他所有地方,邮件都只是尽力而为:能发就发。第四,它帮不了管理员自己的用户桶,那里根本没有自助重置密码。
我们实际采用的做法更窄,我认为也是正确的。完成一次密码重置会清除计数器。仅仅请求重置则不会。区别在于各自证明了什么。请求重置什么也证明不了。如果请求就能清除计数器,任何知道某个地址的人,都能抹掉正在挡住他们的计数。要完成重置,你必须使用发送到账户邮箱的那个一次性秘密。这证明你控制着那个邮箱。这和「输入验证码」那一步本该收集的证据相同。只不过这里是由一个已经存在的流程来收集,而这个流程已经有自己的请求间隔和每日上限。
管理员的用户桶,是下面这条规则存在的原因。(但不是封顶定为一小时的原因。)管理员通过同一扇门登录,却没有密码重置可以脱身。所以针对管理员地址的长时间升级,是唯一一种没人能撤销的锁定。保护他们的规则并没有写成「如果这是管理员的用户桶」。它写的是:「在要求第二因素的用户桶里,无论节流被触发多少次,每次锁定都停留在第一个窗口」。原因是,在这样的用户桶里,仅凭猜中的密码登录不了。所以在那里长时间锁定收益甚微,风险却很大:可能把真正的管理员挡在门外。一旦运维人员为管理员的用户桶开启第二因素,它就适用这条规则。「创建第一个管理员」指南要求最先做的就是这件事。
这样表述,规则说出了真正的理由。而且它会自我纠正:关掉一个用户桶的第二因素,它的锁定就会重新开始逐次加长。无需任何人记得去改什么。
不该做的事:关闭开关、数据库出错时放行登录,以及只依赖节流
我不会给节流装关闭开关,我们的节流也确实没有。第一个去设置控制台里找开关的人为此感到意外。控制台里有三个数字:上限、第一个窗口和封顶值。每个数字都有取值范围,由服务器启动时检查配置的同一个校验器来检查。因此控制台无法保存服务器会拒绝启动的配置。上限不能超过 100,因为允许更多次数的节流就不再是节流了。没有开/关复选框。安全边界上的关闭开关,就是一个重新打开漏洞的开关。而控制台正是开关在凌晨两点被拨动、然后再也没被拨回来的地方。
我也不会让节流失效放行,也就是在它出故障时放行登录。如果读取计数器时出现存储错误,这个错误不会被捕获。计数器读取就在账户查找的前一行,而账户查找访问的是同一个数据库。所以如果数据库出了故障,登录片刻之后本来也会失败。而捕获这个错误,就等于对一个登录从未因这个原因被拒绝的用户回答「invalid username or password」。
我也不会假装节流把活儿都干完了。按邮箱地址的节流约束的是针对一个账户的猜测。它对密码喷洒(password spraying)毫无作用:用一个常见密码,对一万个账户各试一次。每个账户只看到一次失败,所以没有哪个计数器会接近上限。限流器能抓住它的朴素版本,即从一个 IP 地址发起的喷洒。而僵尸网络(受攻击者控制的大量机器)绕过它的方式,和本文开头那次攻击绕过我的限流器一模一样。真正能阻止喷洒攻击的是两件事:拒绝出现在泄露密码列表中的密码,以及要求第二因素。我逐渐认为,登录节流的主要作用,是让你在还没有第二因素时也足够安全,直到你有了它。
我们服务器的默认值是五次失败、十五分钟的第一个窗口和一小时的封顶。设置参考记录了这三个数字及其取值范围。威胁模型列出了登录时的每项检查是为了阻止什么。它关于局限性的那一节还明确写出了这个服务器中哪些计数器是安全边界,哪些只是保护资源。