В конце марта 2020 года в популярном инструменте GitLab был найден баг, который позволяет перейти от простого чтения файлов в системе к выполнению произвольных команд. Уязвимости присвоили статус критической, поскольку никаких особых прав в системе атакующему не требуется. В этой статье я покажу, как возникла эта брешь и как ее эксплуатировать.
Автор эксплоита, который мы разберем, — исследователь и разработчик из Австрии Уильям vakzz Боулинг (William Bowling). Он обнаружил, что класс UploadsRewriter
при определенных условиях никак не проверяет путь до файла. Это открывает злоумышленнику возможность скопировать любой файл в системе и использовать его в качестве аттача при переносе issue из одного проекта в другой.
На этом исследователь не остановился и нашел возможность превратить эту «читалку» в полноценную уязвимость типа RCE. Атакующий может прочитать файл secrets.yml
, в котором находится токен для подписи cookie. Специально сформированная и подписанная кука позволяет выполнять произвольные команды на сервере.
Уязвимость относится к типу 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.
В русской версии интерфейса issue перевели как «обсуждение», но мне кажется, что по смыслу ближе термин «баг», «ошибка» или «проблема», ведь именно их чаще всего и описывают в issue. Я буду использовать то английское написание, то различные вариации русского, так что не удивляйся.
Создадим в проекте Test
новый issue.
При создании можно описать детали проблемы в формате Markdown, а еще загрузить произвольный файл, например скриншот с ошибкой или лог-файл, чтобы упростить жизнь разработчикам.
Все загруженные файлы складываются на диск в папку /var/opt/gitlab/gitlab-rails/uploads/
. За это отвечает класс FileUploader
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-строка, которая будет именем папки.
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
А имя файла используется то, которое передали при загрузке.
212: def secure_url 213: File.join('/uploads', @secret, filename) 214: end
После загрузки аттача ссылка в формате Markdown вставляется в описание проблемы. Сохраним ее.
GitLab позволяет перенести issue из одного проекта в другой, что бывает очень полезно, если ошибка касается и другого продукта того же разработчика.
После нажатия на кнопку выбираем проект, куда хотим отправить issue.
Во время перемещения в старом проекте issue закрывается и появляется в новом.
Причем аттачи копируются, а не переносятся. То есть для них создаются новые файлы и ссылки на них, соответственно.
Посмотрим в коде, как выполняется перенос. Все роуты, которые касаются issues, можно найти в папке routes
в файле issues.rb
. Там в том числе есть роут move
, который отвечает за перенос. Именно он обрабатывает пользовательский POST-запрос с необходимыми параметрами.
5: resources :issues, concerns: :awardable, constraints: { id: /d+/ } do 6: member do ... 9: post :move
Затем мы попадаем в одноименную функцию.
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
.
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
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
.
3: module Issues 4: class MoveService < Issuable::Clone::BaseService
Здесь сначала вызывается метод execute из дочернего, а затем из родительского класса.
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
.
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.
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
.
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
, который копирует содержимое описания ошибки.
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 в поисках шаблона с аттачем.
11: class FileUploader < GitlabUploader ... 17: MARKDOWN_PATTERN = %r{!?[.*?](/uploads/(?<secret>[0-9a-f]{32})/(?<file>.*?))}.freeze
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)
60: def find_file(project, secret, file) 61: uploader = FileUploader.new(project, secret: secret) 62: uploader.retrieve_from_store!(file) 63: uploader 64: 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
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 Score! Подробнее
-35%
1 год7690 рублей 4990 р. |
1 месяц720 р. |
Читайте также
Последние новости