MAATRIX / Блог / Docker-сокет в контейнере: почему это равно root на хосте

Docker-сокет в контейнере: почему это равно root на хосте

MAATRIX

Рано или поздно в любом проекте с Docker всплывает одна и та же строчка: -v /var/run/docker.sock:/var/run/docker.sock. Её добавляют почти не задумываясь — Portainer просит, CI-раннеру нужно, мониторингу нужно, «ну это же просто файл». На деле эта одна строка отменяет всю изоляцию контейнера и отдаёт тому, кто получил доступ к нему, полные права root на хосте. Разберём, почему это так, когда сокет действительно нужен и как получить нужную функциональность без раздачи ключей от всего сервера.

Что на самом деле открывает доступ к сокету

/var/run/docker.sock — это unix-сокет, через который Docker CLI и любой другой клиент говорят с демоном dockerd. Демон почти всегда работает от root (если не настроен rootless-режим, о котором ниже) и имеет полный доступ к файловой системе хоста, сетевому стеку, ядру. Любой, кто может писать в этот сокет, получает доступ к REST API Docker — тому самому, которым пользуется команда docker на вашей рабочей машине.

Ключевой момент: API не различает «доверенных» и «недоверенных» клиентов. Если у процесса есть доступ к сокету, он может запросить у демона что угодно — в том числе создание нового контейнера с произвольными параметрами. А среди этих параметров есть, например:

  • монтирование любого пути хоста внутрь нового контейнера (-v /:/hostroot);
  • флаг --privileged, снимающий почти все ограничения по capabilities и seccomp;
  • запуск от --user 0 независимо от того, от какого пользователя работает исходный контейнер;
  • доступ к сетевому namespace хоста (--network host), к PID-namespace хоста (--pid host).

Иными словами, контейнер с доступом к сокету не обязан ничего «взламывать» — он просто вежливо просит демон (который и так root) сделать за него грязную работу. Это принципиально отличается от эскалации через уязвимость ядра: здесь используется штатный, документированный API. Разница между «процесс внутри контейнера имеет UID 0» и «процесс имеет доступ к Docker-сокету» разобрана в статье про capabilities и root в контейнере — сокет обнуляет все те ограничения одним запросом.

От сокета до root на хосте: пошаговая демонстрация

Вот как это выглядит на практике — ровно то, что смог бы сделать любой процесс, скомпрометированный внутри контейнера с примонтированным сокетом. Предположим, у нас есть контейнер, где смонтирован сокет и установлен Docker CLI:

docker run -it --rm -v /var/run/docker.sock:/var/run/docker.sock docker:cli sh

Внутри этого контейнера, даже если сам он запущен без --privileged и от непривилегированного пользователя, можно попросить демон на хосте создать новый, уже привилегированный контейнер с корнем хоста внутри:

docker run -it --rm -v /:/hostroot --privileged alpine chroot /hostroot sh

После этой команды вы получаете интерактивный shell, в котором / — это корень файловой системы хоста, а не контейнера. Отсюда можно читать /etc/shadow, менять /etc/passwd, ставить cron-задачи, читать SSH-ключи любого пользователя сервера, редактировать systemd-юниты — всё, что доступно root на хосте. Никакого exploit'а, никакой уязвимости — только штатный вызов Docker API, для которого сокет и создан.

Важно понимать: это работает даже без флага --privileged у второго контейнера, если задача — просто прочитать файлы хоста (chroot в смонтированный / уже достаточно для чтения/записи большинства данных). --privileged нужен, только если атакующему требуется что-то более специфичное — например, доступ к устройствам или изменение сетевых настроек хоста напрямую из процесса контейнера.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Зачем сокет монтируют на практике — и где граница «нужно» и «удобно»

Причины, по которым сокет оказывается в контейнере, почти всегда рациональны на первый взгляд:

СценарийЗачем нужен сокетНасколько оправдано
CI-раннер (GitLab CI, Jenkins agent)Сборка Docker-образов внутри job'а, запуск сервисов для интеграционных тестовЧасто можно заменить безопасной альтернативой (см. ниже)
Мониторинг (cAdvisor, Portainer, Netdata)Чтение метрик контейнеров, списка образов, логовОбычно достаточно read-only доступа к части API
Watchtower и авто-обновление образовПересоздание контейнеров при выходе новой версии образаРеальная необходимость в записи, но круг операций узкий
«Docker-in-Docker» для локальной разработкиЗапуск docker-compose внутри dev-контейнераЧаще решается монтированием сокета с хоста разработчика, а не в проде

Проблема не в том, что все эти сценарии незаконны — проблема в том, что монтирование сокета даёт куда больше прав, чем требуется задаче. Мониторингу не нужно право создавать привилегированные контейнеры с root хоста, ему нужно читать список процессов и статистику. CI-раннеру для сборки образа не нужен полный доступ к демону хоста — ему нужно собрать слои и получить готовый образ.

Отдельно стоит сценарий с общими (shared) CI-раннерами, где на одном сервере выполняются джобы разных проектов или разных команд. Если хотя бы один pipeline имеет доступ к сокету, любой, кто может модифицировать .gitlab-ci.yml или Jenkinsfile в любом из проектов этого раннера, фактически получает root на сервере раннера. Это уже не гипотетический риск, а стандартная модель угроз для мультитенантных CI-систем.

Docker-in-Docker без сокета: сборка образов иначе

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

Kaniko. Инструмент от Google, который собирает образ по Dockerfile полностью в userspace, без демона Docker и без привилегированного режима. Запускается как обычный под/контейнер:

docker run --rm -v $(pwd):/workspace \
  gcr.io/kaniko-project/executor:latest \
  --dockerfile=/workspace/Dockerfile \
  --context=/workspace \
  --destination=registry.example.com/app:latest

Kaniko не нуждается ни в сокете, ни в --privileged — он читает слои существующего образа и собирает новые сам, вручную работая с файловой системой внутри своего собственного контейнера.

Buildah / Podman build. Похожий подход: демона нет вообще, сборка идёт как обычный процесс. Buildah можно запускать rootless, что закрывает вопрос эскалации привилегий даже при компрометации самого процесса сборки. Сравнение подходов есть в статье Docker vs Podman: что выбрать.

BuildKit как отдельный сервис. Если вы хотите остаться в экосистеме Docker, можно поднять buildkitd отдельным сервисом с ограниченным доступом (например, через TCP с TLS-аутентификацией и отдельными правами), а не через хостовый сокет демона общего назначения. Это сужает поверхность атаки до одного узкоспециализированного API вместо полного Docker API.

Sysbox runtime. Отдельная история — когда вам действительно нужен полноценный Docker-in-Docker (например, для тестирования самого Docker или для сложных интеграционных сценариев). Runtime вроде Sysbox позволяет запускать вложенный демон Docker внутри контейнера с настоящей изоляцией через вложенные user namespaces — вложенный root не совпадает с root хоста. Это тяжелее в настройке, чем монтирование сокета, но закрывает именно ту дыру, которую монтирование открывает.

Rootless Docker: как работает и что ограничивает

Начиная с версии 20.10 в Docker есть официальный rootless-режим — демон запускается от обычного пользователя, а не от root, используя user namespaces для ремаппинга UID. Установка:

curl -fsSL https://get.docker.com/rootless | sh
export PATH=$HOME/bin:$PATH
export DOCKER_HOST=unix://$XDG_RUNTIME_DIR/docker.sock
dockerd-rootless-setuptool.sh install

Ключевая разница с точки зрения нашей темы: даже если кто-то получит доступ к сокету rootless-демона, он получит права того непривилегированного пользователя, от которого запущен демон, — не root на хосте. Это не устраняет проблему полностью (доступ к сокету всё ещё даёт возможность создавать контейнеры и делать многое от имени этого пользователя), но радикально снижает ущерб: злоумышленник не получает root, не может читать чужие файлы, не может трогать системные сервисы.

Ограничения, о которых стоит знать заранее:

  • Привилегированные порты (< 1024) требуют дополнительной настройки — setcap на бинарник rootlesskit или проброс через slirp4netns/docker-proxy.
  • Некоторые сетевые режимы (в частности --network host в чистом виде) работают иначе или требуют дополнительных пакетов вроде slirp4netns / VPNKit.
  • Storage-драйвер зависит от возможностей ядра и файловой системы — где-то overlay2 работает из коробки, где-то нужен fuse-overlayfs.
  • Производительность сети через slirp4netns может быть заметно ниже, чем у обычного bridge-режима — для высоконагруженных сетевых сервисов это стоит проверить заранее, а не постфактум.

Rootless — не панацея и не всегда тривиально встаёт поверх уже работающей инфраструктуры, но для новых серверов, где безопасность важнее удобства «как было раньше», это разумный дефолт.

Прокси-обёртка вместо прямого доступа: docker-socket-proxy

Когда контейнеру реально нужен именно доступ к Docker API хоста (типичный случай — Traefik или Watchtower, которым нужно слушать события о контейнерах), правильный паттерн — не давать сокет напрямую, а поставить между контейнером и сокетом прокси, который пропускает только разрешённые эндпоинты.

Популярный вариант — образ tecnativa/docker-socket-proxy. Он сам получает сокет (только он один), а остальным контейнерам отдаёт HTTP API с белым списком разрешённых операций:

services:
  docker-socket-proxy:
    image: tecnativa/docker-socket-proxy
    environment:
      CONTAINERS: 1   # разрешить чтение списка контейнеров
      EVENTS: 1        # разрешить подписку на события
      POST: 0          # запретить любые операции создания/изменения
      NETWORKS: 0
      VOLUMES: 0
      EXEC: 0          # запретить docker exec — частый вектор побега
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
    networks:
      - proxy-internal

  traefik:
    image: traefik:latest
    environment:
      - DOCKER_HOST=tcp://docker-socket-proxy:2375
    networks:
      - proxy-internal
    # реальный docker.sock сюда НЕ монтируется

Теперь Traefik может читать список контейнеров и подписываться на события (что ему и нужно для авто-обнаружения роутов), но не может создать новый контейнер, смонтировать хостовый путь или выполнить exec внутри чужого контейнера. Если Traefik будет скомпрометирован через уязвимость в самом приложении, атакующий упрётся в закрытые эндпоинты прокси, а не получит root хоста за один вызов API.

Тот же паттерн стоит применять для мониторинга и для CI-агентов, которым действительно нужно что-то из Docker API — определите минимальный набор операций и явно закройте всё остальное, а не открывайте всё «на всякий случай». Общий принцип разобран в статье про антипаттерн «всё под root» — сокет без ограничений это тот же самый антипаттерн, просто на уровне API вместо UID.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

А если контейнер с сокетом запущен от непривилегированного пользователя внутри — это безопаснее?

Нет, если демон Docker на хосте работает от root (обычный режим по умолчанию). UID процесса внутри контейнера не имеет значения — все запросы к сокету обрабатывает демон на хосте от своего имени, а не от имени вызывающего процесса.

Read-only монтирование сокета (:ro) решает проблему?

Частично, но не полностью. Флаг :ro относится к файловой системе — он не даёт создать новый unix-сокет поверх старого, но не ограничивает, какие запросы можно отправить через уже существующий сокет. GET-запросы (чтение списка контейнеров, логов) и POST-запросы (создание контейнеров) идут через один и тот же файл сокета — :ro их не различает.

Достаточно ли AppArmor/SELinux, чтобы закрыть эту дыру?

Обычные профили AppArmor для Docker-контейнеров не блокируют обращения к примонтированному сокету — это штатный файл внутри контейнера, доступ к нему не считается нарушением политики. Нужны либо отдельные точечные правила, либо, что надёжнее, вообще не монтировать сокет напрямую.

Что делать, если сторонний образ (например, готовый docker-compose из маркетплейса) требует сокет для работы?

Сначала проверьте, действительно ли функциональность, ради которой это нужно, вам критична. Если да — заворачивайте в docker-socket-proxy с максимально узким белым списком, а не монтируйте сокет как есть, даже если в официальной документации образа написано иначе.

Rootless Docker и docker-socket-proxy — это взаимоисключающие подходы?

Нет, это разные уровни защиты, которые можно комбинировать: rootless снижает ущерб от компрометации демона в целом, а прокси ограничивает конкретных клиентов сокета в рамках уже работающей инфраструктуры.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →