Привет! На связи Виктор из Cloud4Y. Хочу поделиться практической историей о том, как сделать fail2ban-подобную механику для Exchange на Windows: быстрое обнаружение brute-force по IIS-логам и автоматическая блокировка атакующих IP.
Fail2ban и аналоги привычны для Linux, но когда у тебя on-prem Exchange на Windows, нужен свой инструмент для быстрого обнаружения массовых неудачных логинов и такой же быстрой блокировки источника.

История простая: у клиента в Exchange прилетел brute-force (ruler). Никого не взломали — но пользователи начали массово жаловаться на блокировки учётных записей (lockout policy отработала идеально — против нас).
Мы закрыли проблему вручную: нашли IP атакующего, заблокировали. Но было понятно: если это повторится ещё раз, тем более ночью или в выходные, исправлять вручную — не вариант.
Сформировалась задача: создать инструмент, который:
обнаруживает такие атаки автоматически;
быстро блокирует атакующего;
формирует отчёт, чтобы не гадать «почему опять лочит пользователей».
Я решил реализовать эту механику на PowerShell. Причины банальные, но практичные:
полный контроль (прокси, балансировщики, политика аутентификации, исключения);
легко встроить в существующие процессы (Task Scheduler, почтовые отчёты, общие шары);
бесплатно.
Так родился IP2Ban — fail2ban-подобная механика, но для Exchange/IIS.
Почему вообще IIS-логи, а не Security-логи AD
Проблему блокировок видно и в AD Security-логах, но для автоматизации IP-блокировок IIS-логи удобнее:
видно, какой endpoint атакуют (Autodiscover/EWS/ActiveSync/OWA и т.д.);
есть User-Agent, который покажет инструмент (например, ruler), если его не подменил атакующий;
есть подробные коды
sc-status/sc-substatus/sc-win32-status, по которым можно отличать «неверный пароль» от «особенностей negotiation/политик».
А вот IIS-логи на Exchange (OWA/ECP/EWS/ActiveSync/Autodiscover/MAPI) содержат именно то, что нужно для IP2Ban:
Source-IP (или
X-Forwarded-For);URI (
/autodiscover/autodiscover.xml,/Microsoft-Server-ActiveSync…);sc-status,sc-substatus,sc-win32-status;cs-usernameиcs(User-Agent).
Пример того, что мы искали в логах: массовые запросы на autodiscover с User-Agent: ruler и пачкой 401.1 + sc-win32-status=1326 (неверный пароль).

Подготовка IIS: включаем нужные поля логирования и ставим модуль блокировки
Прежде чем писать аналитику по логам, нужно убедиться, что IIS логирует достаточно данных, чтобы отличать «шум» от атаки и корректно идентифицировать источник.
Я добавил дополнительные поля в W3C IIS-логи
По умолчанию в W3C-логах часто не хватает либо статусов, либо реального IP клиента, если трафик идёт через прокси/балансировщик. Я включил расширенный набор стандартных полей (минимум, который реально нужен для IP2Ban):
c-ip— IP источника (если нет прокси);cs-username— имя пользователя (часто пустое при failed auth, но может быть полезно для корреляции);cs-uri-stem/cs-uri-query— куда именно стучатся (Autodiscover/EWS/ActiveSync и т. п.);cs(User-Agent) — полезно для обнаружения инструментов/скриптов, если не был изменён атакующим;sc-status/sc-substatus— чтобы различать 401.0 / 401.1 / 401.2;sc-win32-status— чтобы понимать «почему именно 401» (1326, 5 и т.д.).
Если трафик идёт через балансировщик/прокси, то c-ip будет адресом прокси. Тогда нужен реальный IP клиента. Для этого я добавил Custom Field:
Source-IP из заголовка X-FORWARDED-FOR — чтобы получать реальный IP клиента за прокси/LB

