GitLab Arbitrary File Read (CVE-2023-2825) Analysis

At watchTowr, some systems are interesting, and some systems are very interesting. When we see impactful, exploitable vulnerabilities dropping out of the sky in typically critical systems - like GitLab - our attention is piqued. It's our job to understand emerging weaknesses, vulnerabilities and misconfigurations - and quickly translate that knowledge into an answer to the question of "how does this impact our clients?".

Through our rapid PoC process, we enable our clients to understand if they are vulnerable to emerging weaknesses before active, indiscriminate exploitation can begin - continuously.

For the unaware, GitLab is a widely used, enterprise-grade web application for managing source code repositories at scale.

In this blog post we’ll be discussing a fresh vulnerability that has been issued an advisory by Gitlab with a CVSS score of 10.0 (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:N).

It’s an interesting one, so let’s get started right away, taking a look at what the vendor shares about the vulnerability:

An issue has been discovered in GitLab CE/EE affecting only version 16.0.0. An unauthenticated malicious user can use a path traversal vulnerability to read arbitrary files on the server when an attachment exists in a public project nested within at least five groups.

The most unusual thing about this advisory are the conditions that were required for exploitation to take place. Why specifically five groups? Why not one, or twelve, or 1337 for that matter?

Given just the information that we are provided in the advisory, we wanted to see if we could reproduce the vulnerability. For this, we’ll be using Burp Suite and a local installation of the vulnerable software - Gitlab Community Edition v16.0.0.

Setting up the target environment

Setting up the target is refreshingly straightforward, thanks to the Docker images available from the official Gitlab DockerHub account. The ‘tags’ section reveals that a tag exists for the version we’re after - 16.0.0-ce. We can run this image with the following command:

docker run -p 80:80  --hostname=hostname --env=PATH=/opt/gitlab/embedded/bin:/opt/gitlab/bin:/assets:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin --env=LANG=C.UTF-8 --env=EDITOR=/bin/vi --env=TERM=xterm --volume=/etc/gitlab --volume=/var/log/gitlab --volume=/var/opt/gitlab --label='org.opencontainers.image.ref.name=ubuntu' --label='org.opencontainers.image.version=22.04' --runtime=runc -d gitlab/gitlab-ce:16.0.0-ce

To reset the root account’s password, open up the container’s terminal and run:

gitlab-rake "gitlab:password:reset[root]"

Reproducing the bug

As we saw in the vendor advisory, an attacker requires two things:

  • Firstly, an attachment of some kind,
  • And secondly, a public project which is nested within at least five groups.

Creating five groups can be done with the root account. It’s important to make sure that they’re nested. While configuring these, you might start guessing about the nature of the bug - perhaps some kind of loop or path normalization, perhaps the names of each subgroup are concatenated in some weird way. Either way, once you’re done, the result should look something like this:

Let’s navigate to Group 5, create a project, and then start looking for ways to create an attachment inside that project.

Intuition guides us to start by looking at the ‘issues’ section, and the comment features surrounding them. Bingo, there is an ‘attachment’ section (shown below).

Now we’ve found the page that exposes the attachment functionality, it’s time to fire up Burp Suite and start looking at the HTTP traffic involved when we create a new attachment!

We create a new issue, save it, take a look at the HTTP traffic. One thing that immediately caught our attention is the token that is present in the URI. This request is authenticated, so why is a token needed? Could this be the clue that alludes to the project having to be public in order to obtain this token value?

It is helpful now to recall the CVSS score of this, a whopping 10.0. This score implies that a user can access all files on the target system. Typically, an issue of this severity could take the form of a path traversal of sorts (as confirmed by the advisory title). Burp Suite includes functionality to automate discovery of many such bugs - so let’s try our luck with that before we do any manual work.

Burp Suite has an inbuilt wordlist for path traversal and a variety of encodings that have been helpful for detecting and exploiting different forms of path traversal. Typically to find this class of bug, we would use “Fuzzing - path traversal (single file)” then simply replace the {FILE} pattern with a file which we know is present on the target system - /etc/passwd is usually a good bet.

We fire this off, and cross our fingers, but it seems today is not our lucky day - there are no retrievals of the file we specified, /etc/passwd, and a look through the status codes and response lengths doesn’t reveal any interesting outliers either. Back to the drawing board!

At this point, it is helpful to know where on the target’s filesystem our attachment file is located. We used the GNU find command to locate it, revealing the path:

/var/opt/gitlab/gitlab-rails/uploads/@hashed/6b/86/6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b/70d9ea90ac2a4a46440dffc9f8c0770e/blank.txt

Instead of attempting to fetch the high-impact /etc/passwd file, let’s try finding something simpler so that we can understand the bug. Noticing that there’s a relatively deep directory structure, we created a flag.txt file two levels above the attachment file itself.

echo flag-is-here > /var/opt/gitlab/gitlab-rails/uploads/@hashed/6b/86/flag.txt

