Как-то раз один знакомый сисадмин пожаловался мне на жизнь суровую. Он рассказал об одном инциденте в его конторе. Стоит оговориться, что контора небольшая и такой сущности как отдельный специалист по информационной безопасности там нет. Инцидент стандартный до банальности. Случайно заметили аномальную активность на линуксовых серверах. Подозрения сразу же подтвердились выводом команды who, который показал подключение по ssh с прокси-сервера с IP одной маленькой, но очень гордой страны. Дальше было то, что и положено в таких ситуациях, а именно: сменить доступы, понять откуда зараза по сети пошла, и что именно она делала. Доступы сменили, а вот когда полезли в логи, с удивлением обнаружил, что они уже несколько дней как пишутся в /dev/null, то есть у злоумышленника на сервере был root-доступ. Позже выяснили, что причиной была утечка пароля от аккаунта одного из сотрудников с доступом к sudo.

История, в общем-то, типичная, тысячи таких. Но меня она зацепила и побудила задаться вопросом: а как, собственно поймать хакера в тот самый момент, когда он попал на сервер впервые и пытается там закрепиться? Возможно, существуют enterprise-решения аудита и мониторинга входа на удалённую машину, но даже крупный бизнес с неохотой тратится на инфобез. Не говоря уже о небольших конторах с IT-отделом в 3,5 человека. Будем делать всё сами, благо в линуксах требуемая функциональность есть практически из коробки.

PAM используется во всех современных *nix-like системах. Арч-вики подсказывает нам, что PAM — это фреймворк для аутентификации пользователей в системе. PAM предоставляет возможность разрабатывать программы, которые не зависят от схемы аутентификации. Во время выполнения к программам подключаются модули аутентификации. Какой конкретно модуль аутентификации должен быть подключен, зависит от настроек локальной системы и остаётся на усмотрение локального системного администратора.

Проще говоря, благодаря PAM в процесс аутентификации можно встроить дополнительные действия. Например поставить модуль для двухфакторной аутентификации, ну или в нашем случае добавить уведомление. PAM присутствует не только в Linux-системах, но и также в BSD и MacOS. Но раз уж говорим про Linux, на его примере и будем рассматривать.

Настройки PAM можно найти в конфигах, лежащих в /etc/pam.conf и /etc/pam.d/. Туда мы и взглянем.

/etc/pam.d/login
#
# The PAM configuration file for the Shadow `login' service
#

# Enforce a minimal delay in case of failure (in microseconds).
# (Replaces the `FAIL_DELAY' setting from login.defs)
# Note that other modules may require another minimal delay. (for example,
# to disable any delay, you should add the nodelay option to pam_unix)
auth       optional   pam_faildelay.so  delay=3000000

# Outputs an issue file prior to each login prompt (Replaces the
# ISSUE_FILE option from login.defs). Uncomment for use
# auth       required   pam_issue.so issue=/etc/issue

# Disallows other than root logins when /etc/nologin exists
# (Replaces the `NOLOGINS_FILE' option from login.defs)
auth       requisite  pam_nologin.so

# SELinux needs to be the first session rule. This ensures that any
# lingering context has been cleared. Without this it is possible
# that a module could execute code in the wrong domain.
# When the module is present, "required" would be sufficient (When SELinux
# is disabled, this returns success.)
session [success=ok ignore=ignore module_unknown=ignore default=bad] pam_selinux.so close

# Sets the loginuid process attribute
session    required     pam_loginuid.so

# Prints the message of the day upon successful login.
# (Replaces the `MOTD_FILE' option in login.defs)
# This includes a dynamically generated part from /run/motd.dynamic
# and a static (admin-editable) part from /etc/motd.
session    optional   pam_motd.so motd=/run/motd.dynamic
session    optional   pam_motd.so noupdate

# SELinux needs to intervene at login time to ensure that the process
# starts in the proper default security context. Only sessions which are
# intended to run in the user's context should be run after this.
# pam_selinux.so changes the SELinux context of the used TTY and configures
# SELinux in order to transition to the user context with the next execve()
# call.
session [success=ok ignore=ignore module_unknown=ignore default=bad] pam_selinux.so open
# When the module is present, "required" would be sufficient (When SELinux
# is disabled, this returns success.)

