Следующая новость
Предыдущая новость

Читай и выполняй. Как работает эксплоит новой уязвимости в GitLab

26.05.2020 15:52
Читай и выполняй. Как работает эксплоит новой уязвимости в GitLab

Содержание статьи

  • Стенд
  • Чтение локальных файлов
  • От читалки к выполнению кода
  • Демонстрация уязвимости (видео)
  • Заключение

В конце марта 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.

docker run --rm -d --hostname gitlab.vh -p 443:443 -p 80:80 -p 2222:22 --name gitlab gitlab/gitlab-ce:12.9.0-ce.0 

Приставка CE означает Community Edition, можно взять и Enterprise (EE), но тогда придется возиться с получением ключа для пробного периода. Для демонстрационных целей хватит и CE, обе версии одинаково уязвимы.
При первом посещении GitLab попросит установить пароль главного админа. По дефолту логин — admin@example.com.

Читай и выполняй. Как работает эксплоит новой уязвимости в GitLab
Задаем пароль админа после первого запуска GitLab

Дальше нам нужно создать два любых проекта.

Читай и выполняй. Как работает эксплоит новой уязвимости в GitLab
Создаем два репозитория на тестовом стенде

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

Чтение локальных файлов

Итак, сразу к делу — проблема находится в функции копирования issue.

Читай и выполняй. Как работает эксплоит новой уязвимости в GitLab

INFO

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

Создадим в проекте Test новый issue.

Читай и выполняй. Как работает эксплоит новой уязвимости в GitLab
Создание нового issue в GitLab

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

Читай и выполняй. Как работает эксплоит новой уязвимости в GitLab
Прикрепление файла к описанию возникшей проблемы

Все загруженные файлы складываются на диск в папку /var/opt/gitlab/gitlab-rails/uploads/. За это отвечает класс FileUploader

doc/development/file_storage.md
31: | Description                           | In DB? | Relative path (from CarrierWave.root)                       | Uploader class         | model_type | ... 39: | Issues/MR/Notes Markdown attachments  | yes    | uploads/:project_path_with_namespace/:random_hex/:filename  | `FileUploader`         | Project    | 

Сначала генерируется рандомная hex-строка, которая будет именем папки.

app/uploaders/file_uploader.rb
011: class FileUploader < GitlabUploader ... 019:   VALID_SECRET_PATTERN = %r{Ah{10,32}z}.freeze ... 069:   def self.generate_secret 070:     SecureRandom.hex 071:   end ... 157:   def secret 158:     @secret ||= self.class.generate_secret 159:  160:     raise InvalidSecret unless @secret =~ VALID_SECRET_PATTERN 161:  162:     @secret 163:   end 

А имя файла используется то, которое передали при загрузке.

app/uploaders/file_uploader.rb
212:   def secure_url 213:     File.join('/uploads', @secret, filename) 214:   end 

После загрузки аттача ссылка в формате Markdown вставляется в описание проблемы. Сохраним ее.

GitLab позволяет перенести issue из одного проекта в другой, что бывает очень полезно, если ошибка касается и другого продукта того же разработчика.

Читай и выполняй. Как работает эксплоит новой уязвимости в GitLab
Эта кнопка перемещает сообщения о проблемах между проектами

После нажатия на кнопку выбираем проект, куда хотим отправить issue.

Читай и выполняй. Как работает эксплоит новой уязвимости в GitLab
Выбор проекта для перемещения issue

Во время перемещения в старом проекте issue закрывается и появляется в новом.

Читай и выполняй. Как работает эксплоит новой уязвимости в GitLab
Старый issue в новом проекте

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

Читай и выполняй. Как работает эксплоит новой уязвимости в GitLab
Прикрепленные файлы копируются при перемещении 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 

Затем мы попадаем в одноименную функцию.

app/controllers/projects/issues_controller.rb
123:   def move 124:     params.require(:move_to_project_id) 125:  126:     if params[:move_to_project_id].to_i > 0 127:       new_project = Project.find(params[:move_to_project_id]) 128:       return render_404 unless issue.can_move?(current_user, new_project) 129:  130:       @issue = Issues::UpdateService.new(project, current_user, target_project: new_project).execute(issue) 131:     end 

Здесь вызывается 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 
app/services/issues/update_service.rb
097:     def move_issue_to_new_project(issue) 098:       target_project = params.delete(:target_project) 099:  100:       return unless target_project && 101:           issue.can_move?(current_user, target_project) && 102:           target_project != issue.project 103:  104:       update(issue) 105:       Issues::MoveService.new(project, current_user).execute(issue, target_project) 106:     end 

Следующую часть уже выполняет класс Issues::MoveService — это наследник Issuable::Clone::BaseService.

app/services/issues/move_service.rb
3: module Issues 4:   class MoveService < Issuable::Clone::BaseService 

Здесь сначала вызывается метод execute из дочернего, а затем из родительского класса.

app/services/issues/move_service.rb
03: module Issues 04:   class MoveService < Issuable::Clone::BaseService 05:     MoveError = Class.new(StandardError) 06:  07:     def execute(issue, target_project) 08:       @target_project = target_project ... 18:       super 19:  20:       notify_participants 21:  22:       new_entity 23:     end 

В родителе нас интересует вызов метода update_new_entity.

