Блог
Ограничитель частоты не остановит подбор пароля
Лимит на один IP-адрес гасит поток запросов, но не подбор пароля. Что должен считать троттлинг входа, где хранить счёт и какие четыре ошибки я сделал.
Автор: FoxAuth ·
Дашборд был ровным. Запросов в секунду — как обычно. Ошибок — как обычно. Ограничитель частоты запросов (rate limiter — счётчик, который не даёт одному IP-адресу слать слишком много запросов) за всю ночь не сработал ни разу. А у одного аккаунта набралось чуть больше двух тысяч неудачных входов.
Атакующий не сделал ничего хитрого. У него было несколько тысяч IP-адресов. С каждого он примерно раз в двадцать секунд пробовал один пароль к одному и тому же email. Наш лимит на один IP-адрес рассчитан на браузер, который делает какую-нибудь глупость, и каждый из этих адресов укладывался в него с большим запасом. Ограничитель сделал ровно то, для чего его создали: не дал ни одному адресу завалить сервер запросами. Но сервер никто и не заваливал. Пароль подбирали — медленно и отовсюду сразу.
Дольше, чем хотелось бы признавать, я верил, что ограничитель частоты перед эндпоинтом входа защищает от перебора паролей (brute force — пробовать пароли, пока какой-нибудь не подойдёт). Не защищает, и никакая настройка этого не исправит. Ограничитель считает запросы по IP-адресу. А IP-адрес атакующий как раз выбирает сам.
Считайте попытки по атакуемому адресу почты, а не по IP-адресу
Атакующий выбирает IP-адреса. Он выбирает, как быстро идти, сколько машин задействовать, когда остановиться и когда начать снова. Не может он выбрать только аккаунт, который атакует: ради этого аккаунта всё и затевалось. Значит, счётчик, который ограничивает подбор, должен считать попытки по аккаунту — по адресу почты, к которому подбирают пароль, — а не по тому, кто их шлёт. Все остальные решения о его устройстве следуют из этого.
Второе решение — где хранить счёт. Наш ограничитель частоты держит счётчики в памяти процесса сервера. Для его задачи это нормально, хотя у такого хранения три слабых места. Если сервер запущен в нескольких копиях (репликах), у каждой копии свои счётчики, и лимит умножается на число копий. Перезапуск стирает счётчики. А таблица счётчиков ограничена по размеру: поток всё новых адресов вытесняет из неё старые счётчики, и адрес, чей счётчик вытеснили, снова получает полный лимит. В этот момент ограничитель тихо пропускает запросы. Это называют 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();}Этот инкремент — одна операция в обеих наших базах: findOneAndUpdate с $inc на MongoDB, UPDATE
с RETURNING по jsonb-колонке на PostgreSQL. Поэтому пятнадцать попыток в один момент получают
пятнадцать разных чисел, от 1 до 15. В лимит укладываются только первые пять. Та же операция переносит
срок жизни записи на двадцать четыре часа от текущего момента. Поэтому «хранить двадцать четыре часа
после последней неудачи» не требует второй записи в базу.
Одного эта функция не показывает: что делает счёт честным. Если пароль затем оказывается верным, запись очищается. Поэтому в счёте остаются только неудачи. А попытка, которую отвергли, потому что дверь закрыта, до проверки пароля вообще не доходит.
Последнее — не мелочь. Проверка пароля через Argon2 (алгоритм, которым мы хешируем пароли) намеренно медленная. Пока проверка троттлинга не стояла перед поиском аккаунта, каждая попытка для известного адреса стоила серверу полного вычисления хеша. Троттлинг после проверки пароля ограничивает число попыток, но нагрузка на процессор остаётся прежней. Если же троттлинг стоит до проверки, закрытая дверь обходится серверу в одно быстрое чтение из базы по индексу.
Шаг 5 (сбросить счётчик) стоит перед шагом 6 (аккаунт неактивен? email не подтверждён? нужен второй фактор?) намеренно. Деактивированный аккаунт или аккаунт с неподтверждённым email всё равно получит отказ на шаге 6. Но сначала его счётчик очищается: пароль ведь верный, а пароль, который уже подтвердился, никто не подбирает. Если поставить очистку в самый низ, рядом с успешным входом, пользователь с правильным паролем и неподтверждённым email до неё не дойдёт. Он попадёт под троттлинг за то, что знает свой пароль.
Ни отказ, ни счётчик не должны выдавать, есть ли у адреса аккаунт
Правый блок на этой схеме — один отказ на все случаи — это исправление моей третьей ошибки. Я ошибся, пытаясь быть полезным. Версия, которую я хотел написать (и уже выпускал в другом месте), говорила: «слишком много попыток, попробуйте через 14 минут». Доброе сообщение. Но это ещё и оракул — так называют ответ, который отвечает атакующему на вопрос, который он не должен иметь возможности задать. Это сообщение говорило, что у адреса, который он прощупывает, есть аккаунт (иначе зачем его считать?). И говорило, когда именно возвращаться.
У исправления две части, и вторая менее очевидна, чем первая. Первая: каждый отказ у этой двери выдаёт одно и то же выражение в коде, и оно рисует одну и ту же страницу. Поэтому закрытая дверь, неизвестный адрес, неверный пароль и деактивированный аккаунт получают один и тот же ответ, байт в байт. Это гарантирует сам код: страницу выдаёт одно место, а не четыре копии одного текста, которые кто-то должен держать одинаковыми.
Вторая: счётчик заводится для любого введённого адреса — даже для того, у которого нет аккаунта. Если бы троттлинг считал только настоящие аккаунты, он вёл бы себя по-разному для настоящих и выдуманных адресов. И как только эта разница хоть где-то станет заметна (во времени ответа, в заголовке, через будущий баг), сам троттлинг станет способом выяснить, у каких адресов есть аккаунты. Такую атаку называют перечислением (enumeration).
Поэтому область хранения с этими счётчиками намеренно не принадлежит ни одному аккаунту. В её записях нет 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 появилась позже), само нормализует адреса, которые сохраняет.
Блокировку снимает завершённый сброс пароля: это может владелец, но не атакующий
Растущие блокировки терпимы, только если у владельца аккаунта есть путь обратно, которого нет у того, кто подбирает пароль. Иначе вы построили инструмент для атаки «отказ в обслуживании»: любой может навести его на любой адрес почты и не пускать владельца в его аккаунт. На потолке это час чьего-то дня, и атакующий может повторять это снова и снова.
Очевидный путь обратно — «введите код, который мы отправили вам на почту, и пробуйте дальше». Я довольно далеко по нему прошёл, прежде чем выписал, почему он неверен. Во-первых, такое сообщение правдиво только для адреса, у которого есть аккаунт. Показывать его только таким адресам — значит вернуть оракул. Во-вторых, этот путь шлёт письмо по требованию на любой введённый адрес. Значит, любой сможет с его помощью завалить чужой ящик письмами (email bombing). Значит, ему нужен свой лимит — и блокировка просто переедет туда. В-третьих, вход перестанет работать всякий раз, когда не работает почта. А во всём остальном почта на нашем сервере работает по принципу best-effort: отправим, если получится. В-четвёртых, этот путь не поможет бакету самих администраторов: там самостоятельного сброса пароля нет вообще.
Вместо этого мы делаем кое-что поуже и, по-моему, правильное. Завершённый сброс пароля очищает счётчик. Запрошенный — нет. Разница в том, что каждый из них доказывает. Запрос сброса не доказывает ничего. Если бы счётчик очищался по запросу, любой, кто знает адрес, мог бы стереть счёт, который его сдерживает. А чтобы завершить сброс, нужно использовать одноразовый секрет, который пришёл на адрес почты аккаунта. Это доказывает, что вы владеете этим почтовым ящиком. Шаг «введите код» собрал бы то же самое доказательство. Только здесь его собирает процедура, которая уже есть. И у неё уже есть своя пауза между запросами и свой суточный лимит.
Бакет администраторов — причина следующего правила. (Но не причина того, что потолок равен часу.) Администраторы входят через ту же дверь, и сброса пароля, чтобы выбраться, у них нет. Поэтому долгая эскалация против адреса администратора — единственная блокировка, которую никто не может отменить. Правило, которое их защищает, записано не как «если это бакет администраторов». Оно звучит так: «в бакете, который требует второй фактор, каждая блокировка остаётся на первом окне, сколько бы раз ни срабатывал троттлинг». Причина в том, что в таком бакете одного угаданного пароля мало, чтобы войти. Значит, долгие блокировки там дают мало, а рискуют многим: можно не пустить настоящего администратора. Бакет администраторов попадает под это правило, как только оператор включит в нём второй фактор. Руководство по созданию первого администратора велит сделать это в первую очередь.
В такой формулировке правило называет настоящую причину. И оно само себя поправляет: отключите в бакете второй фактор — и блокировки в нём снова начнут расти. Никому не придётся помнить, что надо что-то поменять.
Чего не делать: выключатель в консоли, пропуск входа при ошибке базы и надежда только на троттлинг
Выключателя у троттлинга я бы делать не стал, и у нашего его нет. Это удивило первого человека, который стал искать его в консоли настроек. Там три числа: лимит, первое окно и потолок. У каждого есть границы, и проверяет их тот же валидатор, который проверяет настройки при запуске сервера. Поэтому консоль не сохранит настройки, с которыми сервер отказался бы стартовать. Лимит не может быть больше 100: троттлинг, который пропускает больше, уже не троттлинг. Флажка «вкл/выкл» нет. Выключатель на границе безопасности — это переключатель, который снова открывает уязвимость. А консоль — как раз то место, где переключатели щёлкают в два часа ночи и забывают вернуть обратно.
Не позволил бы я троттлингу и fail open — пропускать входы, когда он сломался. Если чтение счётчика падает с ошибкой хранилища, ошибка не перехватывается. Счётчик читается на строку раньше поиска аккаунта, а поиск идёт в ту же базу данных. Так что, если база сломалась, вход всё равно упал бы мгновением позже. А перехватить ошибку значило бы ответить «invalid username or password» пользователю, чей вход по этой причине никто не отклонял.
И я бы не стал делать вид, что троттлинг решает задачу целиком. Троттлинг по адресу почты ограничивает попытки к одному аккаунту. Против password spraying он бессилен. Это атака, когда один распространённый пароль пробуют по одному разу на каждом из десяти тысяч аккаунтов. Каждый аккаунт видит одну-единственную неудачу, и ни один счётчик даже близко не подходит к лимиту. Ограничитель частоты ловит наивную версию такой атаки — с одного IP-адреса. Но ботнет (много машин под управлением атакующего) обходит его ровно так же, как атака из начала этого текста обошла мой. По-настоящему spraying останавливают две вещи: не принимать пароли из списков утёкших паролей и требовать второй фактор. Я пришёл к выводу, что троттлинг входа нужен в основном затем, чтобы продержаться без второго фактора, пока он не появится.
По умолчанию в нашем сервере — пять неудач, первое окно в пятнадцать минут и потолок в один час. Справочник настроек описывает эти три числа и их границы. Модель угроз перечисляет, от чего защищает каждая проверка при входе. А в её разделе об ограничениях прямо сказано, какие счётчики этого сервера — границы безопасности, а какие только защищают ресурсы.