Побег из контейнера: признаки в логах хоста и как закрыть путь
Контейнер скомпрометировали — это плохая новость, но не катастрофа, пока атакующий заперт внутри его namespace. Катастрофа начинается в момент, когда процесс из контейнера дотягивается до процессов, файлов или ядра хоста: тогда одна взломанная веб-морда превращается в полный контроль над сервером и, если это тот же хост, — над соседними контейнерами тоже. Разберём, из-за каких настроек побег вообще возможен, какие следы он оставляет в логах и процессах хоста, и что настроить заранее, чтобы даже успешный взлом приложения не стал взломом сервера.
Содержание
- Почему «контейнер — это песочница» работает не всегда
- Три двери наружу: privileged, capabilities, docker.sock
- Признаки на хосте: процессы, которых там не должно быть
- Признаки на хосте: файлы и права, изменённые «снаружи»
- Базовая защита: capabilities, seccomp, AppArmor по умолчанию
- Регулярный аудит: как находить опасные контейнеры до инцидента
Почему «контейнер — это песочница» работает не всегда
Docker-контейнер изолирован ядерными механизмами namespaces и cgroups — свой PID-namespace, своя сеть, своё дерево монтирования, ограничение по CPU и памяти. Для подавляющего большинства сценариев этого достаточно: процесс внутри не видит чужие процессы, не может залезть в /etc/shadow хоста напрямую, не спутает свою файловую систему с чужой. Но это программная изоляция поверх общего ядра, а не отдельная виртуальная машина с собственным ядром — и граница держится ровно настолько прочно, насколько её не ослабили конкретными настройками запуска. Подробнее о том, где заканчивается реальная защита и начинается миф, — в статье про контейнер как песочницу.
Три вещи чаще всего превращают эту границу в дырявую:
- Привилегированный режим (
--privileged) — отключает почти все ограничения capabilities, seccomp и AppArmor разом, отдавая контейнеру доступ к устройствам хоста и возможность монтировать что угодно. - Избыточные Linux capabilities — точечные привилегии root, которые контейнеру не нужны для работы, но по инерции или незнанию добавлены (
SYS_ADMIN,SYS_MODULE,SYS_PTRACEи другие). - Смонтированный Docker socket (
/var/run/docker.sock) внутрь контейнера — фактически передача контейнеру прав управлять всем Docker-демоном хоста, включая запуск новых контейнеров с любыми привилегиями.
Каждый из этих трёх сценариев разберём отдельно — как они превращаются в побег и какие следы оставляют.
Три двери наружу: privileged, capabilities, docker.sock
Привилегированный режим. Контейнер, запущенный с --privileged, получает почти весь набор capabilities ядра, доступ ко всем устройствам хоста (/dev) и возможность монтировать файловые системы, включая псевдо-ФС cgroup. Классический путь побега — примонтировать cgroup-иерархию хоста внутри контейнера и воспользоваться файлом release_agent: этот механизм ядра исполняет указанный скрипт от имени хоста при определённых событиях cgroup, и если контейнер может писать в этот файл, он заставляет хост выполнить произвольную команду. Это не уязвимость конкретной версии, а системное следствие privileged-режима — работает там, где этот флаг включён.
Опасные capabilities. Даже без --privileged отдельные capabilities дают почти эквивалентную власть. Самые опасные для изоляции:
| Capability | Что даёт | Почему опасна |
|---|---|---|
CAP_SYS_ADMIN | Широкий набор административных операций, включая монтирование | Часто называют «новым root» — через неё реализуется большинство техник побега |
CAP_SYS_MODULE | Загрузка модулей ядра | Модуль ядра выполняется с максимальными привилегиями хоста |
CAP_SYS_PTRACE | Трассировка чужих процессов через ptrace | При общем PID-namespace позволяет читать память процессов хоста |
CAP_DAC_READ_SEARCH | Обход проверки прав на чтение файлов | Открывает файлы хоста по файловому дескриптору в обход namespace |
CAP_NET_ADMIN | Настройка сети, включая правила netfilter | Позволяет перехватывать или подменять трафик хоста |
Проверить, какие capabilities реально даны запущенному контейнеру, можно снаружи:
docker inspect --format '{{.Id}}: CapAdd={{.HostConfig.CapAdd}} Privileged={{.HostConfig.Privileged}}' $(docker ps -q)
О том, что вообще такое capabilities и почему root внутри контейнера физически ограничен этим набором, если его не расширять, — в отдельной статье про capabilities.
Docker socket внутри контейнера. Монтирование -v /var/run/docker.sock:/var/run/docker.sock — очень частая практика для CI-раннеров, Portainer, Watchtower и подобных инструментов, которым нужно управлять другими контейнерами. Проблема в том, что доступ к сокету — это не «доступ к одному контейнеру», а полный контроль над Docker-демоном хоста: через API можно запустить новый контейнер с --privileged и смонтированным / хоста внутрь, получив по сути root-шелл на хосте одной командой из уже скомпрометированного контейнера. Если такой доступ действительно нужен, разумнее выносить его в отдельный сервис с ограниченным прокси перед сокетом, а не пробрасывать сырой сокет во всё подряд.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверПризнаки на хосте: процессы, которых там не должно быть
Успешный или неудачный побег почти всегда оставляет след на уровне процессов хоста — потому что цель побега обычно в том и состоит, чтобы выполнить что-то вне namespace контейнера.
Базовая проверка — сверить, какие процессы хоста принадлежат cgroup контейнеров, и посмотреть, не появилось ли там процессов, которых контейнер по идее запускать не должен:
# Для каждого работающего контейнера — реальные PID его процессов на хосте
for c in $(docker ps -q); do
echo "== $c =="
docker top "$c" -eo pid,ppid,cmd
done
Тревожные признаки:
- Родитель процесса вне ожидаемого дерева. Если у процесса контейнера родительский PID указывает не на
containerd-shim/runc, а на какой-то системный процесс хоста (например,systemdнапрямую, не через обычный путь запуска) — это повод разобраться, как этот процесс появился. - Процесс без вложенного PID-namespace. Проверяется через
/proc/<pid>/status: строкиNSpid/NStgidпоказывают PID процесса в разных вложенных namespace. Если у процесса, который должен принадлежать контейнеру, в этих строках всего одно значение — он работает прямо в namespace хоста, что для контейнерного процесса ненормально. - Неожиданные shell-процессы (
bash,sh,nc,curlс подозрительными аргументами) с временем запуска, совпадающим с активностью в скомпрометированном контейнере, но с PPID вне дерева containerd. - Новые smount-события и mount namespace ID, не привязанные ни к одному известному контейнеру — смотрите
lsns -t mntи сверяйте сdocker inspect.
lsns -t mnt
lsns -t pid
Если у процесса lsns показывает namespace ID, не встречающийся ни в одном docker inspect <container> | grep -i pid, разбираться нужно немедленно — это либо контейнер, запущенный в обход обычного docker-инструментария, либо процесс, вырвавшийся из своего namespace.
Признаки на хосте: файлы и права, изменённые «снаружи»
Второй класс следов — изменения файловой системы хоста, инициированные процессом, который по логике должен был иметь доступ только к своей файловой системе внутри контейнера. Здесь без auditd почти не обойтись — обычные логи приложений об этом молчат, потому что изменение файла через системный вызов не проходит через логику самого приложения. Минимальный набор правил для отслеживания подозрительной записи в системные файлы хоста:
auditctl -w /etc/passwd -p wa -k container_escape_watch
auditctl -w /etc/shadow -p wa -k container_escape_watch
auditctl -w /etc/sudoers -p wa -k container_escape_watch
auditctl -w /etc/cron.d -p wa -k container_escape_watch
auditctl -w /root/.ssh/authorized_keys -p wa -k container_escape_watch
auditctl -w /var/run/docker.sock -p wa -k docker_sock_watch
Как разворачивать auditd с нуля, писать правила и читать вывод через ausearch/aureport — тема отдельного разговора, здесь важно только то, что без него подобные изменения на хосте останутся полностью незамеченными.
После срабатывания правила важно понять, из какого процесса пришло изменение — auditd пишет PID, дальше нужно проверить его cgroup:
ausearch -k container_escape_watch -ts recent
# в выводе смотрим поле pid=, затем:
cat /proc/<pid>/cgroup
cat /proc/<pid>/cmdline
Если /proc/<pid>/cgroup показывает путь вида /docker/<container_id>/..., а само изменение затронуло файл хоста вне файловой системы этого контейнера — это прямое подтверждение побега, а не ложного срабатывания.
Дополнительные признаки на уровне файлов:
- Новые setuid/setgid-бинарники, которых раньше не было — стандартный шаг закрепления после побега. Разовая сверка:
find / -xdev -perm -4000 -o -perm -2000 -type f 2>/dev/nullс сохранённым эталоном для сравнения. - Изменённый
mtimeу системных бинарников (/usr/bin/sudo,/bin/su), не совпадающий с датой последнегоapt upgrade/dnf update. - Новые cron-задачи и systemd unit-файлы вне системы управления конфигурацией — если сервер настраивается через Ansible/Terraform, любой ручной unit-файл в обход пайплайна подозрителен сам по себе.
Базовая защита: capabilities, seccomp, AppArmor по умолчанию
Правильная защита от побега — это не один переключатель, а комбинация нескольких независимых слоёв, каждый из которых сужает то, что может делать процесс внутри контейнера, даже если он полностью скомпрометирован.
Не давайте --privileged без крайней необходимости. Подавляющему большинству контейнеров он не нужен вообще — ни базам данных, ни веб-приложениям, ни очередям сообщений. Реальные причины использовать privileged — редкие: например, контейнеры, которым нужно управлять самим Docker-демоном (сборочные раннеры) или работать с устройствами на низком уровне. В остальных случаях это почти всегда решается конкретными capabilities, а не полным снятием ограничений.
Начинайте с --cap-drop=ALL и добавляйте только нужное. Docker по умолчанию выдаёт контейнеру довольно широкий (хотя и не полный) набор capabilities. Правильная практика — сбросить всё и добавлять точечно под конкретную задачу:
docker run --cap-drop=ALL --cap-add=NET_BIND_SERVICE nginx
Для docker-compose:
services:
web:
image: nginx
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE
NET_BIND_SERVICE здесь нужен только для портов ниже 1024 — большинству сервисов не нужна и она, если порт проброшен наружу через маппинг Docker, а внутри слушается 8080 и выше.
Не отключайте seccomp-профиль по умолчанию. Docker с версии, где эта функция появилась, применяет к каждому контейнеру профиль seccomp, блокирующий порядка сорока потенциально опасных системных вызовов — mount, ptrace, reboot, init_module и другие, которые обычному контейнеризованному приложению попросту не нужны. Флаг --security-opt seccomp=unconfined снимает эту защиту целиком и оправдан только для отладки или для процессов, которым syscall из этого списка реально нужен (например, некоторым контейнерам с трассировкой производительности). О том, какие именно вызовы фильтруются и что при этом может сломаться в легитимном софте, — в статье про seccomp.
Включайте AppArmor или SELinux, если дистрибутив их поддерживает. Docker на Ubuntu/Debian по умолчанию применяет профиль docker-default AppArmor, ограничивающий доступ к файлам хоста дополнительно к namespace-изоляции. Проверить, применяется ли профиль к конкретному контейнеру:
docker inspect --format '{{.AppArmorProfile}}' <container>
Пустое значение или unconfined означает, что этот дополнительный слой отключён.
Не монтируйте docker.sock, если без этого можно обойтись. Если инструменту действительно нужно управлять контейнерами (CI-раннер, оркестратор), рассмотрите socket-прокси с ограниченным набором разрешённых API-эндпоинтов вместо прямого доступа к сокету демона.
Используйте user namespace remapping, если это применимо к вашей нагрузке — тогда root внутри контейнера (UID 0) отображается на непривилегированный UID на хосте, и даже удавшийся побег с правами «root контейнера» не даёт root на хосте. Это не панацея и совместимо не со всеми сценариями (некоторые volume-монтирования усложняются), но для большинства stateless-сервисов работает без проблем.
Регулярный аудит: как находить опасные контейнеры до инцидента
Разовая настройка ничего не гарантирует — конфигурация дрейфует: кто-то добавил --privileged для отладки и забыл убрать, новый образ из чужого docker-compose принёс с собой CAP_SYS_ADMIN по умолчанию. Разумная практика — регулярно (например, раз в неделю через cron или в рамках CI) прогонять простую сверку всех работающих контейнеров:
#!/bin/bash
# audit-containers.sh — быстрая проверка опасных настроек
for c in $(docker ps -q); do
name=$(docker inspect --format '{{.Name}}' "$c")
priv=$(docker inspect --format '{{.HostConfig.Privileged}}' "$c")
caps=$(docker inspect --format '{{.HostConfig.CapAdd}}' "$c")
mounts=$(docker inspect --format '{{range .Mounts}}{{.Source}} {{end}}' "$c")
echo "$name | privileged=$priv | cap_add=$caps"
echo "$mounts" | grep -q "docker.sock" && echo " !! docker.sock смонтирован внутрь"
done
Что дополнительно стоит проверять периодически:
- Контейнеры без ограничений по памяти и CPU — не признак побега напрямую, но признак того, что конфигурация вообще не проверялась после деплоя.
- Образы из непроверенных источников с root-пользователем внутри по умолчанию — большинство официальных образов сегодня можно запускать с
--user, переопределяя пользователя на непривилегированного. - Расхождение между тем, что задокументировано в docker-compose.yml, и тем, что реально запущено — если кто-то менял флаги вручную через
docker runв обход файла конфигурации, аудит-скрипт это покажет, а git-история — нет.
Для более систематического подхода есть готовые инструменты класса CIS-бенчмарков для Docker, но даже простой скрипт выше по расписанию с алертом при privileged=true на проде закрывает большую часть практического риска — дешевле, чем разворачивание полноценной security-платформы.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Если контейнер скомпрометирован, но не privileged и без лишних capabilities — можно расслабиться?
Нет, но риск заметно ниже. Остаются менее очевидные векторы — уязвимости ядра, ошибки в runc/containerd, неудачные комбинации монтирований. Базовая гигиена снижает вероятность на порядки, но не сводит её к нулю.
Как быстро проверить один контейнер на privileged и capabilities?
docker inspect --format '{{.HostConfig.Privileged}} {{.HostConfig.CapAdd}} {{.HostConfig.CapDrop}}' <container> — все три параметра одной командой.
Auditd сильно нагружает сервер?
При аккуратно подобранном наборе правил (конкретные файлы и пути, а не все syscall подряд) нагрузка небольшая — заметна только при очень высокой частоте файловых операций.
User namespace remapping ломает volume-монтирования?
Может — если контейнер пишет в volume с конкретным UID/GID хоста (частый случай для БД с bind-mount), владелец файлов внутри и снаружи начинает расходиться, и нужно либо настраивать сопоставление вручную, либо переходить на именованные Docker volumes.
Что делать, если признаки побега уже найдены?
Изолировать хост от сети, не перезагружать (потеряете состояние процессов и памяти для форензики), снять образ диска и действовать по заранее готовому плану — если такого плана нет, стоит прочитать план на случай взлома сервера.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →