Перейти к содержимому

Безопасность GitLab CI/CD: как токен раннера без ротации открывает продакшен

Безопасность GitLab CI/CD: как токен раннера без ротации открывает продакшен
Время чтения: 12 мин.

В марте 2026 года supply chain атака (атака на цепочку поставок — когда компрометируют не целевой продукт, а его зависимость или инструмент сборки) прошла через CI/CD-пайплайн. Злоумышленники украли учётные данные мейнтейнера и опубликовали троянизированный Trivy v0.69.4 (CVE-2026-33634), подменили 76 из 77 версионных тегов aquasecurity/trivy-action и все 7 тегов aquasecurity/setup-trivy. Результат — доступ к секретам множества проектов. CVSS (шкала критичности уязвимости, от 0 до 10) — 9.4 по CVSS 4.0. Точка входа — CI/CD-токен, который никто не ротировал. Один токен, ноль ротаций, полный доступ к продакшену.

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

Как работает GitLab Runner токен и зачем его крадут

Прежде чем разбирать атаку, нужно понять, что именно крадут. GitLab Runner (раннер — агент, который получает задачи от GitLab-сервера и выполняет их на своей машине) работает по pull-модели: не сервер отправляет задачу раннеру, а раннер сам периодически спрашивает сервер «есть работа?». Для аутентификации раннер использует токен.

В GitLab два механизма регистрации раннеров. Разница между ними — ключ к пониманию безопасности.

Legacy-регистрация (выключена по умолчанию с GitLab 17.0, запланирована к удалению в 20.0): администратор копирует registration token из интерфейса GitLab, вводит его в gitlab-runner register, сервер возвращает runner token. Раннер сохраняет эту строку в config.toml и использует для всех запросов. Проблема очевидна: один токен на все машины, нет механизма отзыва конкретного раннера, нет способа отследить, кто именно зарегистрировался. Для тех, кто в танке — это как один пароль от Wi-Fi на весь офис, и никто не знает, кто подключился.

Новый workflow (с GitLab 16+): администратор создаёт объект раннера в UI или API, получает authentication token с префиксом glrt-, регистрирует раннер через gitlab-runner register --token <glrt-token>. Каждая машина получает уникальный system ID, даже если несколько машин используют один токен. Можно отозвать конкретный раннер, не ломая остальные.

По данным PwnedLabs, ключевая проблема legacy-подхода: если атакующий получает runner token или registration token, он регистрирует rogue runner (поддельный раннер) и начинает перехватывать задачи, предназначенные для легитимных раннеров. С каждой задачей приходят переменные окружения, секреты, код из приватных репозиториев.

Каждый раз, когда раннер получает задачу, сервер присылает job payload с уникальным CI_JOB_TOKEN — он действует только на время выполнения задачи. Через него раннер клонирует репозиторий, скачивает артефакты, пушит логи. Сам по себе CI_JOB_TOKEN ограничен по scope. Но в связке с утёкшим runner token картина меняется: атакующий получает не один токен, а поток токенов от каждой новой задачи.

Executor (исполнитель — среда, в которой раннер запускает задачи) определяет масштаб последствий компрометации:

Тип executor’а Изоляция Риск
Shell Нет — задачи выполняются прямо на хосте Критический: полный доступ к хост-системе
Docker (non-privileged) Средняя — контейнер без root Умеренный: ограничен контейнером
Docker (privileged) Нет — все возможности root на хосте Критический: container escape тривиален
Kubernetes Высокая — pod с ограниченными правами Низкий при правильной настройке RBAC

Из документации GitLab по безопасности self-managed runners: «Any user that has the Developer role for the project’s repository could compromise the security of the environment hosting the runner, whether intentional or not». Любой разработчик с доступом к проекту потенциально контролирует машину, на которой работает раннер. Не теоретически — практически.

Цепочка атаки: утечка секретов в пайплайне GitLab и путь до продакшена

Зачем атакующему вообще нужен токен раннера? CI/CD-пайплайн — точка максимального доверия. Через него проходят облачные ключи, токены реестров контейнеров, deploy-credentials. Контроль над пайплайном = контроль над всем, что пайплайн может деплоить. Это не баг приложения — это supply chain risk.

Каждый шаг ниже маппится на MITRE ATT&CK (открытая база тактик и техник атак, где каждой технике присвоен идентификатор вида T1190):

Шаг 1: Получение токена. Атакующий находит runner token в config.toml, в логах CI-задачи, в переменных окружения или в коммите. Техника: Credentials In Files (T1552.001, credential-access — тактика получения учётных данных).

Шаг 2: Регистрация rogue runner. С токеном атакующий выполняет gitlab-runner register на своей машине. При legacy-workflow сервер не отличит поддельный раннер от легитимного. Техника: Steal Application Access Token (T1528, credential-access).

Шаг 3: Перехват задач. Rogue runner с тем же токеном конкурирует за задачи наравне с легитимным раннером (pull-модель) и получает часть job’ов. В каждой задаче — CI_JOB_TOKEN, переменные с секретами (если они не ограничены protected branches), исходный код. Техника: Code Repositories (T1213.003, collection — тактика сбора данных). Rogue runner клонирует репозиторий и получает job payload с секретами.

Шаг 4: Кража облачных учётных данных. Переменные вроде AWS_SECRET_ACCESS_KEY, KUBECONFIG, SSH-ключи для деплоя — всё доступно внутри задачи. Техника: Private Keys (T1552.004, credential-access).

Шаг 5: Доступ к продакшен-инфраструктуре через CI/CD. С облачными ключами атакующий входит в AWS/GCP/Azure, Kubernetes-кластер или на серверы. Техника: Cloud Accounts (T1078.004, initial-access / privilege-escalation / persistence). Для закрепления — Additional Cloud Credentials (T1098.001, persistence).

Вся цепочка — от утёкшего токена до shell на продакшене — занимает минуты. Не часы, не дни. Минуты. Если секреты не ограничены по scope и не ротируются.

Уязвимости GitLab CI/CD: три CVE, которые всё усугубили

Описанная выше цепочка работает даже без уязвимостей в GitLab — это misconfiguration. Но три конкретных CVE дают атакующему начальную точку входа и делают ситуацию хуже.

CVE-2021-22205 — выполнение кода без аутентификации

Что произошло: GitLab CE/EE начиная с версии 11.9 некорректно валидировал файлы изображений, передаваемых через Workhorse в библиотеку ExifTool. Через специально сформированный файл атакующий мог выполнить произвольные команды на сервере без аутентификации (CWE-94 — Code Injection, внедрение кода).

CVSS: 10.0 (CRITICAL). Вектор: AV:N (атака по сети) / AC:L (низкая сложность) / PR:N (привилегии не нужны) / UI:N (участие пользователя не требуется) / S:C (выход за границы уязвимого компонента) / C:H / I:H / A:H (полная компрометация конфиденциальности, целостности и доступности).

EPSS (оценка вероятности эксплуатации уязвимости в ближайшие 30 дней, шкала 0–1): 0.9973, percentile 0.9995 — top 1%. Эксплуатация в дикой природе зафиксирована с 25 октября 2021 года. Неделю спустя, 3 ноября, CISA (американское агентство по кибербезопасности) добавила уязвимость в каталог KEV (Known Exploited Vulnerabilities — реестр уязвимостей, эксплуатируемых в реальных атаках) с пометкой Known Ransomware Campaign Use. Техника начального доступа: Exploit Public-Facing Application (T1190, initial-access).

На практике: получив RCE на GitLab-сервере, атакующий видит ВСЕ registration tokens, ВСЕ runner tokens из базы данных. Дальше — рядовая цепочка из предыдущего раздела.

CVE-2023-7028 — захват аккаунта через сброс пароля

Что произошло: все версии GitLab CE/EE с 16.1 до 16.7.2 позволяли указать два адреса электронной почты при запросе сброса пароля. Код сброса приходил на оба адреса, в том числе на неверифицированный, который контролировал атакующий (CWE-640 — Weak Password Recovery Mechanism for Forgotten Password). Звучит нелепо — и это нелепо. Но работало.

CVSS: 10.0 (CRITICAL). Вектор: AV:N / AC:L / PR:N / UI:N / S:C / C:H / I:H / A:N.

EPSS: 0.9446, percentile 0.9985 — top 1%. Уязвимость эксплуатируется в дикой природе: CISA добавила в KEV 1 мая 2024 года. Публичный PoC-эксплоит (EDB-51889) доступен с 14 марта 2024 года. Единственная защита, которая реально работала — двухфакторная аутентификация (2FA): даже при сбросе пароля атакующий не мог пройти второй фактор.

Связь с токенами раннеров: захватив аккаунт администратора, атакующий получал доступ к настройкам CI/CD проектов — protected variables (переменные, доступные только определённым веткам), registration tokens — и мог зарегистрировать rogue runner от имени администратора.

