The high fidelity positioning, data density and such other intricacies characteristic of modern HDD can be the subject matter for a great many articles, but we are not going to explore the disk mechanics or process physics profoundly, let’s focus on the most interesting component for us — electronics, instead.
In late March 2020, a bug was discovered in a popular web-based tool called GitLab. The error enables the attacker to advance from reading files in the system to executing arbitrary commands. The vulnerability was recognized critical because the attacker doesn’t need any special rights in the target system. This article explains the origin of the bug and shows how to exploit it.
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.yml 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--hostnamegitlab.vh -p443:443 -p80:80 -p2222: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.com.
Setting admin password at the first start of GitLab
Then I have to create two new projects.
Creating two repositories in the test system
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.
Creating a new issue in GitLab
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.
Attaching a file to the issue description
All uploaded files are stored in the folder /var/opt/gitlab/gitlab-rails/uploads/ on the hard drive. The FileUploader class is responsible for this.
The name transmitted during the upload becomes the file name.
app/uploaders/file_uploader.rb
212:defsecure_url
213: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.
This button moves issue notifications between projects
I press the button and select a project to move the issue to.
Selecting a project to move the issue to
When you move in issue, it closes in the old project and appears in the new one.
Old issue in the new project
Important: the attachments are copied, not moved. In other words, the program creates new files and links to them.
When an issue is moved, files attached to it are copied
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.rb. The move route (the one that processes the user’s POST request with the required parameters) is also kept there.
At this point, Issues::UpdateService.new 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.
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::Gfm::UploadsRewriter.
165:# Return a new uploader with a file copy on another project
166:defself.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:defcopy_file(file)
176:to_path=iffile_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
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.
Path to GitLab attachments on the hard drive
The full path to the picture from my issue is as follows:
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 /etc/passwd. 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.
Path traversal in the name of the file attached to the issue
Then I save and move the file to another project.
The attached file has been successfully substituted in GitLab using path traversal
Now I can download the passwd file and view the content of /etc/passwd.
Path traversal allows to read local files in GitLab
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:
The file stores the important, secret_key_base variable, which is used to sign cookies.
Secret_key_base variable in the GitLab config
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 :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: <% [expression] %>. 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 console.
Testing code execution in the Rails console through ERB templates
The next construct is required to declare the result method of the ERB class deprecated.
This triggers the call of result, and the uname -a 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.yml using the above-described bug. First, I create an issue with the following content:
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.
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.yml 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 console again.
Then I create a new request and specify GitLab environment variables as the config.
Voila! The command has been executed, and the file /tmp/owned successfully created.
Remote command execution in GitLab
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!