Безопасность в Docker: от правильной настройки хоста до демона

tixceyzy7iepl9flterqllro3zu.png


Привет, Хабр! Меня зовут Эллада, я специалист по информационной безопасности в Selectel. Помогаю клиентам обеспечивать защиту инфраструктуры и участвую в разработке новых решений компании в сфере ИБ. И сейчас я начала больше погружаться в тему разработки и изучать лучшие практики по обеспечению безопасности приложений.

Все больше компаний используют контейнеры в разработке сервисов. Популярность технологии объяснима: с помощью контейнеров можно легко упаковать приложение вместе со всеми зависимостями в один образ. Его разработчики могут передавать между собой с уверенностью, что приложение запустится на любой платформе. Однако эта же популярность контейнеров приводит к рискам: в контейнерах широко распространена эксплуатация уязвимостей, которые во многом возникают из-за неаккуратного использования инструмента.

Сегодня сложно представить современное приложение без технологий контейнеризации. Поэтому я решила подробно изучить вопросы безопасности в этом направлении и собрала рекомендации, как лучше подойти к работе с Docker-платформой. Подробности под катом!

Дисклеймер: каждая рекомендация должна рассматриваться индивидуально, в зависимости от вашего кейса. Необязательно следовать советам «в полном объеме». Но скажу так: иногда лучше учесть больше сценариев и перестраховаться, чем закрыть глаза на, казалось бы, мелочи и потерпеть фиаско.


Ложное чувство безопасности


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

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

Безопасная конфигурация контейнеров — это набор настроек, которые позволяет минимизировать риски возникновения инцидентов. Можно выделить несколько блоков, в которых важно ее обеспечить.
  • Docker-хост
  • Docker Daemon
  • Docker-образ
  • Runtime контейнеров


278efa0fc90d9c96153b4269bbf2ee15.png


Источник.

kfx6vexfs0kke50j3sz53ddlxug.png


Безопасность Docker-хоста


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


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

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

Общие рекомендации по безопасности ОС


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

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

Используйте Thin OS, минимальный дистрибутив хостовой ОС. Рекомендуется включать только необходимые для работы контейнеров компоненты, чтобы сократить поверхность атаки на хост. Простое правило: чем меньше компонентов установлено, тем меньше уязвимостей.

Существует несколько «тонких» дистрибутивов операционных систем, специально предназначенных для запуска контейнеров. В их числе — RancherOS, Fedora CoreOS от Red Hat и Photon OS от VMware.

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

Используйте единую конфигурацию и автоматизацию развертывания. При таком подходе хост-машину можно считать неизменной (Immutable). Если компьютеру требуется обновление, то нужно не устанавливать патчи, а просто исключить его из кластера и заменить новой машиной. Неизменяемость машин упрощает выявление вторжений, а также единовременное обновление.

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

Логиройте попытки входа в систему на уровне хоста. К этим данным можно обратиться при анализе атак.

Для более подробного изучения рекомендую послушать доклад о безопасности ядра Linux и изучить текст с 20 советами по тому, как надежно защитить ОС.


Аудит системы


Стоить уделить особое внимание аудиту хост-системы с помощью, например, инструмента Lynis и Docker Bench for Security — утилиты для автотестов Docker-систем на CIS Docker Benchmark. Эти инструменты позволят найти слабые места в конфигурации и получить рекомендации по их исправлению.

Безопасная конфигурация Docker Daemon


Итак, мы установили Docker на нашей хост-машине, запустили контейнеры и постарались соблюсти базовые рекомендации. Что дальше?

Контролируйте доступ пользователей к Docker


Вероятно, вы уже заметили, что не каждый пользователь системы может запустить контейнер и даже выполнить команду docker ps. Скорее всего, гайд, на который вы ориентировались при установке Docker, предлагал вам создать отдельного sudo-пользователя и добавить его в специальную группу docker.

dockerenjoyer@ubuntu$ docker ps
permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock: Get "http://%2Fvar%2Frun%2Fdocker.sock/v1.24/containers/json": dial unix /var/run/docker.sock: connect: permission denied


Это связано с тем, что Docker-клиент не имеет доступа к Docker Daemon. Права на сокет /var/run/docker.sock — о нем поговорим подробнее ниже — имеют только пользователи с допуском администратора (root) и те, кто входит в группу docker.

dockerenjoyer@ubuntu$ ls -la /var/run/docker.sock
srw-rw---- 1 root docker 0 Feb 12 22:10 /var/run/docker.sock


Ограничение числа пользователей, имеющих доступ к Docker Daemon, — важная часть процесса повышения безопасности. Удалите лишних пользователей из группы docker и следите за тем, чтобы никто «просто так» в ней не появлялся.

Не предоставляйте доступ к сокету демона Docker


Как уже было сказано выше, каждый Docker-клиент, в том числе docker cli, обращается к Docker Daemon — для этого используется сокет var/run/docker.sock. При вызове команд docker ps, docker build и прочих клиент отправляет HTTP-запрос демону Docker, который, по сути, выполняет всю работу.

Самое главное: любой, у кого есть доступ к сокету, может отправлять инструкции Docker Daemon и имеет полный контроль над ним, контейнерами и другими объектами. Демон выполняется от имени суперпользователя и легко может собрать или запустить любое приложение. Следовательно, доступ к сокету Docker по своей сущности эквивалентен доступу с полномочиями sudo-пользователя на хосте.

Попробуем получить список всех контейнеров на хосте напрямую через сокет:

# curl -s --unix-socket /var/run/docker.sock http://localhost/containers/json | jq .
[
  {
        "Id": "bdeee2239e44b563939d7122ee3f73c0b27923de53bb212076ad62471b3b2098",
        "Names": [
          "/thirsty_carson"
        ],
        "Image": "wordpress",
        "ImageID": "sha256:7d59b122c499df4a2e6e428430035c84b95f16e5a5d3732be59676c494512b48",
        "Command": "docker-entrypoint.sh apache2-foreground",
        "Created": 1707901415,
        "Ports": [
          {
            "PrivatePort": 80,
            "Type": "tcp"
          }
        ],
        "Labels": {},
        "State": "running",
        "Status": "Up 2 weeks",
        "HostConfig": {
          "NetworkMode": "default"
        },
        "NetworkSettings": {
          "Networks": {
            "bridge": {
              "IPAMConfig": null,
              "Links": null,
              "Aliases": null,
              "MacAddress": "02:42:ac:11:00:02",
              "NetworkID": "6d42ab2ce634d124f93a1f6619e43344c3dfd71a854e4a6217e2475ad7792e8c",
              "EndpointID": "30fa0322f2d44654653267f5debfbd812a4a377a9b6a267bb337cfcb1857f703",
              "Gateway": "172.17.0.1",
              "IPAddress": "172.17.0.2",
              "IPPrefixLen": 16,
              "IPv6Gateway": "",
              "GlobalIPv6Address": "",
              "GlobalIPv6PrefixLen": 0,
              "DriverOpts": null,
              "DNSNames": null
            }
          }
        },
        "Mounts": [
          {
            "Type": "volume",
            "Name": "0531a7aa47561b8ab5f123d8e98af801d8d90f42f6896cb208ae6319c1ca4c8a",
            "Source": "",
            "Destination": "/var/www/html",
            "Driver": "local",
            "Mode": "",
            "RW": true,
            "Propagation": ""
          }
        ]
  }
]


Важно отметить, что так мы можем не только читать, но изменять существующие контейнеры, запускать новые и даже влиять на их состояние.

Попробуем остановить этот запущенный контейнер:

# docker ps
CONTAINER ID   IMAGE           COMMAND                      CREATED           STATUS           PORTS         NAMES
bdeee2239e44   wordpress   "docker-entrypoint.s…"   2 weeks ago   Up 2 weeks   80/tcp        thirsty_carson
# curl --unix-socket /var/run/docker.sock -XPOST http://localhost/containers/bdeee2239e44b563939d7122ee3f73c0b27923de53bb212076ad62471b3b2098/stop
# docker ps
CONTAINER ID   IMAGE         COMMAND   CREATED   STATUS        PORTS         NAMES


Возможно, здесь у вас возникнет вопрос: если у нас уже есть доступ к хосту, то в чем опасность доступа к сокету? Постараюсь показать на примере.

Возможна ситуация, когда внутри контейнера нужны права для работы с сокетом. И тогда делают то, что категорически делать нельзя: запускают новый контейнер и монтируют в него сокет Docker с хоста. Таким образом, позволяют через контейнер управлять самим демоном.

#Так делать нельзя!
docker run -it -v /var/run/docker.sock:/var/run/docker.sock myapp


Никогда не пробрасывайте сокет внутрь контейнера. Иначе у него появится возможность выполнять команды Docker и, как следствие, контролировать хост. Такая дыра в системе безопасности — просто праздник для злоумышленника. Ведь он может этим воспользоваться и получить удаленный доступ к shell контейнера.

# Запустили основной контейнер и установили Docker внутри
#docker run -it -v /var/run/docker.sock:/var/run/docker.sock --rm wordpress bash
root@e0d602c19573:/var/www/html# apt-get update > /dev/null
root@e0d602c19573:/var/www/html# apt-get install -y curl > /dev/null
root@e0d602c19573:/var/www/html# curl -fsSL https://get.docker.com -o install-docker.sh
root@e0d602c19573:/var/www/html# sh install-docker.sh > /dev/null 2>&1
root@e0d602c19573:/var/www/html# docker -v
Docker version 25.0.3, build 4debf41

root@e0d602c19573:/var/www/html# docker ps
CONTAINER ID   IMAGE           COMMAND                      CREATED             STATUS             PORTS         NAMES
e0d602c19573   wordpress   "docker-entrypoint.s…"   2 minutes ago   Up 2 minutes   80/tcp        condescending_germain
# Запустили новый контейнер внутри основного, открыли оболочку bash и смонтировали всю файловую систему хоста в /mnt
root@e0d602c19573:/var/www/html# docker run -it -v /:/mnt ubuntu:22.04 bash
Unable to find image 'ubuntu:22.04' locally
22.04: Pulling from library/ubuntu
01007420e9b0: Pull complete
Digest: sha256:f9d633ff6640178c2d0525017174a688e2c1aef28f0a0130b26bd5554491f0da
Status: Downloaded newer image for ubuntu:22.04

root@90d8958c729d:/# ls /mnt
bdist.linux-x86_64  boot  etc   lib        lib64   lost+found  mnt  proc  run   snap  sys  usr
bin                     dev   home  lib32  libx32  media           opt  root  sbin  srv   tmp  var

# Меняем основной каталог контейнера на /mnt
root@90d8958c729d:/# chroot /mnt
# Находим расшифрованный пароль пользователя
# grep dockerenjoyer /etc/shadow
dockerenjoyer:9cQoPO5M2VNT:19785:0:99999:7:::


Так, получив доступ к уязвимому контейнеру, мы добрались до файловой системы хоста и смогли прочитать пароль в расшифрованном виде. Кажется, отличный пример дыры в безопасности.

Еще раз: никогда не пробрасывайте сокет в контейнеры! Обязательно найдется человек, который воспользуется этим и получит доступ ко всей системе.

Подробнее про сокеты можно прочитать в официальной документации.

Риски CI/CD и проблема демона Docker


Далеко не уходя от темы сокетов, отмечу, что сокет Docker очень часто монтируют в инструментах CI/CD — например, Jenkins и Gitlab-CI — для отправки инструкций по сборке образов как части пайплайна.

Например, разработчикам необходимо использовать Docker executor для отправки команд по сборке. Но тогда в контейнер, внутри которого крутится джоба, монтируется Docker-сокет. Это позволит злоумышленникам делать docker exec или docker cp, чтобы воровать секреты и подменять артефакты, а также запускать привилегированные контейнеры и «выбираться» на хост.

Хорошая практика в CI/CD, особенно в enterprise, — не использовать Docker. Одна из важных его проблем в безопасности — это объединение в себе двух абсолютно разных функционалов: сборки образов и управления рантаймом контейнеров.

То есть нам нужна машина, на которой мы хотим только собирать и сохранять в реестре образы. А с Docker Daemon наши возможности выходят далеко за эти пределы. И тогда злоумышленник, добравшись до наших несчастных раннеров, сможет делать и build, и run и прочее.

Чтобы избежать рисков и дыр в безопасности, лучше воспользоваться одной из альтернативных утилит для сборки образов контейнеров, не полагающихся на Docker Daemon. Например, инструментами, которые предназначены только для сборки. Среди них — BuildKit, kaniko и buildah и другие решения, которые работают без использования полномочий root-пользователя Именно такой подход желательно использовать в CI/CD вместо Docker.

Добавлю, что уже с 23.0 версии Docker BuildKit был встроен в билдер вместо устаревшего.

Ограничивайте риск эскалации привилегий


Docker Daemon может по умолчанию запускать контейнеры от имени суперпользователя, так как только у него есть достаточно привилегий для создания пространства имен. Но стартовать контейнер могут и пользователи из группы docker, у которых есть права на отправку команд через сокеты демону.

Любой член данной группы может запускать контейнеры. А если смонтировать корневой каталог хоста с помощью команды docker run -v /:/host <образ>, то получим полный доступ к корневой файловой системе хоста.

Более того, Docker запускает контейнеры от root-пользователя, даже если не указать этого явно. Сочетание этих факторов предоставляет нам практически неограниченный доступ на хосте.

Лучший способ предотвратить атаки с эскалацией привилегий из контейнера — настроить запуск контейнера от имени непривилегированных пользователей. Как это сделать — рассмотрим в следующем разделе. А сейчас поговорим подробнее про ограничение «видимых» контейнеру ресурсов — пространств имен пользователей в Docker.

