Перейти к содержимому

Как я написал на Python парсер логов аутентификации, чтобы найти brute-force раньше SIEM

Как я написал на Python парсер логов аутентификации, чтобы найти brute-force раньше SIEM
Время чтения: 11 мин.

На ночной смене в SOC (центр мониторинга безопасности — место, где аналитики круглосуточно смотрят в дашборды и пьют кофе литрами) я заметил всплеск CPU на SSH-бастионе. Splunk ещё не проиндексировал свежие логи — задержка около 15 минут, очередь форвардеров забита. Открыл /var/log/auth.log по SSH, прогнал grep "Failed password" — 800+ строк с одного IP за полчаса. Через 40 минут SIEM наконец выдал алерт. К тому моменту IP уже заблокирован, таймлайн собран, тикет в L3. После того случая я написал Python-скрипт на 60 строк, который делает то же самое за секунды. Запускаю его каждый раз, когда нужно проверить гипотезу до того, как правила корреляции отработают. SIEM никто не отменял — но ждать его, когда можно не ждать, глупо.

Что лежит в auth.log и почему SIEM опаздывает

auth.log (на Debian/Ubuntu) или secure (на CentOS/RHEL) — файл, куда Linux через rsyslog пишет все события аутентификации: неудачные и успешные входы по SSH, команды sudo, смену паролей. На системах с systemd события также доступны через journalctl -u ssh, но для парсинга текстового файла подход из статьи применим именно к auth.log/secure. Каждая строка — одно событие с меткой времени, именем хоста, процессом и описанием.

Типичная строка неудачного входа: Jan 5 14:22:01 server sshd[12345]: Failed password for root from 192.168.1.100 port 54321 ssh2. Разберём по частям. Jan 5 14:22:01 — дата и время (два пробела перед однозначным днём — нюанс, к нему вернёмся). server — hostname машины. sshd[12345] — процесс SSH-демона с PID. Failed password for root — неудачная попытка ввода пароля для пользователя root. from 192.168.1.100 — IP атакующего. port 54321 ssh2 — порт на стороне клиента и протокол.

Если атакующий пробует несуществующего пользователя, формат чуть другой: Failed password for invalid user admin from 10.0.0.1 port 54322 ssh2. Между for и именем пользователя появляется invalid user. Кроме паролей SSH пишет и неудачные попытки аутентификации по ключу: Failed publickey for root from 192.168.1.100 port 54323 ssh2. Оба типа — следы атаки, оба нужно ловить.

Корпоративные SIEM-системы (Splunk, QRadar, MaxPatrol SIEM) собирают логи через forwarder-агенты, индексируют, нормализуют и только потом прогоняют правила корреляции. В нагруженной инфраструктуре задержка между событием в auth.log и алертом на экране аналитика — от 5 до 30 минут. Для brute-force атаки (перебор паролей, техника T1110 в MITRE ATT&CK — это открытая база тактик и техник атак, где каждому приёму присвоен T-код для единообразной классификации) этого хватит, чтобы атакующий подобрал пароль к слабой учётке и начал двигаться по сети.

Проблема не теоретическая. По данным IBM X-Force Threat Intelligence Index 2025, использование действительных учётных данных в атаках выросло на 71% за год. По их же оценке, в даркнете ежедневно появляется около 6000 свежих учётных записей. Verizon DBIR 2025 фиксирует: 38% утечек данных связаны с кражей credentials. Brute-force — не абстрактная угроза из учебника, а повседневность для любого SSH-сервера с доступом из интернета. OWASP относит такие проблемы к категории A07:2021 — Identification and Authentication Failures (ошибки идентификации и аутентификации), а недостаточный мониторинг логов — к A09:2021 — Security Logging and Monitoring Failures (недостатки логирования и мониторинга).

Парсинг логов аутентификации на Python: regex и подсчёт

Задача скрипта: прочитать auth.log, найти все строки с неудачными попытками входа, вытащить из каждой IP-адрес и имя пользователя, подсчитать количество попыток с каждого адреса и пометить те, где число попыток превышает порог.

Что понадобится: Python 3.8+, доступ к файлу /var/log/auth.log (нужны права root или членство в группе adm). На рабочей станции можно тренироваться на скопированном файле — sudo cp /var/log/auth.log ~/test_auth.log && sudo chown "$(whoami)":"$(whoami)" ~/test_auth.log.

Регулярные выражения для логов SSH

Ключевая задача — написать regex (регулярное выражение — шаблон для поиска и извлечения текста по его структуре), который ловит все четыре варианта строк Failed: пароль для существующего пользователя, пароль для несуществующего, ключ для существующего, ключ для несуществующего.

Пропуск Failed publickey — одна из самых частых ошибок. Пентестеры и боты тестируют и ключи тоже, эти попытки не менее подозрительны.

import re
from collections import defaultdict

