GitLab Pages directory traversal leads to arbitrary command execution
HackerOne report #473210 by bink
on 2018-12-29:
Remote Command Execution via GitLab Pages
Happy Holidays, GitLab!
I've found a flaw involving symlink support in GitLab Pages / Artifacts that allows for arbitrary command execution.
This is a bit complicated to exploit. I hope I've done a good job with the write-up but feel free to ask questions.
The basic flaw is that GitLab Pages extracts HTML from a file named
artifacts.zip
that is uploaded by a GitLab Runner via Workhorse. The Runner
takes care of creating the zip file that includes the custom HTML content provided
by the user in a CI job. This file is uploaded to GitLab where it is extracted
to a temporary directory. Because zip files can support symlinks we can perform
some crafty maneuvering to step-by-step move a symlink from the temporary
directory to a directory of our choosing.
Step 1 - Create a project named "public"
Under our user account we create a project named "public". The name is key to the attack, and the reason we use "public" will become clear later.
We open an issue in the "public" project and include an attachment.
We then view the attachment link and record the random hash used in the URL for this attachment. This will be used in steps 4 and 6. (see attached images)
Step 2 - Create an attack project that uses Pages
We want to now create a project that will utilize the Pages feature to create our first symlink.
This project needs a runner to use Pages so we go ahead and register a runner and enable it or we can use shared runner on the server if it exists.
- Create the project "attack".
- Enable a runner.
- Add a CI file and use the Pages HTML template.
Adjust the CI file to look like so:
pages:
stage: deploy
script:
- mkdir .public
- cp -r * .public
- mv .public public
- mkdir -p /var/opt/gitlab/gitlab-rails/uploads/<user>/
- ln -s /var/opt/gitlab/gitlab-rails/uploads/<user>/ public/tmpx
- chmod a-w public
artifacts:
paths:
- public
only:
- master
We replace <user> with our username.
Here we're linking to a directory that will be used as a first step towards global file
access. We're choosing the uploads
directory corresponding to our own username
for reasons that will be explained later. It's best not to use capital letters
as GitLab can lower-case the path to a project when the project is created.
The last script line is key. Normally the contents of the public
directory will
be zipped and uploaded to the GitLab server. There it will be unarchived to a
temporary directory in /var/opt/gitlab/gitlab-rails/shared/pages/tmp/<semi-random dir>/public/
.
Things start to get complicated here. For performance reasons any existing Pages files are not simply removed and the new ones copied into the same directory as the deletion can take time to finish.
Instead, this tmp/.../public
directory will be moved into the project's Pages
directory (/var/opt/gitlab/gitlab-rails/shared/pages/<user>/<project>) with a name like
public.<randomhex1>
. The old public
Pages directory (if there is one) will
then be moved to something like public.<randomhex2>
. Then public.<randomhex1>
will be moved to public
to complete the process.
It seems like this would accomplish our goal of getting access to sensitive files via the symlink, but it will not. The Pages daemon is chroot'd to the Pages directory. The most we could do is access the already public files of other users.
Above I made a point of mentioning the temporary directory to which Pages files are
extracted before they are moved to the public Pages directories. These tmp directories
fall under /var/opt/gitlab/gitlab-rails/shared/pages/tmp/
. Notice that this
shares the same root as the actual public directories. In our case:
/var/opt/gitlab/gitlab-rails/shared/pages/<user>/<project>/
We can therefore use a group named tmp/<semi-random dir>/public
and have our pages files
copied into the symlinked directory. But it is not so easy. These tmp folders
are normally removed when the Pages files are mv
'd into their public locations.
To stop this we use that final line in the Pages CI shown above: chmod a-w public
.
This will prevent the server from removing the directory when it is finished.
Step 3 - Create our subgroups
I said above that we can use a temporary directory with a semi-random name as our subgroup in order to gain the ability to copy arbitrary files across the symlink we created. But if this directory has a semi-random name, how can we find it?
As it turns out, GitLab will happily show you the directory name in the error message returned by the Pages deploy job when it finds it can't delete the temporary directory. Simply hover over the failed job and you will see an error that includes the full path. (see attachments)
This temporary directory name will be of the format tmp/dYYYYMMDD-<pid>-<random>
.
Use this information to create a subgroup of the format tmp/dYYYYMMDD-<pid>-<random>/public
.
Step 4 - Create our second Pages project
Now we just need a project with Pages enabled to copy another symlink across the first symlink directory. This second symlink will point at the file or directory to which we want access.
Create a project under the tmp/dYYYYMMDD-<pid>-<random>/public
namespace named tmpx
(the name we chose in the CI file in Step 2). Enable a runner and create a GitLab CI
file using the Pages HTML template. The contents of the file should look like
this:
pages:
stage: deploy
script:
- mkdir .public
- cp -r * .public
- mv .public public
- mkdir -p /var/opt/gitlab/.ssh
- ln -s /var/opt/gitlab/.ssh public/<uploads random directory name>
artifacts:
paths:
- public
only:
- master
We need to use the randomly generated uploads directory name we saw when we
created our very first project and uploaded an attachment (Step 1). The result of running
this Pages pipeline will be that the /var/opt/gitlab/gitlab-rails/uploads/<user>/public/
directory will now contain our new symlink, with the random name, pointing at
/var/opt/gitlab/.ssh
. Now we could stop here, as we have the ability to read
arbitrary files from any folder just by fetching the URL:
http://<gitlab_instance>/<user>/public/uploads/<random>/authorized_keys
(see attachments for demo)
We could have simply pointed the link to /var/opt/gitlab/gitlab-rails/etc/
and
read the Rails secret key which would have allowed us to authenticate as any user.
But we're not happy with just read and/or application access, we want write
access to gain an immediate shell.
Step 5 - Export the "public" project
The rest of this exploit works similarly to previously reported Gitlab file import vulnerabilities, but instead of copying a symlink into the uploads folder we will use the existing symlink we created above and copy across it.
We must create a GitLab export for the <user>/public
project and download it. This
export will not contain an uploads
directory as we effectively deleted the old
attachment we created in Step 1 when we moved our new Pages folder into place.
The contents should look like this:
tar -tzf file.tar.gz
VERSION
project.bundle
project.json
Now we delete the <user>/public
project. This leaves our newly created uploads directory in-place.
Step 6 - Create a new custom GitLab import file and import it
We then extract the previous export into a temporary directory and inside that directory we run:
mkdir -p uploads/<random hash from step 1>/
We can create an authorized_keys
file inside that randomly named folder which
should be copied into /var/opt/gitlab/.ssh
. This file can contain our own
pub key and run the command of our choosing. For demonstration purposes it's best
to use another file name, however. So we can create a file named HACKED.txt
with the contents "hack the planet!".
echo "hack the planet!" > uploads/<random hash>/HACKED.txt
Then we re-tar the file making sure not to include the upload directories themselves.
tar -czf ../new.tar.gz VERSION project.* uploads/<random dir>/HACKED.txt
We then import this file using the same name as before, <user>/public
.
And there we have it, there should now be a file on the GitLab server named /var/opt/gitlab/.ssh/HACKED.txt
.
# cat /var/opt/gitlab/.ssh/HACKED.txt
hack the planet!
Impact
Shell access to a GitLab instance, the ability to execute arbitrary commands as the git
user, and the ability to access and modify arbitrary repositories.
Attachments
Warning: Attachments received through HackerOne, please exercise caution!