В конце марта 2020 года в популярном инструменте GitLab был найден баг, который позволяет перейти от простого чтения файлов в системе к выполнению произвольных команд. Уязвимости присвоили статус критической, поскольку никаких особых прав в системе атакующему не требуется. В этой статье я покажу, как возникла эта брешь и как ее эксплуатировать.
Автор эксплоита, который мы разберем, — исследователь и разработчик из Австрии Уильям vakzz Боулинг (William Bowling). Он обнаружил, что класс UploadsRewriter РїСЂРё определенных условиях никак РЅРµ проверяет путь РґРѕ файла. Рто открывает злоумышленнику возможность скопировать любой файл РІ системе Рё использовать его РІ качестве аттача РїСЂРё переносе issue РёР· РѕРґРЅРѕРіРѕ проекта РІ РґСЂСѓРіРѕР№.
РќР° этом исследователь РЅРµ остановился Рё нашел возможность превратить эту «читалку» РІ полноценную уязвимость типа RCE. Атакующий может прочитать файл secrets.yml, в котором находится токен для подписи cookie. Специально сформированная и подписанная кука позволяет выполнять произвольные команды на сервере.
INFO
Уязвимость относится к типу path traversal и получила номер CVE-2020-10977. Уязвимы версии GitLab EE/CE начиная с 8.5 и 12.9. Компания GitLab в рамках программы bug bounty выплатила за этот баг 20 тысяч долларов.
Стенд
Тестовое окружение для изучения этого бага поднять очень просто, так как у GitLab есть официальный докер-репозиторий. Можно одной командой запустить контейнер с любой версией приложения. Поэтому поднимем последнюю уязвимую версию — 12.9.0.
Приставка CE означает Community Edition, можно взять и Enterprise (EE), но тогда придется возиться с получением ключа для пробного периода. Для демонстрационных целей хватит и CE, обе версии одинаково уязвимы. При первом посещении GitLab попросит установить пароль главного админа. По дефолту логин — admin@example.com.
Создадим РІ проекте Test новый issue.
Создание нового issue в GitLab
При создании можно описать детали проблемы в формате Markdown, а еще загрузить произвольный файл, например скриншот с ошибкой или лог-файл, чтобы упростить жизнь разработчикам.
Прикрепление файла к описанию возникшей проблемы
Все загруженные файлы складываются на диск в папку /var/opt/gitlab/gitlab-rails/uploads/. Р—Р° это отвечает класс FileUploader
А имя файла используется то, которое передали при загрузке.
app/uploaders/file_uploader.rb
212: def secure_url 213: File.join('/uploads', @secret, filename) 214: end
После загрузки аттача ссылка в формате Markdown вставляется в описание проблемы. Сохраним ее.
GitLab позволяет перенести issue из одного проекта в другой, что бывает очень полезно, если ошибка касается и другого продукта того же разработчика.
Эта кнопка перемещает сообщения о проблемах между проектами
После нажатия на кнопку выбираем проект, куда хотим отправить issue.
Выбор проекта для перемещения issue
Во время перемещения в старом проекте issue закрывается и появляется в новом.
Старый issue в новом проекте
Причем аттачи копируются, а не переносятся. То есть для них создаются новые файлы и ссылки на них, соответственно.
Прикрепленные файлы копируются при перемещении issue
Посмотрим в коде, как выполняется перенос. Все роуты, которые касаются issues, можно найти в папке routes РІ файле issues.rb. Там в том числе есть роут move, который отвечает за перенос. Именно он обрабатывает пользовательский POST-запрос с необходимыми параметрами.
config/routes/issues.rb
5: resources :issues, concerns: :awardable, constraints: { id: /d+/ } do
6:
member do
...
9:
post :move
Здесь вызывается Issues::UpdateService.new, РІ качестве аргументов передаются ID текущего проекта, пользователь, который инициировал перенос, Рё проект, РєСѓРґР° РЅСѓР¶РЅРѕ перенести issue. После этого управление переходит Рє классу UpdateService. Он, в свою очередь, вызывает метод move_issue_to_new_project.
app/services/issues/update_service.rb
03: module Issues 04: class UpdateService < Issues::BaseService 05: include SpamCheckMethods 06: 07: def execute(issue) 08: handle_move_between_ids(issue) 09: filter_spam_check_params 10: change_issue_duplicate(issue) 11: move_issue_to_new_project(issue) || update_task_event(issue) || update(issue) 12: end
165: # Return a new uploader with a file copy on another project 166: def self.copy_to(uploader, to_project) 167: moved = self.new(to_project) 168: moved.object_store = uploader.object_store 169: moved.filename = uploader.filename 170: 171: moved.copy_file(uploader.file) 172: moved 173: end
app/uploaders/file_uploader.rb
175: def copy_file(file) 176: to_path = if file_storage? 177: File.join(self.class.root, store_path) 178: else 179: store_path 180: end 181: 182: self.file = file.copy_to(to_path) 183: record_upload # after_store is not triggered 184: end
Как видишь, ни find_file, РЅРё copy_to, ни copy_file никак РЅРµ проверяют РёРјСЏ файла, Р° значит, любой файл РІ системе может легким движением СЂСѓРєРё превратиться РІ аттач.
Чтобы это проверить, воспользуемся методом выхода РёР· директории РїСЂРё помощи стандартного ../. Нужно только определиться с количеством ходов наверх. По дефолту полный путь до загружаемых файлов в контейнере GitLab такой, как на скриншоте.
Путь к аттачам GitLab на диске
Полный путь до картинки из моего issue будет выглядеть следующим образом:
Длинный код в середине — это уникальный хеш текущего проекта. Таким образом, нам нужно минимум десять конструкций ../, чтобы попасть РІ корневую директорию контейнера.
Попробуем прочитать файл /etc/passwd. Редактируем описание issue и добавляем необходимое количество ../ РІ пути Рє файлу. РЇ рекомендую ставить РёС… побольше, чтобы точно попасть РєСѓРґР° РЅСѓР¶РЅРѕ.
Чтение локальных файлов через path traversal в GitLab
Таким образом можно читать все, на что хватает прав у пользователя, от имени которого работает GitLab. В случае с Docker это git. Тогда возникает другой вопрос: а что же интересного можно прочитать?