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

 

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

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

INFO

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

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

Создание нового issue в GitLab
Создание нового 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 в новом проекте
Старый 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 на диске
Путь к аттачам GitLab на диске

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


2 комментария

  1. Аватар

    ilnaz112

    01.06.2020 в 23:52

    Браво браво друг мой

Оставить мнение