
Почему CI/CD так важен? Он представляет собой автоматизированный конвейер сборки и доставки ПО и, если говорить в контексте безопасности, существует целый ряд критичных инцидентов безопасности, например внедрение бэкдоров в ПО, подмена артефактов (supply chain), утечка чувствительных данных клиентов, компрометация deployment environment и так далее.
А если посмотреть на проблемы в контексте разработки публичного облака?
Так, например, мультитенантность инфраструктуры приводит к экспоненциальному росту проблем, связанных с расширением экспансии злоумышленников, ведь, получив доступ к CI/CD и внедрив бэкдор в ПО, которое затем будет развёрнуто в продакшене, мы получим дыру в безопасности, причём импакт для облака усугубляется, так как полученная уязвимость:
Позволяет проводить горизонтальное перемещение (lateral movement) внутри облачной инфраструктуры.
Усиливает риски за счёт высокой связанности компонентов (общие сети, API или системы управления).
Может приводить к каскадной компрометации различных систем, поскольку облачные среды используют IAM, общие хранилища (S3, Blob Storage).
Но самое главное, что реализация атак аффектит не только конкретную компанию — провайдера облачных услуг, а всех клиентов публичного облака.
Ладно, ладно — для того, чтобы заинжектить бэкдор в ПО, нужно иметь доступ к self-hosted github/gitlab, то есть быть внутренним злоумышленником, имеющим доступ к коду, права на пуш в репозиторий, и пройти все ревью ответственных коллег — это очень сложная атака, успешность которой зависит от огромного количества факторов. Куда проще скомпрометировать внутренние режистри — например, harbor, nexus, artifactory, подменить там библиотеку или контейнер и получить такой же импакт за счёт автоматизации CI/CD и отсутствия контролей безопасности.
Примеры можно приводить долго, но мы считаем, что хорошо бы рассмотреть различные кейсы более детально, как это может быть реализовано, как этого избежать — чтобы предупредить вас о существующих рисках безопасности.
Мы — Алексей Федулаев, лид Cloud Native Security, и Андрей Моисеев, Cloud Native Security DevSecOps в MWS. И сегодня поговорим с вами об атаках на CI/CD — разберём их, начиная от misconfig и заканчивая supply chain, расскажем, как от них защищаться. Ведь если применять технологии без понимания, а особенно использовать CI/CD, не задумываясь о безопасности, можно получить ситуацию, когда всё горит, но непонятно где.

Нашли что-то знакомое? Тогда вы не зря потратите время на чтение.
Дисклеймер. Системы сборки и доставки ПО применяются практически всеми участниками разработки. Часть разработчиков может работать в формате аутсорса или аутстафа, некоторые работы отданы смежным подразделениям, иногда бывает, что разработчики меняют проекты, а есть ещё девопс-инженеры и тестировщики, которые могут иметь доступы ко множеству проектов. Такой формат накладывает определённые риски безопасности и предоставляет вектор атаки потенциальным злоумышленникам.
Для наглядности представим, что мы аутсорс-девопсы, работающие в компании или получившие доступ к аналогичной учётной записи. Будем рассматривать уязвимости с точки зрения злоумышленника, и первым делом мы захотим украсть всё, до чего дотянутся руки. В порядке простоты реализации, конечно.
Поехали ломать.
PPE
PPE (Pipeline Policy Execution) — злоупотребление правами доступа к выполнению своего кода и команд.
Как правило, при написании конвейеров используются переменные, ведь это тоже код. В этих переменных могут храниться токены доступа к сервисам, API-токены CI/CD и прочие чувствительные данные, которые могут предоставить возможность нарушителю проникнуть за пределы CI/CD. Например, часто используются GItlab Variables, чтобы не хранить ключи в самих манифестах. Мы можем использовать базовый вывод через выполняющуюся джобу, которая выведет в свой лог нужные нам значения. А затем подсунуть этот код девопсу или разработчику в компании, который его выполнит. В нашем случае мы сами его выполним, ведь мы аутсорс-девопсы.


По умолчанию ключи выводятся в b64-кодировке. Если мы размаскируем Base64, мы получим сам секрет.
Интересное замечание — сам Gitlab понимает, что это секрет, сравнивая выводимые в лог джобы символы с фактическим значением секрета, поэтому, как [MASKED], будут отображаться все включения строки с секретом в логах джобы, даже если это просто текст.
Да, это база, и никого не удивить такими атаками. Но знать и предотвращать нужно, поэтому напомним методы предотвращения такой атаки:
● изоляция окружений;
● грамотное управление правами;
● отсутствие выдачи лишних прав;
● использование Protected Branches и Protected Runners.
AbuseFlow
Эта атака заключается во влиянии на флоу работы пайплайна, а именно — мы рассмотрим возможность отключения проверок безопасности на примере сканера секретов. Есть множество других способов, и каждый из них уникален для стека компании.
У нас есть пайплайн, выполняется сборка, тестирование и развёртывание приложения. Пайплайн успешно завершается, приложение поднялось, всё работает корректно:




Но что, если кто-то случайно добавит свой ключ в код? Или забудет убрать тестовые ключи? А может, закоммитит папку, где лежат другие чувствительные данные?
Вариантов, как секреты могут попасть в систему контроля версий SCM, множество. Но проблема не столько в том, что они туда попадут, а в том, что, получив к ним доступ, можно получить доступ к тестовым стендам. Если это не так критично, можно использовать найденные секреты для дальнейшего развития атаки, например, горизонтального продвижения по сети через ssh spray.
Если секреты попадают на этап сборки, они могут проникнуть в приложение и, как следствие, в production. Это создаёт серьёзные риски для безопасности. Поэтому важно предотвращать такие ситуации на этапе сборок, а не пытаться исправлять последствия уже после того, как секреты оказались в коде.
В нашем пайплайне используется джоба secret_detection, которая с помощью gitleaks позволяет находить секреты в коде:

Таких инструментов множество, например deepsecrets, semgrep.

Однако pipeline может быть настроен некорректно: реализована определённая логика через переменные или флаги или, как в нашем случае, используется популярный gitleaks, который может задавать игнор-лист для результатов анализа. Соответственно, используя механизм .gitleaksignore, который работает схожим образом с .gitignore, мы добавим ключ или вообще всю папку в игнор-лист. Останется только подождать, пока кто-то допустит ошибку и запушит секреты в SCM.

Но что, если изменение .gitleaksignore отслеживается и разрешено только owner проекта? Как тогда обойти защиту? Как правило, в крупных командах и репозиториях управление автоматизировано и кто-то использует шаблоны. Кстати, теперь им на смену пришли components.

К примеру, есть remote-шаблон и local-шаблон, при этом локальный шаблон имеет приоритет и всё, что было в remote, можно переопределить в локальном.
Так мы можем в нашем локальном gitlab-ci.yml определить джобу secret-detection, в которой просто добавим exit 0. В этом случае при сборке общего пайплайна значение поля script будет заменено на то, которое было указано в локальном файле, так как у него выше приоритет. Джобы выполнятся успешно без ошибок или предупреждений:

Но если у фирмы есть самописный CODEOWNERS, тогда что?

Или платный GitLab, где его можно использовать по умолчанию, или грамотно выстроенный процесс code review, включая approval rules:

Или используются сторонние проверки, например пайплайн «особое мнение», доступ к которому ограничен:
Как мы тогда их похакаем? Будем делать кое-что посерьёзнее. Для воспроизведения этих атак потребуется некий prepairment steps. Так что, если кто-то хочет повторить, начнём с регистрации GitLab Runner от разных пользователей:
● с правами sudo (user with sudo);
● базовая регистрация (user gitlab-runner);
● ограниченного пользователя (noroot user).
Затем запустим простую джобу, чтобы убедиться, что всё настроено корректно:




Пользователь с правами sudo по умолчанию имеет привилегии, что открывает дополнительные возможности для атак.
Ну и что мы со всем этим будем делать? Перейдём к следующему виду атак.
PBAC
Атака PBAC связана с недостаточным управлением правами в рамках pipeline-based access control. Мы можем исследовать, что произойдёт с привилегированными раннерами, если использовать их в атаках.
Начнём с секретов, например попробуем вывести содержимое /etc/shadow:


Добавим sudo:

Мы получили доступ к /etc/shadow. Это мало что даст — просто зарегистрированный раннер с пользователем, так мы не сможем восстановить хэш. Что с этим делать?
Попробуем получить доступ к системе, например к раннеру. Если есть возможность заставить кого-то выполнить malicious code в рамках джобы, можно попробовать получить initial access к раннеру. Будем пробовать reverse shell. Это опасная, но эффективная техника, позволяющая открыть терминал и выполнять любые команды на хосте.
Напишем простой скрипт для reverse shell и отловим его на тестовой машине:


Но есть нюанс: когда джоба завершится, мы потеряем reverse shell. Если reverse shell активен, джоба будет выполняться до закрытия сессии. Это заметно, но, пока сессия открыта, у нас есть доступ.
Попробуем сделать более элегантно:
Снова откроем reverse shell.
Используем системный cron, который есть на большинстве систем.
Добавим задачу в crontab:

4. Тогда reverse shell будет открываться из cron без необходимости зависания джобы и вообще вне контекста выполнения каких-либо джоб от CI/CD, по сути мы просто оставим бэкдор через cron:


Тут тоже есть нюанс: reverse shell будет запущен от пользователя, под которым выполнялась джоба. Если бы это был root, можно было бы сразу получить full access.
Lateral movement shell
Доступа к раннеру часто недостаточно, тогда можно применить lateral movement. Это любая техника, позволяющая исследовать систему и распространяться дальше. Например, украли раннер → украли хост → украли CI/CD → GitLab теперь под нашим контролем. Запустим майнеры, и всё прекрасно.
Но что искать на раннере? Очевидно, первое, что приходит в голову, — SSH-ключи. Поищем их:

Чтобы провести атаку, мы можем попробовать исследовать раннер и собрать доступные креды:

Это могут быть ключи, которые использовались при первичной настройке системы или были оставлены для дебага на раннерах. Часто встречаются случаи, когда не использовались vaults и на системе можно найти и извлечь такие данные вплоть до ключей от staging или даже production. Имея ключ от production, можно загрузить туда любой код и извлечь все пользовательские данные.

Однако если ошибок нет, зарегистрирован дефолтный раннер, всё настроено правильно — нет sudoers, nopassword, пользователи GitLab Runner не имеют лишних привилегий, образ захарднен, права установлены корректно, используются эфемерные раннеры в Kubernetes, которые поднимаются в отдельных подах и исчезают, или вообще не используются Shell-раннеры, то задача усложняется. Но применяете ли вы такие меры?
Lateral movement docker
Следующий вариант — мы можем внедрить код напрямую в сборочную инфраструктуру, раннер или файлы на нём. Раннер — это по сути образ, который где-то собирается и куда-то складывается. А куда? Правильно — в registry. Соответственно, где-то должны быть аутентификационные данные. Попробуем их найти:


А наш токен — это b64 encoded логин и пароль:

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


Теперь нам подконтролен этот образ. Мы можем собрать нужный нам вредоносный образ, запушить его в registry и на production. Выльется наш образ, мы сможем украсть данные или сделать что-то более плохое.

Что будет, если мы не найдём кредов от Docker, от registry? Сейчас посмотрим.
Code Injection
Попробуем внедрить вредоносный код в сборочную инфраструктуру, раннер или файлы на нём.
Сборка запускается на раннере, который принимает код из git-репозитория:

В папке раннера находится всё, что есть в репозитории, и, имея доступ, мы можем заменить код:

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

Но что делать, если нет Shell-раннера? Продолжим работать с Docker. Мы смогли угнать контейнер, теперь попробуем поработать с ним напрямую.
Например, представим, что есть Docker-сборка, который использует DinD. Скорее всего, проброшен Docker-сокет, чтобы сборка работала корректно, так как сборки иногда требуют привилегий или доступа к хостовой системе:

Подробнее про побеги из контейнера можно посмотреть в этом видео.
Reverse-shell via crontab
Наша цель — доступ к хосту. Для этого мы для начала сбежим из контейнера. Одна из известных техник — использование проброшенного Docker-сокета:

Итак, ревершелл успешно открылся, и мы внутри:


Попадание на ноду, которая используется для запуска docker-раннеров, позволяет внедриться в любую сборку, которая выполняется в контейнерах. Мы находимся на хосте и можем выполнять команды и взаимодействовать с кодом: прокинуть реверс хоста дальше, добавить свои ключи, найти чужие ключи, выполнить exec в любой контейнер, подменить файлы при сборке и много чего другого.
Теперь рассмотрим ситуацию, когда на сборочный раннер попадает джоба, которая должна собрать образ. Этот образ попадает на хост, где поднимается контейнер, и в этом контейнере находится весь код:

С помощью команды docker ps мы можем получить список всех контейнеров. Например, видим контейнер build:

Каждая джоба, которая прилетает, поднимается отдельным контейнером. Будем пытаться эскейпить:

Если один способ недоступен, можно попробовать другой. Простейших способов всего шесть-семь, о сложных мы рассказывать не будем. Если у нас привилегированный контейнер, можно сбежать через блочное устройство:

Таким образом мы можем вытащить всё, что нужно:

Однако если на одной железной ноде находятся как обычные, так и protected-раннеры или если раннер может принимать обычные и protected-джобы, это не сработает. Чтобы предотвратить такие атаки, в компании должны быть отдельные раннеры для protected-веток и хосты для protected-раннеров. Тогда сбежать из них не получится, всё будет проходить через Code Review.
А если есть подпись? В лучших традициях контейнерной безопасности многие знают про SLSA, который включает подпись и другие меры. Однако если у нас есть прямой доступ к раннеру, мы можем вытащить ключ для подписи:

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

