← All posts

我们那道输错五次密码就该锁定的机制,从没锁住过任何人

Authagonal·August 5, 2026

账户锁定是最基本的要求。输错五次密码,账户就会被锁定十分钟,这就把在线猜密码从一个真实的威胁变成了一条死路:攻击者对每个账户每十分钟只有五次尝试,几乎等于什么都没有。我们本来是有这套机制的。阈值是五次,时间窗口是十分钟,检查它的代码是正确的,而且每次测试都完美运行,因为每次测试时,我们都是把错误密码一次接一次地连输五遍,像一个真人那样。

攻击者并不是一个一次接一次输入的真人。攻击者会一次性发出五百个。而当我们这样做时,那个本应在五次就停下的计数器几乎纹丝不动,账户也从未被锁定。你可以向单个账户抛出无限数量的猜测,而那个专门为拦住这种情况而设计的唯一防护,就那样呆坐着什么也不做,不是因为逻辑错了,而是因为它假定自己每次只会被问一个问题。

一个会数漏的计数器

处理登录失败的代码做了那件显而易见的事。遇到错误密码时,它加载用户,把用户的失败尝试计数加一,检查新值是否已经到达五,然后把用户保存回去。读取,修改,写入。按照一个人思考它的方式写出来,它是正确的。

按照两个请求在同一时刻到达的方式写出来,它就不正确了。两者都读取计数,而且两者读到的是同一个值,就说是三吧。两者都加一,得到四。两者都把四写回去。发生了两次失败尝试,可计数只从三变成了四。其中一次自增就这么凭空消失了,被另一次覆盖掉,因为两者都是从同一个陈旧的起点出发的,而第二次写入落在第一次之上,仿佛第一次从未发生过。

现在一次性打出五百个错误密码。它们正是以这种方式相互叠压,每一个都读到一个偏低的计数,再写回一个只是略微没那么低的计数,自增就这样成百地蒸发掉。计数漂着往上挪了一点点,停在离五还很远的地方。锁定阈值从未被越过,不是因为检查有错,它每一次都被忠实地执行,而是因为它所检查的那个数字是一个谎言,由一群相互覆盖的请求拼凑而成。暴力破解防护被击败,不是靠一个逻辑缺陷,而是靠并发,而这种缺陷会通过你此生写下的每一个串行测试,却会在遇到一个真正的攻击者的那一刻失效,而攻击者是唯一有动机并行发送请求的一方。

这个缺陷藏在哪里,很重要。它并没有埋在某个存储层里。它就在那里,在登录处理器中,一目了然,一个 ++ 和一次保存,是世上最自然不过的代码。读它的时候,没有任何东西暗示危险。你必须正想着它的两个副本同时运行,这个缺陷才会显现,而一行一行地读代码,恰恰是那种你不会那样想的心态。

在一个不会计数的存储上让计数器变得原子

修复必须让整个读取-修改-写入变得不可分割:任何其他尝试都不得挤进我们的读取和写入之间。有意思的地方在于,我们所在的是一个没有自增操作的键值存储。你没法告诉它“给这个字段加一”,而那正是一个带有原子计数器的数据库会递到你手上的原语。你只能读取一行、写入一行。

存储确实给你的,是一次条件写入。每一行都带着一个版本标记,它在每次写入时都会改变,你可以说:把这一行写回去,但只有当它的版本仍然是我读到的那个时才写。如果在这期间有别人写过了,版本就变了,于是你的写入会被拒绝,而不是悄无声息地把对方的写入砸掉。

这就把丢失自增的竞态变成了一次被捕获的冲突。读取这一行和它的版本。加一。尝试在以那个版本为条件的前提下写回。如果成功,你的自增就一定落在了你读到的那个值之上,什么也没丢。如果被拒绝,那意味着另一个尝试抢在了你前面,于是你重新读取如今更高的计数,再试一次。在被拒绝时循环,重复有限的那么几次,每一次自增就都被迫排在上一次之后串行进行,而不是把它覆盖掉。计数重新变得真实,而五百个一次性的攻击如今会把计数器径直推到五并触发锁定,这正是全部意义所在。

我们把同样的形态搬到了我们另一个存储后端上,它同样没有自增,但同样提供条件写入,这一次是以计数器的值本身为条件,而不是以一个版本标记:只有当已存储的计数仍然是我读到的那个时,才写入新的计数。同样的保证,用那个存储的词汇表达出来。这条教训超越了任何一种具体的数据库:当你缺少一个原子自增时,一次条件写入加上一个重试循环就能把它重建出来,因为正是那个条件拒绝了那一直在让你丢失计数的陈旧覆盖。

有一处刻意的不对称值得一提。我们把失败路径做成了原子的,而把成功路径,也就是在登录正确时把计数器重置为零的那条路径,留作一次普通的、最后写入者胜出的写入。并发并不威胁一次重置:几次成功的登录相互竞争,都想写入零,谁胜出都无所谓。只有自增才是对抗性的,因为只有自增才是攻击者试图压住的那个东西。你把条件写入和重试的成本花在对手正在施压的地方,而不花在他没有施压的地方。

假定有人正在计数

这个缺陷真正的价值,在于它逼出来的那种心态,因为就在修复它的同一天,我们去把其余一切悄悄假定了“一次一个请求”或假定了没有人在度量的地方都找了一遍,结果发现了一小簇同类。

我们的登录响应正在告诉攻击者哪些账户存在。一个不存在的账户、一个被停用的账户、一个被锁定的账户,以及一个错误密码,各自都产生了一个可区分的回答,而“查无此人”那条路径甚至返回得更快,因为它完全跳过了密码哈希,于是攻击者可以通过观察状态或观察时钟来得知谁有账户。我们把这些回答合并了:一个未知用户和一个错误密码如今返回完全相同的响应,而一个未知用户仍然会被拿去和一个固定的假哈希做校验,好让无论账户是否存在,耗时都一样。账户存在或者不存在,而你从外面看不出来。

注册环节有着同样的泄露,只是形式更棘手,因为当有人试图注册一个已被占用的邮箱时,你不能简单地一律回答成功,因为那似乎等于把账户拱手交给了他。所以,针对一个已注册地址的注册,如今返回的是和一次全新注册完全相同的中性成功,花费同样的密码哈希时间,什么也不创建,而是转而给真正的所有者发一封邮件,说有人试图注册他的地址。攻击者什么也学不到。真正的所有者则被悄悄地告知。

而找回密码的表单是一门邮件大炮:没有按地址的限制,于是你可以让我们朝任意地址反复发射重置邮件,这是一种既针对第三方、又针对我们自身发信声誉的攻击。它现在会按目标邮箱做速率限制,一旦超过上限,就跳过发送,同时照旧返回同样的成功响应,好让限流本身不会变成一种探测哪些地址存在的新办法。

上面每一个,都是那个锁定缺陷的教训换了身衣裳。请把设计做成仿佛有一个攻击者正在并行发送请求、给你的响应计时、并且在计数。因为你唯一能确定的一件事,就是那个正在攻击你登录的人,绝不会一次一个礼貌请求地去做。

为什么一个安全计数器必须并发地测试

安全控制中的一个并发缺陷,比任何其他地方的并发缺陷都更糟糕,理由很具体:最有动机去触发它的那一方就是攻击者,而触发它的条件,也就是许多同时到达的请求,恰恰是一次攻击会制造、而一个测试几乎从不会制造的条件。所以一道锁定、一个速率限制、一份配额,任何建立在计数器之上的控制,都无法靠在一个循环里递增它、看着它跳闸来验证。它必须被并发地击打,因为并发是攻击者的原生环境,而这个测试的串行版本只能证明你的控制在面对一个已经同意讲礼貌的对手时是有效的。

而当你确实要加固这个计数器时,在一个没有原子自增的存储上,工具就是条件写入:读取版本,只有当它没有移动过时才写入,如果它移动了就重试。它用数据库给了你的那唯一一条保证,把数据库没有给你的那个原子自增重建了出来。

如果你更愿意运行在一个早已假定攻击者正在并行计数的身份提供方之上,Authagonal 把这些控制打造成会被一次性全部击打,因为无论如何,那本来就是它们唯一会被真正击打的方式。