Notre verrouillage après cinq mauvais mots de passe n'a jamais verrouillé personne
Le verrouillage de compte, c'est le minimum vital. Cinq mauvais mots de passe et le compte est verrouillé pendant dix minutes, ce qui transforme le devinage de mots de passe en ligne, d'une véritable menace, en une impasse: un attaquant obtient cinq essais par compte et par dix minutes, autant dire rien. Nous l'avions. Le seuil était de cinq, la fenêtre de dix minutes, le code qui le vérifiait était correct, et il fonctionnait parfaitement à chaque test, parce qu'à chaque test nous tapions le mauvais mot de passe cinq fois de suite, l'un après l'autre, comme une personne.
Un attaquant n'est pas une personne qui tape l'un après l'autre. Un attaquant en envoie cinq cents d'un coup. Et quand nous l'avons fait, le compteur censé s'arrêter à cinq a à peine bougé, et le compte ne s'est jamais verrouillé. Vous pouviez jeter un nombre illimité de tentatives sur un seul compte, et le seul contrôle conçu pour arrêter exactement cela restait là, sans rien faire, non pas parce que la logique était fausse, mais parce qu'elle supposait qu'on ne lui poserait jamais qu'une seule question à la fois.
Un compteur qui perd le compte
Le code de l'échec de connexion faisait la chose évidente. Sur un mauvais mot de passe, il chargeait l'utilisateur, ajoutait un à son nombre de tentatives échouées, vérifiait si la nouvelle valeur avait atteint cinq, puis réenregistrait l'utilisateur. Lire, modifier, écrire. Rédigé comme une personne y penserait, c'est correct.
Rédigé comme deux requêtes arrivant au même instant, ça ne l'est pas. Les deux lisent le compteur, et les deux lisent la même valeur, disons trois. Les deux ajoutent un et obtiennent quatre. Les deux réécrivent quatre. Deux tentatives échouées ont eu lieu, et le compteur est passé de trois à quatre. L'un des incréments a tout simplement disparu, écrasé par l'autre, parce que les deux partaient du même point de départ périmé et que la seconde écriture s'est posée sur la première comme si la première n'avait jamais existé.
Lancez maintenant cinq cents mauvais mots de passe d'un coup. Ils s'empilent exactement de cette façon, chacun lisant un compteur bas et réécrivant un compteur à peine moins bas, les incréments s'évaporant par centaines. Le compteur dérive un peu vers le haut et s'arrête bien loin de cinq. Le seuil de verrouillage n'est jamais franchi, non pas parce que la vérification est fausse, elle est effectuée fidèlement à chaque fois, mais parce que le nombre qu'elle vérifie est un mensonge assemblé par une foule de requêtes qui s'écrasent toutes les unes les autres. La protection contre la force brute est mise en échec non par une faille de logique mais par la concurrence, ce genre de faille qui passe tous les tests séquentiels que vous écrirez jamais et échoue à l'instant où elle rencontre un vrai attaquant, le seul qui ait un motif d'envoyer des requêtes en parallèle.
L'endroit où vivait ce bogue compte. Il n'était pas enterré dans une couche de stockage. Il était là, en plein dans le gestionnaire de connexion, bien visible, un ++ et un enregistrement, le code le plus naturel du monde. Rien à sa lecture ne suggère un danger. Il faut penser à deux exemplaires qui s'exécutent en même temps pour que le bogue apparaisse, et lire du code une ligne à la fois est précisément l'état d'esprit dans lequel on ne le fait pas.
Rendre un compteur atomique sur un magasin qui ne sait pas compter
La correction doit rendre indivisible tout le lire-modifier-écrire: aucune autre tentative ne peut se glisser entre notre lecture et notre écriture. Le point intéressant, c'est que nous sommes sur un magasin clé-valeur dépourvu d'opération d'incrément. Vous ne pouvez pas lui dire "ajoute un à ce champ", la primitive qu'une base de données à compteurs atomiques vous tendrait. Vous pouvez seulement lire une ligne et écrire une ligne.
Ce que le magasin vous donne bien, c'est une écriture conditionnelle. Chaque ligne porte une étiquette de version qui change à chaque écriture, et vous pouvez dire: réécris cette ligne, mais seulement si sa version est toujours celle que j'ai lue. Si quelqu'un d'autre a écrit entre-temps, la version a bougé, et votre écriture est rejetée au lieu d'écraser silencieusement la sienne.
Cela transforme la course aux incréments perdus en une collision détectée. Lisez la ligne et sa version. Ajoutez un. Essayez de réécrire sous la condition de cette version. Si ça réussit, votre incrément a assurément atterri sur la valeur que vous avez lue, sans rien perdre. Si c'est rejeté, cela signifie qu'une autre tentative vous a devancé, donc vous relisez le compteur désormais plus élevé et vous réessayez. Bouclez sur le rejet, un nombre borné de fois, et chaque incrément est forcé de se sérialiser derrière le précédent au lieu de l'écraser. Le compteur redevient vrai, et l'attaque à cinq cents d'un coup mène désormais le compteur droit jusqu'à cinq et déclenche le verrou, ce qui est tout l'intérêt.
Nous avons porté la même forme sur notre autre backend de stockage, qui n'a lui non plus pas d'incrément mais offre lui aussi une écriture conditionnelle, cette fois conditionnée par la valeur du compteur elle-même plutôt que par une étiquette de version: écris le nouveau compteur seulement si le compteur stocké est toujours celui que j'ai lu. Même garantie, exprimée dans le vocabulaire de ce magasin. La leçon dépasse toute base de données particulière: quand il vous manque un incrément atomique, une écriture conditionnelle plus une boucle de reprise en reconstruit un, car la condition est ce qui refuse l'écrasement périmé qui perdait votre compte.
Une asymétrie délibérée mérite d'être notée. Nous avons rendu le chemin d'échec atomique et laissé le chemin de succès, qui remet le compteur à zéro lors d'une connexion correcte, en simple écriture où le dernier qui écrit l'emporte. La concurrence ne menace pas une remise à zéro: plusieurs connexions réussies en course veulent toutes écrire zéro, et peu importe laquelle l'emporte. Seul l'incrément est hostile, parce que seul l'incrément est la chose qu'un attaquant tente de maintenir basse. Vous dépensez le coût des écritures conditionnelles et des reprises là où un adversaire pousse, et non là où il ne pousse pas.
Supposez que quelqu'un compte
La vraie valeur de ce bogue a été l'état d'esprit qu'il a imposé, car le jour même où nous l'avons corrigé, nous sommes partis chercher tout le reste qui supposait discrètement une-requête-à-la-fois ou supposait que personne ne mesurait, et nous avons trouvé une petite grappe de cousins.
Nos réponses de connexion révélaient aux attaquants quels comptes existaient. Un compte absent, un compte désactivé, un compte verrouillé et un mauvais mot de passe produisaient chacun une réponse distinguable, et le chemin de l'utilisateur inexistant revenait même plus vite parce qu'il sautait entièrement le hachage du mot de passe, si bien qu'un attaquant pouvait apprendre qui possède un compte en surveillant soit le statut, soit l'horloge. Nous avons fusionné les réponses: un utilisateur inconnu et un mauvais mot de passe renvoient désormais la réponse identique, et un utilisateur inconnu est toujours vérifié contre un faux hachage fixe pour que le temps soit le même, que le compte existe ou non. Le compte existe ou non, et vous ne pouvez pas le savoir de l'extérieur.
L'inscription avait la même fuite sous une forme plus difficile, car vous ne pouvez pas simplement toujours annoncer un succès quand quelqu'un essaie d'inscrire un e-mail déjà pris, puisque cela reviendrait à lui remettre le compte. Alors une inscription pour une adresse déjà enregistrée renvoie désormais exactement le même succès neutre qu'une inscription toute fraîche, dépense le même temps de hachage de mot de passe, ne crée rien, et envoie plutôt au véritable propriétaire un e-mail disant que quelqu'un a essayé d'inscrire son adresse. L'attaquant n'apprend rien. Le véritable propriétaire est discrètement prévenu.
Et le formulaire de réinitialisation de mot de passe était un canon à e-mails: aucune limite par adresse, si bien que vous pouviez nous faire tirer des e-mails de réinitialisation vers n'importe quelle adresse en boucle, une attaque visant un tiers ainsi que notre propre réputation d'expéditeur. Il limite désormais le débit par adresse cible et, au-delà du plafond, saute l'envoi tout en renvoyant la même réponse de succès que d'habitude, pour que la limitation elle-même ne devienne pas un nouveau moyen de sonder quelles adresses existent.
Chacun de ces cas est la leçon du bogue de verrouillage habillée autrement. Concevez comme si un attaquant envoyait des requêtes en parallèle, chronométrait vos réponses et comptait. Parce que la seule chose dont vous puissiez être sûr, c'est que la personne qui attaque votre connexion ne le fait pas une polie requête à la fois.
Pourquoi un compteur de sécurité doit être testé en concurrence
Un bogue de concurrence dans un contrôle de sécurité est pire qu'un bogue de concurrence n'importe où ailleurs, pour une raison précise: la partie qui a le motif le plus fort de le déclencher est l'attaquant, et les conditions qui le déclenchent, de nombreuses requêtes simultanées, sont exactement les conditions qu'une attaque crée et qu'un test ne crée presque jamais. Ainsi un verrouillage, une limitation de débit, un quota, tout contrôle bâti sur un compteur, ne peut pas être validé en l'incrémentant dans une boucle et en le regardant se déclencher. Il doit être frappé en concurrence, car la concurrence est l'environnement natif de l'attaquant, et la version séquentielle du test prouve seulement que votre contrôle fonctionne contre un adversaire qui a accepté d'être poli.
Et quand vous durcissez le compteur, l'outil sur un magasin sans incréments atomiques, c'est l'écriture conditionnelle: lisez la version, n'écrivez que si elle n'a pas bougé, réessayez si elle a bougé. Elle reconstruit l'incrément atomique que la base de données ne vous a pas donné, à partir de la seule garantie qu'elle vous a donnée.
Si vous préférez tourner sur un fournisseur d'identité qui suppose déjà que l'attaquant compte en parallèle, Authagonal a bâti ces contrôles pour être frappés tous d'un coup, parce que c'était de toute façon la seule manière dont ils allaient jamais être frappés pour de vrai.