Практический нюанс: если у вас есть балансировщик/прокси, но X-FORWARDED-FOR не пробрасывается корректно — вы будете банить прокси, а не клиента. Поэтому этот шаг в реальных инфраструктурах часто важнее всего.
Установка модуля блокировки в IIS
Чтобы блокировка происходила «на входе» и не зависела от конкретного приложения, я использовал стандартный компонент IIS: IP and Domain Restrictions (IP Address and Domain Restrictions).
Он удобен тем, что режет запросы ещё на уровне IIS, и при этом правила можно контролировать и автоматизировать. В нашем случае это идеальный вариант: скрипты формируют список IP, а IIS применяет блокировку на веб-слое.
В итоге схема стала такой:
скрипт анализирует IIS-логи и формирует FinalList IP-адресов
дальше этот список применяется в IIS как Deny и отправляется на почту администратору
Первая сложность
Самая неприятная часть: не все 401 одинаковы. В Exchange/IIS 401 может быть:
Частью нормального negotiation (challenge/response)
Для Integrated Windows Authentication (Kerberos/NTLM) типичен сценарий: первый запрос приходит без корректногоAuthorization, IIS отвечает 401 +WWW-Authenticate, а клиент повторяет запрос уже с токеном. В итоге вы видите 401, после которого сразу идёт 200 — это нормально.Следствием политики безопасности (например, запретом NTLMv1)
Если в домене/на серверах запрещён NTLMv1, старые клиенты/устройства могут пытаться начать negotiation с NTLMv1, и сервер отклонит попытку. Тогда в IIS появляются серии 401 (sc-win32-status=2148074252/0x8009030C,SEC_E_LOGON_DENIED), хотя это не brute-force, а несовместимость по политике.Несовпадением настроек аутентификации на виртуальных директориях
Клиент ожидает один механизм (Basic/NTLM/Negotiate), сервер разрешает другой — получаются повторяющиеся 401 на конкретных URL.Рассинхронизацией времени на телефоне.
Я перестал смотреть на простой 401 и перешёл к «401 с дополнительными полями sc-substatus и sc-win32-status».
Практически полезные признаки (упрощённо):
401.1 + sc-win32-status (
1326/0x0000052E,ERROR_LOGON_FAILURE) — неверный логин/пароль, отлично подходит под brute-force.401.1 + sc-win32-status (
2148074252/0x8009030C,SEC_E_LOGON_DENIED) ошибки уровня security context — учитываем, но осторожно (зависит от окружения).401.2/401.0 без деталей — либо не баним, либо баним только при очень высоких порогах и дополнительных условиях.
Архитектура: три модуля вместо одного “монолита”
Если делать «всё в одном», получится плохо отлаживаемый комбайн. Я разделил ответственность:
CheckIpAtack.ps1 — анализ IIS-логов и отчётность (агрегация 401 по IP).
Get-IISLogIncremental.ps1 — ускорение: читаем IIS-лог инкрементально, а не с нуля.
IP2Ban.ps1 — формирование списков и применение блокировки через IIS IP.
Почему так:
отчётность и бан — разные жизненные циклы;
отчёты полезны даже без блокировок (SOC/аудит/разбор);
проще дебажить пороги и фильтры, когда есть промежуточные отчёты.
Схема работы получилась такой: Mailbox сервер → локальный отчёт → merge → фильтрация → risky → IP2Ban → IIS блокировка.
Хранилище отчётов: общая DFS-папка
Ключевая деталь реализации: головная папка для отчётов — это DFS-шара. Это дало два важных эффекта:
каждый сервер пишет «свой» отчёт в общий namespace;
при merge любой сервер видит отчёты всех серверов, потому что они лежат в едином DFS-пути.
То есть мне не пришлось «таскать» отчёты копированием между серверами: сбор и merge работают поверх общей DFS-папки.
Скрипт отчётности: CheckIpAtack.ps1
Окно времени и пороги
Анализировать каждые 3 минуты — это бессмысленно. Вместо этого каждый Mailbox-сервер анализирует IIS-логи за скользящее окно (например, 10 минут) и пишет компактный CSV-отчёт.
Пример настроек:
$threshold = 10 # minimum 401 fails per ip to put in server report
$minutes = 10 # lookback window (minutes) for IIS log processing
$thresholdFiltr = 800 # minimum 401 errors per ip to put in Filtered_Report
$Date = Get-Date
$cutoff = $Date.ToUniversalTime().AddMinutes(-$minutes)
$keepDays = 5 # MBX-logs lifetimeПороги несколько раз менял: начинал с небольших значений, потом поднял, но оставил возможность быстро перенастроить.
Далее идёт агрегация событий в объекты и подсчет статистики на месте, чтобы не гонять гигабайты сырых логов по сети:
Source-IP;User-Agent;количество попаданий;
sc-status/sc-substatus/sc-win32-status.
Что именно скрипт считает
Скрипт берёт IIS-строки за окно времени, парсит нужные поля и делает группировку. Ключевые измерения:
Source-IP(или реальный client IP, если настроено черезX-FORWARDED-FOR);User-Agent;sc-status/sc-substatus/sc-win32-status;количество событий.
Идея агрегации:
«сколько 401 пришло с IP X за 10 минут»;
«какой именно паттерн 401»;
«по User Agent».
На выходе каждый MBX складывает CSV в свою папку (DFS): ..\_401_Logs\<MBX>\MBX*_YYYYMMDD_HHMM.csv

Merge и фильтрацию делаем на одном узле
Так как все отчёты лежат в DFS и видны всем серверам, merge можно выполнять на любом узле. Чтобы не создавать лишнюю нагрузку, merge/очистку выполняет один «активный» сервер (выбираю псевдослучайно) — в каждый момент времени только один MBX выполняет тяжёлые действия:
сбор файлов со всех MBX;
объединение в один (Merged_Report);
фильтрация по лимитам: Merged_Report → Filtered_Report;
отсев наиболее рисковых подключений по
sc-substatus/sc-win32-status: Filtered_Report → Risky_Report;ежедневное (раз в день) объединение всех Merged_Report в один дневной лог (Daily_Report).
Выбор активного узла сделал «псевдослучайным» от времени:
# Randomize active server
$timeRange = 60 / $ServCount
$activeNumber = [math]::Floor($cutoff.Minute / $timeRange) + 1
$activeServer = "MBX$activeNumber"
if ($env:COMPUTERNAME -like "*$activeServer*") {$activeMBX = $True} else {$activeMBX = $False}Производительность чтения IIS-логов: Get-IISLogIncremental.ps1
Главная боль на практике — производительность. Каждые 5–10 минут читать большие IIS-логи с нуля — это лишняя нагрузка. Чтение IIS-логов к концу суток измеряется уже в минутах, что неприемлемо.
Поэтому был добавлен отдельный модуль Get-IISLogIncremental.ps1, который читает лог с последней сохранённой позиции (байтовый офсет). Идея:
хранится
state(файл + позиция + длина);файл открывается так, чтобы IIS мог продолжать писать (
ReadWrite);Seekна последнюю позицию;читает только новые строки;
сохраняет новую позицию.

Ключевой момент: IIS пишет лог постоянно, поэтому файл открываем в режиме, который не мешает записи.
$fs = [System.IO.File]::Open($path,'Open','Read','ReadWrite')
$fs.Seek($startPos, 'Begin') | Out-NullИтог: удалось сократить время чтения логов IIS до 1–2 секунд.
Обработка ротации/обрезки
Если IIS начал новый лог-файл или файл «обнулили», сохранённый офсет становится некорректным. Скрипт явно обрабатывает кейсы:
«файл вырос» → читаем дальше;
«файл стал меньше/другой» → начинаем заново (с начала или с конца — по режиму).
Фильтрация: Microsoft IP-диапазоны и «свои» сети
Одна из практических проблем: в отчётах всплывают IP-адреса, которые банить нельзя или бессмысленно (особенно если есть мобильные клиенты через инфраструктуру Microsoft):
внутренние сети (RFC1918);
инфраструктурные адреса (прокси/балансировщик);
Microsoft-подсети (в зависимости от сценариев клиентов и маршрутизации).
Поэтому в фильтрации я:
исключаю внутренние диапазоны;
использую актуальные диапазоны Microsoft;
веду отдельный WhiteList, который имеет приоритет над всем.
Также надо учесть — сети Microsoft динамические. И их надо постоянно обновлять. Поэтому в CheckIpAtack.ps1 я добавил функцию, которая подтягивает актуальные диапазоны Microsoft и сохраняет в файл MsIps.csv. Это позволяет исключать их из блокировки.
На этом этапе важно не превращать бан-систему в самоубийцу: банить Microsoft-подсети или свой reverse proxy — весело, но недолго.

