Читай и выполняй. Как работает эксплоит новой уязвимости в 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, чтобы наглядно продемонстрировать, в какие части кода закралась ошибка.

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

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

INFO

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

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

Создание нового issue в GitLab

При создании можно описать детали проблемы в формате 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{\A\h{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

Во время перемещения 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 такой, как на скриншоте.

Путь к аттачам GitLab на диске

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

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

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

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

Path traversal в имени прикрепляемого к issue файла

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

Успешная подмена прикрепленного файла через path traversal в GitLab

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

Чтение локальных файлов через path traversal в GitLab

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

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

Вариант 1. Присоединись к сообществу «Xakep.ru», чтобы читать все материалы на сайте

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

Вариант 2. Открой один материал

Заинтересовала статья, но нет возможности стать членом клуба «Xakep.ru»? Тогда этот вариант для тебя! Обрати внимание: этот способ подходит только для статей, опубликованных более двух месяцев назад.


aLLy: Специалист по информационной безопасности в ONsec. Research, ethical hacking and Photoshop.

Комментарии (2)

Похожие материалы