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

Fail2ban и аналоги привычны для Linux, но когда у тебя on-prem Exchange на Windows, нужен свой инструмент для быстрого обнаружения массовых неудачных логинов и такой же быстрой блокировки источника.

История простая: у клиента в Exchange прилетел brute-force (ruler). Никого не взломали — но пользователи начали массово жаловаться на блокировки учётных записей (lockout policy отработала идеально — против нас).

Мы закрыли проблему вручную: нашли IP атакующего, заблокировали. Но было понятно: если это повторится ещё раз, тем более ночью или в выходные, исправлять вручную — не вариант.

Сформировалась задача: создать инструмент, который:

  1. обнаруживает такие атаки автоматически;

  2. быстро блокирует атакующего;

  3. формирует отчёт, чтобы не гадать «почему опять лочит пользователей».

Я решил реализовать эту механику на 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-лога с ruler/autodiscover
Строка IIS-лога с ruler/autodiscover

Подготовка 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

 W3C Logging Fields: добавлены X-FORWARDED-FOR (Custom Fields)
W3C Logging Fields: добавлены X-FORWARDED-FOR (Custom Fields)

Практический нюанс: если у вас есть балансировщик/прокси, но 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 может быть:

  1. Частью нормального negotiation (challenge/response)

    Для Integrated Windows Authentication (Kerberos/NTLM) типичен сценарий: первый запрос приходит без корректного Authorization, IIS отвечает 401 + WWW-Authenticate, а клиент повторяет запрос уже с токеном. В итоге вы видите 401, после которого сразу идёт 200 — это нормально.

  2. Следствием политики безопасности (например, запретом NTLMv1)

    Если в домене/на серверах запрещён NTLMv1, старые клиенты/устройства могут пытаться начать negotiation с NTLMv1, и сервер отклонит попытку. Тогда в IIS появляются серии 401 (sc-win32-status = 2148074252 / 0x8009030C, SEC_E_LOGON_DENIED), хотя это не brute-force, а несовместимость по политике.

  3. Несовпадением настроек аутентификации на виртуальных директориях

    Клиент ожидает один механизм (Basic/NTLM/Negotiate), сервер разрешает другой — получаются повторяющиеся 401 на конкретных URL.

  4. Рассинхронизацией времени на телефоне.

Я перестал смотреть на простой 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 без деталей — либо не баним, либо баним только при очень высоких порогах и дополнительных условиях.

Архитектура: три модуля вместо одного “монолита”

Если делать «всё в одном», получится плохо отлаживаемый комбайн. Я разделил ответственность:

  1. CheckIpAtack.ps1 — анализ IIS-логов и отчётность (агрегация 401 по IP).

  2. Get-IISLogIncremental.ps1 — ускорение: читаем IIS-лог инкрементально, а не с нуля.

  3. 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

 Пример отчёта: MBX_Report \ Merged_Report
Пример отчёта: MBX_Report \ Merged_Report

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 на последнюю позицию;

  • читает только новые строки;

  • сохраняет новую позицию.

 Пример state-файла с позицией чтения (JSON)
Пример state-файла с позицией чтения (JSON)

Ключевой момент: 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 — весело, но недолго.

 Итоговый файл MsIps.csv
Итоговый файл MsIps.csv

Скрипт блокировки: 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 добавлены;

  • полный итоговый список.

Письмо-отчёт IP2Ban (Added / Full Final List)
Письмо-отчёт IP2Ban (Added / Full Final List)

Реальные сложности и как я их решил

«Слишком общий паттерн» и ложные срабатывания

Отдельной дискуссией был паттерн 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 и легко дорабатывается под конкретную инфраструктуру.