Скрипт блокировки: IP2Ban.ps1
IP2Ban получает на вход не сырые IIS-логи, а уже «сухой остаток»: последний Risky_*.csv.
Списки: White / Black / Spam
Я пришёл к простой модели, которая легко объясняется и легко поддерживается:
WhiteList — никогда не блокировать (приоритет над всеми).
BlackList — блокировать всегда и надолго (ручное пополнение).
SpamList — временный список из Risky (с датой добавления и авто-истечением).
И финальный:
FinalList = ( SpamList + BlackList ) − WhiteList
FinalList отправляется на почту администратору и идёт в бан; сначала в режиме Allow, чтобы безопасно обкатать.

Почему так удобно:
можно быстро «разбанить навсегда» (WhiteList);
можно «забанить навсегда» (BlackList);
можно автоматом банить «шумящих» на сутки/несколько часов (SpamList).
Блокировка включается не сразу
Если сразу включить бан — велик шанс забанить:
свой прокси/балансировщик;
подсети Microsoft (если у вас часть клиентов/инфраструктуры оттуда);
или редкого легитимного клиента с кривой конфигурацией.
Поэтому скрипт обновляет правило в IIS\IP Address and Domain Restrictions как Allow, собирает статистику и показывает вероятные IP для бана. Когда в списке останутся только нелегитимные IP — нужно перевести на Deny.
Обновление правил и отчёт администратору
FinalList хранит актуальную таблицу IP-адресов, она должна быть идентична списку в IIS\IP Address and Domain Restrictions.
IP2Ban не сразу отправляет IP атакующего в блок. Сначала сравнивает IP, прошедший фильтрацию, и подготовленные к блокировке с FinalList и применяет только в том случае, если этот IP — новый (требуется заблокировать) или наоборот истёк срок блокировки.
Это позволяет избежать многократного переписывания конфига IIS. Ситуация, когда внесли IP в бан на 24 часа, а скрипт в планировщике запускается каждые 5 минут, может существенно нагрузить CPU. Так как IIS будет вынужден перечитывать весь конфиг после перезаписи секции.
Далее скрипт отправляет HTML-отчёт. Я включил отправку только с активного MailBox-сервера, чтобы не заспамить почту. В отчёте две таблицы:
какие IP добавлены;
полный итоговый список.

Реальные сложности и как я их решил
«Слишком общий паттерн» и ложные срабатывания
Отдельной дискуссией был паттерн 401 0 0. Клиент показал кейс, который по количеству выглядел как атака (сотни/тысячи 401), но User-Agent был похож на Apple EWS/ActiveSync.
Я не стал добавлять всё подряд в Risky. Вместо этого оставил в Risky только самые «жёсткие» сигнатуры (401.1 + sc-win32-status=1326 / 0x8009030C).
Порог/лимит в неправильном месте ломает цепочку
Был классический баг архитектуры: лимит стоял на этапе Merged, из-за чего Merged иногда становился пустым, и дальше цепочка «Filtered → Risky → Ban» просто не работала.
В итоге свёл к правильной схеме: Mailbox → Merged (без лимита) → Filtered (лимит) → Risky
То есть «лимиты — на фильтрации, а не на сборе».
Нагрузка и стабилизация
Когда отчёты стали регулярными (каждые несколько минут), всплыли вопросы:
утилизация CPU на нескольких серверах;
задержки в чтении IIS-логов.
Решения:
инкрементальное чтение IIS-логов (
System.IO.StreamReader), вместоGet-Contentпо всему файлу с сохранением позиции;только один (активный) узел для merge/cleanup;
аккуратный размер окна времени;
минимальный набор полей в анализе;
вносим в блокировку только новые записи — если добавлять\удалять нечего, то не трогаем FW\IIS (IP Address and Domain Restrictions);
обернуть IIS-командлеты (
Add-WebConfiguration/Remove-WebConfigurationProperty) вStart-WebCommitDelay/Stop-WebCommitDelay. Чтобы в конфиг IIS писать один раз, независимо от количества добавляемых\удаляемых IP.
Что получилось в итоге
Я получил »«fail2ban-подобную» механику для Exchange на Windows:
видно атаки по протоколам IIS;
нет дезориентации в шуме мобильных клиентов;
автоматическая блокировка источника (с TTL);
отчётность (для SOC/разбора/аудита).
Решение полностью на PowerShell и легко дорабатывается под конкретную инфраструктуру.
