Контейнер скомпрометировали: что злоумышленник видит дальше
Уязвимость в приложении отработала, шелл внутри контейнера получен — и дальше начинается вопрос, на который у большинства команд нет чёткого ответа. Контейнер — это не отдельная виртуалка и не «песочница» в бытовом смысле: реальные границы того, что атакующий увидит и куда сможет пойти дальше, целиком зависят от того, как контейнер был запущен. Разберём по пунктам, что видно типично, а что зависит от конкретных флагов и монтирований.
Содержание
- Первые минуты после взлома: что видно из шелла в контейнере
- Сеть: кто ещё виден в той же docker-сети
- Volumes и bind mounts: путь на хост
- Привилегированный режим и capabilities: где проходит настоящая граница
- Что не видно атакующему при правильной изоляции
- Принцип наименьших привилегий: практический чеклист
Первые минуты после взлома: что видно из шелла в контейнере
Первое, что делает атакующий, получив исполнение кода внутри контейнера — осматривается. Это дёшево и почти всегда доступно независимо от настроек изоляции:
cat /proc/1/cgroup # подтверждает, что это контейнер
ls -la /.dockerenv # ещё один маркер
ps aux # процессы — обычно видны только свои
env # переменные окружения — частый источник секретов
cat /etc/os-release
mount # список точек монтирования
ps aux в контейнере с изоляцией PID-namespace по умолчанию покажет только процессы самого контейнера — это работает "из коробки" в Docker и не требует отдельной настройки. Гораздо интереснее переменные окружения: пароли к БД, API-ключи, токены доступа к облаку часто передаются через environment: в docker-compose.yml, и любой процесс внутри контейнера читает их без ограничений. Если секреты нужны только на старте, правильнее передавать их через Docker secrets или volume с файлом, который читается один раз и не остаётся в env.
Отдельная находка — файлы приложения: конфиги, .env, иногда случайно скопированный приватный ключ или .git с историей коммитов внутри образа. Это не про изоляцию Docker, а про гигиену сборки образа — но именно так чаще всего расширяется первоначальный плацдарм.
Сеть: кто ещё виден в той же docker-сети
Вот где начинаются реальные различия в зависимости от конфигурации. Контейнеры, подключённые к одной пользовательской (user-defined) bridge-сети, видят друг друга по имени благодаря встроенному DNS Docker:
getent hosts db
curl http://db:5432
nc -zv redis 6379
Если приложение и база данных сидят в одном docker network (а в типичном docker-compose.yml это ровно так — все сервисы попадают в одну сеть проекта по умолчанию), скомпрометированный контейнер приложения может достучаться до базы напрямую, минуя внешний firewall. Это нормальная, ожидаемая работа Docker-сети — но именно поэтому нельзя рассчитывать, что "база не торчит наружу" эквивалентно "база защищена от атакующего внутри инфраструктуры".
Проверить топологию можно снаружи:
docker network inspect имя_сети
docker network ls
Если сеть не указана явно и используется старый default bridge (docker0), DNS-резолвинга по именам не будет, но связность по IP между контейнерами остаётся — и её можно ограничить параметром --icc=false в конфигурации демона, хотя на практике почти все продакшен-стенды используют user-defined сети, где ICC включён всегда.
Что действительно ограничивает дальнейшее движение — сегментация сетей на уровне compose-файла:
networks:
frontend:
backend:
internal: true
services:
app:
networks: [frontend, backend]
db:
networks: [backend]
worker:
networks: [backend]
internal: true убирает у сети выход в интернет вообще — если контейнер db скомпрометирован через app, у него не будет прямого канала наружу для эксфильтрации данных или скачивания второго стейджа. Подробнее о видах сетей и их поведении — в статье про типы docker-сетей bridge, host и overlay.
Отдельно: контейнер с network_mode: host не имеет собственного сетевого namespace вообще — он видит все интерфейсы и порты хоста напрямую, как обычный процесс. Это резко расширяет то, что видно атакующему, и обычно не нужно 95% сервисов.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверVolumes и bind mounts: путь на хост
Смонтированный volume — самый прямой путь наружу из контейнера, и разница между "безопасно" и "root на хосте" здесь измеряется одной строчкой в compose-файле.
Именованный volume (app_data:/data) — это управляемое Docker хранилище, изолированное от остальной файловой системы хоста; посмотреть его содержимое напрямую из контейнера-жертвы нельзя, если явно не подключить тот же volume к другому сервису.
Bind mount — это прямая проекция пути с хоста внутрь контейнера, и её опасность зависит от того, что именно смонтировано:
| Монтирование | Что получает атакующий |
|---|---|
./app:/app (код приложения, ro) | Чтение исходников, не более |
./uploads:/data/uploads (rw) | Запись файлов на хост в этой директории |
/etc:/host-etc (по ошибке, rw) | Изменение конфигов хоста, потенциально /etc/passwd, cron |
/var/run/docker.sock:/var/run/docker.sock | Полный контроль над Docker-демоном — фактически root на хосте |
Последняя строка — не гипотеза, а рабочий сценарий, который встречается в реальных compose-файлах (Portainer, CI-раннеры, watchtower-подобные утилиты часто просят именно этот сокет). Если сокет доступен внутри скомпрометированного контейнера, атакующий может запустить новый контейнер с любыми правами:
curl --unix-socket /var/run/docker.sock -X POST \
-H "Content-Type: application/json" \
-d '{"Image":"alpine","Cmd":["chroot","/host","sh"],
"HostConfig":{"Binds":["/:/host"],"Privileged":true}}' \
http:/v1.43/containers/create
Дальше — docker start на созданный контейнер, и внутри него полноценный chroot в корень хоста. Это классический контейнерный побег не через уязвимость ядра, а через штатный API Docker, которому по ошибке дали доступ. Если сокет нужен инструменту не для полного управления, а для чтения статуса — ставьте прокси уровня tecnativa/docker-socket-proxy, который отдаёт только разрешённые эндпоинты API.
О том, какие типы volumes есть и когда какой уместен, — в статье про типы docker volumes.
Привилегированный режим и capabilities: где проходит настоящая граница
--privileged — это не "чуть больше прав", а фактическое отключение почти всей контейнерной изоляции. Контейнер получает доступ ко всем устройствам хоста (/dev/*), может монтировать файловые системы, загружать модули ядра и обходить AppArmor/SELinux-профили. Из такого контейнера побег на хост — вопрос техники, а не удачи: классический приём — смонтировать раздел диска хоста через /dev/sda1, который виден напрямую.
Без --privileged Docker всё равно выдаёт контейнеру не пустой, а конкретный набор Linux capabilities по умолчанию — около полутора десятков, включая CHOWN, SETUID, NET_BIND_SERVICE. Это меньше, чем root на хосте, но не ноль. Опасные для добавления capability, которые встречаются в реальных compose-файлах "чтобы заработало":
SYS_ADMIN— де-факто почти полный root внутри namespace, открывает десятки векторов эскейпа;NET_ADMIN— перенастройка сети контейнера, включая обход правил изоляции сети;SYS_PTRACE— трассировка и инъекция в чужие процессы, включая процессы других контейнеров при общем PID-namespace;SYS_MODULE— загрузка модулей ядра, общего для хоста и всех контейнеров.
Проверить, что реально выдано работающему контейнеру:
docker inspect --format '{{.HostConfig.Privileged}}' имя_контейнера
docker inspect --format '{{.HostConfig.CapAdd}}' имя_контейнера
Разбор того, почему root внутри контейнера — не то же самое, что root на хосте, и как это меняется с capabilities, — в статье «Capabilities: почему root в контейнере — не root».
Что не видно атакующему при правильной изоляции
При стандартном запуске (без --privileged, без --net=host, без --pid=host, без лишних bind mount) реальные ограничения такие:
- PID namespace. Контейнер видит только свои процессы. Процессы хоста и других контейнеров не видны через
ps,/proc,top— сигналkillдо них тоже не долетит. - Mount namespace. Файловая система хоста вне явно смонтированных путей недоступна.
ls /внутри контейнера покажет корень образа, а не хоста. - Network namespace. Контейнер видит только свои сетевые интерфейсы и те сети Docker, к которым подключён явно. Контейнеры из других docker-сетей — недоступны, если только demon не настроен иначе.
- User namespace (при
userns-remap). Дополнительный слой: root внутри контейнера маппится на непривилегированный UID на хосте, так что даже эскейп через уязвимость ядра не даёт хостового root напрямую. На практике включают редко — усложняет volume-права — но для мультитенантных стендов это оправданная мера. - Read-only rootfs. Флаг
read_only: trueв compose делает файловую систему образа неперезаписываемой — атакующий не сможет закрепиться, подменив бинарник или добавив cron-задачу внутри контейнера. Для путей, куда приложению нужно писать, добавляются точечныеtmpfs. - seccomp / AppArmor. Docker по умолчанию включает seccomp-профиль, блокирующий порядка 40+ потенциально опасных синколлов (
mount,reboot,keyctlи другие) — это закрывает часть путей эскейпа даже без явной привилегированности.
Важно понимать: эти механизмы — namespaces, cgroups, capabilities, seccomp — не образуют одну «стену», а работают как независимые слои. Ослабление любого одного (лишний cap, --privileged, host-сеть) снимает конкретную защиту, а не «немного ослабляет всё». Хорошая ментальная модель для новых людей в команде — статья про то, что теряется на каждом уровне изоляции.
Принцип наименьших привилегий: практический чеклист
Для продакшен-стенда набор мер, которые реально закрывают перечисленные выше пути, — не абстрактная рекомендация, а конкретные строки в Dockerfile и compose:
# Dockerfile
FROM node:20-alpine
RUN addgroup -S app && adduser -S app -G app
USER app
WORKDIR /app
COPY --chown=app:app . .
CMD ["node", "server.js"]
# docker-compose.yml
services:
app:
build: .
read_only: true
tmpfs:
- /tmp
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE # только если реально нужен порт < 1024
networks:
- backend
deploy:
resources:
limits:
memory: 512m
cpus: '1.0'
networks:
backend:
internal: true
Ключевые пункты, которые стоит проверить на каждом сервисе:
- Не root внутри контейнера.
USER appв Dockerfile — базовая мера, обесценивающая часть техник эскейпа, где нужен root именно внутри namespace. Разбор типичной ошибки — в статье «Антипаттерн: всё под root». cap_drop: ALLи точечныйcap_add. Выдавать capability под конкретную задачу, а не оставлять дефолтный набор "на всякий случай".- Никогда не монтировать
docker.sockбез крайней необходимости — а если необходимость есть, ставить socket-proxy с ограниченным набором эндпоинтов. read_only: true+ точечныеtmpfsтам, где приложению реально нужна запись.- Сегментированные сети —
internal: trueдля сетей, которым не нужен выход в интернет, отдельные сети для фронта и бэкенда. - Лимиты ресурсов (
mem_limit,cpus) — ограничивают не проникновение, а ущерб: контейнер, ушедший в майнинг после компрометации, не положит соседей по хосту. - Не запускать без необходимости
--privilegedи--net=host/--pid=host— если инструмент требует это "для удобства", это повод поискать альтернативу или явно осознать риск.
Ни одна из этих мер не устраняет саму уязвимость в приложении, из-за которой произошла компрометация — они ограничивают, что атакующий делает дальше. Это и есть разница между «инцидентом в одном контейнере» и «инцидентом на всём хосте».
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Если контейнер скомпрометирован, нужно ли пересобирать весь сервер?
Не обязательно, если контейнер был запущен без --privileged, без общих с хостом namespace и без опасных bind mount — тогда ущерб реалистично ограничен этим контейнером и тем, что видно в его сети. Но если есть хоть малейшее подозрение на доступ к docker.sock или на --privileged, безопаснее считать скомпрометированным весь хост и пересобирать с нуля.
Помогает ли непривилегированный пользователь внутри контейнера, если сам Docker-демон работает от root на хосте?
Да, частично: он не защищает от эскейпа через ядро или через docker.sock, но закрывает целый класс атак, которым для работы нужен root именно внутри контейнерного namespace — например, некоторые техники записи в /proc или манипуляции с capabilities.
Chем отличается защита от эскейпа контейнера от защиты от бокового перемещения по сети?
Это разные рубежи. Namespaces, capabilities, seccomp защищают от выхода за пределы контейнера на хост. Сегментация docker-сетей и internal: true защищают от перемещения между контейнерами через сеть. Оба рубежа нужны одновременно — один без другого не даёт полной картины.
Стоит ли переходить на gVisor или Kata Containers ради изоляции?
Это осмысленно для мультитенантных сценариев или запуска недоверенного кода — они добавляют настоящую границу уровня виртуальной машины поверх контейнера. Для типичного продакшен-стека с доверенным кодом обычно достаточно правильно настроенных namespaces, capabilities и сегментации сети — прирост безопасности от рантайм-изоляции не всегда оправдывает падение производительности и сложность отладки. Сравнение подходов — в статье про gVisor и Kata Containers.
Как быстро проверить, не запущены ли уже уязвимые контейнеры на сервере?
docker ps --format '{{.Names}}' вместе с docker inspect по каждому — на Privileged: true, непустой CapAdd, NetworkMode: host и bind mount на /var/run/docker.sock или корень хоста. Это можно автоматизировать одним скриптом и гонять регулярно как часть базовой гигиены.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →