Привет! Меня зовут Эллада, я специалист по информационной безопасности в 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-образов. Тема объемная, поэтому подробности обсудим в следующем материале.
Автор: Эллада Нуралиева