app/services/issuable/clone/base_service.rb
03: module Issuable 04:   module Clone 05:     class BaseService < IssuableBaseService 06:       attr_reader :original_entity, :new_entity 07:  08:       alias_method :old_project, :project 09:  10:       def execute(original_entity, new_project = nil) 11:         @original_entity = original_entity 12:  13:         # Using transaction because of a high resources footprint 14:         # on rewriting notes (unfolding references) 15:         # 16:         ActiveRecord::Base.transaction do 17:           @new_entity = create_new_entity 18:  19:           update_new_entity 20:           update_old_entity 21:           create_notes 22:         end 23:       end 

После создания нового issue в целевом проекте этот метод выполняет перенос данных из оригинального issue.

app/services/issuable/clone/base_service.rb
27:       def update_new_entity 28:         rewriters = [ContentRewriter, AttributesRewriter] 29:  30:         rewriters.each do |rewriter| 31:           rewriter.new(current_user, original_entity, new_entity).execute 32:         end 33:       end 

За копирование отвечает ContentRewriter.

app/services/issuable/clone/content_rewriter.rb
03: module Issuable 04:   module Clone 05:     class ContentRewriter < ::Issuable::Clone::BaseService 06:       def initialize(current_user, original_entity, new_entity) 07:         @current_user = current_user 08:         @original_entity = original_entity 09:         @new_entity = new_entity 10:         @project = original_entity.project 11:       end ... 13:       def execute 14:         rewrite_description 15:         rewrite_award_emoji(original_entity, new_entity) 16:         rewrite_notes 17:       end 

На данном этапе нам интересен только метод rewrite_description, который копирует содержимое описания ошибки.

app/services/issuable/clone/content_rewriter.rb
21:       def rewrite_description 22:         new_entity.update(description: rewrite_content(original_entity.description)) 23:       end 

Наконец мы добрались до rewrite_content. Здесь и вызывается метод, который дублирует аттачи старого issue в новый. Этим занимается Gitlab::Gfm::UploadsRewriter.

54:       def rewrite_content(content) 55:         return unless content 56:  57:         rewriters = [Gitlab::Gfm::ReferenceRewriter, Gitlab::Gfm::UploadsRewriter] 58:  59:         rewriters.inject(content) do |text, klass| 60:           rewriter = klass.new(text, old_project, current_user) 61:           rewriter.rewrite(new_parent) 62:         end 63:       end 

Он парсит содержимое описания issue в поисках шаблона с аттачем.

app/uploaders/file_uploader.rb
11: class FileUploader < GitlabUploader ... 17:   MARKDOWN_PATTERN = %r{!?[.*?](/uploads/(?<secret>[0-9a-f]{32})/(?<file>.*?))}.freeze 
lib/gitlab/gfm/uploads_rewriter.rb
05: module Gitlab 06:   module Gfm ... 14:     class UploadsRewriter 15:       def initialize(text, source_project, _current_user) 16:         @text = text 17:         @source_project = source_project 18:         @pattern = FileUploader::MARKDOWN_PATTERN 19:       end 20:  21:       def rewrite(target_parent) 22:         return @text unless needs_rewrite? 23:  24:         @text.gsub(@pattern) do |markdown| 

И если находит, то копирует этот файл.

25:           file = find_file(@source_project, $~[:secret], $~[:file]) 26:           break markdown unless file.try(:exists?) 27:  28:           klass = target_parent.is_a?(Namespace) ? NamespaceFileUploader : FileUploader 29:           moved = klass.copy_to(file, target_parent) 
lib/gitlab/gfm/uploads_rewriter.rb
60:       def find_file(project, secret, file) 61:         uploader = FileUploader.new(project, secret: secret) 62:         uploader.retrieve_from_store!(file) 63:         uploader 64:       end 
app/uploaders/file_uploader.rb
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
Путь к аттачам GitLab на диске

Полный путь до картинки из моего issue будет выглядеть следующим образом:

/var/opt/gitlab/gitlab-rails/uploads/@hashed/d4/73/d4735e3a265e16eee03f59718b9b5d03019c07d8b6c51f90da3a666eec13ab35/ed4ae110d9f4021350e5c1eaa123b6e1/mia.jpg 

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

Попробуем прочитать файл /etc/passwd. Редактируем описание issue и добавляем необходимое количество ../ в пути к файлу. Я рекомендую ставить их побольше, чтобы точно попасть куда нужно.

Читай и выполняй. Как работает эксплоит новой уязвимости в GitLab
Path traversal в имени прикрепляемого к issue файла

Теперь сохраняем и переносим файл в другой проект.

Читай и выполняй. Как работает эксплоит новой уязвимости в GitLab
Успешная подмена прикрепленного файла через path traversal в GitLab

Появилась возможность скачать файл passwd, и если это сделать, то ты увидишь содержимое /etc/passwd.

Читай и выполняй. Как работает эксплоит новой уязвимости в GitLab
Чтение локальных файлов через path traversal в GitLab

Таким образом можно читать все, на что хватает прав у пользователя, от имени которого работает GitLab. В случае с Docker это git.
Тогда возникает другой вопрос: а что же интересного можно прочитать?

Продолжение доступно только участникам

Материалы из последних выпусков становятся доступны по отдельности только через два месяца после публикации. Чтобы продолжить чтение, необходимо стать участником сообщества «Xakep.ru».

Присоединяйся к сообществу «Xakep.ru»!

Членство в сообществе в течение указанного срока откроет тебе доступ ко ВСЕМ материалам «Хакера», увеличит личную накопительную скидку и позволит накапливать профессиональный рейтинг Xakep Score! Подробнее

-35%

1 год

7690 рублей 4990 р.

1 месяц

720 р.

Источник

Последние новости