FAILED = re.compile(
    r"(\w+\s+\d+\s+[\d:]+)\s+\S+\s+sshd\[\d+\]:\s+"
    r"Failed (?:password|publickey) for"
    r"(?: invalid user)?\s+(\S+)\s+from\s+(\S+)"  # NB: пробел между 'for' и username обеспечивается \s+ после опциональной группы
)

Разберём построчно — тут каждый кусок выполняет конкретную работу:

  • (\w+\s+\d+\s+[\d:]+) — первая группа захвата, метка времени. \w+ ест название месяца (Jan, Feb…), \s+ — один или несколько пробелов (ловит и Jan 5, и Jan 15), \d+ — день, [\d:]+ — часы:минуты:секунды.
  • \S+ — пропускает hostname. Для анализа brute-force он не нужен.
  • sshd\[\d+\]: — ловит процесс sshd с любым PID в квадратных скобках.
  • Failed (?:password|publickey) for — матчит оба типа неудачной аутентификации. (?:...) — группировка без захвата, чтобы не засорять результат.
  • (?: invalid user)? — опциональный блок. Пользователь не существует в системе — блок появится; существует — regex его пропустит.
  • (\S+) — вторая группа захвата, имя пользователя. \S+ ловит и root, и test-user, и john.doe, и user@domain.
  • from\s+(\S+) — третья группа захвата, IP-адрес. \S+ вместо [\d.]+ — осознанный выбор, чтобы ловить и IPv6 (вроде 2001:db8::1 или ::1).

Обнаружение подбора пароля SSH по порогу

Теперь проходим по файлу, применяем regex к каждой строке и считаем попытки по IP:

def parse_auth_log(path, threshold=5):
    attempts = defaultdict(list)
    with open(path, "r", errors="replace") as f:
        for line in f:
            m = FAILED.search(line)
            if m:
                ts, user, ip = m.groups()
                attempts[ip].append((ts, user))
    brute = {ip: e for ip, e in attempts.items()
             if len(e) >= threshold}
    return attempts, brute

Что тут происходит. Функция принимает путь к файлу и порог (по умолчанию 5 попыток). defaultdict(list) — словарь, где для каждого нового IP автоматически создаётся пустой список. Для каждой строки файла: если regex сработал — извлекаем метку времени, имя пользователя и IP, складываем кортеж в список по IP. В конце фильтруем: IP с числом попыток >= threshold попадают в словарь brute. Функция возвращает оба словаря — полный и отфильтрованный.

Если запустить parse_auth_log("/var/log/auth.log") и вывести brute, увидите словарь вида {"185.220.101.42": [("Jan 5 03:14:01", "root"), ("Jan 5 03:14:03", "admin"), ...]} — ключ это IP, значение — список всех неудачных попыток с временем и пользователем. Словарь пуст — brute-force не обнаружен (или порог слишком высокий).

Последний блок — вывод результатов в терминал:

def report(brute):
    for ip, events in sorted(
            brute.items(), key=lambda x: -len(x[1])):
        users = {u for _, u in events}
        print(f"[ALERT] {ip}: {len(events)} failures, "
              f"users: {', '.join(sorted(users))}, "
              f"first: {events[0][0]}, "
              f"last: {events[-1][0]}")

Ожидаемый вывод: [ALERT] 185.220.101.42: 312 failures, users: admin, root, test, first: Jan 5 03:14:01, last: Jan 5 03:47:22. Одна строка — один потенциальный brute-force с количеством попыток, целевыми учётками и временным окном атаки.

Порог 5 попыток — не магическое число. Таков дефолт в конфигурации fail2ban на большинстве дистрибутивов (jail.conf: maxretry = 5). На проде я обычно ставлю 3 для привилегированных учёток (root, admin) и 10 для обычных — так отсекается шум от легитимных ошибок ввода пароля.

Грабли при анализе auth.log: edge cases

Первый скрипт, который я написал, сломался на реальных логах за первые сутки. Вот грабли, на которые наступают при написании парсера логов на Python.

Однозначные дни и двойные пробелы. В auth.log первое число месяца записывается с ведущим пробелом: Jan 5 (два пробела), двузначное — с одним: Jan 15. Если разбираете строку через split() — количество элементов поменяется и индексы поедут. Regex с \s+ решает это автоматически, потому что жадно ест любое количество пробелов. Мелочь, но именно на ней ломается каждый второй самописный парсер.

IPv6-адреса. Боты с IPv6 — реальность. Строка с Failed password for root from 2001:db8::1 port 22 ssh2 или даже from ::1 (localhost) встречается на серверах с dual-stack. Если в regex стоит [\d.]+ для IP — IPv6 не поймается. В нашем шаблоне используется \S+, который ловит оба формата. Обратная сторона: если формат лога нестандартный, может захватить лишнее. Для auth.log работает стабильно.

