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

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

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

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

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


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

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

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

  • Docker-хост
  • Docker Daemon
  • Docker-образ
  • Runtime контейнеров


Источник.


Безопасность 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


Процессы, запущенные в контейнере на хосте также работают в контексте пользователя 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


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

Заключение


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

Комментарии (2)


  1. dsoastro
    16.04.2024 18:12
    +2

    Разве в /etc/shadow пароли хранятся в открытом виде? https://www.cyberciti.biz/faq/understanding-etcshadow-file/. Проблема на самом деле в том, что можно вписать в /etc/shadow свой хеш


  1. piton369
    16.04.2024 18:12

    Спасибо за статью! Я даже и не знал, что существуют проблемы такого типа. Правда с докером и не работал толком, но информация полезна на будущее.