# This module parses environment configuration file(s)
# and also allows you to use an extended config
# file /etc/security/pam_env.conf.
# 
# parsing /etc/environment needs "readenv=1"
session       required   pam_env.so readenv=1
# locale variables are also kept into /etc/default/locale in etch
# reading this file *in addition to /etc/environment* does not hurt
session       required   pam_env.so readenv=1 envfile=/etc/default/locale

# Standard Un*x authentication.
@include common-auth

# This allows certain extra groups to be granted to a user
# based on things like time of day, tty, service, and user.
# Please edit /etc/security/group.conf to fit your needs
# (Replaces the `CONSOLE_GROUPS' option in login.defs)
auth       optional   pam_group.so

# Uncomment and edit /etc/security/time.conf if you need to set
# time restraint on logins.
# (Replaces the `PORTTIME_CHECKS_ENAB' option from login.defs
# as well as /etc/porttime)
# account    requisite  pam_time.so

# Uncomment and edit /etc/security/access.conf if you need to
# set access limits.
# (Replaces /etc/login.access file)
# account  required       pam_access.so

# Sets up user limits according to /etc/security/limits.conf
# (Replaces the use of /etc/limits in old login)
session    required   pam_limits.so

# Prints the last login info upon successful login
# (Replaces the `LASTLOG_ENAB' option from login.defs)
session    optional   pam_lastlog.so

# Prints the status of the user's mailbox upon successful login
# (Replaces the `MAIL_CHECK_ENAB' option from login.defs). 
#
# This also defines the MAIL environment variable
# However, userdel also needs MAIL_DIR and MAIL_FILE variables
# in /etc/login.defs to make sure that removing a user 
# also removes the user's mail spool file.
# See comments in /etc/login.defs
session    optional   pam_mail.so standard

# Create a new session keyring.
session    optional   pam_keyinit.so force revoke

# Standard Un*x account and session
@include common-account
@include common-session
@include common-password

Конфигурационный файл представляет собой описание правил проверки, необходимых для успешной аутентификации пользователя или выполнения других операций. Строка в файле содержит одно правило, и проверки осуществляются последовательно.

Каждая строка содержит три параметра: тип модуля, control_flag и имя модуля. Наибольший интерес для нас представляют модули типа "auth", поскольку именно они отвечают за аутентификацию. Control_flag определяет свойства модуля и может иметь следующие значения:

  • requisite (необходимый) — если модуль возвращает положительный ответ, остальные проверки в цепочке выполняются, и запрос удовлетворяется. В случае отрицательного ответа запрос немедленно отвергается, и другие проверки не выполняются.

  • required (требуемый) — аналогично requisite: при положительном ответе выполняются остальные проверки в цепочке. Отличие заключается в том, что при отрицательном ответе цепочка проверок продолжает работу, но запрос отклоняется.

  • sufficient (достаточный) — удовлетворяет запрос, если ни одна из предыдущих проверок в цепочке не вернула отрицательный результат. В случае негативного срабатывания модуля результат игнорируется, и проверка продолжается.

  • optional (необязательный) — модуль выполняет свою работу, но результат его работы не учитывается.

Общие для всех сервисов правила записаны в common-auth, взглянем и туда тоже.

/etc/pam.d/common-auth
#
# /etc/pam.d/common-auth - authentication settings common to all services
#
# This file is included from other service-specific PAM config files,
# and should contain a list of the authentication modules that define
# the central authentication scheme for use on the system
# (e.g., /etc/shadow, LDAP, Kerberos, etc.).  The default is to use the
# traditional Unix authentication mechanisms.
#
# As of pam 1.0.1-6, this file is managed by pam-auth-update by default.
# To take advantage of this, it is recommended that you configure any
# local modules either before or after the default block, and use
# pam-auth-update to manage selection of other modules.  See
# pam-auth-update(8) for details.

# here are the per-package modules (the "Primary" block)
auth	[success=1 default=ignore]	pam_unix.so nullok
# here's the fallback if no module succeeds
auth	requisite			pam_deny.so
# prime the stack with a positive return value if there isn't one already;
# this avoids us returning an error just because nothing sets a success code
# since the modules above will each just jump around
auth	required			pam_permit.so
# and here are more per-package modules (the "Additional" block)
# end of pam-auth-update config

Что здесь происходит: отрабатывает модуль pam_unix.so, и в случае неуспешной отработки pam_unix.so выполняется pam_deny.so и следующие инструкции не выполняются. В случае успеха pam_unix.so следует инструкция из следующей строчки, то есть pam_deny.so игнорируется, и выполняется pam_permit.so.

Предполагаем следующую ситуацию. В один прекрасный момент злоумышленник получает рута, сервер становится полностью скомпрометированным. Верить логам с сервера мы больше не можем, хакер ими может манипулировать, причём хакер, который уже вышел из школьного возраста сделает это в первую очередь, потерев логи с предыдущими (успешными и не очень) попытками пройти аутентификацию.

Итак, что из этого следует. Периметром атаки является полностью сервер, следовательно нам нужно отправлять данные о попытке логина куда-то вовне этого самого периметра. Для этого можно написать собственный PAM-модуль. А можно ограничиться простым скриптом, подключаемым через pam_exec.so. На гитхабе есть куча различных вариантов, с отправкой логов в Telegram, Slack, Discord, да куда угодно. Но если ничего не нравится, можно на коленке накидать самому за 5 минут.

Необходимые значения pam_exeс записывает в переменные окружения. PAM_SERVICE — сервис, через который производится логин, например ssh, logind или lightdm. PAM_USER — логин пользователя, под которым осуществляется попытка входа. PAM_RHOST — IP машины, с которой происходит вход, в случае удалённого входа.

Код скрипта с отправкой в Telegram:

#!/usr/bin/python3
import requests
from os import environ, uname
from sys import argv

TELEGRAM_TOKEN = 'токен бота'
TELEGRAM_CHAT_ID = 'ID получателя отчётов'

if argv[1] == 'success':
    status = 'successful'
elif argv[1] == 'fail':
    status = 'failed'

message = f"Detected {status} login on server {uname()[1]} through {environ['PAM_SERVICE']} by user {environ['PAM_USER']} from remote host {environ.get('PAM_RHOST')}."

requests.get(f'https://api.telegram.org/bot{TELEGRAM_TOKEN}/sendMessage?chat_id={TELEGRAM_CHAT_ID}&text={message}')

Редактируем /etc/pam.d/common-auth:

# here are the per-package modules (the "Primary" block)
auth    [success=2 default=ignore]      pam_unix.so nullok
# here's the fallback if no module succeeds
auth    optional                        pam_exec.so /usr/local/bin/our_script.py fail
auth    requisite                       pam_deny.so
# prime the stack with a positive return value if there isn't one already;
# this avoids us returning an error just because nothing sets a success code
# since the modules above will each just jump around
auth    optional                        pam_exec.so /usr/local/bin/our_script.py success
auth    required                        pam_permit.so
# and here are more per-package modules (the "Additional" block)
auth    optional                        pam_cap.so 
# end of pam-auth-update config

Что мы здесь сделали:

Меняем параметр success, устанавливая значение 2, то есть говорим: в случае успеха pam_unix.so игнорировать две следующих инструкции. И добавляем опциональную инструкцию, не влияющую на конечную проверку, с командой запустить наш скрипт с аргументом fail.

Перед pam_permit.so добавляем опциональную инструкцию запуска скрипта с параметром success.

В общем-то всё кажется просто, но когда серверов или пользователей становится много, логи превращаются в месиво. Вроде бы и видно, кто, когда и куда зашёл, но глаза замыливаются и в длинном списке сообщений о логине становится проблематично найти нужную запись или заметить аномалию. Что ж, будем превращать маленький безымянный скриптик в полноценную систему мониторинга и аудита попыток логина. Система получает имя Gryffine.

Мы помним, что у нас есть необходимость вынести логи активности за пределы периметра потенциальной атаки. Если мы можем отправлять информацию в Telegram, значит мы можем её отправлять по API на свой сервер, который будет заниматься обработкой полученных данных.

Модифицируем клиентскую часть для отправки информации в более понятном для машины варианте:

#!/usr/bin/python3
import requests
from os import environ, uname
from sys import argv
from syslog import syslog, LOG_ERR

endpoint = argv[1]

record = {
    'hostname': uname()[1],
    'service': environ['PAM_SERVICE'],
    'user': environ['PAM_USER'],
    'rhost': environ.get('PAM_RHOST') # Может оказаться пустым, поэтому здесь запрашиваем значение через метод .get, чтобы получить Null, а не KeyError
}
if argv[2] == 'success':
    record['is_successful'] = True
elif argv[2] == 'fail':
    record['is_successful'] = False

try:
    requests.post(endpoint, json=record)
except Exception as err:
    error_message = 'Gryffine monitoring system error: '
    error_message += str(err)
    syslog(LOG_ERR, error_message)

Суть вся та же самая, за исключением пары моментов. Для более удобной автоматизации установки на множество машин выносим в аргументы принимающий эндпоинт и добавляем запись в syslog об ошибках. Раз мы поменяли аргументы, придётся ещё раз поправить common-auth.

# here are the per-package modules (the "Primary" block)
auth    [success=2 default=ignore]      pam_unix.so nullok
# here's the fallback if no module succeeds
auth    optional                        pam_exec.so /usr/local/bin/gryffine.py http://gryffine-server/api/v1/records/ fail
auth    requisite                       pam_deny.so
# prime the stack with a positive return value if there isn't one already;
# this avoids us returning an error just because nothing sets a success code
# since the modules above will each just jump around
auth    optional                        pam_exec.so /usr/local/bin/gryffine.py http://gryffine-server/api/v1/records/ success
auth    required                        pam_permit.so
# and here are more per-package modules (the "Additional" block)
auth    optional                        pam_cap.so 
# end of pam-auth-update config

Теперь пишем бэкенд для нашего сервера. На эндпоинт приходит информация о попытке логина в формате:

{
    "service": "",          # сервис, через который происходит попытка входа в систему
    "user": "",             # логин пользователя
    "hostname": "",         # хостнейм машины, на которую производится логин
    "rhost": "",            # IP, с которого выполняется запрос, может быть пустым
    "is_successful": false  # является ли попытка успешной
}

С этим уже можно работать. Для начала сделаем простенький веб-интерфейс для отображения этих попыток. Для этого берём Django, и оборачиваем приходящую информацию в объект записи, для последующего вывода в табличку.

class Record(models.Model):
    timestamp = models.DateTimeField(auto_now_add=True)
    hostname = models.CharField(max_length=50)
    service = models.CharField(max_length=50)
    user = models.CharField(max_length=50)
    rhost = models.GenericIPAddressField(null=True, blank=True)
    is_successful = models.BooleanField(default=True)
Так выглядел первый прототип.
Так выглядел первый прототип.

А дальше поехали навешивать плюшки. Если есть внешний IP-адрес, то можно определить к какой стране он принадлежит, для чего воспользуемся базой GeoLite2. Принадлежность IP к той или иной стране — это уже довольно ценная информация. Если серверами пользуется команда, расположенная в России, то внезапный успешный логин откуда-нибудь из Китая или ЮАР наводит на странные мысли.

Если у нас есть возможность производить проверку по стране, почему бы некоторые страны, которые славятся обилием ботов не объявить подозрительными? Для этого сделаем блэк и вайтлисты для стран и диапазонов IP, причём не забудем о тех специалистах, кто мог релоцироваться. Можно добавить некоторую страну целиком в блэклист, а диапазоны, с которых может быть легитимный вход — в вайтлист, и наличие в вайтлисте будет приоритетнее.

В итоге, модели будут выглядеть примерно так:

class Record(models.Model):
    timestamp = models.DateTimeField(auto_now_add=True)
    hostname = models.CharField(max_length=50)
    service = models.CharField(max_length=50)
    user = models.CharField(max_length=50)
    rhost = models.GenericIPAddressField(null=True, blank=True)
    country = CountryField(null=True, blank=True)
    is_successful = models.BooleanField(default=True)
    is_suspicious = models.BooleanField(null=True, blank=True)

class Rule(models.Model):
    country = CountryField(null=True, blank=True)
    rhost = models.CharField(max_length=50, null=True, blank=True)