CVE-2026-33634 — ваш сканер безопасности стал оружием

Что произошло: 19 марта 2026 года злоумышленники использовали украденные учётные данные мейнтейнера, чтобы опубликовать троянизированный Trivy v0.69.4 и подменить 76 из 77 версионных тегов aquasecurity/trivy-action, а также все 7 тегов aquasecurity/setup-trivy (CWE-506 — Embedded Malicious Code). По данным NVD, в число затронутых пакетов также входит LiteLLM.

CVSS: 9.4 (CRITICAL) по CVSS 4.0. Уязвимость эксплуатируется в дикой природе и внесена в каталог CISA KEV (2026-03-26).

EPSS: 0.5973, percentile 0.9913 — top 1%. Публичного PoC-эксплойта в Exploit-DB и на GitHub нет (по состоянию на дату публикации). Инцидент — supply chain compromise через украденные учётные данные, а не эксплуатация отдельной уязвимости.

По данным блога GitLab об уроках supply chain инцидентов марта 2026 года, малварь внутри Trivy могла получить доступ к переменным окружения, облачным токенам, SSH-ключам и секретам CI/CD из пайплайнов, которые использовали скомпрометированные версии trivy-action/setup-trivy. Просто подключить пакет было достаточно для запуска payload.

Вдумайтесь: безопасность GitLab CI/CD оказалась скомпрометирована через инструмент, который команды запускали ИМЕННО для защиты пайплайна. Инцидент стал продолжением атаки, начавшейся в конце февраля 2026 года. После первого раскрытия 1 марта ротацию учётных данных провели, но не атомарно — и атакующие вернулись 19 марта. Это указывает на фундаментальную проблему: пайплайны доверяют своим зависимостям без верификации.

Защита CI/CD пайплайнов: делай раз, делай два, делай три

Чек-лист харденинга ниже. Каждый шаг закрывает конкретный риск из описанной выше цепочки.

Ротация токенов CI/CD и миграция на glrt-workflow

Зачем: legacy registration tokens — основная причина, по которой rogue runner вообще возможен. Без ротации токен, попавший в лог или коммит полгода назад, работает до сих пор.

Шаг 1. Проверьте, какой workflow используется. Откройте config.toml на машине раннера. Токен начинается с glrt- — новый workflow. Если нет — legacy, и нужно мигрировать.

Шаг 2. Мигрируйте на новый workflow. В GitLab UI: Settings → CI/CD → Runners → создайте новый runner object, получите glrt--токен. На машине раннера: gitlab-runner register --token glrt-XXXXX. После проверки работоспособности удалите старый раннер через gitlab-runner unregister --all-runners и зарегистрируйте только новый.

Шаг 3. Настройте регулярную ротацию. GitLab API позволяет через POST /api/v4/runners/:id/reset_authentication_token сгенерировать новый authentication token. Автоматизируйте вызов через cron или отдельную CI-задачу с protected переменной.

Результат: каждый раннер имеет уникальный идентификатор, токены меняются по расписанию, отзыв конкретного раннера не ломает остальные.

Изоляция раннеров и выбор executor’а

Зачем: shell executor означает, что вредоносная CI-задача получает права пользователя, под которым запущен раннер, и видит файлы, процессы, сеть хоста. Privileged Docker — то же самое, но ещё и с root-правами.

Шаг 1. Используйте Docker executor в non-privileged режиме. В config.toml убедитесь, что privileged = false. Сбросьте лишние capabilities:

[runners.docker]
  privileged = false
  cap_drop = ["ALL"]
  # cap_add = ["NET_BIND_SERVICE"]  # раскомментируйте, только если задачам нужен bind на порт <1024
  pull_policy = "always"

Параметр pull_policy = "always" закрывает ситуацию, когда пользователь B без доступа к приватному образу использует кэшированную копию от пользователя A. Это задокументированный вектор в документации GitLab.

Шаг 2. Разделите раннеры по уровню доверия. Отдельные раннеры для: merge request’ов из форков (untrusted), обычных feature-веток, protected-веток и деплоя. Используйте теги: tag: ["untrusted"], tag: ["deploy-prod"].

Шаг 3. Сделайте раннеры эфемерными. Для Docker Machine / Kubernetes executor задайте MaxBuilds = 1 — каждая задача получает свежую среду, ничего не остаётся между сборками.

Сканирование секретов в пайплайне с gitleaks