Пустой файл и отсутствие Failed-строк. Если auth.log свежий, на сервере нет SSH или файл просто пустой — скрипт не должен падать с IndexError. В нашем коде это обработано: если regex ни разу не сработал, attempts остаётся пустым defaultdict, а brute — пустым словарём. Функция report просто ничего не выведет. Но если вы обращаетесь к events[0] в другом месте — проверяйте длину списка.

Необычные имена пользователей. Атакующие пробуют не только root и admin. В реальных логах встречаются test-user, john.doe, user@domain, чисто цифровые 12345 и даже одиночный символ _. Regex с \S+ ловит всё это, но если строите whitelist допустимых учёток — будьте готовы к экзотике.

Часовые пояса. auth.log не содержит информации о таймзоне — только локальное время сервера. При сравнении логов с серверов в разных часовых поясах нужно приводить время к UTC вручную. Для скрипта на одном хосте это не проблема, но для корреляции между серверами — обязательно.

Детектирование атак без SIEM: что делать с результатами

Парсер выдал список IP с превышением порога. Дальше — конкретные шаги, которые я выполняю на каждом дежурстве.

Шаг 1: проверить IP по AbuseIPDB. AbuseIPDB — сервис репутации IP-адресов с бесплатным API (до 1000 запросов в день). Confidence score (оценка от 0 до 100 — чем выше, тем больше независимых жалоб на этот адрес) выше 75 — рекомендация к блокировке. Но перед этим исключите CGNAT-адреса (за одним IP может сидеть тысяча пользователей провайдера) и проверьте, не Tor exit node ли это (список выходных узлов публикуется на torproject.org/exit-addresses). Блокировка Tor — отдельный вопрос корпоративной политики.

Шаг 2: классифицировать атаку по MITRE ATT&CK. Три разновидности brute-force, которые различаются прямо в логах:

Паттерн в логах Техника MITRE Признак
Один IP, один пользователь, сотни попыток T1110.001 — Password Guessing Классический перебор пароля
Один IP, десятки пользователей, 1-2 попытки на каждого T1110.003 — Password Spraying Один пароль на много учёток, обходит блокировку по числу попыток
Много IP, одинаковые пары user + password T1110.004 — Credential Stuffing Утёкшие базы данных (HIBP фиксирует миллионы таких записей)

Скрипт позволяет различить эти паттерны: смотрите на количество уникальных пользователей для каждого IP в словаре attempts. IP перебирает только root — guessing. Пробует admin, test, oracle, postgres по одному разу — spraying. Одни и те же пары приходят с разных IP — credential stuffing, и тут уже нужно смотреть на корреляцию между адресами.

Шаг 3: проверить успешные входы. Критический момент, который новички пропускают. Если с IP из списка brute-force в auth.log есть строка Accepted password или Accepted publickey — это уже не попытка атаки, а возможная компрометация. Техника T1078 (Valid Accounts) — атакующий получил действительные учётные данные и использует их. CrowdStrike фиксирует среднее время lateral movement (горизонтальное перемещение по сети после первого проникновения) после initial access в 62 минуты. Рекорд — 51 секунда. Каждая минута задержки в детектировании — минута, которую атакующий тратит на закрепление.

Шаг 4: заблокировать и эскалировать. Блокировка IP через iptables -A INPUT -s <IP> -j DROP или добавление в fail2ban jail. Параллельно — тикет с таймлайном: первая попытка, последняя попытка, целевые учётки, наличие или отсутствие успешного входа. Есть успешный вход — эскалация в L3 немедленно.

Шаг 5: автоматизировать. Когда скрипт работает стабильно, добавьте его в cron: */5 * * * * python3 /opt/scripts/auth_parser.py --threshold 5 >> /var/log/brute_alerts.log — проверка auth.log каждые 5 минут, алерты пишутся в отдельный файл. Это не замена SIEM, а дополнительный слой мониторинга логов аутентификации, который срабатывает мгновенно без задержки на индексацию.

Половина аналитиков, с которыми я работал, считают скрипты для анализа логов безопасности костылём — мол, станет не нужен, когда «нормально настроят SIEM». На практике SIEM настраивают годами, очередь индексации растёт вместе с инфраструктурой, а правила корреляции покрывают не все сценарии. Скрипт на 60 строк не конкурирует со Splunk — он закрывает окно между событием и алертом. Я видел инциденты, где эти 15 минут разницы определяли, успеет ли атакующий выгрузить хэши из SAM или будет остановлен на этапе подбора. Аналитик, который умеет открыть терминал и за минуту написать парсер под конкретную гипотезу, полезнее того, кто ждёт, пока дашборд обновится. Это вопрос привычки. Чем раньше она формируется, тем лучше. На IB Basics показывают не «что такое CIA-триада», а как делать первые задачи в SOC и blue team — примерно с этих навыков и начинается реальная работа.

Эту тему и смежные навыки разбирают на практике в курсе «Основы программирования на Python» Codeby Academy.