Наконец, вспоминаем ради чего мы начали делать серверную часть. Django-tables2 даст нам удобное отображение, а django-filter удобный поиск и фильтрацию. Также прикручиваем возможность экспорта логов в csv и xls. В итоге получаем вот такую картинку.

Легенда таблицы: красные записи означают успешный логин, попавший под правила из блэклиста, зелёные — успешный логины из-под вайтлиста, жёлтые — успешные логины, не попавшие ни под одно из правил. Проваленные попытки логина всегда серые.
Легенда таблицы: красные записи означают успешный логин, попавший под правила из блэклиста, зелёные — успешный логины из-под вайтлиста, жёлтые — успешные логины, не попавшие ни под одно из правил. Проваленные попытки логина всегда серые.

Цель каким-то образом блокировать подозрительные попытки входа не ставится. Для этого есть фаерволлы, fail2ban, короче говоря, специализированные для этого инструменты. Информация в таблице несёт по большей части справочный характер: появилась "красная" запись — значит нужно разобраться, что происходит, уточнить не забыл ли случайно сотрудних перед входом на рабочий сервер отключить VPN с которого смотрел Netflix, или это всё-таки хакеры шалят. Много серых — значит плохо настроен фаерволл, боты брутят ssh.

Если начал навешивать плюшки, становится трудно остановиться. Вернём оповещения о логине в Telegram, только теперь этим будет заниматься сервер. При создании экземпляра записи лога опционально отправляем информацию об этом событии в Telegram, а заодно и на e-mail.

Также важно понимать, что подобный лог уже сам по себе представляет большую ценность потенциальному злоумышленнику. Хостнеймы дают понимание о топологии сети, а логины юзеров помогут при составлении списков для брута. Поэтому даже для чтения вешаем авторизацию. Заодно в профиле юзера можно повесить telegram id и почту, куда логи дублировать. Или не вешать, это опционально. Разумным решением будет не выпускать ресурс с логами в интернет, оставив доступ исключительно из корпоративной сети.

Наконец, заворачиваем всё, что у нас получилось в Docker и прикручиваем обновление базы GeoIP при каждом перезапуске контейнера.

Итог вышел, по моему мнению, довольно неплохим. Система позволяет оперативно обнаруживать аномальную активность и реагировать на неё. Успешно обнаруженная зараза — это уже наполовину обезвреженная зараза.

Проект опубликован на гитхабе, серверная часть, клиентская часть.

А ещё я ищу работу.

Если вам нужен бэкендер, который понимает в линуксах, буду рад пообщаться. Ссылка на резюме, почта для связи pressxtowin7@gmail.com.

Комментарии (9)


  1. mc2
    03.11.2023 01:14
    +1

    А почему не сделать свой ВПН и прикрыть его через port knocking?


    1. PressXToWin Автор
      03.11.2023 01:14
      +1

      Есть множество способов осложнить проникновение злоумышленника на сервер. В данной статье они не рассматриваются.


  1. baldr
    03.11.2023 01:14

    Довольно интересное решение, однако, не делает ли всё то же самое Radius?


    1. PressXToWin Автор
      03.11.2023 01:14
      +1

      Не будет ли это слишком тяжёлым решением для того, чтобы просто в красивой табличке посмотреть логи?


      1. baldr
        03.11.2023 01:14

        Я сам с Radius не работал, но это первое что пришло на ум при упоминании ssh/pam. То есть вы рассматривали этот вариант?

        У вас же не просто логи в табличке - это Django, база данных, какой-то мониторинг и бэкап этого - то есть тоже не бесплатно если делать нормально.


  1. aeder
    03.11.2023 01:14

    Машину которая логи пишет подключите через data diode - тогда совсем нормально будет.


  1. micronull
    03.11.2023 01:14

    причиной была утечка пароля от аккаунта одного из сотрудников

    У вас вход по ssh был защищён только паролем?


    1. PressXToWin Автор
      03.11.2023 01:14

      У меня — нет, у моего знакомого в конторе — да. Думаю, после инцидента они сделали выводы и поменяли способ авторизации на что-то более безопасное.


      1. micronull
        03.11.2023 01:14

        Как минимум у себя делаю:

        • Авторизацию по ssh ключам + пароль.

        • Меняю порты по умолчанию.

        • Добавляю задержки между попытками входа, если нет.