Зачем: 80% утечек секретов в DevOps происходят через захардкоженные ключи в коде — API-токены, пароли, SSH-ключи, попавшие в коммит. gitleaks обнаруживает их до того, как код попадёт в main-ветку.

Предпосылки: GitLab CI/CD с Docker executor, образ с доступом к сети для скачивания gitleaks.

Добавьте в .gitlab-ci.yml стадию проверки секретов:

secrets-scan:
  stage: test
  image: zricethezav/gitleaks:v8.18.4
  script:
    - gitleaks detect --source . --verbose --no-git
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"

gitleaks просканирует файлы в рабочей директории на паттерны секретов (AWS-ключи, токены GitLab, приватные ключи). Нашёл — задача падает с ошибкой, merge request не пройдёт. Вывод при обнаружении: Finding: ... RuleID: gitlab-pat Match: glpat-XXXX....

Как проверить: merge request с чистым кодом — задача secrets-scan зелёная. Merge request с захардкоженным ключом — красная, с указанием файла и строки.

Для глубокого аудита истории коммитов: trufflehog git file://. --only-verified. Он проверяет не только паттерны, но и валидность найденных секретов — обращается к API-провайдеру, чтобы убедиться, что ключ ещё рабочий. Запускайте вручную, не в каждом пайплайне — процесс долгий.

Обнаружение утечки секретов DevOps и компрометации раннера

DevSecOps практики не ограничиваются предотвращением — нужно уметь обнаружить компрометацию, которая уже произошла.

Аномалии в регистрации раннеров. Мониторьте GitLab Audit Events на события runner_registration. Новый раннер в нерабочее время или с неизвестного IP — повод для расследования. В GitLab Premium/Ultimate audit events доступны через API: GET /api/v4/audit_events?entity_type=Runner.

Сетевая активность раннера. Раннер, который вдруг начал отправлять данные на внешние хосты — curl к неизвестным endpoint’ам, DNS-запросы к нетипичным доменам — классический паттерн из инцидента с CVE-2026-33634, где малварь эксфильтрировала данные на внешние домены.

Неожиданные изменения в .gitlab-ci.yml. Настройте уведомления на diff в файле пайплайна: новые curl/wget-команды, base64-кодирование, чтение переменных окружения (printenv, env) — маркеры внедрения вредоносного кода. По данным GitLab, Pipeline Execution Policies (PEP) позволяют инжектировать обязательные проверочные задачи в каждый пайплайн организации, которые нельзя пропустить даже через [skip ci].

Контроль зависимостей. После инцидентов марта 2026 года GitLab выпустил open-source набор Supply Chain Policies, включающий политику Artifact Hygiene — автоматическую проверку содержимого артефактов перед публикацией. Рекомендация из блога GitLab: пиньте зависимости по SHA (commit hash) или digest образа, а не по мутабельным тегам вроде latest или v1. Злоумышленники получили доступ через украденные учётные данные мейнтейнера и выполнили force-push поверх версионных тегов — это стало возможным именно потому, что теги мутабельны.

По данным IBM X-Force Threat Intelligence Index 2025, рост атак с использованием действительных учётных данных — +71% год к году, а среднее время между публикацией CVE и устранением в организациях — 29 месяцев. Даже после публикации патча для CVE-2023-7028 или CVE-2021-22205 тысячи инстансов GitLab остаются уязвимыми больше двух лет.

И тут я скажу вещь, которая не укладывается ни в один чеклист, но определяет всё остальное. Большинство команд, с которыми я работал, относятся к CI/CD как к вспомогательной штуке — «собирает и деплоит, не трогай». Это ошибка. Пайплайн — production-grade сервис удалённого выполнения кода, который хранит облачные ключи, SSH-доступы и deploy-credentials. Он заслуживает того же уровня мониторинга и сегментации, что и база данных клиентов. Но на практике пайплайн живёт в слепой зоне: security-команда не видит его логи, сетевики не сегментируют его трафик, а DevOps-инженер настраивает раннер один раз и забывает. Три CVE с CVSS 10.0 эксплуатируются в дикой природе спустя годы после патчей — именно поэтому. Ротация токенов, изоляция раннеров, сканирование секретов — всё работает. Но только если CI/CD воспринимается как первоклассная цель для атак, а не как build-скрипт. Если DevSecOps пока в новинку и хочется разложить базу системно — на IB Basics закрывают фундамент от сетей до пайплайнов за пару месяцев, без академического тона.

Эту тему и смежные навыки разбирают на практике в курсе «Основы практик DevOps» Codeby Academy.