Напомню, что основой контейнеризации являются Linux namespace, которые позволяют изолировать и разделять системные ресурсы для процессов, тем самым — эффективно защитить хост от потенциально вредного влияния приложений, запущенных в контейнерах. Каждое пространство имен функционирует как независимый слой, ограничивая видимость и доступ к ресурсам системы для процессов.


Иногда все же могут быть причины, когда нужно «выполнять» контейнеры от root. Но это не значит, что нужно забывать о рисках под предлогом «исключения из правил». Мы можем ограничить неймспейс с помощью переназначения (re-map) root на менее привилегированного пользователя на хосте.

Mapped пользователю присваивается ряд UID, которые функционируют в пространстве имен как обычные UID от 0 до 65536, но не имеют привилегий на самом хосте. Для этого у Docker есть параметр userns-remap, который по умолчанию отключен. Для большего понимания покажу на примере.

1. Запустим контейнер и выполним команду, чтобы посмотреть список запущенных процессов:

dockerenjoyer@ubuntu:~$  docker run -itd --name ubuntu1 ubuntu:22.04
dockerenjoyer@ubuntu:~$ docker exec -it ubuntu1 bash
root@93eb3b2d27d8:/# ps -u
USER             PID %CPU %MEM        VSZ   RSS TTY          STAT START   TIME COMMAND
root               1  0.0  0.1   4628  3804 pts/0        Ss+  10:36   0:00 /bin/bash
root              16  0.0  0.1   4628  3840 pts/1        Ss   10:38   0:00 bash
root              23  0.0  0.0   7064  1560 pts/1        R+   10:38   0:00 ps -u


Как можно увидеть, процессы, запущенные в контейнере Docker, работают в контексте пользователя root.

2. Теперь проверим, как процессы в контейнере сопоставляются с процессами на хосте:

dockerenjoyer@ubuntu:~$ docker container top ubuntu1
UID                     PID                     PPID                    C                       STIME                   TTY                     TIME                    CMD
root                    389168                  389145                  0                       10:36                   pts/0                   00:00:00                /bin/bash


f6a2040337621a0c91a93c59d5a77aa1.png


Процессы, запущенные в контейнере на хосте также работают в контексте пользователя root. Это позволяет злоумышленнику, «сбежавшему» из контейнера, получить root-доступ на хосте. Минимизировать этот риск можно с remapping.

3. В файле /etc/docker/daemon.json (если его нет, то создайте) укажем параметр userns-remap:

{
  "userns-remap": "default"
}


После установки userns-remap в значение default и перезапуска Docker система автоматически создаст пользователя с именем dockremap. Контейнеры будут запускаться в его контексте, а не от имени пользователя root.

4. Убедимся, что пользователь действительно был создан:

dockerenjoyer@ubuntu:~$ id dockremap
uid=111(dockremap) gid=119(dockremap) groups=119(dockremap)
dockerenjoyer@ubuntu:~$ cat /etc/subuid
dockerenjoyer:100000:65536
dockremap:165536:65536


Файл /etc/subuid говорит нам, какой подчиненный UID будет назначен в пространстве имен, где уникальное значение 165536 будет соответствовать UID 0 (root) в контейнере, 165537 — UID 1, 165538 — UID 2 и так далее.

5. Теперь повторим запуск контейнера:

dockerenjoyer@ubuntu:~$docker run -itd --name ubuntu1 ubuntu:22.04
dockerenjoyer@ubuntu:~$docker exec -it ubuntu1 bash
root@98cdca1cd725:/# ps -u
USER             PID %CPU %MEM        VSZ   RSS TTY          STAT START   TIME COMMAND
root               1  0.0  0.1   4628  3700 pts/0        Ss+  10:53   0:00 /bin/bash
root               8  0.0  0.1   4628  3768 pts/1        Ss   10:54   0:00 bash
root              16  0.0  0.0   7064  1608 pts/1        R+   10:54   0:00 ps -u

root@98cdca1cd725:/# exit
exit
dockerenjoyer@ubuntu:~$ docker container top ubuntu1
UID                     PID                     PPID                    C                       STIME                   TTY                     TIME                    CMD
165536                  389598                  389575                  0                       10:53                   pts/0                   00:00:00                /bin/bash


d121888773f392a4c3278690a952afe7.png


Может показаться, что ничего не изменилось, однако значительные изменения произошли после выполнения команды docker container top ubuntu1. Мы видим, что теперь, после внесенных изменений, процесс контейнера запущен на хосте в контексте недавно созданного непривилегированного пользователя dockeremap. Такая конфигурация значительно ограничивает возможность повышения привилегий в системе хоста.

Заключение


В рамках этой статьи мы обсудили не все аспекты. На очереди — безопасная сборка Docker-образов. Тема объемная, поэтому подробности обсудим в следующем материале.

© Habrahabr.ru