Есть один сценарий, который я не могу разлюбить как объект для разбора, потому что в нём удивительно чисто сходятся техника и психология.
Атакующий уже знает логин и пароль — купил, подобрал по утечке, вытащил стилером с домашнего ноутбука. Второй фактор — пуш в приложении с кнопками «Одобрить» и «Отклонить». И вот он просто начинает логиниться. Раз за разом. Телефон жертвы звенит в двадцать третий раз в 02:40, человек спросонок жмёт зелёную кнопку — не потому что глупый, а потому что это самый быстрый известный ему способ прекратить вибрацию. Всё, второй фактор пройден.
Так вынесли Uber в сентябре 2022-го — там пушам ещё помогло сообщение в мессенджере от «айтишника»: мол, одобрите, чтобы это прекратилось. Примерно так же заходили в Cisco несколькими месяцами раньше. Оба разбора выложены самими пострадавшими: Uber Security Update и отчёт Cisco Talos.
Дальше — про то, как это выглядит в логах Entra ID, какой детект я бы писал, где он даёт фолзы, и почему number matching — редкий случай, когда одна маленькая правка в UI действительно закрывает класс атак.
Что именно видно в SigninLogs
Разбирать буду на Entra ID (бывший Azure AD), потому что там самый детальный лог из массовых. Логика переносится на что угодно с пуш-подтверждением.
Ключевое, что надо понимать про эту таблицу: ResultType — это строка с кодом ошибки, и в контексте пуш-бомбинга интересуют не все коды, а вполне конкретные.
0— успех.50126— неверный логин или пароль. Если у вас всплеск именно этих — это не MFA-усталость, это password spraying, другая история и другой детект.50053— сработал smart lockout: либо заблокирована учётная запись, либо временно закрыт адрес, с которого шли попытки.50074— пользователь не прошёл strong authentication. Сюда попадает часть отказов.50076— MFA потребовался по политике (Conditional Access, вход из новой локации).50079— пользователь ещё не зарегистрировал второй фактор.500121— вот он, главный. «Authentication failed during strong authentication request». Именно он записывается, когда пуш отправлен, а подтверждения не было.
Проблема в том, что 500121 — это и «пользователь нажал Отклонить», и «пользователь ничего не нажал, истёк таймаут», и «пользователь нажал Сообщить о подозрительной активности». Разница живёт не в ResultType, а глубже — в Status.additionalDetails и массиве AuthenticationDetails, где по каждому фактору есть authenticationStepResultDetail с текстом вроде MFA denied; user declined the authentication, MFA denied; user did not respond to mobile app notification или MFA denied; user is blocked.
Вот это различие — не занудство, а суть детекта. Двадцать неотвеченных пушей и двадцать явных отказов — это разные истории. Во втором случае человек всё понял и осознанно отбивался; в первом — он спал, и утром может не вспомнить, что вообще что-то было.
Первый запрос: посмотреть глазами
Прежде чем писать правило, всегда смотрю на распределение. Обычно оказывается, что представление о «нормальном» количестве отказов у меня и у реальности сильно разное.
SigninLogs | where TimeGenerated > ago(30d) | where ResultType == "500121" | extend Detail = tostring(Status.additionalDetails) | summarize Events = count(), Users = dcount(UserPrincipalName) by Detail | order by Events desc
В нормальной живой инфраструктуре на несколько тысяч человек 500121 будет тысячами в месяц, и подавляющее большинство — таймауты. Люди оставляют телефон на столе, уходят на обед, сессия отваливается, клиент переспрашивает. Это фон, и именно он определяет пороги.
Второй запрос: собственно детект
Логика: серия неуспешных MFA по одному пользователю в узком окне, а следом — успех. Успех после серии — это и есть момент, когда человек сдался.
let window = 15m; let min_denials = 5; let denials = SigninLogs | where TimeGenerated > ago(1d) | where ResultType == "500121" | extend Detail = tostring(Status.additionalDetails) | summarize Denials = count(), DistinctIPs = dcount(IPAddress), IPs = make_set(IPAddress, 10), Apps = make_set(AppDisplayName, 5), Details = make_set(Detail, 5), FirstDenial = min(TimeGenerated), LastDenial = max(TimeGenerated) by UserPrincipalName, bin(TimeGenerated, window) | where Denials >= min_denials; let successes = SigninLogs | where TimeGenerated > ago(1d) | where ResultType == "0" | project UserPrincipalName, SuccessTime = TimeGenerated, SuccessIP = IPAddress, SuccessApp = AppDisplayName, Device = tostring(DeviceDetail.displayName), Browser = tostring(DeviceDetail.browser); denials | join kind=inner successes on UserPrincipalName | where SuccessTime between (FirstDenial .. LastDenial + 10m) | project UserPrincipalName, Denials, DistinctIPs, IPs, Apps, Details, FirstDenial, LastDenial, SuccessTime, SuccessIP, SuccessApp, Device, Browser | order by Denials desc
Несколько вещей, которые я бы здесь подчеркнул.
bin(TimeGenerated, 15m) — грубое окно, и оно честно теряет серии, попавшие на границу бинов. Атака в 14:58–15:03 попадёт в два соседних окна и ни в одном не наберёт порог. Если критично — надо переходить на скользящее окно через row_window_session() или считать по разнице между соседними событиями. Я обычно оставляю bin в первой версии правила и усложняю только если реально ловлю пропуск: скользящее окно на большом объёме заметно дороже по ресурсам, а прибавка в детекте не всегда стоит того.
DistinctIPs в выводе — не для порога, а для аналитика. Один и тот же IP на всех попытках — скорее упрямый скрипт, россыпь IP из разных ASN — резидентный прокси, и это уже другой уровень подготовки атакующего.
Сравнение SuccessIP с IPs из серии отказов — самая ценная строчка во всей выборке. Если успех пришёл с того же адреса, откуда шли попытки, — почти наверняка сдались. Если успех с домашнего IP пользователя, а отказы с чужого — возможно, человек проснулся, отбился и потом сам зашёл. Это два разных инцидента с разной срочностью.
Порог min_denials = 5 — это то, с чего я начинаю, а не то, что я рекомендую. Реальное число берётся из первого запроса: смотрите 99-й процентиль числа отказов на пользователя в окне и ставите чуть выше.
SigninLogs | where TimeGenerated > ago(30d) and ResultType == "500121" | summarize Denials = count() by UserPrincipalName, bin(TimeGenerated, 15m) | summarize p50 = percentile(Denials, 50), p95 = percentile(Denials, 95), p99 = percentile(Denials, 99), maxv = max(Denials)
Фолзы, которые придут обязательно
Сразу скажу, что чистого детекта тут не бывает, и вот основные источники шума, с которыми придётся жить.
Старые почтовые клиенты и мобильные приложения с сохранённым паролем. Пользователь сменил пароль — клиент об этом не знает и продолжает в фоне пытаться войти по расписанию. Получается идеальная имитация: серия неуспехов, потом человек руками заходит с ноутбука — и вот вам «успех после серии». Отличить несложно: одна и та же пара «пользователь + AppDisplayName» тянется месяцами и никуда не девается. Такие связки проще всего вынести в белый список по паре пользователь + приложение, но с ревизией раз в квартал, а не навсегда.
Несколько устройств у одного человека. Планшет, рабочий телефон, личный телефон — пуш приходит на все, а подтверждают его на одном, остальные записываются как неотвеченные. На отдельных конфигурациях это даёт кратный рост фонового 500121 буквально на ровном месте.
Люди в самолёте, в метро, на объекте без связи. Пять таймаутов подряд, потом успех, когда связь появилась. Ровно рисунок атаки.
Именно поэтому я бы не выводил это правило в автоматическую блокировку, а вешал на него обогащение и ручной разбор — по крайней мере первые пару месяцев. И обязательно бы добавил в карточку алерта готовый текст первого вопроса пользователю. Не «ваша учётка скомпрометирована», а «вам сегодня ночью приходили запросы на вход? вы что-нибудь подтверждали?». Разница огромная: в первом случае человек защищается, во втором — рассказывает.
Почему number matching работает
Теперь про ту самую правку в интерфейсе.
Number matching — это когда на экране входа показывается двузначное число, и в приложении на телефоне надо не нажать «Одобрить», а ввести это число. У Microsoft оно с мая 2023-го включено принудительно для всех, кто пользуется Authenticator, и по-хорошему это надо было делать сильно раньше.
Почему это работает — и мне тут интереснее психологическая часть, чем техническая.
Пуш с кнопкой «Одобрить» — это задача на один клик, доступная на автопилоте. Не нужно ничего понимать, не нужно даже просыпаться: рука сама тянется убрать раздражитель. Именно на этом и построена вся атака — она не убеждает жертву, она изматывает её до состояния, в котором решение принимает не человек, а рефлекс.
Ввод числа автопилотом не проходится. Числа нет на телефоне — оно на том экране, куда логинится атакующий. Чтобы подтвердить вход, жертве нужно физически иметь перед глазами источник запроса, то есть быть той стороной, которая этот вход инициировала. Атака ломается не потому, что стала сложнее технически, а потому что перестала быть решаемой на рефлексе. Плюс в самом уведомлении теперь показывается имя приложения и геолокация — это уже не «кто-то куда-то», а конкретика, на которую можно среагировать.
По логам после включения number matching видно ровно то, что ожидаешь: доля MFA denied; user did not respond падает, а доля явных user declined растёт. Люди перестают отмахиваться и начинают отказывать. Для детекта, кстати, это хорошая новость — сигнал становится чище.
Но панацеей это не является, и вот честные ограничения. Если атакующий одновременно звонит жертве и представляется техподдержкой, он просто спрашивает число вслух: «вам сейчас придёт код, продиктуйте, мы завершим настройку». Это уже не MFA-усталость, а обычный вишинг, и number matching его не ловит. По-настоящему такое закрывается только phishing-resistant факторами — FIDO2-ключами, passkeys, привязкой к устройству, — где нет ничего, что можно продиктовать по телефону в принципе.
Что кроме детекта
Детект — это последний рубеж, он ловит уже происходящее. Что стоит сделать до:
Ограничить, откуда вообще может прилететь запрос на аутентификацию. Conditional Access с требованием compliant-устройства обесценивает украденный пароль полностью: нет устройства — нет и пуша, который можно спамить. Это самая сильная мера из перечисленных и, разумеется, самая тяжёлая во внедрении.
Включить в приложении кнопку «сообщить о подозрительной активности» прямо в уведомлении, и — что важнее — назначить того, кто эти жалобы разбирает. Кнопка, нажатия на которую никто не читает, хуже, чем отсутствие кнопки.
Убрать SMS и голосовые звонки как второй фактор там, где это возможно. Перевыпуск SIM-карты и перехват — не экзотика, а рабочая практика, и в связке с известным паролем этого достаточно.
И самое дешёвое: сказать людям прямым текстом, что отклонить непонятный запрос и лечь спать дальше — правильное поведение, а не «мешать работе». Пока человек боится, что своим отказом он что-то сломает или кого-то задержит, он будет жать зелёную кнопку. Технически мы это лечим числами на экране, но по-хорошему тут лечится не техника.
Вывод
В целом вся эта история про то, что уязвимости в MFA нет. Уязвим сценарий, в котором «да» нажать проще, чем «нет», — и лечится он не столько логами, сколько тем, чтобы нажать «нет» ночью было для человека нормально и безнаказанно.