Now, we can retry our earlier fuzzing process, but this time instead of attempting to retrieve /etc/passwd we can instruct the fuzzer to attempt to retrieve flag.txt. Lo and behold, this time we see success:

It’s apparent that the specific URL encoding to reach the file is ..%2f ,  shown below in the full request/response. Note that requests such as ../../flag.txt are handled correctly, while the HTTP-encoded ..%2f..%2fflag.txt are not, exposing the vulnerability.

Finally, we have reproduced the vulnerability from the vendor advisory! Our work is not yet done, however. For example, why did we achieve no results when trying to read /etc/passwd? Now that we understand the bug itself, it’s time to figure out why the initial fuzzing failed, and how can we overcome the issue in the future.

Note that if we traverse back as many directory levels as possible, we can see the web server responds with a 400 Bad Request when it reaches as many paths as we have in current URI. This is normal behaviour for a webservice such as this - it is nonsensical to use .. to traverse to a level above the domain name itself.

This is when the thought crossed our minds - perhaps we can only traverse as many directories as there are subgroups present in the project? The more subgroups we have, the more elements will be in the URL, and thus the more .. we can specify.

It turns out, this is easy to test - we can simply keep creating more and more subgroups, until we hit gold. Some quick tests revealed that 9 was the magic number to hit the /etc/ directory:

Full PoC via cURL:

curl -i -s -k -X $'GET' \
-H $'Host: 127.0.0.1' \
$'http://127.0.0.1/group1/group2/group3/group4/group5/group6/group7/group8/group9/project9/uploads/4e02c376ac758e162ec674399741e38d//..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2Fetc%2Fpasswd'

But Why?

With the vulnerability thoroughly understood, and exploitation pinpointed, the only remaining step in this exercise is to narrow down exactly where in the code this happened, and when the bug was introduced. We first tested the patched version 16.0.1-ce.0 and the version prior to the vulnerable 15.11.5-ce with our current payload/methodology.

We found, interestingly, that while 15.11.5-ce does exhibit some behaviour similar to 16.0, there was no vulnerability present.

As this is an open source project, and all of the commits are available, we can go searching through source control to find the bug itself (and often come across some useful analysis by the developers). Luckily enough for us, we quickly find a commit entitled “Fix arbitrary file read via filename param” which seems to contain the code we’re interested in.

Also enlightening to us, the changes also contain additional validation logic and modifications to two existing files:

  • /app/uploaders/object_storage.rb
  • /app/controllers/concerns/uploads_actions.rb

It is interesting to note that the lead to the payload can be found in comments for the file /spec/support/shared_examples/requests/uploads_actions_shared_examples.rb:

Ruby is not a language that I particularly enjoy so my analysis is rudimentary at best - however, we don’t need to be experts in a particular language to understand what’s going on. If we look through the differences between three versions of the file:

Pre-Vulnerable: 15.11.5-ce.0

Vulnerable: 16.0.0-ce.0

Post-Patch: 16.0.1-ce.0

We can see one of the outstanding changes lies within the file /app/uploaders/object_storage.rb.

In the vulnerable version, a new method for “retrieve_from_store()” was introduced with detailed comments as to why it is there to handle the specific variable for “@filename

The intention is to handle scenarios where fetching the file may have the hash or the path of the file contained within the value. This logic will separate the values, splitting so that only the actual filename is set:

In the patched version, new filtering and validation functions are introduced to prevent the injection of path traversal elements. They can be found in the method Gitlab::Utils.check_path_traversal:

How impactful is this vulnerability?

It goes without saying that being able to read local files as deep as the root of the server is critical. However, the conditions required for successful exploitation are unusual:

  • 5-9 Nested Groups
  • A Public Project
  • An Attachment

Realistically, it is unlikely that these conditions will be met organically. However, if an attacker can register their own project, they may be able to cultivate the environment required for successful exploitation.

By default, new users are permitted to sign up to on community editions of GitLab, but each new user requires an administrative approval. If these administrative approvals are disabled, there are various other options that can be applied by administrators (such as email domain validation) which may mitigate risk to an acceptable level by allowing only trusted users to manipulate the environment.

Conclusion

This has been an awesome bug to explore! I hope you’ve enjoyed reading, and gained some insights into the process of reversing a CVE advisory to a PoC exploit.

One of the key takeaways from this is the ability to understand the vulnerability in the context of the application internals. This CVE is an excellent demonstration of moving through a different context than is being shown on the surface of the web application, so huge kudos to the original discoverer.

Going forward, I’d like to use the technique of pushing flags and canaries to every directory on the server, to see if we have some level of traversal apparent. We can’t always immediately reach /etc/passwd and get the golden screenshot, but this doesn’t necessarily mean there aren’t other items to catch.

Lets hunt for the behaviour first then move on from there.

At watchTowr, we believe continuous security testing is the future, enabling the rapid identification of holistic exploitable vulnerabilities that affect your organisation.

If you'd like to learn more about the watchTowr Platform, our Attack Surface Management and Continuous Automated Red Teaming solution, please get in touch.