Но что, если в компании нет таких мисконфигураций и всё сделано правильно? Есть ли другой способ провести атаку? Здесь поможет кеширование — ускорение сборок.
Cache poisoning
Сборочные раннеры могут использовать кеши для ускорения работы. Мы можем попробовать атаковать кеш и отравить его.
Рассмотрим пример. Есть кеш, который используется джобой:

Эта джоба делает условную сборку. Например, просто собирает бинарь и кладёт его в кеш:

Позже этот бинарь подтягивается и используется в других джобах без необходимости его пересборки. У нас есть restore, сборка и push в кеш:

Мы можем написать джобу, и она будет подменять бинарь, который собирается. Мы пересоберём его в своей отдельной ветке или на раннере с другим тегом:

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

При перезапуске кеш уже будет отравлен и наш бинарь будет содержать вредоносный код. Таким образом, даже в мейн-ветке при переиспользовании кеша будет наш поддельный бинарь:

Управляя кешем, мы можем подделывать кеш для других джоб, подкладывая туда нужный нам инструментарий. Этот инструментарий соберётся, подпишется, пройдёт проверки и спокойно отправится туда, куда нужно.

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

Advanced flow abuse
Можно повлиять на флоу работы пайплайнов через конфигурацию самого раннера, если мы уже получили туда доступ. Это может позволить замести следы и выиграть время для развития атаки:

В этом примере мы добавляем условие, при котором будет игнорироваться весь стейдж security. А значит, будут пропущены все стадии безопасности. При этом все проверки успешно завершатся, но не будут выполнены. Так можно обойти любую защиту.
Этот подход подобен атаке для переопределения шаблонов. Только мы меняем настройки на самих раннерах, а не в пайплайне, при этом варианте уже не помогут ни Approval Rules, ни Code Review.

Избежать этого можно, используя всё тот же пайплайн «особое мнение» с отдельным раннером или прописать повторный запуск в after script:

Есть нюанс: даже если post-build скрипт выполнится, джоба может не завершиться с ошибкой, поскольку post-build скрипт заметит, что основной скрипт выполнен успешно. Он просто завершит работу:

И наконец, давайте просто подменим раннер на наш.
Runner hijacking
Если у нас есть доступ к конфигу раннера, мы можем извлечь его токен и сделать что-то пакостное. Мы можем зарегистрировать свой раннер с этим токеном, и GitLab будет считать его валидным:

Да, мы можем создать дубликаты раннера, но это будет заметно. Вместо этого можно сделать что-то поинтереснее — Runner spoofing.
При работающем раннере есть «окно», через которое он отчитывается в GitLab о своей активности. GitLab определяет, на какой адрес отправлять задачи. Мы можем заспуфить раннер, подделав сигнал раннера, и теперь все задачи будут отправляться на наш поддельный раннер. При этом реальная джоба не будет выполнена, но все переменные окружения попадут к атакующему. Это один из эффективных вариантов эксплойтов:

Лёгким движением руки мы получаем все переменные окружения совершенно другого раннера.
В заключение
Мы разобрали основные атаки на CI/CD и затронули способы митигации. Надеемся, это было интересно и полезно. Помните: если CI/CD ломаете не вы, это делают хакеры.
У себя, при разработке публичного облака MWS Cloud Platform, мы также используем собственную разработку — сервис Star Maker. Star Maker — многофункциональная утилита, позволяющая контролировать настройки в GitLab для всех репозиториев, собирать различные метаданные, необходимые для аудита (не только в контексте gitlab).
Он работает как единая информационная точка входа по всем сервисам и многое знает про устройство облака: как связаны сервисы, из каких компонентов состоят, какой состав команд, роли разработчиков в проекте, кто когда дежурит, где смотреть дашборды, технические детали реализации пайплайнов, какие раннеры подключены и многое другое. Единый агрегатор такой метаинформации позволяет централизованно проводить анализ безопасности и выявлять возможные мисконфигурации, которые приводят к рассмотренным угрозам.

P. S. Мы также решили оставить для вас небольшой threat-checklist, который может помочь вам оперативно провести ревью конфигурации gitlab в вашей команде.

Спасибо за внимание и до новых ресерчей про безопасность!
Читайте и смотрите другие материалы про MWS Cloud Platform:
Зачем мы строим собственное публичное облако? Рассказывает CTO MWS Данила Дюгуров.
Реалити-проект для инженеров про разработку облака. Рассказываем про архитектуру сервисов в серии видео ещё до релиза.
Подкаст "Расскажите про MWS". Рассказываем про команду новой облачной платформы MWS.