← All posts

Nuestro bloqueo tras cinco contraseñas incorrectas nunca bloqueó a nadie

Authagonal·August 5, 2026

El bloqueo de cuenta es lo mínimo indispensable. Cinco contraseñas incorrectas y la cuenta queda bloqueada durante diez minutos, lo que convierte el adivinado de contraseñas en línea, de una amenaza real, en un callejón sin salida: un atacante consigue cinco intentos por cuenta y por cada diez minutos, o sea nada. Lo teníamos. El umbral era cinco, la ventana era diez minutos, el código que lo comprobaba era correcto, y funcionaba a la perfección cada vez que lo probábamos, porque cada vez que lo probábamos tecleábamos la contraseña incorrecta cinco veces seguidas, una tras otra, como una persona.

Un atacante no es una persona tecleando una tras otra. Un atacante envía quinientas de golpe. Y cuando hicimos eso, el contador que se suponía debía detenerse en cinco apenas se movió, y la cuenta nunca se bloqueó. Podías lanzar una cantidad ilimitada de intentos contra una sola cuenta, y el único control diseñado para detener exactamente eso se quedaba ahí sin hacer nada, no porque la lógica fuera incorrecta, sino porque suponía que solo se le haría una pregunta a la vez.

Un contador que pierde la cuenta

El código del inicio de sesión fallido hacía lo obvio. Ante una contraseña incorrecta cargaba al usuario, sumaba uno a su recuento de intentos fallidos, comprobaba si el nuevo valor había alcanzado cinco, y volvía a guardar al usuario. Leer, modificar, escribir. Escrito tal como una persona lo pensaría, es correcto.

Escrito tal como dos solicitudes que llegan en el mismo instante, no lo es. Ambas leen el recuento, y ambas leen el mismo valor, digamos tres. Ambas suman uno y obtienen cuatro. Ambas escriben cuatro de vuelta. Ocurrieron dos intentos fallidos, y el recuento pasó de tres a cuatro. Uno de los incrementos simplemente se esfumó, sobrescrito por el otro, porque ambas partían del mismo punto de partida caduco y la segunda escritura se posó sobre la primera como si la primera nunca hubiera existido.

Ahora dispara quinientas contraseñas incorrectas de golpe. Se amontonan unas sobre otras exactamente de esta manera, cada una leyendo un recuento bajo y escribiendo uno apenas menos bajo, con los incrementos evaporándose por centenares. El contador sube un poco a la deriva y se detiene muy lejos de cinco. El umbral de bloqueo nunca se cruza, no porque la comprobación sea incorrecta, se comprueba fielmente cada una de las veces, sino porque el número que comprueba es una mentira ensamblada por una multitud de solicitudes que se sobrescriben todas entre sí. La protección contra la fuerza bruta queda derrotada no por un fallo de lógica sino por la concurrencia, que es la clase de fallo que pasa cada prueba secuencial que jamás escribirás y falla en el momento en que se topa con un atacante real, la única parte con un motivo para enviar solicitudes en paralelo.

Dónde vivía este error importa. No estaba enterrado en alguna capa de almacenamiento. Estaba ahí mismo, en el manejador de inicio de sesión, a plena vista, un ++ y un guardado, el código más natural del mundo. Nada en su lectura sugiere peligro. Tienes que estar pensando en dos de ellos ejecutándose a la vez para que el error aparezca, y leer código una línea a la vez es precisamente el estado mental en el que no lo estás.

Hacer atómico un contador sobre un almacén que no sabe contar

La corrección tiene que hacer indivisible todo el leer-modificar-escribir: ningún otro intento puede colarse entre nuestra lectura y nuestra escritura. Lo interesante es que estamos sobre un almacén clave-valor sin operación de incremento. No puedes decirle "suma uno a este campo", que es la primitiva que una base de datos con contadores atómicos te entregaría. Solo puedes leer una fila y escribir una fila.

Lo que el almacén sí te da es una escritura condicional. Cada fila lleva una etiqueta de versión que cambia con cada escritura, y puedes decir: vuelve a escribir esta fila, pero solo si su versión sigue siendo la que leí. Si alguien más escribió mientras tanto, la versión se movió, y tu escritura se rechaza en lugar de aplastar en silencio la suya.

Eso convierte la carrera por los incrementos perdidos en una colisión detectada. Lee la fila y su versión. Suma uno. Intenta volver a escribir condicionado a esa versión. Si tiene éxito, tu incremento aterrizó con certeza sobre el valor que leíste, sin perder nada. Si se rechaza, significa que otro intento se te adelantó, así que vuelves a leer el recuento ahora más alto y lo intentas de nuevo. Repite ante el rechazo, un puñado acotado de veces, y cada incremento se ve forzado a serializarse detrás del anterior en lugar de sobrescribirlo. El recuento vuelve a ser verdadero, y el ataque de quinientas de golpe ahora lleva el contador derecho hasta cinco y activa el bloqueo, que es la finalidad entera.

Llevamos la misma forma a nuestro otro backend de almacenamiento, que tampoco tiene incremento pero también ofrece una escritura condicional, esta vez condicionada al propio valor del contador en lugar de a una etiqueta de versión: escribe el nuevo recuento solo si el recuento almacenado sigue siendo el que leí. La misma garantía, expresada en el vocabulario de ese almacén. La lección va más allá de cualquier base de datos concreta: cuando te falta un incremento atómico, una escritura condicional más un bucle de reintento reconstruye uno, porque la condición es lo que rechaza la sobrescritura caduca que estaba perdiendo tu cuenta.

Vale la pena señalar una asimetría deliberada. Hicimos atómico el camino de fallo y dejamos el camino de éxito, que reinicia el contador a cero ante un inicio de sesión correcto, como una simple escritura donde gana el último que escribe. La concurrencia no amenaza un reinicio: varios inicios de sesión exitosos compitiendo quieren todos escribir cero, y no importa cuál gane. Solo el incremento es adversario, porque solo el incremento es aquello que un atacante intenta mantener bajo. Gastas el coste de las escrituras condicionales y los reintentos allí donde un adversario empuja, y no allí donde no lo hace.

Da por hecho que alguien está contando

El verdadero valor de este error fue la mentalidad que impuso, porque el mismo día en que lo corregimos nos pusimos a buscar todo lo demás que suponía en silencio una-solicitud-a-la-vez o suponía que nadie estaba midiendo, y encontramos un pequeño racimo de hermanos.

Nuestras respuestas de inicio de sesión les decían a los atacantes qué cuentas existían. Una cuenta inexistente, una deshabilitada, una bloqueada y una contraseña incorrecta producían cada una una respuesta distinguible, y el camino del usuario inexistente incluso regresaba más rápido porque se saltaba por completo el hash de la contraseña, de modo que un atacante podía averiguar quién tiene una cuenta observando o bien el estado o bien el reloj. Fusionamos las respuestas: un usuario desconocido y una contraseña incorrecta devuelven ahora la respuesta idéntica, y un usuario desconocido sigue verificándose contra un hash ficticio fijo para que el tiempo sea el mismo exista o no la cuenta. La cuenta existe o no, y no puedes saberlo desde fuera.

El registro tenía la misma fuga en una forma más difícil, porque no puedes simplemente decir siempre éxito cuando alguien intenta registrar un correo que ya está tomado, ya que eso parecería entregarle la cuenta. Así que un registro para una dirección ya registrada devuelve ahora exactamente el mismo éxito neutro que devuelve un alta nueva, gasta el mismo tiempo de hash de contraseña, no crea nada, y en su lugar le envía al verdadero propietario un correo diciendo que alguien intentó registrar su dirección. El atacante no averigua nada. Al verdadero propietario se le avisa en silencio.

Y el formulario de restablecimiento de contraseña era un cañón de correos: sin límite por dirección, así que podías hacernos disparar correos de restablecimiento a cualquier dirección una y otra vez, un ataque dirigido a un tercero y a nuestra propia reputación de envío. Ahora limita la tasa por dirección de destino y, por encima del tope, se salta el envío mientras devuelve la misma respuesta de éxito de siempre, para que la propia limitación no se convierta en una nueva forma de sondear qué direcciones existen.

Cada uno de esos casos es la lección del error de bloqueo vestida con otra ropa. Diseña como si un atacante estuviera enviando solicitudes en paralelo, cronometrando tus respuestas y contando. Porque de lo único que puedes estar seguro es de que la persona que ataca tu inicio de sesión no lo hace una educada solicitud a la vez.

Por qué un contador de seguridad tiene que probarse en concurrencia

Un error de concurrencia en un control de seguridad es peor que un error de concurrencia en cualquier otra parte, por una razón concreta: la parte con el motivo más fuerte para dispararlo es el atacante, y las condiciones que lo disparan, muchas solicitudes simultáneas, son exactamente las condiciones que un ataque crea y que una prueba casi nunca crea. Así que un bloqueo, una limitación de tasa, una cuota, cualquier control construido sobre un contador, no se puede validar incrementándolo en un bucle y viéndolo dispararse. Hay que golpearlo en concurrencia, porque la concurrencia es el entorno natural del atacante, y la versión secuencial de la prueba solo demuestra que tu control funciona contra un adversario que ha accedido a ser educado.

Y cuando endureces el contador, la herramienta sobre un almacén sin incrementos atómicos es la escritura condicional: lee la versión, escribe solo si no se ha movido, reintenta si se ha movido. Reconstruye el incremento atómico que la base de datos no te dio, a partir de la única garantía que sí te dio.

Si prefieres funcionar sobre un proveedor de identidad que ya da por hecho que el atacante está contando en paralelo, Authagonal construyó estos controles para ser golpeados todos de golpe, porque esa era de todos modos la única manera en que alguna vez iban a ser golpeados de verdad.