Содержание статьи
- Стенд
- Чтение локальных файлов
- От читалки к выполнению кода
- Демонстрация уязвимости (видео)
- Заключение
В конце марта 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, чтобы наглядно продемонстрировать, в какие части кода закралась ошибка.
Чтение локальных файлов
Итак, сразу к делу — проблема находится в функции копирования issue.
![]()
INFO
В русской версии интерфейса issue перевели как «обсуждение», но мне кажется, что по смыслу ближе термин «баг», «ошибка» или «проблема», ведь именно их чаще всего и описывают в issue. Я буду использовать то английское написание, то различные вариации русского, так что не удивляйся.
Создадим в проекте Test новый issue.

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

Все загруженные файлы складываются на диск в папку /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 из одного проекта в другой, что бывает очень полезно, если ошибка касается и другого продукта того же разработчика.

После нажатия на кнопку выбираем проект, куда хотим отправить 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
Затем мы попадаем в одноименную функцию.
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 такой, как на скриншоте.

Полный путь до картинки из моего issue будет выглядеть следующим образом:
/var/opt/gitlab/gitlab-rails/uploads/@hashed/d4/73/d4735e3a265e16eee03f59718b9b5d03019c07d8b6c51f90da3a666eec13ab35/ed4ae110d9f4021350e5c1eaa123b6e1/mia.jpg
Длинный код в середине — это уникальный хеш текущего проекта. Таким образом, нам нужно минимум десять конструкций ../, чтобы попасть в корневую директорию контейнера.
Попробуем прочитать файл /etc/passwd. Редактируем описание issue и добавляем необходимое количество ../ в пути к файлу. Я рекомендую ставить их побольше, чтобы точно попасть куда нужно.

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

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

Таким образом можно читать все, на что хватает прав у пользователя, от имени которого работает GitLab. В случае с Docker это git.
Тогда возникает другой вопрос: а что же интересного можно прочитать?
Продолжение доступно только участникам
Материалы из последних выпусков становятся доступны по отдельности только через два месяца после публикации. Чтобы продолжить чтение, необходимо стать участником сообщества «Xakep.ru».
Присоединяйся к сообществу «Xakep.ru»!
Членство в сообществе в течение указанного срока откроет тебе доступ ко ВСЕМ материалам «Хакера», увеличит личную накопительную скидку и позволит накапливать профессиональный рейтинг Xakep Score! Подробнее
-35%
1 год7690 рублей 4990 р. |
1 месяц720 р. |